// useSnapshotEditor — scoped useReducer hook backing SnapshotEditPage.
//
// Lifecycle of a single snapshot (Issue #146 / Bilan #1b; reworked for
// per-title detail in Issue #213 / Bilan détail par titre):
//   1. mount in 'new' mode (no `?date=` query param) → user picks a date,
//      types values, hits Save → service.saveSnapshotAtomic;
//   2. mount in 'edit' mode (`?date=YYYY-MM-DD`) → load snapshot + lines,
//      user edits values, hits Save → upsert on the existing snapshot;
//   3. delete → service.deleteSnapshot (the page wraps this in a
//      double-confirm modal that requires retyping the snapshot date).
//
// ENTRY MODE DISPATCH (#213) — the editor classifies each account by its OWN
// `account.kind` (simple | detailed), NOT by `category_kind` (simple | priced).
// The category kind is only a *suggested default* for a brand-new account in
// AccountForm; once an account exists, its stored `kind` is authoritative.
//   - simple  : one scalar value per account, kept as a string in `values`.
//   - detailed: a basket of per-security holdings in `holdings` — one
//               `HoldingDraft` per title. On save the aggregated line carries
//               NO scalar qty/price; the service recomputes value = SUM(holdings)
//               and writes the holdings in the same transaction.
//
// The legacy "priced scalar" path (one security per account via account.symbol
// + scalar quantity/unit_price on the line) is SUPERSEDED: after migration v16
// (#211) every former-priced account is now `kind='detailed'` with one holding,
// so those accounts flow through the holdings path. There is no scalar-priced
// editor branch anymore.

import {
  useReducer,
  useCallback,
  useEffect,
  useRef,
} from "react";
import { logInfo, logError } from "../services/logService";
import type {
  BalanceAccountWithCategory,
  BalanceAssetType,
  BalanceCategory,
  BalanceSecurity,
  BalanceSnapshot,
  BalanceSnapshotLine,
  BalanceSnapshotHoldingWithSecurity,
} from "../shared/types";
import {
  listBalanceAccounts,
  listBalanceCategories,
  listSecurities,
  getSnapshotByDate,
  deleteSnapshot,
  listLinesBySnapshot,
  saveSnapshotAtomic,
  getPreviousSnapshot,
  getHoldingsForLatestSnapshot,
  listHoldingsBySnapshotLine,
  normalizeSecuritySymbol,
  BalanceServiceError,
  type SnapshotLineInput,
  type SnapshotHoldingInput,
} from "../services/balance.service";
import { parseFrenchAmount } from "../utils/amountParser";
import type { HoldingColumnMapping } from "../utils/csvAutoDetect";

export type SnapshotEditorMode = "new" | "edit";

/**
 * String-typed, editable mirror of one position inside a detailed account
 * (Issue #213). All numeric fields are kept as strings to preserve empty /
 * partial input; conversion to numbers happens at save time. `rowId` is a
 * stable client-side identity so React can key the sub-rows even before the
 * holding is persisted (a fresh holding has no DB id yet).
 */
export interface HoldingDraft {
  /** Stable client-side row identity for React keys (NOT a DB id). */
  rowId: string;
  /** Security symbol (normalized server-side). */
  symbol: string;
  asset_type: BalanceAssetType;
  /** ISO 4217; defaults to 'CAD'. */
  currency: string;
  /** Optional human-readable security name. */
  security_name: string;
  quantity: string;
  unit_price: string;
  /** Acquisition cost basis for the unrealized-gain column; optional. */
  book_cost: string;
  /** Carried through from a fetched price so save can attribute the source. */
  price_source: string | null;
  price_fetched_at: string | null;
}

let holdingRowSeq = 0;
/** Monotonic client-side row id for holding drafts. */
function nextRowId(): string {
  holdingRowSeq += 1;
  return `h${holdingRowSeq}`;
}

/**
 * Build an empty holding draft (used by ADD_HOLDING). `asset_type` defaults to
 * the account's category asset_type when known, else 'stock'.
 */
export function makeEmptyHolding(
  assetType: BalanceAssetType = "stock"
): HoldingDraft {
  return {
    rowId: nextRowId(),
    symbol: "",
    asset_type: assetType,
    currency: "CAD",
    security_name: "",
    quantity: "",
    unit_price: "",
    book_cost: "",
    price_source: null,
    price_fetched_at: null,
  };
}

