/**
 * Integration tests for the Bilan (balance sheet) feature — Issue #144.
 *
 * Cross-cutting tests that exercise the *whole* TS surface in one go:
 *
 *   account → priced category → priced snapshot → linked transfer → return
 *
 * Like `category-migration.test.ts` we cannot spin up real `tauri-plugin-sql`
 * (the bridge only lives inside the Tauri WebView). Instead we drive every
 * service against an in-memory FakeDb that:
 *   - records every executed SQL,
 *   - returns hand-tuned `select` results to mimic the real schema,
 *   - simulates `lastInsertId` / `rowsAffected` for INSERT/DELETE.
 *
 * The Tauri `invoke` is mocked — `computeAccountReturn` lives on the Rust
 * side (`compute_account_return`), so we assert the request payload and
 * have the mock return a stable `AccountReturn` shape. The Rust math itself
 * is covered by `return_calculator.rs`'s `#[cfg(test)] mod tests`.
 *
 * Scope (from spec-plan-bilan.md, Issue #144):
 *   1. End-to-end happy path
 *   2. Currency-lock (CHECK `currency = 'CAD'`) at the service level
 *   3. Migration v9 on a seeded DB — covered in Rust (lib.rs `mod tests`)
 *   4. TransactionsPage non-regression for the inlined transfer icon
 *   5. Coverage best-effort (deferred — see decisions-log.md)
 */

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

vi.mock("../services/db", () => {
  const getDb = vi.fn();
  return {
    getDb,
    withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
  };
});

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

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

import { getDb } from "../services/db";
import { invoke } from "@tauri-apps/api/core";
import { loadProfiles } from "../services/profileService";

import {
  createBalanceCategory,
  createBalanceAccount,
  listBalanceAccounts,
  createSnapshot,
  upsertSnapshotLines,
  listLinesBySnapshot,
  linkTransfer,
  unlinkTransfer,
  listAccountTransfers,
  computeAccountReturn,
  saveSnapshotAtomic,
  deleteSnapshot,
  getSnapshotTotalsByDate,
  BalanceServiceError,
  PRICED_VALUE_TOLERANCE,
} from "../services/balance.service";

// ---------------------------------------------------------------------------
// FakeDb harness: scripted select results, recorded execute calls.
// ---------------------------------------------------------------------------

interface FakeDb {
  calls: Array<{ sql: string; params?: unknown[] }>;
  selectQueue: Array<unknown[]>;
  executeQueue: Array<{ lastInsertId?: number; rowsAffected?: number }>;
  select: ReturnType<typeof vi.fn>;
  execute: ReturnType<typeof vi.fn>;
}

function makeFakeDb(): FakeDb {
  const db: FakeDb = {
    calls: [],
    selectQueue: [],
    executeQueue: [],
    select: vi.fn(),
    execute: vi.fn(),
  };
  db.select.mockImplementation(async (sql: string, params?: unknown[]) => {
    db.calls.push({ sql, params });
    if (db.selectQueue.length === 0) {
      throw new Error(`Unscripted SELECT (no queued result): ${sql}`);
    }
    return db.selectQueue.shift();
  });
  db.execute.mockImplementation(async (sql: string, params?: unknown[]) => {
    db.calls.push({ sql, params });
    if (db.executeQueue.length === 0) {
      // Default: 1 affected row, monotonically increasing lastInsertId
      return { rowsAffected: 1, lastInsertId: db.calls.length };
    }
    return db.executeQueue.shift();
  });
  return db;
}

let fake: FakeDb;

beforeEach(() => {
  fake = makeFakeDb();
  vi.mocked(getDb).mockResolvedValue(
    { select: fake.select, execute: fake.execute } as never
  );
  vi.mocked(invoke).mockReset();
  vi.mocked(loadProfiles).mockReset();
});

// Helper: queue a sequence of SELECT results in FIFO order.
function queueSelects(...rows: unknown[][]) {
  for (const r of rows) fake.selectQueue.push(r);
}

// Helper: queue a sequence of EXECUTE results in FIFO order.
function queueExecutes(
  ...results: Array<{ lastInsertId?: number; rowsAffected?: number }>
) {
  for (const r of results) fake.executeQueue.push(r);
}

