// Universal import wizard — Task 5 of
// docs/superpowers/plans/2026-08-02-switch-from-anywhere.md (spec Part B of
// docs/superpowers/specs/2026-08-02-switch-from-anywhere-design.md). The
// "Restore from other software" path beside the QuickBooks card: pick the
// software you're leaving, upload its chart-of-accounts export, match up the
// columns (server-guessed, user-confirmed — always), map each of its account
// types onto our five kinds, commit; then the same for the general ledger —
// preview, stage into the existing per-year validation pipeline, fix what it
// finds, and post. The staging/validate/post machinery is EXACTLY the QB
// restore's (tools/lib/journal-load.ts via server/src/universalImport.ts) —
// this file is only the mapping front door, and it reuses the QB wizard's
// own presentation pieces (Dropzone, YearReportRow, ErrorBanner, the
// success-screen/report layouts) rather than inventing a second visual
// language. The chart preview is grouped by chartPreview.ts's groupPreview —
// the SAME standard sections the Start-fresh preview and the Chart of
// Accounts screen use (CLAUDE.md §9 as amended 2026-08-02), so a credit card
// is visibly a credit card before anything is created.
//
// Mounted by SetupWizard.tsx as its "universal" step, so the surrounding
// heading/wrap (and the resumability rules) stay SetupWizard's business.
//
// The software you pick on the first screen is not decoration: it rides along
// with the upload as `presetKey`, and the server starts the column guesses
// from THAT product's hints. Header scoring still runs on the server, but only
// as a second opinion — when the file's column names score clearly better
// against a different product, that comes back as `alternatePreset` and this
// screen ASKS ("You picked Sage BusinessWorks. This file's column names look
// more like a Xero export — want to use those guesses instead?"), with both
// answers one click apart. Nothing overrides the pick silently, and the line
// "This file looks like a X export" is only ever shown when a product really
// was detected from the file and no pick contradicts it.
//
// Server contract: server/src/universalImport.ts. Every error string it
// sends is already English (CLAUDE.md §9) and is surfaced VERBATIM here; the
// two deliberate 409s (byte-identical re-import; same entries under a
// different import label) additionally get a short plain-English line of
// their own so the refusal reads as protection, not obstruction.
//
// One honest wrinkle: the upload reply's distinct account-type list (for the
// type value-mapping table) is computed server-side against the GUESSED type
// column. If the user points the type mapping at a DIFFERENT column, there
// is no endpoint that re-lists that column's values — so this screen runs
// the normal chart preview and reads the engine's own row errors ("account
// type "X" isn't mapped to one of the five kinds yet…") to discover the
// values, merging them into the table and staying put until every one has a
// kind. The message prefix it matches is the engine's stable, tested string
// (tools/universal-map-logic.ts's applyChartMapping); error lists are capped
// at 50 per reply, so a file with more distinct types than that converges
// over a couple of preview rounds instead of one. Guesses either way — the
// user always confirms.

import { useCallback, useEffect, useMemo, useState } from "react";
import { apiGet, apiPost, ApiError } from "../lib/api";
import { apiUpload } from "./apiUpload";
import { Dropzone } from "./Dropzone";
import { YearReportRow, type YearReportData } from "./YearReportRow";
import { groupPreview } from "./chartPreview";
import {
  sniffDateFormatSample,
  summarizeSampleDirections,
  type SampleDirection,
  type KnownAccount,
} from "./universalSample";
import { ErrorBanner } from "../components/ErrorBanner";
import type { NavTarget } from "../lib/useHashView";

// ---------------------------------------------------------------------------
// server reply shapes (server/src/universalImport.ts)
// ---------------------------------------------------------------------------

type LedgerKind = "asset" | "liability" | "equity" | "income" | "expense";

interface FieldGuess {
  field: string;
  col: number;
  header: string;
  confidence: "exact" | "fuzzy";
}

interface PresetGuess {
  key: string;
  label: string;
  description: string;
  likelyKind: "chart" | "journal";
  score: number;
  guesses: FieldGuess[];
  /** "picked": these guesses came from the software the user named on the
   *  first screen. "detected": nobody named one, so the file's headers chose.
   *  ONLY "detected" may be reported as "this file looks like a X export" —
   *  see the preset banner below. */
  source: "picked" | "detected";
}

/** The second opinion: header scoring found a clearly different product than
 *  the one picked. Offered, never applied behind the user's back. */
type AlternatePreset = Omit<PresetGuess, "source">;

/** Which preset's guesses the column picks were last filled from — null once
 *  a saved setup has overwritten them. */
type PresetInUse = "picked" | "alternate" | null;

interface UploadBase {
  uploadId: string;
  kind: "chart" | "journal";
  filename: string;
  headers: string[];
  columnCount: number;
  dataStartIndex: number;
  sampleRows: string[][];
  presetGuess: PresetGuess | null;
  alternatePreset: AlternatePreset | null;
}

interface ChartUploadResponse extends UploadBase {
  distinctTypes: string[];
  typeMapSuggestions: Record<string, LedgerKind | null>;
}

interface DateSniffReply {
  column: number;
  format: string | null;
  confident: boolean;
  ambiguousBecause?: string;
}

interface JournalUploadResponse extends UploadBase {
  dateSniff: DateSniffReply | null;
}

interface RowError {
  row: number;
  message: string;
}

interface ChartPreviewAccount {
  name: string;
  type: LedgerKind;
  code?: string;
  aliases: string[];
  subtype?: string;
}

interface ChartPreviewResponse {
  accounts: ChartPreviewAccount[];
  errors: RowError[];
  errorTotal: number;
}

interface ChartCommitResponse {
  totalInFile: number;
  accountsCreated: number;
  accountsSkipped: number;
  aliasesCreated: number;
  aliasesSkipped: number;
}

interface ChartCommitErrorBody {
  error?: string;
  errors?: RowError[];
  errorTotal?: number;
  conflicts?: { name: string; existingType: string; mappedType: string }[];
  codeProblems?: { name: string; code: string; problem: string }[];
}

interface JournalPreviewResponse {
  entryCount: number;
  lineCount: number;
  dateRange: { from: string; to: string | null } | null;
  yearBreakdown: { year: number; entries: number; lines: number }[];
  errors: RowError[];
  errorTotal: number;
}

interface StageResponse {
  source: string;
  years: YearReportData[];
}

interface UPostYear {
  year: number;
  batchId: number;
  posted: number | null;
  alreadyPosted: boolean;
  reconciliation: { matches?: boolean } | null;
  error?: string;
}

interface UPostResponse {
  years: UPostYear[];
}

interface PostErrorBody {
  error?: string;
  dirtyYears?: number[];
}

interface SavedMapping {
  id: number;
  kind: "chart" | "journal";
  name: string;
  mapping: Record<string, unknown>;
  updatedAt: string;
}

/** GET /api/c/accounts — only the fields the direction check needs. */
interface ApiAccountLite {
  id: string;
  code: string | null;
  name: string;
  type: "asset" | "liability" | "equity" | "income" | "expense";
}

// ---------------------------------------------------------------------------
// the source picker's cards — labels/keys track tools/universal-presets.ts
// (the two generic presets get the spec's "Something else" card copy)
// ---------------------------------------------------------------------------

interface SourceCard {
  key: string;
  label: string;
  blurb: string;
}

const SOURCE_CARDS: readonly SourceCard[] = [
  { key: "sage-businessworks", label: "Sage BusinessWorks", blurb: "General Ledger report, exported to CSV or Excel." },
  { key: "sage-50", label: "Sage 50 (Peachtree)", blurb: "General Ledger report, exported to CSV or Excel." },
  { key: "xero", label: "Xero", blurb: "Journal Report export plus the Chart of Accounts export." },
  { key: "wave", label: "Wave", blurb: "Accounting Transactions export plus the Chart of Accounts export." },
  { key: "freshbooks", label: "FreshBooks", blurb: "General Ledger / Journal Entries report export." },
  {
    key: "generic-debit-credit",
    label: "Something else — spreadsheet with debit and credit columns",
    blurb: "Any software's ledger export with separate debit and credit columns.",
  },
  {
    key: "generic-signed",
    label: "Something else — spreadsheet with one amount column",
    blurb: "Any software's ledger export with a single plus/minus amount column.",
  },
];

const KIND_OPTIONS: { value: LedgerKind; label: string }[] = [
  { value: "asset", label: "Asset — something the business owns" },
  { value: "liability", label: "Liability — something the business owes" },
  { value: "equity", label: "Equity — the owners' stake" },
  { value: "income", label: "Income — money earned" },
  { value: "expense", label: "Expense — money spent" },
];

// Must track server/src/universalImport.ts's dateFormatSchema exactly.
const DATE_FORMAT_OPTIONS: { value: string; label: string }[] = [
  { value: "MM/DD/YYYY", label: "Month/Day/Year — 12/31/2025" },
  { value: "M/D/YY", label: "Month/Day/Year, short — 12/31/25" },
  { value: "YYYY-MM-DD", label: "Year-Month-Day — 2025-12-31" },
  { value: "DD/MM/YYYY", label: "Day/Month/Year — 31/12/2025" },
  { value: "MMM D YYYY", label: "Month name — Dec 31 2025" },
  { value: "D-MMM-YY", label: "Day-Month name-Year — 31-Dec-25" },
  { value: "MM-DD-YYYY", label: "Month-Day-Year, dashes — 12-31-2025" },
];

// The engine's stable unmapped-type row error (see file header) — used to
// DISCOVER a re-pointed type column's distinct values from a normal preview.
const UNMAPPED_TYPE_RE = /^account type "([\s\S]+)" isn't mapped to one of the five kinds yet/;

// ---------------------------------------------------------------------------
// small shared pieces
// ---------------------------------------------------------------------------

function isRecord(v: unknown): v is Record<string, unknown> {
  return typeof v === "object" && v !== null;
}

function fileExtensionOk(name: string): boolean {
  const lower = name.toLowerCase();
  return lower.endsWith(".csv") || lower.endsWith(".xlsx");
}

/** "1. Account ID" — the option text for one column of the file. */
function columnOptionLabel(headers: string[], col: number): string {
  const header = (headers[col] ?? "").trim();
  return header === "" ? `Column ${col + 1}` : `${col + 1}. ${header}`;
}