/**
 * Map server holdings (from `getHoldingsForLatestSnapshot` for prefill, or
 * `listHoldingsBySnapshotLine` for an edited snapshot) into editable string
 * drafts. `keepPrice` controls whether the unit_price is carried over:
 *   - LOADED (editing an existing snapshot) keeps the saved price.
 *   - PREFILL of the NEXT snapshot drops the price (the user re-enters or
 *     re-fetches it) but keeps quantity + book_cost. Titles with quantity 0 are
 *     already excluded by `getHoldingsForLatestSnapshot`; a title sold then
 *     re-bought reappears because its latest non-zero holding wins server-side.
 */
export function holdingsFromServiceHoldings(
  rows: BalanceSnapshotHoldingWithSecurity[],
  opts: { keepPrice: boolean }
): HoldingDraft[] {
  return rows.map((h) => ({
    rowId: nextRowId(),
    symbol: h.security_symbol,
    asset_type: h.security_asset_type,
    // The joined holdings view doesn't carry the security currency; CAD is the
    // MVP currency and the save path defaults it server-side anyway.
    currency: "CAD",
    security_name: h.security_name ?? "",
    quantity:
      h.quantity !== null && h.quantity !== undefined ? String(h.quantity) : "",
    unit_price: opts.keepPrice
      ? h.unit_price !== null && h.unit_price !== undefined
        ? String(h.unit_price)
        : ""
      : "",
    book_cost:
      h.book_cost !== null && h.book_cost !== undefined
        ? String(h.book_cost)
        : "",
    // Prefill drops the source (the carried price is stale); LOADED keeps it.
    price_source: opts.keepPrice ? h.price_source : null,
    price_fetched_at: opts.keepPrice ? h.price_fetched_at : null,
  }));
}

/**
 * Map parsed CSV data rows into holding drafts for a detailed account (Issue
 * #245 — CSV import). `rows` are DATA rows only (no header); `mapping` gives the
 * column indices from `analyzeHoldingsCsv`. Behavior:
 *   - Symbols are normalized (UPPER/TRIM) like manual entry (SecurityPicker) so
 *     an imported title collapses onto the same `balance_securities` row.
 *   - Numbers are parsed with `parseFrenchAmount` (handles `1 234,56`, `1,234.56`,
 *     `(140,10)`); a cell it cannot read is left EMPTY, or kept verbatim for the
 *     quantity, so validation refuses the row instead of storing a wrong number.
 *   - unit_price + book_cost are OPTIONAL: when the mapping's column is null (no
 *     price/cost column) the field stays empty; the user fetches/types it later.
 *   - Duplicate symbols WITHIN the CSV are merged into one draft to respect the
 *     UNIQUE(snapshot_line_id, security_id) constraint: quantities and book_costs
 *     are SUMMED (multiple lots of the same title) and the FIRST non-empty price
 *     is kept (the current market price is shared across lots).
 * Rows with a blank symbol are skipped. Exported for unit tests.
 */
export function holdingsFromCsvRows(
  rows: string[][],
  mapping: HoldingColumnMapping,
  opts: { defaultAssetType?: BalanceAssetType } = {}
): HoldingDraft[] {
  const defaultAssetType = opts.defaultAssetType ?? "stock";
  const order: string[] = [];
  const bySymbol = new Map<
    string,
    {
      symbol: string;
      qty: number | null;
      qtyRaw: string;
      book: number | null;
      price: string;
    }
  >();

  for (const row of rows) {
    const rawSymbol = (row[mapping.symbol] ?? "").trim();
    if (!rawSymbol) continue;
    const symbol = normalizeSecuritySymbol(rawSymbol);
    if (!symbol) continue;

    // An unreadable quantity used to be coerced to 0, which SAVED a
    // zero-value position without a word (#325). It stays `null` now and the
    // draft keeps the offending text, so `buildDetailedLines` raises the
    // existing `snapshot_priced_quantity_required` and the user sees the cell
    // to correct.
    const qtyRaw = (row[mapping.quantity] ?? "").trim();
    const qtyParsed = parseFrenchAmount(qtyRaw);
    const qty = isNaN(qtyParsed) ? null : qtyParsed;

    let price = "";
    if (mapping.unit_price !== null) {
      const p = parseFrenchAmount((row[mapping.unit_price] ?? "").trim());
      if (!isNaN(p)) price = String(p);
    }

    let book: number | null = null;
    if (mapping.book_cost !== null) {
      const b = parseFrenchAmount((row[mapping.book_cost] ?? "").trim());
      if (!isNaN(b)) book = b;
    }

    const existing = bySymbol.get(symbol);
    if (existing) {
      // One unreadable lot taints the merged quantity: summing it as 0 would
      // hide the bad cell behind a plausible total.
      if (existing.qty === null || qty === null) {
        existing.qty = null;
        if (!existing.qtyRaw) existing.qtyRaw = qtyRaw;
      } else {
        existing.qty += qty;
      }
      if (book !== null) existing.book = (existing.book ?? 0) + book;
      if (!existing.price && price) existing.price = price; // first non-empty
    } else {
      order.push(symbol);
      bySymbol.set(symbol, {
        symbol,
        qty,
        qtyRaw: qty === null ? qtyRaw : "",
        book,
        price,
      });
    }
  }

  return order.map((sym) => {
    const a = bySymbol.get(sym)!;
    return {
      ...makeEmptyHolding(defaultAssetType),
      symbol: a.symbol,
      quantity: a.qty !== null ? String(a.qty) : a.qtyRaw,
      unit_price: a.price,
      book_cost: a.book !== null ? String(a.book) : "",
    };
  });
}

