import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

vi.mock("./db", () => {
  const getDb = vi.fn();
  return {
    getDb,
    // Tests don't exercise the real lock; withTransaction just runs the
    // callback with whatever db the test wired getDb to resolve, so every
    // existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the
    // service issues them on the mock db inside the callback).
    withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
  };
});

vi.mock("@tauri-apps/api/core", () => ({
  invoke: vi.fn(),
}));

vi.mock("./profileService", () => ({
  loadProfiles: vi.fn(),
}));

import { getDb } from "./db";
import { invoke } from "@tauri-apps/api/core";
import { loadProfiles } from "./profileService";
import {
  listBalanceCategories,
  createBalanceCategory,
  updateBalanceCategory,
  deleteBalanceCategory,
  listBalanceAccounts,
  getBalanceAccount,
  createBalanceAccount,
  updateBalanceAccount,
  archiveBalanceAccount,
  unarchiveBalanceAccount,
  listSnapshots,
  getSnapshotByDate,
  createSnapshot,
  updateSnapshot,
  deleteSnapshot,
  listLinesBySnapshot,
  upsertSnapshotLines,
  saveSnapshotAtomic,
  getPreviousSnapshot,
  validateLineKindInvariants,
  validateDetailedSnapshot,
  isDetailedLine,
  normalizeSecuritySymbol,
  roundToCent,
  findOrCreateSecurity,
  listSecurities,
  getSecurity,
  updateSecurity,
  listHoldingsBySnapshotLine,
  getHoldingsForLatestSnapshot,
  computeUnrealizedGain,
  getAccountLatentGainByLine,
  rollupLatentGain,
  type AccountUnrealizedGain,
  PRICED_VALUE_TOLERANCE,
  BalanceServiceError,
  getSnapshotTotalsByDate,
  getSnapshotTotalsByCategoryAndDate,
  getSnapshotTotalsByVehicleAndDate,
  getAccountsLatestSnapshot,
  getAccountsPeriodAnchor,
  computeAccountReturn,
  linkTransfer,
  unlinkTransfer,
  listAccountTransfers,
  listLinkedTransactionIds,
  listAllLinkedTransfersForTooltip,
  isLinkedTransactionFkError,
  suggestTransferDirection,
} from "./balance.service";

const mockSelect = vi.fn();
const mockExecute = vi.fn();
const mockDb = { select: mockSelect, execute: mockExecute };

beforeEach(() => {
  vi.mocked(getDb).mockResolvedValue(mockDb as never);
  mockSelect.mockReset();
  mockExecute.mockReset();
});

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

describe("listBalanceCategories", () => {
  it("orders by sort_order then key", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await listBalanceCategories();
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("FROM balance_categories");
    expect(sql).toContain("ORDER BY sort_order, key");
  });
});

describe("createBalanceCategory", () => {
  it("rejects an empty key", async () => {
    await expect(
      createBalanceCategory({ key: "  ", i18n_key: "x", kind: "simple" })
    ).rejects.toBeInstanceOf(BalanceServiceError);
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("rejects an invalid kind", async () => {
    await expect(
      createBalanceCategory({
        key: "custom",
        i18n_key: "balance.category.custom",
        // @ts-expect-error testing runtime guard
        kind: "weird",
      })
    ).rejects.toBeInstanceOf(BalanceServiceError);
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("inserts with is_seed = 0 and returns lastInsertId", async () => {
    mockExecute.mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 });
    const id = await createBalanceCategory({
      key: "ferr",
      i18n_key: "balance.category.ferr",
      kind: "simple",
      sort_order: 35,
    });
    expect(id).toBe(42);
    const sql = mockExecute.mock.calls[0][0] as string;
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(sql).toContain("INSERT INTO balance_categories");
    expect(sql).toContain("is_seed");
    // is_seed hardcoded to 0; asset_type = $5, custom_label = $6 (#202).
    expect(sql).toContain("0, $5, $6)");
    // simple kind → asset_type coerced to NULL; custom_label NULL when omitted.
    expect(params).toEqual([
      "ferr",
      "balance.category.ferr",
      "simple",
      35,
      null,
      null,
    ]);
  });

  it("rejects priced category without asset_type (#169)", async () => {
    await expect(
      createBalanceCategory({
        key: "mining_etf",
        i18n_key: "x.mining",
        kind: "priced",
      })
    ).rejects.toMatchObject({
      name: "BalanceServiceError",
      code: "asset_type_required",
    });
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("rejects an invalid asset_type value (#169)", async () => {
    await expect(
      createBalanceCategory({
        key: "mining_etf",
        i18n_key: "x.mining",
        kind: "priced",
        // @ts-expect-error testing runtime guard
        asset_type: "gold",
      })
    ).rejects.toMatchObject({
      name: "BalanceServiceError",
      code: "asset_type_invalid",
    });
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("inserts a priced category with asset_type='stock' (#169)", async () => {
    mockExecute.mockResolvedValueOnce({ lastInsertId: 99, rowsAffected: 1 });
    const id = await createBalanceCategory({
      key: "tsx",
      i18n_key: "x.tsx",
      kind: "priced",
      asset_type: "stock",
    });
    expect(id).toBe(99);
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[2]).toBe("priced");
    expect(params[4]).toBe("stock");
  });

  it("inserts a priced category with asset_type='crypto' (#169)", async () => {
    mockExecute.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 });
    await createBalanceCategory({
      key: "alts",
      i18n_key: "x.alts",
      kind: "priced",
      asset_type: "crypto",
    });
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[4]).toBe("crypto");
  });

  it("forces asset_type to NULL on simple kind even if provided (#169)", async () => {
    mockExecute.mockResolvedValueOnce({ lastInsertId: 1, rowsAffected: 1 });
    await createBalanceCategory({
      key: "savings",
      i18n_key: "x.savings",
      kind: "simple",
      // Service coerces simple kind → asset_type=null regardless of caller.
      asset_type: "stock",
    });
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[4]).toBeNull();
  });
});

describe("deleteBalanceCategory", () => {
  it("refuses to delete a seeded category", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        id: 1,
        key: "cash",
        i18n_key: "balance.category.cash",
        kind: "simple",
        sort_order: 10,
        is_active: 1,
        is_seed: 1,
      },
    ]);
    await expect(deleteBalanceCategory(1)).rejects.toMatchObject({
      code: "category_seed_protected",
    });
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("refuses to delete a category with linked accounts", async () => {
    // 1st select = getBalanceCategory; 2nd select = COUNT(*) accounts linked
    mockSelect
      .mockResolvedValueOnce([
        {
          id: 8,
          key: "ferr",
          i18n_key: "balance.category.ferr",
          kind: "simple",
          sort_order: 35,
          is_active: 1,
          is_seed: 0,
        },
      ])
      .mockResolvedValueOnce([{ count: 2 }]);
    await expect(deleteBalanceCategory(8)).rejects.toMatchObject({
      code: "category_has_accounts",
    });
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("deletes a user-created category with no linked accounts", async () => {
    mockSelect
      .mockResolvedValueOnce([
        {
          id: 8,
          key: "ferr",
          i18n_key: "balance.category.ferr",
          kind: "simple",
          sort_order: 35,
          is_active: 1,
          is_seed: 0,
        },
      ])
      .mockResolvedValueOnce([{ count: 0 }]);
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
    await deleteBalanceCategory(8);
    expect(mockExecute).toHaveBeenCalledWith(
      "DELETE FROM balance_categories WHERE id = $1",
      [8]
    );
  });
});

describe("updateBalanceCategory", () => {
  it("renames a seeded category (allowed)", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        id: 1,
        key: "cash",
        i18n_key: "balance.category.cash",
        kind: "simple",
        sort_order: 10,
        is_active: 1,
        is_seed: 1,
      },
    ]);
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
    await updateBalanceCategory(1, { i18n_key: "balance.category.cash_renamed" });
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[0]).toBe("balance.category.cash_renamed");
  });

  it("rejects update on missing category", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await expect(updateBalanceCategory(999, { sort_order: 5 })).rejects.toMatchObject({
      code: "category_not_found",
    });
  });
});

// -----------------------------------------------------------------------------
// custom_label (Bilan axe véhicule, Étape 1 — issue #202)
// -----------------------------------------------------------------------------

describe("balance categories — custom_label (#202)", () => {
  it("listBalanceCategories selects custom_label and filters is_active when asked", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await listBalanceCategories({ includeInactive: false });
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("custom_label");
    expect(sql).toContain("WHERE is_active = 1");
  });

  it("listBalanceCategories includes inactive categories by default (#202 behavior-neutral)", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await listBalanceCategories();
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("custom_label");
    expect(sql).not.toContain("WHERE is_active = 1");
  });

  it("createBalanceCategory trims custom_label and stores it as the 6th param", async () => {
    mockExecute.mockResolvedValueOnce({ lastInsertId: 5, rowsAffected: 1 });
    await createBalanceCategory({
      key: "savings",
      i18n_key: "balance.category.savings",
      kind: "simple",
      custom_label: "  Mon épargne  ",
    });
    const sql = mockExecute.mock.calls[0][0] as string;
    expect(sql).toContain("custom_label");
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[5]).toBe("Mon épargne");
  });

  it("createBalanceCategory normalizes a blank custom_label to null", async () => {
    mockExecute.mockResolvedValueOnce({ lastInsertId: 6, rowsAffected: 1 });
    await createBalanceCategory({
      key: "x",
      i18n_key: "balance.category.x",
      kind: "simple",
      custom_label: "   ",
    });
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[5]).toBeNull();
  });

  it("updateBalanceCategory sets custom_label without touching i18n_key (fixes bug I)", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        id: 1,
        key: "cash",
        i18n_key: "balance.category.cash",
        kind: "simple",
        sort_order: 10,
        is_active: 1,
        is_seed: 1,
        asset_type: null,
        custom_label: null,
      },
    ]);
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
    await updateBalanceCategory(1, { custom_label: "Comptes courants" });
    const sql = mockExecute.mock.calls[0][0] as string;
    expect(sql).toContain("custom_label = $5");
    const params = mockExecute.mock.calls[0][1] as unknown[];
    // i18n_key (param 0) preserved verbatim; custom_label (param 4) is the rename.
    expect(params[0]).toBe("balance.category.cash");
    expect(params[4]).toBe("Comptes courants");
  });

  it("updateBalanceCategory clears custom_label on explicit null", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        id: 1,
        key: "cash",
        i18n_key: "balance.category.cash",
        kind: "simple",
        sort_order: 10,
        is_active: 1,
        is_seed: 1,
        asset_type: null,
        custom_label: "Old label",
      },
    ]);
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
    await updateBalanceCategory(1, { custom_label: null });
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[4]).toBeNull();
  });

  it("updateBalanceCategory preserves existing custom_label when omitted", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        id: 1,
        key: "cash",
        i18n_key: "balance.category.cash",
        kind: "simple",
        sort_order: 10,
        is_active: 1,
        is_seed: 1,
        asset_type: null,
        custom_label: "Kept label",
      },
    ]);
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
    await updateBalanceCategory(1, { sort_order: 99 });
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[4]).toBe("Kept label");
  });
});

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

describe("listBalanceAccounts", () => {
  it("excludes archived accounts by default", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await listBalanceAccounts();
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("a.is_active = 1");
    expect(sql).toContain("a.archived_at IS NULL");
  });

  it("includes archived accounts when requested", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await listBalanceAccounts({ includeArchived: true });
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).not.toContain("archived_at IS NULL");
  });

  it("threads category_asset_type from the join (#169)", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await listBalanceAccounts();
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("c.asset_type AS category_asset_type");
  });
});

