// Bill form — AP Task 4, mechanical mirror of invoices/InvoiceForm.tsx
// (vendor/bill vocabulary). One component backs three call sites:
//   - "New bill" on #bills (mode="create", documentOnly=false)
//   - "Add older bill (already in your books)" on #bills
//     (mode="create", documentOnly=true)
//   - "Edit" on a draft's #bill/: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/bills.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).
//
// Category picker groups use the STANDARD sections (Chart Management
// Task 3), expenses first with income last — same likelihood ordering the
// old spending-first four-bucket grouping had, and asset sections included
// because a bill line may legitimately be an asset purchase (design spec).
// The company's A/P account itself appears under Accounts payable and is
// deliberately NOT filtered out here — picking it is refused with a friendly
// 422 by the API (bills.ts's NOT_CATEGORY_ACCOUNT), which is the one source
// of truth for that rule (CLAUDE.md §12); this form doesn't duplicate it.
//
// "Save & record what I owe" shows the confirm sentence BEFORE anything is
// submitted (mirrors InvoiceForm's "Save & send to the books" confirm,
// design brief's confirm-sentence-first pattern: 'This will record $150.00
// owed to Cascade Supply Co — due Aug 30.'), then creates the draft and
// issues it in two calls. Document-only bills 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). A bill line may name an item, and for a
// company that stocks anything, RECEIVING IS ENTERING THE BILL — there is no
// separate receipt document and no second entry (spec as-built decision 1), so
// the quantity on this form IS the quantity received.
//
// Picking an item fills the cost from the item and targets the right account
// automatically:
//   - a STOCKED item posts to its own inventory account, which is not the
//     user's to choose (server/src/bills.ts refuses a different one), so the
//     Category cell shows that account as plain text and the line says out
//     loud what it will do to the shelf: "adds 10 to stock on hand".
//   - a NON-STOCKED item prefills its cost account, still editable — a one-off
//     purchase can be coded wherever it belongs.
//   - a line with NO item behaves EXACTLY as it always has: same fields, same
//     validation, same payload.
//
// 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. If the items fetch FAILS, that is said out loud rather
// than degrading into "this company has no items" (review finding F5).
// ---------------------------------------------------------------------------

import { Fragment, useEffect, useMemo, useState } from "react";
import { useCompany } from "../lib/company";
import { useApiGet } from "../lib/useApiGet";
import { apiPost, apiPatch, ApiError } from "../lib/api";
import { todayIso, addDaysIso, fmtDate } from "../lib/date";
import { fmtMoney } from "../lib/money";
import { ErrorBanner } from "../components/ErrorBanner";
import { SearchablePicker, type PickerGroup } from "../components/SearchablePicker";
import { NewAccountModal } from "../accounts/NewAccountModal";
import type { ApiAccount, LedgerType } from "../accounts/subtypes";
import { accountPickerGroups } from "../accounts/pickerGroups";
import { computeLineAmountPreview, sumMoney } from "./lineAmount";
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/bills.ts's itemUnitPriceSchema: FOUR decimals, accepted only on a
// line that names an item, because sub-cent purchase costs are real (a washer
// costs $0.0042). A line with no item keeps the two-decimal rule and its exact
// original message.
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 (those
 *  are numeric strings), so it can't collide with one. */
const NO_ITEM = "__no_item__";

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


export interface BillFormLine {
  description: string;
  quantity: string;
  unitPrice: string;
  categoryAccountId: string | null;
  /** 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;
  /** The purchase order line this one fulfils, carried in by the receive
   *  handoff. Never user-editable: it comes from the order or not at all. */
  poLineId?: string | null;
}

export interface BillFormInitial {
  vendorId: string;
  billDate: string;
  dueDate: string;
  refNo: string;
  memo: string;
  lines: BillFormLine[];
}

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

