// Tests for year-end close (Task 6 of docs/superpowers/plans/2026-07-31-local-app.md):
//
//  - tools/tb-logic.ts's parseTrialBalanceRows(): pure unit tests against
//    constructed sheetRows arrays (tolerant header location, strict cell
//    coercion, loud TOTAL-row mismatch), plus one real-xlsx integration case
//    through readQbSheet (mirrors tests/tools/journal.test.ts's pattern).
//  - the close API itself (server/src/close.ts), against a throwaway DB
//    (tests/helpers/db.ts): POST /api/c/close/compare, POST /api/c/close/lock,
//    GET /api/c/close/status.
//
// We do NOT have a real QuickBooks Trial Balance export (see tb-logic.ts's
// header note) — every xlsx fixture here is a hand-built workbook consistent
// with the family shape shared by the other QuickBooks exports this codebase
// already parses (a Tips sheet, leading title rows, a header row located by
// content, data rows, a trailing TOTAL row).

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import ExcelJS from "exceljs";
import { writeFile, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createTestDb, type TestDb, insertDraftEntry, postEntry } 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 type { Client } from "pg";
import { parseTrialBalanceRows } from "../../tools/tb-logic.js";
import { readQbSheet, TIPS_SHEET_NAME } from "../../tools/qb-xlsx.js";

// ---------------------------------------------------------------------------
// tb-logic.ts unit tests — plain sheetRows arrays, no I/O. Mirrors the
// dataRow()/sheet() style tests/tools/journal.test.ts uses for parseJournalRows.
// Column layout for these hand-built rows (1-indexed, matching readQbSheet's
// convention): column 1 left intentionally blank (mimicking an indent
// column QuickBooks reports commonly have), column 2 = Account,
// column 3 = Debit, column 4 = Credit.
// ---------------------------------------------------------------------------

function tbHeaderRow(): unknown[] {
  const r: unknown[] = new Array(4).fill(undefined);
  r[1] = "Account"; r[2] = "Debit"; r[3] = "Credit";
  return r;
}
function tbDataRow(account: unknown, debit?: unknown, credit?: unknown): unknown[] {
  const r: unknown[] = new Array(4).fill(undefined);
  r[1] = account;
  if (debit !== undefined) r[2] = debit;
  if (credit !== undefined) r[3] = credit;
  return r;
}
function tbTotalRow(debit: unknown, credit: unknown): unknown[] {
  const r: unknown[] = new Array(4).fill(undefined);
  r[1] = "TOTAL"; r[2] = debit; r[3] = credit;
  return r;
}
function titleRow(text: string): unknown[] {
  return [text];
}
/** index 0 unused, per readQbSheet's 1-indexed-by-row convention. */
function sheetOf(...rows: unknown[][]): unknown[][] {
  return [[], ...rows];
}

describe("parseTrialBalanceRows: tolerant header location", () => {
  it("locates the header row past any number of leading title rows", () => {
    const rows = sheetOf(
      titleRow("Harborlight Trading Co"),
      titleRow("Trial Balance"),
      titleRow("As of December 31, 2011"),
      tbHeaderRow(),
      tbDataRow("Cash", 800),
      tbDataRow("Revenue", undefined, 800),
      tbTotalRow(800, 800),
    );
    const parsed = parseTrialBalanceRows(rows);
    expect(parsed).toEqual([
      { account: "Cash", side: "debit", amount: "800.00" },
      { account: "Revenue", side: "credit", amount: "800.00" },
    ]);
  });

  it("throws when no Debit/Credit header row is found at all", () => {
    const rows = sheetOf(titleRow("Harborlight Trading Co"), titleRow("nothing else here"));
    expect(() => parseTrialBalanceRows(rows)).toThrow(/no header row found/);
  });

  it("picks the rightmost non-empty text column left of Debit as the account column", () => {
    // An extra non-empty label column further left of Account shouldn't be
    // mistaken for the account column — "Account" (index 2) is rightmost.
    const header: unknown[] = new Array(5).fill(undefined);
    header[1] = "Type"; header[2] = "Account"; header[3] = "Debit"; header[4] = "Credit";
    const data: unknown[] = new Array(5).fill(undefined);
    data[1] = "Bank"; data[2] = "Cash"; data[3] = 500;
    const total: unknown[] = new Array(5).fill(undefined);
    total[2] = "TOTAL"; total[3] = 500; total[4] = 0;
    const rows = sheetOf(header, data, total);
    const parsed = parseTrialBalanceRows(rows);
    expect(parsed).toEqual([{ account: "Cash", side: "debit", amount: "500.00" }]);
  });
});

