// Sales tax API — Task 2 of docs/superpowers/plans/2026-08-02-sales-tax.md
// (spec: docs/superpowers/specs/2026-08-02-sales-tax-design.md). Three route
// families in one module, all under /api/c/ (company scope from X-Company-Id,
// enforced by companyContextPlugin), all inside db.withCompany() so RLS is
// the real guard (CLAUDE.md §4/§12):
//
//  - GET/PUT /api/c/settings/sales-tax — the per-company toggle. PUT calls
//    enable_sales_tax()/disable_sales_tax() (SECURITY DEFINER functions from
//    db/migrations/0015_sales_tax.sql — the ONLY paths that can flip
//    sales_tax_enabled; the column itself is not app-updatable), passing the
//    company's owner user for audit attribution (no login in v1, same as
//    every other route module's resolveOwner).
//  - /api/c/tax-rates — CRUD, no DELETE ever (rates referenced by issued
//    invoices are history; `active` is the retirement mechanism). The
//    percent freezes once an issued invoice references the rate — that's
//    0015's trigger, mapped to English in errors.ts, not re-checked here.
//  - GET /api/c/reports/sales-tax?from=&to= — the remittance report:
//    sales_tax_summary() rows plus the payable account's balance as of `to`
//    (derived from trial_balance() at query time — CLAUDE.md §3.4, never
//    stored). When sales tax is disabled this returns {enabled:false,
//    rows:[]} with a 200 (UI-friendly; no other conditional feature had an
//    established shape to mirror), never a 404.
//
// Rate percents ride as STRINGS end-to-end ("7.25" in, "7.2500" out — the
// numeric(7,4) text form, matching how every other numeric leaves this API).
// Never a JS number (CLAUDE.md §2): the 0-100 bound check below is BigInt
// string surgery, not parseFloat.

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";

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

const nameSchema = z.string({ error: "A tax rate needs a name." })
  .trim().min(1, "A tax rate needs a name.").max(200, "That name is too long.");

/** Percent, not fraction (7.25 means 7.25%) — numeric(7,4) in storage with a
 *  0-100 CHECK. The regex enforces shape (up to 4 decimal places, no leading
 *  zeros); the refine enforces the 0-100 bound without ever parsing the
 *  string into a JS number: whole part < 100, or exactly 100 with no nonzero
 *  fraction. The refine must be THROW-PROOF: zod runs every check even after
 *  the regex has already failed, so a non-numeric input like "1e2" or "abc"
 *  reaches it too — it re-matches the digits shape itself and passes such
 *  input through (the regex check has already rejected it with its own
 *  message) rather than letting BigInt() throw and surface as a raw 500. */
const ratePercentSchema = z
  .string({ error: "Enter a tax rate like 7.25." })
  .regex(
    /^(0|[1-9]\d{0,2})(\.\d{1,4})?$/,
    "Enter a rate like 7.25 (up to 4 decimal places, no leading zeros).",
  )
  .refine((v) => {
    const m = /^(\d+)(?:\.(\d+))?$/.exec(v);
    if (!m) return true; // malformed — the regex check above already rejected it
    const w = BigInt(m[1]!);
    return w < 100n || (w === 100n && !/[1-9]/.test(m[2] ?? ""));
  }, { message: "A tax rate has to be between 0 and 100 percent." });

// null and absent both mean "no agency vendor" on create; on PATCH, absent
// means "keep" and null means "clear" (see the PATCH handler).
const agencyVendorIdSchema = z.preprocess(
  (v) => (v === null ? null : v),
  z.union([idSchema, z.null()]).optional(),
);

const rateCreateSchema = z.object({
  name: nameSchema,
  ratePercent: ratePercentSchema,
  agencyVendorId: agencyVendorIdSchema,
});

// PATCH is partial on purpose (per the plan: {name?, ratePercent?,
// agencyVendorId?|null, active?}) — unlike the document PATCHes, the rates
// table edits one field at a time inline, so absent means "keep".
const rateUpdateSchema = z.object({
  name: nameSchema.optional(),
  ratePercent: ratePercentSchema.optional(),
  agencyVendorId: agencyVendorIdSchema,
  active: z.boolean().optional(),
});

// accountId is the optional "use THIS payable account" escape hatch: enable's
// auto-detect refuses loudly when more than one active liability account is
// named 'Sales Tax Payable' (0015's exactly-one-active semantics), and the
// Settings UI resolves that — or changes the account later — by re-PUTting
// with an explicit choice. Validated below as an existing ACTIVE LIABILITY
// account of this company; 0015's company_settings_sales_tax_account_is_liability
// trigger stays the real guard (CLAUDE.md §12).
const enabledBodySchema = z.object({
  enabled: z.boolean({ error: "Say whether sales tax should be on or off (true or false)." }),
  accountId: idSchema.optional(),
});

