// #bank/statement/:id — the review workbench, Task 5 of
// docs/superpowers/plans/2026-08-01-bank-rec.md (spec: "Review workbench"
// and "Reconciled state" sections of
// docs/superpowers/specs/2026-08-01-bank-rec-design.md are binding).
// Replaces Task 4's route-only placeholder.
//
// Every statement line falls into exactly one of four buckets by its own
// `status` (server/src/bank.ts's lineOut()): 'suggested' -> "Needs a look",
// 'unmatched' -> "New", 'matched'/'created' -> "Settled" (collapsed by
// default), 'excluded' -> "Excluded" (collapsed by default). Nothing here
// invents a fifth bucket or a client-side notion of status — every action
// below just POSTs to the existing line-action routes and refetches.
//
// Per-line ephemeral UI state (candidates panel open/loading, the New-flow
// mini-form's category/who/memo, the exclude-reason box, busy/error) lives
// in one `uiByLine` map keyed by line id rather than N separate useState
// calls — a statement can have dozens of lines, and each only ever has ONE
// of these things open at a time in practice, so a single map is simpler
// than a component per row with its own local state (which would also lose
// its state on every parent refetch/remount anyway, given how React keys
// list items here).
//
// "Pick different" (suggested rows) and "Find a match" (new rows) both open
// the same CandidatesPanel — GET .../lines/:id/candidates, top 5 by date
// proximity (server/src/lib/statementMatch.ts). Design brief: candidates
// list only, no manual entry-id search box; "None of these" on a SUGGESTED
// row falls through to the same New-flow mini-form new rows use (client-side
// only — the line's real status stays 'suggested' until the user actually
// does something: create_entry_from_line()/exclude_statement_line() both
// accept 'suggested' exactly like 'unmatched', per 0011_bank_rec.sql, so
// nothing server-side needs to change first).
//
// The reconcile footer's book balance comes from Task 5's own new endpoint,
// GET /api/c/bank/accounts/:id/book-balance?asOf= (server/src/bank.ts) —
// added because the only existing figure (GET /api/c/bank/overview's
// bookBalance) is always as-of TODAY, not as-of the user's chosen
// through-date. The English difference sentence (reconcileMoney.ts) mirrors
// server/src/errors.ts's own reconcile-mismatch wording exactly, so the live
// preview never disagrees with what a real 409 would say.

import { useEffect, useState } from "react";
import { useCompany } from "../lib/company";
import { useApiGet } from "../lib/useApiGet";
import { apiGet, apiPost, ApiError } from "../lib/api";
import { fmtMoney } from "../lib/money";
import { fmtDate, todayIso } from "../lib/date";
import { ErrorBanner } from "../components/ErrorBanner";
import { SearchablePicker, type PickerGroup } from "../components/SearchablePicker";
import type { ApiAccount } from "../accounts/subtypes";
import {
  accountPickerGroups,
  CATEGORY_TYPES_MONEY_IN,
  CATEGORY_TYPES_MONEY_OUT,
} from "../accounts/pickerGroups";
import type { NavTarget } from "../lib/useHashView";
import {
  centsOfNumeric, centsOfSignedEntered, fmtCentsUsd, numericToSignedEntered,
  reconcileDifferenceSentence, signedEnteredError,
} from "./reconcileMoney";

// ---------------------------------------------------------------------------
// API shapes
// ---------------------------------------------------------------------------

type LineStatus = "unmatched" | "suggested" | "matched" | "created" | "excluded";

interface EntryRef {
  entryNo: string | null;
  date: string | null;
  memo: string | null;
}

interface LineRow {
  id: string;
  lineNo: number;
  postedDate: string;
  description: string | null;
  amount: string;
  fitid: string | null;
  status: LineStatus;
  matchedEntryId: string | null;
  matchedEntry: EntryRef | null;
  suggestionEntryId: string | null;
  suggestionEntry: EntryRef | null;
  excludedReason: string | null;
}

interface StatementInfo {
  id: string;
  moneyAccountId: string;
  moneyAccountName: string;
  filename: string;
  format: string;
  periodStart: string | null;
  periodEnd: string | null;
  endingBalance: string | null;
  status: "open" | "reconciled";
}

interface LinesResponse {
  statement: StatementInfo;
  lines: LineRow[];
}

interface CandidateEntry {
  entryId: string;
  entryNo: string | null;
  date: string;
  memo: string | null;
  amount: string;
}


interface OverviewAccount {
  accountId: string;
  reconciledThrough: string | null;
}
interface OverviewResponse {
  accounts: OverviewAccount[];
}

