// Integration tests for the transaction register API — Task 1 of
// docs/superpowers/plans/2026-08-02-switch-from-anywhere.md (spec Part A).
// Boots the app builder against a throwaway DB (tests/helpers/db.ts) and
// seeds company A with a representative mix of every provenance the type
// derivation covers: manual entries (three sharing one date, for pagination
// stability), an invoice + applied payment, a bill + check payment + an
// electronic vendor payment, a reversal, imported history (posted with
// source='quickbooks_journal' + staging provenance), an unknown source, a
// bank-statement-created entry, and an intercompany posting. Company B gets
// ONLY its own intercompany leg — the RLS check asserts B's register shows
// exactly that and none of A's rows.
//
// Running-balance expectations are hand-computed in the table below AND
// cross-checked against trial_balance() directly (the register's final
// running balance must equal the trial balance for that account as of the
// same date — spec's own test requirement).
//
// Cash (asset, debit-normal) posted lines in (entry_date, entry_no, line id)
// order — signed amount / running balance:
//   i1   2023-06-01  +1000  1000
//   i2   2023-07-01    -20   980
//   u1   2023-08-01     +5   985
//   m1   2024-01-10   +100  1085
//   m2   2024-01-10    -40  1045
//   m3   2024-01-10    +60  1105
//   m5a  2024-01-10    +30  1135   (same ENTRY as m5b — line-id tiebreak)
//   m5b  2024-01-10    +20  1155
//   pay  2024-02-15   +300  1455
//   chk  2024-03-10   -200  1255
//   ep   2024-03-12    -50  1205
//   m4   2024-04-01    -75  1130
//   rev4 2024-04-05    +75  1205
//   bank 2024-05-01    +80  1285
//   ic   2024-06-01   -120  1165

import { describe, it, expect, beforeAll, afterAll } from "vitest";
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 { accountCsvFilename } from "../../server/src/register.js";
import type { FastifyInstance } from "fastify";
import type { Client } from "pg";

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

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

