// Purchase orders API — Task 2 of docs/superpowers/plans/2026-08-03-inventory.md
// (spec: docs/superpowers/specs/2026-08-03-inventory-design.md). Same
// route-module pattern as bills.ts: everything under /api/c/ (company scope
// from X-Company-Id via companyContextPlugin), every query inside
// db.withCompany() so RLS is the real guard (CLAUDE.md §4/§12).
//
//   GET    /api/c/purchase-orders?state=&vendorId=&limit=&offset=
//   GET    /api/c/purchase-orders/:id
//   POST   /api/c/purchase-orders                 (draft)
//   PATCH  /api/c/purchase-orders/:id             (draft only)
//   DELETE /api/c/purchase-orders/:id             (draft only)
//   POST   /api/c/purchase-orders/:id/issue       (assigns po_no)
//   POST   /api/c/purchase-orders/:id/cancel
//   GET    /api/c/purchase-orders/:id/print
//   POST   /api/c/purchase-orders/:id/receive     (returns a bill DRAFT payload)
//
// Every refusal from /receive carries a stable `code` alongside its English
// `error` — 'not-sent' | 'cancelled' | 'all-on-draft-bills' | 'fully-received'.
// The wording is free to change; the code is the contract, so a UI branching
// on one of these cases never has to match on prose.
//
// ---------------------------------------------------------------------------
// A PURCHASE ORDER POSTS NOTHING. It is a promise, not a transaction: no
// journal entry, no stock movement, no balance anywhere changes when one is
// created, issued or cancelled. db/migrations/0018_inventory.sql's
// issue_purchase_order() does nothing but allocate the number and write an
// audit row, and there is no code in this file that opens an entry.
// ---------------------------------------------------------------------------
//
// DECISION — status is STORED for draft/open/cancelled and DERIVED for
// partial/closed, and the stored column is never written with 'partial' or
// 'closed' at all.
//
// 0018's CHECK permits all five values and its forward-only trigger permits
// open -> partial -> closed, because a future explicit "close this PO early"
// action is a legitimate stored state. We do not use them, because receipt
// progress is COMPUTABLE — sum bill_lines.quantity per po_line_id — and
// CLAUDE.md §3.4 says nothing computable is cached. The two ways to keep a
// stored copy honest are (a) recompute it inside every transaction that could
// change it, or (b) never store it. (a) means every path that issues a bill,
// voids a bill, or edits a draft bill line has to remember to re-derive the
// status of any PO it touched — and the day something forgets, the PO list
// lies with no test able to notice, because the stored value is the only thing
// anyone reads. (b) cannot go stale by construction. So: `status` on the wire
// is the STORED lifecycle, and `state` is the DERIVED one the user sees.
//
// Only ISSUED, non-void bills count as received (bills.status = 'open' — that
// column is draft/open/void, and "paid" is itself derived elsewhere). A draft
// bill has no accounting impact and has moved no stock, so it must not close a
// purchase order; a voided one has given the stock back.
//
// Lines with no item (a plain description line) are excluded from the
// closed/partial decision entirely: bill_lines_po_line_requires_item (0018)
// makes it impossible for a bill line to fulfil one, so there is no honest way
// to say it has been received. Two consequences, both real and both surfaced
// rather than hidden:
//
//   - a purchase order made only of description lines stays 'open' until
//     somebody cancels it;
//   - a MIXED purchase order reads 'closed' once its item lines are all in,
//     while its description lines still show qtyOutstanding — because a
//     description line's receipt is not something this system can observe, not
//     because it has arrived.
//
// Every response therefore carries `unreceivableLines` (a count) and
// `receivableLines`, so the UI can say "closed — 1 line on this order isn't
// tracked" rather than leaving the reader to reconcile two numbers that look
// like they disagree.
//
// OVER-RECEIPTS are allowed and flagged, never refused: 105 arriving against
// 100 ordered is a real thing that happens, and the bill records what the
// vendor actually sent. qty_outstanding clamps at zero, so `overReceived` (per
// line) and `overReceivedLines` (per order) are what stop that clamp from
// hiding it.
//
// DRAFT bills are excluded from the derived state (they have posted nothing)
// but SUBTRACTED by the receive prefill, which reports what it subtracted —
// otherwise clicking "receive" twice hands back the full outstanding quantity
// both times and entering both bills receives double what was ordered.

import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { Db } from "./db.js";
import type { PoolClient } from "pg";
import { mapPgError } from "./errors.js";
import { findOwnerId } from "../../tools/lib/journal-load.js";
import { blankToUndefined } from "./lib/textNormalize.js";