// ---------------------------------------------------------------------------
// per-line ephemeral UI state
// ---------------------------------------------------------------------------

interface LineUi {
  candidatesOpen: boolean;
  candidatesLoading: boolean;
  candidatesError: string | null;
  candidates: CandidateEntry[] | null;
  newFormOpen: boolean;
  categoryAccountId: string | null;
  who: string;
  memo: string;
  excludeOpen: boolean;
  excludeReason: string;
  busy: boolean;
  error: string | null;
}

const EMPTY_LINE_UI: LineUi = {
  candidatesOpen: false, candidatesLoading: false, candidatesError: null, candidates: null,
  newFormOpen: false, categoryAccountId: null, who: "", memo: "",
  excludeOpen: false, excludeReason: "",
  busy: false, error: null,
};

const STATUS_LABEL: Record<LineStatus, string> = {
  unmatched: "New", suggested: "Needs a look", matched: "Settled", created: "Settled", excluded: "Excluded",
};

// Same ordering rule as Record.tsx's categoryGroups, now under the STANDARD
// sections (Chart Management Task 3): the likelier type's sections come
// first, but every category is always offered either way (a "money in" line
// could still be a refund landing in an expense category).
function categoryGroupsFor(accounts: ApiAccount[], moneyIn: boolean): PickerGroup[] {
  return accountPickerGroups(
    accounts,
    moneyIn ? CATEGORY_TYPES_MONEY_IN : CATEGORY_TYPES_MONEY_OUT,
  );
}

function isMoneyIn(line: LineRow): boolean {
  return !line.amount.trim().startsWith("-");
}

// ---------------------------------------------------------------------------
// small presentational pieces
// ---------------------------------------------------------------------------

function CandidatesPanel({
  ui, onPick, onNone, onClose,
}: {
  ui: LineUi;
  onPick: (entryId: string) => void;
  onNone: () => void;
  onClose: () => void;
}) {
  return (
    <div className="rec-candidates">
      {ui.candidatesLoading && <p className="msg-inline">Looking for matches&hellip;</p>}
      {ui.candidatesError && <ErrorBanner message={ui.candidatesError} onRetry={onClose} />}
      {ui.candidates && ui.candidates.length === 0 && (
        <p className="sub">No other matching entries found.</p>
      )}
      {ui.candidates && ui.candidates.length > 0 && (
        <div className="rec-candidate-list">
          {ui.candidates.map((c) => (
            <div className="rec-candidate-row" key={c.entryId}>
              <span>{fmtDate(c.date)}</span>
              <span className="rec-candidate-memo">{c.memo || "(no memo)"}</span>
              <span className="rec-line-amount">{fmtMoney(c.amount)}</span>
              <button type="button" className="btn" disabled={ui.busy} onClick={() => onPick(c.entryId)}>
                Match
              </button>
            </div>
          ))}
        </div>
      )}
      <div className="wizard-actions">
        <button type="button" className="acct-link" disabled={ui.busy} onClick={onNone}>
          None of these
        </button>
        <button type="button" className="acct-link" disabled={ui.busy} onClick={onClose}>
          Cancel
        </button>
      </div>
    </div>
  );
}

function CreateEntryForm({
  line, accounts, ui, lineIdPrefix,
  onChangeCategory, onChangeWho, onChangeMemo, onSubmit, onFindMatch, onExclude,
}: {
  line: LineRow;
  accounts: ApiAccount[];
  ui: LineUi;
  lineIdPrefix: string;
  onChangeCategory: (id: string) => void;
  onChangeWho: (v: string) => void;
  onChangeMemo: (v: string) => void;
  onSubmit: () => void;
  onFindMatch: () => void;
  onExclude: () => void;
}) {
  const groups = categoryGroupsFor(accounts, isMoneyIn(line));
  return (
    <div className="rec-new-form">
      <SearchablePicker
        id={`${lineIdPrefix}-category-${line.id}`}
        label="Category"
        placeholder="Search categories…"
        groups={groups}
        value={ui.categoryAccountId}
        onChange={onChangeCategory}
      />
      <div className="rec-field-row">
        <div className="field">
          <label htmlFor={`${lineIdPrefix}-who-${line.id}`}>Who (optional)</label>
          <input
            id={`${lineIdPrefix}-who-${line.id}`}
            className="text-input"
            type="text"
            value={ui.who}
            onChange={(ev) => onChangeWho(ev.target.value)}
          />
        </div>
        <div className="field">
          <label htmlFor={`${lineIdPrefix}-memo-${line.id}`}>Memo (optional)</label>
          <input
            id={`${lineIdPrefix}-memo-${line.id}`}
            className="text-input"
            type="text"
            value={ui.memo}
            onChange={(ev) => onChangeMemo(ev.target.value)}
          />
        </div>
      </div>
      {ui.error && <ErrorBanner message={ui.error} onRetry={onSubmit} />}
      <div className="wizard-actions">
        <button type="button" className="btn" disabled={ui.busy || !ui.categoryAccountId} onClick={onSubmit}>
          {ui.busy ? "Adding…" : "Add to books"}
        </button>
        <button type="button" className="acct-link" disabled={ui.busy} onClick={onFindMatch}>
          Find a match
        </button>
        <button type="button" className="acct-link" disabled={ui.busy} onClick={onExclude}>
          Exclude
        </button>
      </div>
    </div>
  );
}