/** insertDraftEntry, plus the source/external_ref pair imports carry. */
async function insertSourcedEntry(
  c: Client, companyId: string, userId: string,
  spec: {
    date: string; memo: string; source: string; externalRef: string;
    lines: Array<{ accountId: number; debit?: string; credit?: string }>;
  },
): Promise<number> {
  await c.query("BEGIN");
  try {
    const er = await c.query(
      `INSERT INTO journal_entries (company_id, entry_date, memo, source, external_ref, created_by)
       VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
      [companyId, spec.date, spec.memo, spec.source, spec.externalRef, userId]);
    const entryId: number = Number(er.rows[0].id);
    for (const l of spec.lines) {
      await c.query(
        `INSERT INTO journal_lines (company_id, entry_id, account_id, debit, credit)
         VALUES ($1, $2, $3, $4, $5)`,
        [companyId, entryId, l.accountId, l.debit ?? "0", l.credit ?? "0"]);
    }
    await c.query("COMMIT");
    return entryId;
  } catch (err) {
    await c.query("ROLLBACK").catch(() => {});
    throw err;
  }
}

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

// Company A fixture ids, assigned in beforeAll (see the header table).
let i1Id: number, i2Id: number, u1Id: number;
let m1Id: number, m2Id: number, m3Id: number, m5Id: number, m4Id: number, m6Id: number;
let invEntryId: number, payEntryId: number, billEntryId: number;
let chkEntryId: number, epEntryId: number, rev4Id: number;
let bankEntryId: number, icEntryId: number, icEntryIdB: number;
let invoiceId: number, paymentId: number, billId: number;
let chkPaymentId: number, epPaymentId: number, bankLineId: number;

beforeAll(async () => {
  db = await createTestDb();
  app = await buildTestApp(db.dbName);
  cA = await db.appClient(db.companyA);
  const A = db.companyA;
  const uA = db.userA;
  const acc = db.accounts;

  // --- imported history (2023) -------------------------------------------
  i1Id = await insertSourcedEntry(cA, A, uA, {
    date: "2023-06-01", memo: "Historic sale",
    source: "quickbooks_journal", externalRef: "101",
    lines: [
      { accountId: acc["1000"].A, debit: "1000.0000" },
      { accountId: acc["4000"].A, credit: "1000.0000" },
    ],
  });
  await postEntry(cA, A, i1Id);

  i2Id = await insertSourcedEntry(cA, A, uA, {
    date: "2023-07-01", memo: "Historic expense",
    source: "quickbooks_journal", externalRef: "102",
    lines: [
      { accountId: acc["6000"].A, debit: "20.0000" },
      { accountId: acc["1000"].A, credit: "20.0000" },
    ],
  });
  await postEntry(cA, A, i2Id);

  // unknown source -> plain 'Entry'
  u1Id = await insertSourcedEntry(cA, A, uA, {
    date: "2023-08-01", memo: "Mystery entry",
    source: "mystery_feed", externalRef: "z1",
    lines: [
      { accountId: acc["1000"].A, debit: "5.0000" },
      { accountId: acc["4000"].A, credit: "5.0000" },
    ],
  });
  await postEntry(cA, A, u1Id);

  // staging provenance for i1 so name search has an imported name to find
  const batch = await db.admin.query<{ id: string }>(
    `INSERT INTO import_batches (company_id, source, filename, file_sha256, status, created_by)
     VALUES ($1, 'quickbooks_journal', 'journal-2023.xlsx', 'feedface01', 'posted', $2)
     RETURNING id`,
    [A, uA],
  );
  await db.admin.query(
    `INSERT INTO staging_journal
       (company_id, batch_id, entry_key, name, memo, account_raw, raw,
        resolved_account_id, parsed_date, parsed_debit, status, posted_entry_id)
     VALUES ($1, $2, '101', 'Historic Customer', 'Historic sale', 'Cash', '{}'::jsonb,
             $3, '2023-06-01', 1000.0000, 'posted', $4)`,
    [A, batch.rows[0]!.id, acc["1000"].A, i1Id],
  );

  // --- manual entries, three sharing 2024-01-10 --------------------------
  m1Id = await insertDraftEntry(cA, A, uA, {
    date: "2024-01-10", memo: 'Cash sale, with "quotes"',
    lines: [
      { accountId: acc["1000"].A, debit: "100.0000" },
      { accountId: acc["4000"].A, credit: "100.0000" },
    ],
  });
  await postEntry(cA, A, m1Id);

  m2Id = await insertDraftEntry(cA, A, uA, {
    date: "2024-01-10", memo: "Coffee, beans",
    lines: [
      { accountId: acc["6000"].A, debit: "40.0000" },
      { accountId: acc["1000"].A, credit: "40.0000" },
    ],
  });
  await postEntry(cA, A, m2Id);

  m3Id = await insertDraftEntry(cA, A, uA, {
    date: "2024-01-10", memo: "Walk-in sale",
    lines: [
      { accountId: acc["1000"].A, debit: "60.0000" },
      { accountId: acc["4000"].A, credit: "60.0000" },
    ],
  });
  await postEntry(cA, A, m3Id);

  // one entry with TWO lines on the same account (line-id tiebreak fixture)
  m5Id = await insertDraftEntry(cA, A, uA, {
    date: "2024-01-10", memo: "Split deposit",
    lines: [
      { accountId: acc["1000"].A, debit: "30.0000" },
      { accountId: acc["1000"].A, debit: "20.0000" },
      { accountId: acc["4000"].A, credit: "50.0000" },
    ],
  });
  await postEntry(cA, A, m5Id);

  // --- invoice + payment (AR) --------------------------------------------
  const cust = await cA.query<{ id: string }>(
    "INSERT INTO customers (company_id, name) VALUES ($1, 'Acme Anvils') RETURNING id", [A]);
  const custId = Number(cust.rows[0]!.id);
  const invr = await cA.query<{ id: string }>(
    `INSERT INTO invoices (company_id, customer_id, invoice_date, due_date, created_by)
     VALUES ($1, $2, '2024-02-01', '2024-03-02', $3) RETURNING id`,
    [A, custId, uA]);
  invoiceId = Number(invr.rows[0]!.id);
  await cA.query(
    `INSERT INTO invoice_lines (company_id, invoice_id, description, quantity, unit_price, amount, income_account_id)
     VALUES ($1, $2, 'Anvil polishing', 1, 500.0000, 500.0000, $3)`,
    [A, invoiceId, acc["4000"].A]);
  await cA.query("SELECT issue_invoice($1, $2, $3)", [A, invoiceId, uA]);
  const invE = await cA.query<{ posted_entry_id: string }>(
    "SELECT posted_entry_id FROM invoices WHERE company_id = $1 AND id = $2", [A, invoiceId]);
  invEntryId = Number(invE.rows[0]!.posted_entry_id);

  const payr = await cA.query<{ id: string }>(
    "SELECT receive_payment($1, $2::jsonb, $3) AS id",
    [A, JSON.stringify({
      customer_id: custId, payment_date: "2024-02-15", amount: "300.0000",
      deposit_account_id: db.accounts["1000"].A, memo: "Payment from Acme",
      applications: [{ invoice_id: invoiceId, amount: "300.0000" }],
    }), uA]);
  paymentId = Number(payr.rows[0]!.id);
  const payE = await cA.query<{ posted_entry_id: string }>(
    "SELECT posted_entry_id FROM payments WHERE company_id = $1 AND id = $2", [A, paymentId]);
  payEntryId = Number(payE.rows[0]!.posted_entry_id);

  // --- bill + check payment + electronic payment (AP) --------------------
  const vend = await cA.query<{ id: string }>(
    "INSERT INTO vendors (company_id, name) VALUES ($1, 'Bolt Supply Co') RETURNING id", [A]);
  const vendId = Number(vend.rows[0]!.id);
  const billr = await cA.query<{ id: string }>(
    `INSERT INTO bills (company_id, vendor_id, ref_no, bill_date, due_date, created_by)
     VALUES ($1, $2, 'INV-77', '2024-03-01', '2024-03-31', $3) RETURNING id`,
    [A, vendId, uA]);
  billId = Number(billr.rows[0]!.id);
  await cA.query(
    `INSERT INTO bill_lines (company_id, bill_id, description, quantity, unit_price, amount, category_account_id)
     VALUES ($1, $2, 'Bolts', 1, 200.0000, 200.0000, $3)`,
    [A, billId, acc["6000"].A]);
  await cA.query("SELECT issue_bill($1, $2, $3)", [A, billId, uA]);
  const billE = await cA.query<{ posted_entry_id: string }>(
    "SELECT posted_entry_id FROM bills WHERE company_id = $1 AND id = $2", [A, billId]);
  billEntryId = Number(billE.rows[0]!.posted_entry_id);

  const chkr = await cA.query<{ id: string }>(
    "SELECT pay_bills($1, $2::jsonb, $3) AS id",
    [A, JSON.stringify({
      vendor_id: vendId, payment_date: "2024-03-10", amount: "200.0000",
      money_account_id: db.accounts["1000"].A, method: "check", check_no: "1001",
      memo: "Check for INV-77",
      applications: [{ bill_id: billId, amount: "200.0000" }],
    }), uA]);
  chkPaymentId = Number(chkr.rows[0]!.id);
  const chkE = await cA.query<{ posted_entry_id: string }>(
    "SELECT posted_entry_id FROM bill_payments WHERE company_id = $1 AND id = $2",
    [A, chkPaymentId]);
  chkEntryId = Number(chkE.rows[0]!.posted_entry_id);

  const epr = await cA.query<{ id: string }>(
    "SELECT pay_bills($1, $2::jsonb, $3) AS id",
    [A, JSON.stringify({
      vendor_id: vendId, payment_date: "2024-03-12", amount: "50.0000",
      money_account_id: db.accounts["1000"].A, method: "electronic",
      memo: "Autopay", applications: [],
    }), uA]);
  epPaymentId = Number(epr.rows[0]!.id);
  const epE = await cA.query<{ posted_entry_id: string }>(
    "SELECT posted_entry_id FROM bill_payments WHERE company_id = $1 AND id = $2",
    [A, epPaymentId]);
  epEntryId = Number(epE.rows[0]!.posted_entry_id);

  // --- a reversed manual entry -------------------------------------------
  m4Id = await insertDraftEntry(cA, A, uA, {
    date: "2024-04-01", memo: "Duplicate rent (error)",
    lines: [
      { accountId: acc["6000"].A, debit: "75.0000" },
      { accountId: acc["1000"].A, credit: "75.0000" },
    ],
  });
  await postEntry(cA, A, m4Id);
  const rev = await cA.query<{ reverse_entry: string }>(
    "SELECT reverse_entry($1, $2, $3, $4)", [A, m4Id, "2024-04-05", uA]);
  rev4Id = Number(rev.rows[0]!.reverse_entry);

  // --- bank-statement-created entry --------------------------------------
  const stmt = await cA.query<{ id: string }>(
    `INSERT INTO bank_statements (company_id, money_account_id, filename, file_sha256, format, created_by)
     VALUES ($1, $2, 'may.csv', 'cafebabe02', 'csv', $3) RETURNING id`,
    [A, acc["1000"].A, uA]);
  const line = await cA.query<{ id: string }>(
    `INSERT INTO bank_statement_lines
       (company_id, statement_id, money_account_id, line_no, posted_date, description, amount, raw)
     VALUES ($1, $2, $3, 1, '2024-05-01', 'Interest earned', 80.0000, '{}'::jsonb)
     RETURNING id`,
    [A, stmt.rows[0]!.id, acc["1000"].A]);
  bankLineId = Number(line.rows[0]!.id);
  const bankE = await cA.query<{ id: string }>(
    "SELECT create_entry_from_line($1, $2, $3::jsonb, $4) AS id",
    [A, bankLineId, JSON.stringify({
      categoryAccountId: String(acc["4000"].A), memo: "Bank interest",
    }), uA]);
  bankEntryId = Number(bankE.rows[0]!.id);

  // --- intercompany posting (A -> B) -------------------------------------
  await db.admin.query(
    `INSERT INTO intercompany_pairs (from_company_id, to_company_id, due_from_account_id, due_to_account_id)
     VALUES ($1, $2, $3, $4)`,
    [A, db.companyB, acc["1200"].A, acc["2200"].B]);
  const ic = await cA.query<{ from_entry_id: string; to_entry_id: string }>(
    "SELECT * FROM post_intercompany($1, $2, 120.0000, '2024-06-01', 'Funding transfer to Beta', $3, $4, $5)",
    [A, db.companyB, uA, acc["1000"].A, acc["6000"].B]);
  icEntryId = Number(ic.rows[0]!.from_entry_id);
  icEntryIdB = Number(ic.rows[0]!.to_entry_id);

  // --- CSV quoting fixture: memo with an embedded CRLF and quotes ---------
  // Posted LAST (entry_no 17) but dated EARLIEST, so it sorts to the end of
  // every DESC listing without shifting any other entry's number. Touches
  // only OpEx/Equity so no account-mode fixture balance changes.
  m6Id = await insertDraftEntry(cA, A, uA, {
    date: "2023-05-01", memo: 'Note with\r\n"newline", quoted',
    lines: [
      { accountId: acc["6000"].A, debit: "7.7700" },
      { accountId: acc["3000"].A, credit: "7.7700" },
    ],
  });
  await postEntry(cA, A, m6Id);
}, 120_000);

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

// ---------------------------------------------------------------------------
// all-activity mode
// ---------------------------------------------------------------------------

describe("GET /api/c/register (all activity)", () => {
  it("returns every posted entry newest first with type labels, totals and status", async () => {
    const res = await inject(app, db.companyA, "/api/c/register?limit=200");
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.mode).toBe("all");
    expect(body.nextCursor).toBeNull();
    expect(body.rows).toHaveLength(17);

    // Stable DESC ordering across identical dates (entry_no breaks the tie).
    expect(body.rows.map((r: { id: string }) => r.id)).toEqual(
      [icEntryId, bankEntryId, rev4Id, m4Id, epEntryId, chkEntryId, billEntryId,
       payEntryId, invEntryId, m5Id, m3Id, m2Id, m1Id, u1Id, i2Id, i1Id, m6Id].map(String),
    );

    const byId = new Map<string, Record<string, unknown>>(
      body.rows.map((r: { id: string }) => [r.id, r]),
    );
    expect(byId.get(String(i1Id))).toMatchObject({
      date: "2023-06-01", type: "imported", typeLabel: "Imported history",
      memo: "Historic sale", ref: "101", amount: "1000.0000", status: "posted",
    });
    expect(byId.get(String(u1Id))).toMatchObject({
      type: "entry", typeLabel: "Entry", amount: "5.0000", status: "posted",
    });
    expect(byId.get(String(m1Id))).toMatchObject({
      type: "manual", typeLabel: "Manual entry", memo: 'Cash sale, with "quotes"',
      amount: "100.0000", status: "posted",
    });
    expect(byId.get(String(invEntryId))).toMatchObject({
      type: "invoice", typeLabel: "Invoice", amount: "500.0000", status: "posted",
      sourceLink: { kind: "invoice", id: String(invoiceId) },
    });
    expect(byId.get(String(payEntryId))).toMatchObject({
      type: "payment", typeLabel: "Payment received", amount: "300.0000",
      memo: "Payment from Acme",
      sourceLink: { kind: "payment", id: String(paymentId) },
    });
    expect(byId.get(String(billEntryId))).toMatchObject({
      type: "bill", typeLabel: "Bill", amount: "200.0000",
      sourceLink: { kind: "bill", id: String(billId) },
    });
    expect(byId.get(String(chkEntryId))).toMatchObject({
      type: "check", typeLabel: "Check", amount: "200.0000", ref: "1001",
      sourceLink: { kind: "bill-payment", id: String(chkPaymentId) },
    });
    expect(byId.get(String(epEntryId))).toMatchObject({
      type: "bill-payment", typeLabel: "Bill payment", amount: "50.0000",
      sourceLink: { kind: "bill-payment", id: String(epPaymentId) },
    });
    expect(byId.get(String(bankEntryId))).toMatchObject({
      type: "bank", typeLabel: "Bank entry", amount: "80.0000",
      sourceLink: { kind: "statement-line", id: String(bankLineId) },
    });
    expect(byId.get(String(icEntryId))).toMatchObject({
      type: "intercompany", typeLabel: "Intercompany", amount: "120.0000",
    });
    // reversal linkage: m4 is 'reversed', its reversal is a 'Reversal'
    expect(byId.get(String(m4Id))).toMatchObject({
      type: "manual", status: "reversed", amount: "75.0000",
    });
    expect(byId.get(String(rev4Id))).toMatchObject({
      type: "reversal", typeLabel: "Reversal", status: "reversal", amount: "75.0000",
    });
  });

  // Spec Part A's column is "Name/Memo" — the memo half alone loses who the
  // transaction was WITH, which for an A/R or A/P row is most of the point.
  it("returns the payee/customer name alongside the memo", async () => {
    const res = await inject(app, db.companyA, "/api/c/register?limit=200");
    const byId = new Map<string, Record<string, unknown>>(
      res.json().rows.map((r: { id: string }) => [r.id, r]),
    );

    // documents name their customer / vendor
    expect(byId.get(String(invEntryId))!.name).toBe("Acme Anvils");
    expect(byId.get(String(payEntryId))!.name).toBe("Acme Anvils");
    expect(byId.get(String(billEntryId))!.name).toBe("Bolt Supply Co");
    expect(byId.get(String(chkEntryId))!.name).toBe("Bolt Supply Co");
    expect(byId.get(String(epEntryId))!.name).toBe("Bolt Supply Co");
    // imported history carries the Name column its staging row kept
    expect(byId.get(String(i1Id))!.name).toBe("Historic Customer");
    // a hand-typed entry has nobody on the other side, and says so honestly
    expect(byId.get(String(m1Id))!.name).toBeNull();
    expect(byId.get(String(icEntryId))!.name).toBeNull();
  });

  it("pages stably across identical dates (keyset cursor, no dupes, no gaps)", async () => {
    const unpaged = await inject(app, db.companyA, "/api/c/register?limit=200");
    const allIds = unpaged.json().rows.map((r: { id: string }) => r.id);

    const walked: string[] = [];
    let cursor: string | null = null;
    let pages = 0;
    do {
      const url = `/api/c/register?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
      const res = await inject(app, db.companyA, url);
      expect(res.statusCode).toBe(200);
      const body = res.json();
      expect(body.rows.length).toBeLessThanOrEqual(2);
      walked.push(...body.rows.map((r: { id: string }) => r.id));
      cursor = body.nextCursor;
      pages += 1;
      expect(pages).toBeLessThan(20); // safety rail
    } while (cursor !== null);

    expect(walked).toEqual(allIds);
    expect(new Set(walked).size).toBe(walked.length);
  });

  it("filters by date range", async () => {
    const res = await inject(
      app, db.companyA, "/api/c/register?from=2024-01-01&to=2024-03-31&limit=200");
    const ids = res.json().rows.map((r: { id: string }) => r.id);
    expect(ids).toEqual(
      [epEntryId, chkEntryId, billEntryId, payEntryId, invEntryId,
       m5Id, m3Id, m2Id, m1Id].map(String),
    );
  });

  it("filters by every type key", async () => {
    const expectByType: Record<string, number[]> = {
      manual: [m4Id, m5Id, m3Id, m2Id, m1Id, m6Id],
      entry: [u1Id], // unrecognized source -> plain 'Entry', still filterable
      invoice: [invEntryId],
      payment: [payEntryId],
      bill: [billEntryId],
      check: [chkEntryId],
      "bill-payment": [epEntryId],
      bank: [bankEntryId],
      imported: [i2Id, i1Id],
      reversal: [rev4Id],
      intercompany: [icEntryId],
    };
    for (const [type, entryIds] of Object.entries(expectByType)) {
      const res = await inject(
        app, db.companyA, `/api/c/register?type=${encodeURIComponent(type)}&limit=200`);
      expect(res.statusCode).toBe(200);
      const ids = res.json().rows.map((r: { id: string }) => r.id).sort();
      expect(ids, `type=${type}`).toEqual(entryIds.map(String).sort());
    }
  });

  it("searches memo, names and refs, case-insensitively", async () => {
    // memo
    const coffee = await inject(app, db.companyA, "/api/c/register?q=COFFEE&limit=200");
    expect(coffee.json().rows.map((r: { id: string }) => r.id)).toEqual([String(m2Id)]);

    // customer name reaches both the invoice and its payment
    const acme = await inject(app, db.companyA, "/api/c/register?q=acme&limit=200");
    expect(acme.json().rows.map((r: { id: string }) => r.id).sort()).toEqual(
      [invEntryId, payEntryId].map(String).sort());

    // vendor name reaches the bill and both vendor payments
    const bolt = await inject(app, db.companyA, "/api/c/register?q=bolt&limit=200");
    expect(bolt.json().rows.map((r: { id: string }) => r.id).sort()).toEqual(
      [billEntryId, chkEntryId, epEntryId].map(String).sort());

    // imported name from staging provenance
    const hist = await inject(app, db.companyA, "/api/c/register?q=historic%20customer&limit=200");
    expect(hist.json().rows.map((r: { id: string }) => r.id)).toEqual([String(i1Id)]);

    // ref: check number
    const ck = await inject(app, db.companyA, "/api/c/register?q=1001&limit=200");
    expect(ck.json().rows.map((r: { id: string }) => r.id)).toEqual([String(chkEntryId)]);
  });

  it("filters by amount range against the entry total", async () => {
    const res = await inject(
      app, db.companyA, "/api/c/register?amountMin=100&amountMax=300&limit=200");
    const ids = res.json().rows.map((r: { id: string }) => r.id).sort();
    expect(ids).toEqual(
      [m1Id, payEntryId, billEntryId, chkEntryId, icEntryId].map(String).sort());
  });

  it("combines filters (type + q + dates)", async () => {
    const res = await inject(
      app, db.companyA,
      "/api/c/register?type=manual&q=coffee&from=2024-01-01&to=2024-12-31&limit=200");
    expect(res.json().rows.map((r: { id: string }) => r.id)).toEqual([String(m2Id)]);

    const none = await inject(
      app, db.companyA, "/api/c/register?type=bill-payment&amountMin=100&limit=200");
    expect(none.json().rows).toEqual([]);
  });

  it("rejects bad params in English", async () => {
    const badCursor = await inject(app, db.companyA, "/api/c/register?cursor=nonsense");
    expect(badCursor.statusCode).toBe(400);
    expect(badCursor.json().error).toBe(
      "That page marker isn't valid — start from the first page.");

    const badAmount = await inject(app, db.companyA, "/api/c/register?amountMin=abc");
    expect(badAmount.statusCode).toBe(400);
    expect(badAmount.json().error).toMatch(/amount like 125\.00/);

    const badLimit = await inject(app, db.companyA, "/api/c/register?limit=500");
    expect(badLimit.statusCode).toBe(400);

    const badType = await inject(app, db.companyA, "/api/c/register?type=bogus");
    expect(badType.statusCode).toBe(400);
  });

  it("rejects well-formed-but-impossible dates instead of 500ing", async () => {
    // shape-valid, calendar-impossible
    const feb30 = await inject(app, db.companyA, "/api/c/register?from=2024-02-30");
    expect(feb30.statusCode).toBe(400);
    expect(feb30.json().error).toMatch(/real calendar date/);

    // z.iso.date accepts year zero; Postgres has no year 0000
    const yearZero = await inject(app, db.companyA, "/api/c/register?to=0000-01-01");
    expect(yearZero.statusCode).toBe(400);
    expect(yearZero.json().error).toMatch(/real calendar date/);

    // a tampered cursor carrying an impossible date must 400, not 500
    const tampered = Buffer.from(JSON.stringify({ d: "2024-02-30", n: "1" }), "utf8")
      .toString("base64url");
    const badCursor = await inject(
      app, db.companyA, `/api/c/register?cursor=${encodeURIComponent(tampered)}`);
    expect(badCursor.statusCode).toBe(400);
    expect(badCursor.json().error).toBe(
      "That page marker isn't valid — start from the first page.");

    // CSV endpoint takes the same validation path
    const csvFeb30 = await inject(app, db.companyA, "/api/c/register.csv?from=2024-02-30");
    expect(csvFeb30.statusCode).toBe(400);
    expect(csvFeb30.json().error).toMatch(/real calendar date/);
  });
});