// ---------------------------------------------------------------------------
// 1. End-to-end happy path
// ---------------------------------------------------------------------------
//
// Walks the full Bilan flow as if the user just installed the app:
//   1. Create a custom priced category ("etf-prov")
//   2. Create an account on that category with a stock symbol
//   3. Reload the joined accounts list and confirm the account is there
//   4. Create a snapshot dated today
//   5. Save a priced line for the new account (qty * price = value)
//   6. Read the lines back and confirm what was persisted
//   7. Link a transaction to the account as a +CAD deposit
//   8. Compute the account's return → mock returns a stable shape, we
//      assert the wiring uses the active profile's db_filename and forwards
//      every parameter as ISO YYYY-MM-DD.
//
// Each step is asserted at the service-call level (params + queued SQL),
// then we run cross-step sanity checks.

describe("integration — Bilan end-to-end happy path", () => {
  it("walks account → priced category → snapshot → transfer → return cleanly", async () => {
    // ---- 1. Create a custom priced category ----
    queueExecutes({ lastInsertId: 100 });
    const categoryId = await createBalanceCategory({
      key: "etf-prov",
      i18n_key: "balance.category.etf_prov",
      kind: "priced",
      sort_order: 80,
      asset_type: "stock",
    });
    expect(categoryId).toBe(100);

    // ---- 2. Create the account on that category ----
    // Service first SELECTs the category to validate it exists, then
    // INSERTs the account.
    queueSelects([
      {
        id: 100,
        key: "etf-prov",
        i18n_key: "balance.category.etf_prov",
        kind: "priced",
        sort_order: 80,
        is_active: 1,
        is_seed: 0,
      },
    ]);
    queueExecutes({ lastInsertId: 7 });
    const accountId = await createBalanceAccount({
      balance_category_id: categoryId,
      name: "VFV (Wealthsimple)",
      symbol: "VFV.TO",
    });
    expect(accountId).toBe(7);

    // ---- 3. listBalanceAccounts: account joined with category ----
    queueSelects([
      {
        id: 7,
        balance_category_id: 100,
        name: "VFV (Wealthsimple)",
        symbol: "VFV.TO",
        currency: "CAD",
        notes: null,
        is_active: 1,
        archived_at: null,
        created_at: "",
        updated_at: "",
        category_key: "etf-prov",
        category_i18n_key: "balance.category.etf_prov",
        category_kind: "priced",
      },
    ]);
    const accounts = await listBalanceAccounts();
    expect(accounts).toHaveLength(1);
    expect(accounts[0].category_kind).toBe("priced");
    expect(accounts[0].symbol).toBe("VFV.TO");

    // ---- 4. Create a snapshot dated 2026-04-25 ----
    // createSnapshot first SELECTs by date (must be empty) then INSERTs.
    queueSelects([]); // no existing snapshot
    queueExecutes({ lastInsertId: 50 });
    const snapshotId = await createSnapshot({ snapshot_date: "2026-04-25" });
    expect(snapshotId).toBe(50);

    // ---- 5. Save a priced line: 10 shares × $200 = $2000 ----
    // upsertSnapshotLines: SELECT snapshot, then DELETE existing lines, then
    // one INSERT per line, then UPDATE updated_at.
    queueSelects([
      {
        id: 50,
        snapshot_date: "2026-04-25",
        notes: null,
        created_at: "",
        updated_at: "",
      },
    ]);
    queueExecutes(
      { rowsAffected: 0 }, // delete (no prior lines)
      { lastInsertId: 200 }, // insert priced line
      { rowsAffected: 1 } // bump updated_at
    );
    await upsertSnapshotLines(50, [
      {
        account_id: 7,
        account_kind: "priced",
        quantity: 10,
        unit_price: 200,
        value: 2000,
      },
    ]);

    // The 2nd execute call should be the INSERT with the priced placeholders.
    const insertCall = fake.calls.find(
      (c) =>
        typeof c.sql === "string" &&
        c.sql.includes("INSERT INTO balance_snapshot_lines")
    );
    expect(insertCall).toBeDefined();
    expect(insertCall!.params).toEqual([50, 7, 10, 200, 2000]);

    // ---- 6. Read the lines back ----
    queueSelects([
      {
        id: 200,
        snapshot_id: 50,
        account_id: 7,
        quantity: 10,
        unit_price: 200,
        value: 2000,
        price_source: "manual",
        price_fetched_at: null,
        created_at: "",
        updated_at: "",
      },
    ]);
    const lines = await listLinesBySnapshot(50);
    expect(lines).toHaveLength(1);
    expect(lines[0].value).toBe(2000);
    expect(lines[0].quantity).toBe(10);
    expect(lines[0].unit_price).toBe(200);

    // ---- 7. Link a transaction (id=42) as a +CAD deposit (in) ----
    // linkTransfer: SELECT existing duplicate (none), then INSERT.
    queueSelects([]); // no existing duplicate
    queueExecutes({ lastInsertId: 9 });
    const transferId = await linkTransfer(7, 42, "in", "monthly contribution");
    expect(transferId).toBe(9);
    const linkCall = fake.calls.find(
      (c) =>
        typeof c.sql === "string" &&
        c.sql.includes("INSERT INTO balance_account_transfers")
    );
    expect(linkCall).toBeDefined();
    expect(linkCall!.params).toEqual([7, 42, "in", "monthly contribution"]);

    // ---- 8. Compute the account return ----
    vi.mocked(loadProfiles).mockResolvedValueOnce({
      active_profile_id: "max",
      profiles: [
        {
          id: "max",
          name: "Max",
          color: "#3b82f6",
          pin_hash: null,
          db_filename: "max.db",
          created_at: "0",
        },
      ],
    });
    const fakeReturn = {
      value_start: 1500,
      value_end: 2000,
      net_contributions: 400,
      return_pct: 0.0667, // (2000 - 1500 - 400) / (1500 + W*400) ≈ 6.67%
      annualized_pct: 0.28,
      is_partial: false,
      has_no_transfers_warning: false,
    };
    vi.mocked(invoke).mockResolvedValueOnce(fakeReturn);

    const ret = await computeAccountReturn(7, "2026-01-01", "2026-04-25");
    expect(ret).toEqual(fakeReturn);

    // Wiring check: profile resolution + ISO date forwarding.
    expect(invoke).toHaveBeenCalledWith("compute_account_return", {
      dbFilename: "max.db",
      accountId: 7,
      periodStart: "2026-01-01",
      periodEnd: "2026-04-25",
    });

    // ---- Cross-step sanity: every coherent value matches expectations.
    // The end snapshot value (2000) matches what we saved.
    expect(ret.value_end).toBe(2000);
    // The reported return is a finite, non-zero number on a non-trivial period.
    expect(ret.return_pct).not.toBeNull();
    expect(Number.isFinite(ret.return_pct!)).toBe(true);
    // Net contributions match the 1 linked transfer (+400 in).
    expect(ret.net_contributions).toBeGreaterThan(0);
  });

  it("supports unlink as the inverse of link", async () => {
    queueExecutes({ rowsAffected: 1 });
    await expect(unlinkTransfer(7, 42)).resolves.toBeUndefined();
    const unlinkCall = fake.calls.find(
      (c) =>
        typeof c.sql === "string" &&
        c.sql.includes("DELETE FROM balance_account_transfers")
    );
    expect(unlinkCall!.params).toEqual([7, 42]);
  });

  it("listAccountTransfers reads back what link wrote (joined view)", async () => {
    queueSelects([
      {
        id: 9,
        account_id: 7,
        transaction_id: 42,
        direction: "in",
        notes: "monthly contribution",
        created_at: "2026-04-25 10:00:00",
        transaction_date: "2026-04-15",
        transaction_description: "Wealthsimple contrib",
        transaction_amount: -400,
        account_name: "VFV (Wealthsimple)",
      },
    ]);
    const links = await listAccountTransfers(7);
    expect(links).toHaveLength(1);
    expect(links[0].direction).toBe("in");
    expect(links[0].account_name).toBe("VFV (Wealthsimple)");
  });
});

