// Chart of Accounts API — Task 1 of
// docs/superpowers/plans/2026-08-02-chart-management.md (spec:
// docs/superpowers/specs/2026-08-02-chart-management-design.md). Route module
// pattern identical to customers.ts: everything under /api/c/ (company scope
// from X-Company-Id via companyContextPlugin), every query inside
// db.withCompany() so RLS is the real guard (CLAUDE.md §4/§12).
//
//   GET   /api/c/accounts?grouped=1&includeInactive=1&withBalances=1
//   POST  /api/c/accounts            {name, type, subtype?, code?}
//   PATCH /api/c/accounts/:id        {name?, code?, subtype?, active?}
//
// GET moved here from entries.ts (it outgrew "picker for the entry form" —
// it is now the chart-management listing too). Default behavior is unchanged:
// active accounts only, ordered by code. ?grouped=1 still returns the legacy
// money/income/spending/other shape for compatibility, but as of Task 3 no
// web caller uses it — every picker fetches the flat list and groups it
// client-side by the standard subtype sections
// (web/src/accounts/pickerGroups.ts).
//
// Balances are derived at query time from trial_balance() — never stored
// (CLAUDE.md §3.4) — and returned NATURAL-SIGNED: asset/expense balances are
// the raw debit-positive numbers, liability/equity/income are sign-flipped so
// a credit-normal account in good standing reads positive. Same string-surgery
// flip as reports.ts (money never passes through a JS number, §2).
//
// DECISION (spec "Testing" + plan Task 1, documented here and tested in
// tests/server/accounts-api.test.ts): deactivating an account hides it from
// PICKERS only. A draft invoice/bill that already references a now-inactive
// account still issues — history is history, and a draft's account choice was
// made while the account was active. Nothing that touches history (reports,
// registers, drill-through, reversals, voids) ever excludes inactive
// accounts. The only hard refusals are the company_settings-designated
// accounts (AR / AP / sales tax payable — the three account pointers that
// exist), which refuse deactivation in English naming the setting.

import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { Db } from "./db.js";
import { mapPgError } from "./errors.js";
import {
  SUBTYPES_BY_TYPE,
  type AccountSubtype,
  type LedgerType,
} from "./templates/types.js";

// ---------------------------------------------------------------------------
// validation
// ---------------------------------------------------------------------------

const idSchema = z.string().regex(/^\d{1,18}$/, "must be a whole number id (no more than 18 digits)");
const idParamSchema = z.object({ id: idSchema });

const LEDGER_TYPES = ["asset", "liability", "equity", "income", "expense"] as const;
const ALL_SUBTYPES = Object.values(SUBTYPES_BY_TYPE).flat() as AccountSubtype[];

const nameSchema = z.string({ error: "An account needs a name." })
  .trim().min(1, "An account needs a name.").max(200, "That name is too long.");
const codeSchema = z.string().trim()
  .min(1, "An account code can't be blank.")
  .max(20, "That account code is too long.");
const typeSchema = z.enum(LEDGER_TYPES, {
  error: "Pick one of the five account types: asset, liability, equity, income, or expense.",
});
const subtypeSchema = z.enum(ALL_SUBTYPES as [AccountSubtype, ...AccountSubtype[]], {
  error: "That isn't one of the account groupings.",
});

const accountCreateSchema = z.object({
  name: nameSchema,
  type: typeSchema,
  subtype: subtypeSchema.optional(),
  code: codeSchema.optional(),
});

// PATCH is a true partial: only the fields present change. `subtype: null`
// explicitly untags the account (groups under the bare type again).
const accountUpdateSchema = z.object({
  name: nameSchema.optional(),
  code: codeSchema.optional(),
  subtype: subtypeSchema.nullable().optional(),
  active: z.boolean().optional(),
});

function zodMessage(issues: Array<{ message: string }>): string {
  return issues.map((i) => i.message).join("; ");
}

const NOT_FOUND_ACCOUNT = { error: "We couldn't find that account." };

/** "an asset account", "a liability account", ... */
const TYPE_WITH_ARTICLE: Record<LedgerType, string> = {
  asset: "an asset", liability: "a liability", equity: "an equity",
  income: "an income", expense: "an expense",
};

function subtypeFitsType(type: LedgerType, subtype: AccountSubtype): boolean {
  return (SUBTYPES_BY_TYPE[type] as readonly string[]).includes(subtype);
}

function subtypeMismatchError(type: LedgerType, subtype: string): string {
  return `The "${subtype}" grouping doesn't fit ${TYPE_WITH_ARTICLE[type]} account — ` +
    `pick one of: ${SUBTYPES_BY_TYPE[type].join(", ")}.`;
}