describe("parseTrialBalanceRows: TOTAL row + loud mismatch", () => {
  it("throws a loud mismatch error when the file's stated total disagrees with the sum of rows", () => {
    const rows = sheetOf(
      tbHeaderRow(),
      tbDataRow("Cash", 800),
      tbDataRow("Revenue", undefined, 800),
      tbTotalRow(900, 800), // wrong debit total
    );
    expect(() => parseTrialBalanceRows(rows)).toThrow(/TOTAL row mismatch/);
  });

  it("throws when there's no TOTAL row at all", () => {
    const rows = sheetOf(tbHeaderRow(), tbDataRow("Cash", 800));
    expect(() => parseTrialBalanceRows(rows)).toThrow(/no TOTAL row found/);
  });

  it("rejects any content after the TOTAL row", () => {
    const rows = sheetOf(
      tbHeaderRow(),
      tbDataRow("Cash", 800),
      tbTotalRow(800, 0),
      tbDataRow("Stray Account", 5),
    );
    expect(() => parseTrialBalanceRows(rows)).toThrow(/unexpected content after the TOTAL row/);
  });

  it("skips blank rows before, between, and after data rows", () => {
    const blank = new Array(4).fill(undefined);
    const rows = sheetOf(
      tbHeaderRow(),
      blank,
      tbDataRow("Cash", 800),
      blank,
      tbDataRow("Revenue", undefined, 800),
      blank,
      tbTotalRow(800, 800),
    );
    const parsed = parseTrialBalanceRows(rows);
    expect(parsed).toHaveLength(2);
  });
});

describe("parseTrialBalanceRows: strict cell coercion + row validation", () => {
  it("throws on an unsupported cell object in the account column", () => {
    const rows = sheetOf(
      tbHeaderRow(),
      tbDataRow({ error: "#REF!" }, 800),
      tbTotalRow(800, 0),
    );
    expect(() => parseTrialBalanceRows(rows)).toThrow(/unsupported cell value/);
  });

  it("throws on a non-numeric value in the Debit column", () => {
    const rows = sheetOf(
      tbHeaderRow(),
      tbDataRow("Cash", "eight hundred"),
      tbTotalRow(0, 0),
    );
    expect(() => parseTrialBalanceRows(rows)).toThrow(/non-numeric value in Debit column/);
  });

  it("rejects a row with both Debit and Credit set", () => {
    const rows = sheetOf(
      tbHeaderRow(),
      tbDataRow("Cash", 800, 800),
      tbTotalRow(800, 800),
    );
    expect(() => parseTrialBalanceRows(rows)).toThrow(/must have exactly one/);
  });

  it("rejects an amount with a blank account name", () => {
    const rows = sheetOf(
      tbHeaderRow(),
      tbDataRow("", 800),
      tbTotalRow(800, 0),
    );
    expect(() => parseTrialBalanceRows(rows)).toThrow(/account name is blank/);
  });

  it("skips a row with a name but no amount (e.g. a group heading)", () => {
    const rows = sheetOf(
      tbHeaderRow(),
      tbDataRow("Assets"),
      tbDataRow("Cash", 800),
      tbTotalRow(800, 0),
    );
    const parsed = parseTrialBalanceRows(rows);
    expect(parsed).toEqual([{ account: "Cash", side: "debit", amount: "800.00" }]);
  });
});