const reportQuerySchema = z.object({
  from: isoDateSchema,
  to: isoDateSchema,
});

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

const NOT_FOUND_RATE = { error: "We couldn't find that tax rate." };
const NOT_AGENCY_VENDOR = {
  status: 422,
  error: "Pick one of your vendors as the tax agency.",
};

// PUT settings' explicit-accountId refusals. The liability wording matches
// errors.ts's mapping of the 0015 trigger exactly, so the UI shows the same
// English whichever guard fires first.
const NOT_FOUND_PAYABLE_ACCOUNT = {
  status: 422,
  error: "We couldn't find that account — pick one of your active accounts.",
};
const NOT_LIABILITY_PAYABLE_ACCOUNT = {
  status: 422,
  error: "The sales tax account has to be a liability account (money you owe), not any other kind.",
};

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 entries.ts/settings.ts's resolveOwner — every enable/disable 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;
  }
}

/**
 * Flips the presentation sign of a NUMERIC(19,4) string (same string surgery
 * as reports.ts's flipSign — storage is debit-positive per CLAUDE.md §12, and
 * a liability's credit balance comes back negative from trial_balance(); the
 * report presents it as a positive "owed" number). Normalizes "-0.0000" to
 * "0.0000" so a flipped zero never displays with a stray minus.
 */
function flipSign(amount: string): string {
  const negative = amount.startsWith("-");
  const digits = negative ? amount.slice(1) : amount;
  if (/^0+(\.0+)?$/.test(digits)) return digits;
  return negative ? digits : `-${digits}`;
}

interface SalesTaxSettings {
  enabled: boolean;
  accountId: string | null;
  accountName: string | null;
}

async function readSalesTaxSettings(
  client: PoolClient, companyId: string,
): Promise<SalesTaxSettings> {
  const res = await client.query<{
    enabled: boolean; account_id: string | null; account_name: string | null;
  }>(
    `SELECT cs.sales_tax_enabled AS enabled, cs.sales_tax_account_id AS account_id,
            a.name AS account_name
       FROM company_settings cs
       LEFT JOIN accounts a ON a.company_id = cs.company_id AND a.id = cs.sales_tax_account_id
      WHERE cs.company_id = $1`,
    [companyId],
  );
  const row = res.rows[0];
  // No settings row yet means the company has never touched any setting —
  // sales tax is off, same as the column's own DEFAULT false.
  return {
    enabled: row?.enabled ?? false,
    accountId: row?.account_id ?? null,
    accountName: row?.account_name ?? null,
  };
}

interface TaxRateRow {
  id: string;
  name: string;
  rate: string;
  agency_vendor_id: string | null;
  agency_vendor_name: string | null;
  active: boolean;
}

const RATE_SELECT = `
  SELECT t.id, t.name, t.rate::text AS rate, t.agency_vendor_id,
         v.name AS agency_vendor_name, t.active
    FROM tax_rates t
    LEFT JOIN vendors v ON v.company_id = t.company_id AND v.id = t.agency_vendor_id`;

function rateToJson(r: TaxRateRow) {
  return {
    id: r.id,
    name: r.name,
    ratePercent: r.rate,
    agencyVendorId: r.agency_vendor_id,
    agencyVendorName: r.agency_vendor_name,
    active: r.active,
  };
}

/** RLS-scoped active-vendor existence check for the agency reference —
 *  friendly 422 here; the composite FK in 0015 stays the real guard
 *  (CLAUDE.md §12). */
async function vendorExists(
  client: PoolClient, companyId: string, vendorId: string,
): Promise<boolean> {
  const res = await client.query(
    "SELECT 1 FROM vendors WHERE company_id = $1 AND id = $2 AND active = true",
    [companyId, vendorId],
  );
  return res.rows.length > 0;
}

/** Duplicate-name 409s name the actual value the caller submitted (Postgres
 *  suppresses DETAIL for the non-owner `app` role, so errors.ts's fallback
 *  can't) — same pattern as bills.ts's mapDuplicateRefNo. */
function isDuplicateRateName(err: unknown): boolean {
  const e = (err ?? {}) as { code?: string; constraint?: string };
  return e.code === "23505" && e.constraint === "tax_rates_company_id_name_key";
}

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

