// New/edit item modal — Task 4 of docs/superpowers/plans/2026-08-03-inventory.md.
// One component backs both the Items screen's "New item" button and the Edit
// action on a row or on an item's own page, exactly as NewAccountModal.tsx
// backs every add-account affordance.
//
// The three account rules are enforced here as well as on the server
// (server/src/items.ts's resolveItemAccounts, itself a restatement of 0018's
// items_accounts_typed trigger and two table CHECKs). That is deliberate
// duplication in the direction CLAUDE.md §12 allows: the database is the
// guard, this is the friendlier, earlier version of the same sentence. Each
// picker is restricted to accounts of the one type its rule permits, so the
// wrong type is not merely refused — it is not offerable:
//
//   Inventory account   asset,   required for and only for a stocked item
//   Cost account        expense, required unless this is a service — but
//                                PERMITTED on one, so the control stays
//                                visible for every kind (review finding F4:
//                                hiding it made PATCH's replace-everything
//                                semantics silently delete a service's
//                                existing cost account)
//   Income account      income,  optional everywhere
//
// FROZEN FIELDS. 0018's items_frozen_once_moved welds `kind` and
// `inventory_account_id` shut the moment an item has stock history — the
// ledger tie depends on the pointer that the movements were written against.
// Rather than let a user fill in a form and then be told no, edit mode loads
// the item's movement history and DISABLES both controls with the reason
// visible on screen. (0020's items_frozen_while_drafted adds a second, rarer
// freeze — an item named by a bill or invoice that hasn't been entered yet —
// which the API alone can see; that one arrives as the server's own English
// on save and is shown verbatim.)

import { useEffect, useRef, useState } from "react";
import { useApiGet } from "../lib/useApiGet";
import { apiPost, apiPatch, ApiError } from "../lib/api";
import { SearchablePicker, type PickerGroup } from "../components/SearchablePicker";
import { NewAccountModal } from "../accounts/NewAccountModal";
import { accountPickerGroups } from "../accounts/pickerGroups";
import { itemsChanged } from "./useItemsPresence";
import type { ApiAccount, LedgerType } from "../accounts/subtypes";
import {
  ITEM_KINDS, KIND_LABELS, KIND_NOTES,
  type ApiItem, type ApiItemDetail, type ItemKind, isItemKind,
} from "./itemTypes";

// server/src/items.ts's priceSchema, mirrored for inline feedback before a
// round trip. Four decimals, not two: sub-cent costs are real (a washer costs
// $0.0042) — the same reason 0018 gave inventory_movements.unit_cost six.
const PRICE_RE = /^(0|[1-9]\d{0,12})(\.\d{1,4})?$/;

const FROZEN_NOTE =
  "This item has stock history, so its type and inventory account are fixed — " +
  "everything that has already moved was recorded against them.";

/** Which account slot the "+ New account" affordance was opened from. */
type NewAccountSlot = "income" | "cogs" | "inventory";

const NEW_ACCOUNT_PRESET: Record<NewAccountSlot, { type: LedgerType; note: string; title: string }> = {
  income: {
    type: "income",
    note: "An item's income account is where the sale lands, so it's an income account.",
    title: "New income account",
  },
  cogs: {
    type: "expense",
    note: "An item's cost account is where what it cost you lands, so it's an expense account.",
    title: "New cost account",
  },
  inventory: {
    type: "asset",
    note: "An inventory account holds the value of what's on the shelf, so it's an asset account.",
    title: "New inventory account",
  },
};

/** Trims a stored numeric(19,4) back to what a person would type: "12.5000"
 *  -> "12.5", "0.0042" -> "0.0042". Not money arithmetic — pure string
 *  surgery, so CLAUDE.md §2 is satisfied. */
function priceForEditing(value: string | null): string {
  if (value === null) return "";
  if (!value.includes(".")) return value;
  return value.replace(/0+$/, "").replace(/\.$/, "") || "0";
}