// ---------------------------------------------------------------------------
// validation
// ---------------------------------------------------------------------------

const idSchema = z.string().regex(/^\d{1,18}$/, "That isn't something we recognise — go back and pick it again.");
const idParamSchema = z.object({ id: idSchema });

const isoDateSchema = z.iso.date({ error: "must be a date like 2024-01-15 (YYYY-MM-DD)" });
const optionalIsoDate = z.preprocess(
  (v) => (v === "" || v === undefined ? undefined : v),
  isoDateSchema.optional(),
);

const memoSchema = z.preprocess(blankToUndefined, z.string().max(1000).optional());
const descriptionSchema = z.preprocess(blankToUndefined, z.string().max(500).optional());

// purchase_order_lines.qty_ordered is numeric(19,4). Same 8-digit integer cap
// bills.ts uses, so an absurd quantity is a friendly 400 rather than a raw
// Postgres overflow — and so a purchase order can never order a quantity the
// receiving bill's own schema would refuse.
const quantitySchema = z
  .string({ error: "Enter how many you're ordering." })
  .regex(
    /^(0|[1-9]\d{0,7})(\.\d{1,4})?$/,
    "Enter a quantity like 1 or 0.5 (up to 4 decimal places, no leading zeros, no more than 8 digits before the decimal point).",
  )
  .refine((v) => /[1-9]/.test(v), { message: "Enter a quantity greater than zero." });

// purchase_order_lines.unit_cost is numeric(19,6), but this API accepts at most
// FOUR decimal places on purpose: the bill that receives the line stores its
// cost in bill_lines.unit_price, which is numeric(19,4). Accepting six here
// would let someone order at a cost the receipt could only round, and the
// prefilled bill would quietly disagree with the paper. Four decimals still
// covers the sub-cent costs 0018 widened the column for (a washer at $0.0042).
const unitCostSchema = z
  .string({ error: "Enter a cost." })
  .regex(
    /^(0|[1-9]\d{0,12})(\.\d{1,4})?$/,
    "Enter a cost like 12 or 12.50 (up to 4 decimal places, no leading zeros, no more than 13 digits before the decimal point).",
  )
  .refine((v) => /[1-9]/.test(v), { message: "Enter a cost greater than zero." });

const poLineSchema = z.object({
  itemId: idSchema.optional(),
  description: descriptionSchema,
  quantity: quantitySchema,
  unitCost: unitCostSchema,
}).refine((l) => l.itemId !== undefined || l.description !== undefined, {
  message: "Each line needs an item or a description.",
});

const poBodySchema = z.object({
  vendorId: idSchema,
  orderDate: isoDateSchema,
  expectedDate: optionalIsoDate,
  memo: memoSchema,
  lines: z.array(poLineSchema).min(1, "Add at least one line."),
});

const listStateSchema = z.enum(["draft", "open", "partial", "closed", "cancelled"]);
const listQuerySchema = z.object({
  state: listStateSchema.optional(),
  vendorId: idSchema.optional(),
  limit: z.coerce.number().int().min(1).max(500).optional(),
  offset: z.coerce.number().int().min(0).optional(),
});

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

/** "10" not "10.0000", "0.5" not "0.5000" — the JS twin of 0018's
 *  format_quantity(). Pure string surgery, never a parsed number (§2). */
function formatQuantity(raw: string): string {
  if (!/^-?\d+\.\d+$/.test(raw)) return raw;
  return raw.replace(/0+$/, "").replace(/\.$/, "");
}

const NOT_FOUND_PO = { error: "We couldn't find that purchase order." };
const NOT_FOUND_VENDOR_FOR_PO = { status: 422, error: "Pick a vendor for this purchase order." };
const NOT_FOUND_ITEM_ON_LINE = {
  status: 422,
  error: "One of these lines names an item we couldn't find.",
};
const NO_OWNER_ERROR = {
  status: 500,
  error: "We couldn't work out who to record this purchase order under, so nothing was saved. Check this company's setup.",
};

/** Mirrors bills.ts's resolveOwner — every PO is attributed to the company's
 *  owner user (there is 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;
  }
}

// ---------------------------------------------------------------------------
// the derived-fulfilment SQL (see the DECISION note at the top of this file)
// ---------------------------------------------------------------------------

/**
 * CTEs computing, per purchase order line, how much of it has actually been
 * received — by summing the quantity of every ISSUED, non-void bill line that
 * points at it — and then rolling that up per purchase order. $1 is the
 * company id in every query that uses this.
 */
