import { describe, it, expect, vi, beforeEach } from "vitest";
import {
  getMonthlyTrends,
  getCategoryOverTime,
  getHighlights,
  getCompareMonthOverMonth,
  getCompareYearOverYear,
  getCategoryZoom,
  buildCompareTree,
  buildOverTimeTree,
} from "./reportService";
import type { CategoryDelta, OverTimeRow } from "../shared/types";

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

import { getDb } from "./db";

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

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

describe("getMonthlyTrends", () => {
  it("builds query without WHERE clause when no filters are provided (regression: byte-identical to pre-#273)", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await getMonthlyTrends();

    const sql = mockSelect.mock.calls[0][0] as string;
    const params = mockSelect.mock.calls[0][1] as unknown[];
    expect(sql).not.toContain("WHERE");
    expect(params).toEqual([]);
  });

  it("applies date range filters without an accountIds argument (regression)", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await getMonthlyTrends("2025-01-01", "2025-12-31");

    const sql = mockSelect.mock.calls[0][0] as string;
    const params = mockSelect.mock.calls[0][1] as unknown[];
    expect(sql).toContain("date >= $1");
    expect(sql).toContain("date <= $2");
    expect(sql).not.toContain("source_id");
    expect(params).toEqual(["2025-01-01", "2025-12-31"]);
  });

  it("an empty accountIds array behaves exactly like no filter", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await getMonthlyTrends("2025-01-01", "2025-12-31", []);

    const sql = mockSelect.mock.calls[0][0] as string;
    const params = mockSelect.mock.calls[0][1] as unknown[];
    expect(sql).not.toContain("source_id");
    expect(params).toEqual(["2025-01-01", "2025-12-31"]);
  });

  it("applies a parameterized accountIds IN clause, one placeholder per id", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await getMonthlyTrends("2025-01-01", "2025-12-31", [3, 7]);

    const sql = mockSelect.mock.calls[0][0] as string;
    const params = mockSelect.mock.calls[0][1] as unknown[];
    expect(sql).toContain("source_id IN ($3, $4)");
    expect(sql).not.toContain("3,7");
    expect(params).toEqual(["2025-01-01", "2025-12-31", 3, 7]);
  });

  it("applies the accountIds filter alone (no dates)", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await getMonthlyTrends(undefined, undefined, [5]);

    const sql = mockSelect.mock.calls[0][0] as string;
    const params = mockSelect.mock.calls[0][1] as unknown[];
    expect(sql).toContain("WHERE source_id IN ($1)");
    expect(params).toEqual([5]);
  });
});