function ColumnSelect({
  id,
  label,
  value,
  onChange,
  headers,
  required,
  disabled,
  hint,
}: {
  id: string;
  label: string;
  value: number | null;
  onChange: (col: number | null) => void;
  headers: string[];
  required?: boolean;
  disabled?: boolean;
  hint?: string;
}) {
  return (
    <div className="field">
      <label htmlFor={id}>{label}</label>
      <select
        id={id}
        className="select-input"
        value={value === null ? "" : String(value)}
        disabled={disabled}
        onChange={(ev) => onChange(ev.target.value === "" ? null : Number(ev.target.value))}
      >
        <option value="">{required ? "Choose a column…" : "— not in this file —"}</option>
        {headers.map((_, i) => (
          <option key={i} value={String(i)}>
            {columnOptionLabel(headers, i)}
          </option>
        ))}
      </select>
      {hint && <p className="sub" style={{ marginTop: 4 }}>{hint}</p>}
    </div>
  );
}

/** The file's headers + first sample rows, so every column pick is made
 *  looking at the real data (same visual as the bank CSV mapping table). */
function SamplePreviewTable({ upload }: { upload: UploadBase }) {
  return (
    <div style={{ overflowX: "auto", marginTop: 12, marginBottom: 4 }}>
      <table className="mapping-table">
        <thead>
          <tr>
            {upload.headers.map((h, i) => (
              <th key={i}>{h.trim() === "" ? `Column ${i + 1}` : h}</th>
            ))}
          </tr>
        </thead>
        <tbody>
          {upload.sampleRows.map((row, ri) => (
            <tr key={ri}>
              {upload.headers.map((_, ci) => (
                <td key={ci}>{row[ci] ?? ""}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

/**
 * One failure, shown honestly (review F5). "Try again" is only offered when
 * pressing it could actually work — a lost connection, a server fault. The
 * deliberate refusals (both double-import 409s) and the expired-upload 404
 * would re-fire the identical doomed request, so they get no retry button;
 * the expired case gets the one action that DOES help instead.
 */
function FailureBlock({
  message,
  note,
  onRetry,
  recoveryLabel,
  onRecover,
  children,
}: {
  message: string;
  note?: string | null;
  onRetry?: (() => void) | null;
  recoveryLabel?: string;
  onRecover?: () => void;
  children?: React.ReactNode;
}) {
  return (
    <div style={{ marginTop: 12 }}>
      {onRetry ? (
        <ErrorBanner message={message} onRetry={onRetry} />
      ) : (
        <div className="error-banner">
          <span>{message}</span>
          {onRecover && recoveryLabel && (
            <button type="button" onClick={onRecover}>
              {recoveryLabel}
            </button>
          )}
        </div>
      )}
      {note && <p className="sub" style={{ marginTop: 6 }}>{note}</p>}
      {children}
    </div>
  );
}

/** Row-numbered engine errors, verbatim, with the "first 50 of N" honesty
 *  line whenever the reply was capped. */
function RowErrorList({ errors, errorTotal }: { errors: RowError[]; errorTotal: number }) {
  if (errors.length === 0) return null;
  return (
    <>
      <ul className="error-groups" style={{ marginTop: 10 }}>
        {errors.map((e, i) => (
          <li key={i}>
            <span>
              Row {e.row}: {e.message}
            </span>
          </li>
        ))}
      </ul>
      {errorTotal > errors.length && (
        <p className="sub" style={{ marginTop: 6 }}>
          Showing the first {errors.length} of {errorTotal} problems.
        </p>
      )}
    </>
  );
}

/**
 * What we can honestly say about where this file's column guesses came from
 * (FIX 1). Three cases, and the difference between them matters:
 *
 *  - source "detected": no software was named, so the file's own headers
 *    chose the preset. That — and ONLY that — earns "this file looks like a
 *    Xero export", because it is the one case where the statement is about
 *    the file rather than about a choice the user already made.
 *  - source "picked", no second opinion: say plainly which product's guesses
 *    we started from. Never dress the user's own answer up as a discovery.
 *  - source "picked" WITH a second opinion: the headers score clearly better
 *    against a different product. That is worth knowing, so it is offered as
 *    a question with two buttons — never applied behind the user's back, and
 *    reversible in one click either way.
 */
function PresetBanner({
  upload,
  inUse,
  onUse,
}: {
  upload: UploadBase;
  /** Which set of guesses the picks below were last filled from, or null once
   *  a saved setup has replaced them (then neither button claims to be live). */
  inUse: PresetInUse;
  onUse: (which: "picked" | "alternate") => void;
}) {
  const guess = upload.presetGuess;
  const alt = upload.alternatePreset;
  if (guess === null) return null;

  if (guess.source === "detected") {
    return (
      <p className="sub" style={{ marginBottom: 4 }}>
        This file looks like a {guess.label} export.
      </p>
    );
  }

  if (alt === null) {
    return (
      <p className="sub" style={{ marginBottom: 4 }}>
        {guess.guesses.length > 0
          ? `We started from the ${guess.label} column guesses — check every one.`
          : `None of the ${guess.label} column names we know turned up in this file, so nothing is ` +
            "filled in below. Pick each column yourself."}
      </p>
    );
  }

  return (
    <div className="uw-preset-conflict">
      <p>
        You picked {guess.label}. This file&apos;s column names look more like a {alt.label} export
        — want to use those guesses instead?
      </p>
      <div className="uw-preset-conflict-actions">
        <button
          type="button"
          className="btn"
          aria-pressed={inUse === "picked"}
          disabled={inUse === "picked"}
          onClick={() => onUse("picked")}
        >
          Keep the {guess.label} guesses
        </button>
        <button
          type="button"
          className="btn"
          aria-pressed={inUse === "alternate"}
          disabled={inUse === "alternate"}
          onClick={() => onUse("alternate")}
        >
          Use the {alt.label} guesses
        </button>
      </div>
      <p className="sub">
        Either way you confirm every column below before anything is read — switching just refills
        the picks.
      </p>
    </div>
  );
}

/**
 * "Save this column setup" — offered after a successful chart commit and a
 * successful journal stage (spec: saved per-company mappings, like the bank
 * CSV maps but named). buildPayload is called at save time so it always
 * captures the mapping as it was actually used.
 */
function SaveSetupBox({
  kind,
  companyId,
  buildPayload,
}: {
  kind: "chart" | "journal";
  companyId: string;
  buildPayload: () => Record<string, unknown>;
}) {
  const [name, setName] = useState("");
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [savedAs, setSavedAs] = useState<string | null>(null);

  async function save() {
    const trimmed = name.trim();
    if (!trimmed) return;
    setBusy(true);
    setError(null);
    try {
      await apiPost(
        "/api/c/universal/mappings",
        { kind, name: trimmed, mapping: buildPayload() },
        companyId,
      );
      setSavedAs(trimmed);
    } catch (err) {
      setError(err instanceof ApiError ? err.message : "Couldn't save that setup.");
    } finally {
      setBusy(false);
    }
  }

  if (savedAs !== null) {
    return (
      <p className="sub" style={{ marginTop: 14 }}>
        Saved as &ldquo;{savedAs}&rdquo; — next time, pick it from &ldquo;Use a saved setup&rdquo; on
        the matching screen.
      </p>
    );
  }

  return (
    <div className="uw-save-box">
      <p className="sub">Save this column setup to reuse on the next file from the same software?</p>
      {error && <p className="field-error">{error}</p>}
      <div className="uw-save-controls">
        <input
          className="text-input"
          type="text"
          placeholder={kind === "chart" ? "e.g. Sage chart export" : "e.g. Sage ledger export"}
          value={name}
          disabled={busy}
          onChange={(ev) => setName(ev.target.value)}
          onKeyDown={(ev) => {
            if (ev.key === "Enter") void save();
          }}
        />
        <button type="button" className="btn" disabled={busy || !name.trim()} onClick={() => void save()}>
          {busy ? "Saving…" : "Save this column setup"}
        </button>
      </div>
    </div>
  );
}

/** "Use a saved setup" picker — rendered only when any exist for this kind. */
function SavedSetupPicker({
  mappings,
  onApply,
}: {
  mappings: SavedMapping[];
  onApply: (m: SavedMapping) => void;
}) {
  if (mappings.length === 0) return null;
  return (
    <div className="field" style={{ maxWidth: 320, marginTop: 10 }}>
      <label htmlFor="uw-saved-setup">Use a saved setup</label>
      <select
        id="uw-saved-setup"
        className="select-input"
        value=""
        onChange={(ev) => {
          const found = mappings.find((m) => String(m.id) === ev.target.value);
          if (found) onApply(found);
        }}
      >
        <option value="">Choose a saved setup…</option>
        {mappings.map((m) => (
          <option key={m.id} value={String(m.id)}>
            {m.name}
          </option>
        ))}
      </select>
    </div>
  );
}

// ---------------------------------------------------------------------------
// chart mapping state
// ---------------------------------------------------------------------------

interface ChartColumnsState {
  nameCol: number | null;
  typeCol: number | null;
  codeCol: number | null;
  pathCol: number | null;
  pathKind: "full-path" | "parent";
  pathSeparator: string;
}

interface TypeRow {
  raw: string;
  kind: LedgerKind | null;
  /**
   * Whether this account type actually appears in the uploaded file (review
   * F7). A saved setup remembers the type values of the file it was saved
   * from, which is usually a SUPERSET of this one's — those extra answers are
   * worth carrying (they cost nothing and applyChartMapping only ever looks
   * up types it reads), but showing them in a table headed "every account
   * type in the file" would be a plain lie. So they ride along unrendered,
   * and they never gate the Continue button.
   */
  inFile: boolean;
}

function guessCol(guesses: FieldGuess[] | undefined, field: string): number | null {
  const g = guesses?.find((x) => x.field === field);
  return g === undefined ? null : g.col;
}

function initChartColumns(
  upload: ChartUploadResponse,
  guesses: FieldGuess[] | undefined = upload.presetGuess?.guesses,
): ChartColumnsState {
  const fullPath = guessCol(guesses, "fullPath");
  const parent = guessCol(guesses, "parent");
  return {
    nameCol: guessCol(guesses, "name"),
    typeCol: guessCol(guesses, "type"),
    codeCol: guessCol(guesses, "code"),
    pathCol: fullPath ?? parent,
    // Full path is the default reading of a subaccount column (it's what
    // QuickBooks, Sage and most exports emit); only an explicit parent-column
    // guess flips it, so a column the user picks by hand starts as a path.
    pathKind: fullPath === null && parent !== null ? "parent" : "full-path",
    pathSeparator: ":",
  };
}

function initTypeRows(upload: ChartUploadResponse): TypeRow[] {
  return upload.distinctTypes.map((raw) => ({
    raw,
    kind: upload.typeMapSuggestions[raw] ?? null,
    inFile: true,
  }));
}

/** The request-body ChartMapping (server zod: chartMappingSchema). */
function buildChartMappingBody(cols: ChartColumnsState, headerRows: number): Record<string, unknown> {
  const body: Record<string, unknown> = {
    headerRows,
    nameCol: cols.nameCol,
    typeCol: cols.typeCol,
  };
  if (cols.codeCol !== null) body.codeCol = cols.codeCol;
  if (cols.pathCol !== null) {
    if (cols.pathKind === "full-path") body.fullPathCol = cols.pathCol;
    else body.parentCol = cols.pathCol;
    const sep = cols.pathSeparator;
    if (sep !== "" && sep !== ":") body.pathSeparator = sep;
  }
  return body;
}

function buildTypeValueMap(rows: TypeRow[]): Record<string, LedgerKind | null> {
  const map: Record<string, LedgerKind | null> = {};
  for (const r of rows) map[r.raw] = r.kind;
  return map;
}

// ---------------------------------------------------------------------------
// journal mapping state
// ---------------------------------------------------------------------------

interface JournalMapState {
  dateCol: number | null;
  /** "" means "not chosen yet" — see initJournalMap / dateFormatState. */
  dateFormat: string;
  accountCol: number | null;
  shape: "debit-credit" | "signed";
  debitCol: number | null;
  creditCol: number | null;
  amountCol: number | null;
  /**
   * NEVER defaulted (review F1). Which side a positive amount means is the
   * one mapping answer nothing downstream can check: debits still equal
   * credits under the wrong choice, so the preview counts, the validation
   * report, the post and even ledger_out_of_balance() are all identical
   * whether it's right or exactly inverted — the only difference is that
   * every asset and every income account ends up on the wrong side. So it is
   * an explicit unanswered question that blocks Continue, exactly like an
   * unmapped account type does on the chart step.
   */
  positiveIs: "debit" | "credit" | null;
  grouping: "key-column" | "consecutive-balanced";
  groupKeyCol: number | null;
  memoCol: number | null;
  nameCol: number | null;
  refCol: number | null;
}

function initJournalMap(
  upload: JournalUploadResponse,
  presetKey: string | null,
  guesses: FieldGuess[] | undefined = upload.presetGuess?.guesses,
): JournalMapState {
  const debitCol = guessCol(guesses, "debit");
  const creditCol = guessCol(guesses, "credit");
  const amountCol = guessCol(guesses, "amount");
  // Shape: what the file's own headers show wins; the picked source card
  // only breaks the tie when the headers said nothing either way.
  let shape: "debit-credit" | "signed";
  if (debitCol !== null && creditCol !== null) shape = "debit-credit";
  else if (amountCol !== null) shape = "signed";
  else shape = presetKey === "generic-signed" || presetKey === "xero" ? "signed" : "debit-credit";
  const dateCol = guessCol(guesses, "date") ?? upload.dateSniff?.column ?? null;
  // The date format is only pre-accepted when the server's sniff is
  // CONFIDENT (review F6) AND it describes the column we're actually using —
  // switching to a different preset's guesses can point the date mapping at
  // another column entirely. Anything less is an unanswered question: a
  // month-first/day-first mix-up parses cleanly either way and silently
  // posts every entry on a different day.
  const sniffedFormat =
    upload.dateSniff !== null && upload.dateSniff.confident && upload.dateSniff.format !== null
      && upload.dateSniff.column === dateCol
      ? upload.dateSniff.format
      : "";
  return {
    dateCol,
    dateFormat: sniffedFormat,
    accountCol: guessCol(guesses, "account"),
    shape,
    debitCol,
    creditCol,
    amountCol,
    positiveIs: null,
    grouping: "key-column",
    groupKeyCol: guessCol(guesses, "groupKey"),
    memoCol: guessCol(guesses, "memo"),
    nameCol: guessCol(guesses, "name"),
    refCol: guessCol(guesses, "ref"),
  };
}

/** Fast, honest pre-flight checks (mirrors the bank wizard's client-side
 *  validation role) — the server re-validates everything regardless. */
function journalMapProblems(jm: JournalMapState): string[] {
  const problems: string[] = [];
  if (jm.dateCol === null) problems.push("Pick the column that holds each line's date.");
  if (jm.dateFormat === "") {
    problems.push("Tell us what order the dates are written in — we couldn't prove it from the file.");
  }
  if (jm.accountCol === null) problems.push("Pick the column that holds each line's account.");
  if (jm.shape === "debit-credit") {
    if (jm.debitCol === null) problems.push("Pick the debit column.");
    if (jm.creditCol === null) problems.push("Pick the credit column.");
    if (jm.debitCol !== null && jm.creditCol !== null && jm.debitCol === jm.creditCol) {
      problems.push("The debit and credit columns can't be the same column.");
    }
  } else {
    if (jm.amountCol === null) problems.push("Pick the amount column.");
    // Review F1: no default, no guess — nothing downstream can catch this.
    if (jm.positiveIs === null) {
      problems.push("Tell us what a positive amount means in this file — we can't tell, and getting it backwards would flip every balance.");
    }
  }
  if (jm.grouping === "key-column" && jm.groupKeyCol === null) {
    problems.push(
      "Pick the transaction-number column — or use “My file has no transaction number” below.",
    );
  }
  return problems;
}

/** The request-body journal mapping (server zod: journalMappingSchema). */
function buildJournalMappingBody(jm: JournalMapState, headerRows: number): Record<string, unknown> {
  const body: Record<string, unknown> = {
    headerRows,
    dateCol: jm.dateCol,
    dateFormat: jm.dateFormat,
    accountCol: jm.accountCol,
    amountShape:
      jm.shape === "debit-credit"
        ? { kind: "debit-credit", debitCol: jm.debitCol, creditCol: jm.creditCol }
        : { kind: "signed", amountCol: jm.amountCol, positiveIs: jm.positiveIs },
    grouping:
      jm.grouping === "key-column"
        ? { kind: "key-column", groupKeyCol: jm.groupKeyCol }
        : { kind: "consecutive-balanced" },
  };
  if (jm.memoCol !== null) body.memoCol = jm.memoCol;
  if (jm.nameCol !== null) body.nameCol = jm.nameCol;
  if (jm.refCol !== null) body.refCol = jm.refCol;
  return body;
}

/** Re-hydrates a saved journal setup, dropping any column index that falls
 *  outside THIS file's columns (a saved setup can outlive an export whose
 *  shape shifted — better to quietly un-set a column than point at nothing). */
function clampCol(v: unknown, columnCount: number): number | null {
  return typeof v === "number" && Number.isInteger(v) && v >= 0 && v < columnCount ? v : null;
}

function applySavedJournalMap(saved: Record<string, unknown>, columnCount: number, prev: JournalMapState): JournalMapState {
  const fmt = typeof saved.dateFormat === "string" &&
    DATE_FORMAT_OPTIONS.some((o) => o.value === saved.dateFormat)
    ? saved.dateFormat
    : prev.dateFormat;
  return {
    dateCol: clampCol(saved.dateCol, columnCount),
    dateFormat: fmt,
    accountCol: clampCol(saved.accountCol, columnCount),
    shape: saved.shape === "signed" ? "signed" : "debit-credit",
    debitCol: clampCol(saved.debitCol, columnCount),
    creditCol: clampCol(saved.creditCol, columnCount),
    amountCol: clampCol(saved.amountCol, columnCount),
    // Only an explicit remembered answer counts — a saved setup that predates
    // (or omitted) the question must leave it unanswered, not guess "debit".
    positiveIs:
      saved.positiveIs === "credit" ? "credit" : saved.positiveIs === "debit" ? "debit" : null,
    grouping: saved.grouping === "consecutive-balanced" ? "consecutive-balanced" : "key-column",
    groupKeyCol: clampCol(saved.groupKeyCol, columnCount),
    memoCol: clampCol(saved.memoCol, columnCount),
    nameCol: clampCol(saved.nameCol, columnCount),
    refCol: clampCol(saved.refCol, columnCount),
  };
}

function applySavedChartMap(
  saved: Record<string, unknown>,
  columnCount: number,
  prevCols: ChartColumnsState,
  typeRows: TypeRow[],
): { cols: ChartColumnsState; typeRows: TypeRow[] } {
  const savedCols = isRecord(saved.columns) ? saved.columns : {};
  const cols: ChartColumnsState = {
    nameCol: clampCol(savedCols.nameCol, columnCount),
    typeCol: clampCol(savedCols.typeCol, columnCount),
    codeCol: clampCol(savedCols.codeCol, columnCount),
    pathCol: clampCol(savedCols.pathCol, columnCount),
    pathKind: savedCols.pathKind === "parent" ? "parent" : "full-path",
    pathSeparator:
      typeof savedCols.pathSeparator === "string" && savedCols.pathSeparator !== ""
        ? savedCols.pathSeparator.slice(0, 5)
        : prevCols.pathSeparator,
  };
  const savedTypes = isRecord(saved.typeValueMap) ? saved.typeValueMap : {};
  const kinds = new Set(["asset", "liability", "equity", "income", "expense"]);
  const savedKindFor = (raw: string): LedgerKind | null => {
    const k = savedTypes[raw];
    return typeof k === "string" && kinds.has(k) ? (k as LedgerKind) : null;
  };
  const rows = typeRows.map((r) => {
    const savedKind = savedKindFor(r.raw);
    return savedKind === null ? r : { ...r, kind: savedKind };
  });
  // A saved setup usually names a DIFFERENT type column than this file's
  // guess, so its remembered type values may not be in the table yet. Carry
  // them in rather than dropping them — the preview round would otherwise
  // rediscover the same values and make the user re-answer questions this
  // saved setup already answers. Values this file doesn't actually contain
  // are harmless: applyChartMapping only ever looks up types it reads.
  const known = new Set(rows.map((r) => r.raw));
  for (const raw of Object.keys(savedTypes)) {
    if (known.has(raw)) continue;
    const savedKind = savedKindFor(raw);
    if (savedKind === null) continue;
    rows.push({ raw, kind: savedKind, inFile: false });
  }
  return { cols, typeRows: rows };
}

// ---------------------------------------------------------------------------
// the wizard
// ---------------------------------------------------------------------------

type UStep =
  | "source"
  | "chart-upload"
  | "chart-map"
  | "chart-preview"
  | "chart-report"
  | "journal-upload"
  | "journal-map"
  | "journal-preview"
  | "journal-staged"
  | "posted";

export function UniversalWizard({
  companyId,
  navigate,
  onExit,
}: {
  companyId: string;
  navigate: (target: NavTarget) => void;
  /** Back out to SetupWizard's choose-path step. */
  onExit: () => void;
}) {
  const [step, setStep] = useState<UStep>("source");
  const [presetKey, setPresetKey] = useState<string | null>(null);

  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [errorBody, setErrorBody] = useState<unknown>(null);
  /** Extra plain-English line under specific refusals (the 409s). */
  const [errorNote, setErrorNote] = useState<string | null>(null);
  /**
   * Whether pressing the same button again could plausibly succeed (review
   * F5). A dropped connection or a server fault: yes. A refusal — this file
   * is already imported, that upload token is gone, this mapping is invalid —
   * no, and offering "Try again" for one is a lie that wastes the user's time.
   */
  const [errorRetryable, setErrorRetryable] = useState(true);
  /** Set when the upload token expired: the only useful action is to upload
   *  the file again, so the failure block offers exactly that. */
  const [errorExpired, setErrorExpired] = useState(false);
  /** The "that isn't a file we can read" complaint — a field-level note next
   *  to the dropzone, never a retryable banner (review F4). */
  const [fileError, setFileError] = useState<string | null>(null);

  // chart flow
  const [chartFile, setChartFile] = useState<File | null>(null);
  const [chartUpload, setChartUpload] = useState<ChartUploadResponse | null>(null);
  const [chartCols, setChartCols] = useState<ChartColumnsState | null>(null);
  const [typeRows, setTypeRows] = useState<TypeRow[]>([]);
  const [chartPreview, setChartPreview] = useState<ChartPreviewResponse | null>(null);
  const [chartReport, setChartReport] = useState<ChartCommitResponse | null>(null);
  const [typeDiscoveryNote, setTypeDiscoveryNote] = useState<string | null>(null);
  /** Whose column guesses the chart mapping currently sits on — the software
   *  the user picked, or the one the file's headers argue for. Always starts
   *  on the pick; only an explicit click moves it (FIX 1). */
  const [chartPresetInUse, setChartPresetInUse] = useState<PresetInUse>("picked");

  // journal flow
  const [journalFile, setJournalFile] = useState<File | null>(null);
  const [journalUpload, setJournalUpload] = useState<JournalUploadResponse | null>(null);
  const [jm, setJm] = useState<JournalMapState | null>(null);
  const [journalPresetInUse, setJournalPresetInUse] = useState<PresetInUse>("picked");
  const [journalPreview, setJournalPreview] = useState<JournalPreviewResponse | null>(null);
  const [stageResult, setStageResult] = useState<StageResponse | null>(null);
  const [postResult, setPostResult] = useState<UPostResponse | null>(null);

  // saved setups ("Use a saved setup" pickers)
  const [savedChartMaps, setSavedChartMaps] = useState<SavedMapping[]>([]);
  const [savedJournalMaps, setSavedJournalMaps] = useState<SavedMapping[]>([]);

  /** This company's accounts, keyed by BOTH name and code, so the direction
   *  check can resolve whichever the file's account column holds. */
  const [knownAccounts, setKnownAccounts] = useState<Map<string, ApiAccountLite>>(new Map());

  const clearError = useCallback(() => {
    setError(null);
    setErrorBody(null);
    setErrorNote(null);
    setErrorRetryable(true);
    setErrorExpired(false);
    setFileError(null);
  }, []);

  function fail(err: unknown, fallback: string) {
    if (err instanceof ApiError) {
      setError(err.message);
      setErrorBody(err.body);
      // No status at all means the request never reached the server (see
      // lib/api.ts) — that IS worth retrying. A 5xx might be transient too.
      // Every 4xx is the server refusing on the merits: the same request
      // will be refused again, so don't offer to repeat it.
      setErrorRetryable(err.status === undefined || err.status >= 500);
      setErrorExpired(err.status === 404 && err.message.includes("upload has expired"));
      // The two deliberate double-import refusals get a friendlier framing
      // on top of the server's own (verbatim) message.
      if (err.status === 409 && err.message === "This exact file was already imported.") {
        setErrorNote(
          "Those entries are already in your books, so importing the file again would double " +
            "everything. If you rolled that import back and meant to redo it, re-uploading works — " +
            "we only refuse while the original is still posted.",
        );
      } else if (err.status === 409 && err.message.includes("already imported under a different import name")) {
        setErrorNote(
          "Same file, different label still means the same entries — importing it twice would " +
            "double your books, so nothing was staged.",
        );
      }
    } else {
      setError(fallback);
      setErrorRetryable(true);
      setErrorExpired(false);
    }
  }

  // Fetch saved setups when a mapping screen is first shown (and refresh on
  // each entry — a save on the report screen must show up next time around).
  useEffect(() => {
    if (step !== "chart-map" && step !== "journal-map") return;
    const kind = step === "chart-map" ? "chart" : "journal";
    let cancelled = false;
    async function load() {
      try {
        const res = await apiGet<{ mappings: SavedMapping[] }>(
          `/api/c/universal/mappings?kind=${kind}`, companyId,
        );
        if (cancelled) return;
        if (kind === "chart") setSavedChartMaps(res.mappings);
        else setSavedJournalMaps(res.mappings);
      } catch {
        // The picker is a convenience — a failed fetch just means no picker.
      }
    }
    void load();
    return () => {
      cancelled = true;
    };
  }, [step, companyId]);

  // The chart, for the journal step's direction check. Fetched on entering
  // the journal mapping screen (by then the chart is committed, or the user
  // skipped because it was already there). A failure just means no direction
  // hint — never a blocked wizard.
  useEffect(() => {
    if (step !== "journal-map") return;
    let cancelled = false;
    async function load() {
      try {
        const rows = await apiGet<ApiAccountLite[]>("/api/c/accounts", companyId);
        if (cancelled || !Array.isArray(rows)) return;
        const map = new Map<string, ApiAccountLite>();
        for (const a of rows) {
          map.set(a.name, a);
          map.set(a.name.toLowerCase(), a);
          if (a.code) map.set(a.code, a);
        }
        setKnownAccounts(map);
      } catch {
        // hint only
      }
    }
    void load();
    return () => {
      cancelled = true;
    };
  }, [step, companyId]);

  const presetLabel = SOURCE_CARDS.find((c) => c.key === presetKey)?.label ?? "your old software";
  const sourceSlug = presetKey ?? "import";

  // -------------------------------------------------------------------------
  // chart handlers
  // -------------------------------------------------------------------------

  async function handleChartUpload() {
    if (!chartFile) return;
    setBusy(true);
    clearError();
    try {
      const res = await apiUpload<ChartUploadResponse>(
        "/api/c/universal/upload", chartFile, companyId,
        // The pick is what decides the guesses — see the preset banner.
        { kind: "chart", ...(presetKey === null ? {} : { presetKey }) },
      );
      setChartUpload(res);
      setChartCols(initChartColumns(res));
      setTypeRows(initTypeRows(res));
      setChartPresetInUse("picked");
      setChartPreview(null);
      setTypeDiscoveryNote(null);
      setStep("chart-map");
    } catch (err) {
      fail(err, "Couldn't upload that file.");
    } finally {
      setBusy(false);
    }
  }

  /** Re-points the account-type table at `col`. The upload's distinct-type
   *  list only ever described the column the SERVER guessed, so any other
   *  column empties the table and lets the preview round (see file header)
   *  rediscover its values. */
  function syncTypeRowsForCol(upload: ChartUploadResponse, col: number | null) {
    if (col === guessCol(upload.presetGuess?.guesses, "type")) {
      setTypeRows(initTypeRows(upload));
      setTypeDiscoveryNote(null);
    } else {
      setTypeRows([]);
      setTypeDiscoveryNote(
        "You've pointed the type mapping at a different column — we'll read that column's values " +
          "when you continue, then ask you to match each one.",
      );
    }
  }

  function changeTypeCol(col: number | null) {
    if (!chartUpload || !chartCols) return;
    setChartCols({ ...chartCols, typeCol: col });
    syncTypeRowsForCol(chartUpload, col);
  }

  /** Switches the chart mapping between the picked software's column guesses
   *  and the ones the file's headers argue for (FIX 1). An explicit click,
   *  both ways — the pick is never overridden silently. */
  function applyChartPreset(which: "picked" | "alternate") {
    if (!chartUpload) return;
    const guesses =
      which === "picked" ? chartUpload.presetGuess?.guesses : chartUpload.alternatePreset?.guesses;
    const cols = initChartColumns(chartUpload, guesses);
    setChartCols(cols);
    setChartPresetInUse(which);
    syncTypeRowsForCol(chartUpload, cols.typeCol);
  }

  async function handleChartPreview() {
    if (!chartUpload || !chartCols) return;
    setBusy(true);
    clearError();
    try {
      const res = await apiPost<ChartPreviewResponse>(
        "/api/c/universal/chart/preview",
        {
          uploadId: chartUpload.uploadId,
          mapping: buildChartMappingBody(chartCols, chartUpload.dataStartIndex),
          typeValueMap: buildTypeValueMap(typeRows),
        },
        companyId,
      );

      // Discover any type values the table doesn't know yet (file header).
      const discovered = new Set<string>();
      for (const e of res.errors) {
        const m = UNMAPPED_TYPE_RE.exec(e.message);
        if (m && !typeRows.some((r) => r.raw === m[1])) discovered.add(m[1]!);
      }
      if (discovered.size > 0) {
        setTypeRows([
          ...typeRows,
          ...[...discovered].map((raw) => ({ raw, kind: null, inFile: true })),
        ]);
        setTypeDiscoveryNote(
          `Found ${discovered.size} account ${discovered.size === 1 ? "type" : "types"} in that ` +
            "column — match each one below, then continue again.",
        );
        return; // stay on chart-map until every type has a kind
      }

      setChartPreview(res);
      setStep("chart-preview");
    } catch (err) {
      fail(err, "Couldn't read that file with this column setup.");
    } finally {
      setBusy(false);
    }
  }

  async function handleChartCommit() {
    if (!chartUpload || !chartCols) return;
    setBusy(true);
    clearError();
    try {
      const res = await apiPost<ChartCommitResponse>(
        "/api/c/universal/chart/commit",
        {
          uploadId: chartUpload.uploadId,
          mapping: buildChartMappingBody(chartCols, chartUpload.dataStartIndex),
          typeValueMap: buildTypeValueMap(typeRows),
        },
        companyId,
      );
      setChartReport(res);
      setStep("chart-report");
    } catch (err) {
      fail(err, "Couldn't create those accounts.");
    } finally {
      setBusy(false);
    }
  }

  // -------------------------------------------------------------------------
  // journal handlers
  // -------------------------------------------------------------------------

  async function handleJournalUpload() {
    if (!journalFile) return;
    setBusy(true);
    clearError();
    try {
      const res = await apiUpload<JournalUploadResponse>(
        "/api/c/universal/upload", journalFile, companyId,
        { kind: "journal", ...(presetKey === null ? {} : { presetKey }) },
      );
      setJournalUpload(res);
      setJm(initJournalMap(res, presetKey));
      setJournalPresetInUse("picked");
      setJournalPreview(null);
      setStep("journal-map");
    } catch (err) {
      fail(err, "Couldn't upload that file.");
    } finally {
      setBusy(false);
    }
  }

  /** The journal-side twin of applyChartPreset. */
  function applyJournalPreset(which: "picked" | "alternate") {
    if (!journalUpload) return;
    const from = which === "picked" ? journalUpload.presetGuess : journalUpload.alternatePreset;
    setJm(initJournalMap(journalUpload, from?.key ?? presetKey, from?.guesses));
    setJournalPresetInUse(which);
  }

  async function handleJournalPreview() {
    if (!journalUpload || !jm) return;
    setBusy(true);
    clearError();
    try {
      const res = await apiPost<JournalPreviewResponse>(
        "/api/c/universal/journal/preview",
        {
          uploadId: journalUpload.uploadId,
          mapping: buildJournalMappingBody(jm, journalUpload.dataStartIndex),
        },
        companyId,
      );
      setJournalPreview(res);
      setStep("journal-preview");
    } catch (err) {
      fail(err, "Couldn't read that file with this column setup.");
    } finally {
      setBusy(false);
    }
  }

  async function handleStage() {
    if (!journalUpload || !jm) return;
    setBusy(true);
    clearError();
    try {
      const res = await apiPost<StageResponse>(
        "/api/c/universal/journal/stage",
        {
          uploadId: journalUpload.uploadId,
          mapping: buildJournalMappingBody(jm, journalUpload.dataStartIndex),
          sourceSlug,
        },
        companyId,
      );
      setStageResult(res);
      setStep("journal-staged");
    } catch (err) {
      fail(err, "Couldn't check that file against your accounts.");
    } finally {
      setBusy(false);
    }
  }

  async function handlePost() {
    if (!stageResult) return;
    setBusy(true);
    clearError();
    try {
      const res = await apiPost<UPostResponse>(
        "/api/c/universal/journal/post",
        { sourceSlug, years: stageResult.years.map((y) => y.year) },
        companyId,
      );
      setPostResult(res);
      setStep("posted");
    } catch (err) {
      fail(err, "Couldn't post to the ledger.");
    } finally {
      setBusy(false);
    }
  }

  // -------------------------------------------------------------------------
  // derived bits
  // -------------------------------------------------------------------------

  /** Only the types actually present in THIS file must be answered (F7). */
  const visibleTypeRows = useMemo(() => typeRows.filter((r) => r.inFile), [typeRows]);

  const chartMapReady =
    chartCols !== null &&
    chartCols.nameCol !== null &&
    chartCols.typeCol !== null &&
    visibleTypeRows.every((r) => r.kind !== null);

  const journalProblems = useMemo(() => (jm ? journalMapProblems(jm) : []), [jm]);

  // The preview is grouped by the SAME standard sections the Chart of
  // Accounts screen uses (CLAUDE.md §9 as amended: a credit card must be
  // findable as a credit card), via the wizard's existing grouping helper —
  // so what you see here is where each account will actually land.
  const chartGroups = useMemo(
    () =>
      chartPreview === null
        ? null
        : groupPreview(
          chartPreview.accounts.map((a) => ({
            code: a.code ?? "",
            name: a.name,
            type: a.type,
            subtype: a.subtype ?? null,
          })),
        ),
    [chartPreview],
  );

  const shortcutCount = useMemo(
    () => (chartPreview === null ? 0 : chartPreview.accounts.filter((a) => a.aliases.length > 0).length),
    [chartPreview],
  );

  const dateSniff = journalUpload?.dateSniff ?? null;
  const sniffApplies = dateSniff !== null && jm !== null && jm.dateCol === dateSniff.column;

  const dateSampleValue = useMemo(() => {
    if (!journalUpload || !jm || jm.dateCol === null) return null;
    const col = jm.dateCol;
    const row = journalUpload.sampleRows.find((r) => (r[col] ?? "").trim() !== "");
    return row?.[col] ?? null;
  }, [journalUpload, jm]);

  /**
   * What we can honestly say about the chosen date column (review F6). The
   * server only ever sniffs the column the PRESET guessed, so when the user
   * points the mapping somewhere else we re-sniff the sample rows ourselves
   * (universalSample.ts) rather than leaving the previous answer standing.
   * `settled` false means the question is still open — journalMapProblems
   * blocks Continue on it via dateFormat === "".
   */
  const dateGuidance = useMemo(() => {
    if (!journalUpload || !jm || jm.dateCol === null) return null;
    if (sniffApplies && dateSniff !== null) {
      return {
        source: "file" as const,
        format: dateSniff.format,
        confident: dateSniff.confident,
        because: dateSniff.ambiguousBecause,
      };
    }
    const col = jm.dateCol;
    const sniffed = sniffDateFormatSample(journalUpload.sampleRows.map((r) => r[col] ?? ""));
    return {
      source: "sample" as const,
      format: sniffed.format,
      confident: sniffed.confident,
      because: sniffed.ambiguousBecause,
    };
  }, [journalUpload, jm, sniffApplies, dateSniff]);

  // Re-sniffing a newly chosen date column can settle the question on its
  // own; when it does, accept that format so the user isn't asked twice.
  // (The column change itself CLEARS the format — see changeDateCol — so
  // this only ever fills an open question, never overrides an answer.)
  useEffect(() => {
    if (!jm || dateGuidance === null) return;
    if (jm.dateFormat !== "") return;
    if (dateGuidance.confident && dateGuidance.format !== null) {
      setJm({ ...jm, dateFormat: dateGuidance.format });
    }
  }, [jm, dateGuidance]);

  /**
   * Changing the date column re-opens the format question (review F6). The
   * old answer described a DIFFERENT column, and month-first vs day-first
   * parses cleanly either way — carrying it over silently posts every entry
   * on the wrong day with zero errors to show for it. The effect above
   * re-answers it immediately when the new column sniffs confidently.
   */
  function changeDateCol(col: number | null) {
    if (!jm) return;
    setJm({ ...jm, dateCol: col, dateFormat: "" });
  }

  /** Name/code -> account, for the sample direction check (review F1). */
  const accountLookup = useCallback(
    (raw: string): KnownAccount | undefined => {
      const key = raw.trim();
      const found = knownAccounts.get(key) ?? knownAccounts.get(key.toLowerCase());
      return found === undefined ? undefined : { name: found.name, type: found.type };
    },
    [knownAccounts],
  );

  /**
   * The direction check (review F1). /journal/preview cannot answer this —
   * it returns identical numbers for a correct mapping and a fully inverted
   * one — so this reads the sample rows the upload already gave us and says
   * where a few recognisable accounts land. Sample-based, and labelled as
   * such on screen.
   */
  const sampleDirections: SampleDirection[] = useMemo(() => {
    if (!journalUpload || !jm || jm.accountCol === null) return [];
    if (jm.shape === "debit-credit") {
      if (jm.debitCol === null || jm.creditCol === null) return [];
      return summarizeSampleDirections(
        journalUpload.sampleRows, jm.accountCol,
        { kind: "debit-credit", debitCol: jm.debitCol, creditCol: jm.creditCol },
        accountLookup,
      );
    }
    if (jm.amountCol === null || jm.positiveIs === null) return [];
    return summarizeSampleDirections(
      journalUpload.sampleRows, jm.accountCol,
      { kind: "signed", amountCol: jm.amountCol, positiveIs: jm.positiveIs },
      accountLookup,
    );
  }, [journalUpload, jm, accountLookup]);

  // -------------------------------------------------------------------------
  // render
  // -------------------------------------------------------------------------

  return (
    <>
      {step === "source" && (
        <div className="card">
          <h2 style={{ fontSize: 17, marginBottom: 4 }}>What are you moving from?</h2>
          <p className="sub" style={{ marginBottom: 4 }}>
            We&apos;ll guess the columns and you confirm — nothing is imported until you&apos;ve seen
            exactly what we read.
          </p>
          <p className="sub" style={{ marginBottom: 12 }}>
            You&apos;ll need two exports: the chart of accounts (the account list) and the general
            ledger or journal — CSV or Excel, covering all dates.
          </p>
          <div className="tpl-cards" role="group" aria-label="Software you're moving from">
            {SOURCE_CARDS.map((c) => (
              <button
                key={c.key}
                type="button"
                className="tpl-card"
                aria-pressed={c.key === presetKey}
                onClick={() => setPresetKey(c.key)}
              >
                <span className="tpl-card-label">{c.label}</span>
                <span className="tpl-card-blurb">{c.blurb}</span>
              </button>
            ))}
          </div>
          <div className="wizard-actions">
            <button
              type="button"
              className="btn"
              disabled={presetKey === null}
              onClick={() => {
                clearError();
                setStep("chart-upload");
              }}
            >
              Continue
            </button>
            <button type="button" className="acct-link" onClick={onExit}>
              Back
            </button>
          </div>
        </div>
      )}

      {step === "chart-upload" && (
        <div className="card">
          <p className="sub" style={{ marginBottom: 4 }}>
            Step 1 of 2: upload the chart of accounts from {presetLabel}.
          </p>
          <p className="sub" style={{ marginBottom: 12 }}>
            Export the account list (every account, including inactive ones if it asks) as CSV or
            Excel, then drop it here.
          </p>
          {error && (
            <FailureBlock
              message={error}
              note={errorNote}
              onRetry={errorRetryable ? handleChartUpload : null}
            />
          )}
          <Dropzone
            label="Chart of accounts (.csv or .xlsx)"
            accept=".csv,.xlsx"
            selectedFileName={chartFile?.name}
            disabled={busy}
            onFileSelected={(file) => {
              if (!fileExtensionOk(file.name)) {
                // Review F4: drop the previous selection too. Leaving it in
                // place meant the complaint was about the file you just
                // dropped while the button would upload the OLD one.
                setChartFile(null);
                clearError();
                setFileError(`"${file.name}" isn't a file we can read — choose a .csv or .xlsx export.`);
                return;
              }
              setChartFile(file);
              clearError();
            }}
          />
          {fileError && <p className="field-error">{fileError}</p>}
          <div className="wizard-actions">
            <button
              type="button"
              className="btn"
              disabled={busy || !chartFile}
              onClick={() => void handleChartUpload()}
            >
              {busy ? "Uploading…" : "Upload and match columns"}
            </button>
            <button type="button" className="acct-link" disabled={busy} onClick={() => setStep("source")}>
              Back
            </button>
          </div>
          <p className="sub" style={{ marginTop: 14 }}>
            <button
              type="button"
              className="acct-link"
              disabled={busy}
              onClick={() => {
                clearError();
                setStep("journal-upload");
              }}
            >
              &hellip;or skip this — my accounts are already set up
            </button>
          </p>
          <p className="sub" style={{ marginTop: 4 }}>
            If you skip it, we&apos;ll match the ledger&apos;s account names against the accounts you
            already have and list every one that doesn&apos;t match, so you can add it before anything
            posts.
          </p>
        </div>
      )}

      {step === "chart-map" && chartUpload && chartCols && (
        <div className="card">
          <h2>Match up the columns</h2>
          <p className="sub" style={{ marginBottom: 4 }}>
            Tell us which column holds what in {chartUpload.filename}. We guessed from the file&apos;s
            headers — check every pick.
          </p>
          <PresetBanner upload={chartUpload} inUse={chartPresetInUse} onUse={applyChartPreset} />

          <SavedSetupPicker
            mappings={savedChartMaps}
            onApply={(m) => {
              const applied = applySavedChartMap(m.mapping, chartUpload.columnCount, chartCols, typeRows);
              setChartCols(applied.cols);
              setTypeRows(applied.typeRows);
              // These picks no longer came from either preset's guesses.
              setChartPresetInUse(null);
            }}
          />

          <SamplePreviewTable upload={chartUpload} />

          <div className="uw-field-grid">
            <ColumnSelect
              id="uw-chart-name"
              label="Account name"
              required
              headers={chartUpload.headers}
              value={chartCols.nameCol}
              onChange={(col) => setChartCols({ ...chartCols, nameCol: col })}
            />
            <ColumnSelect
              id="uw-chart-type"
              label="Account type"
              required
              headers={chartUpload.headers}
              value={chartCols.typeCol}
              onChange={changeTypeCol}
            />
            <ColumnSelect
              id="uw-chart-code"
              label="Account number"
              headers={chartUpload.headers}
              value={chartCols.codeCol}
              onChange={(col) => setChartCols({ ...chartCols, codeCol: col })}
            />
            <ColumnSelect
              id="uw-chart-path"
              label="Subaccount info"
              headers={chartUpload.headers}
              value={chartCols.pathCol}
              onChange={(col) => setChartCols({ ...chartCols, pathCol: col })}
              hint="Only if the file records subaccounts — as a parent-account column or a full path like Assets:Checking."
            />
          </div>

          {chartCols.pathCol !== null && (
            <div className="uw-field-grid" style={{ marginTop: 0 }}>
              <div className="field">
                <label htmlFor="uw-chart-pathkind">That column holds…</label>
                <select
                  id="uw-chart-pathkind"
                  className="select-input"
                  value={chartCols.pathKind}
                  onChange={(ev) =>
                    setChartCols({ ...chartCols, pathKind: ev.target.value as "full-path" | "parent" })
                  }
                >
                  <option value="full-path">the account&apos;s full path (e.g. Assets:Checking)</option>
                  <option value="parent">the parent account&apos;s name</option>
                </select>
              </div>
              <div className="field" style={{ maxWidth: 160 }}>
                <label htmlFor="uw-chart-sep">Path separator</label>
                <input
                  id="uw-chart-sep"
                  className="text-input"
                  type="text"
                  maxLength={5}
                  value={chartCols.pathSeparator}
                  onChange={(ev) => setChartCols({ ...chartCols, pathSeparator: ev.target.value })}
                />
              </div>
            </div>
          )}

          {(visibleTypeRows.length > 0 || typeDiscoveryNote) && (
            <div style={{ marginTop: 18 }}>
              <h3 style={{ fontSize: 14.5, fontWeight: 650 }}>Match the account types</h3>
              <p className="sub" style={{ marginTop: 4 }}>
                Every account type in the file needs a home in one of the five standard kinds. We
                pre-matched the obvious ones — the highlighted rows still need you to choose.
              </p>
              {typeDiscoveryNote && (
                <p className="sub" style={{ marginTop: 6, color: "var(--warn-text)" }}>
                  {typeDiscoveryNote}
                </p>
              )}
              {visibleTypeRows.length > 0 && (
                <div style={{ overflowX: "auto", marginTop: 10 }}>
                  <table className="mapping-table uw-type-table">
                    <thead>
                      <tr>
                        <th style={{ textAlign: "left" }}>In the file</th>
                        <th style={{ textAlign: "left" }}>In your books</th>
                      </tr>
                    </thead>
                    <tbody>
                      {visibleTypeRows.map((r) => (
                        <tr key={r.raw} className={r.kind === null ? "uw-type-row-unmapped" : undefined}>
                          <td>{r.raw}</td>
                          <td>
                            <select
                              className="select-input"
                              aria-label={`Kind for source type ${r.raw}`}
                              value={r.kind ?? ""}
                              onChange={(ev) =>
                                setTypeRows(
                                  typeRows.map((row) =>
                                    row.raw === r.raw
                                      ? { ...row, kind: (ev.target.value || null) as LedgerKind | null }
                                      : row,
                                  ),
                                )
                              }
                            >
                              <option value="">Choose a kind…</option>
                              {KIND_OPTIONS.map((o) => (
                                <option key={o.value} value={o.value}>
                                  {o.label}
                                </option>
                              ))}
                            </select>
                          </td>
                        </tr>
                      ))}
                    </tbody>
                  </table>
                </div>
              )}
            </div>
          )}

          {error && (
            <FailureBlock
              message={error}
              note={errorNote}
              onRetry={errorRetryable ? handleChartPreview : null}
              recoveryLabel={errorExpired ? "Upload the file again" : undefined}
              onRecover={errorExpired ? () => { clearError(); setStep("chart-upload"); } : undefined}
            />
          )}

          <div className="wizard-actions">
            <button
              type="button"
              className="btn"
              disabled={busy || !chartMapReady}
              onClick={() => void handleChartPreview()}
            >
              {busy ? "Checking…" : "Preview what we'll create"}
            </button>
            <button type="button" className="acct-link" disabled={busy} onClick={() => setStep("chart-upload")}>
              Choose a different file
            </button>
          </div>
          {!chartMapReady && (
            <p className="sub" style={{ marginTop: 8 }}>
              {chartCols.nameCol === null || chartCols.typeCol === null
                ? "Pick the account-name and account-type columns to continue."
                : "Choose a kind for every highlighted account type to continue."}
            </p>
          )}
        </div>
      )}

      {step === "chart-preview" && chartPreview && chartGroups && (
        <div className="card">
          <h2>Here&apos;s what we read</h2>
          <p className="sub" style={{ marginBottom: 12 }}>
            {chartPreview.accounts.length}{" "}
            {chartPreview.accounts.length === 1 ? "account" : "accounts"} found in the file, sorted
            into the standard sections. Nothing has been created yet.
          </p>
          {chartGroups.length > 0 && (
            <div className="preview-groups">
              {chartGroups.map((group) => (
                <div className="preview-group" key={group.heading}>
                  <h3>
                    {group.heading} <span className="preview-group-count">({group.count})</span>
                  </h3>
                  <p className="preview-group-subtitle">{group.subtitle}</p>
                  {group.sections.map((section) => (
                    <div className="preview-section" key={section.label || group.heading}>
                      {section.label !== "" && (
                        <h4 className="preview-section-label">{section.label}</h4>
                      )}
                      <ul>
                        {section.names.map((name) => (
                          <li key={name}>{name}</li>
                        ))}
                      </ul>
                    </div>
                  ))}
                </div>
              ))}
            </div>
          )}
          {shortcutCount > 0 && (
            <p className="sub" style={{ marginTop: 10 }}>
              {shortcutCount} of these are subaccounts, so their short names become shortcuts too —
              the ledger file can name them either way and still match.
            </p>
          )}

          {chartPreview.errorTotal > 0 && (
            <>
              <p className="sub" style={{ marginTop: 12, color: "var(--bad-text)" }}>
                {chartPreview.errorTotal} {chartPreview.errorTotal === 1 ? "row has" : "rows have"}{" "}
                problems — fix the file (or the column setup), then try again. Nothing imports until
                every row reads cleanly.
              </p>
              <RowErrorList errors={chartPreview.errors} errorTotal={chartPreview.errorTotal} />
            </>
          )}

          {error && (
            <FailureBlock
              message={error}
              note={errorNote}
              onRetry={errorRetryable ? handleChartCommit : null}
              recoveryLabel={errorExpired ? "Upload the file again" : undefined}
              onRecover={errorExpired ? () => { clearError(); setStep("chart-upload"); } : undefined}
            >
              <ChartCommitErrorDetail body={errorBody} />
            </FailureBlock>
          )}

          <div className="wizard-actions">
            <button
              type="button"
              className="btn"
              disabled={busy || chartPreview.errorTotal > 0 || chartPreview.accounts.length === 0}
              onClick={() => void handleChartCommit()}
            >
              {busy
                ? "Creating…"
                : `Create ${chartPreview.accounts.length} ${chartPreview.accounts.length === 1 ? "account" : "accounts"}`}
            </button>
            <button type="button" className="acct-link" disabled={busy} onClick={() => setStep("chart-map")}>
              Back to the column setup
            </button>
          </div>
        </div>
      )}

      {step === "chart-report" && chartReport && chartUpload && chartCols && (
        <div className="card">
          <h2>Chart of accounts loaded</h2>
          <div className="rows">
            <div className="row">
              <span className="n">Accounts in the file</span>
              <span className="a">{chartReport.totalInFile}</span>
            </div>
            <div className="row">
              <span className="n">Accounts created</span>
              <span className="a">{chartReport.accountsCreated}</span>
            </div>
            <div className="row">
              <span className="n">Accounts already on file (skipped)</span>
              <span className="a">{chartReport.accountsSkipped}</span>
            </div>
            <div className="row">
              <span className="n">Shortcuts (aliases) created</span>
              <span className="a">{chartReport.aliasesCreated}</span>
            </div>
            <div className="row">
              <span className="n">Shortcuts already on file (skipped)</span>
              <span className="a">{chartReport.aliasesSkipped}</span>
            </div>
          </div>

          <SaveSetupBox
            kind="chart"
            companyId={companyId}
            buildPayload={() => ({
              columns: {
                nameCol: chartCols.nameCol,
                typeCol: chartCols.typeCol,
                codeCol: chartCols.codeCol,
                pathCol: chartCols.pathCol,
                pathKind: chartCols.pathKind,
                pathSeparator: chartCols.pathSeparator,
              },
              typeValueMap: buildTypeValueMap(typeRows),
            })}
          />

          <div className="wizard-actions">
            <button type="button" className="btn" onClick={() => { clearError(); setStep("journal-upload"); }}>
              Continue to the ledger upload
            </button>
            <button type="button" className="acct-link" onClick={() => setStep("chart-upload")}>
              Upload a different chart file
            </button>
          </div>
        </div>
      )}

      {step === "journal-upload" && (
        <div className="card">
          <p className="sub" style={{ marginBottom: 4 }}>
            Step 2 of 2: upload the general ledger from {presetLabel}.
          </p>
          <p className="sub" style={{ marginBottom: 12 }}>
            Export the general ledger or journal — every line, all dates — as CSV or Excel. Each line
            should carry a date, an account, and an amount.
          </p>
          {error && (
            <FailureBlock
              message={error}
              note={errorNote}
              onRetry={errorRetryable ? handleJournalUpload : null}
            />
          )}
          <Dropzone
            label="General ledger (.csv or .xlsx)"
            accept=".csv,.xlsx"
            selectedFileName={journalFile?.name}
            disabled={busy}
            onFileSelected={(file) => {
              if (!fileExtensionOk(file.name)) {
                setJournalFile(null); // review F4 — see the chart dropzone
                clearError();
                setFileError(`"${file.name}" isn't a file we can read — choose a .csv or .xlsx export.`);
                return;
              }
              setJournalFile(file);
              clearError();
            }}
          />
          {fileError && <p className="field-error">{fileError}</p>}
          <div className="wizard-actions">
            <button
              type="button"
              className="btn"
              disabled={busy || !journalFile}
              onClick={() => void handleJournalUpload()}
            >
              {busy ? "Uploading…" : "Upload and match columns"}
            </button>
            <button
              type="button"
              className="acct-link"
              disabled={busy}
              onClick={() => setStep(chartReport === null ? "chart-upload" : "chart-report")}
            >
              Back
            </button>
          </div>
        </div>
      )}

      {step === "journal-map" && journalUpload && jm && (
        <div className="card">
          <h2>Match up the columns</h2>
          <p className="sub" style={{ marginBottom: 4 }}>
            Tell us which column holds what in {journalUpload.filename}. We guessed from the
            file&apos;s headers — check every pick.
          </p>
          <PresetBanner
            upload={journalUpload}
            inUse={journalPresetInUse}
            onUse={applyJournalPreset}
          />

          <SavedSetupPicker
            mappings={savedJournalMaps}
            onApply={(m) => {
              setJm(applySavedJournalMap(m.mapping, journalUpload.columnCount, jm));
              setJournalPresetInUse(null);
            }}
          />

          <SamplePreviewTable upload={journalUpload} />

          <div className="uw-field-grid">
            <div>
              <ColumnSelect
                id="uw-j-date"
                label="Date"
                required
                headers={journalUpload.headers}
                value={jm.dateCol}
                onChange={changeDateCol}
              />
              <div className="field" style={{ marginTop: 8 }}>
                <label htmlFor="uw-j-datefmt">
                  {dateGuidance?.format != null && dateGuidance.confident
                    ? `These dates look like ${dateGuidance.format}.`
                    : dateGuidance?.format != null
                      ? `These dates look like ${dateGuidance.format} — right?`
                      : "What order are the dates written in?"}
                </label>
                <select
                  id="uw-j-datefmt"
                  className="select-input"
                  value={jm.dateFormat}
                  onChange={(ev) => setJm({ ...jm, dateFormat: ev.target.value })}
                >
                  <option value="">Choose the date order&hellip;</option>
                  {DATE_FORMAT_OPTIONS.map((o) => (
                    <option key={o.value} value={o.value}>
                      {o.label}
                    </option>
                  ))}
                </select>
                {dateGuidance !== null && !dateGuidance.confident && dateGuidance.because && (
                  <p className="sub" style={{ marginTop: 4, color: "var(--warn-text)" }}>
                    We have to ask: {dateGuidance.because}
                  </p>
                )}
                {dateGuidance?.source === "sample" && (
                  <p className="sub" style={{ marginTop: 4 }}>
                    Read from the rows shown above, since you changed the date column.
                  </p>
                )}
                {dateSampleValue && (
                  <p className="sub" style={{ marginTop: 4 }}>
                    First date in that column: &ldquo;{dateSampleValue}&rdquo;
                  </p>
                )}
              </div>
            </div>
            <ColumnSelect
              id="uw-j-account"
              label="Account"
              required
              headers={journalUpload.headers}
              value={jm.accountCol}
              onChange={(col) => setJm({ ...jm, accountCol: col })}
              hint="Account names or numbers — we'll match them against your chart of accounts, and list anything that doesn't match."
            />
          </div>

          <div className="field" style={{ marginTop: 14 }}>
            <span style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 6 }}>
              How does this file show amounts?
            </span>
            <label className="method-option">
              <input
                type="radio"
                name="uw-amount-shape"
                checked={jm.shape === "debit-credit"}
                onChange={() => setJm({ ...jm, shape: "debit-credit" })}
              />
              <span>Two columns — one for debits, one for credits</span>
            </label>
            <label className="method-option">
              <input
                type="radio"
                name="uw-amount-shape"
                checked={jm.shape === "signed"}
                onChange={() => setJm({ ...jm, shape: "signed" })}
              />
              <span>One amount column, with plus and minus amounts</span>
            </label>
          </div>

          {jm.shape === "debit-credit" ? (
            <div className="uw-field-grid" style={{ marginTop: 4 }}>
              <ColumnSelect
                id="uw-j-debit"
                label="Debit column"
                required
                headers={journalUpload.headers}
                value={jm.debitCol}
                onChange={(col) => setJm({ ...jm, debitCol: col })}
              />
              <ColumnSelect
                id="uw-j-credit"
                label="Credit column"
                required
                headers={journalUpload.headers}
                value={jm.creditCol}
                onChange={(col) => setJm({ ...jm, creditCol: col })}
              />
            </div>
          ) : (
            <div className="uw-field-grid" style={{ marginTop: 4 }}>
              <ColumnSelect
                id="uw-j-amount"
                label="Amount column"
                required
                headers={journalUpload.headers}
                value={jm.amountCol}
                onChange={(col) => setJm({ ...jm, amountCol: col })}
              />
              {/* Review F1: deliberately unanswered. There is no safe default
                  here — a wrong answer balances perfectly and inverts every
                  account, so we ask instead of guessing. */}
              <div className={`field${jm.positiveIs === null ? " uw-must-answer" : ""}`}>
                <label htmlFor="uw-j-positive">What does a positive amount mean?</label>
                <select
                  id="uw-j-positive"
                  className="select-input"
                  value={jm.positiveIs ?? ""}
                  onChange={(ev) =>
                    setJm({
                      ...jm,
                      positiveIs: ev.target.value === "" ? null : (ev.target.value as "debit" | "credit"),
                    })
                  }
                >
                  <option value="">Choose one&hellip;</option>
                  <option value="debit">
                    Money into a bank account (a deposit) — a debit
                  </option>
                  <option value="credit">
                    Money out of a bank account (a payment) — a credit
                  </option>
                </select>
                {jm.positiveIs === null && (
                  <p className="sub" style={{ marginTop: 4, color: "var(--warn-text)" }}>
                    We can&apos;t work this out from the file, and there&apos;s no safe guess: get it
                    backwards and the books still balance perfectly while every account ends up on
                    the wrong side. Open the file, find a line you recognise as money going IN, and
                    answer from that.
                  </p>
                )}
              </div>
            </div>
          )}

          {jm.grouping === "key-column" ? (
            <div style={{ marginTop: 14 }}>
              <div className="uw-field-grid" style={{ marginTop: 0 }}>
                <ColumnSelect
                  id="uw-j-groupkey"
                  label="Transaction number"
                  required
                  headers={journalUpload.headers}
                  value={jm.groupKeyCol}
                  onChange={(col) => setJm({ ...jm, groupKeyCol: col })}
                  hint="Lines sharing a transaction number become one entry."
                />
              </div>
              <p className="sub" style={{ marginTop: 6 }}>
                <button
                  type="button"
                  className="acct-link"
                  onClick={() => setJm({ ...jm, grouping: "consecutive-balanced" })}
                >
                  My file has no transaction number
                </button>
              </p>
            </div>
          ) : (
            <div className="uw-lastresort" style={{ marginTop: 14 }}>
              <p>
                <strong>Grouping consecutive rows until they balance.</strong> Without a transaction
                number we&apos;ll read down the file and close an entry each time the amounts even
                out. This is a last resort: it only works when the file lists each entry&apos;s lines
                together, in order. We&apos;ll refuse the file — with row numbers — the moment the
                rows stop balancing cleanly, rather than guess.
              </p>
              <p className="sub" style={{ marginTop: 6 }}>
                <button
                  type="button"
                  className="acct-link"
                  onClick={() => setJm({ ...jm, grouping: "key-column" })}
                >
                  Actually, it does have a transaction number
                </button>
              </p>
            </div>
          )}

          <div className="uw-field-grid" style={{ marginTop: 14 }}>
            <ColumnSelect
              id="uw-j-memo"
              label="Memo / description"
              headers={journalUpload.headers}
              value={jm.memoCol}
              onChange={(col) => setJm({ ...jm, memoCol: col })}
            />
            <ColumnSelect
              id="uw-j-name"
              label="Name (customer/vendor)"
              headers={journalUpload.headers}
              value={jm.nameCol}
              onChange={(col) => setJm({ ...jm, nameCol: col })}
            />
            <ColumnSelect
              id="uw-j-ref"
              label="Reference / check number"
              headers={journalUpload.headers}
              value={jm.refCol}
              onChange={(col) => setJm({ ...jm, refCol: col })}
            />
          </div>

          {journalProblems.length > 0 && (
            <ul className="error-groups" style={{ marginTop: 12 }}>
              {journalProblems.map((p, i) => (
                <li key={i}>
                  <span>{p}</span>
                </li>
              ))}
            </ul>
          )}

          {error && (
            <FailureBlock
              message={error}
              note={errorNote}
              onRetry={errorRetryable ? handleJournalPreview : null}
              recoveryLabel={errorExpired ? "Upload the file again" : undefined}
              onRecover={errorExpired ? () => { clearError(); setStep("journal-upload"); } : undefined}
            />
          )}

          <div className="wizard-actions">
            <button
              type="button"
              className="btn"
              disabled={busy || journalProblems.length > 0}
              onClick={() => void handleJournalPreview()}
            >
              {busy ? "Checking…" : "Preview what we'll import"}
            </button>
            <button type="button" className="acct-link" disabled={busy} onClick={() => setStep("journal-upload")}>
              Choose a different file
            </button>
          </div>
        </div>
      )}

      {step === "journal-preview" && journalPreview && (
        <div className="card">
          <h2>Here&apos;s what we read</h2>
          <p className="sub" style={{ marginBottom: 12 }}>
            Nothing has been imported yet — this is just what the file contains under your column
            setup.
          </p>
          <div className="rows">
            <div className="row">
              <span className="n">Entries</span>
              <span className="a">{journalPreview.entryCount}</span>
            </div>
            <div className="row">
              <span className="n">Lines</span>
              <span className="a">{journalPreview.lineCount}</span>
            </div>
            {journalPreview.dateRange && (
              <div className="row">
                <span className="n">Dates</span>
                <span className="a">
                  {journalPreview.dateRange.from} to {journalPreview.dateRange.to}
                </span>
              </div>
            )}
            {journalPreview.yearBreakdown.map((y) => (
              <div className="row" key={y.year}>
                <span className="n">{y.year}</span>
                <span className="a">
                  {y.entries} {y.entries === 1 ? "entry" : "entries"} &middot; {y.lines} lines
                </span>
              </div>
            ))}
          </div>

          {/* Review F1: the one thing these counts CANNOT tell you. The
              numbers above are byte-identical whether the amounts went in the
              right way round or exactly backwards, so spell out where a few
              accounts actually land and let a human judge it. */}
          {journalPreview.errorTotal === 0 && sampleDirections.length > 0 && (
            <div className="uw-direction-check">
              <p>
                <strong>Does this look right?</strong> Reading the first rows of your file with this
                setup:
              </p>
              <ul>
                {sampleDirections.map((d) => (
                  <li key={d.account} className={d.unusual ? "uw-direction-unusual" : undefined}>
                    {d.account} &mdash; {d.effect}
                  </li>
                ))}
              </ul>
              <p className="sub">
                {jm !== null && jm.shape === "signed"
                  ? "If that reads backwards — sales coming in as reversals, money out where money should be in — go back and change “What does a positive amount mean?”."
                  : "If that reads backwards — sales coming in as reversals, money out where money should be in — go back and check you haven't swapped the debit and credit columns."}{" "}
                Everything balances either way, so this is the one thing we can&apos;t check for you.
              </p>
            </div>
          )}

          {journalPreview.errorTotal === 0 && journalPreview.entryCount === 0 && (
            <p className="sub" style={{ marginTop: 12, color: "var(--warn-text)" }}>
              We didn&apos;t find a single entry in that file. Check that you exported the ledger
              itself (not just its headings), and that the rows start below the header row.
            </p>
          )}

          {journalPreview.errorTotal > 0 && (
            <>
              <p className="sub" style={{ marginTop: 12, color: "var(--bad-text)" }}>
                {journalPreview.errorTotal} {journalPreview.errorTotal === 1 ? "row has" : "rows have"}{" "}
                problems. Usually this means a column pick or the date format is off — go back, fix
                the setup, and preview again. Nothing imports until every row reads cleanly.
              </p>
              <RowErrorList errors={journalPreview.errors} errorTotal={journalPreview.errorTotal} />
            </>
          )}

          {error && (
            <FailureBlock
              message={error}
              note={errorNote}
              onRetry={errorRetryable ? handleStage : null}
              recoveryLabel={errorExpired ? "Upload the file again" : undefined}
              onRecover={errorExpired ? () => { clearError(); setStep("journal-upload"); } : undefined}
            />
          )}

          <div className="wizard-actions">
            {journalPreview.errorTotal === 0 ? (
              <button
                type="button"
                className="btn"
                disabled={busy || journalPreview.entryCount === 0}
                onClick={() => void handleStage()}
              >
                {busy ? "Checking…" : "Check it against your accounts"}
              </button>
            ) : (
              <button type="button" className="btn" disabled={busy} onClick={() => setStep("journal-map")}>
                Fix the column setup
              </button>
            )}
            <button type="button" className="acct-link" disabled={busy} onClick={() => setStep("journal-map")}>
              Back to the column setup
            </button>
          </div>
        </div>
      )}

      {step === "journal-staged" && stageResult && journalUpload && jm && (
        <div className="card">
          <h2>Validation report</h2>
          <p className="sub" style={{ marginBottom: 12 }}>
            Every year found in the file, checked against your chart of accounts.
          </p>
          <div className="year-report-list">
            {stageResult.years.map((y) => (
              <YearReportRow key={y.year} year={y} />
            ))}
          </div>

          {stageResult.years.some((y) => y.errors > 0) && (
            <p className="sub" style={{ marginTop: 14 }}>
              Add the missing accounts or shortcuts to your chart, then check the same file again —
              already-loaded years are re-checked automatically.
            </p>
          )}

          {error && (
            <FailureBlock
              message={error}
              note={errorNote}
              onRetry={
                errorRetryable
                  ? stageResult.years.some((y) => y.errors > 0) ? handleStage : handlePost
                  : null
              }
              recoveryLabel={errorExpired ? "Upload the file again" : undefined}
              onRecover={errorExpired ? () => { clearError(); setStep("journal-upload"); } : undefined}
            >
              {isRecord(errorBody) && (errorBody as PostErrorBody).dirtyYears && (
                <p className="sub">
                  Still failing for: {(errorBody as PostErrorBody).dirtyYears!.join(", ")}
                </p>
              )}
            </FailureBlock>
          )}

          {stageResult.years.every((y) => y.errors === 0) && (
            <SaveSetupBox
              kind="journal"
              companyId={companyId}
              buildPayload={() => ({
                dateCol: jm.dateCol,
                dateFormat: jm.dateFormat,
                accountCol: jm.accountCol,
                shape: jm.shape,
                debitCol: jm.debitCol,
                creditCol: jm.creditCol,
                amountCol: jm.amountCol,
                positiveIs: jm.positiveIs,
                grouping: jm.grouping,
                groupKeyCol: jm.groupKeyCol,
                memoCol: jm.memoCol,
                nameCol: jm.nameCol,
                refCol: jm.refCol,
              })}
            />
          )}

          <div className="wizard-actions">
            <button
              type="button"
              className="btn"
              disabled={busy || stageResult.years.some((y) => y.errors > 0)}
              onClick={() => void handlePost()}
            >
              {busy
                ? "Posting…"
                : `Post ${stageResult.years.length} ${stageResult.years.length === 1 ? "year" : "years"} to the ledger`}
            </button>
            <button type="button" className="btn" disabled={busy} onClick={() => void handleStage()}>
              {busy ? "Checking…" : "Check the file again"}
            </button>
            <button type="button" className="acct-link" disabled={busy} onClick={() => setStep("journal-map")}>
              Change the column setup
            </button>
          </div>
        </div>
      )}

      {step === "posted" && postResult && stageResult && (
        <div className="card success-screen">
          <h2>Posted to the ledger</h2>
          <p>
            {stageResult.years.reduce((sum, y) => sum + y.entries, 0)} entries posted across{" "}
            {stageResult.years.length} {stageResult.years.length === 1 ? "year" : "years"}.
          </p>
          <div className="rows">
            {postResult.years.map((y) => (
              <div className="row" key={y.year}>
                <span className="n">
                  {y.year}
                  {y.alreadyPosted ? " (already posted)" : ""}
                </span>
                <span className="a">
                  {y.reconciliation?.matches ? (
                    <span className="chip">Reconciled</span>
                  ) : y.reconciliation?.matches === false ? (
                    <span className="chip chip-bad">Doesn&apos;t reconcile</span>
                  ) : (
                    ""
                  )}
                </span>
              </div>
            ))}
          </div>
          <p className="sub" style={{ marginTop: 10 }}>
            To finish the switch, compare each year against the numbers {presetLabel} shows, then
            lock it in Year-end close — oldest year first.
          </p>
          <div className="wizard-actions">
            <button type="button" className="btn" onClick={() => navigate("dashboard")}>
              Go to dashboard
            </button>
            <button type="button" className="btn" onClick={() => navigate("close")}>
              Reconcile and lock years
            </button>
          </div>
        </div>
      )}
    </>
  );
}

/** Structured 422 detail from chart/commit — conflicts (same name, different
 *  type) and account-number clashes, rendered like the QB ChartErrorDetail. */
function ChartCommitErrorDetail({ body }: { body: unknown }) {
  if (!isRecord(body)) return null;
  const b = body as ChartCommitErrorBody;
  if (b.conflicts?.length) {
    return (
      <ul className="error-groups">
        {b.conflicts.map((row, i) => (
          <li key={i}>
            <span>
              {row.name} &mdash; already exists as {row.existingType}, this file expects {row.mappedType}
            </span>
          </li>
        ))}
      </ul>
    );
  }
  if (b.codeProblems?.length) {
    return (
      <ul className="error-groups">
        {b.codeProblems.map((row, i) => (
          <li key={i}>
            <span>
              {row.name} (number {row.code}) &mdash; {row.problem}
            </span>
          </li>
        ))}
      </ul>
    );
  }
  if (b.errors?.length) {
    return <RowErrorList errors={b.errors} errorTotal={b.errorTotal ?? b.errors.length} />;
  }
  return null;
}
