// Vendors API — Task 2 of docs/superpowers/plans/2026-08-01-ap.md. Mechanical
// mirror of server/src/customers.ts (vendor/bill vocabulary, plus the
// track_1099 flag) — same route-module pattern as every other /api/c/*
// module: 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 vendor's open payable balance (sum of open bills' open balances,
// including document_only — see bill_open_balance() in db/migrations/0009_ap.sql)
// and oldest open due date in ONE SQL query via a LATERAL join, not one query
// per vendor. "paid"/void bills, and drafts (never issued, no accounting
// impact yet), never contribute to the balance — status = 'open' already
// excludes both.
//
// No delete route exists here on purpose (mirrors customers.ts) — vendors are
// deactivated (active = false) via PATCH instead.

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 vendor needs a name." })
  .trim().min(1, "A vendor 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.");

const vendorCreateSchema = z.object({
  name: nameSchema,
  email: emailSchema,
  phone: phoneSchema,
  address: addressSchema,
  termsDays: termsDaysSchema.optional(),
  track1099: z.boolean().optional(),
});

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

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

const NOT_FOUND_VENDOR = { error: "We couldn't find that vendor." };

interface VendorListRow {
  id: string;
  name: string;
  email: string | null;
  phone: string | null;
  address: string | null;
  terms_days: number;
  track_1099: boolean;
  active: boolean;
  balance: string;
  oldest_open_due_date: string | null;
}

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

export function registerVendorRoutes(fastify: FastifyInstance, db: Db): void {
  // -------------------------------------------------------------------------
  // GET /api/c/vendors
  // -------------------------------------------------------------------------
  fastify.get("/api/c/vendors", async (request, reply) => {
    const companyId = request.companyId!;
    try {
      return await db.withCompany(companyId, async (client) => {
        // ONE query for every vendor: a LATERAL subquery per row computes
        // the derived payable balance (sum of open bills' open balances, via
        // the existing bill_open_balance() function — document_only bills
        // are 'open' status too, so they're included automatically) and the
        // oldest due date among bills that still have money owed. No
        // per-vendor round trip from the app (no N+1).
        const rowsRes = await client.query<VendorListRow>(
          `SELECT v.id, v.name, v.email, v.phone, v.address, v.terms_days, v.track_1099, v.active,
                  coalesce(ob.balance, 0)::numeric(19,4) AS balance,
                  ob.oldest_open_due_date::text AS oldest_open_due_date
             FROM vendors v
             LEFT JOIN LATERAL (
               SELECT sum(bill_open_balance(v.company_id, b.id)) AS balance,
                      (min(b.due_date) FILTER (WHERE bill_open_balance(v.company_id, b.id) > 0))
                        AS oldest_open_due_date
                 FROM bills b
                WHERE b.company_id = v.company_id AND b.vendor_id = v.id AND b.status = 'open'
             ) ob ON true
            WHERE v.company_id = $1
            ORDER BY v.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,
          track1099: r.track_1099,
          active: r.active,
          balance: r.balance,
          oldestOpenDueDate: r.oldest_open_due_date,
        }));
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "vendors list failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/vendors
  // -------------------------------------------------------------------------
  fastify.post("/api/c/vendors", async (request, reply) => {
    const companyId = request.companyId!;
    const parsed = vendorCreateSchema.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 vendors (company_id, name, email, phone, address, terms_days, track_1099)
           VALUES ($1, $2, $3, $4, $5, coalesce($6, 30), coalesce($7, false))
           RETURNING id`,
          [companyId, body.name, body.email ?? null, body.phone ?? null, body.address ?? null,
            body.termsDays ?? null, body.track1099 ?? 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,
        track1099: body.track1099 ?? false,
        active: true,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "create vendor failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // PATCH /api/c/vendors/:id
  // -------------------------------------------------------------------------
  fastify.patch("/api/c/vendors/: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 = vendorUpdateSchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: zodMessage(parsed.error.issues) });
    }
    const vendorId = 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; track_1099: boolean; active: boolean;
        }>(
          `UPDATE vendors
              SET name = $3, email = $4, phone = $5, address = $6,
                  terms_days = coalesce($7, 30), track_1099 = coalesce($8, false),
                  active = coalesce($9, active)
            WHERE company_id = $1 AND id = $2
            RETURNING id, name, email, phone, address, terms_days, track_1099, active`,
          [companyId, vendorId, body.name, body.email ?? null, body.phone ?? null,
            body.address ?? null, body.termsDays ?? null, body.track1099 ?? null,
            body.active ?? 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_VENDOR);
      }
      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, track1099: r.track_1099, active: r.active,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "update vendor failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });
}
