// Industry chart templates — Task 1 of
// docs/superpowers/plans/2026-08-02-chart-templates.md.
//
// Three layers:
//   1. Structural invariants over all eight templates (pure, no DB): unique
//      codes/names, valid kinds, the shared spine present and identical
//      everywhere, exactly one Sales Tax Payable at 2200, single AR/AP.
//   2. The snapshot-equality guarantee: the 'general' template must
//      reproduce the PRE-refactor small-business chart exactly — the list
//      below was copied verbatim from server/src/templates/small-business.ts
//      BEFORE the spine extraction, and must never be regenerated from the
//      template code it is guarding.
//   3. Functional round-trips against a throwaway DB: every template stamps
//      into a fresh company and immediately supports the verb-first entry
//      flow, invoicing (ensure_ar), bills + payment (ensure_ap), sales-tax
//      enable (adopts 2200 with no ambiguity), and a clean trial balance
//      with ledger_out_of_balance() = 0.0000.

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createTestDb, type TestDb } from "../helpers/db.js";
// Where the database is, resolved once for the whole run: your ADMIN_URL /
// DATABASE_URL if you set one, otherwise the PostgreSQL Balanced Sum installed on
// this machine. Every file in this directory used to carry its own copy of that
// decision, hardcoded to the Docker container that no longer exists.
import { dbUrl } from "../helpers/test-db-admin.js";
import { buildApp } from "../../server/src/app.js";
import type { FastifyInstance } from "fastify";
import {
  CHART_TEMPLATES,
  CHART_TEMPLATES_BY_KEY,
  type TemplateAccount,
} from "../../server/src/templates/types.js";
import { SPINE_ACCOUNTS, isIndustryCode } from "../../server/src/templates/core.js";
import { SUBTYPES_BY_TYPE } from "../../server/src/templates/types.js";
import { GENERAL_TEMPLATE, SETUP_TEMPLATES } from "../../server/src/templates/small-business.js";

let db: TestDb;
let app: FastifyInstance;

beforeAll(async () => {
  db = await createTestDb();
  app = await buildApp({
    databaseUrl: await dbUrl(db.dbName, "app", "app"),
    adminDatabaseUrl: await dbUrl(db.dbName),
  });
}, 60_000);

afterAll(async () => {
  await app.close();
  await db.close();
});

function post(companyId: string, url: string, payload?: unknown) {
  return app.inject({
    method: "POST", url, headers: { "x-company-id": companyId }, payload: payload ?? {},
  });
}
function put(companyId: string, url: string, payload?: unknown) {
  return app.inject({
    method: "PUT", url, headers: { "x-company-id": companyId }, payload: payload ?? {},
  });
}
function get(companyId: string, url: string) {
  return app.inject({ method: "GET", url, headers: { "x-company-id": companyId } });
}

async function createCompany(name: string): Promise<string> {
  const res = await app.inject({ method: "POST", url: "/api/companies", payload: { name } });
  expect(res.statusCode).toBe(201);
  return (res.json() as { id: string }).id;
}

// SANCTIONED CHANGE 2026-08-02 (chart-management milestone, Task 1):
// TemplateAccount rows now additionally carry a `subtype` (presentation
// grouping, migration 0016). The snapshot guarantee is deliberately
// UNCHANGED: codes, names, and types must stay byte-identical to the
// pre-refactor chart — so the snapshot rows below keep their original
// three-field shape, and sortByCode strips every row down to exactly those
// three fields before comparing. Subtype validity is asserted separately in
// the structural suite ("every row carries a subtype valid for its type").
interface SnapshotRow {
  code: string;
  name: string;
  type: TemplateAccount["type"];
}

function sortByCode(rows: readonly SnapshotRow[]): SnapshotRow[] {
  return rows
    .map((r) => ({ code: r.code, name: r.name, type: r.type }))
    .sort((a, b) => (a.code < b.code ? -1 : a.code > b.code ? 1 : 0));
}

// ---------------------------------------------------------------------------
// 2. snapshot equality — the chart every existing company was stamped from,
// copied VERBATIM from small-business.ts before the spine refactor.
// ---------------------------------------------------------------------------