// ---------------------------------------------------------------------------
// account (register) mode
// ---------------------------------------------------------------------------

describe("GET /api/c/register (account mode)", () => {
  it("requires an accountId", async () => {
    const res = await inject(app, db.companyA, "/api/c/register?mode=account");
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toBe("Pick an account to view its register.");
  });

  it("404s for an unknown account", async () => {
    const res = await inject(app, db.companyA, "/api/c/register?mode=account&accountId=999999");
    expect(res.statusCode).toBe(404);
    expect(res.json().error).toBe("We couldn't find that account.");
  });

  it("shows an asset account's lines signed debit-positive with running balances", async () => {
    const res = await inject(
      app, db.companyA,
      `/api/c/register?mode=account&accountId=${db.accounts["1000"].A}&limit=200`);
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.mode).toBe("account");
    expect(body.account).toMatchObject({
      id: String(db.accounts["1000"].A), name: "Cash", type: "asset", debitNormal: true,
    });
    expect(body.rows).toHaveLength(15);

    // Newest first; running balance from the header table.
    const expected: Array<[number, string, string]> = [
      [icEntryId, "-120.0000", "1165.0000"],
      [bankEntryId, "80.0000", "1285.0000"],
      [rev4Id, "75.0000", "1205.0000"],
      [m4Id, "-75.0000", "1130.0000"],
      [epEntryId, "-50.0000", "1205.0000"],
      [chkEntryId, "-200.0000", "1255.0000"],
      [payEntryId, "300.0000", "1455.0000"],
      [m5Id, "20.0000", "1155.0000"], // second line of the split entry
      [m5Id, "30.0000", "1135.0000"], // first line (line-id tiebreak, DESC)
      [m3Id, "60.0000", "1105.0000"],
      [m2Id, "-40.0000", "1045.0000"],
      [m1Id, "100.0000", "1085.0000"],
      [u1Id, "5.0000", "985.0000"],
      [i2Id, "-20.0000", "980.0000"],
      [i1Id, "1000.0000", "1000.0000"],
    ];
    expect(
      body.rows.map((r: { entryId: string; amount: string; runningBalance: string }) =>
        [Number(r.entryId), r.amount, r.runningBalance]),
    ).toEqual(expected);

    // Dr/Cr toggle raw strings: exactly one side present per row.
    const icRow = body.rows[0];
    expect(icRow.debit).toBeUndefined();
    expect(icRow.credit).toBe("120.0000");
    const bankRow = body.rows[1];
    expect(bankRow.debit).toBe("80.0000");
    expect(bankRow.credit).toBeUndefined();

    // type labels carry through to line rows
    expect(icRow).toMatchObject({ type: "intercompany", typeLabel: "Intercompany" });
    expect(body.rows[5]).toMatchObject({
      type: "check", typeLabel: "Check", ref: "1001",
      sourceLink: { kind: "bill-payment", id: String(chkPaymentId) },
    });
  });

  // The register an accountant actually reconciles in must not show a
  // reversed entry as though it were live — and must say who each line was
  // with, exactly like all-activity mode does.
  it("carries status and the payee name onto account-register lines", async () => {
    const res = await inject(
      app, db.companyA,
      `/api/c/register?mode=account&accountId=${db.accounts["1000"].A}&limit=200`);
    const rows = res.json().rows as Record<string, unknown>[];
    const byEntry = new Map(rows.map((r) => [Number(r.entryId), r]));

    expect(byEntry.get(m4Id)).toMatchObject({ status: "reversed" });
    expect(byEntry.get(rev4Id)).toMatchObject({ status: "reversal" });
    expect(byEntry.get(m1Id)).toMatchObject({ status: "posted" });

    expect(byEntry.get(payEntryId)!.name).toBe("Acme Anvils");
    expect(byEntry.get(chkEntryId)!.name).toBe("Bolt Supply Co");
    expect(byEntry.get(i1Id)!.name).toBe("Historic Customer");
    expect(byEntry.get(m1Id)!.name).toBeNull();
  });

  it("shows an income account credit-positive and a liability account credit-positive", async () => {
    const rev = await inject(
      app, db.companyA,
      `/api/c/register?mode=account&accountId=${db.accounts["4000"].A}&limit=200`);
    const revBody = rev.json();
    expect(revBody.account).toMatchObject({ type: "income", debitNormal: false });
    expect(
      revBody.rows.map((r: { entryId: string; amount: string; runningBalance: string }) =>
        [Number(r.entryId), r.amount, r.runningBalance]),
    ).toEqual([
      [bankEntryId, "80.0000", "1795.0000"],
      [invEntryId, "500.0000", "1715.0000"],
      [m5Id, "50.0000", "1215.0000"],
      [m3Id, "60.0000", "1165.0000"],
      [m1Id, "100.0000", "1105.0000"],
      [u1Id, "5.0000", "1005.0000"],
      [i1Id, "1000.0000", "1000.0000"],
    ]);

    const ap = await inject(
      app, db.companyA,
      `/api/c/register?mode=account&accountId=${db.accounts["2000"].A}&limit=200`);
    const apBody = ap.json();
    expect(apBody.account).toMatchObject({ type: "liability", debitNormal: false });
    expect(
      apBody.rows.map((r: { entryId: string; amount: string; runningBalance: string }) =>
        [Number(r.entryId), r.amount, r.runningBalance]),
    ).toEqual([
      [epEntryId, "-50.0000", "-50.0000"],
      [chkEntryId, "-200.0000", "0.0000"],
      [billEntryId, "200.0000", "200.0000"],
    ]);
  });

  it("keeps the true running balance under a date filter (history before the filter counts)", async () => {
    const res = await inject(
      app, db.companyA,
      `/api/c/register?mode=account&accountId=${db.accounts["1000"].A}&from=2024-01-01&limit=200`);
    const body = res.json();
    expect(body.rows).toHaveLength(12); // 2023's three lines filtered out
    const oldest = body.rows[body.rows.length - 1];
    // m1: +100 on top of 2023's 985 — NOT 100.
    expect(oldest.entryId).toBe(String(m1Id));
    expect(oldest.runningBalance).toBe("1085.0000");
  });

  it("cross-checks the running balance against trial_balance() as of the same date", async () => {
    for (const [code, asOf] of [
      ["1000", "2024-03-31"],
      ["1000", "2024-12-31"],
      ["4000", "2024-12-31"],
      ["2000", "2024-12-31"],
    ] as const) {
      const accountId = db.accounts[code].A;
      const res = await inject(
        app, db.companyA,
        `/api/c/register?mode=account&accountId=${accountId}&to=${asOf}&limit=200`);
      const body = res.json();
      const newest = body.rows[0];
      const tb = await cA.query<{ balance: string; type: string }>(
        "SELECT balance, type FROM trial_balance($1, $2) WHERE account_id = $3",
        [db.companyA, asOf, accountId]);
      const raw = tb.rows[0]!.balance; // debit-positive storage convention
      const debitNormal = tb.rows[0]!.type === "asset" || tb.rows[0]!.type === "expense";
      // presentation flip for credit-normal accounts, in SQL terms: -raw
      const natural = debitNormal
        ? raw
        : raw.startsWith("-") ? raw.slice(1) : raw === "0.0000" ? raw : `-${raw}`;
      expect(newest.runningBalance, `${code} as of ${asOf}`).toBe(natural);
    }
  });

  it("pages stably including within a single entry's multiple lines", async () => {
    const unpaged = await inject(
      app, db.companyA,
      `/api/c/register?mode=account&accountId=${db.accounts["1000"].A}&limit=200`);
    const allRows = unpaged.json().rows.map(
      (r: { entryId: string; amount: string }) => `${r.entryId}:${r.amount}`);

    const walked: string[] = [];
    let cursor: string | null = null;
    let pages = 0;
    do {
      const url = `/api/c/register?mode=account&accountId=${db.accounts["1000"].A}&limit=3` +
        (cursor ? `&cursor=${encodeURIComponent(cursor)}` : "");
      const res = await inject(app, db.companyA, url);
      expect(res.statusCode).toBe(200);
      const body = res.json();
      walked.push(...body.rows.map(
        (r: { entryId: string; amount: string }) => `${r.entryId}:${r.amount}`));
      cursor = body.nextCursor;
      pages += 1;
      expect(pages).toBeLessThan(20);
    } while (cursor !== null);

    expect(walked).toEqual(allRows);
    expect(new Set(walked).size).toBe(walked.length);
  });

  it("resumes MID-ENTRY: a page boundary between two lines of the same entry", async () => {
    const unpaged = await inject(
      app, db.companyA,
      `/api/c/register?mode=account&accountId=${db.accounts["1000"].A}&limit=200`);
    const allRows = unpaged.json().rows.map(
      (r: { entryId: string; amount: string; runningBalance: string }) =>
        [r.entryId, r.amount, r.runningBalance]);

    // limit=1 puts a page boundary between EVERY pair of rows — including
    // between the split entry's two Cash lines (the cursor's line-id
    // component is what makes the resume unambiguous there).
    const walked: Array<[string, string, string]> = [];
    let cursor: string | null = null;
    let pages = 0;
    do {
      const url = `/api/c/register?mode=account&accountId=${db.accounts["1000"].A}&limit=1` +
        (cursor ? `&cursor=${encodeURIComponent(cursor)}` : "");
      const res = await inject(app, db.companyA, url);
      expect(res.statusCode).toBe(200);
      const body = res.json();
      expect(body.rows).toHaveLength(1);
      const r = body.rows[0];
      walked.push([r.entryId, r.amount, r.runningBalance]);
      cursor = body.nextCursor;
      pages += 1;
      expect(pages).toBeLessThan(30);
    } while (cursor !== null);

    // no dupes, no gaps, balances identical to the unpaged result
    expect(walked).toEqual(allRows);

    // and the walk really did cross between m5's two lines on separate pages
    const m5Positions = walked
      .map(([entryId], i) => (entryId === String(m5Id) ? i : -1))
      .filter((i) => i >= 0);
    expect(m5Positions).toHaveLength(2);
    expect(m5Positions[1]).toBe(m5Positions[0] + 1); // adjacent rows...
    expect(walked[m5Positions[0]]).toEqual([String(m5Id), "20.0000", "1155.0000"]);
    expect(walked[m5Positions[1]]).toEqual([String(m5Id), "30.0000", "1135.0000"]);
  });

  it("filters by abs line amount and by type in account mode", async () => {
    const res = await inject(
      app, db.companyA,
      `/api/c/register?mode=account&accountId=${db.accounts["1000"].A}&amountMin=75&amountMax=120&limit=200`);
    const pairs = res.json().rows.map(
      (r: { entryId: string; amount: string }) => [Number(r.entryId), r.amount]);
    // |amount| in [75, 120]: ic 120, bank 80, rev4 75, m4 75, m1 100
    expect(pairs).toEqual([
      [icEntryId, "-120.0000"],
      [bankEntryId, "80.0000"],
      [rev4Id, "75.0000"],
      [m4Id, "-75.0000"],
      [m1Id, "100.0000"],
    ]);

    const typed = await inject(
      app, db.companyA,
      `/api/c/register?mode=account&accountId=${db.accounts["1000"].A}&type=payment&limit=200`);
    expect(typed.json().rows.map((r: { entryId: string }) => r.entryId))
      .toEqual([String(payEntryId)]);
  });

  it("rejects an all-mode cursor used in account mode", async () => {
    const first = await inject(app, db.companyA, "/api/c/register?limit=2");
    const allCursor: string = first.json().nextCursor;
    const res = await inject(
      app, db.companyA,
      `/api/c/register?mode=account&accountId=${db.accounts["1000"].A}&cursor=${encodeURIComponent(allCursor)}`);
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toBe("That page marker isn't valid — start from the first page.");
  });
});

