// Check printing — Task 3 of docs/superpowers/plans/2026-08-01-ap.md; the
// one genuinely new piece of machinery with no AR analogue (docs/superpowers/
// specs/2026-08-01-ap-design.md's check-printing section is binding). Same
// route-module pattern as every other file: 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.
//
// The queue (GET /checks/queue) lists bill_payments that are method='check',
// check_status='to_print', for one money account. The print run (POST
// /checks/print) assigns sequential numbers ONLY on confirm, via
// assign_check_numbers() (a SECURITY DEFINER function from
// db/migrations/0009_ap.sql) — the counter row is locked first, so two
// concurrent print runs on the SAME account always serialize on that one
// lock (0009's own header comment). The settings routes
// (GET/PUT /settings/checks) read/write company_settings' check_format/
// check_offset_x/check_offset_y directly (app has a column-scoped UPDATE
// grant on those three — see 0009_ap.sql's GRANT list) and, for a per-account
// "next check number" edit, go through set_next_check_no() (app has
// SELECT-only on check_counters; every write to it happens inside a
// SECURITY DEFINER function, same hardening pattern as invoice_counters).
//
// Collision pre-check (queued Task 1 review finding, now addressed here):
// assign_check_numbers() itself does NOT check whether the range of numbers
// it's about to hand out collides with a manual check_no some caller already
// entered by hand on this same account — it would only ever find out via the
// bill_payments_check_no_unique constraint raising a raw, unmapped 23505 the
// moment it tries to UPDATE a colliding row. So this file reads (never
// locks — the `app` role only has SELECT on check_counters; the real,
// race-safe guard is assign_check_numbers()'s own FOR UPDATE, which runs
// with the function owner's privileges since it's SECURITY DEFINER) the
// current next_check_no BEFORE calling assign_check_numbers(), and checks
// whether [next, next + count) overlaps any check_no already used on this
// account. A collision is reported as a friendly, specific 409 (naming the
// colliding number(s) and pointing at Settings → Checks) instead of letting
// the raw constraint violation reach the caller. This is a courtesy, not the
// guard — the UNIQUE constraint, and assign_check_numbers()'s own atomic
// per-payment status check, are what actually make this safe under
// concurrency (CLAUDE.md §12): if a race slips a payment past this
// courtesy check, assign_check_numbers() still can't produce a duplicate
// number (the constraint would refuse it), and a concurrent print run
// targeting the SAME payment ids fails cleanly (mapped to English via
// errors.ts's "is not a check payment queued to print on this account").

import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { Db } from "./db.js";
import { mapPgError } from "./errors.js";
import { findOwnerId } from "../../tools/lib/journal-load.js";
import { amountToWords, centsFromAmountString } from "./lib/amountWords.js";
import { mapDuplicateCheckNo } from "./billPayments.js";

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

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

const checkFormatSchema = z.enum(["three_per_page", "voucher"], {
  error: "Check format must be 'three_per_page' or 'voucher'.",
});

// Offsets are a print-alignment nudge in millimeters, not money — company_
// settings.check_offset_x/y are numeric(6,2): up to 4 integer digits, 2
// decimal places, either sign. Not subject to CLAUDE.md §2's money-as-float
// ban (this isn't a monetary value), but still rendered through toFixed(2)
// before being sent to Postgres so no float noise (e.g. 0.1 + 0.2) can leak
// into a stored value.
const offsetSchema = z.coerce
  .number({ error: "Enter a number." })
  .finite("Enter a real number.")
  .min(-999.99, "That offset is too large.")
  .max(999.99, "That offset is too large.");

const queueQuerySchema = z.object({ accountId: idSchema });

const printBodySchema = z.object({
  accountId: idSchema,
  paymentIds: z.array(idSchema).min(1, "Pick at least one payment to print a check for."),
  format: checkFormatSchema.optional(),
  offsetX: offsetSchema.optional(),
  offsetY: offsetSchema.optional(),
});

const settingsPutSchema = z.object({
  format: checkFormatSchema.optional(),
  offsetX: offsetSchema.optional(),
  offsetY: offsetSchema.optional(),
  accounts: z.array(z.object({
    accountId: idSchema,
    nextCheckNo: z.coerce
      .number({ error: "Enter a whole check number." })
      .int("Enter a whole number, with no decimal point.")
      .min(1, "Enter a number of 1 or more.")
      .max(999_999_999_999_999, "That number is too large."),
  })).optional(),
});

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