describe("createBalanceAccount", () => {
  it("rejects empty name", async () => {
    await expect(
      createBalanceAccount({ balance_category_id: 1, name: "  " })
    ).rejects.toMatchObject({ code: "name_required" });
  });

  it("rejects non-CAD currency at the MVP", async () => {
    await expect(
      createBalanceAccount({
        balance_category_id: 1,
        name: "USD account",
        currency: "USD",
      })
    ).rejects.toMatchObject({ code: "currency_unsupported" });
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("rejects when the category does not exist", async () => {
    mockSelect.mockResolvedValueOnce([]); // getBalanceCategory returns null
    await expect(
      createBalanceAccount({ balance_category_id: 999, name: "Mystery" })
    ).rejects.toMatchObject({ code: "category_not_found" });
  });

  it("inserts with default CAD currency", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        id: 1,
        key: "cash",
        i18n_key: "balance.category.cash",
        kind: "simple",
        sort_order: 10,
        is_active: 1,
        is_seed: 1,
      },
    ]);
    mockExecute.mockResolvedValueOnce({ lastInsertId: 7, rowsAffected: 1 });
    const id = await createBalanceAccount({
      balance_category_id: 1,
      name: "Encaisse Wealthsimple",
    });
    expect(id).toBe(7);
    const params = mockExecute.mock.calls[0][1] as unknown[];
    // 6th param is vehicle_type (NULL when not provided) — #202.
    expect(params).toEqual([1, "Encaisse Wealthsimple", null, "CAD", null, null]);
  });

  it("allows a priced-category account WITHOUT a symbol (Issue #199)", async () => {
    // Symbol is optional even for priced categories — manual valuation
    // (quantity × unit price) never needs it; only the price-fetch button does.
    mockSelect.mockResolvedValueOnce([
      {
        id: 3,
        key: "stock",
        i18n_key: "balance.category.stock",
        kind: "priced",
        sort_order: 50,
        is_active: 1,
        is_seed: 1,
        asset_type: "stock",
      },
    ]);
    mockExecute.mockResolvedValueOnce({ lastInsertId: 9, rowsAffected: 1 });
    const id = await createBalanceAccount({
      balance_category_id: 3,
      name: "Portefeuille Wealthsimple",
      // no symbol provided
    });
    expect(id).toBe(9);
    const params = mockExecute.mock.calls[0][1] as unknown[];
    // symbol param (3rd) is null — insert succeeds, no validation thrown.
    expect(params[2]).toBeNull();
  });
});

// -----------------------------------------------------------------------------
// vehicle_type (Bilan axe véhicule, Étape 1 — issue #202)
// -----------------------------------------------------------------------------

describe("balance accounts — vehicle_type (#202)", () => {
  const cashCategoryRow = {
    id: 1,
    key: "cash",
    i18n_key: "balance.category.cash",
    kind: "simple",
    sort_order: 10,
    is_active: 1,
    is_seed: 1,
    asset_type: null,
    custom_label: null,
  };

  it("createBalanceAccount stores a valid vehicle_type as the 6th param", async () => {
    mockSelect.mockResolvedValueOnce([cashCategoryRow]);
    mockExecute.mockResolvedValueOnce({ lastInsertId: 8, rowsAffected: 1 });
    await createBalanceAccount({
      balance_category_id: 1,
      name: "Mon CELI",
      vehicle_type: "tfsa",
    });
    const sql = mockExecute.mock.calls[0][0] as string;
    expect(sql).toContain("vehicle_type");
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[5]).toBe("tfsa");
  });

  it("createBalanceAccount accepts every enum value", async () => {
    for (const v of ["unregistered", "tfsa", "rrsp", "rrif", "fhsa", "resp"] as const) {
      mockSelect.mockReset();
      mockExecute.mockReset();
      mockSelect.mockResolvedValueOnce([cashCategoryRow]);
      mockExecute.mockResolvedValueOnce({ lastInsertId: 1, rowsAffected: 1 });
      await createBalanceAccount({
        balance_category_id: 1,
        name: `acct-${v}`,
        vehicle_type: v,
      });
      const params = mockExecute.mock.calls[0][1] as unknown[];
      expect(params[5]).toBe(v);
    }
  });

  it("createBalanceAccount rejects an out-of-enum vehicle_type", async () => {
    mockSelect.mockResolvedValueOnce([cashCategoryRow]);
    await expect(
      createBalanceAccount({
        balance_category_id: 1,
        name: "Bad",
        // @ts-expect-error testing runtime guard — not an automobile type
        vehicle_type: "car",
      })
    ).rejects.toMatchObject({ code: "vehicle_type_invalid" });
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("createBalanceAccount stores NULL when vehicle_type is omitted", async () => {
    mockSelect.mockResolvedValueOnce([cashCategoryRow]);
    mockExecute.mockResolvedValueOnce({ lastInsertId: 8, rowsAffected: 1 });
    await createBalanceAccount({ balance_category_id: 1, name: "Encaisse" });
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[5]).toBeNull();
  });

  it("getBalanceAccount selects vehicle_type", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        id: 7,
        balance_category_id: 1,
        name: "Encaisse",
        symbol: null,
        currency: "CAD",
        notes: null,
        is_active: 1,
        archived_at: null,
        vehicle_type: "tfsa",
        created_at: "",
        updated_at: "",
      },
    ]);
    const acct = await getBalanceAccount(7);
    expect(acct?.vehicle_type).toBe("tfsa");
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("vehicle_type");
  });

  it("listBalanceAccounts threads vehicle_type and category_custom_label from the join", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await listBalanceAccounts();
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("a.vehicle_type");
    expect(sql).toContain("c.custom_label AS category_custom_label");
  });

  it("updateBalanceAccount preserves the existing vehicle_type when omitted", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        id: 7,
        balance_category_id: 1,
        name: "Mon CELI",
        symbol: null,
        currency: "CAD",
        notes: null,
        is_active: 1,
        archived_at: null,
        vehicle_type: "tfsa",
        kind: "simple",
        detailed_since: null,
        created_at: "",
        updated_at: "",
      },
    ]);
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
    await updateBalanceAccount(7, { name: "CELI renommé" });
    const sql = mockExecute.mock.calls[0][0] as string;
    expect(sql).toContain("vehicle_type = $6");
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[5]).toBe("tfsa"); // preserved
  });

  it("updateBalanceAccount sets a new vehicle_type when provided", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        id: 7,
        balance_category_id: 1,
        name: "Compte",
        symbol: null,
        currency: "CAD",
        notes: null,
        is_active: 1,
        archived_at: null,
        vehicle_type: null,
        kind: "simple",
        detailed_since: null,
        created_at: "",
        updated_at: "",
      },
    ]);
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
    await updateBalanceAccount(7, { vehicle_type: "rrsp" });
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[5]).toBe("rrsp");
  });

  it("updateBalanceAccount clears vehicle_type on explicit null", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        id: 7,
        balance_category_id: 1,
        name: "Compte",
        symbol: null,
        currency: "CAD",
        notes: null,
        is_active: 1,
        archived_at: null,
        vehicle_type: "tfsa",
        kind: "simple",
        detailed_since: null,
        created_at: "",
        updated_at: "",
      },
    ]);
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
    await updateBalanceAccount(7, { vehicle_type: null });
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[5]).toBeNull();
  });

  it("updateBalanceAccount rejects an out-of-enum vehicle_type", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        id: 7,
        balance_category_id: 1,
        name: "Compte",
        symbol: null,
        currency: "CAD",
        notes: null,
        is_active: 1,
        archived_at: null,
        vehicle_type: null,
        created_at: "",
        updated_at: "",
      },
    ]);
    await expect(
      // @ts-expect-error testing runtime guard
      updateBalanceAccount(7, { vehicle_type: "truck" })
    ).rejects.toMatchObject({ code: "vehicle_type_invalid" });
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("getAccountsLatestSnapshot threads category_custom_label", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await getAccountsLatestSnapshot();
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("c.custom_label AS category_custom_label");
  });

  it("getAccountsLatestSnapshot threads vehicle_type (#204)", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await getAccountsLatestSnapshot();
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("a.vehicle_type AS vehicle_type");
  });
});

describe("updateBalanceAccount", () => {
  it("rejects when account does not exist", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await expect(updateBalanceAccount(42, { name: "x" })).rejects.toMatchObject({
      code: "account_not_found",
    });
  });

  it("normalizes empty symbol to null", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        id: 7,
        balance_category_id: 1,
        name: "Encaisse",
        symbol: "OLD",
        currency: "CAD",
        notes: null,
        is_active: 1,
        archived_at: null,
        kind: "simple",
        detailed_since: null,
        created_at: "",
        updated_at: "",
      },
    ]);
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
    await updateBalanceAccount(7, { symbol: "  " });
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[2]).toBeNull(); // symbol
  });
});

describe("archiveBalanceAccount / unarchiveBalanceAccount", () => {
  it("archives an existing account", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        id: 7,
        balance_category_id: 1,
        name: "Encaisse",
        symbol: null,
        currency: "CAD",
        notes: null,
        is_active: 1,
        archived_at: null,
        created_at: "",
        updated_at: "",
      },
    ]);
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
    await archiveBalanceAccount(7);
    const sql = mockExecute.mock.calls[0][0] as string;
    expect(sql).toContain("archived_at = CURRENT_TIMESTAMP");
    expect(sql).toContain("is_active = 0");
  });

  it("unarchives an existing account", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        id: 7,
        balance_category_id: 1,
        name: "Encaisse",
        symbol: null,
        currency: "CAD",
        notes: null,
        is_active: 0,
        archived_at: "2026-04-25 10:00:00",
        created_at: "",
        updated_at: "",
      },
    ]);
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
    await unarchiveBalanceAccount(7);
    const sql = mockExecute.mock.calls[0][0] as string;
    expect(sql).toContain("archived_at = NULL");
    expect(sql).toContain("is_active = 1");
  });
});

// -----------------------------------------------------------------------------
// Snapshots + lines (Issue #146 / Bilan #1b — simple kind only)
// -----------------------------------------------------------------------------

const FAKE_SNAPSHOT = {
  id: 5,
  snapshot_date: "2026-04-15",
  notes: null,
  created_at: "",
  updated_at: "",
};

describe("listSnapshots", () => {
  it("orders by snapshot_date DESC", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await listSnapshots();
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("FROM balance_snapshots");
    expect(sql).toContain("ORDER BY snapshot_date DESC");
  });
});

describe("getSnapshotByDate", () => {
  it("rejects empty / invalid dates with snapshot_date_required", async () => {
    await expect(getSnapshotByDate("")).rejects.toMatchObject({
      code: "snapshot_date_required",
    });
    await expect(getSnapshotByDate("2026/04/15")).rejects.toMatchObject({
      code: "snapshot_date_required",
    });
    expect(mockSelect).not.toHaveBeenCalled();
  });

  it("returns the snapshot row when found", async () => {
    mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
    const got = await getSnapshotByDate("2026-04-15");
    expect(got).toEqual(FAKE_SNAPSHOT);
    expect(mockSelect.mock.calls[0][1]).toEqual(["2026-04-15"]);
  });

  it("returns null when no row matches", async () => {
    mockSelect.mockResolvedValueOnce([]);
    expect(await getSnapshotByDate("2026-04-15")).toBeNull();
  });
});

describe("createSnapshot", () => {
  it("rejects an invalid date", async () => {
    await expect(
      createSnapshot({ snapshot_date: "  " })
    ).rejects.toMatchObject({ code: "snapshot_date_required" });
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("rejects a duplicate snapshot date with snapshot_date_taken", async () => {
    mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); // existing
    await expect(
      createSnapshot({ snapshot_date: "2026-04-15" })
    ).rejects.toMatchObject({ code: "snapshot_date_taken" });
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("inserts a new snapshot and returns its id", async () => {
    mockSelect.mockResolvedValueOnce([]); // no existing
    mockExecute.mockResolvedValueOnce({ lastInsertId: 12, rowsAffected: 1 });
    const id = await createSnapshot({
      snapshot_date: "2026-04-25",
      notes: "  monthly check  ",
    });
    expect(id).toBe(12);
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params).toEqual(["2026-04-25", "monthly check"]);
  });
});

describe("updateSnapshot", () => {
  it("rejects when snapshot does not exist", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await expect(
      updateSnapshot(999, { notes: "x" })
    ).rejects.toMatchObject({ code: "snapshot_not_found" });
  });

  it("normalizes empty notes to null", async () => {
    mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
    await updateSnapshot(5, { notes: "  " });
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[0]).toBeNull();
  });
});