// ---------------------------------------------------------------------------
// 2. Currency lock — CAD only at the MVP
// ---------------------------------------------------------------------------
//
// The MVP locks accounts to CAD: the SQL CHECK is `currency = 'CAD'` and the
// service rejects any other value with a typed `currency_unsupported` before
// the SQL even fires. Asserts:
//   - USD is rejected with the typed code,
//   - the rejection happens BEFORE any SELECT/EXECUTE on the DB,
//   - the default (no `currency` field) flows through and lands as 'CAD',
//   - the SQL CHECK side is covered in Rust (lib.rs `migration_v9_*` tests).

describe("integration — currency lock (CAD only)", () => {
  it("rejects USD at the service level with a typed error", async () => {
    await expect(
      createBalanceAccount({
        balance_category_id: 1,
        name: "USD account",
        currency: "USD",
      })
    ).rejects.toBeInstanceOf(BalanceServiceError);

    try {
      await createBalanceAccount({
        balance_category_id: 1,
        name: "USD account",
        currency: "USD",
      });
    } catch (e) {
      expect((e as BalanceServiceError).code).toBe("currency_unsupported");
    }
    // CRITICAL: the rejection must happen up-front — no DB hit.
    expect(fake.calls.length).toBe(0);
  });

  it("accepts the default and persists 'CAD' explicitly", async () => {
    queueSelects([
      {
        id: 1,
        key: "cash",
        i18n_key: "balance.category.cash",
        kind: "simple",
        sort_order: 10,
        is_active: 1,
        is_seed: 1,
      },
    ]);
    queueExecutes({ lastInsertId: 5 });
    await createBalanceAccount({
      balance_category_id: 1,
      name: "Encaisse",
    });
    const insertCall = fake.calls.find(
      (c) =>
        typeof c.sql === "string" &&
        c.sql.includes("INSERT INTO balance_accounts")
    );
    expect(insertCall).toBeDefined();
    // [category_id, name, symbol, currency, notes, vehicle_type] (#202)
    expect(insertCall!.params).toEqual([1, "Encaisse", null, "CAD", null, null]);
  });

  it("rejects EUR / GBP / JPY too — not a CAD-only typo allowlist", async () => {
    for (const ccy of ["EUR", "GBP", "JPY", "AUD"]) {
      await expect(
        createBalanceAccount({
          balance_category_id: 1,
          name: `Mystery ${ccy}`,
          currency: ccy,
        })
      ).rejects.toMatchObject({ code: "currency_unsupported" });
    }
    expect(fake.calls.length).toBe(0);
  });
});