// ---------------------------------------------------------------------------
// Per-line editor state. The three fields below the payload ones are UI-only
// bookkeeping and never go over the wire.
//
// itemFilledPrice / itemFilledDescription record what an ITEM last wrote into
// this line, so "is this value still the one an item put there, or has the
// user since typed their own?" is a question this form can answer. Review
// finding F1: they used to be re-derived from the currently-selected item,
// which meant clearing the item first (pick A -> "No item" -> pick B) left
// `previous` null, both guards false, and item A's price and description
// stranded on item B's line. On a stocked line that stale price becomes the
// receipt cost and therefore the item's average cost — the exact failure the
// guard exists to prevent. Kept in state, a clear-then-pick behaves
// identically to a direct switch.
//
// categoryBeforeItem remembers the category the line carried before the FIRST
// item took it over, so clearing the item hands it back instead of leaving an
// ordinary line with no category at all (review finding F8).
// ---------------------------------------------------------------------------
interface LineDraft extends BillFormLine {
  key: string;
  itemId: string | null;
  poLineId: string | null;
  itemFilledPrice: string | null;
  itemFilledDescription: string | null;
  itemFilledCategory: string | null;
  categoryBeforeItem: string | null;
}

function blankLine(defaultCategoryAccountId: string | null): LineDraft {
  return {
    key: nextLineKey(),
    description: "",
    quantity: "1",
    unitPrice: "",
    categoryAccountId: defaultCategoryAccountId,
    itemId: null,
    poLineId: null,
    itemFilledPrice: null,
    itemFilledDescription: null,
    itemFilledCategory: null,
    categoryBeforeItem: null,
  };
}

function fromInitial(initial: BillFormInitial): LineDraft[] {
  return initial.lines.length > 0
    ? initial.lines.map((l) => ({
        ...l,
        key: nextLineKey(),
        itemId: l.itemId ?? null,
        poLineId: l.poLineId ?? null,
        // A line arriving from a saved draft or a receive prefill carries
        // values that came from the item, but they are equally the user's own
        // by now (they were saved). Treat them as typed: never silently
        // overwritten, only replaced when the user picks a different item and
        // the value is blank.
        itemFilledPrice: null,
        itemFilledDescription: null,
        itemFilledCategory: null,
        categoryBeforeItem: null,
      }))
    : [blankLine(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).";
  }
  // bill_lines.quantity is NOT NULL DEFAULT 1, so the database can't tell
  // "omitted" from "one" — silently receiving one of something is worse than
  // asking (server/src/bills.ts says the same thing at the same moment).
  if (stocked && line.quantity.trim() === "") {
    return `Enter how many of "${item.name}" you received.`;
  }
  if (line.unitPrice.trim() === "") {
    return item ? `Enter what "${item.name}" cost on this bill.` : "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).";
  }
  // A stocked line has no category to pick — its cost goes to the item's own
  // inventory account, and the API refuses any other.
  if (!stocked && !line.categoryAccountId) {
    return item
      ? `"${item.name}" doesn't have a cost account set, so pick a category for this line.`
      : "Pick a category for this line.";
  }
  return null;
}

/** "2026-07-16" -> "Jul 16" — the confirm sentence's short date form (mirrors
 * server/src/bills.ts's formatShortDate(), used in the issue summary this
 * confirm text is previewing) built from fmtDate's already-abbreviated
 * month, just without the trailing year. */
function fmtShortDate(iso: string): string {
  return fmtDate(iso).replace(/, \d{4}$/, "");
}

/** Expense sections first, asset sections next (a bill line may be an asset
 * purchase), income last — the standard-groups successor to the old
 * spending/money/other/income ordering (see the file header). */
const BILL_CATEGORY_TYPES: readonly LedgerType[] =
  ["expense", "asset", "liability", "equity", "income"];

function categoryGroups(accounts: ApiAccount[] | null | undefined): PickerGroup[] {
  return accountPickerGroups(accounts, BILL_CATEGORY_TYPES);
}