describe("deleteSnapshot", () => {
  it("rejects when snapshot does not exist", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await expect(deleteSnapshot(999)).rejects.toMatchObject({
      code: "snapshot_not_found",
    });
  });

  it("deletes when found (lines cascade via FK)", async () => {
    mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
    await deleteSnapshot(5);
    expect(mockExecute).toHaveBeenCalledWith(
      "DELETE FROM balance_snapshots WHERE id = $1",
      [5]
    );
  });
});

describe("listLinesBySnapshot", () => {
  it("orders by id and filters by snapshot_id", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await listLinesBySnapshot(5);
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("FROM balance_snapshot_lines");
    expect(sql).toContain("WHERE snapshot_id = $1");
    expect(sql).toContain("ORDER BY id");
    expect(mockSelect.mock.calls[0][1]).toEqual([5]);
  });
});

describe("upsertSnapshotLines (simple kind)", () => {
  it("rejects when the parent snapshot is missing", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await expect(
      upsertSnapshotLines(99, [{ account_id: 1, value: 1000 }])
    ).rejects.toMatchObject({ code: "snapshot_not_found" });
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("rejects non-finite values with snapshot_value_invalid", async () => {
    mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
    await expect(
      upsertSnapshotLines(5, [
        { account_id: 1, value: 1000 },
        // @ts-expect-error testing runtime guard
        { account_id: 2, value: "not a number" },
      ])
    ).rejects.toMatchObject({ code: "snapshot_value_invalid" });
    // Validation happens up-front, before any mutation
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("rejects NaN and Infinity", async () => {
    mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
    await expect(
      upsertSnapshotLines(5, [{ account_id: 1, value: NaN }])
    ).rejects.toMatchObject({ code: "snapshot_value_invalid" });

    mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
    await expect(
      upsertSnapshotLines(5, [{ account_id: 1, value: Infinity }])
    ).rejects.toMatchObject({ code: "snapshot_value_invalid" });
  });

  it("clears existing lines, inserts each line with NULL quantity/unit_price, and bumps updated_at", async () => {
    mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
    // #212: the rewrite is now wrapped in BEGIN/COMMIT for holdings atomicity.
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
      .mockResolvedValueOnce({ rowsAffected: 1 }) // delete
      .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert 1
      .mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert 2
      .mockResolvedValueOnce({ rowsAffected: 1 }) // update updated_at
      .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
    await upsertSnapshotLines(5, [
      { account_id: 1, value: 1234.56 },
      { account_id: 2, value: 0 },
    ]);
    // 1st call = BEGIN, 2nd = DELETE
    expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
    expect(mockExecute.mock.calls[1][0]).toContain(
      "DELETE FROM balance_snapshot_lines"
    );
    // Inserts use literal NULL for quantity/unit_price (simple kind invariant)
    const insertSql = mockExecute.mock.calls[2][0] as string;
    expect(insertSql).toContain("INSERT INTO balance_snapshot_lines");
    expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/);
    expect(insertSql).toContain("'manual'");
    // First insert params
    expect(mockExecute.mock.calls[2][1]).toEqual([5, 1, 1234.56]);
    // Second insert params (zero is allowed)
    expect(mockExecute.mock.calls[3][1]).toEqual([5, 2, 0]);
    // UPDATE updated_at on parent snapshot, then COMMIT last.
    expect(mockExecute.mock.calls[4][0]).toContain("UPDATE balance_snapshots");
    expect(mockExecute.mock.calls[4][0]).toContain("updated_at");
    expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe(
      "COMMIT"
    );
  });

  it("clears all lines when called with an empty array", async () => {
    mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
      .mockResolvedValueOnce({ rowsAffected: 3 }) // delete only
      .mockResolvedValueOnce({ rowsAffected: 1 }) // bump updated_at
      .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
    await upsertSnapshotLines(5, []);
    // BEGIN + DELETE + UPDATE updated_at + COMMIT — no INSERTs
    expect(mockExecute).toHaveBeenCalledTimes(4);
    expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
    expect(mockExecute.mock.calls[3][0]).toBe("COMMIT");
  });
});

describe("getPreviousSnapshot", () => {
  it("returns the most recent snapshot strictly before referenceDate", async () => {
    mockSelect.mockResolvedValueOnce([
      { ...FAKE_SNAPSHOT, snapshot_date: "2026-03-15" },
    ]);
    const got = await getPreviousSnapshot("2026-04-15");
    expect(got?.snapshot_date).toBe("2026-03-15");
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("snapshot_date < $1");
    expect(sql).toContain("ORDER BY snapshot_date DESC");
    expect(sql).toContain("LIMIT 1");
  });

  it("returns null when no earlier snapshot exists", async () => {
    mockSelect.mockResolvedValueOnce([]);
    expect(await getPreviousSnapshot("2026-04-15")).toBeNull();
  });

  it("rejects an invalid reference date", async () => {
    await expect(getPreviousSnapshot("nope")).rejects.toMatchObject({
      code: "snapshot_date_required",
    });
  });
});

// -----------------------------------------------------------------------------
// Priced-kind validation (Issue #140 / Bilan #2)
// -----------------------------------------------------------------------------

describe("validateLineKindInvariants — simple kind", () => {
  it("accepts a clean simple line", () => {
    expect(() =>
      validateLineKindInvariants({ account_id: 1, value: 1234.56 })
    ).not.toThrow();
  });

  it("accepts simple kind with explicit account_kind = simple", () => {
    expect(() =>
      validateLineKindInvariants({
        account_id: 1,
        value: 0,
        account_kind: "simple",
      })
    ).not.toThrow();
  });

  it("rejects a simple line carrying a quantity", () => {
    expect(() =>
      validateLineKindInvariants({
        account_id: 1,
        value: 100,
        account_kind: "simple",
        quantity: 10,
      })
    ).toThrowError(BalanceServiceError);
  });

  it("rejects a simple line carrying a unit_price", () => {
    expect(() =>
      validateLineKindInvariants({
        account_id: 1,
        value: 100,
        account_kind: "simple",
        unit_price: 10,
      })
    ).toThrowError(BalanceServiceError);
  });

  it("rejects a non-finite value", () => {
    expect(() =>
      validateLineKindInvariants({ account_id: 1, value: NaN })
    ).toThrowError(BalanceServiceError);
    expect(() =>
      validateLineKindInvariants({ account_id: 1, value: Infinity })
    ).toThrowError(BalanceServiceError);
  });
});

describe("validateLineKindInvariants — priced kind", () => {
  const baseInput = {
    account_id: 7,
    account_kind: "priced" as const,
  };

  it("accepts a clean priced line where value === qty * price", () => {
    expect(() =>
      validateLineKindInvariants({
        ...baseInput,
        quantity: 10,
        unit_price: 25.5,
        value: 255,
      })
    ).not.toThrow();
  });

  it("rejects a priced line missing the quantity", () => {
    expect(() =>
      validateLineKindInvariants({
        ...baseInput,
        quantity: null,
        unit_price: 25.5,
        value: 255,
      })
    ).toMatchObject; // sanity, real assertion below
    expect(() =>
      validateLineKindInvariants({
        ...baseInput,
        quantity: null,
        unit_price: 25.5,
        value: 255,
      })
    ).toThrowError(BalanceServiceError);
    try {
      validateLineKindInvariants({
        ...baseInput,
        quantity: null,
        unit_price: 25.5,
        value: 255,
      });
    } catch (e) {
      expect((e as BalanceServiceError).code).toBe(
        "snapshot_priced_quantity_required"
      );
    }
  });

  it("rejects a priced line missing the unit_price", () => {
    try {
      validateLineKindInvariants({
        ...baseInput,
        quantity: 10,
        unit_price: null,
        value: 255,
      });
    } catch (e) {
      expect((e as BalanceServiceError).code).toBe(
        "snapshot_priced_unit_price_required"
      );
      return;
    }
    throw new Error("expected throw");
  });

  it("rejects a priced line where value disagrees with qty × price", () => {
    try {
      validateLineKindInvariants({
        ...baseInput,
        quantity: 10,
        unit_price: 25.5,
        // off by way more than tolerance — 255.0 expected, 999 saved
        value: 999,
      });
    } catch (e) {
      expect((e as BalanceServiceError).code).toBe(
        "snapshot_priced_value_mismatch"
      );
      return;
    }
    throw new Error("expected throw");
  });

  it("accepts a priced line within the tolerance ε", () => {
    // 12.34 × 1.07 = 13.2038 in math, but JS gives 13.2038000000000002.
    // The drift is well within ε = 0.01.
    const qty = 12.34;
    const price = 1.07;
    expect(() =>
      validateLineKindInvariants({
        ...baseInput,
        quantity: qty,
        unit_price: price,
        value: 13.2038,
      })
    ).not.toThrow();
  });

  it("rejects a priced line just outside the tolerance ε", () => {
    // expected = 100, threshold ε = 0.01 → 100.011 fails, 100.005 passes.
    expect(() =>
      validateLineKindInvariants({
        ...baseInput,
        quantity: 10,
        unit_price: 10,
        value: 100 + PRICED_VALUE_TOLERANCE * 1.5,
      })
    ).toThrowError(BalanceServiceError);
    expect(() =>
      validateLineKindInvariants({
        ...baseInput,
        quantity: 10,
        unit_price: 10,
        value: 100 + PRICED_VALUE_TOLERANCE * 0.5,
      })
    ).not.toThrow();
  });

  it("rejects priced when quantity is non-finite", () => {
    expect(() =>
      validateLineKindInvariants({
        ...baseInput,
        quantity: NaN,
        unit_price: 10,
        value: 100,
      })
    ).toThrowError(BalanceServiceError);
  });
});

describe("upsertSnapshotLines — priced kind", () => {
  it("rejects a priced line where qty × price drifts beyond ε", async () => {
    mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
    await expect(
      upsertSnapshotLines(5, [
        {
          account_id: 7,
          account_kind: "priced",
          quantity: 10,
          unit_price: 25,
          value: 999, // wrong on purpose
        },
      ])
    ).rejects.toMatchObject({ code: "snapshot_priced_value_mismatch" });
    // No DB mutation when validation fails up-front.
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("inserts a priced line with quantity + unit_price + value", async () => {
    mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
      .mockResolvedValueOnce({ rowsAffected: 1 }) // delete
      .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert
      .mockResolvedValueOnce({ rowsAffected: 1 }) // bump updated_at
      .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
    await upsertSnapshotLines(5, [
      {
        account_id: 7,
        account_kind: "priced",
        quantity: 10,
        unit_price: 25.5,
        value: 255,
      },
    ]);
    const insertSql = mockExecute.mock.calls[2][0] as string;
    expect(insertSql).toContain("INSERT INTO balance_snapshot_lines");
    // Priced inserts use parameter placeholders for qty/price (not literal NULLs)
    expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/);
    expect(mockExecute.mock.calls[2][1]).toEqual([5, 7, 10, 25.5, 255]);
  });

  it("supports a mix of simple + priced lines in the same batch", async () => {
    mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
      .mockResolvedValueOnce({ rowsAffected: 1 }) // delete
      .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert simple
      .mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert priced
      .mockResolvedValueOnce({ rowsAffected: 1 }) // bump updated_at
      .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
    await upsertSnapshotLines(5, [
      { account_id: 1, value: 1000 },
      {
        account_id: 7,
        account_kind: "priced",
        quantity: 10,
        unit_price: 50,
        value: 500,
      },
    ]);
    // Simple insert uses literal NULLs for qty/price
    expect(mockExecute.mock.calls[2][0] as string).toMatch(
      /VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/
    );
    expect(mockExecute.mock.calls[2][1]).toEqual([5, 1, 1000]);
    // Priced insert uses placeholders
    expect(mockExecute.mock.calls[3][0] as string).toMatch(
      /VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/
    );
    expect(mockExecute.mock.calls[3][1]).toEqual([5, 7, 10, 50, 500]);
  });
});

// -----------------------------------------------------------------------------
// saveSnapshotAtomic (#176) — atomic BEGIN/COMMIT/ROLLBACK orchestration
// -----------------------------------------------------------------------------

describe("saveSnapshotAtomic — new mode", () => {
  it("issues BEGIN before any write and COMMIT once everything succeeds", async () => {
    // Order: SELECT dup-check → INSERT snapshot → DELETE lines → INSERT line → UPDATE → COMMIT
    mockSelect.mockResolvedValueOnce([]); // no duplicate
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
      .mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot
      .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
      .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // INSERT line
      .mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at
      .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT

    const res = await saveSnapshotAtomic({
      existingSnapshotId: null,
      snapshot_date: "2026-04-30",
      lines: [{ account_id: 1, value: 1000 }],
    });

    expect(res.snapshotId).toBe(42);
    // First execute is BEGIN
    expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
    // INSERT snapshot is second
    expect(mockExecute.mock.calls[1][0]).toContain(
      "INSERT INTO balance_snapshots"
    );
    // DELETE lines, INSERT line, UPDATE updated_at all happen between BEGIN and COMMIT
    expect(mockExecute.mock.calls[2][0]).toContain(
      "DELETE FROM balance_snapshot_lines"
    );
    expect(mockExecute.mock.calls[3][0]).toContain(
      "INSERT INTO balance_snapshot_lines"
    );
    expect(mockExecute.mock.calls[4][0]).toContain("UPDATE balance_snapshots");
    // Last execute is COMMIT
    expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe(
      "COMMIT"
    );
    // No ROLLBACK on success
    expect(
      mockExecute.mock.calls.some((c: unknown[]) => c[0] === "ROLLBACK")
    ).toBe(false);
  });

  it("rejects when a snapshot already exists at this date (snapshot_date_taken) and ROLLBACKs", async () => {
    mockSelect.mockResolvedValueOnce([{ id: 7 }]); // duplicate found
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
      .mockResolvedValueOnce({ rowsAffected: 0 }); // ROLLBACK

    await expect(
      saveSnapshotAtomic({
        existingSnapshotId: null,
        snapshot_date: "2026-04-30",
        lines: [{ account_id: 1, value: 1000 }],
      })
    ).rejects.toMatchObject({ code: "snapshot_date_taken" });

    // BEGIN ran, then ROLLBACK because the duplicate threw mid-transaction.
    expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
    expect(mockExecute.mock.calls[1][0]).toBe("ROLLBACK");
    // No INSERT INTO balance_snapshots happened.
    expect(
      mockExecute.mock.calls.some((c: unknown[]) =>
        String(c[0]).includes("INSERT INTO balance_snapshots")
      )
    ).toBe(false);
  });

  it("ROLLBACKs and re-throws when a line INSERT fails (no orphan snapshot persists)", async () => {
    mockSelect.mockResolvedValueOnce([]); // no duplicate
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
      .mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot
      .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
      .mockRejectedValueOnce(new Error("simulated FK violation")) // INSERT line fails
      .mockResolvedValueOnce({ rowsAffected: 0 }); // ROLLBACK

    await expect(
      saveSnapshotAtomic({
        existingSnapshotId: null,
        snapshot_date: "2026-04-30",
        lines: [{ account_id: 999, value: 1000 }],
      })
    ).rejects.toThrow("simulated FK violation");

    // BEGIN happened, ROLLBACK was the last call — no COMMIT.
    expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
    expect(
      mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]
    ).toBe("ROLLBACK");
    expect(
      mockExecute.mock.calls.some((c: unknown[]) => c[0] === "COMMIT")
    ).toBe(false);
  });

  it("rejects validation failures BEFORE BEGIN — no transaction is opened", async () => {
    await expect(
      saveSnapshotAtomic({
        existingSnapshotId: null,
        snapshot_date: "2026-04-30",
        // Priced line missing quantity should fail validation before any DB write.
        lines: [
          { account_id: 1, value: 100, account_kind: "priced", unit_price: 10 },
        ],
      })
    ).rejects.toMatchObject({ code: "snapshot_priced_quantity_required" });
    // Pre-DB validation: no BEGIN, no SELECT, no execute at all.
    expect(mockExecute).not.toHaveBeenCalled();
    expect(mockSelect).not.toHaveBeenCalled();
  });
});

describe("saveSnapshotAtomic — edit mode", () => {
  it("skips INSERT INTO balance_snapshots when existingSnapshotId is provided", async () => {
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
      .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
      .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // INSERT line
      .mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at
      .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT

    const res = await saveSnapshotAtomic({
      existingSnapshotId: 5,
      snapshot_date: "2026-04-30",
      lines: [{ account_id: 1, value: 1000 }],
    });

    expect(res.snapshotId).toBe(5);
    // No SELECT (no duplicate check in edit mode), no INSERT INTO balance_snapshots.
    expect(mockSelect).not.toHaveBeenCalled();
    expect(
      mockExecute.mock.calls.some((c: unknown[]) =>
        String(c[0]).includes("INSERT INTO balance_snapshots")
      )
    ).toBe(false);
    // BEGIN / DELETE / INSERT line / UPDATE / COMMIT
    expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
    expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe(
      "COMMIT"
    );
  });

  it("moves the snapshot date in-txn and preserves the existing lines (Issue #200)", async () => {
    // In edit mode with moveToDate set: BEGIN → collision SELECT (free) →
    // UPDATE date → DELETE lines → INSERT line → UPDATE updated_at → COMMIT.
    mockSelect.mockResolvedValueOnce([]); // collision check → target date free
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
      .mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE snapshot_date
      .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
      .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // INSERT line (preserved)
      .mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at
      .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT

    const res = await saveSnapshotAtomic({
      existingSnapshotId: 5,
      snapshot_date: "2026-04-15",
      moveToDate: "2026-05-20",
      lines: [{ account_id: 1, value: 1234 }],
    });

    expect(res.snapshotId).toBe(5);
    // Collision SELECT excludes the moved snapshot's own id.
    const clashParams = mockSelect.mock.calls[0][1] as unknown[];
    expect(clashParams).toEqual(["2026-05-20", 5]);
    // First execute is BEGIN, then the date UPDATE happens before the lines.
    expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
    expect(mockExecute.mock.calls[1][0]).toContain("SET snapshot_date = $1");
    expect(mockExecute.mock.calls[1][1]).toEqual(["2026-05-20", 5]);
    // Lines are still rewritten (preserved): DELETE then INSERT.
    expect(mockExecute.mock.calls[2][0]).toContain(
      "DELETE FROM balance_snapshot_lines"
    );
    expect(mockExecute.mock.calls[3][0]).toContain(
      "INSERT INTO balance_snapshot_lines"
    );
    // Commits, never rolls back.
    expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe(
      "COMMIT"
    );
    expect(
      mockExecute.mock.calls.some((c: unknown[]) => c[0] === "ROLLBACK")
    ).toBe(false);
  });

  it("rolls back and throws snapshot_date_exists when moveToDate collides with another snapshot (Issue #200)", async () => {
    mockSelect.mockResolvedValueOnce([{ id: 42 }]); // collision: another snapshot at the target
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
      .mockResolvedValueOnce({ rowsAffected: 0 }); // ROLLBACK

    await expect(
      saveSnapshotAtomic({
        existingSnapshotId: 5,
        snapshot_date: "2026-04-15",
        moveToDate: "2026-05-20",
        lines: [{ account_id: 1, value: 1234 }],
      })
    ).rejects.toMatchObject({ code: "snapshot_date_exists" });

    // BEGIN ran, then ROLLBACK — no date UPDATE, no line writes committed.
    expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
    expect(mockExecute.mock.calls[1][0]).toBe("ROLLBACK");
    expect(
      mockExecute.mock.calls.some((c: unknown[]) =>
        String(c[0]).includes("SET snapshot_date")
      )
    ).toBe(false);
    expect(
      mockExecute.mock.calls.some((c: unknown[]) => c[0] === "COMMIT")
    ).toBe(false);
  });
});

// -----------------------------------------------------------------------------
// Time-series aggregators (Issue #141 / Bilan #3)
// -----------------------------------------------------------------------------

describe("getSnapshotTotalsByDate", () => {
  it("returns an empty array on an empty DB", async () => {
    mockSelect.mockResolvedValueOnce([]);
    expect(await getSnapshotTotalsByDate()).toEqual([]);
  });

  it("aggregates SUM(value) and orders ASC by snapshot_date", async () => {
    mockSelect.mockResolvedValueOnce([
      { snapshot_date: "2026-01-31", total: 1000 },
      { snapshot_date: "2026-02-28", total: 1100 },
      { snapshot_date: "2026-03-31", total: 1250 },
    ]);
    const out = await getSnapshotTotalsByDate();
    expect(out).toEqual([
      { snapshot_date: "2026-01-31", total: 1000 },
      { snapshot_date: "2026-02-28", total: 1100 },
      { snapshot_date: "2026-03-31", total: 1250 },
    ]);
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("FROM balance_snapshots");
    expect(sql).toContain("LEFT JOIN balance_snapshot_lines");
    expect(sql).toContain("GROUP BY s.snapshot_date");
    expect(sql).toContain("ORDER BY s.snapshot_date ASC");
    // Empty range → no WHERE clause + no params
    expect(sql).not.toContain("WHERE");
    expect(mockSelect.mock.calls[0][1]).toEqual([]);
  });

  it("applies an inclusive [from, to] date range filter", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await getSnapshotTotalsByDate({ from: "2026-01-01", to: "2026-03-31" });
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("WHERE");
    expect(sql).toContain("s.snapshot_date >=");
    expect(sql).toContain("s.snapshot_date <=");
    expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-03-31"]);
  });

  it("supports an open-ended `from` only", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await getSnapshotTotalsByDate({ from: "2026-01-01" });
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("s.snapshot_date >=");
    expect(sql).not.toContain("s.snapshot_date <=");
    expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]);
  });
});