export function ItemModal({
  companyId,
  itemId,
  onClose,
  onSaved,
}: {
  companyId: string;
  /** Present = edit that item; absent = create a new one. */
  itemId?: string;
  onClose: () => void;
  onSaved: (item: ApiItem) => void;
}) {
  const editing = itemId !== undefined;
  // Edit mode loads the DETAIL endpoint, not the list row: its `movements`
  // array is how this form knows whether the frozen fields are frozen.
  const { data: existing, loading: loadingItem, error: loadError } =
    useApiGet<ApiItemDetail>(editing ? `/api/c/items/${itemId}` : null, companyId);
  const { data: accounts, retry: retryAccounts } =
    useApiGet<ApiAccount[]>("/api/c/accounts", companyId);

  const [name, setName] = useState("");
  const [sku, setSku] = useState("");
  const [kind, setKind] = useState<ItemKind>("inventory");
  const [salesPrice, setSalesPrice] = useState("");
  const [purchaseCost, setPurchaseCost] = useState("");
  const [description, setDescription] = useState("");
  const [incomeAccountId, setIncomeAccountId] = useState<string | null>(null);
  const [cogsAccountId, setCogsAccountId] = useState<string | null>(null);
  const [inventoryAccountId, setInventoryAccountId] = useState<string | null>(null);

  const [saving, setSaving] = useState(false);
  // Synchronous twin of `saving` — Enter auto-repeats, and without this a
  // held Enter fires one POST per keydown (NewAccountModal's review finding 2).
  const savingRef = useRef(false);
  const [error, setError] = useState<string | null>(null);
  const [touched, setTouched] = useState(false);
  const [newAccountSlot, setNewAccountSlot] = useState<NewAccountSlot | null>(null);
  // One-shot: only the FIRST arrival of the loaded item seeds the fields, so a
  // background refetch can't wipe out what the user has typed since.
  const seeded = useRef(false);

  useEffect(() => {
    if (!existing || seeded.current) return;
    seeded.current = true;
    setName(existing.name);
    setSku(existing.sku ?? "");
    if (isItemKind(existing.kind)) setKind(existing.kind);
    setSalesPrice(priceForEditing(existing.salesPrice));
    setPurchaseCost(priceForEditing(existing.purchaseCost));
    setDescription(existing.description ?? "");
    setIncomeAccountId(existing.incomeAccountId);
    setCogsAccountId(existing.cogsAccountId);
    setInventoryAccountId(existing.inventoryAccountId);
  }, [existing]);

  const frozen = editing && (existing?.movements.length ?? 0) > 0;

  function handleKindChange(next: ItemKind) {
    setKind(next);
    // Only the INVENTORY account is cleared, and only because 0018's
    // items_inventory_account_iff_inventory forbids a non-stocked item from
    // having one — the save would be refused otherwise. It happens under the
    // control the user just changed, and the field visibly disappears.
    //
    // The COST account is deliberately NOT cleared (review finding F4). 0018's
    // items_cogs_account_required is `kind = 'service' OR cogs IS NOT NULL`,
    // so a service is permitted to keep one — and since PATCH replaces the
    // whole editable set, an omitted field is a DELETION. Hiding the control
    // and dropping the value would have silently deleted an account pointer
    // the user never saw and never agreed to lose. The control below now stays
    // visible for every kind, marked optional for a service, so nothing is
    // hidden and nothing is cleared behind anyone's back.
    if (next !== "inventory") setInventoryAccountId(null);
  }

  const incomeGroups: PickerGroup[] = accountPickerGroups(accounts, ["income"]);
  const expenseGroups: PickerGroup[] = accountPickerGroups(accounts, ["expense"]);
  // TYPE only, no subtype filter — which is exactly what 0018's
  // items_accounts_typed() enforces on the way in ("must be an asset
  // account"), so the picker offers what the database will actually accept.
  //
  // Note what that means, rather than assuming otherwise: an inventory account
  // CAN be a bank-subtype asset account. Review proved it by pointing an item
  // at one and then reconciling it. That is not the hole it looks like, and
  // the reason is NOT "an inventory account is never a bank account" — it is
  // that every inventory posting goes through post_entry(), whose
  // assert_not_reconciled() (0011) tests EVERY account on the entry, not just
  // the ones tagged as money. A sale dated inside that account's reconciled
  // window is refused like any other, which is what
  // tests/server/inventory-selling.test.ts's "inherits the reconciled-through
  // guard on the NEW inventory leg" pins. The guard is structural; the subtype
  // is a label, and labelling was never what was protecting this.
  const assetGroups: PickerGroup[] = accountPickerGroups(accounts, ["asset"]);

  // Validation, in the server's own words (server/src/items.ts) so the
  // sentence a user reads is the same either side of the round trip.
  const nameError = name.trim() === "" ? "An item needs a name." : null;
  const salesPriceError =
    salesPrice.trim() !== "" && !PRICE_RE.test(salesPrice.trim())
      ? "Enter an amount like 12 or 12.50 (up to 4 decimal places, no leading zeros)."
      : null;
  const purchaseCostError =
    purchaseCost.trim() !== "" && !PRICE_RE.test(purchaseCost.trim())
      ? "Enter an amount like 12 or 12.50 (up to 4 decimal places, no leading zeros)."
      : null;
  const inventoryAccountError =
    kind === "inventory" && inventoryAccountId === null
      ? "Pick the account that holds this item's value on the balance sheet (an asset account, usually Inventory)."
      : null;
  const cogsAccountError =
    kind !== "service" && cogsAccountId === null
      ? "Pick the account that records what this item costs you (an expense account, usually Cost of Goods Sold)."
      : null;

  const firstError =
    nameError ?? salesPriceError ?? purchaseCostError ?? inventoryAccountError ?? cogsAccountError;
  const valid = firstError === null;

  async function handleSave() {
    if (savingRef.current) return;
    setTouched(true);
    if (!valid) return;
    savingRef.current = true;
    setSaving(true);
    setError(null);
    try {
      // PATCH replaces the whole editable set (server/src/items.ts's
      // itemUpdateSchema), so the create and edit bodies are the same shape;
      // an omitted account is a CLEARED account, never "leave it alone".
      const body: Record<string, unknown> = {
        name: name.trim(),
        kind,
        ...(sku.trim() !== "" ? { sku: sku.trim() } : {}),
        ...(description.trim() !== "" ? { description: description.trim() } : {}),
        ...(salesPrice.trim() !== "" ? { salesPrice: salesPrice.trim() } : {}),
        ...(purchaseCost.trim() !== "" ? { purchaseCost: purchaseCost.trim() } : {}),
        ...(incomeAccountId ? { incomeAccountId } : {}),
        // Sent whenever it is set, for every kind — a service may legitimately
        // carry one (F4), and PATCH treats an omitted field as a deletion.
        ...(cogsAccountId ? { cogsAccountId } : {}),
        ...(kind === "inventory" && inventoryAccountId ? { inventoryAccountId } : {}),
      };
      const saved = editing
        ? await apiPatch<ApiItem>(`/api/c/items/${itemId}`, { ...body, active: existing?.active ?? true }, companyId)
        : await apiPost<ApiItem>("/api/c/items", body, companyId);
      // The item list just changed, so the screens gated on "does this company
      // keep stock" have to hear about it — creating your FIRST stocked item is
      // what puts Builds and Stock adjustments in the sidebar (Inventory Task
      // 5). This sits here rather than in each caller because every route into
      // creating an item comes through this modal: the Items screen, an item's
      // own page, and the "+ New item" action inside the bill, invoice and
      // purchase-order line pickers.
      itemsChanged(companyId);
      onSaved(saved);
    } catch (err) {
      // Duplicate name/SKU, the frozen-field refusals and the account-type
      // refusals all arrive in the API's own English — shown verbatim.
      setError(err instanceof ApiError ? err.message : "Couldn't save that item. Try again.");
    } finally {
      savingRef.current = false;
      setSaving(false);
    }
  }

  const title = editing ? "Edit item" : "New item";

  return (
    <div
      className="modal-overlay"
      onMouseDown={(ev) => { if (ev.target === ev.currentTarget) onClose(); }}
      onKeyDown={(ev) => { if (ev.key === "Escape") onClose(); }}
    >
      <div className="modal-card modal-card-wide" role="dialog" aria-modal="true" aria-label={title}>
        <h2>{title}</h2>

        {loadError && <p className="field-error">{loadError}</p>}
        {editing && loadingItem && !existing && <p className="msg-inline">Loading this item&hellip;</p>}

        <div className="field">
          <label htmlFor="item-name">Name</label>
          <input
            id="item-name"
            className="text-input"
            type="text"
            autoFocus
            value={name}
            onChange={(ev) => setName(ev.target.value)}
            onKeyDown={(ev) => { if (ev.key === "Enter") void handleSave(); }}
          />
          {touched && nameError && <p className="field-error">{nameError}</p>}
        </div>

        <div className="field-row">
          <div className="field">
            <label htmlFor="item-sku">SKU / part number (optional)</label>
            <input
              id="item-sku"
              className="text-input"
              type="text"
              value={sku}
              onChange={(ev) => setSku(ev.target.value)}
            />
          </div>
          <div className="field">
            <label htmlFor="item-kind">Type</label>
            <select
              id="item-kind"
              className="select-input"
              value={kind}
              disabled={frozen}
              onChange={(ev) => handleKindChange(ev.target.value as ItemKind)}
            >
              {ITEM_KINDS.map((k) => (
                <option key={k} value={k}>
                  {KIND_LABELS[k]} — {KIND_NOTES[k]}
                </option>
              ))}
            </select>
          </div>
        </div>

        {frozen && <p className="sub item-frozen-note">{FROZEN_NOTE}</p>}

        <div className="field-row">
          <div className="field">
            <label htmlFor="item-sales-price">Sales price (optional)</label>
            <input
              id="item-sales-price"
              className="text-input"
              type="text"
              inputMode="decimal"
              placeholder="0.00"
              value={salesPrice}
              onChange={(ev) => setSalesPrice(ev.target.value)}
            />
            {salesPriceError && <p className="field-error">{salesPriceError}</p>}
          </div>
          <div className="field">
            <label htmlFor="item-purchase-cost">
              {kind === "service" ? "Cost (optional)" : "Purchase cost (optional)"}
            </label>
            <input
              id="item-purchase-cost"
              className="text-input"
              type="text"
              inputMode="decimal"
              placeholder="0.00"
              value={purchaseCost}
              onChange={(ev) => setPurchaseCost(ev.target.value)}
            />
            {purchaseCostError && <p className="field-error">{purchaseCostError}</p>}
          </div>
        </div>

        <h3 className="item-modal-section">Where it posts</h3>

        <SearchablePicker
          id="item-income-account"
          label="Income account — where a sale of this lands (optional)"
          placeholder="Search income accounts…"
          groups={incomeGroups}
          value={incomeAccountId}
          onChange={setIncomeAccountId}
          action={{ label: "+ New income account", onSelect: () => setNewAccountSlot("income") }}
        />

        {/* Shown for every kind — see handleKindChange's note (F4). Only the
            REQUIREMENT is kind-dependent, not the control. */}
        <SearchablePicker
          id="item-cogs-account"
          label={
            kind === "service"
              ? "Cost account — where what this costs you lands (optional for a service)"
              : "Cost account — where what this cost you lands"
          }
          placeholder="Search expense accounts…"
          groups={expenseGroups}
          value={cogsAccountId}
          onChange={setCogsAccountId}
          action={{ label: "+ New cost account", onSelect: () => setNewAccountSlot("cogs") }}
        />
        {touched && cogsAccountError && <p className="field-error">{cogsAccountError}</p>}

        {kind === "inventory" && (
          <>
            <SearchablePicker
              id="item-inventory-account"
              label="Inventory account — where the value on the shelf sits"
              placeholder="Search asset accounts…"
              groups={assetGroups}
              value={inventoryAccountId}
              onChange={setInventoryAccountId}
              disabled={frozen}
              action={{ label: "+ New inventory account", onSelect: () => setNewAccountSlot("inventory") }}
            />
            {touched && inventoryAccountError && <p className="field-error">{inventoryAccountError}</p>}
          </>
        )}

        <div className="field">
          <label htmlFor="item-description">Description (optional)</label>
          <input
            id="item-description"
            className="text-input"
            type="text"
            value={description}
            onChange={(ev) => setDescription(ev.target.value)}
          />
        </div>

        {error && <p className="field-error">{error}</p>}

        <div className="wizard-actions">
          <button
            type="button"
            className="btn"
            disabled={saving || (editing && !existing)}
            onClick={() => void handleSave()}
          >
            {saving ? "Saving…" : editing ? "Save changes" : "Add item"}
          </button>
          <button type="button" className="acct-link" disabled={saving} onClick={onClose}>
            Cancel
          </button>
        </div>

        {newAccountSlot !== null && (
          <NewAccountModal
            companyId={companyId}
            title={NEW_ACCOUNT_PRESET[newAccountSlot].title}
            presetType={NEW_ACCOUNT_PRESET[newAccountSlot].type}
            lockType
            typeNote={NEW_ACCOUNT_PRESET[newAccountSlot].note}
            onClose={() => setNewAccountSlot(null)}
            onCreated={(acct) => {
              if (newAccountSlot === "income") setIncomeAccountId(acct.id);
              else if (newAccountSlot === "cogs") setCogsAccountId(acct.id);
              else setInventoryAccountId(acct.id);
              setNewAccountSlot(null);
              retryAccounts(); // pull the fresh account into the picker's groups
            }}
          />
        )}
      </div>
    </div>
  );
}