function ExcludeForm({
  line, ui, onChangeReason, onSubmit, onCancel,
}: {
  line: LineRow;
  ui: LineUi;
  onChangeReason: (v: string) => void;
  onSubmit: () => void;
  onCancel: () => void;
}) {
  return (
    <div className="rec-exclude-form">
      <div className="field">
        <label htmlFor={`exclude-reason-${line.id}`}>Why exclude this line?</label>
        <input
          id={`exclude-reason-${line.id}`}
          className="text-input"
          type="text"
          placeholder="e.g. duplicate, bank error"
          value={ui.excludeReason}
          onChange={(ev) => onChangeReason(ev.target.value)}
        />
      </div>
      {ui.error && <ErrorBanner message={ui.error} onRetry={onSubmit} />}
      <div className="wizard-actions">
        <button type="button" className="btn" disabled={ui.busy || ui.excludeReason.trim() === ""} onClick={onSubmit}>
          {ui.busy ? "Excluding…" : "Exclude"}
        </button>
        <button type="button" className="acct-link" disabled={ui.busy} onClick={onCancel}>
          Cancel
        </button>
      </div>
    </div>
  );
}

function BankLineFields({ line }: { line: LineRow }) {
  return (
    <div className="rec-line-bank">
      <div className="rec-line-date">{fmtDate(line.postedDate)}</div>
      <div className="rec-line-desc">{line.description || "(no description)"}</div>
      <div className="rec-line-amount">{fmtMoney(line.amount)}</div>
    </div>
  );
}

function EntrySideFields({
  ref, entryId, amount, navigate,
}: {
  ref: EntryRef | null;
  entryId: string | null;
  amount: string;
  navigate: (target: NavTarget) => void;
}) {
  if (!ref || !entryId) return <p className="sub">No suggestion.</p>;
  return (
    <div className="rec-line-entry">
      <div className="rec-line-date">{ref.date ? fmtDate(ref.date) : "—"}</div>
      <div className="rec-line-desc">{ref.memo || "(no memo)"}</div>
      <div className="rec-line-amount">{fmtMoney(amount)}</div>
      <button type="button" className="acct-link" onClick={() => navigate({ view: "entry", id: entryId })}>
        Entry #{ref.entryNo ?? entryId}
      </button>
    </div>
  );
}

// ---------------------------------------------------------------------------
// main component
// ---------------------------------------------------------------------------

