// balance.service.ts — domain service for the Bilan (balance sheet) feature.
//
// Scope at Issue #138 (Bilan #1a): CRUD for `balance_categories` and
// `balance_accounts` only. Snapshots, snapshot lines, transfers and price
// fetching ship in subsequent issues (#1b / #2 / #4 / #5).
//
// CRUD goes through `getDb()` + tauri-plugin-sql directly — the project
// convention for database operations. Tauri commands are reserved for
// filesystem / OAuth / license / profile work and the future Modified Dietz
// + price-fetch work in Issue #142.

import { invoke } from "@tauri-apps/api/core";
import { getDb, withTransaction } from "./db";
import { loadProfiles } from "./profileService";
import type {
  AccountReturn,
  BalanceAccount,
  BalanceAccountKind,
  BalanceAccountTransferWithTransaction,
  BalanceAccountWithCategory,
  BalanceAssetType,
  BalanceCategory,
  BalanceCategoryKind,
  BalanceSecurity,
  BalanceSnapshot,
  BalanceSnapshotHolding,
  BalanceSnapshotHoldingWithSecurity,
  BalanceSnapshotLine,
  BalanceTransferDirection,
  BalanceVehicleType,
} from "../shared/types";
import { BALANCE_CURRENCY_CAD, BALANCE_VEHICLE_TYPES } from "../shared/types";

// -----------------------------------------------------------------------------
// Errors — typed so the UI can show distinct i18n messages.
// -----------------------------------------------------------------------------

export type BalanceErrorCode =
  | "currency_unsupported"
  | "category_seed_protected"
  | "category_has_accounts"
  | "category_not_found"
  | "account_not_found"
  | "name_required"
  | "kind_invalid"
  | "asset_type_required"
  | "asset_type_invalid"
  | "vehicle_type_invalid"
  | "snapshot_date_required"
  | "snapshot_date_taken"
  | "snapshot_date_exists"
  | "snapshot_not_found"
  | "snapshot_value_invalid"
  | "snapshot_priced_unsupported"
  | "snapshot_priced_quantity_required"
  | "snapshot_priced_unit_price_required"
  | "snapshot_priced_value_mismatch"
  | "snapshot_simple_must_be_scalar"
  // Issue #212 — securities + detailed snapshot (holdings)
  | "security_symbol_required"
  | "security_asset_type_invalid"
  | "security_not_found"
  | "snapshot_detailed_must_be_aggregate"
  | "snapshot_detailed_value_mismatch"
  | "snapshot_holding_invalid"
  | "account_kind_invalid"
  | "account_kind_detailed_has_holdings"
  // Issue #142 — transfers + returns
  | "transfer_direction_invalid"
  | "transfer_already_linked"
  | "transfer_not_linked"
  | "transfer_active_profile_unknown"
  | "transaction_linked_to_balance_account";

export class BalanceServiceError extends Error {
  readonly code: BalanceErrorCode;
  constructor(code: BalanceErrorCode, message: string) {
    super(message);
    this.name = "BalanceServiceError";
    this.code = code;
  }
}

// -----------------------------------------------------------------------------
// Categories
// -----------------------------------------------------------------------------

export async function listBalanceCategories(options?: {
  includeInactive?: boolean;
}): Promise<BalanceCategory[]> {
  // Default `true` keeps the data-layer change (#202) behavior-neutral: the
  // existing callers see every category. The dropdown-side filtering of
  // deactivated ex-envelope seeds (tfsa/rrsp after v13) is threaded by the UI
  // issue (#203) via `includeInactive: false`.
  const includeInactive = options?.includeInactive ?? true;
  const db = await getDb();
  const where = includeInactive ? "" : "WHERE is_active = 1";
  return db.select<BalanceCategory[]>(
    `SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed, asset_type,
            custom_label
     FROM balance_categories
     ${where}
     ORDER BY sort_order, key`
  );
}

export async function getBalanceCategory(
  id: number
): Promise<BalanceCategory | null> {
  const db = await getDb();
  const rows = await db.select<BalanceCategory[]>(
    `SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed, asset_type,
            custom_label
     FROM balance_categories
     WHERE id = $1`,
    [id]
  );
  return rows[0] ?? null;
}

export interface CreateBalanceCategoryInput {
  key: string;
  i18n_key: string;
  kind: BalanceCategoryKind;
  sort_order?: number;
  /**
   * Required when `kind === 'priced'` (Issue #169). Drives PriceFetchControl
   * provider routing (best-effort Yahoo for stocks, exchange APIs for crypto).
   * For `kind === 'simple'`, the service forces this to NULL regardless of
   * the input value.
   */
  asset_type?: BalanceAssetType | null;
  /**
   * Optional user-facing label override (migration v12). Empty/blank is
   * normalized to NULL so the renderer falls back to `t(i18n_key)`.
   */
  custom_label?: string | null;
}

/**
 * Create a user-defined balance category. The seed categories are created by
 * Migration v9 — never call this for seeded keys (UNIQUE will reject the
 * insert anyway).
 */
export async function createBalanceCategory(
  input: CreateBalanceCategoryInput
): Promise<number> {
  if (!input.key || input.key.trim().length === 0) {
    throw new BalanceServiceError("name_required", "Category key is required");
  }
  if (input.kind !== "simple" && input.kind !== "priced") {
    throw new BalanceServiceError("kind_invalid", "Invalid category kind");
  }
  const assetType = normalizeAssetTypeForKind(input.kind, input.asset_type);
  // Coalesce undefined → null so we never bind `undefined` to SQL on insert.
  const customLabel = normalizeCustomLabel(input.custom_label) ?? null;
  const db = await getDb();
  const result = await db.execute(
    `INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_active, is_seed, asset_type, custom_label)
     VALUES ($1, $2, $3, $4, 1, 0, $5, $6)`,
    [
      input.key.trim(),
      input.i18n_key.trim(),
      input.kind,
      input.sort_order ?? 0,
      assetType,
      customLabel,
    ]
  );
  return result.lastInsertId as number;
}

/**
 * Normalize a free-text label to `string | null`: trims whitespace and maps
 * an empty result to NULL so the renderer falls back to `t(i18n_key)`.
 * `undefined` input is preserved as `undefined` so callers can detect
 * "field not provided" vs "explicitly cleared" in update flows.
 */
function normalizeCustomLabel(
  raw: string | null | undefined
): string | null | undefined {
  if (raw === undefined) return undefined;
  if (raw === null) return null;
  const trimmed = raw.trim();
  return trimmed.length > 0 ? trimmed : null;
}

export interface UpdateBalanceCategoryInput {
  i18n_key?: string;
  sort_order?: number;
  is_active?: boolean;
  /**
   * Allows backfilling `asset_type` on legacy priced categories created
   * before migration v10. The service rejects an explicit `null` when the
   * existing kind is priced (would unset a required field).
   */
  asset_type?: BalanceAssetType | null;
  /**
   * User-facing label override (migration v12). Pass a string to set/rename,
   * `null` (or blank) to clear and fall back to `t(i18n_key)`. Omit to leave
   * unchanged. This is the supported way to rename a category — it never
   * touches `i18n_key` (fixes bug I).
   */
  custom_label?: string | null;
}

/**
 * Rename / re-order / toggle active state of a category. Seeded categories
 * are renamable. Changing `kind` is intentionally not supported (would
 * invalidate existing snapshot lines).
 */
export async function updateBalanceCategory(
  id: number,
  input: UpdateBalanceCategoryInput
): Promise<void> {
  const existing = await getBalanceCategory(id);
  if (!existing) {
    throw new BalanceServiceError(
      "category_not_found",
      `Category ${id} not found`
    );
  }
  const db = await getDb();
  const i18n = input.i18n_key !== undefined ? input.i18n_key : existing.i18n_key;
  const sortOrder =
    input.sort_order !== undefined ? input.sort_order : existing.sort_order;
  const isActive =
    input.is_active !== undefined ? (input.is_active ? 1 : 0) : existing.is_active ? 1 : 0;
  const assetType =
    input.asset_type !== undefined
      ? normalizeAssetTypeForKind(existing.kind, input.asset_type)
      : existing.asset_type;
  const normalizedLabel = normalizeCustomLabel(input.custom_label);
  const customLabel =
    normalizedLabel !== undefined ? normalizedLabel : existing.custom_label ?? null;
  await db.execute(
    `UPDATE balance_categories
       SET i18n_key = $1, sort_order = $2, is_active = $3, asset_type = $4,
           custom_label = $5
     WHERE id = $6`,
    [i18n, sortOrder, isActive, assetType, customLabel, id]
  );
}

/**
 * Coerce/validate `asset_type` against `kind`:
 *   - simple → always NULL (input is ignored).
 *   - priced → required, must be 'stock' or 'crypto'.
 */
function normalizeAssetTypeForKind(
  kind: BalanceCategoryKind,
  raw: BalanceAssetType | null | undefined
): BalanceAssetType | null {
  if (kind === "simple") {
    return null;
  }
  if (raw === null || raw === undefined) {
    throw new BalanceServiceError(
      "asset_type_required",
      "asset_type is required for priced categories"
    );
  }
  if (raw !== "stock" && raw !== "crypto") {
    throw new BalanceServiceError(
      "asset_type_invalid",
      "asset_type must be 'stock' or 'crypto'"
    );
  }
  return raw;
}

/**
 * Delete a user-created category. Refuses to delete:
 *   - seeded categories (`is_seed = 1`) — UI must disable the button;
 *   - categories with linked accounts — FK RESTRICT would also reject, but
 *     we pre-check to surface a clean i18n message.
 */
export async function deleteBalanceCategory(id: number): Promise<void> {
  const existing = await getBalanceCategory(id);
  if (!existing) {
    throw new BalanceServiceError(
      "category_not_found",
      `Category ${id} not found`
    );
  }
  if (existing.is_seed) {
    throw new BalanceServiceError(
      "category_seed_protected",
      "Seeded categories cannot be deleted"
    );
  }
  const db = await getDb();
  const linked = await db.select<Array<{ count: number }>>(
    `SELECT COUNT(*) AS count FROM balance_accounts WHERE balance_category_id = $1`,
    [id]
  );
  if ((linked[0]?.count ?? 0) > 0) {
    throw new BalanceServiceError(
      "category_has_accounts",
      "Cannot delete a category with linked accounts"
    );
  }
  await db.execute("DELETE FROM balance_categories WHERE id = $1", [id]);
}

// -----------------------------------------------------------------------------
// Accounts
// -----------------------------------------------------------------------------

export async function listBalanceAccounts(options?: {
  includeArchived?: boolean;
}): Promise<BalanceAccountWithCategory[]> {
  const includeArchived = options?.includeArchived ?? false;
  const db = await getDb();
  const where = includeArchived
    ? ""
    : "WHERE a.is_active = 1 AND a.archived_at IS NULL";
  return db.select<BalanceAccountWithCategory[]>(
    `SELECT a.id, a.balance_category_id, a.name, a.symbol, a.currency,
            a.notes, a.is_active, a.archived_at, a.vehicle_type,
            a.kind, a.detailed_since,
            a.created_at, a.updated_at,
            c.key AS category_key, c.i18n_key AS category_i18n_key,
            c.kind AS category_kind, c.asset_type AS category_asset_type,
            c.custom_label AS category_custom_label
     FROM balance_accounts a
     INNER JOIN balance_categories c ON c.id = a.balance_category_id
     ${where}
     ORDER BY c.sort_order, a.name`
  );
}

export async function getBalanceAccount(
  id: number
): Promise<BalanceAccount | null> {
  const db = await getDb();
  const rows = await db.select<BalanceAccount[]>(
    `SELECT id, balance_category_id, name, symbol, currency, notes,
            is_active, archived_at, vehicle_type, kind, detailed_since,
            created_at, updated_at
     FROM balance_accounts
     WHERE id = $1`,
    [id]
  );
  return rows[0] ?? null;
}

export interface CreateBalanceAccountInput {
  balance_category_id: number;
  name: string;
  symbol?: string | null;
  /** Defaults to 'CAD'. MVP rejects any other value. */
  currency?: string;
  notes?: string | null;
  /**
   * Fiscal envelope / tax shelter (migration v12). Optional/nullable — NOT an
   * automobile type. Validated against the enum; an out-of-enum value throws
   * `vehicle_type_invalid`.
   */
  vehicle_type?: BalanceVehicleType | null;
}

/**
 * The six recognized fiscal envelopes. Re-uses the shared/types const so the
 * service validation and the account-form dropdown (Issue #203) share one
 * source of truth. Kept in sync with the SQL CHECK.
 */
const VEHICLE_TYPES = BALANCE_VEHICLE_TYPES;