describe("getCategoryOverTime", () => {
  it("builds query without WHERE clause when no filters are provided", async () => {
    // First call: top categories, second call: monthly breakdown
    mockSelect
      .mockResolvedValueOnce([]) // topCategories
      .mockResolvedValueOnce([]); // monthlyRows

    await getCategoryOverTime();

    const topCatSQL = mockSelect.mock.calls[0][0] as string;
    // No WHERE clause should appear (no filters at all)
    expect(topCatSQL).not.toContain("WHERE");
  });

  it("applies typeFilter via category type when provided", async () => {
    mockSelect
      .mockResolvedValueOnce([]) // topCategories
      .mockResolvedValueOnce([]); // monthlyRows

    await getCategoryOverTime(undefined, undefined, 50, undefined, "expense");

    const topCatSQL = mockSelect.mock.calls[0][0] as string;
    const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
    expect(topCatSQL).toContain("COALESCE(c.type, 'expense')");
    expect(topCatSQL).toContain("WHERE");
    expect(topCatParams[0]).toBe("expense");
  });

  it("applies income typeFilter correctly", async () => {
    mockSelect
      .mockResolvedValueOnce([]) // topCategories
      .mockResolvedValueOnce([]); // monthlyRows

    await getCategoryOverTime("2025-01-01", "2025-06-30", 50, undefined, "income");

    const topCatSQL = mockSelect.mock.calls[0][0] as string;
    const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
    expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1");
    expect(topCatParams[0]).toBe("income");
    // Date params follow
    expect(topCatParams[1]).toBe("2025-01-01");
    expect(topCatParams[2]).toBe("2025-06-30");
  });

  it("applies date range filters", async () => {
    mockSelect
      .mockResolvedValueOnce([]) // topCategories
      .mockResolvedValueOnce([]); // monthlyRows

    await getCategoryOverTime("2025-01-01", "2025-12-31");

    const topCatSQL = mockSelect.mock.calls[0][0] as string;
    const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
    expect(topCatSQL).toContain("t.date >= $1");
    expect(topCatSQL).toContain("t.date <= $2");
    expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
  });

  it("applies accountIds filter (single id)", async () => {
    mockSelect
      .mockResolvedValueOnce([]) // topCategories
      .mockResolvedValueOnce([]); // monthlyRows

    await getCategoryOverTime(undefined, undefined, 50, [3]);

    const topCatSQL = mockSelect.mock.calls[0][0] as string;
    const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
    expect(topCatSQL).toContain("t.source_id IN ($1)");
    expect(topCatParams[0]).toBe(3);
  });

  it("applies accountIds filter with one bound placeholder per id (never joined into the SQL string)", async () => {
    mockSelect
      .mockResolvedValueOnce([]) // topCategories
      .mockResolvedValueOnce([]); // monthlyRows

    await getCategoryOverTime(undefined, undefined, 50, [3, 7, 12]);

    const topCatSQL = mockSelect.mock.calls[0][0] as string;
    const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
    expect(topCatSQL).toContain("t.source_id IN ($1, $2, $3)");
    expect(topCatSQL).not.toContain("3,7,12");
    // topN ($4) follows the 3 account placeholders.
    expect(topCatParams).toEqual([3, 7, 12, 50]);
  });

  it("an empty accountIds array behaves exactly like no filter (no clause, no orphan param)", async () => {
    mockSelect
      .mockResolvedValueOnce([]) // topCategories
      .mockResolvedValueOnce([]); // monthlyRows

    await getCategoryOverTime("2025-01-01", "2025-12-31", 50, []);

    const topCatSQL = mockSelect.mock.calls[0][0] as string;
    const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
    expect(topCatSQL).not.toContain("source_id");
    expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
  });

  it("combines typeFilter, date range, and accountIds", async () => {
    mockSelect
      .mockResolvedValueOnce([]) // topCategories
      .mockResolvedValueOnce([]); // monthlyRows

    await getCategoryOverTime("2025-01-01", "2025-06-30", 10, [2], "expense");

    const topCatSQL = mockSelect.mock.calls[0][0] as string;
    const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
    expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1");
    expect(topCatSQL).toContain("t.date >= $2");
    expect(topCatSQL).toContain("t.date <= $3");
    expect(topCatSQL).toContain("t.source_id IN ($4)");
    expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]);
  });

  it("projects category type into the top-N SELECT", async () => {
    mockSelect
      .mockResolvedValueOnce([]) // topCategories
      .mockResolvedValueOnce([]); // monthlyRows

    await getCategoryOverTime();

    const topCatSQL = mockSelect.mock.calls[0][0] as string;
    expect(topCatSQL).toContain("COALESCE(c.type, 'expense') AS category_type");
  });

  it("returns correct structure with categories, data, colors, categoryIds, and types", async () => {
    mockSelect
      .mockResolvedValueOnce([
        { category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
        { category_id: 2, category_name: "Salary", category_color: "#00ff00", category_type: "income", total: 200 },
      ])
      .mockResolvedValueOnce([
        { month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
        { month: "2025-01", category_id: 2, category_name: "Salary", total: 100 },
        { month: "2025-02", category_id: 1, category_name: "Food", total: 200 },
        { month: "2025-02", category_id: 2, category_name: "Salary", total: 100 },
      ]);

    const result = await getCategoryOverTime("2025-01-01", "2025-02-28", 50, undefined, "expense");

    expect(result.categories).toEqual(["Food", "Salary"]);
    expect(result.colors).toEqual({ Food: "#ff0000", Salary: "#00ff00" });
    expect(result.categoryIds).toEqual({ Food: 1, Salary: 2 });
    expect(result.types).toEqual({ Food: "expense", Salary: "income" });
    expect(result.data).toHaveLength(2);
    expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Salary: 100 });
    expect(result.data[1]).toEqual({ month: "2025-02", Food: 200, Salary: 100 });
  });

  it("defaults a null category type to expense in the types map", async () => {
    mockSelect
      .mockResolvedValueOnce([
        // COALESCE(c.type, 'expense') yields 'expense'; a null slipping through defaults too.
        { category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: null, total: 500 },
      ])
      .mockResolvedValueOnce([{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 }]);

    const result = await getCategoryOverTime();

    expect(result.types).toEqual({ Food: "expense" });
  });

  it("keeps the pivot top-N + Other but the id-keyed tree carries every category (Issue #264)", async () => {
    mockSelect
      // 1. top categories — top-N = 1, so only Food.
      .mockResolvedValueOnce([
        { category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
      ])
      // 2. monthly breakdown — every category (no LIMIT here).
      .mockResolvedValueOnce([
        { month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
        { month: "2025-01", category_id: 2, category_name: "Transport", total: 100 },
        { month: "2025-01", category_id: 3, category_name: "Entertainment", total: 50 },
      ])
      // 3. category metadata for the tree.
      .mockResolvedValueOnce([
        { id: 1, name: "Food", color: "#ff0000", type: "expense", parent_id: null },
        { id: 2, name: "Transport", color: "#00ff00", type: "expense", parent_id: null },
        { id: 3, name: "Entertainment", color: "#0000ff", type: "expense", parent_id: null },
      ]);

    const result = await getCategoryOverTime("2025-01-01", "2025-01-31", 1, undefined, "expense");

    // Pivot path is UNCHANGED — top-N (Food) plus an "Other" bucket for the rest.
    expect(result.categories).toEqual(["Food", "Other"]);
    expect(result.colors["Other"]).toBe("#9ca3af");
    expect(result.types).toEqual({ Food: "expense" });
    expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 });

    // Sidecar tree — EVERY category (no top-N, no "Other"), keyed by id.
    const treeNames = result.tree.map((r) => r.categoryName);
    expect(treeNames).toEqual(expect.arrayContaining(["Food", "Transport", "Entertainment"]));
    expect(treeNames).not.toContain("Other");
    const food = result.tree.find((r) => r.categoryId === 1)!;
    expect(food.monthly).toEqual([300]);
    expect(food.total).toBe(300);
    expect(food.category_type).toBe("expense");
    expect(food.is_parent).toBe(false);
  });

  it("separates homonym categories of different types in the tree (Issue #264)", async () => {
    mockSelect
      .mockResolvedValueOnce([]) // top categories (irrelevant to the tree)
      .mockResolvedValueOnce([
        // Two DIFFERENT categories both named "Divers".
        { month: "2025-01", category_id: 1, category_name: "Divers", total: 1000 },
        { month: "2025-01", category_id: 2, category_name: "Divers", total: 300 },
      ])
      .mockResolvedValueOnce([
        { id: 1, name: "Divers", color: null, type: "income", parent_id: null },
        { id: 2, name: "Divers", color: null, type: "expense", parent_id: null },
      ]);

    const result = await getCategoryOverTime("2025-01-01", "2025-01-31");

    // The pivot merges them (single "Divers" cell); the tree keeps them apart.
    const incomeDivers = result.tree.find((r) => r.categoryId === 1)!;
    const expenseDivers = result.tree.find((r) => r.categoryId === 2)!;
    expect(incomeDivers.category_type).toBe("income");
    expect(incomeDivers.total).toBe(1000);
    expect(expenseDivers.category_type).toBe("expense");
    expect(expenseDivers.total).toBe(300);
  });
});

