// StarterAccountsModal — unit tests (issue #179)
//
// NOTE: This project does not have @testing-library/react or jsdom configured
// (matches the BalanceOnboardingCard.test.tsx pattern from #178). Tests cover
// the service-layer helpers (`getStarterCollisions`, `proposeStarterAccounts`)
// and the `STARTER_ACCOUNTS` constant — the modal itself is pure orchestration
// over those helpers.

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())),
  };
});

import { getDb } from "../../services/db";
import {
  STARTER_ACCOUNTS,
  getStarterCollisions,
  proposeStarterAccounts,
} from "../../services/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();
});

describe("STARTER_ACCOUNTS", () => {
  it("ships exactly 4 starters keyed cash/tfsa/rrsp/other", () => {
    expect(STARTER_ACCOUNTS).toHaveLength(4);
    expect(STARTER_ACCOUNTS.map((s) => s.key)).toEqual([
      "cash",
      "tfsa",
      "rrsp",
      "other",
    ]);
    for (const s of STARTER_ACCOUNTS) {
      expect(s.i18nKey).toMatch(/^balance\.starters\.items\./);
    }
  });

  it("maps CELI/REER to the `other` asset class with a vehicle_type (#202)", () => {
    // After the envelope/asset-class split, only `cash` keeps its own class;
    // CELI/REER/non-registered all live under `other`, and the envelope is
    // carried by vehicle_type (NOT an automobile type).
    const byKey = Object.fromEntries(STARTER_ACCOUNTS.map((s) => [s.key, s]));
    expect(byKey.cash.categoryKey).toBe("cash");
    expect(byKey.cash.vehicleType).toBeNull();
    expect(byKey.tfsa.categoryKey).toBe("other");
    expect(byKey.tfsa.vehicleType).toBe("tfsa");
    expect(byKey.rrsp.categoryKey).toBe("other");
    expect(byKey.rrsp.vehicleType).toBe("rrsp");
    expect(byKey.other.categoryKey).toBe("other");
    expect(byKey.other.vehicleType).toBeNull();
  });
});

describe("getStarterCollisions", () => {
  it("returns empty set when no accounts collide", async () => {
    mockSelect.mockResolvedValueOnce([]);
    const result = await getStarterCollisions();
    expect(result.size).toBe(0);
  });

  it("flags exact-name collisions case-insensitive trim", async () => {
    mockSelect.mockResolvedValueOnce([
      { key: "cash", account_name: "  compte chèque " },
      { key: "tfsa", account_name: "Mon CELI 2024" }, // does NOT match "CELI" exactly
    ]);
    const result = await getStarterCollisions();
    expect(result.has("cash")).toBe(true);
    expect(result.has("tfsa")).toBe(false);
    expect(result.has("rrsp")).toBe(false);
    expect(result.has("other")).toBe(false);
  });

  it("requires the account to live in the matching category", async () => {
    // CELI-named account but in 'cash' category → not a collision for tfsa starter
    mockSelect.mockResolvedValueOnce([
      { key: "cash", account_name: "CELI" },
    ]);
    const result = await getStarterCollisions();
    expect(result.has("tfsa")).toBe(false);
    expect(result.has("cash")).toBe(false); // name "CELI" != "Compte chèque"
  });

  it("flags the CELI starter when a CELI account lives in the `other` class (#202)", async () => {
    // Post-split, the CELI starter's categoryKey is `other`. An exact-name CELI
    // account in `other` is a collision; a non-registered account in `other` is not.
    mockSelect.mockResolvedValueOnce([
      { key: "other", account_name: "CELI" },
      { key: "other", account_name: "Compte non-enregistré" },
    ]);
    const result = await getStarterCollisions();
    expect(result.has("tfsa")).toBe(true);
    expect(result.has("other")).toBe(true);
    expect(result.has("rrsp")).toBe(false);
    expect(result.has("cash")).toBe(false);
  });

  it("queries only the cash + other asset classes (#202)", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await getStarterCollisions();
    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toMatch(/c\.key IN \('cash','other'\)/);
  });

  it("excludes archived accounts via SQL filter", async () => {
    mockSelect.mockResolvedValueOnce([]);
    await getStarterCollisions();
    const sql = mockSelect.mock.calls[0][0];
    expect(sql).toMatch(/archived_at IS NULL/);
  });
});