/**
 * Validate an optional `vehicle_type` against the fiscal-envelope enum.
 * Mirrors `normalizeAssetTypeForKind`. NULL/undefined are allowed (the
 * envelope is optional); any non-enum string throws `vehicle_type_invalid`.
 * Returns `null` for absent input, the validated value otherwise.
 */
function normalizeVehicleType(
  raw: BalanceVehicleType | null | undefined
): BalanceVehicleType | null {
  if (raw === null || raw === undefined) {
    return null;
  }
  if (!VEHICLE_TYPES.includes(raw)) {
    throw new BalanceServiceError(
      "vehicle_type_invalid",
      `vehicle_type must be one of ${VEHICLE_TYPES.join(", ")}`
    );
  }
  return raw;
}

/**
 * Create an account. Currency must be 'CAD' at the MVP — the SQL CHECK
 * would reject anything else, but we pre-check to surface a clean i18n
 * message instead of a raw SQL error.
 */
export async function createBalanceAccount(
  input: CreateBalanceAccountInput
): Promise<number> {
  if (!input.name || input.name.trim().length === 0) {
    throw new BalanceServiceError("name_required", "Account name is required");
  }
  const currency = input.currency ?? BALANCE_CURRENCY_CAD;
  if (currency !== BALANCE_CURRENCY_CAD) {
    throw new BalanceServiceError(
      "currency_unsupported",
      "Only CAD is supported at the MVP"
    );
  }
  const cat = await getBalanceCategory(input.balance_category_id);
  if (!cat) {
    throw new BalanceServiceError(
      "category_not_found",
      "Linked balance category not found"
    );
  }
  const vehicleType = normalizeVehicleType(input.vehicle_type);
  const db = await getDb();
  const result = await db.execute(
    `INSERT INTO balance_accounts (balance_category_id, name, symbol, currency, notes, is_active, vehicle_type)
     VALUES ($1, $2, $3, $4, $5, 1, $6)`,
    [
      input.balance_category_id,
      input.name.trim(),
      input.symbol ? input.symbol.trim() : null,
      currency,
      input.notes ? input.notes.trim() : null,
      vehicleType,
    ]
  );
  return result.lastInsertId as number;
}

export interface UpdateBalanceAccountInput {
  balance_category_id?: number;
  name?: string;
  symbol?: string | null;
  notes?: string | null;
  is_active?: boolean;
  /**
   * Fiscal envelope / tax shelter (migration v12). Pass a value to set, `null`
   * to clear, omit to leave unchanged. Validated against the enum. NOT an
   * automobile type.
   */
  vehicle_type?: BalanceVehicleType | null;
  /**
   * Entry mode (migration v15). 'simple' = one denormalized value, 'detailed' =
   * per-security holdings. Pass to change, omit to leave unchanged. Switching
   * `detailed → simple` is rejected with `account_kind_detailed_has_holdings`
   * when the account already has holdings recorded — UI gating alone is
   * insufficient (Issue #212 service backstop).
   */
  kind?: BalanceAccountKind;
  /**
   * Authoritative pivot date (ISO YYYY-MM-DD) from which detailed entry is
   * expected (migration v15). Pass a value to set, `null` to clear, omit to
   * leave unchanged.
   */
  detailed_since?: string | null;
}

export async function updateBalanceAccount(
  id: number,
  input: UpdateBalanceAccountInput
): Promise<void> {
  const existing = await getBalanceAccount(id);
  if (!existing) {
    throw new BalanceServiceError(
      "account_not_found",
      `Account ${id} not found`
    );
  }
  const name = input.name !== undefined ? input.name.trim() : existing.name;
  if (!name) {
    throw new BalanceServiceError("name_required", "Account name is required");
  }
  const categoryId =
    input.balance_category_id !== undefined
      ? input.balance_category_id
      : existing.balance_category_id;
  const symbol =
    input.symbol !== undefined
      ? input.symbol === null
        ? null
        : input.symbol.trim() || null
      : existing.symbol;
  const notes =
    input.notes !== undefined
      ? input.notes === null
        ? null
        : input.notes.trim() || null
      : existing.notes;
  const isActive =
    input.is_active !== undefined
      ? input.is_active
        ? 1
        : 0
      : existing.is_active
        ? 1
        : 0;
  // Read-and-rewrite: when the caller omits vehicle_type, preserve the
  // existing value so a full UPDATE never silently wipes the envelope.
  const vehicleType =
    input.vehicle_type !== undefined
      ? normalizeVehicleType(input.vehicle_type)
      : existing.vehicle_type ?? null;
  // Entry mode (migration v15). Validate against the enum; preserve when
  // omitted. The detailed → simple downgrade is the dangerous transition —
  // gated below.
  const kind: BalanceAccountKind =
    input.kind !== undefined ? input.kind : existing.kind;
  if (kind !== "simple" && kind !== "detailed") {
    throw new BalanceServiceError(
      "account_kind_invalid",
      `account kind must be 'simple' or 'detailed', got ${kind}`
    );
  }
  const detailedSince =
    input.detailed_since !== undefined
      ? input.detailed_since === null
        ? null
        : normalizeSnapshotDate(input.detailed_since)
      : existing.detailed_since ?? null;
  // Service backstop (spec finding 🟢 TECHNIQUE): block a detailed → simple
  // flip when holdings exist, otherwise they'd be orphaned (the line keeps a
  // total value while the per-title rows dangle). The UI disables this too,
  // but a direct service call must not bypass the invariant.
  if (existing.kind === "detailed" && kind === "simple") {
    const db0 = await getDb();
    const held = await db0.select<Array<{ n: number }>>(
      `SELECT COUNT(*) AS n
         FROM balance_snapshot_holdings h
         JOIN balance_snapshot_lines l ON l.id = h.snapshot_line_id
        WHERE l.account_id = $1`,
      [id]
    );
    if ((held[0]?.n ?? 0) > 0) {
      throw new BalanceServiceError(
        "account_kind_detailed_has_holdings",
        `Account ${id} has detailed holdings and cannot be switched back to simple`
      );
    }
  }
  const db = await getDb();
  await db.execute(
    `UPDATE balance_accounts
       SET balance_category_id = $1, name = $2, symbol = $3, notes = $4,
           is_active = $5, vehicle_type = $6, kind = $7, detailed_since = $8,
           updated_at = CURRENT_TIMESTAMP
     WHERE id = $9`,
    [categoryId, name, symbol, notes, isActive, vehicleType, kind, detailedSince, id]
  );
}

/**
 * Soft-delete an account: stamp `archived_at` and set `is_active = 0`.
 * Archived accounts are hidden from new snapshots but kept in the historic
 * snapshot lines (which is why we never hard-delete here).
 */
export async function archiveBalanceAccount(id: number): Promise<void> {
  const existing = await getBalanceAccount(id);
  if (!existing) {
    throw new BalanceServiceError(
      "account_not_found",
      `Account ${id} not found`
    );
  }
  const db = await getDb();
  await db.execute(
    `UPDATE balance_accounts
       SET archived_at = CURRENT_TIMESTAMP, is_active = 0,
           updated_at = CURRENT_TIMESTAMP
     WHERE id = $1`,
    [id]
  );
}

export async function unarchiveBalanceAccount(id: number): Promise<void> {
  const existing = await getBalanceAccount(id);
  if (!existing) {
    throw new BalanceServiceError(
      "account_not_found",
      `Account ${id} not found`
    );
  }
  const db = await getDb();
  await db.execute(
    `UPDATE balance_accounts
       SET archived_at = NULL, is_active = 1,
           updated_at = CURRENT_TIMESTAMP
     WHERE id = $1`,
    [id]
  );
}

// -----------------------------------------------------------------------------
// Starter accounts (Issue #179 / Bilan onboarding)
// -----------------------------------------------------------------------------
//
// The 4 starter accounts proposed to existing profiles via StarterAccountsModal.
// New profiles get the same 4 directly via consolidated_schema.sql, so the
// names/categories MUST stay in sync between the two sources.

export interface StarterDef {
  /**
   * Stable identifier used by the modal checkbox state. Kept as cash/tfsa/
   * rrsp/other for UI continuity even though the CELI/REER starters now
   * attach to the `other` asset class (Bilan axe véhicule, Étape 1).
   */
  key: "cash" | "tfsa" | "rrsp" | "other";
  /** Default account name (FR — matches consolidated_schema seed). */
  name: string;
  /** i18n key for the user-facing label in the modal. */
  i18nKey: string;
  /**
   * balance_categories.key (asset class) this starter attaches to. After the
   * envelope/asset-class split, CELI and REER both map to `other`.
   */
  categoryKey: "cash" | "other";
  /**
   * Fiscal envelope stamped on the created account (migration v12). NULL for
   * the plain chequing / non-registered starters.
   */
  vehicleType: BalanceVehicleType | null;
}

export const STARTER_ACCOUNTS: StarterDef[] = [
  {
    key: "cash",
    name: "Compte chèque",
    i18nKey: "balance.starters.items.cash",
    categoryKey: "cash",
    vehicleType: null,
  },
  {
    key: "tfsa",
    name: "CELI",
    i18nKey: "balance.starters.items.tfsa",
    categoryKey: "other",
    vehicleType: "tfsa",
  },
  {
    key: "rrsp",
    name: "REER",
    i18nKey: "balance.starters.items.rrsp",
    categoryKey: "other",
    vehicleType: "rrsp",
  },
  {
    key: "other",
    name: "Compte non-enregistré",
    i18nKey: "balance.starters.items.other",
    categoryKey: "other",
    vehicleType: null,
  },
];

/**
 * Returns the set of starter keys whose proposed (name, category) already
 * exists as an account on the active profile. Comparison is case-insensitive
 * and trim-tolerant on the name. Used by StarterAccountsModal to disable the
 * matching checkbox + render a "Déjà présent" tooltip.
 */
export async function getStarterCollisions(): Promise<Set<string>> {
  const db = await getDb();
  // After the envelope/asset-class split (Étape 1) the CELI/REER/non-registered
  // starters all live in the `other` class and are told apart only by name, so
  // the collision check matches on (categoryKey, name). The category filter is
  // the union of the starters' asset classes: cash + other.
  const rows = await db.select<
    { key: string; account_name: string }[]
  >(
    `SELECT c.key AS key, a.name AS account_name
       FROM balance_accounts a
       INNER JOIN balance_categories c ON c.id = a.balance_category_id
      WHERE c.key IN ('cash','other')
        AND a.archived_at IS NULL`
  );
  const collisions = new Set<string>();
  for (const starter of STARTER_ACCOUNTS) {
    const wanted = starter.name.trim().toLowerCase();
    const hit = rows.some(
      (r) =>
        r.key === starter.categoryKey &&
        r.account_name.trim().toLowerCase() === wanted
    );
    if (hit) collisions.add(starter.key);
  }
  return collisions;
}

/**
 * Insert the selected starter accounts atomically. Resolves each starter's
 * `category_id` from the seeded `balance_categories.key`. Wraps the inserts
 * in BEGIN/COMMIT — on any failure ROLLBACK is issued and the original error
 * is re-thrown. Returns the inserted account ids in input order.
 *
 * Callers SHOULD pre-filter `selectedKeys` against `getStarterCollisions()`
 * to keep the UI honest, but each iteration ALSO re-checks for an existing
 * (name, category) account inside the transaction and skips silently on a
 * hit — a defense-in-depth guard since the table has no UNIQUE constraint
 * on (name, balance_category_id). Returned ids exclude any skipped starter.
 */
export async function proposeStarterAccounts(
  selectedKeys: string[]
): Promise<number[]> {
  const wanted = STARTER_ACCOUNTS.filter((s) => selectedKeys.includes(s.key));
  if (wanted.length === 0) return [];
  return withTransaction((db) =>
    proposeStarterAccountsInTransaction(db, wanted)
  );
}

async function proposeStarterAccountsInTransaction(
  db: SqlExecutor,
  wanted: StarterDef[]
): Promise<number[]> {
  let inTxn = false;
  const inserted: number[] = [];
  try {
    await db.execute("BEGIN");
    inTxn = true;
    for (const starter of wanted) {
      // Resolve category id by key. Seeded keys are guaranteed to exist on
      // a freshly migrated profile (Migration v9), so we surface a clean
      // error if somehow missing rather than letting the FK fire. We require
      // `is_active = 1` so a deactivated ex-envelope seed (tfsa/rrsp after v13)
      // is never picked up — the starters now resolve to the active `other`
      // class and carry the envelope in vehicle_type instead.
      const catRows = await db.select<{ id: number }[]>(
        `SELECT id FROM balance_categories WHERE key = $1 AND is_active = 1`,
        [starter.categoryKey]
      );
      if (catRows.length === 0) {
        throw new BalanceServiceError(
          "category_not_found",
          `Seeded category '${starter.categoryKey}' missing — expected v9 schema`
        );
      }
      // Defense-in-depth: re-check collision in-txn before INSERT so we
      // never create a silent duplicate even if the upstream pre-filter
      // raced or was bypassed (S3 from PR #185 review).
      const existing = await db.select<{ count: number }[]>(
        `SELECT COUNT(*) AS count FROM balance_accounts
           WHERE name = $1 AND balance_category_id = $2
             AND archived_at IS NULL`,
        [starter.name, catRows[0].id]
      );
      if ((existing[0]?.count ?? 0) > 0) {
        continue;
      }
      const result = await db.execute(
        `INSERT INTO balance_accounts (balance_category_id, name, currency, is_active, vehicle_type)
         VALUES ($1, $2, 'CAD', 1, $3)`,
        [catRows[0].id, starter.name, starter.vehicleType]
      );
      inserted.push(result.lastInsertId as number);
    }
    await db.execute("COMMIT");
    inTxn = false;
    return inserted;
  } catch (e) {
    if (inTxn) {
      try {
        await db.execute("ROLLBACK");
      } catch {
        // Preserve original error.
      }
    }
    throw e;
  }
}