describe("getHighlights", () => {
  // Reference month = March 2026, YTD year = 2026, today = 2026-04-14.
  const REF_YEAR = 2026;
  const REF_MONTH = 3;
  const YTD_YEAR = 2026;
  const TODAY = new Date(2026, 3, 14); // April 14, 2026 (month is 0-based here)
  const TODAY_STR = "2026-04-14";

  function queueEmpty(n: number) {
    for (let i = 0; i < n; i++) {
      mockSelect.mockResolvedValueOnce([]);
    }
  }

  it("computes windows and returns zeroed data on an empty profile", async () => {
    queueEmpty(5); // currentBalance, ytd, series, movers, recent

    const result = await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 30, 5, 10, TODAY);

    expect(result.currentMonth).toBe("2026-03");
    expect(result.netBalanceCurrent).toBe(0);
    expect(result.netBalanceYtd).toBe(0);
    expect(result.monthlyBalanceSeries).toHaveLength(12);
    // 12 months ending at the reference month (March 2026), inclusive.
    expect(result.monthlyBalanceSeries[11].month).toBe("2026-03");
    expect(result.monthlyBalanceSeries[0].month).toBe("2025-04");
    expect(result.topMovers).toEqual([]);
    expect(result.topTransactions).toEqual([]);
  });

  it("parameterises every query with no inlined strings", async () => {
    queueEmpty(5);

    await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 60, 5, 10, TODAY);

    for (const call of mockSelect.mock.calls) {
      const sql = call[0] as string;
      const params = call[1] as unknown[];
      expect(sql).not.toContain(`'${TODAY_STR}'`);
      expect(Array.isArray(params)).toBe(true);
    }
    // Reference month range
    const currentParams = mockSelect.mock.calls[0][1] as unknown[];
    expect(currentParams[0]).toBe("2026-03-01");
    expect(currentParams[1]).toBe("2026-03-31");
    // YTD spans Jan 1 of ytdYear → today (independent of reference month).
    const ytdParams = mockSelect.mock.calls[1][1] as unknown[];
    expect(ytdParams[0]).toBe("2026-01-01");
    expect(ytdParams[1]).toBe(TODAY_STR);
  });

  it("uses a 60-day window ending today for top transactions when requested", async () => {
    queueEmpty(5);

    await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 60, 5, 10, TODAY);

    const recentParams = mockSelect.mock.calls[4][1] as unknown[];
    // 60-day window ending today (2026-04-14): start = 2026-04-14 - 59 days = 2026-02-14.
    expect(recentParams[0]).toBe("2026-02-14");
    expect(recentParams[1]).toBe(TODAY_STR);
    expect(recentParams[2]).toBe(10);
  });

  it("computes top movers against the reference month's previous month", async () => {
    queueEmpty(3); // current balance, ytd, series
    mockSelect
      .mockResolvedValueOnce([
        {
          category_id: 1,
          category_name: "Restaurants",
          category_color: "#f97316",
          current_total: 240,
          previous_total: 200,
        },
      ])
      .mockResolvedValueOnce([]); // recent

    await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 30, 5, 10, TODAY);

    const moversParams = mockSelect.mock.calls[3][1] as unknown[];
    // Reference month = March 2026, previous = February 2026.
    expect(moversParams[0]).toBe("2026-03-01");
    expect(moversParams[1]).toBe("2026-03-31");
    expect(moversParams[2]).toBe("2026-02-01");
    expect(moversParams[3]).toBe("2026-02-28");
  });

  it("wraps January reference month back to December of the previous year for top movers", async () => {
    queueEmpty(5);

    await getHighlights(2026, 1, 2026, 30, 5, 10, new Date(2026, 0, 10));

    const moversParams = mockSelect.mock.calls[3][1] as unknown[];
    expect(moversParams[0]).toBe("2026-01-01");
    expect(moversParams[1]).toBe("2026-01-31");
    expect(moversParams[2]).toBe("2025-12-01");
    expect(moversParams[3]).toBe("2025-12-31");
  });

  it("computes deltaAbs and deltaPct from movers rows", async () => {
    mockSelect
      .mockResolvedValueOnce([{ net: -500 }]) // current balance
      .mockResolvedValueOnce([{ net: -1800 }]) // ytd
      .mockResolvedValueOnce([
        { month: "2026-03", net: -500 },
        { month: "2026-02", net: -400 },
      ]) // series
      .mockResolvedValueOnce([
        {
          category_id: 1,
          category_name: "Restaurants",
          category_color: "#f97316",
          current_total: 240,
          previous_total: 200,
        },
        {
          category_id: 2,
          category_name: "Groceries",
          category_color: "#10b981",
          current_total: 85,
          previous_total: 170,
        },
      ])
      .mockResolvedValueOnce([]); // recent

    const result = await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 30, 5, 10, TODAY);

    expect(result.netBalanceCurrent).toBe(-500);
    expect(result.netBalanceYtd).toBe(-1800);
    expect(result.topMovers).toHaveLength(2);
    expect(result.topMovers[0]).toMatchObject({
      categoryName: "Restaurants",
      currentAmount: 240,
      previousAmount: 200,
      deltaAbs: 40,
    });
    expect(result.topMovers[0].deltaPct).toBeCloseTo(20, 4);
    expect(result.topMovers[1].deltaAbs).toBe(-85);
    expect(result.topMovers[1].deltaPct).toBeCloseTo(-50, 4);
  });

  it("returns deltaPct=null when previous month total is zero", async () => {
    mockSelect
      .mockResolvedValueOnce([{ net: 0 }])
      .mockResolvedValueOnce([{ net: 0 }])
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([
        {
          category_id: 3,
          category_name: "New expense",
          category_color: "#3b82f6",
          current_total: 120,
          previous_total: 0,
        },
      ])
      .mockResolvedValueOnce([]);

    const result = await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 30, 5, 10, TODAY);

    expect(result.topMovers[0].deltaPct).toBeNull();
    expect(result.topMovers[0].deltaAbs).toBe(120);
  });
});