describe("getSnapshotTotalsByCategoryAndDate", () => {
  it("returns [] on empty DB", async () => {
    mockSelect.mockResolvedValueOnce([]);
    expect(await getSnapshotTotalsByCategoryAndDate()).toEqual([]);
  });

  it("buckets multiple category rows under the same snapshot_date", async () => {
    mockSelect.mockResolvedValueOnce([
      { snapshot_date: "2026-01-31", category_key: "cash", total: 500 },
      { snapshot_date: "2026-01-31", category_key: "tfsa", total: 1500 },
      { snapshot_date: "2026-02-28", category_key: "cash", total: 700 },
      { snapshot_date: "2026-02-28", category_key: "tfsa", total: 1700 },
    ]);
    const out = await getSnapshotTotalsByCategoryAndDate();
    expect(out).toEqual([
      {
        snapshot_date: "2026-01-31",
        byCategory: { cash: 500, tfsa: 1500 },
      },
      {
        snapshot_date: "2026-02-28",
        byCategory: { cash: 700, tfsa: 1700 },
      },
    ]);
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("INNER JOIN balance_snapshot_lines");
    expect(sql).toContain("INNER JOIN balance_accounts");
    expect(sql).toContain("INNER JOIN balance_categories");
    expect(sql).toContain("GROUP BY s.snapshot_date, c.key");
  });

  it("applies date range params when supplied", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await getSnapshotTotalsByCategoryAndDate({
      from: "2026-01-01",
      to: "2026-12-31",
    });
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("WHERE");
    expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-12-31"]);
  });
});