export function BillForm({
  mode,
  documentOnly = false,
  billId,
  initial,
  notice,
  onCancel,
  onSaved,
}: {
  mode: "create" | "edit";
  documentOnly?: boolean;
  billId?: string;
  initial?: BillFormInitial;
  /** The receive prefill's own sentence about quantities it left off because
   *  they're already on a bill nobody has entered (server/src/purchaseOrders.ts).
   *  Shown verbatim — it names the item and the number. */
  notice?: string | null;
  onCancel: () => void;
  onSaved: (result: { billId: string; issued: boolean }) => void;
}) {
  const { selectedCompanyId } = useCompany();
  const { data: vendors } = useApiGet<VendorOption[]>("/api/c/vendors", selectedCompanyId);
  const { data: accounts, retry: retryAccounts } = useApiGet<ApiAccount[]>(
    "/api/c/accounts", selectedCompanyId,
  );
  // Active items only — the picker never offers something that's been turned
  // off (mirrors accountPickerGroups' active-only rule). A company with none
  // gets no Item column at all; see the file header.
  const {
    data: itemsBody, error: itemsError, loading: itemsLoading, retry: retryItems,
  } = useApiGet<{ items: ApiItem[] }>("/api/c/items", selectedCompanyId);
  const items = useMemo(() => itemsBody?.items ?? [], [itemsBody]);
  // A FAILED fetch is not "this company has no items" (review finding F5).
  // Left unsaid, the Item column would simply vanish on a company that stocks
  // things, with a stocked line's itemId still sitting in state — so the
  // failure is reported and the column is held open while it is unresolved.
  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 bill records history that is already in the books, so it
  // posts nothing — and stock cannot move without a posted entry behind it
  // (0018). The API refuses a stocked line on one at draft time; this keeps
  // those items out of the picker so nobody gets there (review finding F2).
  const selectableItems = useMemo(
    () => (documentOnly ? items.filter((i) => i.kind !== "inventory") : items),
    [items, documentOnly],
  );

  const [vendorId, setVendorId] = useState<string | null>(initial?.vendorId ?? null);
  const [billDate, setBillDate] = useState(initial?.billDate ?? todayIso());
  const [dueDate, setDueDate] = useState(initial?.dueDate ?? "");
  const [dueDateTouched, setDueDateTouched] = useState(mode === "edit");
  const [refNo, setRefNo] = useState(initial?.refNo ?? "");
  const [memo, setMemo] = useState(initial?.memo ?? "");
  const [lines, setLines] = useState<LineDraft[]>(() => (initial ? fromInitial(initial) : [blankLine(null)]));

  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 (expense preset — these are bill 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 vendorGroups: PickerGroup[] = useMemo(
    () => [{ label: "Vendors", options: (vendors ?? []).map((v) => ({ id: v.id, label: v.name })) }],
    [vendors],
  );
  const categoryPickerGroups = useMemo(() => categoryGroups(accounts), [accounts]);
  // Grouped by kind so a stocked part and a service don't sit in one
  // undifferentiated list — the same standard-sections treatment the account
  // pickers get. The "no item" row leads, so clearing a line is one click.
  const itemGroups: PickerGroup[] = useMemo(() => {
    const groups: PickerGroup[] = [
      { label: "No item", options: [{ id: NO_ITEM, label: "No item — just a category" }] },
    ];
    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 selectedVendor = vendors?.find((v) => v.id === vendorId) ?? null;

  // A create-mode form can arrive with a vendor already chosen but no due
  // date (the sales tax report's "Record a tax payment" prefill —
  // BillsScreen's newBillPrefillHandoff consumption): once vendors load,
  // default the due date from that vendor's terms exactly as if the vendor
  // had been picked by hand (handleVendorChange below). Guarded on
  // dueDateTouched so it never overwrites a user's own edit.
  useEffect(() => {
    if (mode === "create" && !dueDateTouched && dueDate === "" && selectedVendor) {
      setDueDate(addDaysIso(billDate, selectedVendor.termsDays));
    }
  }, [mode, dueDateTouched, dueDate, selectedVendor, billDate]);

  function handleVendorChange(id: string) {
    setVendorId(id);
    if (!dueDateTouched) {
      const vendor = vendors?.find((v) => v.id === id);
      if (vendor) setDueDate(addDaysIso(billDate, vendor.termsDays));
    }
  }

  function handleBillDateChange(value: string) {
    setBillDate(value);
    if (!dueDateTouched && selectedVendor) {
      setDueDate(addDaysIso(value, selectedVendor.termsDays));
    }
  }

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

  /**
   * Picking an item on a line fills in what the item already knows and points
   * the line at the right account.
   *
   * The rule, stated once because it is the whole of finding F1: an auto-fill
   * only ever replaces a BLANK field or a value THIS FORM auto-filled from a
   * previous item (tracked in line state, not re-derived from the current
   * selection — see LineDraft's comment). Anything the user typed, and
   * anything loaded from a saved draft, is theirs and is never touched.
   *
   * And when the new item has nothing to offer for a field this form had
   * filled, the stale value is CLEARED rather than left behind: one item's
   * cost sitting on another item's line is how the wrong number gets posted,
   * whether it arrived by a direct switch or via "No item" in between.
   */
  function handleItemChange(line: LineDraft, pickedId: string) {
    const nextId = pickedId === NO_ITEM ? null : pickedId;
    const next = nextId ? itemsById.get(nextId) ?? null : null;

    if (next === null) {
      // Clearing leaves an ordinary category line behind: the purchase-order
      // link goes (a bill line can't fulfil an order without saying with
      // what), and the category the item took over is handed back (F8) — but
      // ONLY if the cell still holds what the item put there. A category the
      // user has since picked for themselves is theirs, exactly as with the
      // price and the description. (The first cut of this fix restored
      // nothing whenever the cell was non-null, which meant a non-stocked
      // item's cost account simply stayed behind; the live probe caught it.)
      // The itemFilled* markers deliberately SURVIVE the clear — they are how
      // the next pick knows those values were never the user's.
      const categoryIsOurs =
        line.categoryAccountId === null ||
        (line.itemFilledCategory !== null && line.itemFilledCategory === line.categoryAccountId);
      updateLine(line.key, {
        itemId: null,
        poLineId: null,
        categoryAccountId: categoryIsOurs ? line.categoryBeforeItem : line.categoryAccountId,
        itemFilledCategory: null,
        categoryBeforeItem: null,
      });
      return;
    }

    const patch: Partial<LineDraft> = { itemId: next.id };

    // Remember the pre-item category once, on the first item this line takes.
    if (line.itemId === null) patch.categoryBeforeItem = line.categoryAccountId;

    const priceIsOurs =
      line.unitPrice.trim() === "" ||
      (line.itemFilledPrice !== null && line.itemFilledPrice === line.unitPrice.trim());
    if (priceIsOurs) {
      const filled = next.purchaseCost !== null ? trimTrailingZeros(next.purchaseCost) : "";
      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;
    }

    // A stocked item's cost has exactly one place to land; anything else is
    // refused by the API and by 0019's bill_lines_item_consistent(). A
    // non-stocked item just prefills its own cost account, still editable.
    patch.categoryAccountId = next.kind === "inventory" ? null : next.cogsAccountId;
    patch.itemFilledCategory = patch.categoryAccountId;
    // A line that no longer names the item the purchase order was for isn't
    // fulfilling that order line any more.
    if (line.poLineId !== null && line.itemId !== next.id) patch.poLineId = null;
    updateLine(line.key, patch);
  }

  function addLine() {
    setLines((ls) => [...ls, blankLine(ls[ls.length - 1]?.categoryAccountId ?? 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 total = sumMoney(previews.filter((p): p is string => p !== null));
  const formValid = vendorId !== null && billDate !== "" && linesValid;

  function buildLinesPayload() {
    return activeLines.map((l) => {
      const item = itemFor(l);
      const stocked = item !== null && item.kind === "inventory";
      return {
        description: l.description.trim() || undefined,
        quantity: l.quantity.trim() || undefined,
        unitPrice: l.unitPrice.trim(),
        // A stocked line has no category of its own to send: the API derives
        // it from the item and refuses any other. Everything else is exactly
        // the payload this form has always sent.
        ...(stocked ? {} : { categoryAccountId: l.categoryAccountId! }),
        ...(l.itemId ? { itemId: l.itemId } : {}),
        ...(l.poLineId ? { poLineId: l.poLineId } : {}),
      };
    });
  }

  function buildHeaderPayload() {
    return {
      vendorId: vendorId!,
      refNo: refNo.trim() || undefined,
      billDate,
      dueDate: dueDateTouched && dueDate ? dueDate : undefined,
      memo: memo.trim() || undefined,
    };
  }

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

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

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

  const confirmSentence =
    selectedVendor && formValid && dueDate
      ? `This will record ${fmtMoney(total)} owed to ${selectedVendor.name} — due ${fmtShortDate(dueDate)}.`
      : null;

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

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

      {notice && <p className="msg-inline po-receive-notice">{notice}</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="bill-vendor"
        label="Vendor"
        placeholder="Search vendors…"
        groups={vendorGroups}
        value={vendorId}
        onChange={handleVendorChange}
      />
      {selectedVendor && <p className="invoice-form-terms-hint">Terms: Net {selectedVendor.termsDays}</p>}

      <div className="field">
        <label htmlFor="bill-ref">Reference number (optional — the vendor's own bill/invoice number)</label>
        <input
          id="bill-ref"
          className="text-input"
          type="text"
          value={refNo}
          onChange={(ev) => setRefNo(ev.target.value)}
        />
      </div>

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

      <div className="field">
        <label htmlFor="bill-memo">Memo (optional)</label>
        <input
          id="bill-memo"
          className="text-input"
          type="text"
          value={memo}
          onChange={(ev) => setMemo(ev.target.value)}
        />
      </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>Category</th>
            <th />
          </tr>
        </thead>
        <tbody>
          {lines.map((line, i) => {
            const preview = previews[i];
            const item = itemFor(line);
            const stocked = item !== null && item.kind === "inventory";
            const err = lineHasContent(line) ? lineError(line, item) : null;
            const colSpan = itemsAvailable ? 7 : 6;
            return (
              <Fragment key={line.key}>
                <tr>
                  {itemsAvailable && (
                    <td>
                      <SearchablePicker
                        id={`bill-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) }
                        }
                      />
                      {/* Said plainly, on the line, before it is saved: this
                          is the whole difference between a stocked item and
                          an expense, and it is the number the shelf will
                          change by. */}
                      {stocked && (
                        <div className="line-note line-note-stock">
                          adds {fmtQuantity(line.quantity.trim() || "1")} to stock on hand
                        </div>
                      )}
                      {line.poLineId && (
                        <div className="line-note">on the purchase order</div>
                      )}
                      {/* F2: a document-only bill posts nothing, so it can't
                          bring stock in. Said where the choice is made, not
                          after a failed save. */}
                      {documentOnly && i === 0 && items.some((it) => it.kind === "inventory") && (
                        <div className="line-note">
                          stocked items aren&rsquo;t here: this bill 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>
                    {stocked ? (
                      // Not a picker: a stocked item's cost has exactly one
                      // legal destination, so showing an empty combobox here
                      // would be offering a choice that doesn't exist. The
                      // account is named in full — §9 as amended: standard
                      // accounting stays visible.
                      <div className="line-fixed-category">
                        {item.inventoryAccountName ?? "This item's inventory account"}
                        <div className="line-note">its inventory account — nothing to pick</div>
                      </div>
                    ) : (
                      <SearchablePicker
                        id={`bill-line-cat-${line.key}`}
                        label=""
                        placeholder="Search categories…"
                        groups={categoryPickerGroups}
                        value={line.categoryAccountId}
                        onChange={(id) => updateLine(line.key, { categoryAccountId: id })}
                        action={{ label: "+ New category", onSelect: () => setNewCatLineKey(line.key) }}
                      />
                    )}
                  </td>
                  <td>
                    <button
                      type="button"
                      className="acct-link"
                      disabled={lines.length <= 1}
                      onClick={() => removeLine(line.key)}
                    >
                      Remove
                    </button>
                  </td>
                </tr>
                {err && (
                  <tr>
                    <td colSpan={colSpan} className="field-error">
                      {err}
                    </td>
                  </tr>
                )}
              </Fragment>
            );
          })}
        </tbody>
      </table>
      <button type="button" className="acct-link" style={{ marginTop: 6 }} onClick={addLine}>
        + Add line
      </button>

      <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 bill"}
            </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; record what I owe
              </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" ? "Recording…" : "Yes, record 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="expense"
          lockType
          typeNote="Bill lines use expense categories."
          onClose={() => setNewCatLineKey(null)}
          onCreated={(acct) => {
            updateLine(newCatLineKey, { categoryAccountId: 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 freshly-created item isn't in itemsById yet (the refetch is
            // 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.purchaseCost !== null
                    ? trimTrailingZeros(created.purchaseCost)
                    : "",
              categoryAccountId: created.kind === "inventory" ? null : created.cogsAccountId,
            });
          }}
        />
      )}
    </div>
  );
}

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