// Purchase order form — Task 4 of docs/superpowers/plans/2026-08-03-inventory.md.
// Shaped like bills/BillForm.tsx (vendor picker, header fields, a lines
// editor with a live HALF-UP amount preview) because it is the same job with
// a different verb, and one shape backs both "New purchase order" on
// #purchase-orders and "Edit" on a draft's own page.
//
// A PURCHASE ORDER POSTS NOTHING. It is a promise, not a transaction: no
// journal entry, no stock movement, no balance moves when one is created,
// sent or cancelled (server/src/purchaseOrders.ts's header says the same). The
// form says so on its face rather than leaving the user to wonder whether
// clicking "Send" has just charged them something.
//
// Regex mirrors: quantitySchema/unitCostSchema below are copied from
// server/src/purchaseOrders.ts's own — client-side validation exists only to
// give inline English before a round trip; the server re-validates everything
// and stays the authority (CLAUDE.md §12).
//
// The cost accepts FOUR decimals, matching the API, because sub-cent purchase
// costs are real (a washer costs $0.0042) — and deliberately not six, which
// the receiving bill's own numeric(19,4) could only round.

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 } from "../lib/date";
import { fmtMoney } from "../lib/money";
import { ErrorBanner } from "../components/ErrorBanner";
import { SearchablePicker, type PickerGroup } from "../components/SearchablePicker";
import { computeLineAmountPreview, sumMoney } from "../bills/lineAmount";
import { ItemModal } from "../items/ItemModal";
import { ITEM_KINDS, KIND_HEADINGS, type ApiItem } from "../items/itemTypes";

const QUANTITY_RE = /^(0|[1-9]\d{0,7})(\.\d{1,4})?$/;
const UNIT_COST_RE = /^(0|[1-9]\d{0,12})(\.\d{1,4})?$/;
const NO_ITEM = "__no_item__";

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

export interface PoFormLine {
  itemId: string | null;
  description: string;
  quantity: string;
  unitCost: string;
}

export interface PoFormInitial {
  vendorId: string;
  orderDate: string;
  expectedDate: string;
  memo: string;
  lines: PoFormLine[];
}

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

interface LineDraft extends PoFormLine {
  key: string;
}

function blankLine(): LineDraft {
  return { key: nextLineKey(), itemId: null, description: "", quantity: "1", unitCost: "" };
}

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

function lineError(line: LineDraft): string | null {
  if (!lineHasContent(line)) return null;
  if (line.itemId === null && line.description.trim() === "") {
    return "Each line needs an item or a description.";
  }
  const qty = line.quantity.trim();
  if (qty === "" || !QUANTITY_RE.test(qty) || !/[1-9]/.test(qty)) {
    return "Enter a quantity greater than zero, like 1 or 0.5.";
  }
  const cost = line.unitCost.trim();
  if (cost === "" || !UNIT_COST_RE.test(cost) || !/[1-9]/.test(cost)) {
    return "Enter a cost greater than zero, like 12 or 12.50 (up to 4 decimal places).";
  }
  return null;
}

/** "12.5000" -> "12.5". Pure string surgery, never a parsed number (§2). */
function trimTrailingZeros(value: string): string {
  if (!value.includes(".")) return value;
  return value.replace(/0+$/, "").replace(/\.$/, "") || "0";
}