describe("getSnapshotTotalsByVehicleAndDate (#204)", () => {
  it("returns [] on empty DB", async () => {
    mockSelect.mockResolvedValueOnce([]);
    expect(await getSnapshotTotalsByVehicleAndDate()).toEqual([]);
  });

  it("COALESCEs NULL envelopes into a single 'none' bucket", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await getSnapshotTotalsByVehicleAndDate();
    const sql = mockSelect.mock.calls[0][0] as string;
    // HIGH caveat: the GROUP BY must coalesce NULL → 'none' so null-envelope
    // accounts never produce a SQL NULL key.
    expect(sql).toContain("COALESCE(a.vehicle_type, 'none')");
    expect(sql).toContain("GROUP BY s.snapshot_date, COALESCE(a.vehicle_type, 'none')");
    expect(sql).toContain("INNER JOIN balance_accounts");
  });

  it("buckets a mix of envelopes plus the 'none' bucket per snapshot_date", async () => {
    mockSelect.mockResolvedValueOnce([
      { snapshot_date: "2026-01-31", vehicle_key: "none", total: 500 },
      { snapshot_date: "2026-01-31", vehicle_key: "tfsa", total: 1500 },
      { snapshot_date: "2026-01-31", vehicle_key: "rrsp", total: 3000 },
      { snapshot_date: "2026-02-28", vehicle_key: "none", total: 700 },
      { snapshot_date: "2026-02-28", vehicle_key: "tfsa", total: 1700 },
    ]);
    const out = await getSnapshotTotalsByVehicleAndDate();
    expect(out).toEqual([
      {
        snapshot_date: "2026-01-31",
        byVehicle: { none: 500, tfsa: 1500, rrsp: 3000 },
      },
      {
        snapshot_date: "2026-02-28",
        byVehicle: { none: 700, tfsa: 1700 },
      },
    ]);
  });

  it("handles a snapshot composed solely of null-envelope accounts", async () => {
    mockSelect.mockResolvedValueOnce([
      { snapshot_date: "2026-03-31", vehicle_key: "none", total: 4200 },
    ]);
    const out = await getSnapshotTotalsByVehicleAndDate();
    expect(out).toEqual([
      { snapshot_date: "2026-03-31", byVehicle: { none: 4200 } },
    ]);
  });

  it("applies date range params when supplied", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await getSnapshotTotalsByVehicleAndDate({
      from: "2026-01-01",
      to: "2026-12-31",
    });
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("WHERE");
    expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-12-31"]);
  });
});

describe("getAccountsLatestSnapshot", () => {
  it("returns [] when there are no active accounts", async () => {
    mockSelect.mockResolvedValueOnce([]);
    expect(await getAccountsLatestSnapshot()).toEqual([]);
  });

  it("returns one row per active account joined with category metadata", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        account_id: 1,
        account_name: "BMO chequing",
        symbol: null,
        balance_category_id: 10,
        category_key: "cash",
        category_i18n_key: "balance.category.cash",
        category_kind: "simple",
        latest_snapshot_date: "2026-03-31",
        latest_value: 1234.56,
      },
      {
        account_id: 2,
        account_name: "Wealthsimple TFSA",
        symbol: null,
        balance_category_id: 11,
        category_key: "tfsa",
        category_i18n_key: "balance.category.tfsa",
        category_kind: "simple",
        latest_snapshot_date: null,
        latest_value: null,
      },
    ]);
    const out = await getAccountsLatestSnapshot();
    expect(out).toHaveLength(2);
    expect(out[0].latest_value).toBe(1234.56);
    expect(out[1].latest_value).toBeNull();
    const sql = mockSelect.mock.calls[0][0] as string;
    // Filter: only active, non-archived accounts.
    expect(sql).toContain("a.is_active = 1");
    expect(sql).toContain("a.archived_at IS NULL");
    // LEFT JOIN-equivalent: scalar subquery so accounts with no lines still surface.
    expect(sql).toContain("ORDER BY s.snapshot_date DESC");
    expect(sql).toContain("LIMIT 1");
    // #216: account kind + latest line id surfaced for the drill-down.
    expect(sql).toContain("a.kind AS kind");
    expect(sql).toContain("AS latest_snapshot_line_id");
  });
});

describe("getAccountsPeriodAnchor", () => {
  it("queries with a from-only filter", async () => {
    mockSelect.mockResolvedValueOnce([
      { account_id: 1, anchor_snapshot_date: "2026-01-31", anchor_value: 1000 },
    ]);
    const rows = await getAccountsPeriodAnchor({ from: "2026-01-01" });
    expect(rows).toHaveLength(1);
    expect(rows[0].anchor_value).toBe(1000);
    const sql = mockSelect.mock.calls[0][0] as string;
    // Window function: ROW_NUMBER partitioned by account_id, earliest first.
    expect(sql).toContain("ROW_NUMBER()");
    expect(sql).toContain("PARTITION BY l.account_id");
    expect(sql).toContain("ORDER BY s.snapshot_date ASC");
    expect(sql).toContain("WHERE rn = 1");
    // Old aggregate-in-WHERE pattern must be gone (regression guard, #175).
    expect(sql).not.toContain("MIN(s.snapshot_date)");
    expect(sql).not.toContain("GROUP BY l.account_id");
    expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]);
  });

  it("queries with both from and to", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await getAccountsPeriodAnchor({ from: "2026-01-01", to: "2026-12-31" });
    expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-12-31"]);
  });

  it("works with an empty range (open-ended)", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await getAccountsPeriodAnchor({});
    const sql = mockSelect.mock.calls[0][0] as string;
    // No WHERE clause when neither bound is set.
    expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/);
  });

  it("returns earliest snapshot per account within range", async () => {
    // Multiple accounts, each with multiple snapshots in the window.
    // The DB returns one row per account (the rn = 1 row), so the mocked
    // result mirrors that contract.
    mockSelect.mockResolvedValueOnce([
      { account_id: 1, anchor_snapshot_date: "2026-02-29", anchor_value: 1500 },
      { account_id: 2, anchor_snapshot_date: "2026-03-31", anchor_value: 2700 },
    ]);
    const rows = await getAccountsPeriodAnchor({
      from: "2026-02-01",
      to: "2026-06-30",
    });
    expect(rows).toEqual([
      { account_id: 1, anchor_snapshot_date: "2026-02-29", anchor_value: 1500 },
      { account_id: 2, anchor_snapshot_date: "2026-03-31", anchor_value: 2700 },
    ]);
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("ROW_NUMBER()");
    expect(sql).toContain("PARTITION BY l.account_id");
    expect(sql).toContain("ORDER BY s.snapshot_date ASC");
    expect(sql).toContain("WHERE rn = 1");
    expect(mockSelect.mock.calls[0][1]).toEqual(["2026-02-01", "2026-06-30"]);
  });

  it("returns [] for an empty window (no snapshots in range)", async () => {
    mockSelect.mockResolvedValueOnce([]);
    const rows = await getAccountsPeriodAnchor({
      from: "2099-01-01",
      to: "2099-12-31",
    });
    expect(rows).toEqual([]);
  });

  // Regression: /balance load (issue #175) used to throw "misuse of aggregate
  // function MIN()" because MIN was used inside the WHERE of a scalar
  // subquery. With ROW_NUMBER() the query is plain SQLite — assert the
  // service forwards rows from db.select without throwing.
  it("regression #175: loads without SQLite aggregate misuse error", async () => {
    mockSelect.mockResolvedValueOnce([
      { account_id: 1, anchor_snapshot_date: "2026-01-15", anchor_value: 500 },
    ]);
    await expect(
      getAccountsPeriodAnchor({ from: "2026-01-01", to: "2026-12-31" })
    ).resolves.toEqual([
      { account_id: 1, anchor_snapshot_date: "2026-01-15", anchor_value: 500 },
    ]);
    const sql = mockSelect.mock.calls[0][0] as string;
    // The exact pattern that triggered the SQLite error must not reappear.
    expect(sql).not.toMatch(/=\s*MIN\(s\.snapshot_date\)/);
  });
});

// -----------------------------------------------------------------------------
// Returns + transfers (Issue #142)
// -----------------------------------------------------------------------------

describe("computeAccountReturn", () => {
  beforeEach(() => {
    vi.mocked(loadProfiles).mockReset();
    vi.mocked(invoke).mockReset();
  });

  it("invokes the Tauri command with the active profile's db_filename", async () => {
    vi.mocked(loadProfiles).mockResolvedValueOnce({
      active_profile_id: "p1",
      profiles: [
        {
          id: "p1",
          name: "Max",
          color: "#fff",
          pin_hash: null,
          db_filename: "max.db",
          created_at: "0",
        },
      ],
    });
    const fakeReturn = {
      value_start: 1000,
      value_end: 1100,
      net_contributions: 0,
      return_pct: 0.1,
      annualized_pct: 0.42,
      is_partial: false,
      has_no_transfers_warning: true,
    };
    vi.mocked(invoke).mockResolvedValueOnce(fakeReturn);

    const out = await computeAccountReturn(7, "2026-01-01", "2026-04-01");

    expect(out).toEqual(fakeReturn);
    expect(invoke).toHaveBeenCalledWith("compute_account_return", {
      dbFilename: "max.db",
      accountId: 7,
      periodStart: "2026-01-01",
      periodEnd: "2026-04-01",
    });
  });

  it("rejects malformed period dates before invoking the command", async () => {
    vi.mocked(loadProfiles).mockResolvedValueOnce({
      active_profile_id: "p1",
      profiles: [
        {
          id: "p1",
          name: "Max",
          color: "#fff",
          pin_hash: null,
          db_filename: "max.db",
          created_at: "0",
        },
      ],
    });
    await expect(
      computeAccountReturn(1, "not-a-date", "2026-04-01")
    ).rejects.toBeInstanceOf(BalanceServiceError);
    expect(invoke).not.toHaveBeenCalled();
  });

  it("throws transfer_active_profile_unknown when no active profile resolves", async () => {
    vi.mocked(loadProfiles).mockResolvedValueOnce({
      active_profile_id: "missing",
      profiles: [],
    });
    await expect(
      computeAccountReturn(1, "2026-01-01", "2026-04-01")
    ).rejects.toMatchObject({ code: "transfer_active_profile_unknown" });
    expect(invoke).not.toHaveBeenCalled();
  });
});

describe("suggestTransferDirection", () => {
  it("maps negative bank amounts to 'in' (money left bank → arrived in account)", () => {
    expect(suggestTransferDirection(-100)).toBe("in");
  });
  it("maps positive bank amounts to 'out' (money came back from account)", () => {
    expect(suggestTransferDirection(50)).toBe("out");
  });
  it("treats zero as 'out' as a deterministic fallback", () => {
    expect(suggestTransferDirection(0)).toBe("out");
  });
});