// -----------------------------------------------------------------------------
// Snapshots + lines (Issue #146 / Bilan #1b — simple kind only)
// -----------------------------------------------------------------------------
//
// At Issue #146 the UI surfaces *only* simple-kind input: every line has
// `quantity = NULL` and `unit_price = NULL`. The SQL CHECK on
// `balance_snapshot_lines` already enforces the kind invariant, but
// `upsertSnapshotLines` re-validates ahead of time so a typed
// BalanceServiceError surfaces a clean i18n message instead of a raw SQL
// error. Priced-kind upsert lands in Issue #140 (Bilan #2).

const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;

function normalizeSnapshotDate(date: string): string {
  const trimmed = (date ?? "").trim();
  if (!trimmed) {
    throw new BalanceServiceError(
      "snapshot_date_required",
      "Snapshot date is required"
    );
  }
  if (!ISO_DATE_REGEX.test(trimmed)) {
    throw new BalanceServiceError(
      "snapshot_date_required",
      "Snapshot date must be in ISO YYYY-MM-DD format"
    );
  }
  return trimmed;
}

export async function listSnapshots(): Promise<BalanceSnapshot[]> {
  const db = await getDb();
  return db.select<BalanceSnapshot[]>(
    `SELECT id, snapshot_date, notes, created_at, updated_at
     FROM balance_snapshots
     ORDER BY snapshot_date DESC`
  );
}

export async function getSnapshotByDate(
  date: string
): Promise<BalanceSnapshot | null> {
  const normalized = normalizeSnapshotDate(date);
  const db = await getDb();
  const rows = await db.select<BalanceSnapshot[]>(
    `SELECT id, snapshot_date, notes, created_at, updated_at
     FROM balance_snapshots
     WHERE snapshot_date = $1`,
    [normalized]
  );
  return rows[0] ?? null;
}

export async function getSnapshotById(
  id: number
): Promise<BalanceSnapshot | null> {
  const db = await getDb();
  const rows = await db.select<BalanceSnapshot[]>(
    `SELECT id, snapshot_date, notes, created_at, updated_at
     FROM balance_snapshots
     WHERE id = $1`,
    [id]
  );
  return rows[0] ?? null;
}

export interface CreateSnapshotInput {
  snapshot_date: string;
  notes?: string | null;
}

/**
 * Create a snapshot row. Throws `snapshot_date_taken` if a snapshot already
 * exists at the same date so the UI can redirect to edit mode (UNIQUE
 * constraint on `snapshot_date` would surface a raw SQL error otherwise).
 */
export async function createSnapshot(
  input: CreateSnapshotInput
): Promise<number> {
  const date = normalizeSnapshotDate(input.snapshot_date);
  const existing = await getSnapshotByDate(date);
  if (existing) {
    throw new BalanceServiceError(
      "snapshot_date_taken",
      `A snapshot already exists at ${date}`
    );
  }
  const db = await getDb();
  const result = await db.execute(
    `INSERT INTO balance_snapshots (snapshot_date, notes)
     VALUES ($1, $2)`,
    [date, input.notes ? input.notes.trim() || null : null]
  );
  return result.lastInsertId as number;
}

export interface UpdateSnapshotInput {
  notes?: string | null;
}

/**
 * Update snapshot metadata (notes only). Snapshot date is immutable once
 * saved — to change the date the user deletes the snapshot and creates a
 * new one (the UI exposes this as a constraint, not a feature).
 */
export async function updateSnapshot(
  id: number,
  input: UpdateSnapshotInput
): Promise<void> {
  const existing = await getSnapshotById(id);
  if (!existing) {
    throw new BalanceServiceError(
      "snapshot_not_found",
      `Snapshot ${id} not found`
    );
  }
  const notes =
    input.notes !== undefined
      ? input.notes === null
        ? null
        : input.notes.trim() || null
      : existing.notes;
  const db = await getDb();
  await db.execute(
    `UPDATE balance_snapshots
       SET notes = $1, updated_at = CURRENT_TIMESTAMP
     WHERE id = $2`,
    [notes, id]
  );
}

/**
 * Delete a snapshot. ON DELETE CASCADE on `balance_snapshot_lines`
 * .snapshot_id removes the lines too. The UI must double-confirm
 * (re-typing the snapshot date) before invoking this.
 */
export async function deleteSnapshot(id: number): Promise<void> {
  const existing = await getSnapshotById(id);
  if (!existing) {
    throw new BalanceServiceError(
      "snapshot_not_found",
      `Snapshot ${id} not found`
    );
  }
  const db = await getDb();
  await db.execute("DELETE FROM balance_snapshots WHERE id = $1", [id]);
}

export async function listLinesBySnapshot(
  snapshotId: number
): Promise<BalanceSnapshotLine[]> {
  const db = await getDb();
  return db.select<BalanceSnapshotLine[]>(
    `SELECT id, snapshot_id, account_id, quantity, unit_price, value,
            price_source, price_fetched_at, created_at, updated_at
     FROM balance_snapshot_lines
     WHERE snapshot_id = $1
     ORDER BY id`,
    [snapshotId]
  );
}

// -----------------------------------------------------------------------------
// Securities catalogue (Issue #212 / Bilan détail par titre — #3)
// -----------------------------------------------------------------------------
//
// `balance_securities` is a shared catalogue keyed by a NORMALIZED symbol
// (UPPER(TRIM(...))). The SQL column is already `UNIQUE COLLATE NOCASE` (v14),
// but we normalize in TS too so the stored value is canonical and the dedup is
// deterministic regardless of the caller's casing/whitespace.

/** Minimal executor surface shared by the top-level db handle and a txn. */
interface SqlExecutor {
  select<T>(query: string, bindValues?: unknown[]): Promise<T>;
  execute(
    query: string,
    bindValues?: unknown[]
  ): Promise<{ lastInsertId?: number; rowsAffected?: number }>;
}

/**
 * Canonical symbol form: UPPER(TRIM(...)). Matches the v14/v16 SQL
 * (`UPPER(TRIM(a.symbol))`) so a TS-created security and a migration-created
 * one collapse to the same `balance_securities` row.
 */
export function normalizeSecuritySymbol(symbol: string): string {
  return symbol.trim().toUpperCase();
}

export interface FindOrCreateSecurityInput {
  symbol: string;
  /** Defaults to 'CAD'. */
  currency?: string;
  /** 'stock' | 'crypto' — required; routes the price-fetch flow. */
  asset_type: BalanceAssetType;
  /** Optional human-readable name. */
  name?: string | null;
}

const ASSET_TYPES: readonly BalanceAssetType[] = ["stock", "crypto"];

/**
 * UPSERT a security by NORMALIZED symbol, returning its row. Idempotent: a
 * second call with the same symbol (any casing) returns the existing row,
 * updating `name`/`asset_type`/`currency` only when the caller passes new
 * values. Callable inside an open transaction by threading `exec` (so a
 * brand-new symbol can create its security then its holding atomically inside
 * `saveSnapshotAtomic`); omit `exec` to use the top-level db handle.
 *
 * @throws `security_symbol_required` on empty symbol,
 *         `security_asset_type_invalid` on a non-enum asset_type.
 */
export async function findOrCreateSecurity(
  input: FindOrCreateSecurityInput,
  exec?: SqlExecutor
): Promise<BalanceSecurity> {
  const symbol = normalizeSecuritySymbol(input.symbol ?? "");
  if (symbol.length === 0) {
    throw new BalanceServiceError(
      "security_symbol_required",
      "Security symbol is required"
    );
  }
  if (!ASSET_TYPES.includes(input.asset_type)) {
    throw new BalanceServiceError(
      "security_asset_type_invalid",
      `asset_type must be one of ${ASSET_TYPES.join(", ")}`
    );
  }
  const currency = input.currency ?? BALANCE_CURRENCY_CAD;
  const name = input.name ? input.name.trim() || null : null;
  const db: SqlExecutor = exec ?? (await getDb());
  // ON CONFLICT(symbol) DO UPDATE keeps the row's metadata fresh (e.g. a name
  // arriving on a later save) while preserving its id. COALESCE keeps an
  // existing name when the new payload omits one. The column is UNIQUE COLLATE
  // NOCASE so the conflict target is the normalized symbol.
  await db.execute(
    `INSERT INTO balance_securities (symbol, name, currency, asset_type)
     VALUES ($1, $2, $3, $4)
     ON CONFLICT(symbol) DO UPDATE SET
       name = COALESCE($2, balance_securities.name),
       currency = $3,
       asset_type = $4,
       updated_at = CURRENT_TIMESTAMP`,
    [symbol, name, currency, input.asset_type]
  );
  const rows = await db.select<BalanceSecurity[]>(
    `SELECT id, symbol, name, currency, asset_type, created_at, updated_at
       FROM balance_securities
      WHERE symbol = $1`,
    [symbol]
  );
  if (rows.length === 0) {
    // Should be unreachable — the UPSERT just guaranteed the row exists.
    throw new BalanceServiceError(
      "security_not_found",
      `Security ${symbol} not found after upsert`
    );
  }
  return rows[0];
}

/** List every security in the catalogue, ordered by symbol. */
export async function listSecurities(): Promise<BalanceSecurity[]> {
  const db = await getDb();
  return db.select<BalanceSecurity[]>(
    `SELECT id, symbol, name, currency, asset_type, created_at, updated_at
       FROM balance_securities
      ORDER BY symbol ASC`
  );
}

/** Fetch one security by id, or `null` when absent. */
export async function getSecurity(id: number): Promise<BalanceSecurity | null> {
  const db = await getDb();
  const rows = await db.select<BalanceSecurity[]>(
    `SELECT id, symbol, name, currency, asset_type, created_at, updated_at
       FROM balance_securities
      WHERE id = $1`,
    [id]
  );
  return rows[0] ?? null;
}

export interface UpdateSecurityInput {
  /** New normalized symbol; omit to leave unchanged. */
  symbol?: string;
  name?: string | null;
  currency?: string;
  asset_type?: BalanceAssetType;
}

/**
 * Update a security's metadata. Symbol is re-normalized when provided.
 * @throws `security_not_found`, `security_symbol_required`,
 *         `security_asset_type_invalid`.
 */
export async function updateSecurity(
  id: number,
  input: UpdateSecurityInput
): Promise<void> {
  const existing = await getSecurity(id);
  if (!existing) {
    throw new BalanceServiceError(
      "security_not_found",
      `Security ${id} not found`
    );
  }
  let symbol = existing.symbol;
  if (input.symbol !== undefined) {
    symbol = normalizeSecuritySymbol(input.symbol);
    if (symbol.length === 0) {
      throw new BalanceServiceError(
        "security_symbol_required",
        "Security symbol is required"
      );
    }
  }
  let assetType = existing.asset_type;
  if (input.asset_type !== undefined) {
    if (!ASSET_TYPES.includes(input.asset_type)) {
      throw new BalanceServiceError(
        "security_asset_type_invalid",
        `asset_type must be one of ${ASSET_TYPES.join(", ")}`
      );
    }
    assetType = input.asset_type;
  }
  const name =
    input.name !== undefined
      ? input.name === null
        ? null
        : input.name.trim() || null
      : existing.name;
  const currency = input.currency ?? existing.currency;
  const db = await getDb();
  await db.execute(
    `UPDATE balance_securities
       SET symbol = $1, name = $2, currency = $3, asset_type = $4,
           updated_at = CURRENT_TIMESTAMP
     WHERE id = $5`,
    [symbol, name, currency, assetType, id]
  );
}

/**
 * Tolerance ε used by the priced-kind invariant `value === quantity * unit_price`.
 *
 * Floating-point multiplication of decimal user input is lossy
 * (`12.34 * 1.07 === 13.2038000000000002`), and the UI displays `value`
 * rounded to 2 decimals while keeping quantity / unit_price at full
 * precision. ε = 0.01 (one cent on the dollar) is generous enough to
 * absorb that drift but tight enough to catch obvious mistakes (off by
 * 10×). See decisions-log.md / Issue #140.
 */
