// Invoice form — AR Task 5 item 3/5. One component backs three call sites:
//   - "New invoice" on #invoices (mode="create", documentOnly=false)
//   - "Add older invoice (already in your books)" on #invoices
//     (mode="create", documentOnly=true)
//   - "Edit" on a draft's #invoice/:id detail page (mode="edit")
// A single shape avoids duplicating the header-fields-plus-lines-editor UI
// (and its client-side HALF-UP amount preview) between create and edit.
//
// Regex/schema mirrors: quantitySchema/unitPriceSchema below are copied from
// server/src/invoices.ts's own — client-side validation only exists to give
// inline English feedback before a round trip; the server re-validates
// everything and remains the authority (CLAUDE.md §12, same rationale as
// Record.tsx's amountError).
//
// "Save & send to the books" shows the confirm sentence BEFORE anything is
// submitted (design brief: 'confirm sentence first: "This will record
// $1,234.00 owed by Harborlight Trading Co across 2 items."'), then creates
// the draft and issues it in two calls. Document-only invoices skip that confirm
// entirely (they carry their own persistent "no accounting impact" banner
// instead) and issue immediately on save, per the design brief.

// ---------------------------------------------------------------------------
// ITEM LINES (Inventory Task 4). An invoice line may name an item. Picking one
// fills the price from the item and points the line at the item's own income
// account; a stocked item additionally shows how many are on hand, so the
// person writing the invoice can see whether it can actually be shipped.
//
// COST OF GOODS SOLD IS NOWHERE ON THIS FORM, deliberately (spec: "the seller
// never sees it unless they look"). Selling a stocked item posts the cost leg
// automatically — issue_invoice() writes it on the same entry, in the same
// transaction — and the place to go LOOK at it is the item's own history or
// the entry the invoice links to, not the sales document.
//
// A company with no items has no Item column, no picker and no itemId in the
// payload — not as a §9 posture (Items and Purchase orders are always in the
// sidebar, because CLAUDE.md §9 as amended says standard functionality stays
// visible), but because a picker with nothing in it is a dead control, and
// because it keeps this form's request body byte-for-byte what it was before
// inventory existed. A FAILED items fetch is reported, never mistaken for a
// company that has none (review finding F5).
// ---------------------------------------------------------------------------

import { Fragment, useMemo, useState } from "react";
import { useCompany } from "../lib/company";
import { useApiGet } from "../lib/useApiGet";
import { apiPost, apiPatch, ApiError } from "../lib/api";
import { todayIso, addDaysIso } from "../lib/date";
import { fmtMoney, isZeroMoney } from "../lib/money";
import { trimPercent } from "../lib/percent";
import { ErrorBanner } from "../components/ErrorBanner";
import { SearchablePicker, type PickerGroup } from "../components/SearchablePicker";
import { NewAccountModal } from "../accounts/NewAccountModal";
import type { ApiAccount } from "../accounts/subtypes";
import { accountPickerGroups } from "../accounts/pickerGroups";
import { computeLineAmountPreview, sumMoney } from "./lineAmount";
import { computeTaxPreview } from "./taxAmount";
import { ItemModal } from "../items/ItemModal";
import { ITEM_KINDS, KIND_HEADINGS, fmtQuantity, type ApiItem } from "../items/itemTypes";

const QUANTITY_RE = /^(0|[1-9]\d{0,7})(\.\d{1,4})?$/;
const UNIT_PRICE_RE = /^(0|[1-9]\d{0,12})(\.\d{1,2})?$/;
// server/src/invoices.ts's itemUnitPriceSchema — four decimals on a line that
// names an item (items.sales_price is numeric(19,4) and sub-cent prices are
// real), two on every other line, message unchanged.
const ITEM_UNIT_PRICE_RE = /^(0|[1-9]\d{0,12})(\.\d{1,4})?$/;

/** Sentinel for the item picker's "no item" row — never a real item id. */
const NO_ITEM = "__no_item__";

interface CustomerOption {
  id: string;
  name: string;
  termsDays: number;
  active: boolean;
  taxExempt: boolean;
}

// Sales tax (Task 3 of docs/superpowers/plans/2026-08-02-sales-tax.md).
// EVERYTHING tax-related in this form is gated on the company's own toggle
// (GET /api/c/settings/sales-tax): a company with sales tax off sees this
// form exactly as it was before the feature existed — no rate picker, no Tax
// column, plain single Total (spec: "the feature MUST stay invisible until
// enabled").
interface SalesTaxSettings {
  enabled: boolean;
  accountId: string | null;
  accountName: string | null;
}