interface State {
  mode: SnapshotEditorMode;
  /** ISO YYYY-MM-DD; editable in both modes (a change in 'edit' moves the snapshot). */
  snapshotDate: string;
  /** Current snapshot row in 'edit' mode (has the id needed for upsert). */
  snapshot: BalanceSnapshot | null;
  /** All active accounts (with category metadata) — drives the line list. */
  accounts: BalanceAccountWithCategory[];
  /** Used to group lines by category in the editor view. */
  categories: BalanceCategory[];
  /** Securities catalogue powering the SecurityPicker autocomplete (#214). */
  securities: BalanceSecurity[];
  /**
   * Map of account_id → string-typed value (simple accounts only). We keep
   * strings to preserve empty / partial input; conversion to number happens
   * at save time.
   */
  values: Record<number, string>;
  /**
   * Map of account_id → array of `HoldingDraft` (detailed accounts only —
   * dispatched on `account.kind === 'detailed'`). One entry per security held.
   * Same partial-input guarantee as `values`.
   */
  holdings: Record<number, HoldingDraft[]>;
  /** Snapshot whose values would prefill if the user clicks "Prefill". */
  previousSnapshot: BalanceSnapshot | null;
  /** Lines from `previousSnapshot` (loaded lazily when needed). */
  previousLines: BalanceSnapshotLine[] | null;
  isLoading: boolean;
  isSaving: boolean;
  isDirty: boolean;
  error: string | null;
  errorCode: string | null;
}

type Action =
  | { type: "SET_LOADING"; payload: boolean }
  | { type: "SET_SAVING"; payload: boolean }
  | { type: "SET_ERROR"; payload: { message: string | null; code: string | null } }
  | {
      type: "LOADED";
      payload: {
        mode: SnapshotEditorMode;
        snapshotDate: string;
        snapshot: BalanceSnapshot | null;
        accounts: BalanceAccountWithCategory[];
        categories: BalanceCategory[];
        securities: BalanceSecurity[];
        values: Record<number, string>;
        holdings: Record<number, HoldingDraft[]>;
        previousSnapshot: BalanceSnapshot | null;
        previousLines: BalanceSnapshotLine[] | null;
      };
    }
  | { type: "SET_DATE"; payload: string }
  | { type: "SET_VALUE"; payload: { accountId: number; value: string } }
  | {
      type: "ADD_HOLDING";
      payload: { accountId: number; holding: HoldingDraft };
    }
  | {
      // Bulk-merge a batch of imported drafts into a detailed account's basket
      // (Issue #245 — CSV import). Merges by normalized symbol so the batch
      // never introduces a duplicate that would violate
      // UNIQUE(snapshot_line_id, security_id) at save.
      type: "IMPORT_HOLDINGS";
      payload: { accountId: number; holdings: HoldingDraft[] };
    }
  | { type: "REMOVE_HOLDING"; payload: { accountId: number; rowId: string } }
  | {
      type: "SET_HOLDING_FIELD";
      payload: {
        accountId: number;
        rowId: string;
        field: keyof Omit<HoldingDraft, "rowId">;
        value: string;
      };
    }
  | {
      // Apply a SecurityPicker selection: sets symbol + asset_type (+ name) in
      // one dispatch and drops any stale fetched-price attribution, since the
      // symbol — and thus the price's subject — has changed (#214).
      type: "SET_HOLDING_SECURITY";
      payload: {
        accountId: number;
        rowId: string;
        symbol: string;
        asset_type: BalanceAssetType;
        security_name: string;
      };
    }
  | {
      type: "PREFILL";
      payload: {
        values: Record<number, string>;
        holdings: Record<number, HoldingDraft[]>;
      };
    }
  | { type: "RESET" }
  | { type: "CLEAR_DIRTY" };