describe("linkTransfer", () => {
  it("rejects an invalid direction without touching the DB", async () => {
    await expect(
      // @ts-expect-error testing runtime guard
      linkTransfer(1, 2, "sideways")
    ).rejects.toBeInstanceOf(BalanceServiceError);
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("guards against duplicate links with a typed error", async () => {
    mockSelect.mockResolvedValueOnce([{ id: 5 }]);
    await expect(linkTransfer(1, 2, "in")).rejects.toMatchObject({
      code: "transfer_already_linked",
    });
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("inserts and returns the new transfer id", async () => {
    mockSelect.mockResolvedValueOnce([]);
    mockExecute.mockResolvedValueOnce({ lastInsertId: 99, rowsAffected: 1 });
    const id = await linkTransfer(1, 2, "out", "  manual ");
    expect(id).toBe(99);
    const sql = mockExecute.mock.calls[0][0] as string;
    expect(sql).toContain("INSERT INTO balance_account_transfers");
    expect(mockExecute.mock.calls[0][1]).toEqual([1, 2, "out", "manual"]);
  });

  it("normalizes empty notes to null", async () => {
    mockSelect.mockResolvedValueOnce([]);
    mockExecute.mockResolvedValueOnce({ lastInsertId: 1, rowsAffected: 1 });
    await linkTransfer(1, 2, "in", "   ");
    expect(mockExecute.mock.calls[0][1][3]).toBeNull();
  });
});

describe("unlinkTransfer", () => {
  it("throws transfer_not_linked when no row was deleted", async () => {
    mockExecute.mockResolvedValueOnce({ lastInsertId: 0, rowsAffected: 0 });
    await expect(unlinkTransfer(1, 2)).rejects.toMatchObject({
      code: "transfer_not_linked",
    });
  });

  it("succeeds when one row is deleted", async () => {
    mockExecute.mockResolvedValueOnce({ lastInsertId: 0, rowsAffected: 1 });
    await expect(unlinkTransfer(1, 2)).resolves.toBeUndefined();
    expect(mockExecute.mock.calls[0][1]).toEqual([1, 2]);
  });
});

describe("listAccountTransfers", () => {
  it("filters by account_id only when no date range is supplied", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await listAccountTransfers(7);
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("FROM balance_account_transfers bat");
    expect(sql).toContain("JOIN transactions t");
    expect(sql).toContain("JOIN balance_accounts a");
    expect(sql).toContain("WHERE bat.account_id = $1");
    expect(sql).not.toContain("t.date >=");
    expect(mockSelect.mock.calls[0][1]).toEqual([7]);
  });

  it("appends inclusive date bounds when supplied", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await listAccountTransfers(7, { from: "2026-01-01", to: "2026-04-01" });
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("t.date >=");
    expect(sql).toContain("t.date <=");
    expect(mockSelect.mock.calls[0][1]).toEqual([7, "2026-01-01", "2026-04-01"]);
  });
});

describe("listLinkedTransactionIds", () => {
  it("returns a Set of transaction ids", async () => {
    mockSelect.mockResolvedValueOnce([
      { transaction_id: 5 },
      { transaction_id: 12 },
    ]);
    const ids = await listLinkedTransactionIds();
    expect(ids).toBeInstanceOf(Set);
    expect(ids.has(5)).toBe(true);
    expect(ids.has(12)).toBe(true);
    expect(ids.size).toBe(2);
  });
});

describe("listAllLinkedTransfersForTooltip", () => {
  it("groups multiple links per transaction id", async () => {
    mockSelect.mockResolvedValueOnce([
      { transaction_id: 1, account_id: 10, account_name: "TFSA", direction: "in" },
      { transaction_id: 1, account_id: 20, account_name: "RRSP", direction: "out" },
      { transaction_id: 2, account_id: 10, account_name: "TFSA", direction: "in" },
    ]);
    const map = await listAllLinkedTransfersForTooltip();
    expect(map.get(1)).toHaveLength(2);
    expect(map.get(2)).toHaveLength(1);
    expect(map.get(1)?.[0].account_name).toBe("TFSA");
  });
});

describe("isLinkedTransactionFkError", () => {
  it("matches the canonical SQLite FK error text", () => {
    expect(
      isLinkedTransactionFkError(new Error("FOREIGN KEY constraint failed"))
    ).toBe(true);
  });

  it("matches the wrapped tauri-plugin-sql variant", () => {
    expect(
      isLinkedTransactionFkError(
        new Error("code: 787, message: FOREIGN KEY constraint failed")
      )
    ).toBe(true);
  });

  it("does not match unrelated errors", () => {
    expect(isLinkedTransactionFkError(new Error("something else"))).toBe(false);
    expect(isLinkedTransactionFkError(undefined)).toBe(false);
  });
});

// -----------------------------------------------------------------------------
// prices namespace (Issue #156 / Bilan #5)
// -----------------------------------------------------------------------------

import { prices } from "./balance.service";

const FAKE_PRICE_RESPONSE = {
  symbol: "AAPL",
  date: "2026-04-25",
  price: 173.45,
  currency: "USD",
  source: "yahoo",
  cached: false,
  actual_date: null,
  fetched_at: "2026-04-25T14:32:11Z",
};

describe("balance.service.prices", () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.mocked(invoke).mockReset();
    prices.__resetForTests();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  // 1. Happy path 200
  it("fetchPrice happy path returns ok:true with price fields", async () => {
    vi.mocked(invoke).mockResolvedValueOnce(FAKE_PRICE_RESPONSE);

    const result = await prices.fetchPrice("AAPL", "2026-04-25");

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.symbol).toBe("AAPL");
      expect(result.date).toBe("2026-04-25");
      expect(result.price).toBe(173.45);
      expect(result.currency).toBe("USD");
      expect(result.source).toBe("yahoo");
      expect(result.cached).toBe(false);
    }
    expect(invoke).toHaveBeenCalledTimes(1);
    expect(invoke).toHaveBeenCalledWith("fetch_price", {
      symbol: "AAPL",
      date: "2026-04-25",
    });
  });

  // 2. 401 (auth) — no retry
  it("fetchPrice auth error returns ok:false code:auth with 1 invoke call", async () => {
    vi.mocked(invoke).mockRejectedValueOnce('{"code":"auth"}');

    const promise = prices.fetchPrice("AAPL", "2026-04-25");
    await vi.runAllTimersAsync();
    const result = await promise;

    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error.code).toBe("auth");
      expect(result.error.i18nKey).toBe(
        "balance.priceFetching.errors.authFailed"
      );
    }
    expect(invoke).toHaveBeenCalledTimes(1);
  });

  // 3. 403 premium_required — no retry
  it("fetchPrice premium_required returns immediately without retry", async () => {
    vi.mocked(invoke).mockRejectedValueOnce('{"code":"premium_required"}');

    const promise = prices.fetchPrice("AAPL", "2026-04-25");
    await vi.runAllTimersAsync();
    const result = await promise;

    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error.code).toBe("premium_required");
      expect(result.error.i18nKey).toBe(
        "balance.priceFetching.errors.premiumRequired"
      );
    }
    expect(invoke).toHaveBeenCalledTimes(1);
  });

  // 4. 404 symbol_not_found — no retry
  it("fetchPrice symbol_not_found returns immediately without retry", async () => {
    vi.mocked(invoke).mockRejectedValueOnce('{"code":"symbol_not_found"}');

    const promise = prices.fetchPrice("AAPL", "2026-04-25");
    await vi.runAllTimersAsync();
    const result = await promise;

    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error.code).toBe("symbol_not_found");
      expect(result.error.i18nKey).toBe(
        "balance.priceFetching.errors.symbolNotFound"
      );
    }
    expect(invoke).toHaveBeenCalledTimes(1);
  });

  // 5. 429 rate_limit — no retry, carries retry_after_s
  it("fetchPrice rate_limit 429 returns ok:false with retry_after_s, no retry", async () => {
    vi.mocked(invoke).mockRejectedValueOnce(
      '{"code":"rate_limit","retry_after_s":30}'
    );

    const promise = prices.fetchPrice("AAPL", "2026-04-25");
    await vi.runAllTimersAsync();
    const result = await promise;

    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error.code).toBe("rate_limit");
      if (result.error.code === "rate_limit") {
        expect(result.error.retry_after_s).toBe(30);
        expect(result.error.i18nKey).toBe(
          "balance.priceFetching.errors.rateLimit"
        );
      }
    }
    expect(invoke).toHaveBeenCalledTimes(1);
  });

  // 6. 5xx provider_unavailable — 3 retries with 2/4/8s backoff (4 total calls)
  it("fetchPrice provider_unavailable retries 3 times with 2/4/8s backoff", async () => {
    vi.mocked(invoke).mockRejectedValue('{"code":"provider_unavailable"}');

    const promise = prices.fetchPrice("AAPL", "2026-04-25");

    // Advance through all retry delays: 2s + 4s + 8s = 14s total
    await vi.advanceTimersByTimeAsync(2000); // retry 1 fires after 2s
    await vi.advanceTimersByTimeAsync(4000); // retry 2 fires after 4s
    await vi.advanceTimersByTimeAsync(8000); // retry 3 fires after 8s
    await vi.runAllTimersAsync();

    const result = await promise;

    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error.code).toBe("provider_unavailable");
      expect(result.error.i18nKey).toBe(
        "balance.priceFetching.errors.serverUnavailable"
      );
    }
    // 1 initial + 3 retries = 4 total invoke calls
    expect(invoke).toHaveBeenCalledTimes(4);
  });

  // 7. In-flight deduplication
  it("fetchPrice dedup: two parallel calls with same key → only one invoke", async () => {
    vi.mocked(invoke).mockResolvedValueOnce(FAKE_PRICE_RESPONSE);

    const p1 = prices.fetchPrice("AAPL", "2026-04-25");
    const p2 = prices.fetchPrice("AAPL", "2026-04-25");

    await vi.runAllTimersAsync();

    const [r1, r2] = await Promise.all([p1, p2]);

    expect(invoke).toHaveBeenCalledTimes(1);
    expect(r1.ok).toBe(true);
    expect(r2.ok).toBe(true);
    if (r1.ok && r2.ok) {
      expect(r1.price).toBe(r2.price);
    }
  });

  // 8. Rate-limit pacing: calls are serialized through _enforceRateLimit,
  // so 3 concurrent calls result in 3 sequential invoke calls, each separated
  // by at least MIN_INTERVAL_MS (2s). We verify that the setTimeout inside
  // _enforceRateLimit is actually called with the correct delay.
  it("fetchPrice rate-limit pacing: each call waits at least 2s after the previous", async () => {
    const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");

    vi.mocked(invoke).mockResolvedValue(FAKE_PRICE_RESPONSE);

    // Start 3 calls for different symbols (no dedup). Only the first fires
    // immediately; the others queue up behind the rate-limit.
    const p1 = prices.fetchPrice("AAPL", "2026-01-01");
    const p2 = prices.fetchPrice("MSFT", "2026-01-01");
    const p3 = prices.fetchPrice("TSLA", "2026-01-01");

    // Advance enough time for all 3 to complete (3 × 2s = 6s).
    await vi.advanceTimersByTimeAsync(6000);
    await vi.runAllTimersAsync();

    await Promise.all([p1, p2, p3]);

    // All 3 invoke calls must have been made.
    expect(invoke).toHaveBeenCalledTimes(3);

    // At least 2 setTimeout calls for the rate-limit waits (p2 and p3 must wait).
    // The actual delay argument should be ~2000ms (or close to it, as the
    // timer fires slightly early in fake-timer environments).
    const rateLimitTimers = setTimeoutSpy.mock.calls.filter(
      ([, delay]) => typeof delay === "number" && delay > 0 && delay <= 2000
    );
    expect(rateLimitTimers.length).toBeGreaterThanOrEqual(2);

    setTimeoutSpy.mockRestore();
  });

  // 9. Session cap: 101st call returns session_cap_reached without calling invoke
  it("fetchPrice session cap: 101st call returns session_cap_reached", async () => {
    // Set up invoke to always resolve successfully
    vi.mocked(invoke).mockResolvedValue(FAKE_PRICE_RESPONSE);

    // Fire 100 successful calls to fill the session cap.
    // We bypass the rate-limit by advancing time enough between each.
    for (let i = 0; i < 100; i++) {
      const p = prices.fetchPrice(`SYM${i}`, "2026-01-01");
      await vi.advanceTimersByTimeAsync(2000);
      await p;
    }

    // The 101st call should immediately return session_cap_reached.
    vi.mocked(invoke).mockClear(); // reset call counter
    const result = await prices.fetchPrice("EXTRA", "2026-01-01");

    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error.code).toBe("session_cap_reached");
      expect(result.error.i18nKey).toBe(
        "balance.priceFetching.errors.sessionCapReached"
      );
    }
    // invoke must NOT have been called for the 101st request
    expect(invoke).not.toHaveBeenCalled();
  });
});

// =============================================================================
// Détail par titre (Issue #212) — securities CRUD, detailed save, holdings,
// unrealized gain, detailed→simple guard.
// =============================================================================

describe("normalizeSecuritySymbol", () => {
  it("uppercases and trims", () => {
    expect(normalizeSecuritySymbol("  aapl  ")).toBe("AAPL");
    expect(normalizeSecuritySymbol("btc")).toBe("BTC");
    expect(normalizeSecuritySymbol("VeQt.TO")).toBe("VEQT.TO");
  });
});

describe("roundToCent", () => {
  it("rounds to two decimals (half-up at the cent)", () => {
    expect(roundToCent(13.2038)).toBe(13.2);
    expect(roundToCent(0.005)).toBe(0.01);
    expect(roundToCent(100)).toBe(100);
    expect(roundToCent(2.675)).toBeCloseTo(2.68, 5);
  });
});