const NOT_ASSET_ACCOUNT = { status: 422, error: "Pick a money account to print checks from." };

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.",
};

/** Mirrors every other route module's resolveOwner — every write here is
 *  attributed to the company's owner user (no login in v1). */
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;
  }
}

export function registerCheckRoutes(fastify: FastifyInstance, db: Db): void {
  // -------------------------------------------------------------------------
  // GET /api/c/checks/queue?accountId=
  // -------------------------------------------------------------------------
  fastify.get("/api/c/checks/queue", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedQuery = queueQuerySchema.safeParse(request.query);
    if (!parsedQuery.success) {
      return reply.code(400).send({ error: zodMessage(parsedQuery.error.issues) });
    }
    const accountId = parsedQuery.data.accountId;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const accountRes = await client.query<{ id: string; name: string }>(
          "SELECT id, name FROM accounts WHERE company_id = $1 AND id = $2 AND type = 'asset' AND active = true",
          [companyId, accountId],
        );
        if (!accountRes.rows[0]) return { found: false as const };

        const queueRes = await client.query<{
          id: string; payment_date: string; vendor_id: string; vendor_name: string;
          amount: string; memo: string | null;
        }>(
          `SELECT p.id, p.payment_date::text AS payment_date, p.vendor_id, v.name AS vendor_name,
                  p.amount, p.memo
             FROM bill_payments p
             JOIN vendors v ON v.company_id = p.company_id AND v.id = p.vendor_id
            WHERE p.company_id = $1 AND p.money_account_id = $2
              AND p.method = 'check' AND p.check_status = 'to_print'
            ORDER BY p.payment_date, p.id`,
          [companyId, accountId],
        );

        // No row yet for this account means it's never had a check printed
        // or a number explicitly set — the next number is still the table's
        // own default of 1 (mirrors settings.ts's readInvoicingSettings).
        const nextRes = await client.query<{ next_check_no: string }>(
          "SELECT next_check_no::text AS next_check_no FROM check_counters WHERE company_id = $1 AND money_account_id = $2",
          [companyId, accountId],
        );

        return {
          found: true as const,
          // Echoed back so a client re-fetching this for a DIFFERENT account
          // (ChecksScreen.tsx switching money accounts) can tell a still-in-
          // flight response for the account it just navigated AWAY from apart
          // from a genuine response for the account it's now showing — two
          // React effects reading the same pre-commit state can otherwise
          // observe the previous account's (still non-null) query result for
          // one extra render before the new fetch resolves, letting a
          // "seed the picker" effect key off stale data (caught during this
          // task's live browser verification, not just a theoretical case).
          accountId,
          accountName: accountRes.rows[0]!.name,
          nextCheckNo: nextRes.rows[0]?.next_check_no ?? "1",
          payments: queueRes.rows.map((r) => ({
            paymentId: r.id,
            date: r.payment_date,
            vendorId: r.vendor_id,
            vendorName: r.vendor_name,
            amount: r.amount,
            memo: r.memo,
          })),
        };
      });

      if (!result.found) {
        return reply.code(404).send({ error: "We couldn't find that money account." });
      }
      return reply.send({
        accountId: result.accountId,
        accountName: result.accountName,
        nextCheckNo: result.nextCheckNo,
        payments: result.payments,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "checks queue lookup failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/checks/print  { accountId, paymentIds, format?, offsetX?, offsetY? }
  // -------------------------------------------------------------------------
  fastify.post("/api/c/checks/print", async (request, reply) => {
    const companyId = request.companyId!;
    const parsed = printBodySchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: zodMessage(parsed.error.issues) });
    }
    const body = parsed.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 accountRes = await client.query<{ id: string; name: string }>(
          "SELECT id, name FROM accounts WHERE company_id = $1 AND id = $2 AND type = 'asset' AND active = true",
          [companyId, body.accountId],
        );
        if (!accountRes.rows[0]) return { ok: false as const, ...NOT_ASSET_ACCOUNT };

        // Collision pre-check — see the file header for why this is a plain
        // (unlocked) SELECT rather than a FOR UPDATE lock, and why that's
        // still safe: it's a courtesy for friendlier English, not the guard.
        const nextRes = await client.query<{ next_check_no: string }>(
          "SELECT next_check_no::text AS next_check_no FROM check_counters WHERE company_id = $1 AND money_account_id = $2",
          [companyId, body.accountId],
        );
        const next = BigInt(nextRes.rows[0]?.next_check_no ?? "1");
        const count = BigInt(body.paymentIds.length);

        const collideRes = await client.query<{ check_no: string }>(
          `SELECT check_no::text AS check_no FROM bill_payments
            WHERE company_id = $1 AND money_account_id = $2 AND check_no IS NOT NULL
              AND check_no >= $3 AND check_no < $4
            ORDER BY check_no`,
          [companyId, body.accountId, next.toString(), (next + count).toString()],
        );
        if (collideRes.rows.length > 0) {
          const nums = collideRes.rows.map((r) => r.check_no);
          const plural = nums.length > 1;
          return {
            ok: false as const,
            status: 409,
            error: `Check number${plural ? "s" : ""} ${nums.join(", ")} ${plural ? "are" : "is"} `
              + "already used on this account — set the next check number (Settings → Checks) before printing this run.",
          };
        }

        // Raises (a named payment isn't a to_print check payment on this
        // account anymore — e.g. a concurrent print run already claimed it)
        // flow through to mapPgError below — see the file header.
        const assignRes = await client.query<{ payment_id: string; check_no: string }>(
          "SELECT payment_id, check_no FROM assign_check_numbers($1, $2, $3::bigint[], $4)",
          [companyId, body.accountId, body.paymentIds, ownerId],
        );
        const checkNoByPayment = new Map(assignRes.rows.map((r) => [r.payment_id, r.check_no]));

        if (body.format !== undefined || body.offsetX !== undefined || body.offsetY !== undefined) {
          await client.query(
            "INSERT INTO company_settings (company_id) VALUES ($1) ON CONFLICT (company_id) DO NOTHING",
            [companyId],
          );
          await client.query(
            `UPDATE company_settings
                SET check_format = coalesce($2, check_format),
                    check_offset_x = coalesce($3, check_offset_x),
                    check_offset_y = coalesce($4, check_offset_y)
              WHERE company_id = $1`,
            [
              companyId, body.format ?? null,
              body.offsetX !== undefined ? body.offsetX.toFixed(2) : null,
              body.offsetY !== undefined ? body.offsetY.toFixed(2) : null,
            ],
          );
        }

        const settingsRes = await client.query<{
          check_format: string; check_offset_x: string; check_offset_y: string;
        }>(
          "SELECT check_format, check_offset_x, check_offset_y FROM company_settings WHERE company_id = $1",
          [companyId],
        );
        const settings = settingsRes.rows[0] ?? {
          check_format: "three_per_page", check_offset_x: "0.00", check_offset_y: "0.00",
        };

        const detailRes = await client.query<{
          id: string; vendor_name: string; amount: string; payment_date: string; memo: string | null;
        }>(
          `SELECT p.id, v.name AS vendor_name, p.amount, p.payment_date::text AS payment_date, p.memo
             FROM bill_payments p
             JOIN vendors v ON v.company_id = p.company_id AND v.id = p.vendor_id
            WHERE p.company_id = $1 AND p.id = ANY($2::bigint[])`,
          [companyId, body.paymentIds],
        );
        const detailById = new Map(detailRes.rows.map((r) => [r.id, r]));

        const assignments = assignRes.rows.map((r) => {
          const detail = detailById.get(r.payment_id)!;
          return {
            paymentId: r.payment_id,
            checkNo: r.check_no,
            vendorName: detail.vendor_name,
            amount: detail.amount,
            amountWords: amountToWords(centsFromAmountString(detail.amount)),
            date: detail.payment_date,
            memo: detail.memo,
          };
        });
        // assign_check_numbers() itself returns rows in (payment_date, id)
        // order (the order numbers were actually handed out in) — re-sort by
        // that same key here in case the driver ever reorders rows, so the
        // print page always lists checks in ascending number order.
        assignments.sort((a, b) => Number(BigInt(a.checkNo) - BigInt(b.checkNo)));

        return {
          ok: true as const,
          assignments,
          format: settings.check_format,
          offsets: { x: settings.check_offset_x, y: settings.check_offset_y },
        };
      });

      if (!result.ok) {
        return reply.code(result.status).send({ error: result.error });
      }
      return reply.send({
        assignments: result.assignments, format: result.format, offsets: result.offsets,
      });
    } catch (err) {
      // The collision pre-check above is a courtesy, not the guard (see the
      // file header) — a race between it and assign_check_numbers()'s own
      // atomic UPDATE can still hit bill_payments_check_no_unique. There's no
      // specific number to name here (the colliding number was assigned
      // automatically, not typed by hand), so mapDuplicateCheckNo's generic
      // (unknown-number) fallback English is what surfaces.
      const dup = mapDuplicateCheckNo(err, null);
      if (dup) return reply.code(dup.status).send({ error: dup.error });
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "print checks failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // GET /api/c/settings/checks
  // -------------------------------------------------------------------------
  fastify.get("/api/c/settings/checks", async (request, reply) => {
    const companyId = request.companyId!;
    try {
      const result = await db.withCompany(companyId, (client) => readCheckSettings(client, companyId));
      return reply.send(result);
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "get check settings failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // PUT /api/c/settings/checks  { format?, offsetX?, offsetY?, accounts?: [{accountId, nextCheckNo}] }
  // -------------------------------------------------------------------------
  fastify.put("/api/c/settings/checks", async (request, reply) => {
    const companyId = request.companyId!;
    const parsed = settingsPutSchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: zodMessage(parsed.error.issues) });
    }
    const body = parsed.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) => {
        if (body.format !== undefined || body.offsetX !== undefined || body.offsetY !== undefined) {
          await client.query(
            "INSERT INTO company_settings (company_id) VALUES ($1) ON CONFLICT (company_id) DO NOTHING",
            [companyId],
          );
          await client.query(
            `UPDATE company_settings
                SET check_format = coalesce($2, check_format),
                    check_offset_x = coalesce($3, check_offset_x),
                    check_offset_y = coalesce($4, check_offset_y)
              WHERE company_id = $1`,
            [
              companyId, body.format ?? null,
              body.offsetX !== undefined ? body.offsetX.toFixed(2) : null,
              body.offsetY !== undefined ? body.offsetY.toFixed(2) : null,
            ],
          );
        }

        // Deliberately let set_next_check_no()'s own "must be greater than
        // the highest used" RAISE propagate and flow through mapPgError,
        // rather than pre-checking ourselves — same pattern as settings.ts's
        // invoicing counter (the DB is the real guard, per CLAUDE.md §12).
        for (const account of body.accounts ?? []) {
          await client.query(
            "SELECT set_next_check_no($1, $2, $3, $4)",
            [companyId, account.accountId, account.nextCheckNo, ownerId],
          );
        }

        return readCheckSettings(client, companyId);
      });
      return reply.send(result);
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "set check settings failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });
}