// ---------------------------------------------------------------------------
// CSV export
// ---------------------------------------------------------------------------

describe("GET /api/c/register.csv", () => {
  it("matches the JSON rows for the same filter (all mode), with RFC quoting", async () => {
    // from=2023-06-01 excludes the embedded-CRLF memo fixture (m6) so this
    // test's naive line-splitting stays valid; the CRLF row gets its own
    // dedicated test below.
    const json = await inject(app, db.companyA, "/api/c/register?from=2023-06-01&limit=200");
    const jsonRows = json.json().rows;

    const res = await inject(app, db.companyA, "/api/c/register.csv?from=2023-06-01");
    expect(res.statusCode).toBe(200);
    expect(res.headers["content-type"]).toMatch(/^text\/csv/);
    expect(res.headers["content-disposition"]).toBe('attachment; filename="transactions.csv"');

    const lines = res.body.split("\r\n").filter((l) => l !== "");
    expect(lines[0]).toBe("Date,No.,Type,Name,Memo,Ref,Amount,Status");
    expect(lines).toHaveLength(1 + jsonRows.length);

    // Row-by-row equality with the JSON payload. Every seeded amount is whole
    // cents, so the plain-2dp form is the 4dp string minus its "00" tail.
    for (let i = 0; i < jsonRows.length; i++) {
      const r = jsonRows[i];
      const csvLine = lines[i + 1];
      expect(csvLine.startsWith(`${r.date},${r.entryNo},`)).toBe(true);
      expect(csvLine).toContain(r.typeLabel);
      expect(csvLine).toContain(`,${r.amount.slice(0, -2)},`);
      expect(csvLine.endsWith(`,${r.status}`)).toBe(true);
    }

    // commas + embedded quotes are RFC-quoted
    expect(res.body).toContain('"Cash sale, with ""quotes"""');
    expect(res.body).toContain('"Coffee, beans"');
    // plain 2dp money, no NUMERIC(19,4) tails
    expect(res.body).toContain(",1000.00,");
    expect(res.body).not.toContain("0000");
  });

  it("matches the JSON rows in account mode, including running balances", async () => {
    const accountId = db.accounts["1000"].A;
    const json = await inject(
      app, db.companyA, `/api/c/register?mode=account&accountId=${accountId}&limit=200`);
    const jsonRows = json.json().rows;

    const res = await inject(
      app, db.companyA, `/api/c/register.csv?mode=account&accountId=${accountId}`);
    expect(res.statusCode).toBe(200);
    // filename is the slugified ACCOUNT NAME (account "1000" is "Cash"),
    // with no date suffix when no range is applied
    expect(res.headers["content-disposition"]).toBe(
      'attachment; filename="cash.csv"');

    const lines = res.body.split("\r\n").filter((l: string) => l !== "");
    expect(lines[0]).toBe(
      "Date,No.,Type,Name,Memo,Ref,Amount,Debit,Credit,Running balance,Status");
    expect(lines).toHaveLength(1 + jsonRows.length);

    // first data row is the intercompany credit: -120 running 1165 (no name —
    // an intercompany posting is between the books, not with anybody)
    expect(lines[1]).toBe(
      "2024-06-01,16,Intercompany,,Funding transfer to Beta,,-120.00,,120.00,1165.00,posted");
    // last data row is the earliest history line — its Name comes from the
    // staging row the import posted it from
    expect(lines[lines.length - 1]).toBe(
      "2023-06-01,1,Imported history,Historic Customer,Historic sale,101," +
      "1000.00,1000.00,,1000.00,posted");
  });

  it("names the account-mode file after the account and the applied date range", async () => {
    const accountId = db.accounts["1100"].A; // "Accounts Receivable"
    const both = await inject(
      app, db.companyA,
      `/api/c/register.csv?mode=account&accountId=${accountId}&from=2024-01-01&to=2024-12-31`);
    expect(both.statusCode).toBe(200);
    expect(both.headers["content-disposition"]).toBe(
      'attachment; filename="accounts-receivable-2024-01-01-to-2024-12-31.csv"');

    const fromOnly = await inject(
      app, db.companyA,
      `/api/c/register.csv?mode=account&accountId=${accountId}&from=2024-01-01`);
    expect(fromOnly.headers["content-disposition"]).toBe(
      'attachment; filename="accounts-receivable-from-2024-01-01.csv"');

    const toOnly = await inject(
      app, db.companyA,
      `/api/c/register.csv?mode=account&accountId=${accountId}&to=2024-12-31`);
    expect(toOnly.headers["content-disposition"]).toBe(
      'attachment; filename="accounts-receivable-through-2024-12-31.csv"');
  });

  it("slugifies hostile account names into inert Content-Disposition filenames", () => {
    // header-safe: no quotes, spaces, CRLF, or 8-bit survive the slug
    expect(accountCsvFilename('Petty "Cash" & Misc.')).toBe("petty-cash-misc.csv");
    expect(accountCsvFilename("  Chase Business Checking ")).toBe(
      "chase-business-checking.csv");
    expect(accountCsvFilename('"\r\n')).toBe("account.csv"); // nothing usable -> fallback
    expect(accountCsvFilename("Café Déjà Vu")).toBe("caf-d-j-vu.csv");
    expect(accountCsvFilename("Cash", "2023-01-01", "2023-12-31")).toBe(
      "cash-2023-01-01-to-2023-12-31.csv");
  });

  it("applies the same filters as the JSON endpoint", async () => {
    const res = await inject(
      app, db.companyA, "/api/c/register.csv?type=manual&q=coffee");
    const lines = res.body.split("\r\n").filter((l: string) => l !== "");
    expect(lines).toHaveLength(2);
    expect(lines[1]).toContain('"Coffee, beans"');
  });

  it("quotes a memo containing an embedded CRLF and quotes per RFC 4180", async () => {
    const res = await inject(app, db.companyA, "/api/c/register.csv?q=newline");
    expect(res.statusCode).toBe(200);
    expect(res.body).toBe(
      "Date,No.,Type,Name,Memo,Ref,Amount,Status\r\n" +
      '2023-05-01,17,Manual entry,,"Note with\r\n""newline"", quoted",,7.77,posted\r\n',
    );
  });

  it("refuses with plain English when the filtered set exceeds the row cap", async () => {
    const capped = await buildTestApp(db.dbName, { registerCsvMaxRows: 3 });
    try {
      const res = await inject(capped, db.companyA, "/api/c/register.csv");
      expect(res.statusCode).toBe(400);
      expect(res.json().error).toBe(
        "That's too many rows for one file — narrow the date range.");

      // a filter that fits under the cap still works
      const ok = await inject(capped, db.companyA, "/api/c/register.csv?type=reversal");
      expect(ok.statusCode).toBe(200);
    } finally {
      await capped.close();
    }
  });
});