describe("findOrCreateSecurity", () => {
  it("rejects an empty/whitespace symbol", async () => {
    await expect(
      findOrCreateSecurity({ symbol: "   ", asset_type: "stock" })
    ).rejects.toMatchObject({ code: "security_symbol_required" });
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("rejects an invalid asset_type", async () => {
    await expect(
      // @ts-expect-error runtime guard
      findOrCreateSecurity({ symbol: "AAPL", asset_type: "bond" })
    ).rejects.toMatchObject({ code: "security_asset_type_invalid" });
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("UPSERTs on the NORMALIZED symbol and returns the row (casing-dedup)", async () => {
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); // upsert
    mockSelect.mockResolvedValueOnce([
      {
        id: 3,
        symbol: "AAPL",
        name: null,
        currency: "CAD",
        asset_type: "stock",
        created_at: "",
        updated_at: "",
      },
    ]);
    const sec = await findOrCreateSecurity({
      symbol: "  aapl ",
      asset_type: "stock",
    });
    expect(sec.id).toBe(3);
    // The INSERT and the lookup both use the normalized symbol.
    expect(mockExecute.mock.calls[0][1]?.[0]).toBe("AAPL");
    expect(mockExecute.mock.calls[0][0]).toContain("ON CONFLICT(symbol) DO UPDATE");
    expect(mockSelect.mock.calls[0][1]).toEqual(["AAPL"]);
  });

  it("a second call with different casing resolves the SAME row (dedup)", async () => {
    // First create.
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
    mockSelect.mockResolvedValueOnce([
      { id: 9, symbol: "BTC", name: null, currency: "CAD", asset_type: "crypto", created_at: "", updated_at: "" },
    ]);
    const a = await findOrCreateSecurity({ symbol: "btc", asset_type: "crypto" });
    // Second call, different casing — the normalized conflict target returns id 9.
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
    mockSelect.mockResolvedValueOnce([
      { id: 9, symbol: "BTC", name: null, currency: "CAD", asset_type: "crypto", created_at: "", updated_at: "" },
    ]);
    const b = await findOrCreateSecurity({ symbol: "  BtC  ", asset_type: "crypto" });
    expect(a.id).toBe(b.id);
    expect(b.id).toBe(9);
  });

  it("threads an executor when given (in-txn create)", async () => {
    const execSelect = vi.fn().mockResolvedValueOnce([
      { id: 1, symbol: "VEQT.TO", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
    ]);
    const execExecute = vi.fn().mockResolvedValueOnce({ rowsAffected: 1 });
    const exec = { select: execSelect, execute: execExecute };
    await findOrCreateSecurity({ symbol: "veqt.to", asset_type: "stock" }, exec);
    // The provided executor was used, NOT the top-level db handle.
    expect(execExecute).toHaveBeenCalledTimes(1);
    expect(mockExecute).not.toHaveBeenCalled();
    expect(mockSelect).not.toHaveBeenCalled();
  });
});

describe("listSecurities / getSecurity / updateSecurity", () => {
  it("listSecurities orders by symbol", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await listSecurities();
    expect(mockSelect.mock.calls[0][0]).toContain("FROM balance_securities");
    expect(mockSelect.mock.calls[0][0]).toContain("ORDER BY symbol ASC");
  });

  it("getSecurity returns null when absent", async () => {
    mockSelect.mockResolvedValueOnce([]);
    expect(await getSecurity(404)).toBeNull();
  });

  it("updateSecurity rejects on missing id", async () => {
    mockSelect.mockResolvedValueOnce([]); // getSecurity → none
    await expect(updateSecurity(7, { name: "x" })).rejects.toMatchObject({
      code: "security_not_found",
    });
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("updateSecurity re-normalizes symbol and validates asset_type", async () => {
    mockSelect.mockResolvedValueOnce([
      { id: 7, symbol: "AAPL", name: "Apple", currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
    ]);
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
    await updateSecurity(7, { symbol: " msft ", asset_type: "stock" });
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[0]).toBe("MSFT"); // normalized
  });

  it("updateSecurity rejects an invalid asset_type", async () => {
    mockSelect.mockResolvedValueOnce([
      { id: 7, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
    ]);
    await expect(
      // @ts-expect-error runtime guard
      updateSecurity(7, { asset_type: "etf" })
    ).rejects.toMatchObject({ code: "security_asset_type_invalid" });
    expect(mockExecute).not.toHaveBeenCalled();
  });
});

describe("isDetailedLine", () => {
  it("is true iff a holdings array is present (even empty)", () => {
    expect(isDetailedLine({ account_id: 1, value: 0, holdings: [] })).toBe(true);
    expect(
      isDetailedLine({
        account_id: 1,
        value: 10,
        holdings: [
          { symbol: "AAPL", asset_type: "stock", quantity: 1, unit_price: 10, value: 10 },
        ],
      })
    ).toBe(true);
    expect(isDetailedLine({ account_id: 1, value: 10 })).toBe(false);
  });
});

describe("validateDetailedSnapshot", () => {
  const h = (value: number, over: Partial<{ symbol: string; quantity: number; unit_price: number }> = {}) => ({
    symbol: over.symbol ?? "AAPL",
    asset_type: "stock" as const,
    quantity: over.quantity ?? 1,
    unit_price: over.unit_price ?? value,
    value,
  });

  it("accepts a detailed line whose value = rounded-cent SUM of holdings", () => {
    expect(() =>
      validateDetailedSnapshot({
        account_id: 7,
        value: 300,
        holdings: [h(100, { symbol: "AAPL" }), h(200, { symbol: "MSFT" })],
      })
    ).not.toThrow();
  });

  it("tolerates a detailed account with NO holdings (pre-pivot aggregated)", () => {
    expect(() =>
      validateDetailedSnapshot({ account_id: 7, value: 5000, holdings: [] })
    ).not.toThrow();
  });

  it("rejects a detailed line that carries a scalar quantity/unit_price", () => {
    expect(() =>
      validateDetailedSnapshot({
        account_id: 7,
        value: 100,
        quantity: 1,
        holdings: [h(100)],
      })
    ).toThrow(BalanceServiceError);
    let scalarErr: unknown;
    try {
      validateDetailedSnapshot({
        account_id: 7,
        value: 100,
        unit_price: 100,
        holdings: [h(100)],
      });
    } catch (e) {
      scalarErr = e;
    }
    expect((scalarErr as BalanceServiceError).code).toBe(
      "snapshot_detailed_must_be_aggregate"
    );
  });

  it("rejects when line value ≠ rounded-cent SUM", () => {
    let caught: unknown;
    try {
      validateDetailedSnapshot({
        account_id: 7,
        value: 305, // should be 300
        holdings: [h(100), h(200, { symbol: "MSFT" })],
      });
    } catch (e) {
      caught = e;
    }
    expect((caught as BalanceServiceError).code).toBe(
      "snapshot_detailed_value_mismatch"
    );
  });

  it("rejects a holding with an empty symbol or bad asset_type or non-finite numbers", () => {
    expect(() =>
      validateDetailedSnapshot({
        account_id: 7,
        value: 10,
        holdings: [{ symbol: "  ", asset_type: "stock", quantity: 1, unit_price: 10, value: 10 }],
      })
    ).toThrowError(/snapshot_holding_invalid|required/);
    expect(() =>
      validateDetailedSnapshot({
        account_id: 7,
        value: 10,
        // @ts-expect-error runtime guard
        holdings: [{ symbol: "AAPL", asset_type: "bond", quantity: 1, unit_price: 10, value: 10 }],
      })
    ).toThrow(BalanceServiceError);
    expect(() =>
      validateDetailedSnapshot({
        account_id: 7,
        value: 10,
        holdings: [{ symbol: "AAPL", asset_type: "stock", quantity: NaN, unit_price: 10, value: 10 }],
      })
    ).toThrow(BalanceServiceError);
  });

  it("accepts N≥20 holdings — per-holding rounding never accumulates past a cent", () => {
    // 21 positions each at 0.005 → roundToCent ⇒ 0.01 each ⇒ exact SUM 0.21.
    // A naive Σ(raw)=0.105 with an absolute ε=0.01 would mis-handle this;
    // rounding each first makes the comparison exact on whole cents.
    const holdings = Array.from({ length: 21 }, (_, i) => ({
      symbol: `S${i}`,
      asset_type: "stock" as const,
      quantity: 1,
      unit_price: 0.005,
      value: 0.005,
    }));
    expect(() =>
      validateDetailedSnapshot({ account_id: 7, value: 0.21, holdings })
    ).not.toThrow();
    // Off-by-a-cent must still be rejected exactly.
    let offByCent: unknown;
    try {
      validateDetailedSnapshot({ account_id: 7, value: 0.22, holdings });
    } catch (e) {
      offByCent = e;
    }
    expect((offByCent as BalanceServiceError).code).toBe(
      "snapshot_detailed_value_mismatch"
    );
  });
});

describe("saveSnapshotAtomic — detailed account (holdings)", () => {
  it("writes the aggregated line + securities + holdings inside ONE BEGIN/COMMIT", async () => {
    mockSelect
      .mockResolvedValueOnce([]) // dup-check: free date
      // findOrCreateSecurity AAPL lookup, then MSFT lookup
      .mockResolvedValueOnce([
        { id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
      ])
      .mockResolvedValueOnce([
        { id: 12, symbol: "MSFT", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
      ]);
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
      .mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot
      .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
      .mockResolvedValueOnce({ lastInsertId: 500, rowsAffected: 1 }) // INSERT aggregated line
      .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE holdings for line 500
      .mockResolvedValueOnce({ rowsAffected: 1 }) // UPSERT security AAPL
      .mockResolvedValueOnce({ rowsAffected: 1 }) // INSERT holding AAPL
      .mockResolvedValueOnce({ rowsAffected: 1 }) // UPSERT security MSFT
      .mockResolvedValueOnce({ rowsAffected: 1 }) // INSERT holding MSFT
      .mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at
      .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT

    const res = await saveSnapshotAtomic({
      existingSnapshotId: null,
      snapshot_date: "2026-05-30",
      lines: [
        {
          account_id: 7,
          value: 300,
          holdings: [
            { symbol: "aapl", asset_type: "stock", quantity: 2, unit_price: 50, value: 100, book_cost: 80 },
            { symbol: "msft", asset_type: "stock", quantity: 1, unit_price: 200, value: 200, book_cost: 150 },
          ],
        },
      ],
    });

    expect(res.snapshotId).toBe(42);
    const calls = mockExecute.mock.calls.map((c: unknown[]) => String(c[0]));
    // The aggregated line is inserted with NULL qty/price and the SUM value.
    const aggInsertIdx = calls.findIndex((s: string) => s.includes("INSERT INTO balance_snapshot_lines"));
    expect(mockExecute.mock.calls[aggInsertIdx][1]).toEqual([42, 7, 300]);
    // Holdings are deleted then inserted referencing the captured line id (500).
    expect(calls).toContain("DELETE FROM balance_snapshot_holdings WHERE snapshot_line_id = $1");
    const holdingInsert = mockExecute.mock.calls.find((c: unknown[]) =>
      String(c[0]).includes("INSERT INTO balance_snapshot_holdings")
    );
    expect(holdingInsert?.[1]?.[0]).toBe(500); // snapshot_line_id
    // Begins once, commits last, never rolls back.
    expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
    expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe("COMMIT");
    expect(calls).not.toContain("ROLLBACK");
  });

  it("ROLLS BACK the whole save when a holding INSERT fails (no partial line/holdings)", async () => {
    mockSelect
      .mockResolvedValueOnce([]) // dup-check
      .mockResolvedValueOnce([
        { id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
      ]); // security AAPL lookup
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
      .mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot
      .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
      .mockResolvedValueOnce({ lastInsertId: 500, rowsAffected: 1 }) // INSERT aggregated line
      .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE holdings
      .mockResolvedValueOnce({ rowsAffected: 1 }) // UPSERT security AAPL
      .mockRejectedValueOnce(new Error("holding FK violation")) // INSERT holding FAILS
      .mockResolvedValueOnce({ rowsAffected: 0 }); // ROLLBACK

    await expect(
      saveSnapshotAtomic({
        existingSnapshotId: null,
        snapshot_date: "2026-05-30",
        lines: [
          {
            account_id: 7,
            value: 100,
            holdings: [
              { symbol: "AAPL", asset_type: "stock", quantity: 2, unit_price: 50, value: 100 },
            ],
          },
        ],
      })
    ).rejects.toThrow("holding FK violation");

    // ROLLBACK was the last call, no COMMIT happened.
    expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe("ROLLBACK");
    expect(
      mockExecute.mock.calls.some((c: unknown[]) => c[0] === "COMMIT")
    ).toBe(false);
  });

  it("recomputes the aggregated value server-side from the rounded-cent SUM", async () => {
    // Caller passes value=0.21 with 21×0.005 holdings; the line must store 0.21.
    mockSelect.mockResolvedValueOnce([]); // dup-check
    for (let i = 0; i < 21; i++) {
      mockSelect.mockResolvedValueOnce([
        { id: 100 + i, symbol: `S${i}`, name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
      ]);
    }
    // BEGIN, INSERT snapshot, DELETE lines, INSERT line, DELETE holdings,
    // then 21×(upsert security + insert holding), UPDATE, COMMIT.
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
      .mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot
      .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
      .mockResolvedValueOnce({ lastInsertId: 500, rowsAffected: 1 }) // INSERT line
      .mockResolvedValueOnce({ rowsAffected: 0 }); // DELETE holdings
    for (let i = 0; i < 21; i++) {
      mockExecute
        .mockResolvedValueOnce({ rowsAffected: 1 }) // upsert security
        .mockResolvedValueOnce({ rowsAffected: 1 }); // insert holding
    }
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at
      .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT

    const holdings = Array.from({ length: 21 }, (_, i) => ({
      symbol: `S${i}`,
      asset_type: "stock" as const,
      quantity: 1,
      unit_price: 0.005,
      value: 0.005,
    }));
    await saveSnapshotAtomic({
      existingSnapshotId: null,
      snapshot_date: "2026-05-30",
      lines: [{ account_id: 7, value: 0.21, holdings }],
    });
    const lineInsert = mockExecute.mock.calls.find((c: unknown[]) =>
      String(c[0]).includes("INSERT INTO balance_snapshot_lines")
    );
    // 21 × roundToCent(0.005)=0.01 ⇒ 0.21 stored.
    expect(lineInsert?.[1]).toEqual([42, 7, 0.21]);
  });
});

describe("updateBalanceAccount — detailed→simple guard (#212)", () => {
  const detailedAccount = {
    id: 7,
    balance_category_id: 1,
    name: "Courtage",
    symbol: null,
    currency: "CAD",
    notes: null,
    is_active: 1,
    archived_at: null,
    vehicle_type: null,
    kind: "detailed",
    detailed_since: "2026-05-01",
    created_at: "",
    updated_at: "",
  };

  it("rejects detailed→simple when holdings exist (typed error, no UPDATE)", async () => {
    mockSelect
      .mockResolvedValueOnce([detailedAccount]) // getBalanceAccount
      .mockResolvedValueOnce([{ n: 3 }]); // holdings count > 0
    await expect(
      updateBalanceAccount(7, { kind: "simple" })
    ).rejects.toMatchObject({ code: "account_kind_detailed_has_holdings" });
    // Guard fires before the UPDATE.
    expect(
      mockExecute.mock.calls.some((c: unknown[]) =>
        String(c[0]).includes("UPDATE balance_accounts")
      )
    ).toBe(false);
  });

  it("ALLOWS detailed→simple when no holdings exist yet", async () => {
    mockSelect
      .mockResolvedValueOnce([detailedAccount]) // getBalanceAccount
      .mockResolvedValueOnce([{ n: 0 }]); // no holdings
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); // UPDATE
    await updateBalanceAccount(7, { kind: "simple" });
    const sql = mockExecute.mock.calls[0][0] as string;
    expect(sql).toContain("kind = $7");
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[6]).toBe("simple"); // kind
  });

  it("does NOT run the holdings check on simple→detailed (only the dangerous direction is gated)", async () => {
    const simpleAccount = { ...detailedAccount, kind: "simple", detailed_since: null };
    mockSelect.mockResolvedValueOnce([simpleAccount]); // getBalanceAccount only
    mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); // UPDATE
    await updateBalanceAccount(7, { kind: "detailed", detailed_since: "2026-06-01" });
    // Exactly one SELECT (the account read) — no holdings count query.
    expect(mockSelect).toHaveBeenCalledTimes(1);
    const params = mockExecute.mock.calls[0][1] as unknown[];
    expect(params[6]).toBe("detailed");
    expect(params[7]).toBe("2026-06-01"); // detailed_since normalized
  });

  it("rejects an invalid kind value", async () => {
    mockSelect.mockResolvedValueOnce([{ ...detailedAccount, kind: "simple" }]);
    await expect(
      // @ts-expect-error runtime guard
      updateBalanceAccount(7, { kind: "weird" })
    ).rejects.toMatchObject({ code: "account_kind_invalid" });
  });
});

describe("listHoldingsBySnapshotLine / getHoldingsForLatestSnapshot", () => {
  it("listHoldingsBySnapshotLine joins the security and filters by line", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await listHoldingsBySnapshotLine(500);
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain("FROM balance_snapshot_holdings h");
    expect(sql).toContain("JOIN balance_securities s");
    expect(sql).toContain("WHERE h.snapshot_line_id = $1");
    expect(mockSelect.mock.calls[0][1]).toEqual([500]);
  });

  it("getHoldingsForLatestSnapshot EXCLUDES quantity-0 positions", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await getHoldingsForLatestSnapshot(7);
    const sql = mockSelect.mock.calls[0][0] as string;
    // Latest-snapshot subquery + the qty<>0 filter for the prefill.
    expect(sql).toContain("h.quantity <> 0");
    expect(sql).toContain("ORDER BY s2.snapshot_date DESC");
    expect(mockSelect.mock.calls[0][1]).toEqual([7]);
  });
});

describe("computeUnrealizedGain", () => {
  it("computes per-holding gain and % from book_cost", () => {
    const r = computeUnrealizedGain([
      { security_id: 1, value: 120, book_cost: 100, symbol: "AAPL" },
      { security_id: 2, value: 80, book_cost: 100, symbol: "MSFT" },
    ]);
    expect(r.total_value).toBe(200);
    expect(r.total_book_cost).toBe(200);
    expect(r.total_gain).toBe(0);
    expect(r.total_gain_pct).toBe(0);
    expect(r.holdings[0].gain).toBe(20);
    expect(r.holdings[0].gain_pct).toBeCloseTo(0.2, 5);
    expect(r.holdings[1].gain).toBe(-20);
    expect(r.holdings[1].gain_pct).toBeCloseTo(-0.2, 5);
    expect(r.has_unknown_book_cost).toBe(false);
  });

  it("book_cost = 0 ⇒ gain = value but gain_pct = null (N/A, no divide-by-zero)", () => {
    const r = computeUnrealizedGain([
      { security_id: 1, value: 50, book_cost: 0 },
    ]);
    expect(r.holdings[0].gain).toBe(50);
    expect(r.holdings[0].gain_pct).toBeNull();
    // book_cost 0 contributes to the (zero) aggregate denominator ⇒ total % null.
    expect(r.total_book_cost).toBe(0);
    expect(r.total_gain_pct).toBeNull();
    expect(r.has_unknown_book_cost).toBe(false);
  });

  it("NULL book_cost ⇒ gain & % null, EXCLUDED from aggregate, flagged", () => {
    const r = computeUnrealizedGain([
      { security_id: 1, value: 120, book_cost: 100 },
      { security_id: 2, value: 300, book_cost: null },
    ]);
    // The NULL-book_cost holding's value still counts toward total_value…
    expect(r.total_value).toBe(420);
    // …but NOT toward book_cost / gain aggregates.
    expect(r.total_book_cost).toBe(100);
    expect(r.total_gain).toBe(20);
    expect(r.total_gain_pct).toBeCloseTo(0.2, 5);
    expect(r.holdings[1].gain).toBeNull();
    expect(r.holdings[1].gain_pct).toBeNull();
    expect(r.has_unknown_book_cost).toBe(true);
  });

  it("empty holdings ⇒ zeros and null %", () => {
    const r = computeUnrealizedGain([]);
    expect(r.total_value).toBe(0);
    expect(r.total_book_cost).toBe(0);
    expect(r.total_gain).toBe(0);
    expect(r.total_gain_pct).toBeNull();
    expect(r.has_unknown_book_cost).toBe(false);
    expect(r.holdings).toEqual([]);
  });
});

describe("getAccountLatentGainByLine", () => {
  it("reads the line's holdings then folds them through computeUnrealizedGain", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        security_id: 1,
        value: 120,
        book_cost: 100,
        security_symbol: "AAPL",
      },
      {
        security_id: 2,
        value: 300,
        book_cost: null,
        security_symbol: "MSFT",
      },
    ]);
    const r = await getAccountLatentGainByLine(42);
    // It reads holdings of the supplied line id.
    expect(mockSelect.mock.calls[0][1]).toEqual([42]);
    // Aggregate mirrors computeUnrealizedGain (NULL book_cost excluded, flagged).
    expect(r.total_value).toBe(420);
    expect(r.total_gain).toBe(20);
    expect(r.has_unknown_book_cost).toBe(true);
    // Symbol from the security join is carried onto the per-holding rows.
    expect(r.holdings[0].symbol).toBe("AAPL");
  });
});

describe("rollupLatentGain", () => {
  // A small AccountUnrealizedGain factory keeps the cases readable.
  const g = (
    over: Partial<AccountUnrealizedGain>
  ): AccountUnrealizedGain => ({
    total_value: 0,
    total_book_cost: 0,
    total_gain: 0,
    total_gain_pct: null,
    has_unknown_book_cost: false,
    holdings: [],
    ...over,
  });

  it("groups by asset class and by envelope, and sums a grand total", () => {
    const r = rollupLatentGain([
      {
        category_key: "stock",
        vehicle_type: "tfsa",
        gain: g({ total_value: 120, total_book_cost: 100, total_gain: 20 }),
      },
      {
        category_key: "stock",
        vehicle_type: "rrsp",
        gain: g({ total_value: 80, total_book_cost: 100, total_gain: -20 }),
      },
      {
        category_key: "crypto",
        vehicle_type: "tfsa",
        gain: g({ total_value: 50, total_book_cost: 40, total_gain: 10 }),
      },
    ]);

    // Asset-class buckets: stock merges the two accounts, crypto stands alone.
    const stock = r.byClass.find((b) => b.group_key === "stock")!;
    expect(stock.total_value).toBe(200);
    expect(stock.total_gain).toBe(0);
    expect(stock.total_gain_pct).toBe(0); // 0 / 200
    const crypto = r.byClass.find((b) => b.group_key === "crypto")!;
    expect(crypto.total_gain_pct).toBeCloseTo(0.25, 5); // 10 / 40

    // Envelope buckets: tfsa merges stock+crypto, rrsp stands alone.
    const tfsa = r.byVehicle.find((b) => b.group_key === "tfsa")!;
    expect(tfsa.total_value).toBe(170);
    expect(tfsa.total_gain).toBe(30);
    expect(tfsa.total_gain_pct).toBeCloseTo(30 / 140, 5);

    // Grand total across every account.
    expect(r.grandTotal.total_value).toBe(250);
    expect(r.grandTotal.total_gain).toBe(10);
    expect(r.grandTotal.total_gain_pct).toBeCloseTo(10 / 240, 5);
    expect(r.grandTotal.has_unknown_book_cost).toBe(false);
  });

  it("buckets a NULL vehicle_type under the 'none' envelope key", () => {
    const r = rollupLatentGain([
      {
        category_key: "other",
        vehicle_type: null,
        gain: g({ total_value: 10, total_book_cost: 10, total_gain: 0 }),
      },
    ]);
    expect(r.byVehicle.map((b) => b.group_key)).toEqual(["none"]);
  });

  it("a bucket with no known book_cost yields a null % and flags unknown", () => {
    const r = rollupLatentGain([
      {
        category_key: "stock",
        vehicle_type: "tfsa",
        // All book_cost unknown ⇒ total_book_cost 0, gain 0, flagged.
        gain: g({ total_value: 500, has_unknown_book_cost: true }),
      },
    ]);
    expect(r.byClass[0].total_gain_pct).toBeNull();
    expect(r.byClass[0].has_unknown_book_cost).toBe(true);
    expect(r.grandTotal.total_gain_pct).toBeNull();
    expect(r.grandTotal.has_unknown_book_cost).toBe(true);
  });

  it("empty input ⇒ empty buckets and a zeroed grand total", () => {
    const r = rollupLatentGain([]);
    expect(r.byClass).toEqual([]);
    expect(r.byVehicle).toEqual([]);
    expect(r.grandTotal.total_value).toBe(0);
    expect(r.grandTotal.total_gain_pct).toBeNull();
  });
});