describe("proposeStarterAccounts", () => {
  it("returns [] when no keys selected without opening a transaction", async () => {
    const result = await proposeStarterAccounts([]);
    expect(result).toEqual([]);
    expect(mockExecute).not.toHaveBeenCalled();
  });

  it("inserts selected starters atomically and returns their ids", async () => {
    // BEGIN
    mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 });
    // For each starter: SELECT category id, SELECT in-txn collision check, INSERT
    mockSelect
      .mockResolvedValueOnce([{ id: 11 }])      // cash category lookup
      .mockResolvedValueOnce([{ count: 0 }])    // S3 collision check for cash
      .mockResolvedValueOnce([{ id: 13 }])      // rrsp category lookup
      .mockResolvedValueOnce([{ count: 0 }]);   // S3 collision check for rrsp
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 100 }) // INSERT cash
      .mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 101 }) // INSERT rrsp
      .mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // COMMIT

    const result = await proposeStarterAccounts(["cash", "rrsp"]);
    expect(result).toEqual([100, 101]);

    const sqls = mockExecute.mock.calls.map((c) => c[0]);
    expect(sqls[0]).toBe("BEGIN");
    expect(sqls[sqls.length - 1]).toBe("COMMIT");
    expect(sqls.filter((s) => /INSERT INTO balance_accounts/.test(s))).toHaveLength(2);
  });

  it("skips silently when in-txn collision check finds an existing account (S3)", async () => {
    // BEGIN
    mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 });
    // First starter "cash": category lookup succeeds, collision check returns count=1 → skip
    mockSelect
      .mockResolvedValueOnce([{ id: 11 }])      // cash category lookup
      .mockResolvedValueOnce([{ count: 1 }])    // S3 collision: cash already exists
      // Second starter "rrsp": category lookup + clean collision check
      .mockResolvedValueOnce([{ id: 13 }])      // rrsp category lookup
      .mockResolvedValueOnce([{ count: 0 }]);   // rrsp clean
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 101 }) // INSERT rrsp
      .mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 });  // COMMIT

    const result = await proposeStarterAccounts(["cash", "rrsp"]);
    expect(result).toEqual([101]); // only rrsp inserted, cash skipped silently

    const sqls = mockExecute.mock.calls.map((c) => c[0]);
    expect(sqls.filter((s) => /INSERT INTO balance_accounts/.test(s))).toHaveLength(1);
    expect(sqls).toContain("COMMIT"); // no rollback — skip is normal flow
  });

  it("resolves categories with is_active=1 and writes vehicle_type (#202)", async () => {
    mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN
    mockSelect
      .mockResolvedValueOnce([{ id: 50 }]) // other category lookup (for tfsa starter)
      .mockResolvedValueOnce([{ count: 0 }]); // S3 collision check clean
    mockExecute
      .mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 200 }) // INSERT CELI
      .mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // COMMIT

    const result = await proposeStarterAccounts(["tfsa"]);
    expect(result).toEqual([200]);

    // Category lookup must require is_active = 1 so a deactivated ex-envelope
    // seed is never picked up.
    const lookupSql = mockSelect.mock.calls[0][0] as string;
    expect(lookupSql).toMatch(/is_active = 1/);
    // The CELI starter is told to resolve `other`, not `tfsa`.
    expect(mockSelect.mock.calls[0][1]).toEqual(["other"]);

    // The INSERT carries vehicle_type='tfsa' as its 3rd param.
    const insertCall = mockExecute.mock.calls.find((c) =>
      /INSERT INTO balance_accounts/.test(c[0] as string)
    )!;
    const insertSql = insertCall[0] as string;
    expect(insertSql).toContain("vehicle_type");
    expect((insertCall[1] as unknown[])[2]).toBe("tfsa");
  });

  it("rolls back on insert failure", async () => {
    mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN
    mockSelect
      .mockResolvedValueOnce([{ id: 11 }])      // cash category
      .mockResolvedValueOnce([{ count: 0 }]);   // S3 collision check clean
    mockExecute.mockRejectedValueOnce(new Error("disk full"));
    mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // ROLLBACK

    await expect(proposeStarterAccounts(["cash"])).rejects.toThrow();

    const sqls = mockExecute.mock.calls.map((c) => c[0]);
    expect(sqls).toContain("BEGIN");
    expect(sqls).toContain("ROLLBACK");
    expect(sqls).not.toContain("COMMIT");
  });
});