describe("tb-logic integration: real workbook through parseTrialBalanceRows", () => {
  it("parses a full family-shape workbook (Tips sheet, title rows, TOTAL row) via readQbSheet", async () => {
    const dir = await mkdtemp(path.join(tmpdir(), "qb-tb-test-"));
    const file = path.join(dir, "trial-balance.xlsx");
    try {
      const wb = new ExcelJS.Workbook();
      wb.addWorksheet(TIPS_SHEET_NAME).addRow(["QuickBooks Desktop Export Tips"]);
      const ws = wb.addWorksheet("Sheet1");
      ws.addRow(["Harborlight Trading Co"]);
      ws.addRow(["Trial Balance"]);
      ws.addRow(["As of December 31, 2011"]);
      ws.addRow(tbHeaderRow());
      ws.addRow(tbDataRow("Cash", 800));
      ws.addRow(tbDataRow("Revenue", undefined, 800));
      ws.addRow(tbTotalRow(800, 800));
      await wb.xlsx.writeFile(file);

      const sheetRows = await readQbSheet(file);
      const parsed = parseTrialBalanceRows(sheetRows);
      expect(parsed).toEqual([
        { account: "Cash", side: "debit", amount: "800.00" },
        { account: "Revenue", side: "credit", amount: "800.00" },
      ]);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });
});

// ---------------------------------------------------------------------------
// close API integration tests
// ---------------------------------------------------------------------------

async function buildTestApp(dbName: string): Promise<FastifyInstance> {
  return buildApp({
    databaseUrl: await dbUrl(dbName, "app", "app"),
    adminDatabaseUrl: await dbUrl(dbName),
  });
}

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

interface TbFixtureRow { account: string; debit?: number; credit?: number }

/** Builds a full family-shape Trial Balance workbook: a Tips sheet, leading
 *  title rows, a header row, the given data rows, and a trailing TOTAL row
 *  (auto-summed from the rows unless `total` is given explicitly — used by
 *  the mismatch test to keep the file internally self-consistent even when
 *  it deliberately doesn't tie to our own books). */
async function buildTbXlsx(rows: TbFixtureRow[], total?: { debit: number; credit: number }): Promise<Buffer> {
  const wb = new ExcelJS.Workbook();
  wb.addWorksheet(TIPS_SHEET_NAME).addRow(["QuickBooks Desktop Export Tips"]);
  const ws = wb.addWorksheet("Sheet1");
  ws.addRow(["Harborlight Trading Co"]);
  ws.addRow(["Trial Balance"]);
  ws.addRow(["As of a year end"]);
  ws.addRow(tbHeaderRow());

  let sumDebit = 0;
  let sumCredit = 0;
  for (const r of rows) {
    ws.addRow(tbDataRow(r.account, r.debit, r.credit));
    if (r.debit !== undefined) sumDebit += r.debit;
    if (r.credit !== undefined) sumCredit += r.credit;
  }
  const t = total ?? { debit: Math.round(sumDebit * 100) / 100, credit: Math.round(sumCredit * 100) / 100 };
  ws.addRow(tbTotalRow(t.debit, t.credit));

  return Buffer.from(await wb.xlsx.writeBuffer());
}

function multipartWithYear(fileBuffer: Buffer, filename: string, year: string) {
  const boundary = `----closeTestBoundary${Math.random().toString(16).slice(2)}`;
  const yearPart = Buffer.from(
    `--${boundary}\r\nContent-Disposition: form-data; name="year"\r\n\r\n${year}\r\n`,
  );
  const fileHead = Buffer.from(
    `--${boundary}\r\n` +
      `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` +
      `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\r\n\r\n`,
  );
  const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
  return {
    payload: Buffer.concat([yearPart, fileHead, fileBuffer, tail]),
    contentType: `multipart/form-data; boundary=${boundary}`,
  };
}

function injectTb(
  app: FastifyInstance, url: string, companyId: string, fileBuffer: Buffer, year: string,
) {
  const { payload, contentType } = multipartWithYear(fileBuffer, "trial-balance.xlsx", year);
  return app.inject({
    method: "POST", url,
    headers: { "x-company-id": companyId, "content-type": contentType },
    payload,
  });
}

let db: TestDb;
let app: FastifyInstance;
let cA: Client;
let rentExpenseAccountId: number;

beforeAll(async () => {
  db = await createTestDb();
  app = await buildTestApp(db.dbName);
  cA = await db.appClient(db.companyA);

  // Extra account with a "Parent:Sub" style name + a leaf alias, so the
  // compare tests can exercise alias-based matching (design spec: the QB TB
  // shows full paths; account_aliases covers leaf-only names).
  const acctRes = await db.admin.query<{ id: string }>(
    `INSERT INTO accounts (company_id, code, name, type)
     VALUES ($1, '6100', 'Overhead:Rent Expense', 'expense') RETURNING id`,
    [db.companyA],
  );
  rentExpenseAccountId = Number(acctRes.rows[0]!.id);
  await db.admin.query(
    `INSERT INTO account_aliases (company_id, alias, account_id) VALUES ($1, 'Rent Expense', $2)`,
    [db.companyA, rentExpenseAccountId],
  );

  // 2011: Cash debit 1000 / Revenue credit 1000, then Rent Expense debit 200
  // / Cash credit 200 -> at 2011-12-31: Cash 800, Revenue -1000, Rent 200.
  const e1 = await insertDraftEntry(cA, db.companyA, db.userA, {
    date: "2011-03-01", memo: "Consulting",
    lines: [
      { accountId: db.accounts["1000"]!.A, debit: "1000.0000" },
      { accountId: db.accounts["4000"]!.A, credit: "1000.0000" },
    ],
  });
  await postEntry(cA, db.companyA, e1);

  const e2 = await insertDraftEntry(cA, db.companyA, db.userA, {
    date: "2011-07-01", memo: "July rent",
    lines: [
      { accountId: rentExpenseAccountId, debit: "200.0000" },
      { accountId: db.accounts["1000"]!.A, credit: "200.0000" },
    ],
  });
  await postEntry(cA, db.companyA, e2);

  // 2012: Cash debit 300 / Revenue credit 300 -> at 2012-12-31: Cash 1100,
  // Revenue -1300, Rent Expense unchanged at 200.
  const e3 = await insertDraftEntry(cA, db.companyA, db.userA, {
    date: "2012-02-01", memo: "More consulting",
    lines: [
      { accountId: db.accounts["1000"]!.A, debit: "300.0000" },
      { accountId: db.accounts["4000"]!.A, credit: "300.0000" },
    ],
  });
  await postEntry(cA, db.companyA, e3);
}, 60_000);

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

describe("POST /api/c/close/compare", () => {
  it("matches when the file ties out exactly, resolving a leaf name via account_aliases", async () => {
    const xlsx = await buildTbXlsx([
      { account: "Cash", debit: 800 },
      { account: "Revenue", credit: 1000 },
      { account: "Rent Expense", debit: 200 }, // leaf alias -> Overhead:Rent Expense
    ]);
    const res = await injectTb(app, "/api/c/close/compare", db.companyA, xlsx, "2011");
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.year).toBe(2011);
    expect(body.matches).toBe(true);
    expect(body.rows).toHaveLength(3);
    for (const row of body.rows) {
      expect(row.status).toBe("match");
      expect(row.diff).toBe("0.0000");
    }
    const byAccount = Object.fromEntries(body.rows.map((r: { account: string; ours: string }) => [r.account, r.ours]));
    expect(byAccount["Cash"]).toBe("800.0000");
    expect(byAccount["Revenue"]).toBe("-1000.0000");
    expect(byAccount["Overhead:Rent Expense"]).toBe("200.0000"); // displayed under OUR canonical name
    expect(body.totals).toEqual({ ours: "1000.0000", theirs: "1000.0000" });
  });

  it("flags a one-cent difference as 'different' and matches:false, without touching the other rows", async () => {
    const xlsx = await buildTbXlsx([
      { account: "Cash", debit: 800.01 }, // off by 0.01 from our 800.00
      { account: "Revenue", credit: 1000 },
      { account: "Rent Expense", debit: 200 },
    ]);
    const res = await injectTb(app, "/api/c/close/compare", db.companyA, xlsx, "2011");
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.matches).toBe(false);
    const cashRow = body.rows.find((r: { account: string }) => r.account === "Cash");
    expect(cashRow).toMatchObject({ ours: "800.0000", theirs: "800.0100", status: "different" });
    expect(cashRow.diff).toBe("-0.0100");
    const revenueRow = body.rows.find((r: { account: string }) => r.account === "Revenue");
    expect(revenueRow.status).toBe("match");
  });

  it("reports only-ours (Cash omitted from the file) and only-theirs (an account we don't have)", async () => {
    const xlsx = await buildTbXlsx([
      { account: "Revenue", credit: 1000 },
      { account: "Rent Expense", debit: 200 },
      { account: "Mystery Account 9999", debit: 50 }, // not in our chart, no alias
    ]);
    const res = await injectTb(app, "/api/c/close/compare", db.companyA, xlsx, "2011");
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.matches).toBe(false);

    const cashRow = body.rows.find((r: { account: string }) => r.account === "Cash");
    expect(cashRow).toMatchObject({ ours: "800.0000", theirs: null, status: "only-ours" });

    const mysteryRow = body.rows.find((r: { account: string }) => r.account === "Mystery Account 9999");
    expect(mysteryRow).toMatchObject({ ours: null, theirs: "50.0000", status: "only-theirs" });
  });

  it("422s with the parser's loud error message on a malformed file, and persists nothing", async () => {
    const wb = new ExcelJS.Workbook();
    const ws = wb.addWorksheet("Sheet1");
    ws.addRow(["not a trial balance at all"]);
    const xlsx = Buffer.from(await wb.xlsx.writeBuffer());

    const res = await injectTb(app, "/api/c/close/compare", db.companyA, xlsx, "2011");
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toMatch(/no header row found/);
  });

  it("400s on an out-of-range year", async () => {
    const xlsx = await buildTbXlsx([{ account: "Cash", debit: 800 }]);
    const res = await injectTb(app, "/api/c/close/compare", db.companyA, xlsx, "1500");
    expect(res.statusCode).toBe(400);
  });
});