interface TaxRateOption {
  id: string;
  name: string;
  ratePercent: string;
  agencyVendorId: string | null;
  agencyVendorName: string | null;
  active: boolean;
}


export interface InvoiceFormLine {
  description: string;
  quantity: string;
  unitPrice: string;
  incomeAccountId: string | null;
  // Per-line "charge tax on this line" flag (0015). Kept in state even while
  // no rate is selected (the column is just hidden then), matching how the
  // server stores the flag independently of the rate.
  taxable: boolean;
  /** Inventory Task 4 — null on every line that doesn't name an item, which
   *  is every line on a company that has none. */
  itemId?: string | null;
}

export interface InvoiceFormInitial {
  customerId: string;
  invoiceDate: string;
  dueDate: string;
  memo: string;
  taxRateId: string | null;
  lines: InvoiceFormLine[];
}

let lineKeySeq = 0;
function nextLineKey(): string {
  lineKeySeq += 1;
  return `line-${lineKeySeq}`;
}

// The three fields below the payload ones are UI-only bookkeeping, never sent.
// See bills/BillForm.tsx's identical block for the full reasoning (review
// finding F1): an auto-fill may only replace a blank or a value THIS FORM
// auto-filled, and that has to be tracked in line state rather than re-derived
// from the current selection — otherwise clearing the item in between
// (pick A -> "No item" -> pick B) strands item A's price on item B's line.
interface LineDraft extends InvoiceFormLine {
  key: string;
  itemId: string | null;
  itemFilledPrice: string | null;
  itemFilledDescription: string | null;
  itemFilledIncomeAccount: string | null;
  incomeAccountBeforeItem: string | null;
}

function blankLine(defaultIncomeAccountId: string | null, taxable: boolean): LineDraft {
  return {
    key: nextLineKey(),
    description: "",
    quantity: "1",
    unitPrice: "",
    incomeAccountId: defaultIncomeAccountId,
    taxable,
    itemId: null,
    itemFilledPrice: null,
    itemFilledDescription: null,
    itemFilledIncomeAccount: null,
    incomeAccountBeforeItem: null,
  };
}

function fromInitial(initial: InvoiceFormInitial): LineDraft[] {
  return initial.lines.length > 0
    ? initial.lines.map((l) => ({
        ...l,
        key: nextLineKey(),
        itemId: l.itemId ?? null,
        // Values loaded from a saved draft are the user's own by now, so no
        // auto-fill may overwrite them (same rule as the bill form).
        itemFilledPrice: null,
        itemFilledDescription: null,
        itemFilledIncomeAccount: null,
        incomeAccountBeforeItem: null,
      }))
    : [blankLine(null, initial.taxRateId !== null)];
}

function lineHasContent(line: LineDraft): boolean {
  return line.description.trim() !== "" || line.unitPrice.trim() !== "" || line.itemId !== null;
}

function lineError(line: LineDraft, item: ApiItem | null): string | null {
  if (!lineHasContent(line)) return null;
  const stocked = item !== null && item.kind === "inventory";
  const qty = line.quantity.trim() || "1";
  if (!QUANTITY_RE.test(qty)) {
    return "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).";
  }
  // invoice_lines.quantity is NOT NULL DEFAULT 1, so only the form and the API
  // can tell "one" from "they forgot" — and silently selling one of something
  // is worse than asking (server/src/invoices.ts's identical refusal).
  if (stocked && line.quantity.trim() === "") {
    return `Enter how many of "${item.name}" you sold.`;
  }
  if (line.unitPrice.trim() === "") {
    return item ? `"${item.name}" doesn't have a price set, so enter one for this line.` : "Enter a price for this line.";
  }
  if (item === null && !UNIT_PRICE_RE.test(line.unitPrice.trim())) {
    return "Enter a price like 120 or 120.50 (no more than 2 decimal places, no leading zeros, no more than 13 digits before the decimal point).";
  }
  if (item !== null && !ITEM_UNIT_PRICE_RE.test(line.unitPrice.trim())) {
    return "Enter a price like 120 or 120.50 (no more than 4 decimal places, no leading zeros, no more than 13 digits before the decimal point).";
  }
  if (!line.incomeAccountId) {
    return item
      ? `"${item.name}" doesn't have an income category set, so pick one for this line.`
      : "Pick an income category for this line.";
  }
  return null;
}

