// Bank statement import + matching + reconcile API — Task 3 of
// docs/superpowers/plans/2026-08-01-bank-rec.md (spec:
// docs/superpowers/specs/2026-08-01-bank-rec-design.md is binding). Every
// route lives under /api/c/ (company scope from X-Company-Id, enforced by
// companyContextPlugin) and every query/insert runs inside db.withCompany()
// so RLS is the real guard (CLAUDE.md §4/§12), never just the WHERE clauses
// written here. created_by/p_user is always the company's owner user (no
// login in v1 — same resolveOwner pattern as every other route module).
//
// Core loop (spec's own words): import a statement for a money account ->
// every line becomes matched (engine, exactly-one candidate — see
// server/src/lib/statementMatch.ts), suggested (one click "Confirm"), or new
// (categorize on the spot -> posts an entry) -> reconcile once every line is
// settled and the statement's ending balance equals the account's book
// balance for the period. The matching ENGINE only ever calls
// suggest_statement_line() (db/migrations/0011_bank_rec.sql) — it never
// calls match_statement_line() itself, so a suggestion is never mistaken for
// a confirmed match; only a user action (this file's /confirm, /confirm-all,
// or /match routes) ever calls match_statement_line(). "Auto-CONFIRM never
// happens without the user" (design spec).
//
// Multipart handling: unlike server/src/imports.ts's receiveXlsxUpload
// (which only ever reads request.file(), since those routes take no other
// fields), the import route here needs BOTH a file AND form fields
// (accountId, and CSV mapping fields on a first-time import) in the SAME
// multipart request — so readMultipart() below iterates request.parts()
// directly (file + field parts interleaved in any order) rather than calling
// request.file(). Saved files land under the SAME imports/uploads/ directory
// as imports.ts, with the identical randomUUID-suffixed filename convention
// (see that file's header for why the uuid is load-bearing, not decoration:
// two uploads landing in the same millisecond must never share a path).
//
// Format detection is content-based, never by filename/extension: sniffFormat()
// reuses parseOfx's own "<OFX" tag search — if it's not there, it's CSV.
//
// CSV mapping: bank_csv_maps is keyed (company_id, money_account_id). If a
// saved mapping exists for the account, POST .../bank/statements uses it and
// any mapping fields submitted in the SAME import request are ignored. If no
// saved mapping exists, the import request must carry mapping fields (the
// UI's mapping step, informed by a prior POST /api/c/bank/csv-preview call,
// supplies them) — those are validated via csv-map-logic.ts's own
// validateMapping(), applied, and then upserted into bank_csv_maps so the
// NEXT import for this account doesn't need them again.
//
// Recovery from a WRONG first mapping (review fast-follow — the design spec's
// own /remap route, deferred out of Task 3's original checklist, implemented
// here): GET/POST /api/c/bank/accounts/:id/csv-map read/replace the saved
// mapping directly, independent of any import. This is the only path that
// changes a saved mapping outside of "no mapping existed yet" — the import
// route itself still never overwrites an EXISTING saved mapping from fields
// riding along with a statement upload; re-mapping is a deliberate, separate
// action, so a stray or stale mapping field on a routine re-import can never
// silently rewrite the account's layout out from under the user.
//
// Dedupe (CLAUDE.md §8's ADP idempotency lesson, generalized to bank
// statements): a line with a FITID (OFX) is deduped via
// bank_statement_lines_fitid_unique's own partial unique index (an
// ON CONFLICT ... DO NOTHING per row) — this is race-safe by construction,
// unlike a SELECT-then-INSERT heuristic. A line with no FITID (every CSV
// line, and any OFX transaction that happens to omit one) is deduped against
// EXISTING lines already on the account by (posted_date, amount, normalized
// description) — normalized meaning trimmed/lowercased/whitespace-collapsed.
//
// This is MULTIPLICITY-aware, not membership-aware — a plain Set of existing
// keys is the wrong data structure here and was a real bug (fixed in
// review): existing lines are loaded once, up front, into a
// Map<key, remainingCount> (one count per distinct key, incremented once per
// existing row that produces it). Each incoming line without a FITID that
// matches a key with remainingCount > 0 consumes ONE count (decrement, treat
// as a duplicate, skip) — once a key's count reaches zero, any FURTHER
// incoming line with that same key is genuinely new and imports. Concretely:
// an account already has one $5 coffee charge on 2026-01-05; a later,
// overlapping re-export contains TWO lines that look identical to it (one is
// truly the same transaction re-appearing in the overlap window, the other
// is a genuinely different $5 coffee that same day that just happens to
// collide on the heuristic key) — remainingCount starts at 1, so the first
// incoming line consumes it and is skipped, and the SECOND is correctly
// imported rather than also silently dropped (a membership Set would have
// matched and skipped both, losing a real transaction).
//
// Deliberately NOT deduped against OTHER lines in the SAME import batch:
// within one file, incoming lines are never added to (or checked against
// each other via) the multiplicity map — only pre-existing DB rows seed it.
// Two genuinely identical transactions on the same day within one file
// (e.g. two identical $20 charges) are both distinct and both import, and
// the column comment on bank_statement_lines.line_no calls this out
// explicitly as "the dedupe tiebreak" — line_no (the file's own row order)
// is what keeps them apart, not a same-batch heuristic collision.
//
// Money in `raw` jsonb: for OFX, the full parsed OfxTransaction (fitid, date,
// amount, name, memo, type) — a faithful, lossless record of what was
// parsed. For CSV, the mapped CsvMappedLine (postedDate/description/amount)
// rather than the pre-mapping raw cells — a deliberate, documented choice:
// the uploaded FILE ITSELF is also saved verbatim under imports/uploads/
// (CLAUDE.md §12's "keep the raw source row... forever" is satisfied at that
// level, the true source of record), so this column's job is just to record
// what this line's own parse produced, not to duplicate the whole file a
// second time per row.