const PRE_REFACTOR_SMALL_BUSINESS: readonly SnapshotRow[] = [
  { code: "1000", name: "Cash on Hand", type: "asset" },
  { code: "1010", name: "Checking Account", type: "asset" },
  { code: "1020", name: "Savings Account", type: "asset" },
  { code: "1100", name: "Accounts Receivable", type: "asset" },
  { code: "1200", name: "Prepaid Expenses", type: "asset" },
  { code: "1500", name: "Furniture and Equipment", type: "asset" },
  { code: "1510", name: "Vehicles", type: "asset" },
  { code: "1590", name: "Accumulated Depreciation", type: "asset" },
  { code: "2000", name: "Accounts Payable", type: "liability" },
  { code: "2100", name: "Credit Card", type: "liability" },
  { code: "2200", name: "Sales Tax Payable", type: "liability" },
  { code: "2300", name: "Payroll Liabilities", type: "liability" },
  { code: "2400", name: "Loan Payable", type: "liability" },
  { code: "3000", name: "Owner's Equity", type: "equity" },
  { code: "3010", name: "Owner's Distributions", type: "equity" },
  { code: "4000", name: "Sales Income", type: "income" },
  { code: "4010", name: "Service Income", type: "income" },
  { code: "4020", name: "Other Income", type: "income" },
  { code: "6000", name: "Advertising", type: "expense" },
  { code: "6010", name: "Bank Fees", type: "expense" },
  { code: "6020", name: "Insurance", type: "expense" },
  { code: "6030", name: "Meals and Entertainment", type: "expense" },
  { code: "6040", name: "Office Supplies", type: "expense" },
  { code: "6050", name: "Payroll Wages", type: "expense" },
  { code: "6060", name: "Payroll Taxes", type: "expense" },
  { code: "6070", name: "Professional Fees", type: "expense" },
  { code: "6080", name: "Rent", type: "expense" },
  { code: "6090", name: "Repairs and Maintenance", type: "expense" },
  { code: "6100", name: "Software Subscriptions", type: "expense" },
  { code: "6110", name: "Phone and Internet", type: "expense" },
  { code: "6120", name: "Travel", type: "expense" },
  { code: "6130", name: "Utilities", type: "expense" },
  { code: "6140", name: "Vehicle Expenses", type: "expense" },
  { code: "6150", name: "Contract Labor", type: "expense" },
  { code: "6160", name: "Dues and Subscriptions", type: "expense" },
  { code: "6170", name: "Postage and Shipping", type: "expense" },
  { code: "6180", name: "Taxes and Licenses", type: "expense" },
  { code: "6190", name: "Continuing Education", type: "expense" },
  { code: "6200", name: "Depreciation Expense", type: "expense" },
  { code: "6210", name: "Miscellaneous Expense", type: "expense" },
];

describe("general template backward compatibility", () => {
  it("reproduces the pre-refactor small-business chart exactly (codes, names, kinds)", () => {
    expect(PRE_REFACTOR_SMALL_BUSINESS).toHaveLength(40);
    expect(sortByCode(GENERAL_TEMPLATE.accounts)).toEqual(sortByCode(PRE_REFACTOR_SMALL_BUSINESS));
  });

  it("keeps the legacy SETUP_TEMPLATES['small-business'] export pointing at the same chart", () => {
    expect(SETUP_TEMPLATES["small-business"]).toBe(GENERAL_TEMPLATE.accounts);
  });
});

// ---------------------------------------------------------------------------
// 1. structural invariants, parametrized over all eight
// ---------------------------------------------------------------------------

const VALID_KINDS = new Set(["asset", "liability", "equity", "income", "expense"]);
const SPINE_CODES = new Set(SPINE_ACCOUNTS.map((a) => a.code));

describe("template registry", () => {
  it("has exactly eight templates, keys unique, general first", () => {
    expect(CHART_TEMPLATES).toHaveLength(8);
    const keys = CHART_TEMPLATES.map((t) => t.key);
    expect(new Set(keys).size).toBe(8);
    expect(keys[0]).toBe("general");
    expect(keys).toEqual([
      "general", "services", "retail", "restaurant",
      "construction", "field-services", "property", "practice",
    ]);
    for (const key of keys) {
      expect(CHART_TEMPLATES_BY_KEY.get(key)).toBeDefined();
    }
  });
});