export function initialState(initialDate: string): State {
  return {
    mode: "new",
    snapshotDate: initialDate,
    snapshot: null,
    accounts: [],
    categories: [],
    securities: [],
    values: {},
    holdings: {},
    previousSnapshot: null,
    previousLines: null,
    isLoading: false,
    isSaving: false,
    isDirty: false,
    error: null,
    errorCode: null,
  };
}

/**
 * Pure reducer — exported so the editor's state machine can be unit-tested
 * without rendering the hook (the project has no jsdom/renderHook harness).
 */
export function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "SET_LOADING":
      return { ...state, isLoading: action.payload };
    case "SET_SAVING":
      return { ...state, isSaving: action.payload };
    case "SET_ERROR":
      return {
        ...state,
        error: action.payload.message,
        errorCode: action.payload.code,
        isLoading: false,
        isSaving: false,
      };
    case "LOADED":
      return {
        ...state,
        mode: action.payload.mode,
        snapshotDate: action.payload.snapshotDate,
        snapshot: action.payload.snapshot,
        accounts: action.payload.accounts,
        categories: action.payload.categories,
        securities: action.payload.securities,
        values: action.payload.values,
        holdings: action.payload.holdings,
        previousSnapshot: action.payload.previousSnapshot,
        previousLines: action.payload.previousLines,
        isLoading: false,
        isDirty: false,
        error: null,
        errorCode: null,
      };
    case "SET_DATE":
      // Editable in both modes now (#200): in 'edit' mode a changed date
      // triggers a snapshot move on save (lines preserved).
      return { ...state, snapshotDate: action.payload, isDirty: true };
    case "SET_VALUE":
      return {
        ...state,
        values: {
          ...state.values,
          [action.payload.accountId]: action.payload.value,
        },
        isDirty: true,
      };
    case "ADD_HOLDING": {
      const existing = state.holdings[action.payload.accountId] ?? [];
      return {
        ...state,
        holdings: {
          ...state.holdings,
          [action.payload.accountId]: [...existing, action.payload.holding],
        },
        isDirty: true,
      };
    }
    case "IMPORT_HOLDINGS": {
      // Merge the imported batch into the existing basket by NORMALIZED symbol.
      // A symbol already present is UPDATED in place (keeping its rowId for a
      // stable React key); a new symbol is appended. This guarantees the basket
      // never holds two rows for the same security (UNIQUE constraint at save).
      const existing = state.holdings[action.payload.accountId] ?? [];
      const result = existing.slice();
      const indexBySymbol = new Map<string, number>();
      result.forEach((h, i) => {
        const key = normalizeSecuritySymbol(h.symbol);
        if (key) indexBySymbol.set(key, i);
      });
      for (const draft of action.payload.holdings) {
        const key = normalizeSecuritySymbol(draft.symbol);
        if (!key) continue; // imported rows always carry a symbol; guard anyway
        const idx = indexBySymbol.get(key);
        if (idx !== undefined) {
          const prev = result[idx];
          result[idx] = {
            ...prev,
            symbol: draft.symbol,
            // Keep the existing asset_type: the CSV carries none (the draft only
            // has the account's default class), while the current row's class
            // was set deliberately via the picker or a prior entry.
            security_name: draft.security_name || prev.security_name,
            quantity: draft.quantity,
            // Import wins for price/book_cost ONLY when it carries a value, so an
            // import-without-price never wipes a previously fetched/typed price.
            unit_price: draft.unit_price !== "" ? draft.unit_price : prev.unit_price,
            book_cost: draft.book_cost !== "" ? draft.book_cost : prev.book_cost,
            // A fresh imported price is a manual value → drop stale attribution.
            price_source:
              draft.unit_price !== "" ? null : prev.price_source,
            price_fetched_at:
              draft.unit_price !== "" ? null : prev.price_fetched_at,
          };
        } else {
          indexBySymbol.set(key, result.length);
          result.push(draft);
        }
      }
      return {
        ...state,
        holdings: {
          ...state.holdings,
          [action.payload.accountId]: result,
        },
        isDirty: true,
      };
    }
    case "REMOVE_HOLDING": {
      const existing = state.holdings[action.payload.accountId] ?? [];
      return {
        ...state,
        holdings: {
          ...state.holdings,
          [action.payload.accountId]: existing.filter(
            (h) => h.rowId !== action.payload.rowId
          ),
        },
        isDirty: true,
      };
    }
    case "SET_HOLDING_FIELD": {
      const existing = state.holdings[action.payload.accountId] ?? [];
      const next = existing.map((h) =>
        h.rowId === action.payload.rowId
          ? { ...h, [action.payload.field]: action.payload.value }
          : h
      );
      return {
        ...state,
        holdings: {
          ...state.holdings,
          [action.payload.accountId]: next,
        },
        isDirty: true,
      };
    }
    case "SET_HOLDING_SECURITY": {
      const existing = state.holdings[action.payload.accountId] ?? [];
      const next = existing.map((h) =>
        h.rowId === action.payload.rowId
          ? {
              ...h,
              symbol: action.payload.symbol,
              asset_type: action.payload.asset_type,
              security_name: action.payload.security_name,
              // The previously-fetched price (if any) was for the OLD symbol;
              // drop its attribution so save doesn't mis-credit the source.
              price_source: null,
              price_fetched_at: null,
            }
          : h
      );
      return {
        ...state,
        holdings: {
          ...state.holdings,
          [action.payload.accountId]: next,
        },
        isDirty: true,
      };
    }
    case "PREFILL":
      return {
        ...state,
        values: { ...state.values, ...action.payload.values },
        holdings: { ...state.holdings, ...action.payload.holdings },
        isDirty: true,
      };
    case "RESET":
      return {
        ...state,
        // Keep the loaded structure (accounts, categories, snapshot) but wipe
        // user input back to a clean slate.
        values: {},
        holdings: {},
        isDirty: true,
      };
    case "CLEAR_DIRTY":
      return { ...state, isDirty: false };
    default:
      return state;
  }
}