describe("getCompareMonthOverMonth", () => {
  it("passes monthly and cumulative boundaries as parameters", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await getCompareMonthOverMonth(2026, 4);

    // Two queries now: the delta aggregation (call 0, unchanged) + the category
    // metadata for the hierarchy (call 1, Issue #247).
    expect(mockSelect).toHaveBeenCalledTimes(2);
    expect(mockSelect.mock.calls[1][0]).toContain("FROM categories");
    const sql = mockSelect.mock.calls[0][0] as string;
    const params = mockSelect.mock.calls[0][1] as unknown[];
    expect(sql).toContain("$1");
    expect(sql).toContain("$8");
    // Monthly current ($1, $2), monthly previous ($3, $4),
    // cumulative current Jan→ref ($5, $6), cumulative previous Jan→prev ($7, $8).
    expect(params).toEqual([
      "2026-04-01", "2026-04-30",
      "2026-03-01", "2026-03-31",
      "2026-01-01", "2026-04-30",
      "2026-01-01", "2026-03-31",
    ]);
    expect(sql).not.toContain("'2026");
  });

  it("wraps to december of previous year when target month is january", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await getCompareMonthOverMonth(2026, 1);

    const params = mockSelect.mock.calls[0][1] as unknown[];
    // Cumulative-previous window lives entirely in 2025 (Jan → Dec), since the
    // previous month (Dec 2025) belongs to the prior calendar year.
    expect(params).toEqual([
      "2026-01-01", "2026-01-31",
      "2025-12-01", "2025-12-31",
      "2026-01-01", "2026-01-31",
      "2025-01-01", "2025-12-31",
    ]);
  });

  it("converts raw rows into CategoryDelta with monthly and cumulative deltas", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        category_id: 1,
        category_name: "Groceries",
        category_color: "#10b981",
        month_current_total: 500,
        month_previous_total: 400,
        cumulative_current_total: 2000,
        cumulative_previous_total: 1500,
      },
      {
        category_id: 2,
        category_name: "Restaurants",
        category_color: "#f97316",
        month_current_total: 120,
        month_previous_total: 0,
        cumulative_current_total: 300,
        cumulative_previous_total: 180,
      },
    ]);

    const result = await getCompareMonthOverMonth(2026, 4);

    expect(result).toHaveLength(2);
    expect(result[0]).toMatchObject({
      categoryName: "Groceries",
      currentAmount: 500,
      previousAmount: 400,
      deltaAbs: 100,
      cumulativeCurrentAmount: 2000,
      cumulativePreviousAmount: 1500,
      cumulativeDeltaAbs: 500,
    });
    expect(result[0].deltaPct).toBeCloseTo(25, 4);
    expect(result[0].cumulativeDeltaPct).toBeCloseTo((500 / 1500) * 100, 4);
    expect(result[1].deltaPct).toBeNull(); // monthly previous = 0
    expect(result[1].cumulativeDeltaPct).toBeCloseTo(((300 - 180) / 180) * 100, 4);
  });

  it("yields null deltaPct for both blocks when previous is zero", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        category_id: 3,
        category_name: "New",
        category_color: "#3b82f6",
        month_current_total: 50,
        month_previous_total: 0,
        cumulative_current_total: 80,
        cumulative_previous_total: 0,
      },
    ]);

    const result = await getCompareMonthOverMonth(2026, 4);

    expect(result[0].deltaPct).toBeNull();
    expect(result[0].cumulativeDeltaPct).toBeNull();
    expect(result[0].deltaAbs).toBe(50);
    expect(result[0].cumulativeDeltaAbs).toBe(80);
  });

  it("without accountIds, the delta SQL/params stay byte-identical to pre-#273 (regression)", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await getCompareMonthOverMonth(2026, 4);

    const sql = mockSelect.mock.calls[0][0] as string;
    const params = mockSelect.mock.calls[0][1] as unknown[];
    expect(sql).not.toContain("source_id");
    expect(params).toHaveLength(8);
  });

  it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await getCompareMonthOverMonth(2026, 4, []);

    const sql = mockSelect.mock.calls[0][0] as string;
    const params = mockSelect.mock.calls[0][1] as unknown[];
    expect(sql).not.toContain("source_id");
    expect(params).toHaveLength(8);
  });

  it("applies a parameterized accountIds IN clause starting at $9 (after the 8 date bounds)", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await getCompareMonthOverMonth(2026, 4, [3, 7]);

    const sql = mockSelect.mock.calls[0][0] as string;
    const params = mockSelect.mock.calls[0][1] as unknown[];
    expect(sql).toContain("AND t.source_id IN ($9, $10)");
    expect(sql).not.toContain("3,7");
    expect(params).toEqual([
      "2026-04-01", "2026-04-30",
      "2026-03-01", "2026-03-31",
      "2026-01-01", "2026-04-30",
      "2026-01-01", "2026-03-31",
      3, 7,
    ]);
  });
});