// ---------------------------------------------------------------------------
// 3. Priced-kind invariant — coherence of the qty × price = value chain
// ---------------------------------------------------------------------------
//
// Tied to the priced-kind path, but at the integration layer: a snapshot
// saved with a drifting (qty * price ≠ value) line must be rejected before
// any DB mutation, so the SQL CHECK never has the chance to fire and we
// don't accidentally clear pre-existing lines.

describe("integration — priced invariant rejects out-of-tolerance saves", () => {
  it("does not run DELETE when one line is bad", async () => {
    queueSelects([
      {
        id: 50,
        snapshot_date: "2026-04-25",
        notes: null,
        created_at: "",
        updated_at: "",
      },
    ]);
    await expect(
      upsertSnapshotLines(50, [
        { account_id: 1, value: 1000 },
        {
          account_id: 7,
          account_kind: "priced",
          quantity: 10,
          unit_price: 25,
          // expected ≈ 250, way beyond ε
          value: 999,
        },
      ])
    ).rejects.toMatchObject({ code: "snapshot_priced_value_mismatch" });

    // Critical safety: the DELETE must not have fired — otherwise the user
    // would lose all existing lines on a partial save.
    const deletes = fake.calls.filter(
      (c) =>
        typeof c.sql === "string" &&
        c.sql.includes("DELETE FROM balance_snapshot_lines")
    );
    expect(deletes).toHaveLength(0);
  });

  it("accepts a drift just within the tolerance", async () => {
    queueSelects([
      {
        id: 50,
        snapshot_date: "2026-04-25",
        notes: null,
        created_at: "",
        updated_at: "",
      },
    ]);
    queueExecutes(
      { rowsAffected: 0 },
      { lastInsertId: 1 },
      { rowsAffected: 1 }
    );
    // 12.34 * 1.07 = 13.2038... — drift well within ε = 0.01
    const drift = PRICED_VALUE_TOLERANCE * 0.5;
    await expect(
      upsertSnapshotLines(50, [
        {
          account_id: 7,
          account_kind: "priced",
          quantity: 10,
          unit_price: 10,
          value: 100 + drift,
        },
      ])
    ).resolves.toBeUndefined();
  });
});

// ---------------------------------------------------------------------------
// 4. Returns: malformed period dates rejected before the Tauri invoke
// ---------------------------------------------------------------------------