// ---------------------------------------------------------------------------
// code auto-suggestion
// ---------------------------------------------------------------------------

/**
 * Conventional numbering range per subtype, drawn from the template numbering
 * (server/src/templates/core.ts: 1000s assets, 2000s liabilities, 3000s
 * equity, 4000s income, 5000s direct costs, 6000s+ expenses, step 10):
 *
 *   bank 1000-1099 (Checking 1010, Savings 1020), A/R 1100-1199,
 *   other-current-asset 1200-1499 (Prepaid 1200, Inventory 1300),
 *   fixed-asset 1500-1599, other-asset 1800-1999 (clear of the industry
 *   asset range 1300-1490 and the fixed-asset block),
 *   accounts-payable 2000-2099, credit-card 2100-2199,
 *   long-term-liability 2400-2449 (Loan Payable is 2400),
 *   line-of-credit 2450-2499 (between the loan block and the deposits-held
 *   convention below — QB has no LOC type, so this range is ours),
 *   other-current-liability 2500-2899 (the industry templates' deposits/tips/
 *   gift-cards convention; the spine's own 2200/2300 are already taken),
 *   equity 3000-3999, income 4000-4499, other-income 4500-4999
 *   (Other Income 4020 predates the split and stays where it is),
 *   cost-of-goods-sold 5000-5999, expense 6000-7999, other-expense 8000-8999.
 *
 * The walk mirrors enable_sales_tax()'s 22xx walk (0015/0016): first in
 * convention steps of 10 from the base, then every code in the range, first
 * free wins. Exported for tests.
 */
export const SUBTYPE_CODE_RANGES: Readonly<Record<AccountSubtype, readonly [number, number]>> =
  Object.freeze({
    "bank": [1000, 1099],
    "accounts-receivable": [1100, 1199],
    "other-current-asset": [1200, 1499],
    "fixed-asset": [1500, 1599],
    "other-asset": [1800, 1999],
    "accounts-payable": [2000, 2099],
    "credit-card": [2100, 2199],
    "long-term-liability": [2400, 2449],
    "line-of-credit": [2450, 2499],
    "other-current-liability": [2500, 2899],
    "equity": [3000, 3999],
    "income": [4000, 4499],
    "other-income": [4500, 4999],
    "cost-of-goods-sold": [5000, 5999],
    "expense": [6000, 7999],
    "other-expense": [8000, 8999],
  });

/** Fallback ranges for an untagged (no-subtype) account: the type's bucket. */
export const TYPE_CODE_RANGES: Readonly<Record<LedgerType, readonly [number, number]>> =
  Object.freeze({
    asset: [1000, 1999],
    liability: [2000, 2999],
    equity: [3000, 3999],
    income: [4000, 4999],
    expense: [6000, 7999],
  });

/**
 * First free code in the range: convention steps of 10 from the base first,
 * then every integer in the range. Returns null when the whole range is
 * taken (the caller asks the user to type a code themselves).
 */
export function suggestCode(
  existingCodes: ReadonlySet<string>, range: readonly [number, number],
): string | null {
  const [base, end] = range;
  for (let n = base; n <= end; n += 10) {
    if (!existingCodes.has(String(n))) return String(n);
  }
  for (let n = base; n <= end; n++) {
    if (!existingCodes.has(String(n))) return String(n);
  }
  return null;
}

// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------

/** Same string surgery as reports.ts's flipSign — display sign only. */
function flipSign(amount: string): string {
  const negative = amount.startsWith("-");
  const digits = negative ? amount.slice(1) : amount;
  if (/^0+(\.0+)?$/.test(digits)) return digits;
  return negative ? digits : `-${digits}`;
}

/** Natural sign: debit-normal types read as stored; credit-normal flip. */
function naturalSigned(type: string, rawBalance: string): string {
  return type === "asset" || type === "expense" ? rawBalance : flipSign(rawBalance);
}

interface AccountRow {
  id: string;
  code: string;
  name: string;
  type: LedgerType;
  subtype: string | null;
  active: boolean;
  balance?: string;
}

function truthyParam(v: unknown): boolean {
  return v !== undefined && v !== "" && v !== "0" && v !== "false";
}

/** The three company_settings account pointers (0005/0009/0015) — the full
 *  list of system-designated accounts as of migration 0016. Each maps to the
 *  English refusal shown when deactivating that account is attempted. */