describe("getCompareYearOverYear", () => {
  it("compares a single month YoY and the YTD window through that month", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await getCompareYearOverYear(2026, 4);

    const params = mockSelect.mock.calls[0][1] as unknown[];
    expect(params).toEqual([
      "2026-04-01", "2026-04-30",
      "2025-04-01", "2025-04-30",
      "2026-01-01", "2026-04-30",
      "2025-01-01", "2025-04-30",
    ]);
  });

  it("defaults to december when no reference month is supplied", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await getCompareYearOverYear(2026);

    const params = mockSelect.mock.calls[0][1] as unknown[];
    expect(params).toEqual([
      "2026-12-01", "2026-12-31",
      "2025-12-01", "2025-12-31",
      "2026-01-01", "2026-12-31",
      "2025-01-01", "2025-12-31",
    ]);
  });

  it("populates both blocks for a YoY row", async () => {
    mockSelect.mockResolvedValueOnce([
      {
        category_id: 1,
        category_name: "Groceries",
        category_color: "#10b981",
        month_current_total: 600,
        month_previous_total: 400,
        cumulative_current_total: 2500,
        cumulative_previous_total: 1600,
      },
    ]);

    const result = await getCompareYearOverYear(2026, 4);

    expect(result[0]).toMatchObject({
      currentAmount: 600,
      previousAmount: 400,
      deltaAbs: 200,
      cumulativeCurrentAmount: 2500,
      cumulativePreviousAmount: 1600,
      cumulativeDeltaAbs: 900,
    });
    expect(result[0].deltaPct).toBeCloseTo(50, 4);
    expect(result[0].cumulativeDeltaPct).toBeCloseTo((900 / 1600) * 100, 4);
  });

  it("without accountIds, the delta SQL/params stay byte-identical to pre-#273 (regression)", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await getCompareYearOverYear(2026, 4);

    const sql = mockSelect.mock.calls[0][0] as string;
    const params = mockSelect.mock.calls[0][1] as unknown[];
    expect(sql).not.toContain("source_id");
    expect(params).toHaveLength(8);
  });

  it("applies a parameterized accountIds IN clause starting at $9 (after the 8 date bounds)", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await getCompareYearOverYear(2026, 4, [9]);

    const sql = mockSelect.mock.calls[0][0] as string;
    const params = mockSelect.mock.calls[0][1] as unknown[];
    expect(sql).toContain("AND t.source_id IN ($9)");
    expect(params).toEqual([
      "2026-04-01", "2026-04-30",
      "2025-04-01", "2025-04-30",
      "2026-01-01", "2026-04-30",
      "2025-01-01", "2025-04-30",
      9,
    ]);
  });
});

describe("real-vs-real compare — transfer netting (Issue #243)", () => {
  // A `transfer` category (e.g. "Paiement CC") is a money move, not spending:
  // its debit and matching credit must cancel to ~0 in the real-vs-real
  // compare. Ordinary categories keep the expense-report behavior (only
  // outflows, summed as positive magnitudes). The netting lives entirely in
  // the SQL (COMPARE_DELTA_SQL): a signed SUM(t.amount) for `transfer`,
  // ABS(t.amount) filtered on amount < 0 for plain expenses. Income, like
  // transfers, is a signed SUM (Issue #253); the WHERE is broadened so
  // income/transfer credits are not dropped before they can count or cancel.
  //
  // There is no runnable SQLite in these unit tests (tauri-plugin-sql only
  // exists inside the Tauri WebView, and CI runs Node 20 → no node:sqlite), so
  // the netting is pinned two ways: (1) a reference-semantics check over
  // concrete debit/credit rows that reproduces the exact per-row rule the SQL
  // encodes, and (2) structural assertions that the real query on BOTH compare
  // functions carries that rule and the broadened WHERE.

  /** Per-row contribution to a bucket — the exact rule COMPARE_DELTA_SQL encodes. */
  function bucketContribution(amount: number, type: string | null): number {
    const t = type ?? "expense";
    return t === "transfer" || t === "income"
      ? amount // signed → transfer debit/credit cancel; income credits kept as revenue (#253)
      : amount < 0
        ? Math.abs(amount) // expense outflow as a positive magnitude
        : 0; // other (expense) credits dropped — the report stays outflow-only for them
  }

  it("nets a balanced transfer to ~0 while an expense keeps its sum of outflows", () => {
    // "Paiement CC" (transfer): -500 out of chequing, +500 onto the card.
    const transfer = [-500, 500].reduce((s, a) => s + bucketContribution(a, "transfer"), 0);
    expect(transfer).toBeCloseTo(0, 6);

    // "Épicerie" (expense): two debits + one refund credit that must NOT net it down.
    const expense = [-120, -80, 60].reduce((s, a) => s + bucketContribution(a, "expense"), 0);
    expect(expense).toBe(200);

    // Uncategorized (c.type NULL) defaults to expense behavior.
    const uncategorized = [-40, 15].reduce((s, a) => s + bucketContribution(a, null), 0);
    expect(uncategorized).toBe(40);

    // Income (paie + a small correction) is a signed SUM — credits are kept as
    // revenue (Issue #253), not dropped like non-transfer expense credits.
    const income = [4200, 150, -50].reduce((s, a) => s + bucketContribution(a, "income"), 0);
    expect(income).toBe(4300);
  });

  it("getCompareMonthOverMonth SQL nets transfers (signed) but keeps ABS for other types", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await getCompareMonthOverMonth(2026, 4);

    const sql = mockSelect.mock.calls[0][0] as string;
    // transfer → signed t.amount; every other type → ABS(t.amount)
    expect(sql).toContain(
      "COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount)",
    );
    // WHERE broadened so transfer credits survive the expense (amount < 0) filter
    expect(sql).toContain(
      "WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))",
    );
    // still four date buckets
    expect(sql).toContain("month_current_total");
    expect(sql).toContain("cumulative_previous_total");
  });

  it("getCompareYearOverYear applies the identical netting contract (both tabs fixed)", async () => {
    mockSelect.mockResolvedValueOnce([]);

    await getCompareYearOverYear(2026, 4);

    const sql = mockSelect.mock.calls[0][0] as string;
    expect(sql).toContain(
      "COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount)",
    );
    expect(sql).toContain(
      "WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))",
    );
  });

  it("keeps a netted transfer at 0 through the pipeline while an expense is unchanged", async () => {
    // Rows as the netted SQL returns them: the balanced transfer arrives at 0,
    // the ordinary expense keeps its outflow sum. Assert nothing downstream
    // re-inflates the transfer.
    mockSelect.mockResolvedValueOnce([
      {
        category_id: 5,
        category_name: "Épicerie",
        category_color: "#10b981",
        month_current_total: 200,
        month_previous_total: 150,
        cumulative_current_total: 800,
        cumulative_previous_total: 600,
      },
      {
        category_id: 71,
        category_name: "Paiement CC",
        category_color: "#9ca3af",
        month_current_total: 0,
        month_previous_total: 0,
        cumulative_current_total: 0,
        cumulative_previous_total: 0,
      },
    ]);

    const result = await getCompareMonthOverMonth(2026, 4);
    const transferRow = result.find((r) => r.categoryName === "Paiement CC")!;
    const expenseRow = result.find((r) => r.categoryName === "Épicerie")!;

    expect(transferRow.currentAmount).toBe(0);
    expect(transferRow.previousAmount).toBe(0);
    expect(transferRow.deltaAbs).toBe(0);
    expect(transferRow.cumulativeCurrentAmount).toBe(0);
    // Ordinary expense keeps EXACTLY its sum of outflows.
    expect(expenseRow.currentAmount).toBe(200);
    expect(expenseRow.previousAmount).toBe(150);
  });
});