export function InvoiceForm({
  mode,
  documentOnly = false,
  invoiceId,
  initial,
  onCancel,
  onSaved,
}: {
  mode: "create" | "edit";
  documentOnly?: boolean;
  invoiceId?: string;
  initial?: InvoiceFormInitial;
  onCancel: () => void;
  onSaved: (result: { invoiceId: string; issued: boolean }) => void;
}) {
  const { selectedCompanyId } = useCompany();
  const { data: customers } = useApiGet<CustomerOption[]>("/api/c/customers", selectedCompanyId);
  const { data: accounts, retry: retryAccounts } = useApiGet<ApiAccount[]>(
    "/api/c/accounts", selectedCompanyId,
  );
  // Sales tax gate — see SalesTaxSettings' comment above. The rates list is
  // only fetched at all once the company is known to have tax on.
  const { data: taxSettings } = useApiGet<SalesTaxSettings>(
    "/api/c/settings/sales-tax", selectedCompanyId,
  );
  const taxEnabled = taxSettings?.enabled === true;
  const { data: taxRates } = useApiGet<TaxRateOption[]>(
    taxEnabled ? "/api/c/tax-rates" : null, selectedCompanyId,
  );
  // Active items only. A company with none never renders an Item column at
  // all — see the file header. A FAILED fetch does NOT masquerade as that (F5).
  const {
    data: itemsBody, error: itemsError, loading: itemsLoading, retry: retryItems,
  } = useApiGet<{ items: ApiItem[] }>("/api/c/items", selectedCompanyId);
  const items = useMemo(() => itemsBody?.items ?? [], [itemsBody]);
  const itemsUnknown = itemsError !== null || (itemsLoading && itemsBody === null);
  const itemsAvailable = items.length > 0 || itemsError !== null;
  const itemsById = useMemo(() => new Map(items.map((i) => [i.id, i])), [items]);
  // A document_only invoice posts nothing and so cannot move stock — 0018
  // forbids a movement without a posted entry, and server/src/invoices.ts
  // refuses it at draft. Keep those items out of reach rather than letting
  // someone pick one and be told no (the selling-side twin of F2).
  const selectableItems = useMemo(
    () => (documentOnly ? items.filter((i) => i.kind !== "inventory") : items),
    [items, documentOnly],
  );

  const [customerId, setCustomerId] = useState<string | null>(initial?.customerId ?? null);
  const [invoiceDate, setInvoiceDate] = useState(initial?.invoiceDate ?? todayIso());
  const [dueDate, setDueDate] = useState(initial?.dueDate ?? "");
  const [dueDateTouched, setDueDateTouched] = useState(mode === "edit");
  const [memo, setMemo] = useState(initial?.memo ?? "");
  const [taxRateId, setTaxRateId] = useState<string | null>(initial?.taxRateId ?? null);
  const [lines, setLines] = useState<LineDraft[]>(() => (initial ? fromInitial(initial) : [blankLine(null, false)]));

  const [confirmingIssue, setConfirmingIssue] = useState(false);
  const [saving, setSaving] = useState<"draft" | "issue" | "edit" | null>(null);
  const [submitError, setSubmitError] = useState<string | null>(null);
  const [retryAction, setRetryAction] = useState<(() => void) | null>(null);
  // "+ New category" (Chart Management Task 2): which line's picker opened
  // the shared add-account modal (income preset — these are invoice lines);
  // on success the new account is selected on that line.
  const [newCatLineKey, setNewCatLineKey] = useState<string | null>(null);
  // Same idea for "+ New item", straight from a line's item picker.
  const [newItemLineKey, setNewItemLineKey] = useState<string | null>(null);

  const itemGroups: PickerGroup[] = useMemo(() => {
    const groups: PickerGroup[] = [
      { label: "No item", options: [{ id: NO_ITEM, label: "No item — just a description" }] },
    ];
    for (const kind of ITEM_KINDS) {
      const options = selectableItems
        .filter((i) => i.kind === kind)
        .map((i) => ({ id: i.id, label: i.sku ? `${i.name} (${i.sku})` : i.name }));
      if (options.length > 0) groups.push({ label: KIND_HEADINGS[kind], options });
    }
    return groups;
  }, [selectableItems]);

  const customerGroups: PickerGroup[] = useMemo(
    () => [{
      label: "Customers",
      options: (customers ?? []).map((c) => ({
        // "(tax-exempt)" suffix per the design spec — only meaningful (and
        // only shown) when the company has sales tax on.
        id: c.id, label: taxEnabled && c.taxExempt ? `${c.name} (tax-exempt)` : c.name,
      })),
    }],
    [customers, taxEnabled],
  );
  // Income-restricted picker under the standard sections (Income / Other
  // income) — Chart Management Task 3. The restriction itself is unchanged:
  // an invoice line's category is always an income account.
  const incomeGroups: PickerGroup[] = useMemo(
    () => accountPickerGroups(accounts, ["income"]),
    [accounts],
  );

  const selectedCustomer = customers?.find((c) => c.id === customerId) ?? null;
  const customerIsExempt = taxEnabled && selectedCustomer?.taxExempt === true;

  // The rate picker's options: every ACTIVE rate, plus — when editing a draft
  // that references a since-deactivated rate — that one rate, marked, so the
  // form doesn't silently blank an existing selection (the API will refuse
  // the save with its own English until it's changed).
  const selectableRates = useMemo(() => {
    const rates = (taxRates ?? []).filter((r) => r.active || r.id === taxRateId);
    return rates;
  }, [taxRates, taxRateId]);
  const selectedTaxRate = taxRateId !== null
    ? (taxRates ?? []).find((r) => r.id === taxRateId) ?? null
    : null;

  function handleCustomerChange(id: string) {
    setCustomerId(id);
    if (!dueDateTouched) {
      const customer = customers?.find((c) => c.id === id);
      if (customer) setDueDate(addDaysIso(invoiceDate, customer.termsDays));
    }
    // Picking a tax-exempt customer clears the rate (the picker also disables
    // with an English note below) — the server refuses the combination anyway;
    // this just keeps the form from ever offering it.
    if (taxEnabled) {
      const customer = customers?.find((c) => c.id === id);
      if (customer?.taxExempt) setTaxRateId(null);
    }
  }

  function handleTaxRateChange(id: string | null) {
    // Picking a rate where there was none defaults every line to taxable
    // (spec: "default on when rate picked") — but ONLY when no line already
    // carries a taxable flag (i.e. a fresh, all-unchecked form). A draft
    // being edited (or a form whose rate was cleared and re-picked) keeps
    // its existing per-line mix rather than having it force-overwritten.
    if (id !== null && taxRateId === null) {
      setLines((ls) => (ls.every((l) => !l.taxable) ? ls.map((l) => ({ ...l, taxable: true })) : ls));
    }
    setTaxRateId(id);
  }

  function handleInvoiceDateChange(value: string) {
    setInvoiceDate(value);
    if (!dueDateTouched && selectedCustomer) {
      setDueDate(addDaysIso(value, selectedCustomer.termsDays));
    }
  }

  function updateLine(key: string, patch: Partial<LineDraft>) {
    setLines((ls) => ls.map((l) => (l.key === key ? { ...l, ...patch } : l)));
  }

  /** Picking an item fills the price and the income category from the item.
   *  See bills/BillForm.tsx's handleItemChange for the rule and why it is
   *  stated the way it is — this is its selling-side twin, and the two must
   *  not drift apart. */
  function handleItemChange(line: LineDraft, pickedId: string) {
    const nextId = pickedId === NO_ITEM ? null : pickedId;
    const next = nextId ? itemsById.get(nextId) ?? null : null;

    if (next === null) {
      // Hand back the category the item took over (F8) — but only if the cell
      // still holds what the item put there; one the user has since picked is
      // theirs. Same rule as the price and the description, and as the bill
      // form's identical block. The itemFilled* markers survive the clear:
      // they are how the NEXT pick knows those values weren't typed.
      const categoryIsOurs =
        line.incomeAccountId === null ||
        (line.itemFilledIncomeAccount !== null &&
          line.itemFilledIncomeAccount === line.incomeAccountId);
      updateLine(line.key, {
        itemId: null,
        incomeAccountId: categoryIsOurs ? line.incomeAccountBeforeItem : line.incomeAccountId,
        itemFilledIncomeAccount: null,
        incomeAccountBeforeItem: null,
      });
      return;
    }

    const patch: Partial<LineDraft> = { itemId: next.id };
    if (line.itemId === null) patch.incomeAccountBeforeItem = line.incomeAccountId;

    const priceIsOurs =
      line.unitPrice.trim() === "" ||
      (line.itemFilledPrice !== null && line.itemFilledPrice === line.unitPrice.trim());
    if (priceIsOurs) {
      const filled = next.salesPrice !== null ? trimTrailingZeros(next.salesPrice) : "";
      patch.unitPrice = filled;
      patch.itemFilledPrice = filled === "" ? null : filled;
    }

    const descriptionIsOurs =
      line.description.trim() === "" ||
      (line.itemFilledDescription !== null && line.itemFilledDescription === line.description.trim());
    if (descriptionIsOurs) {
      patch.description = next.name;
      patch.itemFilledDescription = next.name;
    }

    if (next.incomeAccountId !== null) {
      patch.incomeAccountId = next.incomeAccountId;
      patch.itemFilledIncomeAccount = next.incomeAccountId;
    }
    updateLine(line.key, patch);
  }

  function addLine() {
    // New lines default to taxable while a rate is selected (spec) — and to
    // not-taxable otherwise, matching the column's own DB default.
    setLines((ls) => [...ls, blankLine(ls[ls.length - 1]?.incomeAccountId ?? null, taxRateId !== null)]);
  }

  function removeLine(key: string) {
    setLines((ls) => (ls.length > 1 ? ls.filter((l) => l.key !== key) : ls));
  }

  const itemFor = (l: LineDraft): ApiItem | null => (l.itemId ? itemsById.get(l.itemId) ?? null : null);

  const activeLines = lines.filter(lineHasContent);
  const linesValid =
    activeLines.length > 0 && activeLines.every((l) => lineError(l, itemFor(l)) === null);
  const previews = lines.map((l) => computeLineAmountPreview(l.quantity, l.unitPrice));
  const subtotal = sumMoney(previews.filter((p): p is string => p !== null));
  // Client-side tax preview (carried-over item A): same integer-cents HALF-UP
  // as the server's computeTaxAmount() — see taxAmount.ts. The server's
  // breakdown on the save response stays authoritative (detail/print always
  // render what the server returned, never this preview).
  const showTax = taxEnabled && selectedTaxRate !== null;
  const taxableSubtotal = sumMoney(
    lines.map((l, i) => (l.taxable ? previews[i] : null)).filter((p): p is string => p != null),
  );
  const taxAmount = showTax
    ? computeTaxPreview(taxableSubtotal, selectedTaxRate.ratePercent) ?? "0.0000"
    : "0.0000";
  const total = showTax ? sumMoney([subtotal, taxAmount]) : subtotal;
  const formValid = customerId !== null && invoiceDate !== "" && linesValid;

  function buildLinesPayload() {
    return activeLines.map((l) => ({
      description: l.description.trim() || undefined,
      quantity: l.quantity.trim() || undefined,
      unitPrice: l.unitPrice.trim(),
      incomeAccountId: l.incomeAccountId!,
      // Only companies with sales tax on ever send tax fields at all — a
      // disabled company's payload is byte-for-byte what it was before the
      // feature existed (spec: invisible until enabled). itemId is omitted on
      // the same payload-compatibility grounds, but for a DIFFERENT reason:
      // sales tax is a feature a company switches on, whereas inventory is
      // always available (Items and Purchase orders are permanent sidebar
      // entries, per CLAUDE.md §9 as amended) — a company simply hasn't
      // defined an item yet.
      ...(taxEnabled ? { taxable: l.taxable } : {}),
      ...(l.itemId ? { itemId: l.itemId } : {}),
    }));
  }

  function buildHeaderPayload() {
    return {
      customerId: customerId!,
      invoiceDate,
      dueDate: dueDateTouched && dueDate ? dueDate : undefined,
      memo: memo.trim() || undefined,
      ...(taxEnabled && taxRateId !== null ? { taxRateId } : {}),
    };
  }

  async function handleSaveDraft() {
    if (!formValid || !selectedCompanyId) return;
    setSaving("draft");
    setSubmitError(null);
    try {
      const res = await apiPost<{ invoiceId: string }>(
        "/api/c/invoices",
        { ...buildHeaderPayload(), documentOnly: false, lines: buildLinesPayload() },
        selectedCompanyId,
      );
      onSaved({ invoiceId: res.invoiceId, issued: false });
    } catch (err) {
      setSubmitError(err instanceof ApiError ? err.message : "Couldn't save that invoice. Try again.");
      setRetryAction(() => () => void handleSaveDraft());
    } finally {
      setSaving(null);
    }
  }

  async function handleCreateAndIssue() {
    if (!formValid || !selectedCompanyId) return;
    setSaving("issue");
    setSubmitError(null);
    try {
      const created = await apiPost<{ invoiceId: string }>(
        "/api/c/invoices",
        { ...buildHeaderPayload(), documentOnly, lines: buildLinesPayload() },
        selectedCompanyId,
      );
      await apiPost(`/api/c/invoices/${created.invoiceId}/issue`, undefined, selectedCompanyId);
      onSaved({ invoiceId: created.invoiceId, issued: true });
    } catch (err) {
      setSubmitError(err instanceof ApiError ? err.message : "Couldn't save that invoice. Try again.");
      setRetryAction(() => () => void handleCreateAndIssue());
      setConfirmingIssue(false);
    } finally {
      setSaving(null);
    }
  }

  async function handleSaveEdits() {
    if (!formValid || !selectedCompanyId || !invoiceId) return;
    setSaving("edit");
    setSubmitError(null);
    try {
      await apiPatch(
        `/api/c/invoices/${invoiceId}`,
        { ...buildHeaderPayload(), lines: buildLinesPayload() },
        selectedCompanyId,
      );
      onSaved({ invoiceId, issued: false });
    } catch (err) {
      setSubmitError(err instanceof ApiError ? err.message : "Couldn't save those changes. Try again.");
      setRetryAction(() => () => void handleSaveEdits());
    } finally {
      setSaving(null);
    }
  }

  // Mirrors the server's own issue summary ("… includes $84.56 CA Sales
  // Tax.") so the confirm reads the same as the result.
  const confirmTaxClause =
    showTax && !isZeroMoney(taxAmount)
      ? ` — including ${fmtMoney(taxAmount)} ${selectedTaxRate.name}`
      : "";
  const confirmSentence =
    selectedCustomer && formValid
      ? `This will record ${fmtMoney(total)} owed by ${selectedCustomer.name} across ${activeLines.length} item${activeLines.length === 1 ? "" : "s"}${confirmTaxClause}.`
      : null;

  return (
    <div className="card invoice-form" style={{ marginBottom: 18 }}>
      <h2>{mode === "edit" ? "Edit draft" : documentOnly ? "Add an older invoice" : "New invoice"}</h2>

      {documentOnly && (
        <p className="badge" style={{ marginBottom: 14, display: "inline-flex" }}>
          No accounting impact — already in your books
        </p>
      )}

      {/* Never allowed to look like "this company has no items" (F5). */}
      {itemsError && (
        <ErrorBanner
          message={`We couldn't load this company's items, so the Item column can't be filled in: ${itemsError}`}
          onRetry={retryItems}
        />
      )}

      {submitError && <ErrorBanner message={submitError} onRetry={() => retryAction?.()} />}

      <SearchablePicker
        id="inv-customer"
        label="Customer"
        placeholder="Search customers…"
        groups={customerGroups}
        value={customerId}
        onChange={handleCustomerChange}
      />
      {selectedCustomer && <p className="invoice-form-terms-hint">Terms: Net {selectedCustomer.termsDays}</p>}

      <div className="field-row">
        <div className="field">
          <label htmlFor="inv-date">Invoice date</label>
          <input
            id="inv-date"
            className="text-input"
            type="date"
            value={invoiceDate}
            onChange={(ev) => handleInvoiceDateChange(ev.target.value)}
          />
        </div>
        <div className="field">
          <label htmlFor="inv-due">Due date</label>
          <input
            id="inv-due"
            className="text-input"
            type="date"
            value={dueDate}
            onChange={(ev) => {
              setDueDateTouched(true);
              setDueDate(ev.target.value);
            }}
          />
        </div>
      </div>

      <div className="field">
        <label htmlFor="inv-memo">Memo (optional)</label>
        <input
          id="inv-memo"
          className="text-input"
          type="text"
          value={memo}
          onChange={(ev) => setMemo(ev.target.value)}
        />
      </div>

      {taxEnabled && (
        <div className="field">
          <label htmlFor="inv-tax-rate">Sales tax</label>
          <select
            id="inv-tax-rate"
            className="select-input"
            value={taxRateId ?? ""}
            disabled={customerIsExempt}
            onChange={(ev) => handleTaxRateChange(ev.target.value === "" ? null : ev.target.value)}
          >
            <option value="">No tax</option>
            {selectableRates.map((r) => (
              <option key={r.id} value={r.id}>
                {r.name} ({trimPercent(r.ratePercent)}%){r.active ? "" : " — inactive"}
              </option>
            ))}
          </select>
          {customerIsExempt && (
            <p className="sub" style={{ marginTop: 6, marginBottom: 0 }}>
              This customer is tax-exempt, so no sales tax is added to their invoices.
            </p>
          )}
        </div>
      )}

      <table className="invoice-lines-table">
        <thead>
          <tr>
            {itemsAvailable && <th>Item</th>}
            <th>Description</th>
            <th className="num">Qty</th>
            <th className="num">Unit price</th>
            <th className="num">Amount</th>
            <th>Income category</th>
            {showTax && <th className="num">Tax</th>}
            <th />
          </tr>
        </thead>
        <tbody>
          {lines.map((line, i) => {
            const preview = previews[i];
            const item = itemFor(line);
            const err = lineHasContent(line) ? lineError(line, item) : null;
            const onHand = item && item.kind === "inventory" ? item.qtyOnHand : null;
            return (
              <Fragment key={line.key}>
                <tr>
                  {itemsAvailable && (
                    <td>
                      <SearchablePicker
                        id={`inv-line-item-${line.key}`}
                        label=""
                        placeholder="Search items…"
                        groups={itemGroups}
                        value={line.itemId ?? NO_ITEM}
                        onChange={(id) => handleItemChange(line, id)}
                        disabled={itemsUnknown}
                        action={
                          itemsUnknown
                            ? undefined
                            : { label: "+ New item", onSelect: () => setNewItemLineKey(line.key) }
                        }
                      />
                      {/* What a seller actually needs to know about a stocked
                          item: whether there's any. Deliberately NOT the cost
                          — that posts itself and lives on the item's page. */}
                      {onHand !== null && (
                        <div className="line-note line-note-stock">{fmtQuantity(onHand)} on hand</div>
                      )}
                      {documentOnly && i === 0 && items.some((it) => it.kind === "inventory") && (
                        <div className="line-note">
                          stocked items aren&rsquo;t here: this invoice is for history that&rsquo;s
                          already in your books, so it can&rsquo;t change what&rsquo;s on the shelf
                        </div>
                      )}
                    </td>
                  )}
                  <td>
                    <input
                      className="text-input"
                      type="text"
                      value={line.description}
                      onChange={(ev) => updateLine(line.key, { description: ev.target.value })}
                    />
                  </td>
                  <td className="num">
                    <input
                      className="text-input line-qty"
                      type="text"
                      inputMode="decimal"
                      value={line.quantity}
                      onChange={(ev) => updateLine(line.key, { quantity: ev.target.value })}
                    />
                  </td>
                  <td className="num">
                    <input
                      className="text-input line-price"
                      type="text"
                      inputMode="decimal"
                      placeholder="0.00"
                      value={line.unitPrice}
                      onChange={(ev) => updateLine(line.key, { unitPrice: ev.target.value })}
                    />
                  </td>
                  <td className="num invoice-line-amount">{preview ? fmtMoney(preview) : "—"}</td>
                  <td>
                    <SearchablePicker
                      id={`inv-line-cat-${line.key}`}
                      label=""
                      placeholder="Search categories…"
                      groups={incomeGroups}
                      value={line.incomeAccountId}
                      onChange={(id) => updateLine(line.key, { incomeAccountId: id })}
                      action={{ label: "+ New category", onSelect: () => setNewCatLineKey(line.key) }}
                    />
                  </td>
                  {showTax && (
                    <td className="num">
                      <input
                        type="checkbox"
                        aria-label="Charge sales tax on this line"
                        checked={line.taxable}
                        onChange={(ev) => updateLine(line.key, { taxable: ev.target.checked })}
                      />
                    </td>
                  )}
                  <td>
                    <button
                      type="button"
                      className="acct-link"
                      disabled={lines.length <= 1}
                      onClick={() => removeLine(line.key)}
                    >
                      Remove
                    </button>
                  </td>
                </tr>
                {err && (
                  <tr>
                    <td colSpan={(showTax ? 7 : 6) + (itemsAvailable ? 1 : 0)} className="field-error">
                      {err}
                    </td>
                  </tr>
                )}
              </Fragment>
            );
          })}
        </tbody>
      </table>
      <button type="button" className="acct-link" style={{ marginTop: 6 }} onClick={addLine}>
        + Add line
      </button>

      {showTax ? (
        <div className="invoice-form-totals-block">
          <div className="invoice-form-total invoice-form-subtotal">
            <span>Subtotal</span>
            <span className="invoice-form-total-amount">{fmtMoney(subtotal)}</span>
          </div>
          <div className="invoice-form-total invoice-form-subtotal">
            <span>
              {selectedTaxRate.name} ({trimPercent(selectedTaxRate.ratePercent)}%)
            </span>
            <span className="invoice-form-total-amount">{fmtMoney(taxAmount)}</span>
          </div>
          <div className="invoice-form-total">
            <span>Total</span>
            <span className="invoice-form-total-amount">{fmtMoney(total)}</span>
          </div>
        </div>
      ) : (
        <div className="invoice-form-total">
          <span>Total</span>
          <span className="invoice-form-total-amount">{fmtMoney(total)}</span>
        </div>
      )}

      {!confirmingIssue ? (
        <div className="wizard-actions">
          {mode === "edit" ? (
            <button type="button" className="btn" disabled={!formValid || saving !== null} onClick={() => void handleSaveEdits()}>
              {saving === "edit" ? "Saving…" : "Save changes"}
            </button>
          ) : documentOnly ? (
            <button
              type="button"
              className="btn"
              disabled={!formValid || saving !== null}
              onClick={() => void handleCreateAndIssue()}
            >
              {saving === "issue" ? "Saving…" : "Save invoice"}
            </button>
          ) : (
            <>
              <button
                type="button"
                className="btn"
                disabled={!formValid || saving !== null}
                onClick={() => void handleSaveDraft()}
              >
                {saving === "draft" ? "Saving…" : "Save draft"}
              </button>
              <button
                type="button"
                className="btn"
                disabled={!formValid || saving !== null}
                onClick={() => setConfirmingIssue(true)}
              >
                Save &amp; send to the books
              </button>
            </>
          )}
          <button type="button" className="acct-link" onClick={onCancel}>
            Cancel
          </button>
        </div>
      ) : (
        <div className="confirm-panel">
          <p>{confirmSentence}</p>
          <div className="wizard-actions">
            <button type="button" className="btn" disabled={saving !== null} onClick={() => void handleCreateAndIssue()}>
              {saving === "issue" ? "Sending…" : "Yes, send it"}
            </button>
            <button type="button" className="acct-link" disabled={saving !== null} onClick={() => setConfirmingIssue(false)}>
              Cancel
            </button>
          </div>
        </div>
      )}

      {mode === "create" && !documentOnly && (
        <p className="invoice-form-drafts-note">Drafts stay until issued or edited.</p>
      )}

      {newCatLineKey !== null && selectedCompanyId && (
        <NewAccountModal
          companyId={selectedCompanyId}
          title="New category"
          presetType="income"
          lockType
          typeNote="Invoice lines use income categories."
          onClose={() => setNewCatLineKey(null)}
          onCreated={(acct) => {
            updateLine(newCatLineKey, { incomeAccountId: acct.id });
            setNewCatLineKey(null);
            retryAccounts(); // pull the fresh account into the picker's groups
          }}
        />
      )}

      {newItemLineKey !== null && selectedCompanyId && (
        <ItemModal
          companyId={selectedCompanyId}
          onClose={() => setNewItemLineKey(null)}
          onSaved={(created) => {
            const key = newItemLineKey;
            setNewItemLineKey(null);
            retryItems();
            // The refetch is still in flight, so fill this line from what the
            // API just handed back rather than waiting for the list.
            const line = lines.find((l) => l.key === key);
            updateLine(key, {
              itemId: created.id,
              description: line && line.description.trim() !== "" ? line.description : created.name,
              unitPrice:
                line && line.unitPrice.trim() !== ""
                  ? line.unitPrice
                  : created.salesPrice !== null
                    ? trimTrailingZeros(created.salesPrice)
                    : "",
              incomeAccountId: created.incomeAccountId ?? line?.incomeAccountId ?? null,
            });
          }}
        />
      )}
    </div>
  );
}

/** "12.5000" -> "12.5" — a stored numeric(19,4) rendered as what a person
 *  would have typed. Pure string surgery, never a parsed number (§2). */
function trimTrailingZeros(value: string): string {
  if (!value.includes(".")) return value;
  return value.replace(/0+$/, "").replace(/\.$/, "") || "0";
}