const DESIGNATED_POINTERS = [
  {
    column: "ar_account_id",
    error: "This is the Accounts Receivable account your invoices post to (set in Settings) — change that setting before deactivating this account.",
  },
  {
    column: "ap_account_id",
    error: "This is the Accounts Payable account your bills post to (set in Settings) — change that setting before deactivating this account.",
  },
  {
    column: "sales_tax_account_id",
    error: "This is the Sales Tax Payable account (set in Settings) — change that setting before deactivating this account.",
  },
] as const;

// ---------------------------------------------------------------------------
// route registration
// ---------------------------------------------------------------------------

export function registerAccountRoutes(fastify: FastifyInstance, db: Db): void {
  // -------------------------------------------------------------------------
  // GET /api/c/accounts
  // -------------------------------------------------------------------------
  fastify.get("/api/c/accounts", async (request, reply) => {
    const companyId = request.companyId!;
    const query = (request.query ?? {}) as Record<string, unknown>;
    const grouped = truthyParam(query.grouped);
    const includeInactive = truthyParam(query.includeInactive);
    const withBalances = truthyParam(query.withBalances);

    try {
      return await db.withCompany(companyId, async (client) => {
        const res = await client.query<{
          id: string; code: string; name: string; type: LedgerType;
          subtype: string | null; active: boolean; balance: string | null;
        }>(
          // trial_balance() already returns a row for EVERY account of the
          // company (its own LEFT JOIN coalesces no-activity accounts to 0),
          // so this join never drops a row; the LEFT JOIN is belt-and-braces.
          `SELECT a.id, a.code, a.name, a.type, a.subtype, a.active,
                  ${withBalances ? "tb.balance::text" : "NULL"} AS balance
             FROM accounts a
             ${withBalances ? "LEFT JOIN trial_balance($1, NULL) tb ON tb.account_id = a.id" : ""}
            WHERE a.company_id = $1 ${includeInactive ? "" : "AND a.active = true"}
            ORDER BY a.code`,
          [companyId],
        );
        const accounts: AccountRow[] = res.rows.map((r) => ({
          id: r.id, code: r.code, name: r.name, type: r.type,
          subtype: r.subtype, active: r.active,
          ...(withBalances
            ? { balance: naturalSigned(r.type, r.balance ?? "0.0000") }
            : {}),
        }));

        if (!grouped) return accounts;

        // Legacy picker grouping (entries.ts's original shape) — kept for
        // compatibility only; the web app now groups client-side from the
        // flat list (see the file header).
        return {
          money: accounts.filter((a) => a.type === "asset"),
          income: accounts.filter((a) => a.type === "income"),
          spending: accounts.filter((a) => a.type === "expense"),
          other: accounts.filter((a) => a.type === "liability" || a.type === "equity"),
        };
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "accounts list failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/accounts
  // -------------------------------------------------------------------------
  fastify.post("/api/c/accounts", async (request, reply) => {
    const companyId = request.companyId!;
    const parsed = accountCreateSchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: zodMessage(parsed.error.issues) });
    }
    const body = parsed.data;

    if (body.subtype !== undefined && !subtypeFitsType(body.type, body.subtype)) {
      return reply.code(422).send({ error: subtypeMismatchError(body.type, body.subtype) });
    }

    try {
      // Plain result returned and replied to only after withCompany's COMMIT
      // (see the identical note in entries.ts's POST /api/c/entries).
      const result = await db.withCompany(companyId, async (client) => {
        // Duplicate-name pre-check (accounts.name has NO unique constraint —
        // QB-migrated charts may legally hold near-duplicates — so the
        // friendly refusal lives here; ensure_ar_account-style ambiguity
        // errors are what it protects against).
        const dup = await client.query<{ active: boolean }>(
          "SELECT active FROM accounts WHERE company_id = $1 AND name = $2 LIMIT 1",
          [companyId, body.name],
        );
        if ((dup.rowCount ?? 0) > 0) {
          return {
            ok: false as const, status: 409,
            error: dup.rows[0]!.active
              ? "You already have an account with that name."
              : "You already have an account with that name — it's deactivated. Turn on \"Show inactive\" to find and reactivate it.",
          };
        }

        let code = body.code;
        if (code === undefined) {
          const codesRes = await client.query<{ code: string }>(
            "SELECT code FROM accounts WHERE company_id = $1", [companyId],
          );
          const existing = new Set(codesRes.rows.map((r) => r.code));
          const range = body.subtype !== undefined
            ? SUBTYPE_CODE_RANGES[body.subtype]
            : TYPE_CODE_RANGES[body.type];
          const suggested = suggestCode(existing, range);
          if (suggested === null) {
            return {
              ok: false as const, status: 422,
              error: `Every code between ${range[0]} and ${range[1]} is taken — enter a code for this account yourself.`,
            };
          }
          code = suggested;
        }

        const insertRes = await client.query<{
          id: string; code: string; name: string; type: LedgerType;
          subtype: string | null; active: boolean;
        }>(
          `INSERT INTO accounts (company_id, code, name, type, subtype)
           VALUES ($1, $2, $3, $4, $5)
           RETURNING id, code, name, type, subtype, active`,
          [companyId, code, body.name, body.type, body.subtype ?? null],
        );
        return { ok: true as const, row: insertRes.rows[0]! };
      });

      if (!result.ok) {
        return reply.code(result.status).send({ error: result.error });
      }
      const r = result.row;
      return reply.code(201).send({
        id: r.id, code: r.code, name: r.name, type: r.type,
        subtype: r.subtype, active: r.active,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "create account failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // PATCH /api/c/accounts/:id
  // -------------------------------------------------------------------------
  fastify.patch("/api/c/accounts/:id", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) {
      return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    }
    // `type` is not editable — re-typing across the five types is a
    // non-goal (spec: "deactivate + create instead, matching the tax-rate
    // convention"). zod would silently strip an unknown field, which would
    // read as false success to the caller — refuse explicitly instead.
    if (typeof request.body === "object" && request.body !== null
      && "type" in (request.body as Record<string, unknown>)) {
      return reply.code(400).send({
        error: "An account's type can't be changed — deactivate this account and create a new one instead.",
      });
    }
    const parsed = accountUpdateSchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: zodMessage(parsed.error.issues) });
    }
    const accountId = parsedParams.data.id;
    const body = parsed.data;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const currentRes = await client.query<{
          id: string; code: string; name: string; type: LedgerType;
          subtype: string | null; active: boolean;
        }>(
          `SELECT id, code, name, type, subtype, active FROM accounts
            WHERE company_id = $1 AND id = $2 FOR UPDATE`,
          [companyId, accountId],
        );
        const current = currentRes.rows[0];
        if (!current) return { ok: false as const, status: 404, error: NOT_FOUND_ACCOUNT.error };

        // Subtype must fit the account's (immutable) type — friendly version
        // of the DB's accounts_subtype_valid_for_type CHECK.
        if (body.subtype !== undefined && body.subtype !== null
          && !subtypeFitsType(current.type, body.subtype)) {
          return {
            ok: false as const, status: 422,
            error: subtypeMismatchError(current.type, body.subtype),
          };
        }

        // Deactivation guard: the settings-designated accounts refuse, in
        // English, naming the setting. Reactivation is always allowed.
        if (body.active === false) {
          const settingsRes = await client.query<{
            ar_account_id: string | null;
            ap_account_id: string | null;
            sales_tax_account_id: string | null;
          }>(
            `SELECT ar_account_id, ap_account_id, sales_tax_account_id
               FROM company_settings WHERE company_id = $1`,
            [companyId],
          );
          const settings = settingsRes.rows[0];
          if (settings) {
            for (const pointer of DESIGNATED_POINTERS) {
              if (settings[pointer.column] !== null
                && String(settings[pointer.column]) === accountId) {
                return { ok: false as const, status: 409, error: pointer.error };
              }
            }
          }
        }

        // Duplicate-name pre-check on rename (excluding this account).
        if (body.name !== undefined && body.name !== current.name) {
          const dup = await client.query(
            "SELECT 1 FROM accounts WHERE company_id = $1 AND name = $2 AND id <> $3 LIMIT 1",
            [companyId, body.name, accountId],
          );
          if ((dup.rowCount ?? 0) > 0) {
            return {
              ok: false as const, status: 409,
              error: "You already have an account with that name.",
            };
          }
        }

        const updateRes = await client.query<{
          id: string; code: string; name: string; type: LedgerType;
          subtype: string | null; active: boolean;
        }>(
          // COALESCE-style partial update; subtype uses an explicit "was it
          // sent" flag because null is a meaningful value (untag).
          `UPDATE accounts
              SET name = coalesce($3, name),
                  code = coalesce($4, code),
                  subtype = CASE WHEN $5 THEN $6 ELSE subtype END,
                  active = coalesce($7, active)
            WHERE company_id = $1 AND id = $2
            RETURNING id, code, name, type, subtype, active`,
          [companyId, accountId, body.name ?? null, body.code ?? null,
            body.subtype !== undefined, body.subtype ?? null, body.active ?? null],
        );
        return { ok: true as const, row: updateRes.rows[0]! };
      });

      if (!result.ok) {
        return reply.code(result.status).send({ error: result.error });
      }
      const r = result.row;
      return reply.send({
        id: r.id, code: r.code, name: r.name, type: r.type,
        subtype: r.subtype, active: r.active,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "update account failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });
}