interface CheckSettings {
  format: string;
  offsetX: string;
  offsetY: string;
  accounts: Array<{ accountId: string; name: string; nextCheckNo: string; highestUsed: string | null }>;
}

async function readCheckSettings(
  client: import("pg").PoolClient, companyId: string,
): Promise<CheckSettings> {
  const settingsRes = await client.query<{
    check_format: string; check_offset_x: string; check_offset_y: string;
  }>(
    "SELECT check_format, check_offset_x, check_offset_y FROM company_settings WHERE company_id = $1",
    [companyId],
  );
  // No company_settings row yet (never touched) means the column DEFAULTs
  // apply, same as if the row existed — mirrors settings.ts's
  // readInvoicingSettings' "no counter row yet -> default 1" fallback.
  const settings = settingsRes.rows[0] ?? {
    check_format: "three_per_page", check_offset_x: "0.00", check_offset_y: "0.00",
  };

  // Every active asset (money) account is a candidate check-printing
  // account, whether or not it's ever actually printed a check yet.
  const accountsRes = await client.query<{
    id: string; name: string; next_check_no: string | null; highest_used: string | null;
  }>(
    `SELECT a.id, a.name, cc.next_check_no::text AS next_check_no,
            (SELECT max(check_no) FROM bill_payments bp
              WHERE bp.company_id = a.company_id AND bp.money_account_id = a.id)::text AS highest_used
       FROM accounts a
       LEFT JOIN check_counters cc ON cc.company_id = a.company_id AND cc.money_account_id = a.id
      WHERE a.company_id = $1 AND a.type = 'asset' AND a.active = true
      ORDER BY a.name`,
    [companyId],
  );

  return {
    format: settings.check_format,
    offsetX: settings.check_offset_x,
    offsetY: settings.check_offset_y,
    accounts: accountsRes.rows.map((r) => ({
      accountId: r.id,
      name: r.name,
      nextCheckNo: r.next_check_no ?? "1",
      highestUsed: r.highest_used,
    })),
  };
}