const PROGRESS_CTES = `
  po_line_progress AS (
    SELECT pl.id AS po_line_id, pl.po_id, pl.item_id, pl.description,
           pl.qty_ordered, pl.unit_cost,
           (pl.qty_ordered * pl.unit_cost)::numeric(19,4) AS line_total,
           coalesce((
             SELECT sum(bl.quantity)
               FROM bill_lines bl
               JOIN bills b ON b.company_id = bl.company_id AND b.id = bl.bill_id
              WHERE bl.company_id = pl.company_id
                AND bl.po_line_id = pl.id
                AND b.status = 'open'
           ), 0)::numeric(19,4) AS qty_received,
           -- Quantity sitting on bills that have been STARTED but not entered.
           -- It has moved no stock and owes nobody anything, so it never counts
           -- toward the derived state — but the receive prefill must subtract
           -- it, or two clicks of "receive" each hand back the full outstanding
           -- quantity and the second bill silently doubles the receipt.
           coalesce((
             SELECT sum(bl.quantity)
               FROM bill_lines bl
               JOIN bills b ON b.company_id = bl.company_id AND b.id = bl.bill_id
              WHERE bl.company_id = pl.company_id
                AND bl.po_line_id = pl.id
                AND b.status = 'draft'
           ), 0)::numeric(19,4) AS qty_drafted
      FROM purchase_order_lines pl
     WHERE pl.company_id = $1
  ),
  po_progress AS (
    SELECT po_id,
           count(*) FILTER (WHERE item_id IS NOT NULL) AS receivable_lines,
           count(*) FILTER (WHERE item_id IS NULL) AS unreceivable_lines,
           count(*) FILTER (WHERE item_id IS NOT NULL AND qty_received >= qty_ordered)
             AS filled_lines,
           -- More arrived than was ordered. Not refused — over-receipts happen,
           -- and the bill is what the vendor actually sent — but never silent.
           count(*) FILTER (WHERE item_id IS NOT NULL AND qty_received > qty_ordered)
             AS over_received_lines,
           coalesce(sum(qty_received), 0)::numeric(19,4) AS qty_received_total,
           coalesce(sum(line_total), 0)::numeric(19,4) AS total
      FROM po_line_progress
     GROUP BY po_id
  )`;

/**
 * The DERIVED state. `p` is the purchase_orders row, `pg` the po_progress row.
 * draft/cancelled are stored facts and pass straight through; 'closed' means
 * every receivable line is fully received; 'partial' means some quantity has
 * arrived but not all of it. The final ELSE returns the stored status, which
 * for an issued purchase order is 'open' (and would faithfully report a
 * future explicit close, should one ever be stored).
 */
const STATE_EXPR = `
  CASE
    WHEN p.status = 'draft' THEN 'draft'
    WHEN p.status = 'cancelled' THEN 'cancelled'
    WHEN coalesce(pg.receivable_lines, 0) > 0 AND pg.filled_lines = pg.receivable_lines
      THEN 'closed'
    WHEN coalesce(pg.qty_received_total, 0) > 0 THEN 'partial'
    ELSE p.status
  END`;

interface PoHeaderRow {
  id: string;
  po_no: string | null;
  vendor_id: string;
  vendor_name: string;
  order_date: string;
  expected_date: string | null;
  memo: string | null;
  status: string;
  state: string;
  total: string;
  receivable_lines: string;
  unreceivable_lines: string;
  over_received_lines: string;
}

interface PoLineRow {
  po_line_id: string;
  item_id: string | null;
  item_name: string | null;
  item_kind: string | null;
  description: string | null;
  qty_ordered: string;
  unit_cost: string;
  line_total: string;
  qty_received: string;
  qty_drafted: string;
  qty_outstanding: string;
  qty_available: string;
  over_received: boolean;
}

const LINE_SELECT = `
  -- unit_cost is numeric(19,6) in storage but is rendered at FOUR decimals
  -- everywhere on the wire: this API accepts at most four (see unitCostSchema),
  -- and bill_lines.unit_price — where the receipt prefill has to land — is
  -- numeric(19,4). Rendering six here would produce a prefilled bill line the
  -- bill API's own validation refuses.
  SELECT lp.po_line_id, lp.item_id, i.name AS item_name, i.kind AS item_kind,
         lp.description, lp.qty_ordered, lp.unit_cost::numeric(19,4) AS unit_cost,
         lp.line_total, lp.qty_received, lp.qty_drafted,
         greatest(lp.qty_ordered - lp.qty_received, 0)::numeric(19,4) AS qty_outstanding,
         -- what a NEW receipt should ask for: outstanding, less whatever is
         -- already waiting on an unentered bill
         greatest(lp.qty_ordered - lp.qty_received - lp.qty_drafted, 0)::numeric(19,4)
           AS qty_available,
         (lp.qty_received > lp.qty_ordered) AS over_received
    FROM po_line_progress lp
    LEFT JOIN items i ON i.company_id = $1 AND i.id = lp.item_id
   WHERE lp.po_id = $2
   ORDER BY lp.po_line_id`;