describe("getCategoryZoom", () => {
  it("uses a bounded recursive CTE when including subcategories", async () => {
    mockSelect
      .mockResolvedValueOnce([]) // transactions
      .mockResolvedValueOnce([{ rollup: 0 }]) // rollup
      .mockResolvedValueOnce([]) // byChild
      .mockResolvedValueOnce([]); // evolution

    await getCategoryZoom(42, "2026-01-01", "2026-12-31", true);

    const txSql = mockSelect.mock.calls[0][0] as string;
    expect(txSql).toContain("WITH RECURSIVE cat_tree");
    expect(txSql).toContain("WHERE ct.depth < 5");
    const txParams = mockSelect.mock.calls[0][1] as unknown[];
    expect(txParams).toEqual([42, "2026-01-01", "2026-12-31"]);
  });

  it("terminates on a cyclic category tree because of the depth cap", async () => {
    // Simulate a cyclic parent_id chain. Since the mocked db.select simply
    // returns our canned values, the real cycle guard (depth < 5) is what we
    // can assert: the CTE must include the bound.
    mockSelect
      .mockResolvedValueOnce([]) // transactions
      .mockResolvedValueOnce([{ rollup: 0 }]) // rollup
      .mockResolvedValueOnce([]) // byChild
      .mockResolvedValueOnce([]); // evolution

    await expect(
      getCategoryZoom(1, "2026-01-01", "2026-01-31", true),
    ).resolves.toBeDefined();

    // Every recursive query sent must contain the depth guard.
    for (const call of mockSelect.mock.calls) {
      const sql = call[0] as string;
      if (sql.includes("cat_tree")) {
        expect(sql).toContain("ct.depth < 5");
      }
    }
  });

  it("issues a direct-only query when includeSubcategories is false", async () => {
    mockSelect
      .mockResolvedValueOnce([]) // transactions
      .mockResolvedValueOnce([{ rollup: 0 }]); // rollup — no byChild / evolution recursive here
    // evolution is still queried with categoryId = direct
    mockSelect.mockResolvedValueOnce([]);

    await getCategoryZoom(7, "2026-01-01", "2026-12-31", false);

    const txSql = mockSelect.mock.calls[0][0] as string;
    expect(txSql).not.toContain("cat_tree");
    expect(txSql).toContain("t.category_id = $1");
  });
});