describe("POST /api/c/close/lock", () => {
  it("refuses locking 2012 before 2011 (out-of-order, oldest-first)", async () => {
    const xlsx2012 = await buildTbXlsx([
      { account: "Cash", debit: 1100 },
      { account: "Revenue", credit: 1300 },
      { account: "Rent Expense", debit: 200 },
    ]);
    const res = await injectTb(app, "/api/c/close/lock", db.companyA, xlsx2012, "2012");
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toBe("Lock 2011 first — years must be locked oldest-first after each one reconciles.");

    const locks = await db.admin.query("SELECT count(*)::int AS n FROM period_locks WHERE company_id = $1", [db.companyA]);
    expect(locks.rows[0].n).toBe(0);
  });

  it("409s with the diff rows when the file doesn't tie out, and locks nothing", async () => {
    const badXlsx = await buildTbXlsx([
      { account: "Cash", debit: 999 }, // wrong
      { account: "Revenue", credit: 1000 },
      { account: "Rent Expense", debit: 200 },
    ]);
    const res = await injectTb(app, "/api/c/close/lock", db.companyA, badXlsx, "2011");
    expect(res.statusCode).toBe(409);
    const body = res.json();
    expect(body.matches).toBe(false);
    expect(body.rows.some((r: { status: string }) => r.status === "different")).toBe(true);

    const locks = await db.admin.query("SELECT count(*)::int AS n FROM period_locks WHERE company_id = $1", [db.companyA]);
    expect(locks.rows[0].n).toBe(0);
  });

  it("locks 2011 on a zero-diff match, inserts period_locks, and blocks further posting into it", async () => {
    const goodXlsx = await buildTbXlsx([
      { account: "Cash", debit: 800 },
      { account: "Revenue", credit: 1000 },
      { account: "Rent Expense", debit: 200 },
    ]);
    const res = await injectTb(app, "/api/c/close/lock", db.companyA, goodXlsx, "2011");
    expect(res.statusCode).toBe(201);
    const body = res.json();
    expect(body.locked).toBe(true);
    expect(body.lockedThrough).toBe("2011-12-31");
    expect(body.summary).toMatch(/2011 is locked/);
    expect(body.summary).not.toMatch(/debit|credit/i);

    const lockRow = await db.admin.query(
      "SELECT locked_through::text AS locked_through FROM period_locks WHERE company_id = $1", [db.companyA],
    );
    expect(lockRow.rows).toEqual([{ locked_through: "2011-12-31" }]);

    // Posting into the now-locked year must fail with the English message,
    // via the SAME entries API Task 5 built — the close endpoint doesn't
    // duplicate that guard, it relies on it (CLAUDE.md §12).
    const postRes = await post(app, db.companyA, "/api/c/entries", {
      kind: "money-in",
      date: "2011-06-15",
      amount: "10",
      intoAccountId: String(db.accounts["1000"]!.A),
      categoryAccountId: String(db.accounts["4000"]!.A),
    });
    expect(postRes.statusCode).toBe(409);
    expect(postRes.json().error).toMatch(/locked/i);
  });

  it("refuses to lock 2011 again (already locked)", async () => {
    const xlsx = await buildTbXlsx([
      { account: "Cash", debit: 800 },
      { account: "Revenue", credit: 1000 },
      { account: "Rent Expense", debit: 200 },
    ]);
    const res = await injectTb(app, "/api/c/close/lock", db.companyA, xlsx, "2011");
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toMatch(/already locked/i);

    const locks = await db.admin.query("SELECT count(*)::int AS n FROM period_locks WHERE company_id = $1", [db.companyA]);
    expect(locks.rows[0].n).toBe(1); // unchanged
  });

  it("locks 2012 once 2011 is locked (order now satisfied)", async () => {
    const xlsx2012 = await buildTbXlsx([
      { account: "Cash", debit: 1100 },
      { account: "Revenue", credit: 1300 },
      { account: "Rent Expense", debit: 200 },
    ]);
    const res = await injectTb(app, "/api/c/close/lock", db.companyA, xlsx2012, "2012");
    expect(res.statusCode).toBe(201);
    expect(res.json()).toMatchObject({ locked: true, lockedThrough: "2012-12-31" });
  });
});