for (const template of CHART_TEMPLATES) {
  describe(`template structure: ${template.key}`, () => {
    const accounts = template.accounts;

    it("has unique codes and unique names", () => {
      expect(new Set(accounts.map((a) => a.code)).size).toBe(accounts.length);
      expect(new Set(accounts.map((a) => a.name)).size).toBe(accounts.length);
    });

    it("uses only the five kinds the schema's CHECK constraint allows", () => {
      for (const a of accounts) {
        expect(VALID_KINDS.has(a.type), `${a.code} ${a.name} has kind ${a.type}`).toBe(true);
      }
    });

    it("carries a subtype on every row, valid for its type (0016's CHECK matrix)", () => {
      for (const a of accounts) {
        expect(
          (SUBTYPES_BY_TYPE[a.type] as readonly string[]).includes(a.subtype),
          `${a.code} ${a.name} (${a.type}) has subtype ${a.subtype}`,
        ).toBe(true);
      }
    });

    it("contains the shared spine, identical in codes, names, and kinds", () => {
      const spineRows = accounts.filter((a) => SPINE_CODES.has(a.code));
      expect(sortByCode(spineRows)).toEqual(sortByCode(SPINE_ACCOUNTS));
    });

    it("has exactly one 'Sales Tax Payable', at code 2200, as a liability", () => {
      const rows = accounts.filter((a) => a.name === "Sales Tax Payable");
      expect(rows).toHaveLength(1);
      expect(rows[0]).toEqual({
        code: "2200", name: "Sales Tax Payable", type: "liability",
        subtype: "other-current-liability",
      });
    });

    it("has exactly one Accounts Receivable (asset) and one Accounts Payable (liability)", () => {
      const ar = accounts.filter((a) => a.name === "Accounts Receivable");
      expect(ar).toHaveLength(1);
      expect(ar[0]).toEqual({
        code: "1100", name: "Accounts Receivable", type: "asset",
        subtype: "accounts-receivable",
      });
      const ap = accounts.filter((a) => a.name === "Accounts Payable");
      expect(ap).toHaveLength(1);
      expect(ap[0]).toEqual({
        code: "2000", name: "Accounts Payable", type: "liability",
        subtype: "accounts-payable",
      });
    });

    it("keeps every industry addition inside the documented industry code ranges", () => {
      const industry = accounts.filter((a) => !SPINE_CODES.has(a.code));
      expect(industry.length).toBe(accounts.length - SPINE_ACCOUNTS.length);
      // Range membership against core.ts's exported INDUSTRY_CODE_RANGES (the
      // single source of truth) — an industry account parked in a gap the
      // spine reserves for its own expansion (e.g. 6250 or 1210) fails here
      // even though it collides with no CURRENT spine code.
      for (const a of industry) {
        expect(
          isIndustryCode(a.code),
          `${template.key}: ${a.code} ${a.name} is outside the industry code ranges`,
        ).toBe(true);
      }
      // And no spine account strays into the industry ranges.
      for (const a of SPINE_ACCOUNTS) {
        expect(
          isIndustryCode(a.code),
          `spine account ${a.code} ${a.name} sits inside an industry range`,
        ).toBe(false);
      }
      if (template.key !== "general") {
        // Per spec: each industry template adds 8-20 accounts on top of the spine.
        expect(industry.length).toBeGreaterThanOrEqual(8);
        expect(industry.length).toBeLessThanOrEqual(20);
      }
    });

    it("has picker copy: a one-sentence blurb and 2-3 examples naming real accounts", () => {
      expect(template.label.length).toBeGreaterThan(0);
      expect(template.blurb.length).toBeGreaterThan(0);
      // §9: the picker never says "debit", "credit", or "chart of accounts".
      expect(template.blurb.toLowerCase()).not.toMatch(/debit|credit|chart of accounts/);
      expect(template.examples.length).toBeGreaterThanOrEqual(2);
      expect(template.examples.length).toBeLessThanOrEqual(3);
      const names = new Set(accounts.map((a) => a.name));
      for (const example of template.examples) {
        expect(names.has(example), `example "${example}" is not an account in ${template.key}`).toBe(true);
      }
    });
  });
}

// ---------------------------------------------------------------------------
// API: template list, create round-trip, unknown key, defaults
// ---------------------------------------------------------------------------

