// Customers API — Task 2 of docs/superpowers/plans/2026-07-31-ar.md. Route
// module pattern identical to entries.ts/reports.ts: every route lives under
// /api/c/ (company scope from X-Company-Id, enforced by companyContextPlugin)
// and every query runs inside db.withCompany() so RLS is the real guard
// (CLAUDE.md §4/§12), never just the WHERE clauses written here.
//
// Balances are derived, never stored (CLAUDE.md §3.4): the list route computes
// each customer's open balance (sum of open invoices' open balances, including
// document_only — see invoice_open_balance() in db/migrations/0005_ar.sql) and
// oldest open due date in ONE SQL query via a LATERAL join, not one query per
// customer. "paid"/void invoices, and drafts (never posted, no accounting
// impact yet), never contribute to the balance — status = 'open' already
// excludes both.
//
// No delete route exists here on purpose (design spec: "no delete") —
// customers are deactivated (active = false) via PATCH instead, same as
// accounts elsewhere in this system.

import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { Db } from "./db.js";
import { mapPgError } from "./errors.js";
import { blankToUndefined } from "./lib/textNormalize.js";

// ---------------------------------------------------------------------------
// shared validation
// ---------------------------------------------------------------------------

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 });

/** Empty string / absent -> undefined, so an untouched or blanked optional
 *  text field ends up NULL in storage rather than an empty string — see
 *  lib/textNormalize.ts's blankToUndefined for why the trim must happen
 *  BEFORE the blankness check. */
function optionalText(max: number, label: string) {
  return z.preprocess(blankToUndefined, z.string().max(max, `${label} is too long.`).optional());
}

const nameSchema = z.string({ error: "A customer needs a name." })
  .trim().min(1, "A customer needs a name.").max(200, "That name is too long.");
const emailSchema = z.preprocess(
  (v) => (v === "" || v === undefined || v === null ? undefined : v),
  z.string().trim().max(320)
    .regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, "Enter a valid email address.")
    .optional(),
);
const phoneSchema = optionalText(50, "Phone number");
const addressSchema = optionalText(1000, "Address");
const termsDaysSchema = z.coerce.number().int().min(0, "Terms can't be negative.").max(3650, "That's too many days.");

// Sales tax (db/migrations/0015_sales_tax.sql): a tax-exempt customer can
// never carry a tax rate on an invoice. DECISION (documented per the plan):
// flipping a customer to exempt while they have taxed DRAFTS is ALLOWED here
// — refusing would strand the flip behind every stale draft — and the
// invoices API re-validates exemption at BOTH draft time and issue time
// (see invoices.ts's resolveTaxRate + the issue route's re-check), so a
// stale taxed draft for a newly-exempt customer is refused, in English, at
// the moment it would matter.
const taxExemptSchema = z.boolean().optional();
const exemptionNoteSchema = optionalText(500, "Exemption note");

const customerCreateSchema = z.object({
  name: nameSchema,
  email: emailSchema,
  phone: phoneSchema,
  address: addressSchema,
  termsDays: termsDaysSchema.optional(),
  taxExempt: taxExemptSchema,
  exemptionNote: exemptionNoteSchema,
});

// PATCH replaces the whole editable set (mirrors the invoice PATCH's "replace
// header fields" convention) — the edit form always resubmits every field, so
// there's no ambiguity between "omitted" and "intentionally cleared".
const customerUpdateSchema = customerCreateSchema.extend({
  active: z.boolean().optional(),
});

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

const NOT_FOUND_CUSTOMER = { error: "We couldn't find that customer." };

interface CustomerListRow {
  id: string;
  name: string;
  email: string | null;
  phone: string | null;
  address: string | null;
  terms_days: number;
  active: boolean;
  tax_exempt: boolean;
  exemption_note: string | null;
  balance: string;
  oldest_open_due_date: string | null;
}

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