function describeError(e: unknown): { message: string; code: string | null } {
  if (e instanceof BalanceServiceError) {
    return { message: e.message, code: e.code };
  }
  return {
    message: e instanceof Error ? e.message : String(e),
    code: null,
  };
}

function todayISO(): string {
  // Avoid timezone drift: use local YYYY-MM-DD, not toISOString() which is UTC.
  const d = new Date();
  const yyyy = d.getFullYear();
  const mm = String(d.getMonth() + 1).padStart(2, "0");
  const dd = String(d.getDate()).padStart(2, "0");
  return `${yyyy}-${mm}-${dd}`;
}

/** Parse "12.34" / "12,34" → finite number, or null when empty/invalid. */
function parseDecimal(raw: string | null | undefined): number | null {
  if (raw === null || raw === undefined) return null;
  const trimmed = String(raw).trim().replace(",", ".");
  if (!trimmed) return null;
  const n = Number(trimmed);
  return Number.isFinite(n) ? n : null;
}

/**
 * Build the simple-account `SnapshotLineInput[]` from the editor's `values`
 * map. Only accounts whose own kind is NOT detailed contribute here; detailed
 * accounts go through `buildDetailedLines`. THROWS a typed BalanceServiceError
 * on the first invalid value so no DB mutation happens on bad input (#176).
 * Exported for unit tests.
 */
export function buildSimpleLines(
  values: Record<number, string>,
  detailedAccountIds: ReadonlySet<number>
): SnapshotLineInput[] {
  return Object.entries(values)
    .filter(
      ([accountIdStr, v]) =>
        !detailedAccountIds.has(Number(accountIdStr)) &&
        v !== undefined &&
        String(v).trim().length > 0
    )
    .map(([accountIdStr, raw]) => {
      const accountId = Number(accountIdStr);
      const num = parseDecimal(raw);
      if (num === null) {
        throw new BalanceServiceError(
          "snapshot_value_invalid",
          `Invalid value for account ${accountId}: "${raw}"`
        );
      }
      return {
        account_id: accountId,
        value: num,
        account_kind: "simple" as const,
      };
    });
}

/**
 * Build the detailed-account `SnapshotLineInput[]` (one per account, each
 * carrying its `holdings` array) from the editor's `holdings` map. The presence
 * of the `holdings` field — even an empty array — marks the line detailed for
 * the service. Empty / blank holding rows (no symbol AND no qty AND no price)
 * are dropped before save so a half-typed row doesn't fail validation. THROWS a
 * typed error on a partially-filled row. The aggregated `value` is the SUM of
 * the rounded-cent holding values; the service re-rounds and re-validates it.
 * Exported for unit tests.
 */