describe("GET /api/setup/templates", () => {
  it("returns the eight picker cards (key/label/blurb/examples/accounts), general first", async () => {
    const res = await app.inject({ method: "GET", url: "/api/setup/templates" });
    expect(res.statusCode).toBe(200);
    const body = res.json() as Array<Record<string, unknown>>;
    expect(body).toHaveLength(8);
    expect(body[0]!.key).toBe("general");
    for (const card of body) {
      expect(Object.keys(card).sort()).toEqual(["accounts", "blurb", "examples", "key", "label"]);
      expect(typeof card.key).toBe("string");
      expect(typeof card.label).toBe("string");
      expect(typeof card.blurb).toBe("string");
      expect(Array.isArray(card.examples)).toBe(true);
    }
  });

  it("includes each template's full account list, matching what the fresh route stamps", async () => {
    const res = await app.inject({ method: "GET", url: "/api/setup/templates" });
    const body = res.json() as Array<{
      key: string;
      accounts: Array<Record<string, unknown>>;
    }>;
    for (const card of body) {
      const template = CHART_TEMPLATES_BY_KEY.get(card.key)!;
      // Exactly the template's chart — same rows, same code order, and only
      // the four fields the preview needs (no accidental extras). subtype
      // rides along since Chart Management Task 3 so the wizard preview can
      // section each type the standard way (Bank accounts / Credit cards /
      // ...).
      expect(card.accounts).toEqual(
        template.accounts.map(({ code, name, type, subtype }) => ({ code, name, type, subtype })),
      );
      for (const row of card.accounts) {
        expect(Object.keys(row).sort()).toEqual(["code", "name", "subtype", "type"]);
        expect(typeof row.subtype).toBe("string"); // templates stamp fully-tagged charts
      }
    }
  });
});

describe("POST /api/c/setup/fresh with templateKey", () => {
  it("rejects an unknown key with the English 400", async () => {
    const companyId = await createCompany("Unknown Template Co");
    const res = await post(companyId, "/api/c/setup/fresh", { templateKey: "underwater-basket-weaving" });
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toBe(
      "That business type isn't one of the options — pick one from the list.",
    );
    const rows = await db.admin.query(
      "SELECT count(*)::int AS n FROM accounts WHERE company_id = $1", [companyId],
    );
    expect(rows.rows[0].n).toBe(0);
  });

  it("defaults to the general template when no templateKey is sent", async () => {
    const companyId = await createCompany("Default Template Co");
    const res = await post(companyId, "/api/c/setup/fresh", {});
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ accountsCreated: 40 });
    const rows = await db.admin.query(
      "SELECT count(*)::int AS n FROM accounts WHERE company_id = $1 AND name = 'Sales Income'",
      [companyId],
    );
    expect(rows.rows[0].n).toBe(1);
  });

  it("400s when templateKey and the legacy template field name two different templates", async () => {
    const companyId = await createCompany("Conflicting Fields Co");
    const res = await post(companyId, "/api/c/setup/fresh", {
      templateKey: "restaurant", template: "small-business",
    });
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toBe("Pick one business type — the request named two different ones.");
    const rows = await db.admin.query(
      "SELECT count(*)::int AS n FROM accounts WHERE company_id = $1", [companyId],
    );
    expect(rows.rows[0].n).toBe(0);
  });

  it("accepts both fields when they name the same template (incl. the small-business alias)", async () => {
    const companyId = await createCompany("Agreeing Fields Co");
    const res = await post(companyId, "/api/c/setup/fresh", {
      templateKey: "general", template: "small-business",
    });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ accountsCreated: 40 });
  });

  it("still accepts the legacy { template: 'small-business' } body as general", async () => {
    const companyId = await createCompany("Legacy Body Co");
    const res = await post(companyId, "/api/c/setup/fresh", { template: "small-business" });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ accountsCreated: 40 });
  });

  it("stores the chosen industry chart (restaurant spot-check)", async () => {
    const companyId = await createCompany("Restaurant Round Trip Co");
    const restaurant = CHART_TEMPLATES_BY_KEY.get("restaurant")!;
    const res = await post(companyId, "/api/c/setup/fresh", { templateKey: "restaurant" });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ accountsCreated: restaurant.accounts.length });

    const rows = await db.admin.query<{ code: string; name: string; type: string }>(
      "SELECT code, name, type FROM accounts WHERE company_id = $1 ORDER BY code", [companyId],
    );
    expect(rows.rows).toEqual(sortByCode(restaurant.accounts));
    // Three industry-distinctive accounts, with the right kinds.
    expect(rows.rows).toContainEqual({ code: "4000", name: "Food Sales", type: "income" });
    expect(rows.rows).toContainEqual({ code: "2500", name: "Tips Payable", type: "liability" });
    expect(rows.rows).toContainEqual({ code: "6310", name: "Delivery Platform Fees", type: "expense" });
  });
});

// ---------------------------------------------------------------------------
// 3. functional round-trip, parametrized over all eight: every system
// feature works immediately on a company stamped from the template.
// ---------------------------------------------------------------------------