export function registerCustomerRoutes(fastify: FastifyInstance, db: Db): void {
  // -------------------------------------------------------------------------
  // GET /api/c/customers
  // -------------------------------------------------------------------------
  fastify.get("/api/c/customers", async (request, reply) => {
    const companyId = request.companyId!;
    try {
      return await db.withCompany(companyId, async (client) => {
        // ONE query for every customer: a LATERAL subquery per row computes
        // the derived balance (sum of open invoices' open balances, via the
        // existing invoice_open_balance() function — document_only invoices
        // are 'open' status too, so they're included automatically) and the
        // oldest due date among invoices that still have money owed. No
        // per-customer round trip from the app (no N+1).
        const rowsRes = await client.query<CustomerListRow>(
          `SELECT c.id, c.name, c.email, c.phone, c.address, c.terms_days, c.active,
                  c.tax_exempt, c.exemption_note,
                  coalesce(ob.balance, 0)::numeric(19,4) AS balance,
                  ob.oldest_open_due_date::text AS oldest_open_due_date
             FROM customers c
             LEFT JOIN LATERAL (
               SELECT sum(invoice_open_balance(c.company_id, i.id)) AS balance,
                      (min(i.due_date) FILTER (WHERE invoice_open_balance(c.company_id, i.id) > 0))
                        AS oldest_open_due_date
                 FROM invoices i
                WHERE i.company_id = c.company_id AND i.customer_id = c.id AND i.status = 'open'
             ) ob ON true
            WHERE c.company_id = $1
            ORDER BY c.name`,
          [companyId],
        );

        return rowsRes.rows.map((r) => ({
          id: r.id,
          name: r.name,
          email: r.email,
          phone: r.phone,
          address: r.address,
          termsDays: r.terms_days,
          active: r.active,
          taxExempt: r.tax_exempt,
          exemptionNote: r.exemption_note,
          balance: r.balance,
          oldestOpenDueDate: r.oldest_open_due_date,
        }));
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "customers list failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/customers
  // -------------------------------------------------------------------------
  fastify.post("/api/c/customers", async (request, reply) => {
    const companyId = request.companyId!;
    const parsed = customerCreateSchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: zodMessage(parsed.error.issues) });
    }
    const body = parsed.data;

    try {
      // Never call reply.send() from inside db.withCompany()'s callback (see
      // the identical note in entries.ts's POST /api/c/entries) — Fastify
      // treats the response as complete the instant reply.send() runs, which
      // races withCompany's own COMMIT (that runs AFTER this callback
      // resolves). Returning the inserted id and replying only once
      // db.withCompany() has fully resolved guarantees COMMIT has already
      // happened by the time anything is sent.
      const insertRes = await db.withCompany(companyId, async (client) => {
        return client.query<{ id: string }>(
          `INSERT INTO customers (company_id, name, email, phone, address, terms_days,
                                  tax_exempt, exemption_note)
           VALUES ($1, $2, $3, $4, $5, coalesce($6, 30), coalesce($7, false), $8)
           RETURNING id`,
          [companyId, body.name, body.email ?? null, body.phone ?? null, body.address ?? null,
            body.termsDays ?? null, body.taxExempt ?? null, body.exemptionNote ?? null],
        );
      });
      return reply.code(201).send({
        id: insertRes.rows[0]!.id,
        name: body.name,
        email: body.email ?? null,
        phone: body.phone ?? null,
        address: body.address ?? null,
        termsDays: body.termsDays ?? 30,
        active: true,
        taxExempt: body.taxExempt ?? false,
        exemptionNote: body.exemptionNote ?? null,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "create customer failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // PATCH /api/c/customers/:id
  // -------------------------------------------------------------------------
  fastify.patch("/api/c/customers/: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 = customerUpdateSchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: zodMessage(parsed.error.issues) });
    }
    const customerId = parsedParams.data.id;
    const body = parsed.data;

    try {
      // Never call reply.send() from inside db.withCompany()'s callback (see
      // the identical note in entries.ts's POST /api/c/entries) — a plain
      // discriminated result is returned and replied to only once
      // db.withCompany() (and its COMMIT) has fully resolved.
      const result = await db.withCompany(companyId, async (client) => {
        const updateRes = await client.query<{
          id: string; name: string; email: string | null; phone: string | null;
          address: string | null; terms_days: number; active: boolean;
          tax_exempt: boolean; exemption_note: string | null;
        }>(
          // taxExempt mirrors `active` (absent -> unchanged); exemptionNote
          // mirrors email/phone (absent -> cleared to NULL — the edit form
          // always resubmits every field).
          `UPDATE customers
              SET name = $3, email = $4, phone = $5, address = $6,
                  terms_days = coalesce($7, 30), active = coalesce($8, active),
                  tax_exempt = coalesce($9, tax_exempt), exemption_note = $10
            WHERE company_id = $1 AND id = $2
            RETURNING id, name, email, phone, address, terms_days, active,
                      tax_exempt, exemption_note`,
          [companyId, customerId, body.name, body.email ?? null, body.phone ?? null,
            body.address ?? null, body.termsDays ?? null, body.active ?? null,
            body.taxExempt ?? null, body.exemptionNote ?? null],
        );
        const row = updateRes.rows[0];
        if (!row) return { found: false as const };
        return { found: true as const, row };
      });

      if (!result.found) {
        return reply.code(404).send(NOT_FOUND_CUSTOMER);
      }
      const r = result.row;
      return reply.send({
        id: r.id, name: r.name, email: r.email, phone: r.phone, address: r.address,
        termsDays: r.terms_days, active: r.active,
        taxExempt: r.tax_exempt, exemptionNote: r.exemption_note,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "update customer failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });
}