export function buildDetailedLines(
  holdings: Record<number, HoldingDraft[]>,
  detailedAccountIds: ReadonlySet<number>
): SnapshotLineInput[] {
  const lines: SnapshotLineInput[] = [];
  for (const accountId of detailedAccountIds) {
    const drafts = holdings[accountId] ?? [];
    const built: SnapshotHoldingInput[] = [];
    for (const d of drafts) {
      const symbol = d.symbol.trim();
      const qtyRaw = String(d.quantity ?? "").trim();
      const priceRaw = String(d.unit_price ?? "").trim();
      const isBlank = symbol.length === 0 && qtyRaw.length === 0 && priceRaw.length === 0;
      if (isBlank) continue; // skip an untouched / freshly-added empty row
      if (symbol.length === 0) {
        throw new BalanceServiceError(
          "snapshot_holding_invalid",
          `A holding for account ${accountId} is missing its symbol`
        );
      }
      const qty = parseDecimal(d.quantity);
      const price = parseDecimal(d.unit_price);
      if (qty === null) {
        throw new BalanceServiceError(
          "snapshot_priced_quantity_required",
          `Invalid quantity for ${symbol} (account ${accountId}): "${d.quantity}"`
        );
      }
      if (price === null) {
        throw new BalanceServiceError(
          "snapshot_priced_unit_price_required",
          `Invalid unit price for ${symbol} (account ${accountId}): "${d.unit_price}"`
        );
      }
      const bookCost = parseDecimal(d.book_cost);
      const value = Math.round(qty * price * 100) / 100;
      built.push({
        symbol,
        asset_type: d.asset_type,
        currency: d.currency || "CAD",
        security_name: d.security_name.trim() || null,
        quantity: qty,
        unit_price: price,
        value,
        book_cost: bookCost,
        price_source: d.price_source,
        price_fetched_at: d.price_fetched_at,
      });
    }
    // Aggregated value = rounded-cent SUM of the holdings' rounded-cent values.
    const total =
      Math.round(built.reduce((s, h) => s + h.value, 0) * 100) / 100;
    lines.push({
      account_id: accountId,
      value: total,
      // `holdings` present (even empty) ⇒ detailed save path; qty/price omitted.
      holdings: built,
    });
  }
  return lines;
}

interface Options {
  /** ISO date from the route query string. `undefined` means 'new' mode. */
  dateParam?: string | null;
}