export const PRICED_VALUE_TOLERANCE = 0.01;

/**
 * Round a monetary amount to the cent. The detailed-snapshot invariant rounds
 * every holding's value to the cent and compares the SUM **exactly** to the
 * stored aggregated line value — no float tolerance (decision 2026-06-03). This
 * sidesteps the rounding-accumulation problem that a single absolute ε would
 * hit once N holdings are summed (see Issue #212 spec finding 🟡 SECURITE).
 */
export function roundToCent(value: number): number {
  return Math.round(value * 100) / 100;
}

export interface SnapshotLineInput {
  account_id: number;
  /**
   * Snapshot value at this date. For priced lines this should match
   * `quantity * unit_price` within `PRICED_VALUE_TOLERANCE`; the service
   * validates the relation ahead of the SQL CHECK and surfaces a typed
   * `snapshot_priced_value_mismatch` error otherwise.
   */
  value: number;
  /**
   * Category kind of the underlying account. Defaults to 'simple' to
   * preserve the #146 callers that don't pass it. Priced lines must
   * provide both `quantity` and `unit_price`.
   */
  account_kind?: BalanceCategoryKind;
  /** Required for priced lines, must be NULL for simple. */
  quantity?: number | null;
  /** Required for priced lines, must be NULL for simple. */
  unit_price?: number | null;
  /**
   * Per-security breakdown for a `detailed` account (Issue #212). When this
   * field is **present** (a defined array, even empty), the line is treated as
   * detailed: the aggregated row stores the rounded-cent SUM of the holdings
   * with `quantity`/`unit_price` NULL, and each holding is written to
   * `balance_snapshot_holdings` in the same transaction. An EMPTY array models a
   * detailed account at/just after its pivot with no positions yet entered
   * (pre-pivot aggregated rows are produced WITHOUT this field — they take the
   * simple path). `undefined` ⇒ not a detailed line (simple or legacy priced
   * scalar path, untouched).
   */
  holdings?: SnapshotHoldingInput[];
}

/**
 * One position inside a `detailed` account's snapshot line (Issue #212).
 * References its security by NORMALIZED symbol (+ asset_type/currency) so
 * `findOrCreateSecurity` can resolve-or-create it inside the save transaction.
 * Downstream #213/#214 (editor reducer + multi-title UI) produce this shape.
 */
export interface SnapshotHoldingInput {
  /** Security symbol; normalized (UPPER/TRIM) by the service. */
  symbol: string;
  /** Asset class — required for find-or-create. */
  asset_type: BalanceAssetType;
  /** Defaults to 'CAD'. */
  currency?: string;
  /** Optional human-readable security name. */
  security_name?: string | null;
  quantity: number;
  unit_price: number;
  /** Position value (= quantity × unit_price); re-rounded to the cent server-side. */
  value: number;
  /** Acquisition cost basis for the unrealized-gain column; optional. */
  book_cost?: number | null;
  /** 'manual' | 'maximus-api'; defaults to 'manual'. */
  price_source?: string | null;
  price_fetched_at?: string | null;
}

/**
 * Pure helper that validates a snapshot line against its account's
 * category kind. Exposed for unit tests and used by `upsertSnapshotLines`
 * before any DB mutation happens.
 *
 * Rules:
 *  - simple kind  → quantity AND unit_price must be NULL/undefined; value
 *                   must be a finite number.
 *  - priced kind  → quantity AND unit_price must be finite numbers; value
 *                   must equal quantity × unit_price within
 *                   `PRICED_VALUE_TOLERANCE`.
 *
 * @throws `BalanceServiceError` with a typed code on the first failure.
 */
export function validateLineKindInvariants(
  line: SnapshotLineInput,
  accountKind: BalanceCategoryKind = line.account_kind ?? "simple"
): void {
  if (typeof line.value !== "number" || !Number.isFinite(line.value)) {
    throw new BalanceServiceError(
      "snapshot_value_invalid",
      `Line for account ${line.account_id}: value must be a finite number`
    );
  }
  if (accountKind === "simple") {
    // Simple-kind: quantity / unit_price must be absent (NULL or undefined).
    if (line.quantity !== undefined && line.quantity !== null) {
      throw new BalanceServiceError(
        "snapshot_simple_must_be_scalar",
        `Line for account ${line.account_id}: simple-kind line must not carry quantity`
      );
    }
    if (line.unit_price !== undefined && line.unit_price !== null) {
      throw new BalanceServiceError(
        "snapshot_simple_must_be_scalar",
        `Line for account ${line.account_id}: simple-kind line must not carry unit_price`
      );
    }
    return;
  }
  // Priced-kind: both fields required and finite.
  if (
    line.quantity === undefined ||
    line.quantity === null ||
    typeof line.quantity !== "number" ||
    !Number.isFinite(line.quantity)
  ) {
    throw new BalanceServiceError(
      "snapshot_priced_quantity_required",
      `Line for account ${line.account_id}: quantity is required for priced accounts`
    );
  }
  if (
    line.unit_price === undefined ||
    line.unit_price === null ||
    typeof line.unit_price !== "number" ||
    !Number.isFinite(line.unit_price)
  ) {
    throw new BalanceServiceError(
      "snapshot_priced_unit_price_required",
      `Line for account ${line.account_id}: unit_price is required for priced accounts`
    );
  }
  const expected = line.quantity * line.unit_price;
  if (Math.abs(expected - line.value) > PRICED_VALUE_TOLERANCE) {
    throw new BalanceServiceError(
      "snapshot_priced_value_mismatch",
      `Line for account ${line.account_id}: value ${line.value} does not match quantity × unit_price (${expected})`
    );
  }
}

/**
 * A line is "detailed" when it carries a `holdings` array (even empty). The
 * presence of the field — not the account's stored kind — drives the save
 * path, so a detailed account can still write a pre-pivot aggregated row by
 * simply omitting `holdings`.
 */
export function isDetailedLine(line: SnapshotLineInput): boolean {
  return line.holdings !== undefined;
}

/**
 * Validate a `detailed` line and its holdings (Issue #212). Companion to
 * `validateLineKindInvariants` (which stays UNCHANGED for the simple/priced
 * scalar path). Called ahead of any DB mutation.
 *
 * Rules (detail by security):
 *  - WITH holdings ⇒ the aggregated line MUST carry `quantity`/`unit_price`
 *    NULL (the total has no single qty/price) AND `line.value` MUST equal the
 *    rounded-cent SUM of the holdings' rounded-cent values, compared EXACTLY
 *    (no float tolerance — decision 2026-06-03). Each holding must have finite
 *    quantity/unit_price/value, a normalizable symbol and a valid asset_type.
 *  - WITHOUT holdings (empty array) ⇒ pre-pivot / no-position-yet aggregated
 *    row is tolerated: `value` only needs to be finite (validated by the
 *    caller's `validateLineKindInvariants` simple pass; here it's a no-op).
 *
 * @throws `BalanceServiceError` with a typed code on the first failure.
 */
export function validateDetailedSnapshot(line: SnapshotLineInput): void {
  const holdings = line.holdings ?? [];
  if (typeof line.value !== "number" || !Number.isFinite(line.value)) {
    throw new BalanceServiceError(
      "snapshot_value_invalid",
      `Line for account ${line.account_id}: value must be a finite number`
    );
  }
  // Pre-pivot / empty: an aggregated total with no per-title breakdown. The
  // line value stands on its own; nothing further to assert.
  if (holdings.length === 0) {
    return;
  }
  // The aggregated row must not carry a scalar qty/price — those live per
  // holding. Allow NULL/undefined only.
  if (line.quantity !== undefined && line.quantity !== null) {
    throw new BalanceServiceError(
      "snapshot_detailed_must_be_aggregate",
      `Line for account ${line.account_id}: detailed line must not carry a scalar quantity`
    );
  }
  if (line.unit_price !== undefined && line.unit_price !== null) {
    throw new BalanceServiceError(
      "snapshot_detailed_must_be_aggregate",
      `Line for account ${line.account_id}: detailed line must not carry a scalar unit_price`
    );
  }
  let centSum = 0;
  for (const h of holdings) {
    if (typeof h.symbol !== "string" || normalizeSecuritySymbol(h.symbol) === "") {
      throw new BalanceServiceError(
        "snapshot_holding_invalid",
        `Line for account ${line.account_id}: holding symbol is required`
      );
    }
    if (!ASSET_TYPES.includes(h.asset_type)) {
      throw new BalanceServiceError(
        "snapshot_holding_invalid",
        `Line for account ${line.account_id}: holding ${h.symbol} has invalid asset_type`
      );
    }
    if (typeof h.quantity !== "number" || !Number.isFinite(h.quantity)) {
      throw new BalanceServiceError(
        "snapshot_holding_invalid",
        `Line for account ${line.account_id}: holding ${h.symbol} quantity must be finite`
      );
    }
    if (typeof h.unit_price !== "number" || !Number.isFinite(h.unit_price)) {
      throw new BalanceServiceError(
        "snapshot_holding_invalid",
        `Line for account ${line.account_id}: holding ${h.symbol} unit_price must be finite`
      );
    }
    if (typeof h.value !== "number" || !Number.isFinite(h.value)) {
      throw new BalanceServiceError(
        "snapshot_holding_invalid",
        `Line for account ${line.account_id}: holding ${h.symbol} value must be finite`
      );
    }
    centSum += roundToCent(h.value);
  }
  // Compare the rounded-cent SUM exactly against the rounded-cent line total.
  // Both sides are rounded so the equality is on whole cents — no ε.
  const expected = roundToCent(centSum);
  if (roundToCent(line.value) !== expected) {
    throw new BalanceServiceError(
      "snapshot_detailed_value_mismatch",
      `Line for account ${line.account_id}: value ${line.value} does not match rounded-cent SUM of holdings (${expected})`
    );
  }
}

/**
 * Validate every input line ahead of any DB mutation. Detailed lines (those
 * carrying a `holdings` field) go through `validateDetailedSnapshot`; all
 * others keep the unchanged `validateLineKindInvariants` scalar pass. Shared by
 * `upsertSnapshotLines` and `saveSnapshotAtomic` so the two save entry points
 * never diverge.
 */
function validateAllLines(lines: SnapshotLineInput[]): void {
  for (const line of lines) {
    if (isDetailedLine(line)) {
      validateDetailedSnapshot(line);
    } else {
      validateLineKindInvariants(line);
    }
  }
}

/**
 * Insert one snapshot line and, for a detailed line, rewrite its holdings in
 * the SAME executor (transaction). Returns the inserted line id.
 *
 * Detailed path: the aggregated row stores the rounded-cent SUM of the
 * holdings (qty/price NULL); after capturing its `lastInsertId`, existing
 * holdings for that line are DELETEd (defensive — a fresh line has none), then
 * each holding's security is found-or-created and the holding INSERTed. The
 * server-side total is recomputed from the rounded-cent SUM so the line's
 * `value` is authoritative regardless of the caller's arithmetic.
 *
 * Simple / legacy-priced scalar path: unchanged — one INSERT, no holdings.
 */