// ---------------------------------------------------------------------------
// tenant isolation
// ---------------------------------------------------------------------------

describe("register RLS", () => {
  it("company B sees only its own rows — none of company A's", async () => {
    const res = await inject(app, db.companyB, "/api/c/register?limit=200");
    expect(res.statusCode).toBe(200);
    const rows = res.json().rows;
    // B's entire ledger is its one intercompany leg.
    expect(rows).toHaveLength(1);
    expect(rows[0]).toMatchObject({
      id: String(icEntryIdB), type: "intercompany", amount: "120.0000",
    });
    const aIds = new Set(
      [i1Id, i2Id, u1Id, m1Id, m2Id, m3Id, m5Id, m4Id, invEntryId, payEntryId,
       billEntryId, chkEntryId, epEntryId, rev4Id, bankEntryId, icEntryId].map(String));
    for (const r of rows) expect(aIds.has(r.id)).toBe(false);
  });

  it("company B cannot open company A's account register (404, like unknown)", async () => {
    const res = await inject(
      app, db.companyB, `/api/c/register?mode=account&accountId=${db.accounts["1000"].A}`);
    expect(res.statusCode).toBe(404);
  });

  it("company B's CSV is equally empty of A's data", async () => {
    const res = await inject(app, db.companyB, "/api/c/register.csv");
    expect(res.statusCode).toBe(200);
    const lines = res.body.split("\r\n").filter((l: string) => l !== "");
    expect(lines).toHaveLength(2); // header + B's one intercompany leg
    expect(lines[1]).toContain("Intercompany");
  });
});