function toLineBody(l: PoLineRow) {
  return {
    id: l.po_line_id,
    itemId: l.item_id,
    itemName: l.item_name,
    itemKind: l.item_kind,
    description: l.description,
    quantity: l.qty_ordered,
    unitCost: l.unit_cost,
    amount: l.line_total,
    qtyReceived: l.qty_received,
    // already on a started-but-unentered bill
    qtyOnDraftBills: l.qty_drafted,
    qtyOutstanding: l.qty_outstanding,
    qtyAvailableToReceive: l.qty_available,
    overReceived: l.over_received,
  };
}

async function loadHeader(
  client: PoolClient, companyId: string, poId: string,
): Promise<PoHeaderRow | null> {
  const res = await client.query<PoHeaderRow>(
    `WITH ${PROGRESS_CTES}
     SELECT p.id, p.po_no, p.vendor_id, v.name AS vendor_name,
            p.order_date::text AS order_date, p.expected_date::text AS expected_date,
            p.memo, p.status, ${STATE_EXPR} AS state,
            coalesce(pg.total, 0)::numeric(19,4) AS total,
            coalesce(pg.receivable_lines, 0) AS receivable_lines,
            coalesce(pg.unreceivable_lines, 0) AS unreceivable_lines,
            coalesce(pg.over_received_lines, 0) AS over_received_lines
       FROM purchase_orders p
       JOIN vendors v ON v.company_id = p.company_id AND v.id = p.vendor_id
       LEFT JOIN po_progress pg ON pg.po_id = p.id
      WHERE p.company_id = $1 AND p.id = $2`,
    [companyId, poId],
  );
  return res.rows[0] ?? null;
}

async function loadLines(
  client: PoolClient, companyId: string, poId: string,
): Promise<PoLineRow[]> {
  const res = await client.query<PoLineRow>(
    `WITH ${PROGRESS_CTES} ${LINE_SELECT}`, [companyId, poId],
  );
  return res.rows;
}

function toDetailBody(header: PoHeaderRow, lines: PoLineRow[]) {
  return {
    id: header.id,
    poNo: header.po_no,
    vendorId: header.vendor_id,
    vendorName: header.vendor_name,
    orderDate: header.order_date,
    expectedDate: header.expected_date,
    memo: header.memo,
    status: header.status,
    state: header.state,
    total: header.total,
    receivableLines: Number(header.receivable_lines),
    // Description-only lines: nothing can ever mark them received, so a mixed
    // order can read 'closed' while one of these still shows an outstanding
    // quantity. Surfaced so the UI can say so out loud.
    unreceivableLines: Number(header.unreceivable_lines),
    // More arrived than was ordered on this many lines. Surfaced, not refused.
    overReceivedLines: Number(header.over_received_lines),
    lines: lines.map(toLineBody),
  };
}

interface VendorRow { id: string; name: string }

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

/** Rewrites a draft purchase order's whole line set. The caller has already
 *  proved the PO exists and (for PATCH) that it is still a draft — the
 *  purchase_order_lines_immutable_when_issued trigger from 0019 is the real
 *  guard either way. */
async function writeLines(
  client: PoolClient, companyId: string, poId: string,
  lines: Array<{ itemId?: string; description?: string; quantity: string; unitCost: string }>,
): Promise<{ status: number; error: string } | null> {
  for (const line of lines) {
    if (line.itemId !== undefined) {
      const itemRes = await client.query<{ id: string }>(
        "SELECT id FROM items WHERE company_id = $1 AND id = $2", [companyId, line.itemId],
      );
      if (!itemRes.rows[0]) return NOT_FOUND_ITEM_ON_LINE;
    }
    await client.query(
      `INSERT INTO purchase_order_lines
         (company_id, po_id, item_id, description, qty_ordered, unit_cost)
       VALUES ($1, $2, $3, $4, $5, $6)`,
      [companyId, poId, line.itemId ?? null, line.description ?? null,
        line.quantity, line.unitCost],
    );
  }
  return null;
}

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