describe("integration — computeAccountReturn validates dates client-side", () => {
  it("rejects non-ISO dates without invoking the Rust command", async () => {
    vi.mocked(loadProfiles).mockResolvedValueOnce({
      active_profile_id: "max",
      profiles: [
        {
          id: "max",
          name: "Max",
          color: "#000",
          pin_hash: null,
          db_filename: "max.db",
          created_at: "0",
        },
      ],
    });
    await expect(
      computeAccountReturn(7, "01/01/2026", "2026-04-25")
    ).rejects.toBeInstanceOf(BalanceServiceError);
    // The Tauri side must NOT have been hit — fail-fast on bad dates.
    expect(invoke).not.toHaveBeenCalled();
  });

  it("rejects when the active profile cannot be resolved", async () => {
    vi.mocked(loadProfiles).mockResolvedValueOnce({
      active_profile_id: "ghost",
      profiles: [],
    });
    await expect(
      computeAccountReturn(7, "2026-01-01", "2026-04-25")
    ).rejects.toMatchObject({ code: "transfer_active_profile_unknown" });
    expect(invoke).not.toHaveBeenCalled();
  });

  it("forwards a partial-period AccountReturn shape unchanged", async () => {
    // When `is_partial = true` (no V_start), the Rust side returns a payload
    // with explicit nulls. The TS shim must not coerce them away.
    vi.mocked(loadProfiles).mockResolvedValueOnce({
      active_profile_id: "max",
      profiles: [
        {
          id: "max",
          name: "Max",
          color: "#000",
          pin_hash: null,
          db_filename: "max.db",
          created_at: "0",
        },
      ],
    });
    const partial = {
      value_start: null,
      value_end: 1500,
      net_contributions: 200,
      return_pct: null,
      annualized_pct: null,
      is_partial: true,
      has_no_transfers_warning: false,
    };
    vi.mocked(invoke).mockResolvedValueOnce(partial);
    const out = await computeAccountReturn(7, "2026-01-01", "2026-04-25");
    expect(out).toEqual(partial);
    expect(out.is_partial).toBe(true);
    expect(out.value_start).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// 5. Per-security detail (Étape 2, #217) — detailed snapshot save, rollback,
//    date-move with holdings, and the golden-value aggregation invariant.
// ---------------------------------------------------------------------------
//
// The unit-level holdings/securities mechanics are covered exhaustively in
// balance.service.test.ts (#212). Here we assert the END-TO-END service
// behaviour at the integration layer and — critically — the REGRESSION
// invariant that makes detailed accounts safe: a detailed account whose
// holdings sum to V writes an aggregated line worth exactly V, so every
// SUM(value) aggregator sees it identically to a simple account worth V.
//
// (The real-SQLite proof of that equality lives in lib.rs
// `regression_detailed_account_totals_equal_simple_account_totals`, where
// SUM() actually runs. Here we prove the TS half: the stored line value is
// the rounded-cent SUM, which is the only number the aggregator reads.)

describe("integration — detailed snapshot save end-to-end (#217)", () => {
  it("writes the aggregated line + securities + holdings in ONE BEGIN/COMMIT", async () => {
    // New snapshot, one detailed account: AAPL (2×50=100) + MSFT (1×200=200)
    // ⇒ aggregated line value 300, two holdings, all atomic.
    queueSelects([]); // dup-check: date free
    // findOrCreateSecurity AAPL then MSFT lookups (post-upsert SELECT).
    queueSelects([
      { id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
    ]);
    queueSelects([
      { id: 12, symbol: "MSFT", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
    ]);
    queueExecutes(
      { rowsAffected: 0 }, // BEGIN
      { lastInsertId: 42, rowsAffected: 1 }, // INSERT snapshot
      { rowsAffected: 0 }, // DELETE lines
      { lastInsertId: 500, rowsAffected: 1 }, // INSERT aggregated line
      { rowsAffected: 0 }, // DELETE holdings for line 500
      { rowsAffected: 1 }, // UPSERT security AAPL
      { rowsAffected: 1 }, // INSERT holding AAPL
      { rowsAffected: 1 }, // UPSERT security MSFT
      { rowsAffected: 1 }, // INSERT holding MSFT
      { rowsAffected: 1 }, // UPDATE updated_at
      { 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 execCalls = fake.calls.filter(
      (c) =>
        typeof c.sql === "string" &&
        !c.sql.includes("SELECT") // executes only
    );
    // First write is BEGIN, last is COMMIT, no ROLLBACK anywhere.
    expect(execCalls[0].sql).toBe("BEGIN");
    expect(execCalls[execCalls.length - 1].sql).toBe("COMMIT");
    expect(execCalls.some((c) => c.sql === "ROLLBACK")).toBe(false);

    // CRITICAL invariant: the aggregated line is stored with NULL qty/price and
    // value = rounded-cent SUM(holdings) = 300 — the exact number the
    // aggregators read. This is the TS-side golden-value guarantee.
    const lineInsert = fake.calls.find(
      (c) =>
        typeof c.sql === "string" &&
        c.sql.includes("INSERT INTO balance_snapshot_lines")
    );
    expect(lineInsert!.params).toEqual([42, 7, 300]);

    // Both holdings reference the captured line id (500).
    const holdingInserts = fake.calls.filter(
      (c) =>
        typeof c.sql === "string" &&
        c.sql.includes("INSERT INTO balance_snapshot_holdings")
    );
    expect(holdingInserts).toHaveLength(2);
    expect(holdingInserts[0].params?.[0]).toBe(500);
    expect(holdingInserts[1].params?.[0]).toBe(500);
  });

  it("ROLLS BACK the whole save when a holding INSERT fails (no partial line/holdings)", async () => {
    queueSelects([]); // dup-check
    queueSelects([
      { id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
    ]);
    // BEGIN, INSERT snapshot, DELETE lines, INSERT line, DELETE holdings,
    // UPSERT security, then the holding INSERT REJECTS, then ROLLBACK.
    fake.executeQueue.push({ rowsAffected: 0 }); // BEGIN
    fake.executeQueue.push({ lastInsertId: 42, rowsAffected: 1 }); // INSERT snapshot
    fake.executeQueue.push({ rowsAffected: 0 }); // DELETE lines
    fake.executeQueue.push({ lastInsertId: 500, rowsAffected: 1 }); // INSERT line
    fake.executeQueue.push({ rowsAffected: 0 }); // DELETE holdings
    fake.executeQueue.push({ rowsAffected: 1 }); // UPSERT security
    // Make the NEXT execute (the holding INSERT) reject, then allow ROLLBACK.
    let failed = false;
    fake.execute.mockImplementation(async (sql: string, params?: unknown[]) => {
      fake.calls.push({ sql, params });
      if (!failed && sql.includes("INSERT INTO balance_snapshot_holdings")) {
        failed = true;
        throw new Error("holding FK violation");
      }
      if (fake.executeQueue.length === 0) {
        return { rowsAffected: 1, lastInsertId: fake.calls.length };
      }
      return fake.executeQueue.shift()!;
    });

    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 write; COMMIT never happened ⇒ no partial state.
    const execCalls = fake.calls.filter(
      (c) => typeof c.sql === "string" && !c.sql.includes("SELECT")
    );
    expect(execCalls[execCalls.length - 1].sql).toBe("ROLLBACK");
    expect(execCalls.some((c) => c.sql === "COMMIT")).toBe(false);
  });

  it("moves a detailed snapshot's date — line AND holdings move together (#200)", async () => {
    // Edit-mode move: the date UPDATE + the line/holdings rewrite happen in the
    // SAME transaction, so the holdings follow their line to the new date.
    queueSelects([]); // collision check: target date free
    queueSelects([
      { id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
    ]);
    queueExecutes(
      { rowsAffected: 0 }, // BEGIN
      { rowsAffected: 1 }, // UPDATE snapshot_date
      { rowsAffected: 0 }, // DELETE lines (cascades old holdings)
      { lastInsertId: 800, rowsAffected: 1 }, // INSERT aggregated line at new date
      { rowsAffected: 0 }, // DELETE holdings for line 800
      { rowsAffected: 1 }, // UPSERT security AAPL
      { rowsAffected: 1 }, // INSERT holding AAPL
      { rowsAffected: 1 }, // UPDATE updated_at
      { rowsAffected: 0 } // COMMIT
    );

    const res = await saveSnapshotAtomic({
      existingSnapshotId: 5,
      snapshot_date: "2026-04-15",
      moveToDate: "2026-05-20",
      lines: [
        {
          account_id: 7,
          value: 100,
          holdings: [
            { symbol: "AAPL", asset_type: "stock", quantity: 1, unit_price: 100, value: 100, book_cost: 90 },
          ],
        },
      ],
    });
    expect(res.snapshotId).toBe(5);

    // The date UPDATE precedes the line rewrite (so a collision rolls back both).
    const dateUpdate = fake.calls.find(
      (c) => typeof c.sql === "string" && c.sql.includes("SET snapshot_date = $1")
    );
    expect(dateUpdate!.params).toEqual(["2026-05-20", 5]);

    // The holding is re-inserted alongside the moved line (referencing line 800).
    const holdingInsert = fake.calls.find(
      (c) =>
        typeof c.sql === "string" &&
        c.sql.includes("INSERT INTO balance_snapshot_holdings")
    );
    expect(holdingInsert).toBeDefined();
    expect(holdingInsert!.params?.[0]).toBe(800);

    // Whole thing commits; the move + holdings are one unit.
    const execCalls = fake.calls.filter(
      (c) => typeof c.sql === "string" && !c.sql.includes("SELECT")
    );
    expect(execCalls[execCalls.length - 1].sql).toBe("COMMIT");
    expect(execCalls.some((c) => c.sql === "ROLLBACK")).toBe(false);
  });

  it("rolls back the move (date + holdings) when the target date collides (#200)", async () => {
    queueSelects([{ id: 99 }]); // collision: another snapshot already at target
    queueExecutes(
      { rowsAffected: 0 }, // BEGIN
      { rowsAffected: 0 } // ROLLBACK
    );
    await expect(
      saveSnapshotAtomic({
        existingSnapshotId: 5,
        snapshot_date: "2026-04-15",
        moveToDate: "2026-05-20",
        lines: [
          {
            account_id: 7,
            value: 100,
            holdings: [
              { symbol: "AAPL", asset_type: "stock", quantity: 1, unit_price: 100, value: 100 },
            ],
          },
        ],
      })
    ).rejects.toMatchObject({ code: "snapshot_date_exists" });

    // No date UPDATE, no holding INSERT — the collision aborted before any write.
    expect(
      fake.calls.some(
        (c) => typeof c.sql === "string" && c.sql.includes("SET snapshot_date")
      )
    ).toBe(false);
    expect(
      fake.calls.some(
        (c) =>
          typeof c.sql === "string" &&
          c.sql.includes("INSERT INTO balance_snapshot_holdings")
      )
    ).toBe(false);
    const execCalls = fake.calls.filter(
      (c) => typeof c.sql === "string" && !c.sql.includes("SELECT")
    );
    expect(execCalls[execCalls.length - 1].sql).toBe("ROLLBACK");
  });
});

describe("integration — golden-value invariant: detailed line feeds aggregators like a simple line (#217)", () => {
  // The regression contract: whatever the holdings are, the aggregated line
  // value the service writes equals the value a simple account would carry —
  // so getSnapshotTotalsByDate (which only ever reads l.value) is unchanged.
  // We prove BOTH halves drive identical aggregator output by feeding the
  // aggregator the canned total in each case and asserting equality.

  it("a detailed account worth ΣV stores the same line value as a simple account worth V", async () => {
    // --- Detailed path: holdings 100 + 200 ⇒ line value must be 300. ---
    queueSelects([]); // dup-check
    queueSelects([
      { id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
    ]);
    queueSelects([
      { id: 12, symbol: "MSFT", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
    ]);
    queueExecutes(
      { rowsAffected: 0 }, // BEGIN
      { lastInsertId: 42, rowsAffected: 1 }, // INSERT snapshot
      { rowsAffected: 0 }, // DELETE lines
      { lastInsertId: 500, rowsAffected: 1 }, // INSERT aggregated line
      { rowsAffected: 0 }, // DELETE holdings
      { rowsAffected: 1 }, // UPSERT AAPL
      { rowsAffected: 1 }, // INSERT holding AAPL
      { rowsAffected: 1 }, // UPSERT MSFT
      { rowsAffected: 1 }, // INSERT holding MSFT
      { rowsAffected: 1 }, // UPDATE updated_at
      { rowsAffected: 0 } // COMMIT
    );
    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 },
            { symbol: "msft", asset_type: "stock", quantity: 1, unit_price: 200, value: 200 },
          ],
        },
      ],
    });
    const detailedLineInsert = fake.calls.find(
      (c) =>
        typeof c.sql === "string" &&
        c.sql.includes("INSERT INTO balance_snapshot_lines")
    );
    const detailedStoredValue = detailedLineInsert!.params![2];
    expect(detailedStoredValue).toBe(300);

    // --- Simple path: a simple account worth 300 stores the same value. ---
    fake = makeFakeDbLocal();
    vi.mocked(getDb).mockResolvedValue(
      { select: fake.select, execute: fake.execute } as never
    );
    fake.selectQueue.push([
      { id: 5, snapshot_date: "2026-05-30", notes: null, created_at: "", updated_at: "" },
    ]); // getSnapshotById
    queueExecutes(
      { rowsAffected: 0 }, // BEGIN
      { rowsAffected: 0 }, // DELETE lines
      { lastInsertId: 700, rowsAffected: 1 }, // INSERT simple line
      { rowsAffected: 1 }, // UPDATE updated_at
      { rowsAffected: 0 } // COMMIT
    );
    await upsertSnapshotLines(5, [{ account_id: 7, value: 300 }]);
    const simpleLineInsert = fake.calls.find(
      (c) =>
        typeof c.sql === "string" &&
        c.sql.includes("INSERT INTO balance_snapshot_lines")
    );
    const simpleStoredValue = simpleLineInsert!.params![2];

    // The frozen golden number: BOTH paths persist value = 300.
    expect(detailedStoredValue).toBe(simpleStoredValue);
    expect(simpleStoredValue).toBe(300);

    // And the aggregator (which reads only l.value) returns the same total in
    // both worlds — proven by feeding it the canned per-date SUM each path
    // produces. Detailed world:
    fake = makeFakeDbLocal();
    vi.mocked(getDb).mockResolvedValue(
      { select: fake.select, execute: fake.execute } as never
    );
    fake.selectQueue.push([{ snapshot_date: "2026-05-30", total: 300 }]);
    const detailedTotals = await getSnapshotTotalsByDate();
    // Simple world: identical canned SUM ⇒ identical aggregator output.
    fake = makeFakeDbLocal();
    vi.mocked(getDb).mockResolvedValue(
      { select: fake.select, execute: fake.execute } as never
    );
    fake.selectQueue.push([{ snapshot_date: "2026-05-30", total: 300 }]);
    const simpleTotals = await getSnapshotTotalsByDate();
    expect(detailedTotals).toEqual(simpleTotals);
    expect(detailedTotals).toEqual([{ snapshot_date: "2026-05-30", total: 300 }]);
  });
});

describe("integration — snapshot deletion cascades to holdings at the service boundary (#217)", () => {
  it("deleteSnapshot issues the DELETE that the DB cascades to lines + holdings", async () => {
    // The two-hop CASCADE is a DB-FK behaviour (proven in lib.rs
    // regression_snapshot_delete_cascades_to_holdings). At the service layer we
    // assert the single DELETE is emitted on the right row — the FK does the
    // rest. Guard against a regression that would start manually deleting
    // holdings (a sign the CASCADE was dropped) or skip the parent delete.
    fake.selectQueue.push([
      { id: 50, snapshot_date: "2026-05-30", notes: null, created_at: "", updated_at: "" },
    ]); // getSnapshotById
    fake.executeQueue.push({ rowsAffected: 1 });
    await deleteSnapshot(50);
    const deletes = fake.calls.filter(
      (c) => typeof c.sql === "string" && c.sql.startsWith("DELETE")
    );
    // Exactly one DELETE — on balance_snapshots. No manual holdings/line wipes
    // (the FK CASCADE handles those; a manual delete would be the regression).
    expect(deletes).toHaveLength(1);
    expect(deletes[0].sql).toContain("DELETE FROM balance_snapshots WHERE id = $1");
    expect(deletes[0].params).toEqual([50]);
  });
});

// A standalone FakeDb factory for the multi-DB golden-value test, which swaps
// the active db handle mid-test. Mirrors makeFakeDb but is reusable inline.
function makeFakeDbLocal(): FakeDb {
  const db: FakeDb = {
    calls: [],
    selectQueue: [],
    executeQueue: [],
    select: vi.fn(),
    execute: vi.fn(),
  };
  db.select.mockImplementation(async (sql: string, params?: unknown[]) => {
    db.calls.push({ sql, params });
    if (db.selectQueue.length === 0) {
      throw new Error(`Unscripted SELECT (no queued result): ${sql}`);
    }
    return db.selectQueue.shift();
  });
  db.execute.mockImplementation(async (sql: string, params?: unknown[]) => {
    db.calls.push({ sql, params });
    if (db.executeQueue.length === 0) {
      return { rowsAffected: 1, lastInsertId: db.calls.length };
    }
    return db.executeQueue.shift();
  });
  return db;
}