describe("GET /api/c/close/status", () => {
  it("reports lockedThrough and per-year posted/locked badges for company A", async () => {
    const res = await get(app, db.companyA, "/api/c/close/status");
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.lockedThrough).toBe("2012-12-31");
    const byYear = Object.fromEntries(
      body.years.map((y: { year: number; postedEntries: number; locked: boolean }) => [y.year, y]),
    );
    expect(byYear[2011]).toEqual({ year: 2011, postedEntries: 2, locked: true });
    expect(byYear[2012]).toEqual({ year: 2012, postedEntries: 1, locked: true });
  });

  it("RLS: company B sees no lock/posting history from company A", async () => {
    const res = await get(app, db.companyB, "/api/c/close/status");
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ lockedThrough: null, years: [] });
  });
});

// ---------------------------------------------------------------------------
// Regression: the out-of-order guard must not be fooled by a GAP year. Only
// checking "is year-1 locked" would let a company with posted entries in
// 2013 and 2015 (nothing in 2014) lock 2015 directly, silently sweeping
// unreconciled 2013 under a lock that can never be un-passed. The guard
// instead finds the EARLIEST year with any posted, not-yet-locked entry —
// so 2013 must be named and locked before 2015 can be, even though 2014
// (the literal year-1 of 2015) has no activity at all.
// ---------------------------------------------------------------------------
describe("POST /api/c/close/lock — gap-year out-of-order guard (regression)", () => {
  // At this point in the suite, company A already has 2011 and 2012 locked
  // (the "POST /api/c/close/lock" describe block above), cumulative through
  // 2012: Cash 1100, Revenue -1300, Overhead:Rent Expense 200.
  beforeAll(async () => {
    // 2013: Cash debit 150 / Revenue credit 150 -> cumulative through 2013:
    // Cash 1250, Revenue -1450, Rent Expense 200 (unchanged).
    const e4 = await insertDraftEntry(cA, db.companyA, db.userA, {
      date: "2013-05-01", memo: "2013 consulting",
      lines: [
        { accountId: db.accounts["1000"]!.A, debit: "150.0000" },
        { accountId: db.accounts["4000"]!.A, credit: "150.0000" },
      ],
    });
    await postEntry(cA, db.companyA, e4);

    // 2014 deliberately has ZERO posted entries — the gap year.

    // 2015: Cash debit 75 / Revenue credit 75 -> cumulative through 2015:
    // Cash 1325, Revenue -1525, Rent Expense 200 (unchanged).
    const e5 = await insertDraftEntry(cA, db.companyA, db.userA, {
      date: "2015-08-01", memo: "2015 consulting",
      lines: [
        { accountId: db.accounts["1000"]!.A, debit: "75.0000" },
        { accountId: db.accounts["4000"]!.A, credit: "75.0000" },
      ],
    });
    await postEntry(cA, db.companyA, e5);
  });

  it("refuses to lock 2015 directly, naming 2013 (not 2014) as the year to lock first", async () => {
    const xlsx2015 = await buildTbXlsx([
      { account: "Cash", debit: 1325 },
      { account: "Revenue", credit: 1525 },
      { account: "Rent Expense", debit: 200 },
    ]);
    const res = await injectTb(app, "/api/c/close/lock", db.companyA, xlsx2015, "2015");
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toBe("Lock 2013 first — years must be locked oldest-first after each one reconciles.");

    const locks = await db.admin.query<{ locked_through: string }>(
      "SELECT locked_through::text FROM period_locks WHERE company_id = $1 ORDER BY id", [db.companyA],
    );
    expect(locks.rows.map((r) => r.locked_through)).toEqual(["2011-12-31", "2012-12-31"]); // unchanged
  });

  it("locks 2013 (the true earliest unlocked year), then 2015 succeeds — the gap year 2014 needs no lock", async () => {
    const xlsx2013 = await buildTbXlsx([
      { account: "Cash", debit: 1250 },
      { account: "Revenue", credit: 1450 },
      { account: "Rent Expense", debit: 200 },
    ]);
    const lock2013 = await injectTb(app, "/api/c/close/lock", db.companyA, xlsx2013, "2013");
    expect(lock2013.statusCode).toBe(201);
    expect(lock2013.json()).toMatchObject({ locked: true, lockedThrough: "2013-12-31" });

    const xlsx2015 = await buildTbXlsx([
      { account: "Cash", debit: 1325 },
      { account: "Revenue", credit: 1525 },
      { account: "Rent Expense", debit: 200 },
    ]);
    const lock2015 = await injectTb(app, "/api/c/close/lock", db.companyA, xlsx2015, "2015");
    expect(lock2015.statusCode).toBe(201);
    expect(lock2015.json()).toMatchObject({ locked: true, lockedThrough: "2015-12-31" });

    const status = await get(app, db.companyA, "/api/c/close/status");
    const byYear = Object.fromEntries(
      status.json().years.map((y: { year: number; locked: boolean }) => [y.year, y.locked]),
    );
    expect(byYear).toEqual({ 2011: true, 2012: true, 2013: true, 2015: true }); // no 2014 entry at all
  });
});