export function registerSalesTaxRoutes(fastify: FastifyInstance, db: Db): void {
  // -------------------------------------------------------------------------
  // GET /api/c/settings/sales-tax
  // -------------------------------------------------------------------------
  fastify.get("/api/c/settings/sales-tax", async (request, reply) => {
    const companyId = request.companyId!;
    try {
      const result = await db.withCompany(companyId, (client) => readSalesTaxSettings(client, companyId));
      return reply.send(result);
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "get sales tax settings failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // PUT /api/c/settings/sales-tax  { enabled, accountId? }
  // -------------------------------------------------------------------------
  fastify.put("/api/c/settings/sales-tax", async (request, reply) => {
    const companyId = request.companyId!;
    const parsed = enabledBodySchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: zodMessage(parsed.error.issues) });
    }
    // accountId only makes sense while turning (or keeping) sales tax ON —
    // silently writing the account as a side effect of a disable would be
    // incoherent, so an explicit refusal instead (review finding 6).
    if (!parsed.data.enabled && parsed.data.accountId !== undefined) {
      return reply.code(400).send({
        error: "The tax account can only be chosen while turning sales tax on — leave it out when turning it off.",
      });
    }

    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) => {
        // Explicit payable-account choice (see enabledBodySchema's comment):
        // validated here for a friendly 422, then written BEFORE the toggle
        // call so enable_sales_tax() sees it set and skips its auto-detect
        // (its ambiguous-name refusal is what this parameter resolves).
        // RLS scopes the lookup, so another company's account id looks
        // exactly like an unknown one; 0015's liability trigger re-checks
        // the type at the UPDATE (CLAUDE.md §12).
        if (parsed.data.accountId !== undefined) {
          const acctRes = await client.query<{ type: string }>(
            "SELECT type FROM accounts WHERE company_id = $1 AND id = $2 AND active = true",
            [companyId, parsed.data.accountId],
          );
          const acct = acctRes.rows[0];
          if (!acct) return { ok: false as const, ...NOT_FOUND_PAYABLE_ACCOUNT };
          if (acct.type !== "liability") {
            return { ok: false as const, ...NOT_LIABILITY_PAYABLE_ACCOUNT };
          }
          // Same lock-free upsert ordering as enable_sales_tax() itself —
          // the settings row may not exist yet on a company that has never
          // touched any setting.
          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 sales_tax_account_id = $2 WHERE company_id = $1",
            [companyId, parsed.data.accountId],
          );
        }

        // Deliberately let the functions' own RAISEs (ambiguous payable
        // account, exhausted 22xx code range, taxed-draft disable refusal)
        // propagate through mapPgError — the DB is the real guard
        // (CLAUDE.md §12), and every one of those has an English mapping.
        if (parsed.data.enabled) {
          await client.query("SELECT enable_sales_tax($1, $2)", [companyId, ownerId]);
        } else {
          await client.query("SELECT disable_sales_tax($1, $2)", [companyId, ownerId]);
        }
        return { ok: true as const, settings: await readSalesTaxSettings(client, companyId) };
      });
      if (!result.ok) {
        return reply.code(result.status).send({ error: result.error });
      }
      return reply.send(result.settings);
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "set sales tax settings failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // GET /api/c/tax-rates
  // -------------------------------------------------------------------------
  fastify.get("/api/c/tax-rates", async (request, reply) => {
    const companyId = request.companyId!;
    try {
      return await db.withCompany(companyId, async (client) => {
        const res = await client.query<TaxRateRow>(
          `${RATE_SELECT} WHERE t.company_id = $1 ORDER BY t.name`,
          [companyId],
        );
        return res.rows.map(rateToJson);
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "tax rates list failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

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

    try {
      // Never reply.send() inside db.withCompany()'s callback — same
      // discriminated-result convention as every other route module.
      const result = await db.withCompany(companyId, async (client) => {
        if (agencyVendorId !== null && !(await vendorExists(client, companyId, agencyVendorId))) {
          return { ok: false as const, ...NOT_AGENCY_VENDOR };
        }
        const insertRes = await client.query<{ id: string }>(
          `INSERT INTO tax_rates (company_id, name, rate, agency_vendor_id)
           VALUES ($1, $2, $3, $4)
           RETURNING id`,
          [companyId, body.name, body.ratePercent, agencyVendorId],
        );
        const rateRes = await client.query<TaxRateRow>(
          `${RATE_SELECT} WHERE t.company_id = $1 AND t.id = $2`,
          [companyId, insertRes.rows[0]!.id],
        );
        return { ok: true as const, row: rateRes.rows[0]! };
      });

      if (!result.ok) {
        return reply.code(result.status).send({ error: result.error });
      }
      return reply.code(201).send(rateToJson(result.row));
    } catch (err) {
      if (isDuplicateRateName(err)) {
        return reply.code(409).send({ error: `You already have a tax rate called ${body.name}.` });
      }
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "create tax rate failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // PATCH /api/c/tax-rates/:id   (partial; no DELETE route exists on purpose)
  // -------------------------------------------------------------------------
  fastify.patch("/api/c/tax-rates/: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 = rateUpdateSchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: zodMessage(parsed.error.issues) });
    }
    const rateId = 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 tax_rates WHERE company_id = $1 AND id = $2",
          [companyId, rateId],
        );
        // RLS makes another company's rate id look exactly like an unknown
        // one — zero rows either way, both correctly 404 (§4/§9).
        if (!existingRes.rows[0]) return { found: false as const };

        // agencyVendorId: absent -> keep, null -> clear, id -> validate+set.
        if (body.agencyVendorId !== undefined && body.agencyVendorId !== null
          && !(await vendorExists(client, companyId, body.agencyVendorId))) {
          return { found: true as const, ok: false as const, ...NOT_AGENCY_VENDOR };
        }

        // The percent-frozen trigger (0015) raises here when the rate has
        // been used on issued invoices — deliberately let it flow through
        // mapPgError (mapped 409 English) rather than pre-checking.
        const updateRes = await client.query<{ id: string }>(
          `UPDATE tax_rates
              SET name = coalesce($3, name),
                  rate = coalesce($4, rate),
                  agency_vendor_id = CASE WHEN $5::boolean THEN $6::bigint ELSE agency_vendor_id END,
                  active = coalesce($7, active)
            WHERE company_id = $1 AND id = $2
            RETURNING id`,
          [companyId, rateId, body.name ?? null, body.ratePercent ?? null,
            body.agencyVendorId !== undefined, body.agencyVendorId ?? null,
            body.active ?? null],
        );
        const rateRes = await client.query<TaxRateRow>(
          `${RATE_SELECT} WHERE t.company_id = $1 AND t.id = $2`,
          [companyId, updateRes.rows[0]!.id],
        );
        return { found: true as const, ok: true as const, row: rateRes.rows[0]! };
      });

      if (!result.found) {
        return reply.code(404).send(NOT_FOUND_RATE);
      }
      if (!result.ok) {
        return reply.code(result.status).send({ error: result.error });
      }
      return reply.send(rateToJson(result.row));
    } catch (err) {
      if (isDuplicateRateName(err)) {
        return reply.code(409).send({
          error: `You already have a tax rate called ${body.name ?? "that"}.`,
        });
      }
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "update tax rate failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // GET /api/c/reports/sales-tax?from=&to=
  // -------------------------------------------------------------------------
  fastify.get("/api/c/reports/sales-tax", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedQuery = reportQuerySchema.safeParse(request.query);
    if (!parsedQuery.success) {
      return reply.code(400).send({ error: zodMessage(parsedQuery.error.issues) });
    }
    const { from, to } = parsedQuery.data;

    try {
      return await db.withCompany(companyId, async (client) => {
        const settings = await readSalesTaxSettings(client, companyId);
        if (!settings.enabled) {
          // Disabled companies get a quiet, UI-friendly shape — the feature
          // is invisible, not an error (spec: "the feature MUST stay
          // invisible until enabled").
          return { enabled: false, rows: [] };
        }

        const rowsRes = await client.query<{
          tax_rate_id: string; rate_name: string; rate: string;
          agency_vendor_id: string | null; agency_name: string | null;
          taxable_sales: string; non_taxable_sales: string; tax_collected: string;
        }>(
          "SELECT * FROM sales_tax_summary($1, $2::date, $3::date)",
          [companyId, from, to],
        );

        // Payable balance as of `to`, derived from journal_lines via
        // trial_balance() (CLAUDE.md §3.4 — never stored). Raw storage is
        // debit-positive, so a liability the company owes comes back
        // negative; flipSign() presents it as a positive "owed" number
        // (presentation-only, per CLAUDE.md §12).
        const balanceRes = await client.query<{ balance: string }>(
          `SELECT coalesce(
                    (SELECT balance FROM trial_balance($1, $2::date) WHERE account_id = $3),
                    0)::numeric(19,4) AS balance`,
          [companyId, to, settings.accountId],
        );

        return {
          enabled: true,
          from,
          to,
          rows: rowsRes.rows.map((r) => ({
            taxRateId: r.tax_rate_id,
            rateName: r.rate_name,
            ratePercent: r.rate,
            agencyVendorId: r.agency_vendor_id,
            agencyName: r.agency_name,
            taxableSales: r.taxable_sales,
            nonTaxableSales: r.non_taxable_sales,
            taxCollected: r.tax_collected,
          })),
          payableAccountId: settings.accountId,
          payableAccountName: settings.accountName,
          payableBalance: flipSign(balanceRes.rows[0]!.balance),
        };
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "sales tax report failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });
}