for (const template of CHART_TEMPLATES) {
  describe(`functional round-trip: ${template.key}`, () => {
    it("supports entries, invoicing, bills + payment, sales tax, and a clean trial balance", async () => {
      const companyId = await createCompany(`Chart Functional ${template.key} Co`);
      const fresh = await post(companyId, "/api/c/setup/fresh", { templateKey: template.key });
      expect(fresh.statusCode).toBe(200);
      expect(fresh.json()).toEqual({ accountsCreated: template.accounts.length });

      // Account ids by code, via the same picker endpoint the UI uses.
      const accountsRes = await get(companyId, "/api/c/accounts");
      expect(accountsRes.statusCode).toBe(200);
      const idByCode = new Map<string, string>(
        (accountsRes.json() as Array<{ id: string; code: string }>).map((a) => [a.code, a.id]),
      );

      // Verb-first manual entry: money in, into Checking, from Other Income —
      // both spine accounts, present in every template.
      const entry = await post(companyId, "/api/c/entries", {
        kind: "money-in",
        date: "2024-03-01",
        amount: "500",
        intoAccountId: idByCode.get("1010")!,
        categoryAccountId: idByCode.get("4020")!,
      });
      expect(entry.statusCode).toBe(201);

      // Invoice against the template's own primary income account (4000 in
      // every template) — issuing it makes ensure_ar_account() adopt 1100.
      const customer = await post(companyId, "/api/c/customers", { name: "Roundtrip Customer" });
      expect(customer.statusCode).toBe(201);
      const invoice = await post(companyId, "/api/c/invoices", {
        customerId: customer.json().id,
        invoiceDate: "2024-03-05",
        dueDate: "2024-04-04",
        lines: [{ unitPrice: "250.00", incomeAccountId: idByCode.get("4000")! }],
      });
      expect(invoice.statusCode).toBe(201);
      const issue = await post(companyId, `/api/c/invoices/${invoice.json().invoiceId}/issue`);
      expect(issue.statusCode).toBe(200);

      // Bill against a spine expense account, issued (ensure_ap_account
      // adopts 2000), then paid in full from Checking.
      const vendor = await post(companyId, "/api/c/vendors", { name: "Roundtrip Vendor" });
      expect(vendor.statusCode).toBe(201);
      const bill = await post(companyId, "/api/c/bills", {
        vendorId: vendor.json().id,
        billDate: "2024-03-10",
        dueDate: "2024-04-09",
        lines: [{ unitPrice: "100.00", categoryAccountId: idByCode.get("6040")! }],
      });
      expect(bill.statusCode).toBe(201);
      const billId = bill.json().billId;
      const billIssue = await post(companyId, `/api/c/bills/${billId}/issue`);
      expect(billIssue.statusCode).toBe(200);
      const pay = await post(companyId, "/api/c/bill-payments", {
        vendorId: vendor.json().id,
        paymentDate: "2024-03-15",
        amount: "100.00",
        moneyAccountId: idByCode.get("1010")!,
        method: "electronic",
        applications: [{ billId, amount: "100.00" }],
      });
      expect(pay.statusCode).toBe(201);

      // Enable sales tax: must ADOPT the template's 2200 without the
      // more-than-one-active ambiguity error and without creating a new one.
      const tax = await put(companyId, "/api/c/settings/sales-tax", { enabled: true });
      expect(tax.statusCode).toBe(200);
      const adopted = await db.admin.query<{ code: string; n: number }>(
        `SELECT a.code,
                (SELECT count(*)::int FROM accounts
                  WHERE company_id = s.company_id AND name = 'Sales Tax Payable') AS n
           FROM company_settings s
           JOIN accounts a ON a.company_id = s.company_id AND a.id = s.sales_tax_account_id
          WHERE s.company_id = $1`,
        [companyId],
      );
      expect(adopted.rows[0]).toEqual({ code: "2200", n: 1 });

      // Trial balance runs clean and the ledger balances to exactly 0.0000.
      const tb = await get(companyId, "/api/c/reports/trial-balance");
      expect(tb.statusCode).toBe(200);
      expect(Array.isArray(tb.json().rows)).toBe(true);
      const oob = await db.admin.query<{ oob: string }>(
        "SELECT ledger_out_of_balance($1)::text AS oob", [companyId],
      );
      expect(oob.rows[0]!.oob).toBe("0.0000");
    }, 30_000);
  });
}