export function useSnapshotEditor(options: Options = {}) {
  const { dateParam } = options;
  const [state, dispatch] = useReducer(
    reducer,
    undefined,
    () => initialState(dateParam ?? todayISO())
  );
  const fetchIdRef = useRef(0);

  /**
   * Load the editor state from the database. In 'new' mode we still load
   * accounts + categories + the previous snapshot (so the prefill button
   * can be enabled); we do NOT pre-create a snapshot row — that happens at
   * save time so the user can abandon the form without leaving an empty
   * snapshot behind.
   */
  const loadForDate = useCallback(async (date: string | null | undefined) => {
    const fetchId = ++fetchIdRef.current;
    dispatch({ type: "SET_LOADING", payload: true });
    dispatch({ type: "SET_ERROR", payload: { message: null, code: null } });
    const targetDate = date && date.length > 0 ? date : todayISO();
    try {
      const [accounts, categories, securities] = await Promise.all([
        listBalanceAccounts(),
        listBalanceCategories(),
        listSecurities(),
      ]);
      const values: Record<number, string> = {};
      const holdings: Record<number, HoldingDraft[]> = {};
      let previousLines: BalanceSnapshotLine[] | null = null;
      // Index each account's OWN kind (simple|detailed) — this, not the
      // category kind, decides which input map a line belongs to (#213).
      const accountById = new Map<number, BalanceAccountWithCategory>();
      for (const acc of accounts) accountById.set(acc.id, acc);

      const existing = await getSnapshotByDate(targetDate);
      const isEdit = !!existing;
      if (existing) {
        const lines = await listLinesBySnapshot(existing.id);
        for (const line of lines) {
          const acc = accountById.get(line.account_id);
          if (acc?.kind === "detailed") {
            // Hydrate the basket from this line's persisted holdings.
            const rows = await listHoldingsBySnapshotLine(line.id);
            holdings[line.account_id] = holdingsFromServiceHoldings(rows, {
              keepPrice: true,
            });
          } else {
            values[line.account_id] = String(line.value);
          }
        }
        // Detailed accounts with NO line yet at this snapshot still get an
        // (empty) basket so the editor renders the detailed variant for them.
        for (const acc of accounts) {
          if (acc.kind === "detailed" && holdings[acc.id] === undefined) {
            holdings[acc.id] = [];
          }
        }
      } else {
        // 'new' mode: prefill detailed baskets from each account's latest
        // snapshot holdings (qty-0 excluded server-side), price dropped.
        for (const acc of accounts) {
          if (acc.kind === "detailed") {
            const rows = await getHoldingsForLatestSnapshot(acc.id);
            holdings[acc.id] = holdingsFromServiceHoldings(rows, {
              keepPrice: false,
            });
          }
        }
      }

      const previous = await getPreviousSnapshot(targetDate);
      if (previous) {
        previousLines = await listLinesBySnapshot(previous.id);
      }
      if (fetchId !== fetchIdRef.current) return;
      dispatch({
        type: "LOADED",
        payload: {
          mode: isEdit ? "edit" : "new",
          snapshotDate: targetDate,
          snapshot: existing,
          accounts,
          categories,
          securities,
          values,
          holdings,
          previousSnapshot: previous,
          previousLines,
        },
      });
    } catch (e) {
      if (fetchId !== fetchIdRef.current) return;
      dispatch({ type: "SET_ERROR", payload: describeError(e) });
    }
  }, []);

  // Load on mount + whenever the route's `?date=` changes.
  useEffect(() => {
    loadForDate(dateParam);
  }, [dateParam, loadForDate]);

  const setDate = useCallback((next: string) => {
    dispatch({ type: "SET_DATE", payload: next });
  }, []);

  const setLineValue = useCallback((accountId: number, value: string) => {
    dispatch({
      type: "SET_VALUE",
      payload: { accountId, value },
    });
  }, []);

  const addHolding = useCallback(
    (accountId: number, assetType: BalanceAssetType = "stock") => {
      dispatch({
        type: "ADD_HOLDING",
        payload: { accountId, holding: makeEmptyHolding(assetType) },
      });
    },
    []
  );

  /**
   * Import a batch of holdings from parsed CSV rows into a detailed account
   * (Issue #245). Builds drafts via `holdingsFromCsvRows` (FR numbers, optional
   * price, duplicate-symbol merge) then dispatches IMPORT_HOLDINGS, which merges
   * them into the account's basket by symbol. New securities default to the
   * account's category asset class (else 'stock').
   */
  const importHoldings = useCallback(
    (accountId: number, rows: string[][], mapping: HoldingColumnMapping) => {
      const acc = state.accounts.find((a) => a.id === accountId);
      const drafts = holdingsFromCsvRows(rows, mapping, {
        defaultAssetType: acc?.category_asset_type ?? "stock",
      });
      if (drafts.length === 0) {
        logInfo("Balance: CSV import produced no holdings (no valid rows)");
        return 0;
      }
      dispatch({
        type: "IMPORT_HOLDINGS",
        payload: { accountId, holdings: drafts },
      });
      logInfo(
        `Balance: imported ${drafts.length} holding(s) from CSV into account ${accountId}`
      );
      return drafts.length;
    },
    [state.accounts]
  );

  const removeHolding = useCallback((accountId: number, rowId: string) => {
    dispatch({ type: "REMOVE_HOLDING", payload: { accountId, rowId } });
  }, []);

  const setHoldingField = useCallback(
    (
      accountId: number,
      rowId: string,
      field: keyof Omit<HoldingDraft, "rowId">,
      value: string
    ) => {
      dispatch({
        type: "SET_HOLDING_FIELD",
        payload: { accountId, rowId, field, value },
      });
    },
    []
  );

  /**
   * Apply a SecurityPicker selection to a holding row (#214): sets symbol +
   * asset_type + name in one shot. `name` is empty for a freshly-created
   * symbol; an existing security carries its catalogue name.
   */
  const setHoldingSecurity = useCallback(
    (
      accountId: number,
      rowId: string,
      pick: {
        symbol: string;
        asset_type: BalanceAssetType;
        name: string | null;
      }
    ) => {
      dispatch({
        type: "SET_HOLDING_SECURITY",
        payload: {
          accountId,
          rowId,
          symbol: pick.symbol,
          asset_type: pick.asset_type,
          security_name: pick.name ?? "",
        },
      });
    },
    []
  );

  const reset = useCallback(() => {
    dispatch({ type: "RESET" });
  }, []);

  /**
   * Build the prefill map from the previous snapshot (simple accounts only —
   * detailed accounts are prefilled from their latest holdings at LOAD time in
   * 'new' mode, which is more accurate than copying the previous *line*). Per
   * spec-decisions row "Bouton Pré-remplir": simple → copy value.
   */
  const prefillFromPrevious = useCallback(() => {
    const lines = state.previousLines;
    if (!lines || lines.length === 0) return;
    const accountById = new Map<number, BalanceAccountWithCategory>();
    for (const acc of state.accounts) accountById.set(acc.id, acc);
    const nextSimple: Record<number, string> = {};
    for (const line of lines) {
      const acc = accountById.get(line.account_id);
      if (!acc) continue; // archived account — skip
      if (acc.kind === "simple") {
        nextSimple[line.account_id] = String(line.value);
      }
      // Detailed accounts: intentionally NOT prefilled from the previous line
      // here — their basket was already hydrated from the latest holdings.
    }
    dispatch({
      type: "PREFILL",
      payload: { values: nextSimple, holdings: {} },
    });
  }, [state.previousLines, state.accounts]);

  /**
   * Persist the editor state to the database (#176 — atomic; #213 — detailed).
   *
   * Order of operations:
   *  1. Build & validate `simpleLines` (scalar) and `detailedLines` (holdings)
   *     from editor state. Any input parsing error throws BEFORE any DB
   *     mutation, so an invalid form never produces an orphan snapshot row.
   *  2. Call `saveSnapshotAtomic` which wraps the snapshot INSERT (new mode),
   *     the line rewrite AND the holdings rewrite in a single BEGIN/COMMIT/
   *     ROLLBACK transaction.
   *
   * Modes:
   *  - 'new' mode: atomic helper inserts the snapshot row + its lines/holdings.
   *  - 'edit' mode: only the lines/holdings get rewritten on the existing row.
   */
  const save = useCallback(async (): Promise<{ snapshotId: number }> => {
    dispatch({ type: "SET_SAVING", payload: true });
    dispatch({ type: "SET_ERROR", payload: { message: null, code: null } });
    try {
      // Set of detailed account ids — dispatched on each account's OWN kind.
      const detailedAccountIds = new Set<number>();
      for (const acc of state.accounts) {
        if (acc.kind === "detailed") detailedAccountIds.add(acc.id);
      }

      // Step 1 — build & validate every line in memory. THROW HERE means no DB
      // mutation has happened yet, so no orphan snapshot can be left behind by
      // a validation failure (#176).
      const simpleLines = buildSimpleLines(state.values, detailedAccountIds);
      const detailedLines = buildDetailedLines(
        state.holdings,
        detailedAccountIds
      );

      // Step 2 — atomic write. BEGIN / INSERT snapshot (if 'new') /
      // INSERT lines + holdings / COMMIT, with ROLLBACK on any failure.
      const existingSnapshotId =
        state.mode === "edit" && state.snapshot ? state.snapshot.id : null;
      // Edit-mode date move (#200): when the user changed the date of an
      // existing snapshot, forward the new date so the atomic save moves the
      // row (preserving its lines) in the same transaction. A collision
      // surfaces as `snapshot_date_exists` and rolls back.
      const moveToDate =
        state.mode === "edit" &&
        state.snapshot &&
        state.snapshotDate !== state.snapshot.snapshot_date
          ? state.snapshotDate
          : null;
      const { snapshotId } = await saveSnapshotAtomic({
        existingSnapshotId,
        snapshot_date: state.snapshotDate,
        lines: [...simpleLines, ...detailedLines],
        moveToDate,
      });
      dispatch({ type: "CLEAR_DIRTY" });
      logInfo(
        `Balance: snapshot saved (${state.snapshotDate}, ${
          simpleLines.length + detailedLines.length
        } lines)`
      );
      // Reload so 'new' mode flips to 'edit' and the snapshot row is in state.
      await loadForDate(state.snapshotDate);
      return { snapshotId };
    } catch (e) {
      logError(`Balance: snapshot save failed (${state.snapshotDate})`, e);
      dispatch({ type: "SET_ERROR", payload: describeError(e) });
      throw e;
    } finally {
      dispatch({ type: "SET_SAVING", payload: false });
    }
  }, [
    state.mode,
    state.snapshot,
    state.snapshotDate,
    state.values,
    state.holdings,
    state.accounts,
    loadForDate,
  ]);

  const remove = useCallback(async () => {
    if (!state.snapshot) return;
    dispatch({ type: "SET_SAVING", payload: true });
    dispatch({ type: "SET_ERROR", payload: { message: null, code: null } });
    try {
      await deleteSnapshot(state.snapshot.id);
    } catch (e) {
      dispatch({ type: "SET_ERROR", payload: describeError(e) });
      throw e;
    } finally {
      dispatch({ type: "SET_SAVING", payload: false });
    }
  }, [state.snapshot]);

  return {
    state,
    setDate,
    setLineValue,
    addHolding,
    importHoldings,
    removeHolding,
    setHoldingField,
    setHoldingSecurity,
    reset,
    prefillFromPrevious,
    save,
    remove,
    /** Manual reload (e.g. after navigation between dates). */
    reload: () => loadForDate(state.snapshotDate),
  };
}