import { randomUUID, createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { FastifyInstance, FastifyRequest } from "fastify";
import type { PoolClient } from "pg";
import { z } from "zod";
import type { Db } from "./db.js";
import { mapPgError } from "./errors.js";
import { findOwnerId } from "../../tools/lib/journal-load.js";
import { blankToUndefined } from "./lib/textNormalize.js";
import { centsFromAmountString } from "./lib/amountWords.js";
import { runMatchingEngine, findCandidateEntries, type EngineResult } from "./lib/statementMatch.js";
import { parseOfx } from "../../tools/ofx-logic.js";
import {
  parseCsvRows, detectCsv, applyCsvMapping, validateMapping, type CsvMapping,
} from "../../tools/csv-map-logic.js";

const serverDir = path.dirname(fileURLToPath(import.meta.url));
const UPLOAD_DIR = path.resolve(serverDir, "../../imports/uploads");

// No separate upload-size constant here: app.ts registers @fastify/multipart
// once, app-wide, with imports.ts's MAX_UPLOAD_BYTES (25MB) as its
// plugin-level fileSize limit — that single registration already governs
// every multipart route in the app, including the two below.

// ---------------------------------------------------------------------------
// shared validation + helpers
// ---------------------------------------------------------------------------

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

const isoDateSchema = z.iso.date({ error: "must be a date like 2024-01-15 (YYYY-MM-DD)" });

// Ending balances (and the difference we report back) may legitimately be
// zero or negative (a line of credit, an overdrawn account) — unlike most
// amount fields in this codebase, no ">0" refine here.
const signedAmountSchema = z
  .string()
  .regex(/^-?(0|[1-9]\d*)(\.\d{1,2})?$/, "Enter an amount like 1234.56 (or -1234.56).");

const statusEnum = z.enum(["unmatched", "suggested", "matched", "created", "excluded"]);

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

const NOT_FOUND_STATEMENT = { error: "We couldn't find that statement." };
const NOT_FOUND_LINE = { error: "We couldn't find that statement line." };
const NOT_FOUND_ACCOUNT = { status: 422, error: "Pick a money account for this statement." };
// Same "not a money account" condition as NOT_FOUND_ACCOUNT above, but for a
// route reached by URL param (GET .../accounts/:id/book-balance) rather than
// a form submission — a 404 ("we couldn't find that") is the honest status
// there, not a 422 ("fix your form input").
const NOT_FOUND_MONEY_ACCOUNT_404 = { status: 404, error: "We couldn't find that account." };

const NO_OWNER_ERROR = {
  status: 500,
  error: "This company doesn't have an owner user on file, so this can't be attributed. This shouldn't happen.",
};

async function resolveOwner(
  db: Db, companyId: string, log: { error: (obj: unknown, msg: string) => void },
): Promise<string | null> {
  try {
    return await findOwnerId(db.adminPool, companyId);
  } catch (err) {
    log.error({ err, companyId }, "resolveOwner: findOwnerId failed");
    return null;
  }
}

/** Formats a NUMERIC(19,4)-shaped or plain decimal string as "$1,234.56" —
 *  pure string surgery, never a parsed JS number (CLAUDE.md §2). */
function formatUsd(amount: string): string {
  const negative = amount.startsWith("-");
  const unsigned = negative ? amount.slice(1) : amount;
  const [wholeRaw, fracRaw = ""] = unsigned.split(".");
  const whole = wholeRaw!.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  const frac = (fracRaw + "00").slice(0, 2);
  return `${negative ? "-" : ""}$${whole}.${frac}`;
}

/** bigint-cents -> signed decimal string ("-12.34"), mirroring tools/
 *  journal-logic.ts's centsToStr but for the bigint centsFromAmountString
 *  server/src/lib/amountWords.ts already exports (reused below for the
 *  reconcile difference, the one place this file does money arithmetic in
 *  JS — purely presentational; reconcile_statement()'s own exact-equality
 *  check is the real enforcement, per this file's header). */
function centsToStr(cents: bigint): string {
  const negative = cents < 0n;
  const abs = negative ? -cents : cents;
  const whole = abs / 100n;
  const frac = (abs % 100n).toString().padStart(2, "0");
  return `${negative ? "-" : ""}${whole.toString()}.${frac}`;
}

/** Trimmed/lowercased/whitespace-collapsed — the "normalized description"
 *  half of the CSV-line dedupe key (see file header). */
function normalizeDescription(text: string | null): string {
  return (text ?? "").trim().toLowerCase().replace(/\s+/g, " ");
}

/**
 * Canonicalizes a signed decimal amount string to the exact text form
 * Postgres returns for a NUMERIC(19,4) column ("-1000.0000", 4 decimal
 * places, no thousands separators) — via centsFromAmountString/bigint, never
 * a parsed JS float (CLAUDE.md §2). This matters because the parsers
 * (tools/ofx-logic.ts, tools/csv-map-logic.ts) always emit exactly 2 decimal
 * places ("-1000.00"), while bank_statement_lines.amount read back from the
 * database always has 4 ("-1000.0000") — comparing those two strings
 * directly would never find a match even for numerically identical amounts,
 * silently breaking the heuristic dedupe entirely. Both the existing-lines
 * lookup and each newly parsed line's own key are run through this so the
 * comparison is on a single canonical representation.
 */
function toDbAmountFormat(amount: string): string {
  const cents = centsFromAmountString(amount);
  const negative = cents < 0n;
  const abs = negative ? -cents : cents;
  const whole = abs / 100n;
  const frac = (abs % 100n).toString().padStart(2, "0");
  return `${negative ? "-" : ""}${whole.toString()}.${frac}00`;
}

function sniffFormat(text: string): "ofx" | "csv" {
  return /<ofx[\s>]/i.test(text) || /^OFXHEADER:/im.test(text) ? "ofx" : "csv";
}

async function saveUpload(companyId: string, buffer: Buffer, format: "ofx" | "csv"): Promise<void> {
  await mkdir(UPLOAD_DIR, { recursive: true });
  // uuid in the filename is load-proof against two concurrent uploads for
  // the same company landing in the same millisecond — see imports.ts's
  // receiveXlsxUpload for the identical rationale (its own regression test
  // fired 4/5 trials before that fix).
  const filePath = path.join(
    UPLOAD_DIR, `${companyId}-${Date.now()}-${randomUUID()}-bank.${format}`,
  );
  await writeFile(filePath, buffer);
}

function sha256(buffer: Buffer): string {
  return createHash("sha256").update(buffer).digest("hex");
}

// ---------------------------------------------------------------------------
// multipart reading (file + fields, either order) — see file header
// ---------------------------------------------------------------------------

interface MultipartRead {
  buffer: Buffer;
  filename: string;
  fields: Record<string, string>;
}
interface MultipartErr { status: number; error: string }

function isMultipartErr(u: MultipartRead | MultipartErr): u is MultipartErr {
  return "error" in u;
}

function mapMultipartError(err: unknown): MultipartErr | null {
  const code = (err as { code?: string } | undefined)?.code;
  if (code === "FST_REQ_FILE_TOO_LARGE") {
    return { status: 413, error: "That file is larger than the 25MB limit." };
  }
  if (code === "FST_INVALID_MULTIPART_CONTENT_TYPE") {
    return { status: 400, error: "Please upload the file as a multipart/form-data request." };
  }
  if (code === "FST_PARTS_LIMIT" || code === "FST_FILES_LIMIT" || code === "FST_FIELDS_LIMIT") {
    return { status: 400, error: "That upload had too many parts — please upload just the one file." };
  }
  return null;
}

/** Reads every part of a multipart request into one file buffer (field name
 *  "file") plus a plain string->string map of every other field, regardless
 *  of the order they were sent in. requireFile=false is used by nothing yet
 *  but kept general; every route in this file requires a file. */
async function readMultipart(request: FastifyRequest): Promise<MultipartRead | MultipartErr> {
  let buffer: Buffer | null = null;
  let filename: string | null = null;
  const fields: Record<string, string> = {};
  try {
    for await (const part of request.parts()) {
      if (part.type === "file") {
        if (part.fieldname !== "file") {
          await part.toBuffer().catch(() => {});
          continue;
        }
        if (buffer !== null) {
          return { status: 400, error: "Please upload just one file." };
        }
        buffer = await part.toBuffer();
        filename = part.filename;
      } else {
        fields[part.fieldname] = typeof part.value === "string" ? part.value : String(part.value ?? "");
      }
    }
  } catch (err) {
    const mapped = mapMultipartError(err);
    if (mapped) return mapped;
    throw err;
  }
  if (buffer === null || !filename) {
    return { status: 400, error: "Please choose a file to upload." };
  }
  return { buffer, filename, fields };
}

// ---------------------------------------------------------------------------
// account / statement / line lookups
// ---------------------------------------------------------------------------

interface MoneyAccount { id: string; name: string }

async function lookupMoneyAccount(
  client: PoolClient, companyId: string, accountId: string,
): Promise<MoneyAccount | null> {
  const res = await client.query<MoneyAccount>(
    "SELECT id, name FROM accounts WHERE company_id = $1 AND id = $2 AND type = 'asset' AND active = true",
    [companyId, accountId],
  );
  return res.rows[0] ?? null;
}

interface AnyAccount { id: string; name: string }

async function lookupAnyActiveAccount(
  client: PoolClient, companyId: string, accountId: string,
): Promise<AnyAccount | null> {
  const res = await client.query<AnyAccount>(
    "SELECT id, name FROM accounts WHERE company_id = $1 AND id = $2 AND active = true",
    [companyId, accountId],
  );
  return res.rows[0] ?? null;
}

interface StatementRow {
  id: string;
  moneyAccountId: string;
  moneyAccountName: string;
  filename: string;
  format: string;
  periodStart: string | null;
  periodEnd: string | null;
  endingBalance: string | null;
  status: string;
}

async function lookupStatement(
  client: PoolClient, companyId: string, statementId: string,
): Promise<StatementRow | null> {
  const res = await client.query<{
    id: string; money_account_id: string; money_account_name: string; filename: string; format: string;
    period_start: string | null; period_end: string | null; ending_balance: string | null; status: string;
  }>(
    `SELECT bs.id, bs.money_account_id, a.name AS money_account_name, bs.filename, bs.format,
            bs.period_start::text AS period_start, bs.period_end::text AS period_end,
            bs.ending_balance, bs.status
       FROM bank_statements bs
       JOIN accounts a ON a.company_id = bs.company_id AND a.id = bs.money_account_id
      WHERE bs.company_id = $1 AND bs.id = $2`,
    [companyId, statementId],
  );
  const r = res.rows[0];
  if (!r) return null;
  return {
    id: r.id, moneyAccountId: r.money_account_id, moneyAccountName: r.money_account_name,
    filename: r.filename, format: r.format, periodStart: r.period_start, periodEnd: r.period_end,
    endingBalance: r.ending_balance, status: r.status,
  };
}

interface LineRow {
  id: string;
  statementId: string;
  moneyAccountId: string;
  moneyAccountName: string;
  postedDate: string;
  description: string | null;
  amount: string;
  status: string;
  matchedEntryId: string | null;
  suggestionEntryId: string | null;
  excludedReason: string | null;
}

async function lookupLine(
  client: PoolClient, companyId: string, lineId: string,
): Promise<LineRow | null> {
  const res = await client.query<{
    id: string; statement_id: string; money_account_id: string; money_account_name: string;
    posted_date: string; description: string | null; amount: string; status: string;
    matched_entry_id: string | null; suggestion_entry_id: string | null; excluded_reason: string | null;
  }>(
    `SELECT l.id, l.statement_id, l.money_account_id, a.name AS money_account_name,
            l.posted_date::text AS posted_date, l.description, l.amount, l.status,
            l.matched_entry_id, l.suggestion_entry_id, l.excluded_reason
       FROM bank_statement_lines l
       JOIN accounts a ON a.company_id = l.company_id AND a.id = l.money_account_id
      WHERE l.company_id = $1 AND l.id = $2`,
    [companyId, lineId],
  );
  const r = res.rows[0];
  if (!r) return null;
  return {
    id: r.id, statementId: r.statement_id, moneyAccountId: r.money_account_id,
    moneyAccountName: r.money_account_name, postedDate: r.posted_date, description: r.description,
    amount: r.amount, status: r.status, matchedEntryId: r.matched_entry_id,
    suggestionEntryId: r.suggestion_entry_id, excludedReason: r.excluded_reason,
  };
}

function lineOut(r: {
  id: string; line_no: number; posted_date: string; description: string | null; amount: string;
  fitid: string | null; status: string; matched_entry_id: string | null; suggestion_entry_id: string | null;
  excluded_reason: string | null;
  matched_entry_no?: string | null; matched_entry_date?: string | null; matched_entry_memo?: string | null;
  suggestion_entry_no?: string | null; suggestion_entry_date?: string | null; suggestion_entry_memo?: string | null;
}) {
  return {
    id: r.id,
    lineNo: r.line_no,
    postedDate: r.posted_date,
    description: r.description,
    amount: r.amount,
    fitid: r.fitid,
    status: r.status,
    matchedEntryId: r.matched_entry_id,
    matchedEntry: r.matched_entry_id
      ? { entryNo: r.matched_entry_no ?? null, date: r.matched_entry_date ?? null, memo: r.matched_entry_memo ?? null }
      : null,
    suggestionEntryId: r.suggestion_entry_id,
    suggestionEntry: r.suggestion_entry_id
      ? { entryNo: r.suggestion_entry_no ?? null, date: r.suggestion_entry_date ?? null, memo: r.suggestion_entry_memo ?? null }
      : null,
    excludedReason: r.excluded_reason,
  };
}

// ---------------------------------------------------------------------------
// CSV mapping-fields schema (multipart form fields, all strings)
// ---------------------------------------------------------------------------

const optionalIntField = z.preprocess(
  (v) => (v === "" || v === undefined ? undefined : v),
  z.coerce.number({ error: "must be a whole number" }).int().min(0).optional(),
);
const requiredIntField = z.preprocess(
  (v) => (v === "" || v === undefined ? undefined : v),
  z.coerce.number({ error: "must be a whole number" }).int().min(0),
);
const boolField = z.preprocess(
  (v) => v === "true", z.boolean(),
);

const csvMappingFieldsSchema = z.object({
  headerRows: requiredIntField,
  dateCol: requiredIntField,
  descriptionCol: requiredIntField,
  amountMode: z.enum(["signed", "debit-credit"], { error: 'amountMode must be "signed" or "debit-credit".' }),
  amountCol: optionalIntField,
  debitCol: optionalIntField,
  creditCol: optionalIntField,
  negateAmount: boolField,
  dateFormat: z.enum(["MDY", "DMY", "YMD"], { error: "dateFormat must be MDY, DMY, or YMD." }),
});

// GET/POST /api/c/bank/accounts/:id/csv-map are plain JSON (no file rides
// along), so the mapping fields arrive as real JSON types, not multipart
// strings — a separate schema from csvMappingFieldsSchema above rather than
// reusing its string-coercing preprocessors. columnCount rides along ONLY
// for validateMapping()'s own out-of-range check (there's no uploaded file
// here to derive it from, so the client — which already has it from its own
// prior GET csv-preview call — supplies it); it is never itself stored.
const csvMapJsonBodySchema = z.object({
  columnCount: z.number().int().min(1, "columnCount must be at least 1."),
  headerRows: z.number().int().min(0, "headerRows must be a non-negative whole number."),
  dateCol: z.number().int().min(0, "dateCol must be a non-negative whole number."),
  descriptionCol: z.number().int().min(0, "descriptionCol must be a non-negative whole number."),
  amountMode: z.enum(["signed", "debit-credit"], { error: 'amountMode must be "signed" or "debit-credit".' }),
  amountCol: z.number().int().min(0).optional(),
  debitCol: z.number().int().min(0).optional(),
  creditCol: z.number().int().min(0).optional(),
  negateAmount: z.boolean().optional(),
  dateFormat: z.enum(["MDY", "DMY", "YMD"], { error: "dateFormat must be MDY, DMY, or YMD." }),
});

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

export function registerBankRoutes(fastify: FastifyInstance, db: Db): void {
  // -------------------------------------------------------------------------
  // POST /api/c/bank/csv-preview — multipart file only, stateless, no DB.
  // -------------------------------------------------------------------------
  fastify.post("/api/c/bank/csv-preview", async (request, reply) => {
    const read = await readMultipart(request);
    if (isMultipartErr(read)) return reply.code(read.status).send({ error: read.error });

    const text = read.buffer.toString("utf8");
    if (sniffFormat(text) === "ofx") {
      return reply.code(422).send({
        error: "This looks like an OFX/QFX file, not a CSV — it can be imported directly, no mapping step needed.",
      });
    }
    try {
      return reply.send(detectCsv(text));
    } catch (err) {
      return reply.code(422).send({ error: (err as Error).message });
    }
  });

  // -------------------------------------------------------------------------
  // GET /api/c/bank/accounts/:id/csv-map — the saved CSV column layout, if any.
  // -------------------------------------------------------------------------
  fastify.get("/api/c/bank/accounts/:id/csv-map", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    const accountId = parsedParams.data.id;

    try {
      const mapping = await db.withCompany(companyId, async (client) => {
        const res = await client.query<{ mapping: CsvMapping }>(
          "SELECT mapping FROM bank_csv_maps WHERE company_id = $1 AND money_account_id = $2",
          [companyId, accountId],
        );
        return res.rows[0]?.mapping ?? null;
      });
      if (!mapping) {
        return reply.code(404).send({ error: "No saved column layout for this account yet." });
      }
      return reply.send({ accountId, mapping });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "csv map lookup failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/bank/accounts/:id/csv-map {columnCount, ...mapping fields} —
  // sets/replaces the saved column layout for this account directly, with no
  // file or import attached. This is the recovery path for a wrong first
  // mapping (review fast-follow — see file header): the wizard's "change it"
  // calls this BEFORE re-importing, so the very next
  // POST /api/c/bank/statements for this account picks up the corrected
  // layout via its own normal "use the saved map" path — this route never
  // imports anything itself.
  // -------------------------------------------------------------------------
  fastify.post("/api/c/bank/accounts/:id/csv-map", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    const parsedBody = csvMapJsonBodySchema.safeParse(request.body);
    if (!parsedBody.success) return reply.code(400).send({ error: zodMessage(parsedBody.error.issues) });
    const accountId = parsedParams.data.id;
    const { columnCount, ...fields } = parsedBody.data;
    const mapping: CsvMapping = {
      headerRows: fields.headerRows, dateCol: fields.dateCol, descriptionCol: fields.descriptionCol,
      amountMode: fields.amountMode, amountCol: fields.amountCol, debitCol: fields.debitCol,
      creditCol: fields.creditCol, negateAmount: fields.negateAmount || undefined, dateFormat: fields.dateFormat,
    };

    const mappingErrors = validateMapping(mapping, columnCount);
    if (mappingErrors.length > 0) {
      return reply.code(400).send({ error: mappingErrors.join("; ") });
    }

    const ownerId = await resolveOwner(db, companyId, request.log);
    if (ownerId === null) return reply.code(NO_OWNER_ERROR.status).send({ error: NO_OWNER_ERROR.error });

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const account = await lookupMoneyAccount(client, companyId, accountId);
        if (!account) return { ok: false as const };
        await client.query(
          `INSERT INTO bank_csv_maps (company_id, money_account_id, mapping, updated_by)
           VALUES ($1, $2, $3::jsonb, $4)
           ON CONFLICT (company_id, money_account_id)
           DO UPDATE SET mapping = EXCLUDED.mapping, updated_by = EXCLUDED.updated_by, updated_at = now()`,
          [companyId, accountId, JSON.stringify(mapping), ownerId],
        );
        return { ok: true as const };
      });
      if (!result.ok) {
        return reply.code(NOT_FOUND_ACCOUNT.status).send({ error: NOT_FOUND_ACCOUNT.error });
      }
      return reply.send({ accountId, mapping });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "csv map save failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/bank/statements — multipart: file + accountId (+ CSV mapping
  // fields when needed).
  // -------------------------------------------------------------------------
  fastify.post("/api/c/bank/statements", async (request, reply) => {
    const companyId = request.companyId!;
    const read = await readMultipart(request);
    if (isMultipartErr(read)) return reply.code(read.status).send({ error: read.error });

    const accountId = read.fields.accountId;
    if (!accountId || !/^\d{1,18}$/.test(accountId)) {
      return reply.code(400).send({ error: "Pick a money account for this statement." });
    }

    const ownerId = await resolveOwner(db, companyId, request.log);
    if (ownerId === null) return reply.code(NO_OWNER_ERROR.status).send({ error: NO_OWNER_ERROR.error });

    const text = read.buffer.toString("utf8");
    const format = sniffFormat(text);

    // Provenance: the file is kept regardless of what happens downstream
    // (CLAUDE.md §12) — mirrors imports.ts saving before it ever parses.
    await saveUpload(companyId, read.buffer, format);

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const account = await lookupMoneyAccount(client, companyId, accountId);
        if (!account) return { ok: false as const, status: NOT_FOUND_ACCOUNT.status, error: NOT_FOUND_ACCOUNT.error };

        let parsedLines: Array<{ fitid: string | null; postedDate: string; amount: string; description: string | null; raw: unknown }>;
        let periodStart: string | null = null;
        let periodEnd: string | null = null;
        let endingBalance: string | null = null;

        if (format === "ofx") {
          let ofx;
          try {
            ofx = parseOfx(text);
          } catch (err) {
            return { ok: false as const, status: 422, error: (err as Error).message };
          }
          periodStart = ofx.periodStart;
          periodEnd = ofx.periodEnd;
          endingBalance = ofx.ledgerBalance?.amount ?? null;
          parsedLines = ofx.transactions.map((t) => ({
            fitid: t.fitid,
            postedDate: t.postedDate,
            amount: t.amount,
            description: [t.name, t.memo].filter((v): v is string => Boolean(v)).join(" — ") || null,
            raw: t,
          }));
        } else {
          let rows: string[][];
          try {
            rows = parseCsvRows(text);
          } catch (err) {
            return { ok: false as const, status: 422, error: (err as Error).message };
          }
          const columnCount = Math.max(0, ...rows.map((r) => r.length));

          const savedMapRes = await client.query<{ mapping: CsvMapping }>(
            "SELECT mapping FROM bank_csv_maps WHERE company_id = $1 AND money_account_id = $2",
            [companyId, accountId],
          );
          const savedMap = savedMapRes.rows[0]?.mapping ?? null;

          let mapping: CsvMapping;
          if (savedMap) {
            mapping = savedMap;
          } else {
            const parsedFields = csvMappingFieldsSchema.safeParse(read.fields);
            if (!parsedFields.success) {
              return {
                ok: false as const, status: 422,
                error: `This account has no saved column mapping yet — the mapping fields are required for a first-time CSV import: ${zodMessage(parsedFields.error.issues)}`,
              };
            }
            const f = parsedFields.data;
            mapping = {
              headerRows: f.headerRows, dateCol: f.dateCol, descriptionCol: f.descriptionCol,
              amountMode: f.amountMode, amountCol: f.amountCol, debitCol: f.debitCol, creditCol: f.creditCol,
              negateAmount: f.negateAmount || undefined, dateFormat: f.dateFormat,
            };
            const mappingErrors = validateMapping(mapping, columnCount);
            if (mappingErrors.length > 0) {
              return { ok: false as const, status: 422, error: mappingErrors.join("; ") };
            }
          }

          let applied;
          try {
            applied = applyCsvMapping(rows, mapping);
          } catch (err) {
            return { ok: false as const, status: 422, error: (err as Error).message };
          }
          parsedLines = applied.lines.map((l) => ({
            fitid: null, postedDate: l.postedDate, amount: l.amount, description: l.description || null, raw: l,
          }));

          if (!savedMap) {
            await client.query(
              `INSERT INTO bank_csv_maps (company_id, money_account_id, mapping, updated_by)
               VALUES ($1, $2, $3::jsonb, $4)
               ON CONFLICT (company_id, money_account_id)
               DO UPDATE SET mapping = EXCLUDED.mapping, updated_by = EXCLUDED.updated_by, updated_at = now()`,
              [companyId, accountId, JSON.stringify(mapping), ownerId],
            );
          }
        }

        // A bank line's amount can never legally be zero (CHECK amount <> 0
        // on bank_statement_lines) — filtered here rather than let one
        // pathological row abort the whole import transaction.
        parsedLines = parsedLines.filter((l) => centsFromAmountString(l.amount) !== 0n);

        if (parsedLines.length === 0) {
          return { ok: false as const, status: 422, error: "No transactions were found in that file — nothing was imported." };
        }

        const sha = sha256(read.buffer);
        const stmtRes = await client.query<{ id: string }>(
          `INSERT INTO bank_statements
             (company_id, money_account_id, filename, file_sha256, format, period_start, period_end, ending_balance, created_by)
           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
           RETURNING id`,
          [companyId, accountId, read.filename, sha, format, periodStart, periodEnd, endingBalance, ownerId],
        );
        const statementId = stmtRes.rows[0]!.id;

        // Preload existing (posted_date, amount, normalized description)
        // keys on this account for lines without a FITID, as a MULTIPLICITY
        // map (one count per distinct key, not a membership Set) — see the
        // file header's dedupe section for why a Set silently drops genuine
        // transactions.
        const existingRes = await client.query<{ posted_date: string; amount: string; description: string | null }>(
          `SELECT posted_date::text AS posted_date, amount, description
             FROM bank_statement_lines WHERE company_id = $1 AND money_account_id = $2`,
          [companyId, accountId],
        );
        const existingCounts = new Map<string, number>();
        for (const r of existingRes.rows) {
          const key = `${r.posted_date}|${r.amount}|${normalizeDescription(r.description)}`;
          existingCounts.set(key, (existingCounts.get(key) ?? 0) + 1);
        }

        let imported = 0;
        let skippedDuplicates = 0;
        let lineNo = 0;
        for (const line of parsedLines) {
          lineNo += 1;
          if (line.fitid !== null) {
            const insRes = await client.query<{ id: string }>(
              `INSERT INTO bank_statement_lines
                 (company_id, statement_id, money_account_id, line_no, posted_date, description, amount, fitid, raw)
               VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
               ON CONFLICT (company_id, money_account_id, fitid) WHERE fitid IS NOT NULL DO NOTHING
               RETURNING id`,
              [companyId, statementId, accountId, lineNo, line.postedDate, line.description, line.amount, line.fitid, JSON.stringify(line.raw)],
            );
            if (insRes.rows.length > 0) imported += 1;
            else skippedDuplicates += 1;
          } else {
            const key = `${line.postedDate}|${toDbAmountFormat(line.amount)}|${normalizeDescription(line.description)}`;
            const remaining = existingCounts.get(key) ?? 0;
            if (remaining > 0) {
              existingCounts.set(key, remaining - 1);
              skippedDuplicates += 1;
              continue;
            }
            await client.query(
              `INSERT INTO bank_statement_lines
                 (company_id, statement_id, money_account_id, line_no, posted_date, description, amount, fitid, raw)
               VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8::jsonb)`,
              [companyId, statementId, accountId, lineNo, line.postedDate, line.description, line.amount, JSON.stringify(line.raw)],
            );
            imported += 1;
            // Deliberately NOT added to existingCounts — within-file
            // duplicates are distinct transactions, see file header.
          }
        }

        const matches = await runMatchingEngine(client, companyId, statementId);

        const prefills: { endingBalance?: string; periodEnd?: string } = {};
        if (endingBalance !== null) prefills.endingBalance = endingBalance;
        if (periodEnd !== null) prefills.periodEnd = periodEnd;

        return {
          ok: true as const,
          statementId,
          lines: { imported, skippedDuplicates },
          matches,
          prefills,
        };
      });

      if (!result.ok) {
        return reply.code(result.status).send({ error: result.error });
      }
      return reply.code(201).send({
        statementId: result.statementId, lines: result.lines, matches: result.matches, prefills: result.prefills,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "bank statement import failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/bank/statements/:id/rematch — re-run the matching engine.
  // -------------------------------------------------------------------------
  fastify.post("/api/c/bank/statements/:id/rematch", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    const statementId = parsedParams.data.id;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const stmt = await lookupStatement(client, companyId, statementId);
        if (!stmt) return { found: false as const };
        const matches = await runMatchingEngine(client, companyId, statementId);
        return { found: true as const, matches };
      });
      if (!result.found) return reply.code(404).send(NOT_FOUND_STATEMENT);
      return reply.send({ statementId, matches: result.matches });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "rematch failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // GET /api/c/bank/overview — the home-screen feed.
  // -------------------------------------------------------------------------
  fastify.get("/api/c/bank/overview", async (request, reply) => {
    const companyId = request.companyId!;
    try {
      const accounts = await db.withCompany(companyId, async (client) => {
        const acctRes = await client.query<{
          id: string; name: string; book_balance: string; reconciled_through: string | null;
        }>(
          `SELECT a.id, a.name,
                  account_book_balance_asof($1, a.id, current_date) AS book_balance,
                  effective_reconciled_through($1, a.id) AS reconciled_through
             FROM accounts a
            WHERE a.company_id = $1 AND a.type = 'asset' AND a.active = true
            ORDER BY a.code`,
          [companyId],
        );

        const stmtRes = await client.query<{
          id: string; money_account_id: string; filename: string; period_end: string | null;
          status_counts: Record<string, number> | null;
        }>(
          `SELECT bs.id, bs.money_account_id, bs.filename, bs.period_end::text AS period_end,
                  coalesce(
                    (SELECT jsonb_object_agg(bsl.status, bsl.cnt)
                       FROM (SELECT status, count(*) AS cnt FROM bank_statement_lines
                              WHERE company_id = bs.company_id AND statement_id = bs.id
                              GROUP BY status) bsl),
                    '{}'::jsonb
                  ) AS status_counts
             FROM bank_statements bs
            WHERE bs.company_id = $1 AND bs.status = 'open'
            ORDER BY bs.money_account_id, bs.created_at`,
          [companyId],
        );

        const statementsByAccount = new Map<
          string, Array<{ id: string; filename: string; periodEnd: string | null; counts: Record<string, number> }>
        >();
        for (const row of stmtRes.rows) {
          const list = statementsByAccount.get(row.money_account_id) ?? [];
          list.push({
            id: row.id, filename: row.filename, periodEnd: row.period_end,
            counts: row.status_counts ?? {},
          });
          statementsByAccount.set(row.money_account_id, list);
        }

        return acctRes.rows.map((a) => ({
          accountId: a.id,
          name: a.name,
          bookBalance: a.book_balance,
          reconciledThrough: a.reconciled_through,
          openStatements: statementsByAccount.get(a.id) ?? [],
        }));
      });
      return reply.send({ accounts });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "bank overview failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // GET /api/c/bank/accounts/:id/book-balance?asOf= — Task 5's review
  // workbench needs a LIVE "your books say $Y" figure as the user edits the
  // reconcile footer's through-date, before ever attempting the real
  // POST .../reconcile. account_book_balance_asof() (0011_bank_rec.sql) is
  // already the authority reconcile_statement() itself calls for the exact
  // same figure — this route exists purely so the web UI can preview it,
  // never to duplicate the enforcement (that stays server-side, in the DB
  // function reconcile_statement() calls at commit time). asOf defaults to
  // today (same "current_date, not a JS Date" rationale as reports.ts).
  // -------------------------------------------------------------------------
  fastify.get("/api/c/bank/accounts/:id/book-balance", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    const parsedQuery = z.object({ asOf: isoDateSchema.optional() }).safeParse(request.query);
    if (!parsedQuery.success) return reply.code(400).send({ error: zodMessage(parsedQuery.error.issues) });
    const accountId = parsedParams.data.id;
    const asOf = parsedQuery.data.asOf ?? null;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const account = await lookupMoneyAccount(client, companyId, accountId);
        if (!account) return { found: false as const };
        const balRes = await client.query<{ book: string }>(
          "SELECT account_book_balance_asof($1, $2, coalesce($3::date, current_date)) AS book",
          [companyId, accountId, asOf],
        );
        return { found: true as const, bookBalance: balRes.rows[0]!.book };
      });
      if (!result.found) {
        return reply.code(NOT_FOUND_MONEY_ACCOUNT_404.status).send({ error: NOT_FOUND_MONEY_ACCOUNT_404.error });
      }
      return reply.send({ accountId, asOf, bookBalance: result.bookBalance });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "book balance lookup failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // GET /api/c/bank/statements/:id/lines?status=
  // -------------------------------------------------------------------------
  fastify.get("/api/c/bank/statements/:id/lines", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    const parsedQuery = z.object({ status: statusEnum.optional() }).safeParse(request.query);
    if (!parsedQuery.success) return reply.code(400).send({ error: zodMessage(parsedQuery.error.issues) });
    const statementId = parsedParams.data.id;
    const status = parsedQuery.data.status ?? null;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const stmt = await lookupStatement(client, companyId, statementId);
        if (!stmt) return { found: false as const };

        const linesRes = await client.query(
          `SELECT l.id, l.line_no, l.posted_date::text AS posted_date, l.description, l.amount, l.fitid,
                  l.status, l.matched_entry_id, l.suggestion_entry_id, l.excluded_reason,
                  me.entry_no AS matched_entry_no, me.entry_date::text AS matched_entry_date, me.memo AS matched_entry_memo,
                  se.entry_no AS suggestion_entry_no, se.entry_date::text AS suggestion_entry_date, se.memo AS suggestion_entry_memo
             FROM bank_statement_lines l
             LEFT JOIN journal_entries me ON me.company_id = l.company_id AND me.id = l.matched_entry_id
             LEFT JOIN journal_entries se ON se.company_id = l.company_id AND se.id = l.suggestion_entry_id
            WHERE l.company_id = $1 AND l.statement_id = $2
              AND ($3::text IS NULL OR l.status = $3)
            ORDER BY l.line_no, l.id`,
          [companyId, statementId, status],
        );

        return { found: true as const, statement: stmt, lines: linesRes.rows.map(lineOut) };
      });

      if (!result.found) return reply.code(404).send(NOT_FOUND_STATEMENT);
      return reply.send({
        statement: {
          id: result.statement.id,
          moneyAccountId: result.statement.moneyAccountId,
          moneyAccountName: result.statement.moneyAccountName,
          filename: result.statement.filename,
          format: result.statement.format,
          periodStart: result.statement.periodStart,
          periodEnd: result.statement.periodEnd,
          endingBalance: result.statement.endingBalance,
          status: result.statement.status,
        },
        lines: result.lines,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "statement lines list failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // GET /api/c/bank/lines/:id/candidates
  // -------------------------------------------------------------------------
  fastify.get("/api/c/bank/lines/:id/candidates", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    const lineId = parsedParams.data.id;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const line = await lookupLine(client, companyId, lineId);
        if (!line) return { found: false as const };
        const candidates = await findCandidateEntries(
          client, companyId, line.moneyAccountId, line.postedDate, line.amount,
          { limit: 5, orderByProximity: true },
        );
        return { found: true as const, candidates };
      });
      if (!result.found) return reply.code(404).send(NOT_FOUND_LINE);
      return reply.send({ candidates: result.candidates });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "line candidates failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/bank/lines/:id/confirm — accept the engine's suggestion.
  // -------------------------------------------------------------------------
  fastify.post("/api/c/bank/lines/:id/confirm", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    const lineId = parsedParams.data.id;

    const ownerId = await resolveOwner(db, companyId, request.log);
    if (ownerId === null) return reply.code(NO_OWNER_ERROR.status).send({ error: NO_OWNER_ERROR.error });

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const line = await lookupLine(client, companyId, lineId);
        if (!line) return { found: false as const };
        if (line.status !== "suggested" || line.suggestionEntryId === null) {
          return { found: true as const, ok: false as const, status: 409, error: "This line doesn't have a suggestion to confirm." };
        }
        await client.query(
          "SELECT match_statement_line($1, $2, $3, $4)",
          [companyId, lineId, line.suggestionEntryId, ownerId],
        );
        return { found: true as const, ok: true as const };
      });
      if (!result.found) return reply.code(404).send(NOT_FOUND_LINE);
      if (!result.ok) return reply.code(result.status).send({ error: result.error });
      return reply.send({ lineId, summary: "Matched to the suggested entry." });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "confirm line failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/bank/lines/:id/match {entryId} — manual pick.
  // -------------------------------------------------------------------------
  fastify.post("/api/c/bank/lines/:id/match", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    const parsedBody = z.object({ entryId: idSchema }).safeParse(request.body);
    if (!parsedBody.success) return reply.code(400).send({ error: zodMessage(parsedBody.error.issues) });
    const lineId = parsedParams.data.id;
    const { entryId } = parsedBody.data;

    const ownerId = await resolveOwner(db, companyId, request.log);
    if (ownerId === null) return reply.code(NO_OWNER_ERROR.status).send({ error: NO_OWNER_ERROR.error });

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const line = await lookupLine(client, companyId, lineId);
        if (!line) return { found: false as const };
        const entryRes = await client.query<{ entry_no: string | null }>(
          "SELECT entry_no FROM journal_entries WHERE company_id = $1 AND id = $2",
          [companyId, entryId],
        );
        if (!entryRes.rows[0]) {
          return { found: true as const, ok: false as const, status: 404, error: "We couldn't find that entry." };
        }
        await client.query("SELECT match_statement_line($1, $2, $3, $4)", [companyId, lineId, entryId, ownerId]);
        return { found: true as const, ok: true as const, entryNo: entryRes.rows[0].entry_no };
      });
      if (!result.found) return reply.code(404).send(NOT_FOUND_LINE);
      if (!result.ok) return reply.code(result.status).send({ error: result.error });
      return reply.send({
        lineId, entryId,
        summary: result.entryNo ? `Matched to entry #${result.entryNo}.` : "Matched.",
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "match line failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/bank/lines/:id/unmatch
  // -------------------------------------------------------------------------
  fastify.post("/api/c/bank/lines/:id/unmatch", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    const lineId = parsedParams.data.id;

    const ownerId = await resolveOwner(db, companyId, request.log);
    if (ownerId === null) return reply.code(NO_OWNER_ERROR.status).send({ error: NO_OWNER_ERROR.error });

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const line = await lookupLine(client, companyId, lineId);
        if (!line) return { found: false as const };
        await client.query("SELECT unmatch_statement_line($1, $2, $3)", [companyId, lineId, ownerId]);
        return { found: true as const };
      });
      if (!result.found) return reply.code(404).send(NOT_FOUND_LINE);
      return reply.send({ lineId, summary: "Unmatched — this line needs a decision again." });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "unmatch line failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/bank/lines/:id/exclude {reason}
  // -------------------------------------------------------------------------
  fastify.post("/api/c/bank/lines/:id/exclude", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    const parsedBody = z.object({
      reason: z.string().trim().min(1, "Tell us why you're excluding this line.").max(500),
    }).safeParse(request.body);
    if (!parsedBody.success) return reply.code(400).send({ error: zodMessage(parsedBody.error.issues) });
    const lineId = parsedParams.data.id;
    const { reason } = parsedBody.data;

    const ownerId = await resolveOwner(db, companyId, request.log);
    if (ownerId === null) return reply.code(NO_OWNER_ERROR.status).send({ error: NO_OWNER_ERROR.error });

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const line = await lookupLine(client, companyId, lineId);
        if (!line) return { found: false as const };
        await client.query("SELECT exclude_statement_line($1, $2, $3, $4)", [companyId, lineId, reason, ownerId]);
        return { found: true as const };
      });
      if (!result.found) return reply.code(404).send(NOT_FOUND_LINE);
      return reply.send({ lineId, summary: "Excluded — this line won't count toward reconciling." });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "exclude line failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/bank/lines/:id/restore
  // -------------------------------------------------------------------------
  fastify.post("/api/c/bank/lines/:id/restore", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    const lineId = parsedParams.data.id;

    const ownerId = await resolveOwner(db, companyId, request.log);
    if (ownerId === null) return reply.code(NO_OWNER_ERROR.status).send({ error: NO_OWNER_ERROR.error });

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const line = await lookupLine(client, companyId, lineId);
        if (!line) return { found: false as const };
        await client.query("SELECT restore_statement_line($1, $2, $3)", [companyId, lineId, ownerId]);
        return { found: true as const };
      });
      if (!result.found) return reply.code(404).send(NOT_FOUND_LINE);
      return reply.send({ lineId, summary: "Restored — this line needs a decision again." });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "restore line failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/bank/lines/:id/create-entry {categoryAccountId, who?, memo?}
  // -------------------------------------------------------------------------
  fastify.post("/api/c/bank/lines/:id/create-entry", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    const parsedBody = z.object({
      categoryAccountId: idSchema,
      who: z.preprocess(blankToUndefined, z.string().max(200).optional()),
      memo: z.preprocess(blankToUndefined, z.string().max(1000).optional()),
    }).safeParse(request.body);
    if (!parsedBody.success) return reply.code(400).send({ error: zodMessage(parsedBody.error.issues) });
    const lineId = parsedParams.data.id;
    const body = parsedBody.data;

    const ownerId = await resolveOwner(db, companyId, request.log);
    if (ownerId === null) return reply.code(NO_OWNER_ERROR.status).send({ error: NO_OWNER_ERROR.error });

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const line = await lookupLine(client, companyId, lineId);
        if (!line) return { found: false as const };

        const category = await lookupAnyActiveAccount(client, companyId, body.categoryAccountId);
        if (!category) {
          return { found: true as const, ok: false as const, status: 422, error: "Pick a category for this transaction." };
        }
        if (category.id === line.moneyAccountId) {
          return {
            found: true as const, ok: false as const, status: 422,
            error: "Pick a different account than the money account itself for the category.",
          };
        }

        const spec = { categoryAccountId: body.categoryAccountId, who: body.who ?? null, memo: body.memo ?? null };
        const entryRes = await client.query<{ create_entry_from_line: string }>(
          "SELECT create_entry_from_line($1, $2, $3::jsonb, $4) AS create_entry_from_line",
          [companyId, lineId, JSON.stringify(spec), ownerId],
        );
        const entryId = entryRes.rows[0]!.create_entry_from_line;

        const entryNoRes = await client.query<{ entry_no: string | null }>(
          "SELECT entry_no FROM journal_entries WHERE company_id = $1 AND id = $2",
          [companyId, entryId],
        );
        const entryNo = entryNoRes.rows[0]?.entry_no ?? null;

        const amountAbs = formatUsd(line.amount.startsWith("-") ? line.amount.slice(1) : line.amount);
        const summary = line.amount.startsWith("-")
          ? `Recorded ${amountAbs} spent on ${category.name} from ${line.moneyAccountName}.`
          : `Recorded ${amountAbs} in from ${category.name} into ${line.moneyAccountName}.`;

        return { found: true as const, ok: true as const, entryId, entryNo, summary };
      });
      if (!result.found) return reply.code(404).send(NOT_FOUND_LINE);
      if (!result.ok) return reply.code(result.status).send({ error: result.error });
      return reply.code(201).send({
        lineId, entryId: result.entryId, entryNo: result.entryNo, summary: result.summary,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "create entry from line failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/bank/statements/:id/confirm-all — every 'suggested' line ->
  // matched, continuing past per-line failures (SAVEPOINT per line, since a
  // RAISE EXCEPTION would otherwise abort the whole surrounding transaction
  // and silently take out every line after the first failure).
  // -------------------------------------------------------------------------
  fastify.post("/api/c/bank/statements/:id/confirm-all", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    const statementId = parsedParams.data.id;

    const ownerId = await resolveOwner(db, companyId, request.log);
    if (ownerId === null) return reply.code(NO_OWNER_ERROR.status).send({ error: NO_OWNER_ERROR.error });

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const stmt = await lookupStatement(client, companyId, statementId);
        if (!stmt) return { found: false as const };

        const linesRes = await client.query<{ id: string; suggestion_entry_id: string }>(
          `SELECT id, suggestion_entry_id FROM bank_statement_lines
            WHERE company_id = $1 AND statement_id = $2 AND status = 'suggested'
            ORDER BY line_no, id`,
          [companyId, statementId],
        );

        const results: Array<{ lineId: string; ok: boolean; error?: string }> = [];
        for (const l of linesRes.rows) {
          await client.query("SAVEPOINT confirm_all_line");
          try {
            await client.query(
              "SELECT match_statement_line($1, $2, $3, $4)",
              [companyId, l.id, l.suggestion_entry_id, ownerId],
            );
            await client.query("RELEASE SAVEPOINT confirm_all_line");
            results.push({ lineId: l.id, ok: true });
          } catch (err) {
            await client.query("ROLLBACK TO SAVEPOINT confirm_all_line");
            await client.query("RELEASE SAVEPOINT confirm_all_line");
            const mapped = mapPgError(err);
            results.push({ lineId: l.id, ok: false, error: mapped.message });
          }
        }
        return { found: true as const, results };
      });

      if (!result.found) return reply.code(404).send(NOT_FOUND_STATEMENT);
      const confirmed = result.results.filter((r) => r.ok).length;
      const failed = result.results.length - confirmed;
      return reply.send({ results: result.results, confirmed, failed });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "confirm-all failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/bank/statements/:id/reconcile {endingBalance, throughDate}
  // -------------------------------------------------------------------------
  fastify.post("/api/c/bank/statements/:id/reconcile", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    const parsedBody = z.object({
      endingBalance: signedAmountSchema,
      throughDate: isoDateSchema,
    }).safeParse(request.body);
    if (!parsedBody.success) return reply.code(400).send({ error: zodMessage(parsedBody.error.issues) });
    const statementId = parsedParams.data.id;
    const body = parsedBody.data;

    const ownerId = await resolveOwner(db, companyId, request.log);
    if (ownerId === null) return reply.code(NO_OWNER_ERROR.status).send({ error: NO_OWNER_ERROR.error });

    // Captured from inside the transaction (via closure) BEFORE the risky
    // call, so it's still available in the catch block below even though a
    // RAISE from reconcile_statement() aborts the surrounding transaction —
    // see the reconcile_statement()'s own equality gate in 0011_bank_rec.sql
    // for why this pre-flight read is purely presentational: it is never
    // itself the enforcement (that's the DB function's job), just how this
    // route surfaces a `difference` number alongside the function's own
    // authoritative English on a balance mismatch.
    let bookBalanceForDiff: string | null = null;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const stmt = await lookupStatement(client, companyId, statementId);
        if (!stmt) return { found: false as const };

        const bookRes = await client.query<{ book: string }>(
          "SELECT account_book_balance_asof($1, $2, $3::date) AS book",
          [companyId, stmt.moneyAccountId, body.throughDate],
        );
        bookBalanceForDiff = bookRes.rows[0]!.book;

        const recRes = await client.query<{ reconcile_statement: string }>(
          "SELECT reconcile_statement($1, $2, $3::jsonb, $4) AS reconcile_statement",
          [companyId, statementId, JSON.stringify({ endingBalance: body.endingBalance, throughDate: body.throughDate }), ownerId],
        );
        return { found: true as const, reconciliationId: recRes.rows[0]!.reconcile_statement };
      });

      if (!result.found) return reply.code(404).send(NOT_FOUND_STATEMENT);
      return reply.send({
        reconciliationId: result.reconciliationId,
        statementId,
        summary: `Reconciled through ${body.throughDate} — the books tie to the penny.`,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      const respBody: Record<string, unknown> = { error: mapped.message };
      if (/doesn't add up — \$/.test(mapped.message) && bookBalanceForDiff !== null) {
        const diffCents = centsFromAmountString(body.endingBalance) - centsFromAmountString(bookBalanceForDiff);
        respBody.difference = centsToStr(diffCents);
      }
      if (mapped.status === 500) request.log.error({ err }, "reconcile statement failed");
      return reply.code(mapped.status).send(respBody);
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/bank/undo-reconciliation {accountId, confirm: true}
  // -------------------------------------------------------------------------
  fastify.post("/api/c/bank/undo-reconciliation", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedBody = z.object({
      accountId: idSchema,
      confirm: z.literal(true, { error: "Confirm undoing this reconciliation by including confirm: true in the request." }),
    }).safeParse(request.body);
    if (!parsedBody.success) return reply.code(400).send({ error: zodMessage(parsedBody.error.issues) });
    const { accountId } = parsedBody.data;

    const ownerId = await resolveOwner(db, companyId, request.log);
    if (ownerId === null) return reply.code(NO_OWNER_ERROR.status).send({ error: NO_OWNER_ERROR.error });

    try {
      const result = await db.withCompany(companyId, async (client) => {
        await client.query("SELECT undo_reconciliation($1, $2, $3)", [companyId, accountId, ownerId]);
        const thruRes = await client.query<{ effective_reconciled_through: string | null }>(
          "SELECT effective_reconciled_through($1, $2) AS effective_reconciled_through",
          [companyId, accountId],
        );
        return { through: thruRes.rows[0]!.effective_reconciled_through };
      });
      const summary = result.through
        ? `Reconciliation undone — this account is now reconciled only through ${result.through}.`
        : "Reconciliation undone — this account has no reconciled history left.";
      return reply.send({ accountId, summary });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "undo reconciliation failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });
}

export type { EngineResult };