export function StatementReview({
  statementId,
  navigate,
}: {
  statementId: string;
  navigate: (target: NavTarget) => void;
}) {
  const { selectedCompanyId } = useCompany();

  const linesQuery = useApiGet<LinesResponse>(`/api/c/bank/statements/${statementId}/lines`, selectedCompanyId);
  const { data: accounts } = useApiGet<ApiAccount[]>("/api/c/accounts", selectedCompanyId);
  const overviewQuery = useApiGet<OverviewResponse>("/api/c/bank/overview", selectedCompanyId);

  const [uiByLine, setUiByLine] = useState<Record<string, LineUi>>({});
  function lineUi(id: string): LineUi {
    return uiByLine[id] ?? EMPTY_LINE_UI;
  }
  function patchLineUi(id: string, patch: Partial<LineUi>) {
    setUiByLine((prev) => ({ ...prev, [id]: { ...(prev[id] ?? EMPTY_LINE_UI), ...patch } }));
  }
  function resetLineUi(id: string) {
    setUiByLine((prev) => {
      const next = { ...prev };
      delete next[id];
      return next;
    });
  }

  const [confirmAllBusy, setConfirmAllBusy] = useState(false);
  const [confirmAllResult, setConfirmAllResult] = useState<{ confirmed: number; failed: number } | null>(null);

  // Footer state.
  const [endingBalance, setEndingBalance] = useState("");
  const [endingBalanceTouched, setEndingBalanceTouched] = useState(false);
  const [throughDate, setThroughDate] = useState(todayIso());
  const [prefilled, setPrefilled] = useState(false);
  const [bookBalance, setBookBalance] = useState<string | null>(null);
  const [bookBalanceLoading, setBookBalanceLoading] = useState(false);
  const [bookBalanceError, setBookBalanceError] = useState<string | null>(null);
  const [reconciling, setReconciling] = useState(false);
  const [reconcileError, setReconcileError] = useState<string | null>(null);
  const [reconciledThroughUsed, setReconciledThroughUsed] = useState<string | null>(null);

  // Undo state.
  const [undoStep, setUndoStep] = useState<0 | 1>(0);
  const [undoAck, setUndoAck] = useState(false);
  const [undoBusy, setUndoBusy] = useState(false);
  const [undoError, setUndoError] = useState<string | null>(null);

  const statement = linesQuery.data?.statement ?? null;

  // Prefill the footer exactly once, the first time the statement loads —
  // never again, so a refetch after e.g. confirming a line doesn't clobber
  // whatever the user has already typed.
  useEffect(() => {
    if (!statement || prefilled) return;
    if (statement.endingBalance) setEndingBalance(numericToSignedEntered(statement.endingBalance));
    setThroughDate(statement.periodEnd ?? todayIso());
    setPrefilled(true);
  }, [statement, prefilled]);

  // Live book balance, refetched whenever the through-date (or the account)
  // changes — see this file's header for why this can't just reuse the
  // overview endpoint's always-as-of-today figure.
  useEffect(() => {
    if (!selectedCompanyId || !statement) return;
    if (!/^\d{4}-\d{2}-\d{2}$/.test(throughDate)) {
      setBookBalance(null);
      return;
    }
    let cancelled = false;
    setBookBalanceLoading(true);
    setBookBalanceError(null);
    apiGet<{ bookBalance: string }>(
      `/api/c/bank/accounts/${statement.moneyAccountId}/book-balance?asOf=${throughDate}`,
      selectedCompanyId,
    )
      .then((res) => {
        if (!cancelled) setBookBalance(res.bookBalance);
      })
      .catch((err: unknown) => {
        if (!cancelled) {
          setBookBalance(null);
          setBookBalanceError(err instanceof ApiError ? err.message : "Couldn't compute your book balance.");
        }
      })
      .finally(() => {
        if (!cancelled) setBookBalanceLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [selectedCompanyId, statement, throughDate]);

  if (linesQuery.loading && !linesQuery.data) {
    return (
      <div className="wrap">
        <p className="msg-inline">Loading the statement&hellip;</p>
      </div>
    );
  }
  if (linesQuery.error && !linesQuery.data) {
    return (
      <div className="wrap">
        <ErrorBanner message={linesQuery.error} onRetry={linesQuery.retry} />
      </div>
    );
  }
  if (!linesQuery.data || !statement) return null;

  const { lines } = linesQuery.data;
  const suggested = lines.filter((l) => l.status === "suggested");
  const unmatched = lines.filter((l) => l.status === "unmatched");
  const settled = lines.filter((l) => l.status === "matched" || l.status === "created");
  const excludedLines = lines.filter((l) => l.status === "excluded");
  const unsettledCount = suggested.length + unmatched.length;

  const overviewAccount = overviewQuery.data?.accounts.find((a) => a.accountId === statement.moneyAccountId) ?? null;
  const reconciledThroughDisplay =
    overviewAccount?.reconciledThrough ?? reconciledThroughUsed ?? statement.periodEnd ?? null;

  // ---------------------------------------------------------------------
  // actions
  // ---------------------------------------------------------------------

  function refresh() {
    linesQuery.retry();
  }

  async function doConfirm(lineId: string) {
    patchLineUi(lineId, { busy: true, error: null });
    try {
      await apiPost(`/api/c/bank/lines/${lineId}/confirm`, undefined, selectedCompanyId);
      resetLineUi(lineId);
      refresh();
    } catch (err) {
      patchLineUi(lineId, { busy: false, error: err instanceof ApiError ? err.message : "Couldn't confirm that line." });
    }
  }

  async function doMatch(lineId: string, entryId: string) {
    patchLineUi(lineId, { busy: true, error: null });
    try {
      await apiPost(`/api/c/bank/lines/${lineId}/match`, { entryId }, selectedCompanyId);
      resetLineUi(lineId);
      refresh();
    } catch (err) {
      patchLineUi(lineId, { busy: false, error: err instanceof ApiError ? err.message : "Couldn't match that entry." });
    }
  }

  async function doUnmatch(lineId: string) {
    patchLineUi(lineId, { busy: true, error: null });
    try {
      await apiPost(`/api/c/bank/lines/${lineId}/unmatch`, undefined, selectedCompanyId);
      resetLineUi(lineId);
      refresh();
    } catch (err) {
      patchLineUi(lineId, { busy: false, error: err instanceof ApiError ? err.message : "Couldn't unmatch that line." });
    }
  }

  async function doExclude(lineId: string) {
    const reason = lineUi(lineId).excludeReason.trim();
    if (reason === "") return;
    patchLineUi(lineId, { busy: true, error: null });
    try {
      await apiPost(`/api/c/bank/lines/${lineId}/exclude`, { reason }, selectedCompanyId);
      resetLineUi(lineId);
      refresh();
    } catch (err) {
      patchLineUi(lineId, { busy: false, error: err instanceof ApiError ? err.message : "Couldn't exclude that line." });
    }
  }

  async function doRestore(lineId: string) {
    patchLineUi(lineId, { busy: true, error: null });
    try {
      await apiPost(`/api/c/bank/lines/${lineId}/restore`, undefined, selectedCompanyId);
      resetLineUi(lineId);
      refresh();
    } catch (err) {
      patchLineUi(lineId, { busy: false, error: err instanceof ApiError ? err.message : "Couldn't restore that line." });
    }
  }

  async function doCreateEntry(lineId: string) {
    const ui = lineUi(lineId);
    if (!ui.categoryAccountId) return;
    patchLineUi(lineId, { busy: true, error: null });
    try {
      await apiPost(
        `/api/c/bank/lines/${lineId}/create-entry`,
        { categoryAccountId: ui.categoryAccountId, who: ui.who.trim() || undefined, memo: ui.memo.trim() || undefined },
        selectedCompanyId,
      );
      resetLineUi(lineId);
      refresh();
    } catch (err) {
      patchLineUi(lineId, { busy: false, error: err instanceof ApiError ? err.message : "Couldn't add that to your books." });
    }
  }

  async function openCandidates(lineId: string) {
    patchLineUi(lineId, { candidatesOpen: true, candidatesLoading: true, candidatesError: null, candidates: null });
    try {
      const res = await apiGet<{ candidates: CandidateEntry[] }>(`/api/c/bank/lines/${lineId}/candidates`, selectedCompanyId);
      patchLineUi(lineId, { candidatesLoading: false, candidates: res.candidates });
    } catch (err) {
      patchLineUi(lineId, {
        candidatesLoading: false,
        candidatesError: err instanceof ApiError ? err.message : "Couldn't load candidate entries.",
      });
    }
  }
  function closeCandidates(lineId: string) {
    patchLineUi(lineId, { candidatesOpen: false });
  }
  function noneOfThese(lineId: string) {
    patchLineUi(lineId, { candidatesOpen: false, newFormOpen: true });
  }

  async function doConfirmAll() {
    if (!selectedCompanyId || !statement) return;
    setConfirmAllBusy(true);
    setConfirmAllResult(null);
    try {
      const res = await apiPost<{ confirmed: number; failed: number }>(
        `/api/c/bank/statements/${statement.id}/confirm-all`, undefined, selectedCompanyId,
      );
      setConfirmAllResult({ confirmed: res.confirmed, failed: res.failed });
      refresh();
    } catch (err) {
      setConfirmAllResult(null);
      // A whole-statement failure here would be unusual (the route itself
      // continues past per-line failures) — fall back to a generic banner
      // via the section's own retry affordance rather than inventing a
      // second error-state field just for this rare path.
      void err;
    } finally {
      setConfirmAllBusy(false);
    }
  }

  async function doReconcile() {
    if (!selectedCompanyId || !statement) return;
    setReconciling(true);
    setReconcileError(null);
    try {
      await apiPost(
        `/api/c/bank/statements/${statement.id}/reconcile`,
        { endingBalance: endingBalance.trim(), throughDate },
        selectedCompanyId,
      );
      setReconciledThroughUsed(throughDate);
      refresh();
      overviewQuery.retry();
    } catch (err) {
      setReconcileError(err instanceof ApiError ? err.message : "Couldn't reconcile that statement.");
    } finally {
      setReconciling(false);
    }
  }

  async function doUndo() {
    if (!selectedCompanyId || !statement) return;
    setUndoBusy(true);
    setUndoError(null);
    try {
      await apiPost("/api/c/bank/undo-reconciliation", { accountId: statement.moneyAccountId, confirm: true }, selectedCompanyId);
      setUndoStep(0);
      setUndoAck(false);
      refresh();
      overviewQuery.retry();
    } catch (err) {
      setUndoError(err instanceof ApiError ? err.message : "Couldn't undo that reconciliation.");
    } finally {
      setUndoBusy(false);
    }
  }

  // ---------------------------------------------------------------------
  // reconciled (frozen) state
  // ---------------------------------------------------------------------

  if (statement.status === "reconciled") {
    return (
      <div className="wrap">
        <header className="top">
          <h1>{statement.moneyAccountName}</h1>
        </header>
        <p className="sub">{statement.filename}</p>

        <div className="success-banner">
          <span>
            Reconciled through {reconciledThroughDisplay ? fmtDate(reconciledThroughDisplay) : "this period"} — entries in this
            period are now protected.
          </span>
        </div>

        <div className="card" style={{ marginTop: 12 }}>
          <h2>Lines ({lines.length})</h2>
          <table>
            <thead>
              <tr>
                <th>Date</th>
                <th>Description</th>
                <th className="num">Amount</th>
                <th>Status</th>
                <th>Entry</th>
              </tr>
            </thead>
            <tbody>
              {lines.map((l) => (
                <tr key={l.id}>
                  <td>{fmtDate(l.postedDate)}</td>
                  <td>{l.description || "(no description)"}</td>
                  <td className="num">{fmtMoney(l.amount)}</td>
                  <td>{STATUS_LABEL[l.status]}</td>
                  <td>
                    {l.matchedEntryId ? (
                      <button type="button" className="acct-link" onClick={() => navigate({ view: "entry", id: l.matchedEntryId! })}>
                        Entry #{l.matchedEntry?.entryNo ?? l.matchedEntryId}
                      </button>
                    ) : l.excludedReason ? (
                      `Excluded: ${l.excludedReason}`
                    ) : (
                      "—"
                    )}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>

        <div style={{ marginTop: 28, paddingTop: 16, borderTop: "1px solid var(--grid)" }}>
          {undoStep === 0 ? (
            <button type="button" className="acct-link" onClick={() => setUndoStep(1)}>
              Undo reconciliation
            </button>
          ) : (
            <div className="confirm-panel">
              <p>
                Undoing this reconciliation moves {statement.moneyAccountName}'s reconciled-through date back to its previous
                checkpoint (or clears it entirely if there isn't one). Entries dated in this period become editable again, and
                this statement reopens for review. You can always redo the reconcile step afterward.
              </p>
              <label className="method-option" style={{ marginBottom: 12 }}>
                <input type="checkbox" checked={undoAck} onChange={(ev) => setUndoAck(ev.target.checked)} />
                <span>I understand — undo it</span>
              </label>
              <div className="wizard-actions">
                <button type="button" className="btn" disabled={!undoAck || undoBusy} onClick={() => void doUndo()}>
                  {undoBusy ? "Undoing…" : "Yes, undo reconciliation"}
                </button>
                <button
                  type="button"
                  className="acct-link"
                  disabled={undoBusy}
                  onClick={() => {
                    setUndoStep(0);
                    setUndoAck(false);
                    setUndoError(null);
                  }}
                >
                  Cancel
                </button>
              </div>
              {undoError && <ErrorBanner message={undoError} onRetry={() => void doUndo()} />}
            </div>
          )}
        </div>

        <div className="wizard-actions" style={{ marginTop: 16 }}>
          <button type="button" className="btn" onClick={() => navigate("home")}>
            Back to Home
          </button>
        </div>
      </div>
    );
  }

  // ---------------------------------------------------------------------
  // open (review) state
  // ---------------------------------------------------------------------

  const endingErr = signedEnteredError(endingBalance);
  const dateValid = /^\d{4}-\d{2}-\d{2}$/.test(throughDate);
  const endingCents = endingErr === null ? centsOfSignedEntered(endingBalance) : null;
  const bookCents = bookBalance !== null ? centsOfNumeric(bookBalance) : null;
  const diffCents = endingCents !== null && bookCents !== null ? endingCents - bookCents : null;
  const canReconcile =
    endingErr === null && dateValid && diffCents === 0n && unsettledCount === 0 && !reconciling;

  return (
    <div className="wrap" style={{ paddingBottom: 140 }}>
      <header className="top">
        <h1>{statement.moneyAccountName}</h1>
      </header>
      <p className="sub">
        {statement.filename}
        {statement.periodStart && statement.periodEnd
          ? ` · ${fmtDate(statement.periodStart)} – ${fmtDate(statement.periodEnd)}`
          : ""}
      </p>
      <p className="sub">
        {lines.length} line{lines.length === 1 ? "" : "s"} — {settled.length} settled, {suggested.length} need a look,{" "}
        {unmatched.length} new, {excludedLines.length} excluded.
      </p>

      {accounts && suggested.length > 0 && (
        <section className="rec-section">
          <div className="rec-section-head">
            <h2>Needs a look ({suggested.length})</h2>
            <button type="button" className="btn" disabled={confirmAllBusy} onClick={() => void doConfirmAll()}>
              {confirmAllBusy ? "Confirming…" : "Confirm all suggestions"}
            </button>
          </div>
          {confirmAllResult && (
            <p className="sub">
              Confirmed {confirmAllResult.confirmed}
              {confirmAllResult.failed > 0
                ? `; ${confirmAllResult.failed} couldn't be confirmed automatically — look at those lines below.`
                : "."}
            </p>
          )}
          {suggested.map((line) => {
            const ui = lineUi(line.id);
            return (
              <div className="rec-line" key={line.id}>
                <div className="rec-line-sides">
                  <BankLineFields line={line} />
                  <div className="rec-line-arrow" aria-hidden="true">
                    ↔
                  </div>
                  <EntrySideFields
                    ref={line.suggestionEntry}
                    entryId={line.suggestionEntryId}
                    amount={line.amount}
                    navigate={navigate}
                  />
                </div>
                {!ui.newFormOpen && (
                  <div className="wizard-actions">
                    <button type="button" className="btn" disabled={ui.busy} onClick={() => void doConfirm(line.id)}>
                      {ui.busy ? "Confirming…" : "Confirm"}
                    </button>
                    <button
                      type="button"
                      className="acct-link"
                      disabled={ui.busy}
                      onClick={() => void openCandidates(line.id)}
                    >
                      Pick different
                    </button>
                  </div>
                )}
                {ui.error && !ui.newFormOpen && <ErrorBanner message={ui.error} onRetry={() => void doConfirm(line.id)} />}
                {ui.candidatesOpen && (
                  <CandidatesPanel
                    ui={ui}
                    onPick={(entryId) => void doMatch(line.id, entryId)}
                    onNone={() => noneOfThese(line.id)}
                    onClose={() => closeCandidates(line.id)}
                  />
                )}
                {ui.newFormOpen && !ui.excludeOpen && accounts && (
                  <CreateEntryForm
                    line={line}
                    accounts={accounts}
                    ui={ui}
                    lineIdPrefix="suggested"
                    onChangeCategory={(id) => patchLineUi(line.id, { categoryAccountId: id })}
                    onChangeWho={(v) => patchLineUi(line.id, { who: v })}
                    onChangeMemo={(v) => patchLineUi(line.id, { memo: v })}
                    onSubmit={() => void doCreateEntry(line.id)}
                    onFindMatch={() => void openCandidates(line.id)}
                    onExclude={() => patchLineUi(line.id, { excludeOpen: true })}
                  />
                )}
                {ui.excludeOpen && (
                  <ExcludeForm
                    line={line}
                    ui={ui}
                    onChangeReason={(v) => patchLineUi(line.id, { excludeReason: v })}
                    onSubmit={() => void doExclude(line.id)}
                    onCancel={() => patchLineUi(line.id, { excludeOpen: false })}
                  />
                )}
              </div>
            );
          })}
        </section>
      )}

      {accounts && unmatched.length > 0 && (
        <section className="rec-section">
          <h2>New ({unmatched.length})</h2>
          {unmatched.map((line) => {
            const ui = lineUi(line.id);
            return (
              <div className="rec-line" key={line.id}>
                <BankLineFields line={line} />
                {!ui.excludeOpen && (
                  <CreateEntryForm
                    line={line}
                    accounts={accounts}
                    ui={ui}
                    lineIdPrefix="new"
                    onChangeCategory={(id) => patchLineUi(line.id, { categoryAccountId: id })}
                    onChangeWho={(v) => patchLineUi(line.id, { who: v })}
                    onChangeMemo={(v) => patchLineUi(line.id, { memo: v })}
                    onSubmit={() => void doCreateEntry(line.id)}
                    onFindMatch={() => void openCandidates(line.id)}
                    onExclude={() => patchLineUi(line.id, { excludeOpen: true })}
                  />
                )}
                {ui.candidatesOpen && (
                  <CandidatesPanel
                    ui={ui}
                    onPick={(entryId) => void doMatch(line.id, entryId)}
                    onNone={() => closeCandidates(line.id)}
                    onClose={() => closeCandidates(line.id)}
                  />
                )}
                {ui.excludeOpen && (
                  <ExcludeForm
                    line={line}
                    ui={ui}
                    onChangeReason={(v) => patchLineUi(line.id, { excludeReason: v })}
                    onSubmit={() => void doExclude(line.id)}
                    onCancel={() => patchLineUi(line.id, { excludeOpen: false })}
                  />
                )}
              </div>
            );
          })}
        </section>
      )}

      {unsettledCount === 0 && (settled.length > 0 || excludedLines.length > 0) && (
        <p className="sub" style={{ margin: "8px 0" }}>
          Every line has a decision — you're ready to reconcile below.
        </p>
      )}

      {settled.length > 0 && (
        <details className="rec-section-details">
          <summary>Settled ({settled.length})</summary>
          {settled.map((line) => {
            const ui = lineUi(line.id);
            return (
              <div className="rec-settled-row" key={line.id}>
                <span className="rec-line-date">{fmtDate(line.postedDate)}</span>
                <span className="rec-line-desc">{line.description || "(no description)"}</span>
                <span className="rec-line-amount">{fmtMoney(line.amount)}</span>
                {line.matchedEntryId && (
                  <button type="button" className="acct-link" onClick={() => navigate({ view: "entry", id: line.matchedEntryId! })}>
                    Entry #{line.matchedEntry?.entryNo ?? line.matchedEntryId}
                  </button>
                )}
                <button type="button" className="acct-link" disabled={ui.busy} onClick={() => void doUnmatch(line.id)}>
                  {ui.busy ? "Unmatching…" : "Unmatch"}
                </button>
                {ui.error && <ErrorBanner message={ui.error} onRetry={() => void doUnmatch(line.id)} />}
              </div>
            );
          })}
        </details>
      )}

      {excludedLines.length > 0 && (
        <details className="rec-section-details">
          <summary>Excluded ({excludedLines.length})</summary>
          {excludedLines.map((line) => {
            const ui = lineUi(line.id);
            return (
              <div className="rec-settled-row" key={line.id}>
                <span className="rec-line-date">{fmtDate(line.postedDate)}</span>
                <span className="rec-line-desc">{line.description || "(no description)"}</span>
                <span className="rec-line-amount">{fmtMoney(line.amount)}</span>
                <span className="sub">{line.excludedReason}</span>
                <button type="button" className="acct-link" disabled={ui.busy} onClick={() => void doRestore(line.id)}>
                  {ui.busy ? "Restoring…" : "Restore"}
                </button>
                {ui.error && <ErrorBanner message={ui.error} onRetry={() => void doRestore(line.id)} />}
              </div>
            );
          })}
        </details>
      )}

      <div className="rec-footer">
        <div className="rec-footer-inputs">
          <div className="field">
            <label htmlFor="rec-ending-balance">Statement ending balance</label>
            <input
              id="rec-ending-balance"
              className="text-input"
              type="text"
              inputMode="decimal"
              value={endingBalance}
              onChange={(ev) => {
                setEndingBalanceTouched(true);
                setEndingBalance(ev.target.value);
              }}
            />
            {endingBalanceTouched && endingErr && <p className="field-error">{endingErr}</p>}
          </div>
          <div className="field">
            <label htmlFor="rec-through-date">Reconcile through</label>
            <input
              id="rec-through-date"
              className="text-input"
              type="date"
              value={throughDate}
              onChange={(ev) => setThroughDate(ev.target.value)}
            />
          </div>
        </div>

        <div className="rec-footer-status">
          {bookBalanceLoading && <p className="msg-inline">Checking your books&hellip;</p>}
          {bookBalanceError && <p className="field-error">{bookBalanceError}</p>}
          {!bookBalanceLoading && !bookBalanceError && bookBalance !== null && endingErr === null && (
            <p className="sub">
              Bank says {fmtCentsUsd(endingCents!)} · your books say {fmtMoney(bookBalance)}
              {diffCents !== null ? ` — ${reconcileDifferenceSentence(diffCents)}` : ""}
            </p>
          )}
          {unsettledCount > 0 && (
            <p className="sub">
              {unsettledCount} line{unsettledCount === 1 ? "" : "s"} still need a decision before you can reconcile.
            </p>
          )}
        </div>

        {reconcileError && <ErrorBanner message={reconcileError} onRetry={() => void doReconcile()} />}

        <div className="wizard-actions">
          <button type="button" className="btn" disabled={!canReconcile} onClick={() => void doReconcile()}>
            {reconciling ? "Reconciling…" : "Reconcile"}
          </button>
          <button type="button" className="acct-link" onClick={() => navigate("home")}>
            Back to Home
          </button>
        </div>
      </div>
    </div>
  );
}