export function PurchaseOrderForm({
  mode,
  poId,
  initial,
  onCancel,
  onSaved,
}: {
  mode: "create" | "edit";
  poId?: string;
  initial?: PoFormInitial;
  onCancel: () => void;
  onSaved: (result: { poId: string; issued: boolean }) => void;
}) {
  const { selectedCompanyId } = useCompany();
  const { data: vendors } = useApiGet<VendorOption[]>("/api/c/vendors", selectedCompanyId);
  const { data: itemsBody, retry: retryItems } = useApiGet<{ items: ApiItem[] }>(
    "/api/c/items", selectedCompanyId,
  );
  const items = useMemo(() => itemsBody?.items ?? [], [itemsBody]);
  const itemsById = useMemo(() => new Map(items.map((i) => [i.id, i])), [items]);

  const [vendorId, setVendorId] = useState<string | null>(initial?.vendorId ?? null);
  const [orderDate, setOrderDate] = useState(initial?.orderDate ?? todayIso());
  const [expectedDate, setExpectedDate] = useState(initial?.expectedDate ?? "");
  const [memo, setMemo] = useState(initial?.memo ?? "");
  const [lines, setLines] = useState<LineDraft[]>(() =>
    initial && initial.lines.length > 0
      ? initial.lines.map((l) => ({ ...l, key: nextLineKey() }))
      : [blankLine()],
  );

  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);
  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 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 = items
        .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;
  }, [items]);

  const selectedVendor = vendors?.find((v) => v.id === vendorId) ?? null;

  function updateLine(key: string, patch: Partial<LineDraft>) {
    setLines((ls) => ls.map((l) => (l.key === key ? { ...l, ...patch } : l)));
  }
  function addLine() {
    setLines((ls) => [...ls, blankLine()]);
  }
  function removeLine(key: string) {
    setLines((ls) => (ls.length > 1 ? ls.filter((l) => l.key !== key) : ls));
  }

  function handleItemChange(line: LineDraft, pickedId: string) {
    const nextId = pickedId === NO_ITEM ? null : pickedId;
    const previous = line.itemId ? itemsById.get(line.itemId) ?? null : null;
    const next = nextId ? itemsById.get(nextId) ?? null : null;
    if (next === null) {
      updateLine(line.key, { itemId: null });
      return;
    }
    const costWasTheOldItems =
      previous !== null && previous.purchaseCost !== null &&
      trimTrailingZeros(previous.purchaseCost) === line.unitCost.trim();
    const patch: Partial<LineDraft> = { itemId: next.id };
    if ((line.unitCost.trim() === "" || costWasTheOldItems) && next.purchaseCost !== null) {
      patch.unitCost = trimTrailingZeros(next.purchaseCost);
    }
    if (line.description.trim() === "" || line.description.trim() === previous?.name) {
      patch.description = next.name;
    }
    updateLine(line.key, patch);
  }

  const activeLines = lines.filter(lineHasContent);
  const linesValid = activeLines.length > 0 && activeLines.every((l) => lineError(l) === null);
  const previews = lines.map((l) => computeLineAmountPreview(l.quantity, l.unitCost));
  const total = sumMoney(previews.filter((p): p is string => p !== null));
  const formValid = vendorId !== null && orderDate !== "" && linesValid;

  function buildPayload() {
    return {
      vendorId: vendorId!,
      orderDate,
      expectedDate: expectedDate || undefined,
      memo: memo.trim() || undefined,
      lines: activeLines.map((l) => ({
        ...(l.itemId ? { itemId: l.itemId } : {}),
        ...(l.description.trim() ? { description: l.description.trim() } : {}),
        quantity: l.quantity.trim(),
        unitCost: l.unitCost.trim(),
      })),
    };
  }

  async function handleSaveDraft() {
    if (!formValid || !selectedCompanyId) return;
    setSaving("draft");
    setSubmitError(null);
    try {
      const res = await apiPost<{ id: string }>("/api/c/purchase-orders", buildPayload(), selectedCompanyId);
      onSaved({ poId: res.id, issued: false });
    } catch (err) {
      setSubmitError(err instanceof ApiError ? err.message : "Couldn't save that purchase order. Try again.");
      setRetryAction(() => () => void handleSaveDraft());
    } finally {
      setSaving(null);
    }
  }

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

  async function handleSaveEdits() {
    if (!formValid || !selectedCompanyId || !poId) return;
    setSaving("edit");
    setSubmitError(null);
    try {
      await apiPatch(`/api/c/purchase-orders/${poId}`, buildPayload(), selectedCompanyId);
      onSaved({ poId, 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
      ? `This gives the order its number and readies ${fmtMoney(total)} of goods to ask ${selectedVendor.name} for. Nothing goes in your books — a purchase order is a promise, not a transaction. The books move when the bill arrives.`
      : null;

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

      <p className="badge" style={{ marginBottom: 14, display: "inline-flex" }}>
        No accounting impact — a purchase order never touches the books
      </p>

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

      <SearchablePicker
        id="po-vendor"
        label="Vendor"
        placeholder="Search vendors…"
        groups={vendorGroups}
        value={vendorId}
        onChange={setVendorId}
      />

      <div className="field-row">
        <div className="field">
          <label htmlFor="po-order-date">Order date</label>
          <input
            id="po-order-date"
            className="text-input"
            type="date"
            value={orderDate}
            onChange={(ev) => setOrderDate(ev.target.value)}
          />
        </div>
        <div className="field">
          <label htmlFor="po-expected-date">Expected by (optional)</label>
          <input
            id="po-expected-date"
            className="text-input"
            type="date"
            value={expectedDate}
            onChange={(ev) => setExpectedDate(ev.target.value)}
          />
        </div>
      </div>

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

      <table className="invoice-lines-table">
        <thead>
          <tr>
            <th>Item</th>
            <th>Description</th>
            <th className="num">Qty</th>
            <th className="num">Unit cost</th>
            <th className="num">Amount</th>
            <th />
          </tr>
        </thead>
        <tbody>
          {lines.map((line, i) => {
            const preview = previews[i];
            const err = lineHasContent(line) ? lineError(line) : null;
            const item = line.itemId ? itemsById.get(line.itemId) ?? null : null;
            return (
              <Fragment key={line.key}>
                <tr>
                  <td>
                    <SearchablePicker
                      id={`po-line-item-${line.key}`}
                      label=""
                      placeholder="Search items…"
                      groups={itemGroups}
                      value={line.itemId ?? NO_ITEM}
                      onChange={(id) => handleItemChange(line, id)}
                      action={{ label: "+ New item", onSelect: () => setNewItemLineKey(line.key) }}
                    />
                    {/* A description-only line can never be marked received —
                        nothing links a bill line to it (0018's
                        bill_lines_po_line_requires_item) — so say that here,
                        while it can still be changed, rather than leaving it
                        to look like a stuck order later. */}
                    {item === null && (
                      <div className="line-note">
                        no item — this line can&rsquo;t be tracked as received
                      </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.unitCost}
                      onChange={(ev) => updateLine(line.key, { unitCost: ev.target.value })}
                    />
                  </td>
                  <td className="num invoice-line-amount">{preview ? fmtMoney(preview) : "—"}</td>
                  <td>
                    <button
                      type="button"
                      className="acct-link"
                      disabled={lines.length <= 1}
                      onClick={() => removeLine(line.key)}
                    >
                      Remove
                    </button>
                  </td>
                </tr>
                {err && (
                  <tr>
                    <td colSpan={6} 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>Order 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>
          ) : (
            <>
              <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 vendor
              </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" && (
        <p className="invoice-form-drafts-note">
          A draft can be changed or thrown away freely. Once it&rsquo;s sent it gets a number and
          its lines are fixed.
        </p>
      )}

      {newItemLineKey !== null && selectedCompanyId && (
        <ItemModal
          companyId={selectedCompanyId}
          onClose={() => setNewItemLineKey(null)}
          onSaved={(created) => {
            const key = newItemLineKey;
            setNewItemLineKey(null);
            retryItems();
            const line = lines.find((l) => l.key === key);
            updateLine(key, {
              itemId: created.id,
              description: line && line.description.trim() !== "" ? line.description : created.name,
              unitCost:
                line && line.unitCost.trim() !== ""
                  ? line.unitCost
                  : created.purchaseCost !== null
                    ? trimTrailingZeros(created.purchaseCost)
                    : "",
            });
          }}
        />
      )}
    </div>
  );
}