async function insertSnapshotLineWithHoldings(
  exec: SqlExecutor,
  snapshotId: number,
  line: SnapshotLineInput
): Promise<number> {
  if (isDetailedLine(line)) {
    const holdings = line.holdings ?? [];
    // Recompute the aggregated total server-side from the rounded-cent SUM —
    // the stored line value is the source of truth for the aggregators.
    const total = roundToCent(
      holdings.reduce((acc, h) => acc + roundToCent(h.value), 0)
    );
    const lineRes = await exec.execute(
      `INSERT INTO balance_snapshot_lines
         (snapshot_id, account_id, quantity, unit_price, value, price_source)
       VALUES ($1, $2, NULL, NULL, $3, 'manual')`,
      [snapshotId, line.account_id, total]
    );
    const lineId = lineRes.lastInsertId as number;
    // Defensive clear — a freshly inserted line can't have holdings yet, but
    // this keeps the helper safe if it's ever reused on an existing line.
    await exec.execute(
      `DELETE FROM balance_snapshot_holdings WHERE snapshot_line_id = $1`,
      [lineId]
    );
    for (const h of holdings) {
      const security = await findOrCreateSecurity(
        {
          symbol: h.symbol,
          asset_type: h.asset_type,
          currency: h.currency,
          name: h.security_name,
        },
        exec
      );
      await exec.execute(
        `INSERT INTO balance_snapshot_holdings
           (snapshot_line_id, security_id, quantity, unit_price, value,
            book_cost, price_source, price_fetched_at)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
        [
          lineId,
          security.id,
          h.quantity,
          h.unit_price,
          roundToCent(h.value),
          h.book_cost ?? null,
          h.price_source ?? "manual",
          h.price_fetched_at ?? null,
        ]
      );
    }
    return lineId;
  }
  // Simple / legacy priced scalar line — unchanged behaviour.
  const kind = line.account_kind ?? "simple";
  if (kind === "simple") {
    const res = await exec.execute(
      `INSERT INTO balance_snapshot_lines
         (snapshot_id, account_id, quantity, unit_price, value, price_source)
       VALUES ($1, $2, NULL, NULL, $3, 'manual')`,
      [snapshotId, line.account_id, line.value]
    );
    return res.lastInsertId as number;
  }
  const res = await exec.execute(
    `INSERT INTO balance_snapshot_lines
       (snapshot_id, account_id, quantity, unit_price, value, price_source)
     VALUES ($1, $2, $3, $4, $5, 'manual')`,
    [snapshotId, line.account_id, line.quantity, line.unit_price, line.value]
  );
  return res.lastInsertId as number;
}

/**
 * Upsert a batch of snapshot lines. Each input row is inserted or
 * replaced atomically per account; lines for accounts not present in
 * `lines` are removed from the snapshot. This makes the editor strictly
 * state-driven — what the user sees is exactly what gets saved.
 *
 * Validation enforced ahead of time so the SQL CHECK never fires
 * (`validateLineKindInvariants`):
 *  - simple kind  → quantity / unit_price must be NULL; value must be finite.
 *  - priced kind  → quantity / unit_price must be finite, and
 *                   `value === quantity * unit_price` within
 *                   `PRICED_VALUE_TOLERANCE`.
 *
 * The default `account_kind = 'simple'` preserves the #146 calling
 * convention — callers that pre-classify their lines (which the priced
 * editor in #140 must do) pass `account_kind: 'priced'` explicitly.
 */
export async function upsertSnapshotLines(
  snapshotId: number,
  lines: SnapshotLineInput[]
): Promise<void> {
  const snapshot = await getSnapshotById(snapshotId);
  if (!snapshot) {
    throw new BalanceServiceError(
      "snapshot_not_found",
      `Snapshot ${snapshotId} not found`
    );
  }
  // Validate every input up-front before mutating anything (detailed lines go
  // through validateDetailedSnapshot, scalar lines through the unchanged pass).
  validateAllLines(lines);

  return withTransaction((db) =>
    upsertSnapshotLinesInTransaction(db, snapshotId, lines)
  );
}

async function upsertSnapshotLinesInTransaction(
  db: SqlExecutor,
  snapshotId: number,
  lines: SnapshotLineInput[]
): Promise<void> {
  // Strategy: clear and rewrite, wrapped in an explicit transaction so the
  // line + holdings writes commit together. Snapshot lines are small (one per
  // active account, typically < 20). CASCADE on snapshot_line_id wipes the old
  // holdings when their parent line is DELETEd here, so a detailed account's
  // per-title rows never outlive their line.
  let inTxn = false;
  try {
    await db.execute("BEGIN");
    inTxn = true;
    await db.execute(
      "DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1",
      [snapshotId]
    );
    for (const line of lines) {
      await insertSnapshotLineWithHoldings(db, snapshotId, line);
    }
    // Bump the parent snapshot's updated_at so list views can sort by recency.
    await db.execute(
      `UPDATE balance_snapshots
         SET updated_at = CURRENT_TIMESTAMP
       WHERE id = $1`,
      [snapshotId]
    );
    await db.execute("COMMIT");
    inTxn = false;
  } catch (e) {
    if (inTxn) {
      try {
        await db.execute("ROLLBACK");
      } catch {
        // Defensive: preserve the original error over a rollback failure.
      }
    }
    throw e;
  }
}

/**
 * Atomic snapshot save (#176). Wraps `INSERT INTO balance_snapshots` and
 * the line writes in a single explicit BEGIN/COMMIT transaction so a
 * failure during line validation or insertion never leaves an orphan
 * snapshot row behind (which used to wedge subsequent saves at the same
 * date through the `snapshot_date_taken` UNIQUE constraint).
 *
 * Caller contract:
 *  - All `lines` MUST already be validated by the caller — this function
 *    does NOT translate string inputs to numbers; it expects the same
 *    `SnapshotLineInput` shape that `upsertSnapshotLines` accepts.
 *  - The caller passes `existingSnapshotId` for edit-mode (no INSERT
 *    happens, only the line rewrite). For new-mode pass `null` and a
 *    `snapshot_date`; this function handles both cases inside the same
 *    transaction.
 *
 * On any error, ROLLBACK is issued and the original error is re-thrown.
 * If ROLLBACK itself fails (e.g. transaction never opened), that error is
 * swallowed and the original is preserved — the caller never sees a
 * misleading rollback error.
 *
 * Edit-mode date move (Issue #200): pass `moveToDate` with the snapshot's
 * NEW date when the user changed it in edit mode. The date move + line
 * rewrite happen in the same transaction, so a collision on `moveToDate`
 * (another snapshot already there) rolls the whole save back and surfaces
 * `snapshot_date_exists`. Passing `moveToDate` in new mode is ignored — the
 * date is taken from `snapshot_date` on INSERT there.
 */
export async function saveSnapshotAtomic(input: {
  existingSnapshotId: number | null;
  snapshot_date: string;
  notes?: string | null;
  lines: SnapshotLineInput[];
  /** New date for an edit-mode move; omit when the date is unchanged. */
  moveToDate?: string | null;
}): Promise<{ snapshotId: number }> {
  // Validate every line ahead of time so the transaction never opens for
  // a doomed save. Detailed lines go through validateDetailedSnapshot; scalar
  // lines keep the unchanged validateLineKindInvariants pass.
  validateAllLines(input.lines);

  return withTransaction((db) => saveSnapshotAtomicInTransaction(db, input));
}

async function saveSnapshotAtomicInTransaction(
  db: SqlExecutor,
  input: {
    existingSnapshotId: number | null;
    snapshot_date: string;
    notes?: string | null;
    lines: SnapshotLineInput[];
    moveToDate?: string | null;
  }
): Promise<{ snapshotId: number }> {
  let inTxn = false;
  try {
    await db.execute("BEGIN");
    inTxn = true;

    let snapshotId: number;
    if (input.existingSnapshotId !== null) {
      snapshotId = input.existingSnapshotId;
      // Edit-mode date move (#200): if a new date was requested, re-check the
      // UNIQUE constraint in-txn and update `snapshot_date`. Done before the
      // line rewrite so a collision rolls the entire save back.
      if (input.moveToDate != null) {
        const moveTo = normalizeSnapshotDate(input.moveToDate);
        const clash = await db.select<Array<{ id: number }>>(
          `SELECT id FROM balance_snapshots
            WHERE snapshot_date = $1 AND id <> $2`,
          [moveTo, snapshotId]
        );
        if (clash.length > 0) {
          throw new BalanceServiceError(
            "snapshot_date_exists",
            `Another snapshot already exists at ${moveTo}`
          );
        }
        await db.execute(
          `UPDATE balance_snapshots
             SET snapshot_date = $1, updated_at = CURRENT_TIMESTAMP
           WHERE id = $2`,
          [moveTo, snapshotId]
        );
      }
    } else {
      const date = normalizeSnapshotDate(input.snapshot_date);
      // Date collision check inside the transaction so a concurrent
      // insert can't sneak between the SELECT and the INSERT.
      const dup = await db.select<Array<{ id: number }>>(
        `SELECT id FROM balance_snapshots WHERE snapshot_date = $1`,
        [date]
      );
      if (dup.length > 0) {
        throw new BalanceServiceError(
          "snapshot_date_taken",
          `A snapshot already exists at ${date}`
        );
      }
      const insRes = await db.execute(
        `INSERT INTO balance_snapshots (snapshot_date, notes)
         VALUES ($1, $2)`,
        [date, input.notes ? input.notes.trim() || null : null]
      );
      snapshotId = insRes.lastInsertId as number;
    }

    // Rewrite-all strategy (matches `upsertSnapshotLines`): clear
    // existing lines, then re-insert every line + its holdings. Cheap
    // because snapshot line counts are small. A detailed line writes its
    // aggregated row and its holdings via the shared helper, all inside this
    // same BEGIN/COMMIT — a holding INSERT failure rolls the whole save back.
    await db.execute(
      "DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1",
      [snapshotId]
    );
    for (const line of input.lines) {
      await insertSnapshotLineWithHoldings(db, snapshotId, line);
    }
    await db.execute(
      `UPDATE balance_snapshots
         SET updated_at = CURRENT_TIMESTAMP
       WHERE id = $1`,
      [snapshotId]
    );

    await db.execute("COMMIT");
    inTxn = false;
    return { snapshotId };
  } catch (e) {
    if (inTxn) {
      try {
        await db.execute("ROLLBACK");
      } catch {
        // Defensive: if ROLLBACK fails we still want the caller to see
        // the original error, not the rollback error.
      }
    }
    throw e;
  }
}

/**
 * Convenience helper used by the "Prefill from previous snapshot" button.
 * Returns the snapshot whose `snapshot_date` is strictly earlier than
 * `referenceDate`, or `null` if none exists.
 */
export async function getPreviousSnapshot(
  referenceDate: string
): Promise<BalanceSnapshot | null> {
  const normalized = normalizeSnapshotDate(referenceDate);
  const db = await getDb();
  const rows = await db.select<BalanceSnapshot[]>(
    `SELECT id, snapshot_date, notes, created_at, updated_at
     FROM balance_snapshots
     WHERE snapshot_date < $1
     ORDER BY snapshot_date DESC
     LIMIT 1`,
    [normalized]
  );
  return rows[0] ?? null;
}

// -----------------------------------------------------------------------------
// Time-series aggregators (Issue #141 / Bilan #3) — used by BalancePage.
// -----------------------------------------------------------------------------

/**
 * Optional [from, to] range filter expressed in ISO `YYYY-MM-DD` format.
 * Both endpoints are inclusive. `from` and `to` may each be omitted to leave
 * that side unbounded.
 */
export interface SnapshotDateRange {
  from?: string;
  to?: string;
}

/** Aggregated total at a given snapshot date. */
export interface SnapshotTotalPoint {
  snapshot_date: string;
  total: number;
}

function buildDateRangeClause(
  range: SnapshotDateRange | undefined,
  baseAlias: string
): { clause: string; params: unknown[] } {
  if (!range || (!range.from && !range.to)) {
    return { clause: "", params: [] };
  }
  const parts: string[] = [];
  const params: unknown[] = [];
  if (range.from) {
    const from = normalizeSnapshotDate(range.from);
    parts.push(`${baseAlias}.snapshot_date >= $${params.length + 1}`);
    params.push(from);
  }
  if (range.to) {
    const to = normalizeSnapshotDate(range.to);
    parts.push(`${baseAlias}.snapshot_date <= $${params.length + 1}`);
    params.push(to);
  }
  return { clause: `WHERE ${parts.join(" AND ")}`, params };
}

/**
 * Returns the aggregated total value of every snapshot, sorted by date ASC.
 * Used by the line variant of the evolution chart on `/balance`.
 *
 * The aggregation is `SUM(value) GROUP BY snapshot_date` — every account
 * contributing to the snapshot is summed in. Snapshots with no lines
 * collapse to a `total = 0` row (preserved so the chart shows continuity).
 */
export async function getSnapshotTotalsByDate(
  range?: SnapshotDateRange
): Promise<SnapshotTotalPoint[]> {
  const { clause, params } = buildDateRangeClause(range, "s");
  const db = await getDb();
  return db.select<SnapshotTotalPoint[]>(
    `SELECT s.snapshot_date AS snapshot_date,
            COALESCE(SUM(l.value), 0) AS total
       FROM balance_snapshots s
       LEFT JOIN balance_snapshot_lines l ON l.snapshot_id = s.id
       ${clause}
       GROUP BY s.snapshot_date
       ORDER BY s.snapshot_date ASC`,
    params
  );
}

/** Per-snapshot breakdown by category. */
export interface SnapshotCategoryBreakdownPoint {
  snapshot_date: string;
  byCategory: Record<string, number>;
}

interface RawCategoryBreakdownRow {
  snapshot_date: string;
  category_key: string;
  total: number;
}

/**
 * Returns per-snapshot totals broken down by `balance_categories.key`,
 * sorted by date ASC. Used by the stacked-area variant of the evolution
 * chart. Categories with no value at a given date are omitted from the
 * `byCategory` map (chart consumers should treat absent keys as zero).
 *
 * Lines whose joined account points to no category are skipped — that
 * shouldn't happen given FK RESTRICT but the JOIN is defensive.
 */
export async function getSnapshotTotalsByCategoryAndDate(
  range?: SnapshotDateRange
): Promise<SnapshotCategoryBreakdownPoint[]> {
  const { clause, params } = buildDateRangeClause(range, "s");
  const db = await getDb();
  const rows = await db.select<RawCategoryBreakdownRow[]>(
    `SELECT s.snapshot_date AS snapshot_date,
            c.key AS category_key,
            COALESCE(SUM(l.value), 0) AS total
       FROM balance_snapshots s
       INNER JOIN balance_snapshot_lines l ON l.snapshot_id = s.id
       INNER JOIN balance_accounts a ON a.id = l.account_id
       INNER JOIN balance_categories c ON c.id = a.balance_category_id
       ${clause}
       GROUP BY s.snapshot_date, c.key
       ORDER BY s.snapshot_date ASC, c.key ASC`,
    params
  );
  // Bucket rows by snapshot_date — many rows per date, one per category.
  const out: SnapshotCategoryBreakdownPoint[] = [];
  let current: SnapshotCategoryBreakdownPoint | null = null;
  for (const r of rows) {
    if (!current || current.snapshot_date !== r.snapshot_date) {
      current = { snapshot_date: r.snapshot_date, byCategory: {} };
      out.push(current);
    }
    current.byCategory[r.category_key] = r.total;
  }
  return out;
}

/** Sentinel bucket key for accounts with no fiscal envelope (NULL vehicle_type). */
export const VEHICLE_NONE_BUCKET = "none";

/** Per-snapshot breakdown by fiscal envelope (`vehicle_type`). */
export interface SnapshotVehicleBreakdownPoint {
  snapshot_date: string;
  /** Keyed by vehicle_type code, with the `'none'` bucket for NULL envelopes. */
  byVehicle: Record<string, number>;
}

interface RawVehicleBreakdownRow {
  snapshot_date: string;
  vehicle_key: string;
  total: number;
}

/**
 * Returns per-snapshot totals broken down by `balance_accounts.vehicle_type`,
 * sorted by date ASC. Mirror of `getSnapshotTotalsByCategoryAndDate` for the
 * "par enveloppe" axis of the stacked-area chart (Issue #204 / Étape 1).
 *
 * ⚠️ `vehicle_type` is NULLABLE — accounts with no envelope are grouped under
 * a single `'none'` bucket via `COALESCE(a.vehicle_type, 'none')`, never a SQL
 * NULL key (which would collapse to a `null` object key on the JS side). The
 * `'none'` bucket is labelled with `balance.vehicle.none` in the UI.
 *
 * Vehicles with no value at a given date are omitted from the `byVehicle` map
 * (chart consumers treat absent keys as zero).
 */
export async function getSnapshotTotalsByVehicleAndDate(
  range?: SnapshotDateRange
): Promise<SnapshotVehicleBreakdownPoint[]> {
  const { clause, params } = buildDateRangeClause(range, "s");
  const db = await getDb();
  const rows = await db.select<RawVehicleBreakdownRow[]>(
    `SELECT s.snapshot_date AS snapshot_date,
            COALESCE(a.vehicle_type, 'none') AS vehicle_key,
            COALESCE(SUM(l.value), 0) AS total
       FROM balance_snapshots s
       INNER JOIN balance_snapshot_lines l ON l.snapshot_id = s.id
       INNER JOIN balance_accounts a ON a.id = l.account_id
       ${clause}
       GROUP BY s.snapshot_date, COALESCE(a.vehicle_type, 'none')
       ORDER BY s.snapshot_date ASC, vehicle_key ASC`,
    params
  );
  // Bucket rows by snapshot_date — many rows per date, one per vehicle.
  const out: SnapshotVehicleBreakdownPoint[] = [];
  let current: SnapshotVehicleBreakdownPoint | null = null;
  for (const r of rows) {
    if (!current || current.snapshot_date !== r.snapshot_date) {
      current = { snapshot_date: r.snapshot_date, byVehicle: {} };
      out.push(current);
    }
    current.byVehicle[r.vehicle_key] = r.total;
  }
  return out;
}

/** Latest-snapshot value per active account (Issue #141). */
export interface AccountLatestSnapshot {
  account_id: number;
  account_name: string;
  symbol: string | null;
  balance_category_id: number;
  category_key: string;
  category_i18n_key: string;
  category_kind: BalanceCategoryKind;
  /** Mirror of `balance_categories.custom_label` — drives renderCategoryLabel. */
  category_custom_label?: string | null;
  /**
   * Entry mode of the account itself (`balance_accounts.kind`, migration v15).
   * 'detailed' accounts can drill down to per-security holdings; 'simple' carry
   * a single value. Surfaced so the accounts table knows which rows expand
   * (Issue #216). Authoritative over `category_kind` for dispatch.
   */
  kind: BalanceAccountKind;
  /**
   * Fiscal envelope of the account (`vehicle_type`), or NULL when none.
   * Surfaced for the "par enveloppe" axis groupings (Issue #204).
   */
  vehicle_type?: BalanceVehicleType | null;
  /** Date of the snapshot whose value is reported, or null if no snapshot exists. */
  latest_snapshot_date: string | null;
  /** Value at that snapshot, or null if the account has no snapshot lines. */
  latest_value: number | null;
  /**
   * Id of the snapshot LINE backing `latest_value` (the row in
   * `balance_snapshot_lines`), or null if the account has no snapshot lines.
   * Lets the per-security drill-down read holdings via
   * `listHoldingsBySnapshotLine` without a second resolve (Issue #216).
   */
  latest_snapshot_line_id: number | null;
}

/**
 * Returns one row per active (non-archived) account with the value of its
 * most-recent snapshot line. Accounts with no snapshot rows yet still
 * appear, with `latest_value = null`. Used by the accounts table on
 * `/balance` (#141) and as a building block for the period Δ% column.
 *
 * Implementation: a correlated subquery picks the line with the largest
 * `s.snapshot_date` for each account — SQLite handles this fine on the
 * indexed `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`.
 */
export async function getAccountsLatestSnapshot(): Promise<
  AccountLatestSnapshot[]
> {
  const db = await getDb();
  return db.select<AccountLatestSnapshot[]>(
    `SELECT a.id AS account_id,
            a.name AS account_name,
            a.symbol AS symbol,
            a.balance_category_id AS balance_category_id,
            c.key AS category_key,
            c.i18n_key AS category_i18n_key,
            c.kind AS category_kind,
            c.custom_label AS category_custom_label,
            a.kind AS kind,
            a.vehicle_type AS vehicle_type,
            (SELECT s.snapshot_date
               FROM balance_snapshot_lines l
               JOIN balance_snapshots s ON s.id = l.snapshot_id
               WHERE l.account_id = a.id
               ORDER BY s.snapshot_date DESC
               LIMIT 1) AS latest_snapshot_date,
            (SELECT l.value
               FROM balance_snapshot_lines l
               JOIN balance_snapshots s ON s.id = l.snapshot_id
               WHERE l.account_id = a.id
               ORDER BY s.snapshot_date DESC
               LIMIT 1) AS latest_value,
            (SELECT l.id
               FROM balance_snapshot_lines l
               JOIN balance_snapshots s ON s.id = l.snapshot_id
               WHERE l.account_id = a.id
               ORDER BY s.snapshot_date DESC
               LIMIT 1) AS latest_snapshot_line_id
       FROM balance_accounts a
       INNER JOIN balance_categories c ON c.id = a.balance_category_id
       WHERE a.is_active = 1 AND a.archived_at IS NULL
       ORDER BY c.sort_order, a.name`
  );
}

/**
 * Returns the value at the earliest snapshot for each account whose
 * `snapshot_date` is `>= range.from` (and `<= range.to` when set), so the
 * accounts table can compute a per-account Δ% over the selected period.
 *
 * Returns one row per account with a snapshot in range. Accounts without
 * any snapshot in the period are omitted — callers default their Δ% to
 * `null` (rendered as "—").
 */
export interface AccountPeriodAnchor {
  account_id: number;
  anchor_snapshot_date: string;
  anchor_value: number;
}

export async function getAccountsPeriodAnchor(
  range: SnapshotDateRange
): Promise<AccountPeriodAnchor[]> {
  // For each account, find the earliest snapshot_date >= range.from (and
  // <= range.to when set), then read that line's value.
  //
  // We use a ROW_NUMBER() window function partitioned by account_id and
  // ordered by snapshot_date ASC, then keep only rn = 1 per account. This
  // avoids the previous "MIN(s.snapshot_date) inside a scalar subquery
  // WHERE" pattern, which SQLite rejects with "misuse of aggregate function
  // MIN()" (issue #175).
  const params: unknown[] = [];
  const conditions: string[] = [];
  if (range.from) {
    conditions.push(`s.snapshot_date >= $${params.length + 1}`);
    params.push(normalizeSnapshotDate(range.from));
  }
  if (range.to) {
    conditions.push(`s.snapshot_date <= $${params.length + 1}`);
    params.push(normalizeSnapshotDate(range.to));
  }
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
  const db = await getDb();
  return db.select<AccountPeriodAnchor[]>(
    `SELECT account_id,
            snapshot_date AS anchor_snapshot_date,
            value AS anchor_value
       FROM (
         SELECT l.account_id AS account_id,
                s.snapshot_date AS snapshot_date,
                l.value AS value,
                ROW_NUMBER() OVER (
                  PARTITION BY l.account_id
                  ORDER BY s.snapshot_date ASC
                ) AS rn
           FROM balance_snapshot_lines l
           JOIN balance_snapshots s ON s.id = l.snapshot_id
           ${where}
       )
      WHERE rn = 1`,
    params
  );
}

// -----------------------------------------------------------------------------
// Holdings reads + unrealized gain (Issue #212 / Bilan détail par titre — #3)
// -----------------------------------------------------------------------------

/**
 * Drill-down read: every holding of a single snapshot line, joined with its
 * security for display (symbol / name / asset_type). Ordered by symbol.
 */
export async function listHoldingsBySnapshotLine(
  lineId: number
): Promise<BalanceSnapshotHoldingWithSecurity[]> {
  const db = await getDb();
  return db.select<BalanceSnapshotHoldingWithSecurity[]>(
    `SELECT h.id, h.snapshot_line_id, h.security_id, h.quantity, h.unit_price,
            h.value, h.book_cost, h.price_source, h.price_fetched_at,
            h.created_at, h.updated_at,
            s.symbol AS security_symbol, s.name AS security_name,
            s.asset_type AS security_asset_type
       FROM balance_snapshot_holdings h
       JOIN balance_securities s ON s.id = h.security_id
      WHERE h.snapshot_line_id = $1
      ORDER BY s.symbol ASC`,
    [lineId]
  );
}

/**
 * Holdings of an account's LATEST snapshot, one per security, joined with the
 * security for display. Used to PREFILL the next snapshot's editor (carry the
 * titles + quantities + book_cost forward; the price gets re-fetched).
 *
 * Titles with quantity 0 are EXCLUDED — a position fully sold at the last
 * snapshot shouldn't be re-offered (Issue #212 acceptance). A title sold then
 * re-bought reappears because its latest non-zero holding wins.
 *
 * "Latest" is resolved per account by the max `snapshot_date` among that
 * account's lines that actually carry holdings.
 */
export async function getHoldingsForLatestSnapshot(
  accountId: number
): Promise<BalanceSnapshotHoldingWithSecurity[]> {
  const db = await getDb();
  return db.select<BalanceSnapshotHoldingWithSecurity[]>(
    `SELECT h.id, h.snapshot_line_id, h.security_id, h.quantity, h.unit_price,
            h.value, h.book_cost, h.price_source, h.price_fetched_at,
            h.created_at, h.updated_at,
            s.symbol AS security_symbol, s.name AS security_name,
            s.asset_type AS security_asset_type
       FROM balance_snapshot_holdings h
       JOIN balance_securities s ON s.id = h.security_id
       JOIN balance_snapshot_lines l ON l.id = h.snapshot_line_id
      WHERE l.account_id = $1
        AND l.snapshot_id = (
          SELECT l2.snapshot_id
            FROM balance_snapshot_lines l2
            JOIN balance_snapshots s2 ON s2.id = l2.snapshot_id
            JOIN balance_snapshot_holdings h2 ON h2.snapshot_line_id = l2.id
           WHERE l2.account_id = $1
           ORDER BY s2.snapshot_date DESC
           LIMIT 1
        )
        AND h.quantity <> 0
      ORDER BY s.symbol ASC`,
    [accountId]
  );
}

/** Per-holding unrealized gain row. */
export interface HoldingUnrealizedGain {
  security_id: number;
  symbol: string;
  value: number;
  book_cost: number | null;
  /** `value - book_cost`, or null when book_cost is unknown (NULL). */
  gain: number | null;
  /**
   * `(value - book_cost) / book_cost`, or null ("N/A") when book_cost is NULL
   * or 0 — never a divide-by-zero. The UI renders null as the i18n "N/A".
   */
  gain_pct: number | null;
}

/** Account-level aggregated unrealized gain across its latest holdings. */
export interface AccountUnrealizedGain {
  /** SUM(value) across the holdings considered. */
  total_value: number;
  /** SUM(book_cost) across holdings WITH a known book_cost (NULLs excluded). */
  total_book_cost: number;
  /** total_value − total_book_cost across the known-book_cost holdings only. */
  total_gain: number;
  /** total_gain / total_book_cost, or null when total_book_cost is 0. */
  total_gain_pct: number | null;
  /**
   * True when at least one holding has a NULL book_cost (excluded from the
   * aggregate) — the UI flags the % as partial so it isn't read as exhaustive.
   */
  has_unknown_book_cost: boolean;
  /** The per-holding breakdown the aggregate was computed from. */
  holdings: HoldingUnrealizedGain[];
}

/**
 * Compute the unrealized gain (`value − book_cost`) per holding and aggregated,
 * in value and percent (Issue #212). Pure over the holdings it's given, so it's
 * trivially unit-testable; pass holdings from `getHoldingsForLatestSnapshot`
 * (latest snapshot) or `listHoldingsBySnapshotLine` (a specific snapshot).
 *
 * Guards:
 *  - per holding: `book_cost = 0` OR `book_cost = NULL` ⇒ `gain_pct = null`
 *    ("N/A") so we never divide by zero. A NULL book_cost also yields
 *    `gain = null` (we don't know the cost basis), whereas `book_cost = 0`
 *    yields `gain = value` (cost basis is genuinely zero).
 *  - aggregate: NULL-book_cost holdings are EXCLUDED from `total_book_cost`
 *    and `total_gain`, and flagged via `has_unknown_book_cost` so the % isn't
 *    silently understated. `total_gain_pct` is null when no known book_cost
 *    contributes (sum is 0).
 */
export function computeUnrealizedGain(
  holdings: Array<
    Pick<BalanceSnapshotHolding, "security_id" | "value" | "book_cost"> & {
      symbol?: string;
    }
  >
): AccountUnrealizedGain {
  const rows: HoldingUnrealizedGain[] = [];
  let totalValue = 0;
  let totalBookCost = 0;
  let totalGainKnown = 0;
  let hasUnknown = false;

  for (const h of holdings) {
    const value = h.value;
    totalValue += value;
    const bc = h.book_cost;
    let gain: number | null;
    let gainPct: number | null;
    if (bc === null || bc === undefined) {
      // Unknown cost basis — no gain figure, excluded from the aggregate.
      gain = null;
      gainPct = null;
      hasUnknown = true;
    } else {
      gain = value - bc;
      totalBookCost += bc;
      totalGainKnown += gain;
      // book_cost = 0 is a real basis (gain = value) but % is undefined.
      gainPct = bc === 0 ? null : (value - bc) / bc;
    }
    rows.push({
      security_id: h.security_id,
      symbol: (h as { symbol?: string }).symbol ?? "",
      value,
      book_cost: bc ?? null,
      gain,
      gain_pct: gainPct,
    });
  }

  return {
    total_value: totalValue,
    total_book_cost: totalBookCost,
    total_gain: totalGainKnown,
    total_gain_pct: totalBookCost === 0 ? null : totalGainKnown / totalBookCost,
    has_unknown_book_cost: hasUnknown,
    holdings: rows,
  };
}

/**
 * Latent gain of a single detailed account, resolved from its latest snapshot
 * line. Reads that line's holdings (joined with their security for display) and
 * folds them through `computeUnrealizedGain` — so the N/A / book_cost guards are
 * shared with the per-holding path (Issue #216). Returns the per-holding
 * breakdown plus the account-level aggregate; `lineId` is the
 * `latest_snapshot_line_id` carried by `AccountLatestSnapshot`.
 */
export async function getAccountLatentGainByLine(
  lineId: number
): Promise<AccountUnrealizedGain> {
  const holdings = await listHoldingsBySnapshotLine(lineId);
  return computeUnrealizedGain(
    holdings.map((h) => ({
      security_id: h.security_id,
      value: h.value,
      book_cost: h.book_cost,
      symbol: h.security_symbol,
    }))
  );
}

/** A single bucket of the latent-gain rollup (one asset class or one envelope). */
export interface LatentGainBucket {
  /**
   * Grouping key: the category `key` for asset-class buckets, or the
   * `vehicle_type` code (with `VEHICLE_NONE_BUCKET` for NULL) for envelope
   * buckets. Lets the UI resolve a translated label without re-querying.
   */
  group_key: string;
  /** SUM(value) across the detailed accounts in this bucket. */
  total_value: number;
  /** SUM(value − book_cost) over holdings WITH a known book_cost. */
  total_gain: number;
  /** total_gain / SUM(book_cost), or null when no known book_cost contributes. */
  total_gain_pct: number | null;
  /** True when ≥1 holding in the bucket has a NULL book_cost (excluded). */
  has_unknown_book_cost: boolean;
}

/** Per-account input row for `rollupLatentGain` (decouples the SQL from the math). */
export interface AccountLatentGainInput {
  category_key: string;
  vehicle_type?: BalanceVehicleType | null;
  gain: AccountUnrealizedGain;
}

/** Output of `rollupLatentGain`: aggregates by asset class, by envelope, grand total. */
export interface LatentGainRollup {
  byClass: LatentGainBucket[];
  byVehicle: LatentGainBucket[];
  /** Grand total across every detailed account (the BalanceOverviewCard figure). */
  grandTotal: {
    total_value: number;
    total_book_cost: number;
    total_gain: number;
    total_gain_pct: number | null;
    has_unknown_book_cost: boolean;
  };
}

/**
 * Pure rollup of per-account latent gains into asset-class buckets, envelope
 * buckets, and a grand total (Issue #216). No DB access — feed it the per-account
 * `AccountUnrealizedGain`s (from `getAccountLatentGainByLine`) tagged with their
 * `category_key` and `vehicle_type`, so it's trivially unit-testable.
 *
 * Accumulates `total_book_cost` per bucket internally to derive a sound `%`
 * (gain / known-book_cost), mirroring `computeUnrealizedGain`'s aggregate guard:
 * a bucket with no known book_cost yields `total_gain_pct = null`. NULL-book_cost
 * holdings are already excluded from each account's `total_book_cost`/`total_gain`
 * and surfaced via `has_unknown_book_cost`, which is OR-ed up per bucket.
 * Buckets are emitted in first-seen order (callers sort for display).
 */
export function rollupLatentGain(
  accounts: AccountLatentGainInput[]
): LatentGainRollup {
  interface Acc {
    group_key: string;
    total_value: number;
    total_book_cost: number;
    total_gain: number;
    has_unknown_book_cost: boolean;
  }
  const classMap = new Map<string, Acc>();
  const vehicleMap = new Map<string, Acc>();
  const grand: Acc = {
    group_key: "",
    total_value: 0,
    total_book_cost: 0,
    total_gain: 0,
    has_unknown_book_cost: false,
  };

  const fold = (map: Map<string, Acc>, key: string, g: AccountUnrealizedGain) => {
    let bucket = map.get(key);
    if (!bucket) {
      bucket = {
        group_key: key,
        total_value: 0,
        total_book_cost: 0,
        total_gain: 0,
        has_unknown_book_cost: false,
      };
      map.set(key, bucket);
    }
    bucket.total_value += g.total_value;
    bucket.total_book_cost += g.total_book_cost;
    bucket.total_gain += g.total_gain;
    bucket.has_unknown_book_cost ||= g.has_unknown_book_cost;
  };

  for (const a of accounts) {
    const vehKey = a.vehicle_type ?? VEHICLE_NONE_BUCKET;
    fold(classMap, a.category_key, a.gain);
    fold(vehicleMap, vehKey, a.gain);
    grand.total_value += a.gain.total_value;
    grand.total_book_cost += a.gain.total_book_cost;
    grand.total_gain += a.gain.total_gain;
    grand.has_unknown_book_cost ||= a.gain.has_unknown_book_cost;
  }

  const toBucket = (b: Acc): LatentGainBucket => ({
    group_key: b.group_key,
    total_value: b.total_value,
    total_gain: b.total_gain,
    total_gain_pct: b.total_book_cost === 0 ? null : b.total_gain / b.total_book_cost,
    has_unknown_book_cost: b.has_unknown_book_cost,
  });

  return {
    byClass: [...classMap.values()].map(toBucket),
    byVehicle: [...vehicleMap.values()].map(toBucket),
    grandTotal: {
      total_value: grand.total_value,
      total_book_cost: grand.total_book_cost,
      total_gain: grand.total_gain,
      total_gain_pct:
        grand.total_book_cost === 0 ? null : grand.total_gain / grand.total_book_cost,
      has_unknown_book_cost: grand.has_unknown_book_cost,
    },
  };
}

// -----------------------------------------------------------------------------
// Returns + transfers (Issue #142 / Bilan #4)
// -----------------------------------------------------------------------------
//
// Two distinct surface areas:
//
// (1) `computeAccountReturn` — Modified Dietz return for one account over a
//     period. Lives on the Rust side (`compute_account_return` Tauri command)
//     because it needs to JOIN snapshots + transfers + transactions and
//     apply day-precision weighting in a single short-lived connection. The
//     TS shim resolves the active profile's `db_filename` from `loadProfiles`
//     and forwards it to the command.
//
// (2) Transfer linking helpers — `linkTransfer`, `unlinkTransfer`,
//     `listAccountTransfers`. Plain CRUD on `balance_account_transfers` via
//     `getDb()`, same pattern as the rest of this file.

/**
 * Compute the Modified Dietz return for `accountId` over the period
 * `[periodStart, periodEnd]` (both ISO `YYYY-MM-DD`). Returns the typed
 * `AccountReturn` shape — see `src/shared/types/index.ts`.
 *
 * Resolves the active profile's `db_filename` from `loadProfiles()` so the
 * caller doesn't have to thread it through every screen. Throws
 * `transfer_active_profile_unknown` if no active profile is set (should be
 * impossible in normal app flow, but the service guards it anyway).
 */
export async function computeAccountReturn(
  accountId: number,
  periodStart: string,
  periodEnd: string
): Promise<AccountReturn> {
  const startNorm = normalizeSnapshotDate(periodStart);
  const endNorm = normalizeSnapshotDate(periodEnd);
  const config = await loadProfiles();
  const profile = config.profiles.find(
    (p) => p.id === config.active_profile_id
  );
  if (!profile) {
    throw new BalanceServiceError(
      "transfer_active_profile_unknown",
      "No active profile is set"
    );
  }
  return invoke<AccountReturn>("compute_account_return", {
    dbFilename: profile.db_filename,
    accountId,
    periodStart: startNorm,
    periodEnd: endNorm,
  });
}

function normalizeDirection(
  direction: BalanceTransferDirection
): BalanceTransferDirection {
  if (direction !== "in" && direction !== "out") {
    throw new BalanceServiceError(
      "transfer_direction_invalid",
      `Invalid transfer direction: ${direction}`
    );
  }
  return direction;
}

/**
 * Suggested direction for an unlinked transaction based on its signed amount.
 * Pure helper so the `LinkTransfersModal` UI can pre-fill the direction
 * column without round-tripping. Convention: in this codebase, expense
 * transactions are stored with negative amounts (money leaving the bank).
 * From the *balance account's* perspective:
 *   - negative bank amount = money left the bank → arrived at the balance
 *     account = `in`
 *   - positive bank amount = money entered the bank = the balance account
 *     gave it back = `out`
 */
export function suggestTransferDirection(
  transactionAmount: number
): BalanceTransferDirection {
  return transactionAmount < 0 ? "in" : "out";
}

/**
 * Link a transaction to a balance account with the given direction.
 * Throws `transfer_already_linked` if the (transaction, account) pair is
 * already in the table (UNIQUE constraint).
 */
export async function linkTransfer(
  accountId: number,
  transactionId: number,
  direction: BalanceTransferDirection,
  notes?: string | null
): Promise<number> {
  const dir = normalizeDirection(direction);
  const trimmedNotes = notes ? notes.trim() || null : null;
  const db = await getDb();
  // Guard duplicate link with a SELECT — keeps the error typed instead of a
  // raw "UNIQUE constraint failed" string.
  const existing = await db.select<{ id: number }[]>(
    `SELECT id FROM balance_account_transfers
      WHERE account_id = $1 AND transaction_id = $2`,
    [accountId, transactionId]
  );
  if (existing.length > 0) {
    throw new BalanceServiceError(
      "transfer_already_linked",
      `Transaction ${transactionId} is already linked to account ${accountId}`
    );
  }
  const result = await db.execute(
    `INSERT INTO balance_account_transfers (account_id, transaction_id, direction, notes)
     VALUES ($1, $2, $3, $4)`,
    [accountId, transactionId, dir, trimmedNotes]
  );
  return result.lastInsertId as number;
}

/**
 * Unlink a transaction from an account. Throws `transfer_not_linked` if the
 * pair isn't in the table — keeps callers from silently no-op'ing on a stale
 * UI state.
 */
export async function unlinkTransfer(
  accountId: number,
  transactionId: number
): Promise<void> {
  const db = await getDb();
  const result = await db.execute(
    `DELETE FROM balance_account_transfers
      WHERE account_id = $1 AND transaction_id = $2`,
    [accountId, transactionId]
  );
  if (result.rowsAffected === 0) {
    throw new BalanceServiceError(
      "transfer_not_linked",
      `No transfer linked transaction ${transactionId} to account ${accountId}`
    );
  }
}

/**
 * List every linked transfer for `accountId`, joined with the transaction
 * table for date/description/amount. Optional `dateRange` (ISO YYYY-MM-DD,
 * inclusive both sides) filters by `transactions.date`.
 */
export async function listAccountTransfers(
  accountId: number,
  dateRange?: { from?: string; to?: string }
): Promise<BalanceAccountTransferWithTransaction[]> {
  const params: unknown[] = [accountId];
  const conditions: string[] = ["bat.account_id = $1"];
  if (dateRange?.from) {
    conditions.push(`t.date >= $${params.length + 1}`);
    params.push(normalizeSnapshotDate(dateRange.from));
  }
  if (dateRange?.to) {
    conditions.push(`t.date <= $${params.length + 1}`);
    params.push(normalizeSnapshotDate(dateRange.to));
  }
  const where = `WHERE ${conditions.join(" AND ")}`;
  const db = await getDb();
  return db.select<BalanceAccountTransferWithTransaction[]>(
    `SELECT bat.id AS id,
            bat.account_id AS account_id,
            bat.transaction_id AS transaction_id,
            bat.direction AS direction,
            bat.notes AS notes,
            bat.created_at AS created_at,
            t.date AS transaction_date,
            t.description AS transaction_description,
            t.amount AS transaction_amount,
            a.name AS account_name
       FROM balance_account_transfers bat
       JOIN transactions t ON t.id = bat.transaction_id
       JOIN balance_accounts a ON a.id = bat.account_id
       ${where}
       ORDER BY t.date DESC, bat.id DESC`,
    params
  );
}

/**
 * Returns the set of `transaction_id`s currently linked to ANY balance
 * account. Used by the transactions table to render the transfer icon
 * without an N+1 query — the caller receives the full set once per render
 * and does an in-memory `.has(id)` lookup. Cheap on real-world scales
 * (typically < 1000 linked transfers per profile).
 */
export async function listLinkedTransactionIds(): Promise<Set<number>> {
  const db = await getDb();
  const rows = await db.select<{ transaction_id: number }[]>(
    `SELECT DISTINCT transaction_id FROM balance_account_transfers`
  );
  return new Set(rows.map((r) => r.transaction_id));
}

/**
 * Returns transfer info keyed by `transaction_id` for tooltip rendering in
 * the transactions table. Each transaction maps to an array because a
 * single transaction *could* be linked to several accounts in principle
 * (the UNIQUE is on the pair, not on transaction alone).
 */
export interface LinkedTransferTooltipRow {
  transaction_id: number;
  account_id: number;
  account_name: string;
  direction: BalanceTransferDirection;
}

export async function listAllLinkedTransfersForTooltip(): Promise<
  Map<number, LinkedTransferTooltipRow[]>
> {
  const db = await getDb();
  const rows = await db.select<LinkedTransferTooltipRow[]>(
    `SELECT bat.transaction_id AS transaction_id,
            bat.account_id AS account_id,
            a.name AS account_name,
            bat.direction AS direction
       FROM balance_account_transfers bat
       JOIN balance_accounts a ON a.id = bat.account_id
       ORDER BY bat.transaction_id`
  );
  const map = new Map<number, LinkedTransferTooltipRow[]>();
  for (const r of rows) {
    const list = map.get(r.transaction_id) ?? [];
    list.push(r);
    map.set(r.transaction_id, list);
  }
  return map;
}

/**
 * Detect whether the SQL error returned by `tauri-plugin-sql` is a FK
 * RESTRICT violation from `balance_account_transfers.transaction_id`. The
 * plugin surfaces the SQLite error message verbatim, so we match on the
 * string. Used by `transactionService.deleteTransaction` to surface a
 * clean i18n error instead of leaking the raw SQL.
 */
export function isLinkedTransactionFkError(error: unknown): boolean {
  const msg = error instanceof Error ? error.message : String(error ?? "");
  // SQLite FK error messages look like:
  //   "FOREIGN KEY constraint failed"
  // or
  //   "code: 787, message: FOREIGN KEY constraint failed"
  // Both contain the canonical "FOREIGN KEY constraint failed" substring.
  return /FOREIGN KEY constraint failed/i.test(msg);
}

// -----------------------------------------------------------------------------
// Prices — fetch_price Tauri command wrapper (Issue #156 / Bilan #5)
// -----------------------------------------------------------------------------
//
// Wraps `invoke('fetch_price', { symbol, date })` with:
//   - Local rate-limit (1 request / 2s via module-level timestamp)
//   - In-flight deduplication (same symbol+date → one request, multiple awaiters)
//   - Exponential backoff on 5xx-class errors (2/4/8s, max 3 retries)
//   - No retry on 4xx errors or rate_limit (429-class)
//   - Hard 100-request session cap (successful fetches only)
//
// The Rust command `fetch_price` (implemented in issue #155) rejects with a
// JSON string serialized from the Rust error enum:
//   {"code":"auth"} | {"code":"rate_limit","retry_after_s":42} | ...
//
// Annexe B i18n mapping (keys live in the i18n PR #160):
//   auth              → balance.priceFetching.errors.authFailed
//   premium_required  → balance.priceFetching.errors.premiumRequired
//   symbol_not_found  → balance.priceFetching.errors.symbolNotFound
//   rate_limit        → balance.priceFetching.errors.rateLimit
//   provider_unavailable → balance.priceFetching.errors.serverUnavailable
//   network           → balance.priceFetching.errors.serverUnavailable
//   internal          → balance.priceFetching.errors.serverUnavailable
//   session_cap_reached → balance.priceFetching.errors.sessionCapReached

export type PriceErrorCode =
  | "auth"
  | "premium_required"
  | "symbol_not_found"
  | "rate_limit"
  | "provider_unavailable"
  | "network"
  | "internal"
  | "session_cap_reached";

export type PriceError =
  | { code: "rate_limit"; retry_after_s: number; i18nKey: string }
  | { code: Exclude<PriceErrorCode, "rate_limit">; i18nKey: string };

export interface PriceSuccess {
  ok: true;
  symbol: string;
  date: string;
  price: number;
  currency: string;
  source: string;
  cached: boolean;
  actual_date?: string | null;
  fetched_at: string;
}

export type PriceResult =
  | PriceSuccess
  | { ok: false; error: PriceError };

/** Raw shape returned by the Rust `fetch_price` command on success. */
interface RawPriceResponse {
  symbol: string;
  date: string;
  price: number;
  currency: string;
  source: string;
  cached: boolean;
  actual_date?: string | null;
  fetched_at: string;
}

// i18n key map for non-rate_limit error codes.
const PRICE_ERROR_I18N_MAP: Record<Exclude<PriceErrorCode, "rate_limit">, string> = {
  auth: "balance.priceFetching.errors.authFailed",
  premium_required: "balance.priceFetching.errors.premiumRequired",
  symbol_not_found: "balance.priceFetching.errors.symbolNotFound",
  provider_unavailable: "balance.priceFetching.errors.serverUnavailable",
  network: "balance.priceFetching.errors.serverUnavailable",
  internal: "balance.priceFetching.errors.serverUnavailable",
  session_cap_reached: "balance.priceFetching.errors.sessionCapReached",
};

/** Codes that map to no-retry behaviour (4xx-class or session cap). */
const NO_RETRY_CODES = new Set<PriceErrorCode>([
  "auth",
  "premium_required",
  "symbol_not_found",
  "rate_limit",
  "session_cap_reached",
]);

/**
 * Parse the string-serialized Rust error into a typed `PriceError`.
 * `invoke` rejects with the value of `Result::Err(String)`, which the Rust
 * side serialises via serde_json (see issue #155 worker decision).
 */
function parseRustError(e: unknown): PriceError {
  if (typeof e === "string") {
    try {
      const j = JSON.parse(e) as Record<string, unknown>;
      if (j && typeof j.code === "string") {
        const code = j.code;
        if (code === "rate_limit") {
          const retry_after_s =
            typeof j.retry_after_s === "number" ? j.retry_after_s : 0;
          return {
            code: "rate_limit",
            retry_after_s,
            i18nKey: "balance.priceFetching.errors.rateLimit",
          };
        }
        if (code in PRICE_ERROR_I18N_MAP) {
          const typedCode = code as Exclude<PriceErrorCode, "rate_limit">;
          return { code: typedCode, i18nKey: PRICE_ERROR_I18N_MAP[typedCode] };
        }
      }
    } catch {
      // Fall through to default below.
    }
  }
  return {
    code: "internal",
    i18nKey: PRICE_ERROR_I18N_MAP.internal,
  };
}

// Module-level state — resets only when the JS module is re-imported
// (i.e. on app process restart). Tests reset via `prices.__resetForTests()`.
let _lastFiredAt = 0;
let _sessionCount = 0;
const SESSION_CAP = 100;
const MIN_INTERVAL_MS = 2000;
const _inFlight = new Map<string, Promise<PriceResult>>();

/** Enforce the 1-request-per-2s local rate limit. */
async function _enforceRateLimit(): Promise<void> {
  const now = Date.now();
  const wait = Math.max(0, _lastFiredAt + MIN_INTERVAL_MS - now);
  if (wait > 0) {
    await new Promise<void>((r) => setTimeout(r, wait));
  }
  _lastFiredAt = Date.now();
}

/** Single attempt: rate-limit, then invoke once. */
async function _doFetchOnce(
  symbol: string,
  date: string
): Promise<PriceResult> {
  await _enforceRateLimit();
  try {
    const raw = await invoke<RawPriceResponse>("fetch_price", { symbol, date });
    return { ok: true, ...raw };
  } catch (e) {
    return { ok: false, error: parseRustError(e) };
  }
}

/** Wrap _doFetchOnce with exponential backoff on retryable errors (5xx-class). */
async function _withRetries(
  symbol: string,
  date: string
): Promise<PriceResult> {
  const delays = [2000, 4000, 8000];
  let lastResult: PriceResult | null = null;
  for (let attempt = 0; attempt <= 3; attempt++) {
    const r = await _doFetchOnce(symbol, date);
    if (r.ok) return r;
    lastResult = r;
    const code = r.error.code;
    if (NO_RETRY_CODES.has(code)) {
      // 4xx-class: return immediately, no retry.
      return r;
    }
    // 5xx-class (provider_unavailable, network, internal): retry with backoff.
    if (attempt < 3) {
      await new Promise<void>((r) => setTimeout(r, delays[attempt]));
    }
  }
  // Should never reach here, but satisfy TypeScript.
  return lastResult!;
}

/**
 * `prices` namespace — entry point for the UI.
 *
 * All outgoing requests are rate-limited (1/2s), deduplicated in-flight, and
 * wrapped with exponential backoff on 5xx-class errors. A hard session cap of
 * 100 successful fetches guards against runaway loops.
 */
export const prices = {
  /**
   * Fetch the price for `symbol` at `date` (ISO YYYY-MM-DD).
   *
   * Decision (MEDIUM): the 100-session cap is checked BEFORE rate-limit and
   * dedup. Successful fetches increment the counter; failures do NOT consume
   * the budget — a 4xx auth error costs nothing, and a user who hits a bad
   * symbol shouldn't have their session budget drained.
   */
  async fetchPrice(symbol: string, date: string): Promise<PriceResult> {
    if (_sessionCount >= SESSION_CAP) {
      return {
        ok: false,
        error: {
          code: "session_cap_reached",
          i18nKey: PRICE_ERROR_I18N_MAP.session_cap_reached,
        },
      };
    }

    const key = `${symbol}|${date}`;
    const existing = _inFlight.get(key);
    if (existing) return existing;

    const promise = (async () => {
      try {
        const result = await _withRetries(symbol, date);
        if (result.ok) _sessionCount++;
        return result;
      } finally {
        _inFlight.delete(key);
      }
    })();

    _inFlight.set(key, promise);
    return promise;
  },

  /**
   * Reset module-level state between tests.
   * Call in `beforeEach` to isolate rate-limit, session count, and in-flight map.
   */
  __resetForTests(): void {
    _lastFiredAt = 0;
    _sessionCount = 0;
    _inFlight.clear();
  },
};