describe("buildCompareTree — hierarchical real-vs-real (Issue #247)", () => {
  // Flat leaf, mirroring what rowsToDeltas returns from COMPARE_DELTA_SQL.
  function leaf(
    categoryId: number | null,
    categoryName: string,
    mc: number,
    mp: number,
    cc = mc,
    cp = mp,
  ): CategoryDelta {
    return {
      categoryId,
      categoryName,
      categoryColor: "#000",
      previousAmount: mp,
      currentAmount: mc,
      deltaAbs: mc - mp,
      deltaPct: mp !== 0 ? ((mc - mp) / mp) * 100 : null,
      cumulativePreviousAmount: cp,
      cumulativeCurrentAmount: cc,
      cumulativeDeltaAbs: cc - cp,
      cumulativeDeltaPct: cp !== 0 ? ((cc - cp) / cp) * 100 : null,
    };
  }

  const cats = [
    { id: 2, name: "Dépenses", color: null, type: "expense", parent_id: null },
    { id: 22, name: "Épicerie", color: "#10b981", type: "expense", parent_id: 2 },
    { id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 },
    { id: 5, name: "Placements", color: null, type: "transfer", parent_id: null },
    { id: 50, name: "REER", color: null, type: "transfer", parent_id: 5 },
    { id: 8, name: "Revenus", color: null, type: "income", parent_id: null },
    { id: 80, name: "Paie", color: null, type: "income", parent_id: 8 },
  ] as Parameters<typeof buildCompareTree>[1];

  it("nests leaves under a parent subtotal equal to the sum of its children", () => {
    const rows = buildCompareTree(
      [leaf(22, "Épicerie", 500, 400, 2000, 1500), leaf(24, "Restaurant", 120, 200, 300, 500)],
      cats,
    );

    // Parent subtotal on top, then children ordered by |monthly delta| desc.
    expect(rows.map((r) => r.categoryName)).toEqual(["Dépenses", "Épicerie", "Restaurant"]);

    const parent = rows[0];
    expect(parent.is_parent).toBe(true);
    expect(parent.categoryId).toBe(2);
    expect(parent.depth).toBe(0);
    expect(parent.category_type).toBe("expense");
    // Subtotal = sum of children (the group's net).
    expect(parent.currentAmount).toBe(620);
    expect(parent.previousAmount).toBe(600);
    expect(parent.deltaAbs).toBe(20);
    expect(parent.cumulativeCurrentAmount).toBe(2300);
    expect(parent.cumulativePreviousAmount).toBe(2000);
    expect(parent.cumulativeDeltaAbs).toBe(300);

    // Children are leaves at depth 1 with UNCHANGED values (no regression).
    const epicerie = rows.find((r) => r.categoryName === "Épicerie")!;
    expect(epicerie.is_parent).toBeFalsy();
    expect(epicerie.depth).toBe(1);
    expect(epicerie.parent_id).toBe(2);
    expect(epicerie.currentAmount).toBe(500);
    expect(epicerie.previousAmount).toBe(400);
    expect(epicerie.deltaAbs).toBe(100);
  });

  it("keeps a balanced transfer group netted to 0 at the subtotal (preserves #243)", () => {
    const rows = buildCompareTree([leaf(50, "REER", 0, 0, 0, 0)], cats);
    const parent = rows.find((r) => r.categoryId === 5)!;
    expect(parent.is_parent).toBe(true);
    expect(parent.category_type).toBe("transfer");
    // The netted-to-zero transfer stays 0 through the subtotal — not re-inflated.
    expect(parent.currentAmount).toBe(0);
    expect(parent.previousAmount).toBe(0);
    expect(parent.deltaAbs).toBe(0);
  });

  it("orders sections income → expense → transfer and keeps subtrees contiguous", () => {
    const rows = buildCompareTree(
      [leaf(80, "Paie", 4200, 4000), leaf(22, "Épicerie", 500, 400), leaf(50, "REER", 0, 0)],
      cats,
    );
    const types = rows.map((r) => r.category_type);
    // Income section first, then expenses, then transfers (Issue #253).
    expect(types).toEqual(["income", "income", "expense", "expense", "transfer", "transfer"]);
  });

  it("preserves grand-total invariance vs the flat leaves", () => {
    const flat = [
      leaf(22, "Épicerie", 500, 400, 2000, 1500),
      leaf(24, "Restaurant", 120, 200, 300, 500),
      leaf(50, "REER", 0, 0, 0, 0),
    ];
    const rows = buildCompareTree(flat, cats);
    const sumLeaves = (rs: CategoryDelta[]) =>
      rs.filter((r) => !r.is_parent).reduce((s, r) => s + r.currentAmount, 0);
    expect(sumLeaves(rows)).toBe(sumLeaves(flat));
  });

  it("surfaces an uncategorized (null id) row as a top-level leaf", () => {
    const rows = buildCompareTree([leaf(null, "Uncategorized", 90, 40)], cats);
    expect(rows).toHaveLength(1);
    expect(rows[0].categoryName).toBe("Uncategorized");
    expect(rows[0].is_parent).toBeFalsy();
    expect(rows[0].depth).toBe(0);
    expect(rows[0].currentAmount).toBe(90);
  });

  it("keeps a leaf whose category was soft-deleted (still in the metadata)", () => {
    // A single leaf with no siblings collapses to just that leaf (no lone subtotal).
    const rows = buildCompareTree([leaf(22, "Épicerie", 75, 60)], cats);
    const names = rows.map((r) => r.categoryName);
    expect(names).toContain("Épicerie");
    // A parent with exactly one contributing child still gets a subtotal row.
    const parent = rows.find((r) => r.is_parent);
    expect(parent?.currentAmount).toBe(75);
  });
});