export function registerPurchaseOrderRoutes(fastify: FastifyInstance, db: Db): void {
  // -------------------------------------------------------------------------
  // GET /api/c/purchase-orders
  // -------------------------------------------------------------------------
  fastify.get("/api/c/purchase-orders", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedQuery = listQuerySchema.safeParse(request.query);
    if (!parsedQuery.success) {
      return reply.code(400).send({ error: zodMessage(parsedQuery.error.issues) });
    }
    const { state, vendorId, limit, offset } = parsedQuery.data;

    try {
      return await db.withCompany(companyId, async (client) => {
        const rowsRes = await client.query<PoHeaderRow & {
          filled_lines: string; full_count: string;
        }>(
          `WITH ${PROGRESS_CTES},
           listed AS (
             SELECT p.id, p.po_no, p.vendor_id, v.name AS vendor_name,
                    p.order_date, p.expected_date, p.memo, p.status,
                    ${STATE_EXPR} AS state,
                    coalesce(pg.total, 0)::numeric(19,4) AS total,
                    coalesce(pg.receivable_lines, 0) AS receivable_lines,
                    coalesce(pg.unreceivable_lines, 0) AS unreceivable_lines,
                    coalesce(pg.over_received_lines, 0) AS over_received_lines,
                    coalesce(pg.filled_lines, 0) AS filled_lines
               FROM purchase_orders p
               JOIN vendors v ON v.company_id = p.company_id AND v.id = p.vendor_id
               LEFT JOIN po_progress pg ON pg.po_id = p.id
              WHERE p.company_id = $1
           )
           SELECT id, po_no, vendor_id, vendor_name,
                  order_date::text AS order_date, expected_date::text AS expected_date,
                  memo, status, state, total, receivable_lines,
                  unreceivable_lines, over_received_lines, filled_lines,
                  count(*) OVER() AS full_count
             FROM listed
            WHERE ($2::text IS NULL OR state = $2)
              AND ($3::bigint IS NULL OR vendor_id = $3)
            -- qualified with the CTE name so the ordering runs against the
            -- date-typed column, not the ::text alias projected above (text
            -- sorts lexically, not chronologically — same note as bills.ts).
            ORDER BY (state IN ('open', 'partial')) DESC,
                     listed.order_date DESC, listed.id DESC
            LIMIT $4 OFFSET $5`,
          [companyId, state ?? null, vendorId ?? null, limit ?? 50, offset ?? 0],
        );

        return {
          total: rowsRes.rows[0] ? Number(rowsRes.rows[0].full_count) : 0,
          purchaseOrders: rowsRes.rows.map((r) => ({
            id: r.id,
            poNo: r.po_no,
            vendorId: r.vendor_id,
            vendorName: r.vendor_name,
            orderDate: r.order_date,
            expectedDate: r.expected_date,
            memo: r.memo,
            status: r.status,
            state: r.state,
            total: r.total,
            receivableLines: Number(r.receivable_lines),
            unreceivableLines: Number(r.unreceivable_lines),
            overReceivedLines: Number(r.over_received_lines),
            filledLines: Number(r.filled_lines),
          })),
        };
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "purchase orders list failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // GET /api/c/purchase-orders/:id
  // -------------------------------------------------------------------------
  fastify.get("/api/c/purchase-orders/:id", 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 poId = parsedParams.data.id;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const header = await loadHeader(client, companyId, poId);
        if (!header) return { found: false as const };
        const lines = await loadLines(client, companyId, poId);
        return { found: true as const, body: toDetailBody(header, lines) };
      });

      if (!result.found) return reply.code(404).send(NOT_FOUND_PO);
      return reply.send(result.body);
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "purchase order detail failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/purchase-orders  (always a draft — purchase_orders_born_draft)
  // -------------------------------------------------------------------------
  fastify.post("/api/c/purchase-orders", async (request, reply) => {
    const companyId = request.companyId!;
    const parsed = poBodySchema.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 vendor = await lookupVendor(client, companyId, body.vendorId);
        if (!vendor) return { ok: false as const, ...NOT_FOUND_VENDOR_FOR_PO };

        const poRes = await client.query<{ id: string }>(
          `INSERT INTO purchase_orders
             (company_id, vendor_id, order_date, expected_date, memo, created_by)
           VALUES ($1, $2, $3, $4, $5, $6)
           RETURNING id`,
          [companyId, body.vendorId, body.orderDate, body.expectedDate ?? null,
            body.memo ?? null, ownerId],
        );
        const poId = poRes.rows[0]!.id;

        const lineError = await writeLines(client, companyId, poId, body.lines);
        if (lineError) return { ok: false as const, ...lineError };

        const header = await loadHeader(client, companyId, poId);
        const lines = await loadLines(client, companyId, poId);
        return { ok: true as const, body: toDetailBody(header!, lines) };
      });

      if (!result.ok) return reply.code(result.status).send({ error: result.error });
      return reply.code(201).send(result.body);
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "create purchase order failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // PATCH /api/c/purchase-orders/:id  (draft only — 0019's triggers enforce it)
  // -------------------------------------------------------------------------
  fastify.patch("/api/c/purchase-orders/:id", 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 parsed = poBodySchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: zodMessage(parsed.error.issues) });
    }
    const poId = parsedParams.data.id;
    const body = parsed.data;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const existingRes = await client.query<{ id: string }>(
          "SELECT id FROM purchase_orders WHERE company_id = $1 AND id = $2",
          [companyId, poId],
        );
        if (!existingRes.rows[0]) return { found: false as const };

        const vendor = await lookupVendor(client, companyId, body.vendorId);
        if (!vendor) return { found: true as const, ok: false as const, ...NOT_FOUND_VENDOR_FOR_PO };

        // Raises (purchase_orders_forward_only's 0019 header-freeze block) if
        // this PO has already been sent — deliberately let that happen and flow
        // through mapPgError rather than pre-checking status here.
        await client.query(
          `UPDATE purchase_orders
              SET vendor_id = $3, order_date = $4, expected_date = $5, memo = $6
            WHERE company_id = $1 AND id = $2`,
          [companyId, poId, body.vendorId, body.orderDate, body.expectedDate ?? null,
            body.memo ?? null],
        );

        // Full line replacement, same convention as the bill PATCH.
        //
        // NOTE, and it is load-bearing if these two statements are ever
        // reordered: the header UPDATE above does NOT prove this purchase order
        // is still a draft. 0019's header freeze only fires when a header field
        // actually CHANGES, so a PATCH that rewrites only the lines sails past
        // it. What refuses that case is
        // purchase_order_lines_immutable_when_issued (0019), which fires on the
        // DELETE below. Both guards are real; neither is redundant.
        await client.query(
          "DELETE FROM purchase_order_lines WHERE company_id = $1 AND po_id = $2",
          [companyId, poId],
        );
        const lineError = await writeLines(client, companyId, poId, body.lines);
        if (lineError) return { found: true as const, ok: false as const, ...lineError };

        const header = await loadHeader(client, companyId, poId);
        const lines = await loadLines(client, companyId, poId);
        return { found: true as const, ok: true as const, body: toDetailBody(header!, lines) };
      });

      if (!result.found) return reply.code(404).send(NOT_FOUND_PO);
      if (!result.ok) return reply.code(result.status).send({ error: result.error });
      return reply.send(result.body);
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "update purchase order failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // DELETE /api/c/purchase-orders/:id  (never-issued drafts only — 0018's
  // purchase_orders_forward_only refuses to delete anything that has a number)
  // -------------------------------------------------------------------------
  fastify.delete("/api/c/purchase-orders/:id", 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 poId = parsedParams.data.id;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const res = await client.query(
          "DELETE FROM purchase_orders WHERE company_id = $1 AND id = $2",
          [companyId, poId],
        );
        return { found: (res.rowCount ?? 0) > 0 };
      });

      if (!result.found) return reply.code(404).send(NOT_FOUND_PO);
      return reply.send({ id: poId, summary: "Deleted this draft purchase order." });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "delete purchase order failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/purchase-orders/:id/issue
  // -------------------------------------------------------------------------
  fastify.post("/api/c/purchase-orders/:id/issue", 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 poId = 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 existingRes = await client.query<{ id: string }>(
          "SELECT id FROM purchase_orders WHERE company_id = $1 AND id = $2",
          [companyId, poId],
        );
        if (!existingRes.rows[0]) return { found: false as const };

        // issue_purchase_order() posts NOTHING (0018) — it allocates the
        // per-company number and writes an audit row, and that is all.
        const res = await client.query<{ po_no: string }>(
          "SELECT issue_purchase_order($1, $2, $3) AS po_no", [companyId, poId, ownerId],
        );
        const poNo = res.rows[0]!.po_no;

        const header = await loadHeader(client, companyId, poId);
        return { found: true as const, poNo, vendorName: header?.vendor_name ?? "this vendor" };
      });

      if (!result.found) return reply.code(404).send(NOT_FOUND_PO);
      return reply.send({
        id: poId,
        poNo: result.poNo,
        summary: `Purchase order #${result.poNo} is ready to send to ${result.vendorName}. Nothing has been recorded in the books — that happens when the bill arrives.`,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "issue purchase order failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/purchase-orders/:id/cancel
  // -------------------------------------------------------------------------
  fastify.post("/api/c/purchase-orders/:id/cancel", 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 poId = parsedParams.data.id;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const existingRes = await client.query<{ status: string }>(
          "SELECT status FROM purchase_orders WHERE company_id = $1 AND id = $2 FOR UPDATE",
          [companyId, poId],
        );
        const existing = existingRes.rows[0];
        if (!existing) return { found: false as const };
        // purchase_orders_forward_only only raises when the status actually
        // CHANGES, so cancelling an already-cancelled PO would be a silent
        // no-op — say so instead.
        if (existing.status === "cancelled") {
          return {
            found: true as const, ok: false as const,
            status: 409, error: "This purchase order has already been cancelled.",
          };
        }

        // Read the progress BEFORE cancelling: cancelling a partly-received
        // order permanently forecloses the rest of it (the status is
        // forward-only), so the reply has to say what was given up rather than
        // reporting a cheerful success.
        const lines = await loadLines(client, companyId, poId);
        const receivedLines = lines.filter((l) => l.qty_received !== "0.0000");
        const stillOwed = lines.filter(
          (l) => l.item_id !== null && l.qty_outstanding !== "0.0000",
        );

        await client.query(
          "UPDATE purchase_orders SET status = 'cancelled' WHERE company_id = $1 AND id = $2",
          [companyId, poId],
        );
        return {
          found: true as const, ok: true as const,
          partiallyReceived: receivedLines.length > 0 && stillOwed.length > 0,
          outstanding: stillOwed.map((l) => ({
            name: l.item_name ?? l.description,
            quantity: formatQuantity(l.qty_outstanding),
          })),
        };
      });

      if (!result.found) return reply.code(404).send(NOT_FOUND_PO);
      if (!result.ok) return reply.code(result.status).send({ error: result.error });

      const base = "Cancelled this purchase order. Nothing was recorded in the books — a purchase order never touches them.";
      const owed = result.outstanding
        .map((o) => `${o.quantity} ${o.name ?? "items"}`)
        .join(", ");
      return reply.send({
        id: poId,
        partiallyReceived: result.partiallyReceived,
        summary: result.partiallyReceived
          ? `${base} Some of this order had already arrived; the rest (${owed}) is now closed off and can't be received against it — start a new order if you still need it.`
          : base,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "cancel purchase order failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // GET /api/c/purchase-orders/:id/print
  // -------------------------------------------------------------------------
  fastify.get("/api/c/purchase-orders/:id/print", 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 poId = parsedParams.data.id;

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

        const companyRes = await client.query<{ name: string }>(
          "SELECT name FROM companies WHERE id = $1", [companyId],
        );
        const vendorRes = await client.query<{
          name: string; email: string | null; phone: string | null; address: string | null;
        }>(
          "SELECT name, email, phone, address FROM vendors WHERE company_id = $1 AND id = $2",
          [companyId, header.vendor_id],
        );
        const lines = await loadLines(client, companyId, poId);
        const vendor = vendorRes.rows[0];

        return {
          found: true as const,
          company: { name: companyRes.rows[0]?.name ?? null },
          purchaseOrder: {
            id: header.id,
            no: header.po_no,
            orderDate: header.order_date,
            expectedDate: header.expected_date,
            memo: header.memo,
            status: header.status,
            state: header.state,
          },
          vendor: {
            name: vendor?.name ?? null,
            email: vendor?.email ?? null,
            phone: vendor?.phone ?? null,
            address: vendor?.address ?? null,
          },
          // Receipt progress travels with the print payload so a PARTLY
          // received order can be reprinted as a chaser rather than as a
          // fresh copy of the whole order (review finding F7: sending a
          // vendor a document asking again for goods they already shipped is
          // the kind of mistake that costs a relationship). The renderer
          // decides what to show; the numbers are derived here, once, by the
          // same CTEs the detail screen uses.
          lines: lines.map((l) => ({
            description: l.description ?? l.item_name,
            itemName: l.item_name,
            quantity: l.qty_ordered,
            unitCost: l.unit_cost,
            amount: l.line_total,
            // null on a description-only line: nothing can observe its
            // receipt, so reporting 0 received would be a claim, not a fact.
            qtyReceived: l.item_id === null ? null : l.qty_received,
            qtyOutstanding: l.item_id === null ? null : l.qty_outstanding,
          })),
          total: header.total,
        };
      });

      if (!result.found) return reply.code(404).send(NOT_FOUND_PO);
      const { found: _found, ...body } = result;
      return reply.send(body);
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "purchase order print failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/purchase-orders/:id/receive
  //
  // Returns a PREFILLED BILL DRAFT and posts nothing whatsoever — no entry, no
  // movement, no draft bill row. The user reviews it and issues the bill
  // through the normal flow, which is where the stock and the money are both
  // recorded, together, in one transaction (spec decision 1: there is no
  // separate receipt document, and the receipt date IS the bill date).
  // -------------------------------------------------------------------------
  fastify.post("/api/c/purchase-orders/:id/receive", 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 poId = parsedParams.data.id;

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

        if (header.status === "draft") {
          return {
            found: true as const, ok: false as const, status: 409,
            code: "not-sent" as const,
            error: "Send this purchase order to the vendor before receiving against it.",
          };
        }
        if (header.status === "cancelled") {
          return {
            found: true as const, ok: false as const, status: 409,
            code: "cancelled" as const,
            error: "This purchase order was cancelled — create a new one if you still need the goods.",
          };
        }

        const lines = await loadLines(client, companyId, poId);
        // SUBTRACT AND TELL. The prefill asks for what is outstanding LESS
        // whatever is already sitting on a started-but-unentered bill against
        // this order. Without that, clicking "receive" twice hands back the
        // full quantity both times and entering both bills receives double what
        // was ordered, with the order cheerfully reading 'closed'.
        const available = lines.filter((l) => l.qty_available !== "0.0000");
        if (available.length === 0) {
          const waiting = lines.some((l) => l.qty_drafted !== "0.0000");
          // `code` is a STABLE machine-readable discriminator, and it exists
          // for one specific reason: the all-drafted case is the only refusal
          // here with a way out (throw the unentered draft bill away), and the
          // UI has to be able to tell it apart from "already received" to
          // offer that. Keying that off the English prose would mean a reword
          // — or a typographic apostrophe — silently removing the escape hatch
          // this whole path exists to provide. Both arms are 409, so the
          // status code alone can't carry it. Same rationale as the `status`
          // field on web/src/lib/api.ts's ApiError.
          return {
            found: true as const, ok: false as const, status: 409,
            code: waiting ? ("all-on-draft-bills" as const) : ("fully-received" as const),
            error: waiting
              ? "Everything left on this purchase order is already on a bill you haven't entered yet — finish that bill instead of starting another."
              : "Everything on this purchase order has already been received.",
          };
        }
        // Filtered from ALL the lines, not from `available`: a line whose whole
        // remaining quantity is already on an unentered bill is not in
        // `available` at all, and saying nothing about it hands the user a bill
        // that is quietly missing the very item they clicked "receive" for.
        const alreadyDrafted = lines.filter((l) => l.qty_drafted !== "0.0000");

        // The DATABASE's clock, not the server process's — same rationale as
        // every other date in this codebase.
        const todayRes = await client.query<{ today: string }>(
          "SELECT current_date::text AS today",
        );

        return {
          found: true as const, ok: true as const,
          payload: {
            vendorId: header.vendor_id,
            vendorName: header.vendor_name,
            billDate: todayRes.rows[0]!.today,
            memo: header.po_no ? `Received against PO #${header.po_no}` : null,
            // Said out loud rather than quietly subtracted: the quantities below
            // are smaller than what is outstanding because some of it is already
            // on paper somebody started.
            notice: alreadyDrafted.length === 0 ? null
              : alreadyDrafted.length === 1
                ? `${formatQuantity(alreadyDrafted[0]!.qty_drafted)} of "${alreadyDrafted[0]!.item_name ?? alreadyDrafted[0]!.description}" ${alreadyDrafted[0]!.qty_drafted === "1.0000" ? "is" : "are"} already on a bill you haven't entered yet, so they aren't on this one.`
                : `Some of what's left is already on bills you haven't entered yet, so those quantities aren't on this one.`,
            lines: available.map((l) => ({
              poLineId: l.item_id ? l.po_line_id : null,
              itemId: l.item_id,
              itemName: l.item_name,
              description: l.description,
              quantity: l.qty_available,
              qtyOnDraftBills: l.qty_drafted,
              unitPrice: l.unit_cost,
              // A description-only purchase order line cannot carry a
              // po_line_id on a bill (bill_lines_po_line_requires_item, 0018),
              // so it arrives as an ordinary bill line the user has to
              // categorise — and it never counts toward fulfilment.
              needsCategory: l.item_id === null,
            })),
          },
        };
      });

      if (!result.found) return reply.code(404).send(NOT_FOUND_PO);
      if (!result.ok) {
        return reply.code(result.status).send({ error: result.error, code: result.code });
      }
      return reply.send(result.payload);
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "purchase order receive failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });
}