describe("buildOverTimeTree — hierarchical trends (Issue #264)", () => {
  /** Flat id-keyed leaf, mirroring what getCategoryOverTime feeds the builder. */
  function otLeaf(categoryId: number | null, categoryName: string, monthly: number[]): OverTimeRow {
    return {
      categoryId,
      categoryName,
      categoryColor: "#000",
      monthly,
      total: monthly.reduce((s, v) => s + v, 0),
      parent_id: null,
      is_parent: false,
      depth: 0,
      category_type: "expense",
    };
  }

  const cats = [
    { id: 2, name: "Dépenses", color: null, type: "expense", parent_id: null },
    { id: 22, name: "Épicerie", color: "#10b981", type: "expense", parent_id: 2 },
    { id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 },
    { id: 8, name: "Revenus", color: null, type: "income", parent_id: null },
    { id: 80, name: "Paie", color: null, type: "income", parent_id: 8 },
    { id: 5, name: "Placements", color: null, type: "transfer", parent_id: null },
    { id: 50, name: "REER", color: null, type: "transfer", parent_id: 5 },
  ] as Parameters<typeof buildOverTimeTree>[1];

  it("nests leaves under a parent subtotal equal to the per-month sum of its children", () => {
    const rows = buildOverTimeTree(
      [otLeaf(22, "Épicerie", [500, 400]), otLeaf(24, "Restaurant", [120, 200])],
      cats,
      2,
    );

    // Parent subtotal on top, then children ordered by |total| desc.
    expect(rows.map((r) => r.categoryName)).toEqual(["Dépenses", "Épicerie", "Restaurant"]);

    const parent = rows[0];
    expect(parent.is_parent).toBe(true);
    expect(parent.categoryId).toBe(2);
    expect(parent.depth).toBe(0);
    expect(parent.category_type).toBe("expense");
    // Subtotal series is index-aligned and equals the per-month child sums.
    expect(parent.monthly).toEqual([620, 600]);
    expect(parent.total).toBe(1220);

    const epicerie = rows.find((r) => r.categoryName === "Épicerie")!;
    expect(epicerie.is_parent).toBe(false);
    expect(epicerie.depth).toBe(1);
    expect(epicerie.parent_id).toBe(2);
    expect(epicerie.monthly).toEqual([500, 400]);
  });

  it("orders sections income → expense → transfer and keeps subtrees contiguous", () => {
    const rows = buildOverTimeTree(
      [otLeaf(80, "Paie", [4200]), otLeaf(22, "Épicerie", [500]), otLeaf(50, "REER", [0])],
      cats,
      1,
    );
    // Each leaf sits under a parent, so every section yields [subtotal, leaf].
    expect(rows.map((r) => r.category_type)).toEqual([
      "income",
      "income",
      "expense",
      "expense",
      "transfer",
      "transfer",
    ]);
  });

  it("preserves grand-total invariance vs the flat leaves", () => {
    const flat = [
      otLeaf(22, "Épicerie", [500, 400]),
      otLeaf(24, "Restaurant", [120, 200]),
      otLeaf(50, "REER", [0, 0]),
    ];
    const rows = buildOverTimeTree(flat, cats, 2);
    const sumLeaves = (rs: OverTimeRow[]) =>
      rs.filter((r) => !r.is_parent).reduce((s, r) => s + r.total, 0);
    expect(sumLeaves(rows)).toBe(sumLeaves(flat));
  });

  it("surfaces an uncategorized (null id) leaf as a top-level row", () => {
    const rows = buildOverTimeTree([otLeaf(null, "Uncategorized", [90])], cats, 1);
    expect(rows).toHaveLength(1);
    expect(rows[0].categoryName).toBe("Uncategorized");
    expect(rows[0].is_parent).toBe(false);
    expect(rows[0].depth).toBe(0);
    expect(rows[0].monthly).toEqual([90]);
  });

  it("terminates on a cyclic parent_id chain (A → B → A) without overflowing", () => {
    const cyclic = [
      { id: 1, name: "A", color: null, type: "expense", parent_id: 2 },
      { id: 2, name: "B", color: null, type: "expense", parent_id: 1 },
    ] as Parameters<typeof buildOverTimeTree>[1];

    // The relevant-ancestor walk and buildNode both cap at MAX_TREE_DEPTH, so a
    // corrupted cycle returns (no root emerges) instead of recursing forever —
    // .not.toThrow() catches a "Maximum call stack" RangeError if it overflowed.
    let rows: OverTimeRow[] = [];
    expect(() => {
      rows = buildOverTimeTree([otLeaf(1, "A", [100])], cyclic, 1);
    }).not.toThrow();
    expect(Array.isArray(rows)).toBe(true);
    for (const r of rows) expect(r.depth).toBeLessThanOrEqual(5);
  });
});

describe("getCompareMonthOverMonth — wires the hierarchy (Issue #247)", () => {
  it("returns parent subtotal rows when category metadata is available", async () => {
    mockSelect
      // 1. COMPARE_DELTA_SQL — two sibling leaves under parent id 2.
      .mockResolvedValueOnce([
        {
          category_id: 22,
          category_name: "Épicerie",
          category_color: "#10b981",
          month_current_total: 500,
          month_previous_total: 400,
          cumulative_current_total: 2000,
          cumulative_previous_total: 1500,
        },
        {
          category_id: 24,
          category_name: "Restaurant",
          category_color: "#f97316",
          month_current_total: 120,
          month_previous_total: 200,
          cumulative_current_total: 300,
          cumulative_previous_total: 500,
        },
      ])
      // 2. COMPARE_CATEGORIES_SQL — the hierarchy.
      .mockResolvedValueOnce([
        { id: 2, name: "Dépenses", color: null, type: "expense", parent_id: null },
        { id: 22, name: "Épicerie", color: "#10b981", type: "expense", parent_id: 2 },
        { id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 },
      ]);

    const result = await getCompareMonthOverMonth(2026, 4);

    // Delta query stays call 0 (netting SQL + params assertions elsewhere rely on it).
    expect(mockSelect.mock.calls[0][0]).toContain("month_current_total");
    expect(mockSelect.mock.calls[1][0]).toContain("FROM categories");

    const parent = result.find((r) => r.is_parent);
    expect(parent).toBeDefined();
    expect(parent!.categoryId).toBe(2);
    expect(parent!.currentAmount).toBe(620);
    // Leaves survive unchanged.
    expect(result.find((r) => r.categoryName === "Épicerie")!.currentAmount).toBe(500);
  });
});
