import { describe, it, expect, vi, beforeEach } from "vitest";
import { shiftMonth, getCartesSnapshot } from "./reportService";

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("shiftMonth", () => {
  it("shifts forward within a year", () => {
    expect(shiftMonth(2026, 1, 2)).toEqual({ year: 2026, month: 3 });
  });

  it("shifts backward within a year", () => {
    expect(shiftMonth(2026, 6, -3)).toEqual({ year: 2026, month: 3 });
  });

  it("wraps around January to the previous year", () => {
    expect(shiftMonth(2026, 1, -1)).toEqual({ year: 2025, month: 12 });
  });

  it("wraps past multiple years back", () => {
    expect(shiftMonth(2026, 4, -24)).toEqual({ year: 2024, month: 4 });
  });

  it("wraps past year forward", () => {
    expect(shiftMonth(2025, 11, 3)).toEqual({ year: 2026, month: 2 });
  });
});

/**
 * Dispatch mock SELECT responses based on the SQL fragment being queried.
 * Each entry returns the canned rows for queries whose text contains `match`.
 */
function routeSelect(routes: { match: string; rows: unknown[] }[]): void {
  mockSelect.mockImplementation((sql: string) => {
    for (const r of routes) {
      if (sql.includes(r.match)) return Promise.resolve(r.rows);
    }
    return Promise.resolve([]);
  });
}

describe("getCartesSnapshot", () => {
  it("returns zero-filled KPIs when there is no data", async () => {
    routeSelect([]);
    const snapshot = await getCartesSnapshot(2026, 3);
    expect(snapshot.referenceYear).toBe(2026);
    expect(snapshot.referenceMonth).toBe(3);
    expect(snapshot.kpis.income.current).toBe(0);
    expect(snapshot.kpis.expenses.current).toBe(0);
    expect(snapshot.kpis.net.current).toBe(0);
    // Savings rate is null (renders as "—") when income is zero.
    expect(snapshot.kpis.savingsRate.current).toBeNull();
    expect(snapshot.kpis.income.sparkline).toHaveLength(13);
    expect(snapshot.flow12Months).toHaveLength(12);
    expect(snapshot.topMoversUp).toHaveLength(0);
    expect(snapshot.topMoversDown).toHaveLength(0);
    expect(snapshot.budgetAdherence.categoriesTotal).toBe(0);
    expect(snapshot.seasonality.historicalYears).toHaveLength(0);
    expect(snapshot.seasonality.historicalAverage).toBeNull();
    expect(snapshot.seasonality.deviationPct).toBeNull();
  });

  it("computes MoM and YoY deltas from a monthly flow stream", async () => {
    // Reference = 2026-03
    routeSelect([
      {
        match: "strftime('%Y-%m', date)",
        rows: [
          { month: "2025-03", income: 3000, expenses: 1800 }, // YoY comparison
          { month: "2026-02", income: 4000, expenses: 2000 }, // MoM comparison
          { month: "2026-03", income: 5000, expenses: 2500 }, // Reference
        ],
      },
    ]);

    const snapshot = await getCartesSnapshot(2026, 3);

    expect(snapshot.kpis.income.current).toBe(5000);
    expect(snapshot.kpis.income.previousMonth).toBe(4000);
    expect(snapshot.kpis.income.previousYear).toBe(3000);
    expect(snapshot.kpis.income.deltaMoMAbs).toBe(1000);
    expect(snapshot.kpis.income.deltaMoMPct).toBe(25);
    expect(snapshot.kpis.income.deltaYoYAbs).toBe(2000);
    expect(Math.round(snapshot.kpis.income.deltaYoYPct ?? 0)).toBe(67);

    expect(snapshot.kpis.expenses.current).toBe(2500);
    expect(snapshot.kpis.net.current).toBe(2500);
    expect(snapshot.kpis.savingsRate.current).toBe(50);
  });

  it("January reference month shifts MoM to December of previous year", async () => {
    routeSelect([
      {
        match: "strftime('%Y-%m', date)",
        rows: [
          { month: "2025-12", income: 2000, expenses: 1000 },
          { month: "2026-01", income: 3000, expenses: 1500 },
        ],
      },
    ]);

    const snapshot = await getCartesSnapshot(2026, 1);

    expect(snapshot.kpis.income.current).toBe(3000);
    expect(snapshot.kpis.income.previousMonth).toBe(2000);
    // YoY for January 2026 = January 2025 = no data
    expect(snapshot.kpis.income.previousYear).toBeNull();
    expect(snapshot.kpis.income.deltaYoYAbs).toBeNull();
  });

  it("savings rate is null when income is zero (no division by zero, renders as — in UI)", async () => {
    routeSelect([
      {
        match: "strftime('%Y-%m', date)",
        rows: [
          { month: "2026-03", income: 0, expenses: 500 },
        ],
      },
    ]);

    const snapshot = await getCartesSnapshot(2026, 3);
    expect(snapshot.kpis.savingsRate.current).toBeNull();
    expect(snapshot.kpis.income.current).toBe(0);
    expect(snapshot.kpis.expenses.current).toBe(500);
    expect(snapshot.kpis.net.current).toBe(-500);
  });

  it("handles less than 13 months of history by filling gaps with zero", async () => {
    routeSelect([
      {
        match: "strftime('%Y-%m', date)",
        rows: [
          { month: "2026-03", income: 1000, expenses: 400 },
        ],
      },
    ]);

    const snapshot = await getCartesSnapshot(2026, 3);
    expect(snapshot.kpis.income.sparkline).toHaveLength(13);
    // First 12 points are zero, last one is 1000
    expect(snapshot.kpis.income.sparkline[12].value).toBe(1000);
    expect(snapshot.kpis.income.sparkline[0].value).toBe(0);
    // MoM comparison with a missing month returns null (no data for 2026-02)
    expect(snapshot.kpis.income.previousMonth).toBeNull();
    expect(snapshot.kpis.income.deltaMoMAbs).toBeNull();
  });

  it("computes seasonality only when historical data exists", async () => {
    routeSelect([
      {
        match: "strftime('%Y-%m', date)",
        rows: [{ month: "2026-03", income: 3000, expenses: 1500 }],
      },
      {
        match: "CAST(strftime('%Y', date) AS INTEGER) AS year",
        rows: [
          { year: 2025, amount: 1200 },
          { year: 2024, amount: 1000 },
        ],
      },
    ]);

    const snapshot = await getCartesSnapshot(2026, 3);
    expect(snapshot.seasonality.historicalYears).toHaveLength(2);
    expect(snapshot.seasonality.historicalAverage).toBe(1100);
    expect(snapshot.seasonality.referenceAmount).toBe(1500);
    // (1500 - 1100) / 1100 * 100 ≈ 36.36
    expect(Math.round(snapshot.seasonality.deviationPct ?? 0)).toBe(36);
  });

  it("seasonality deviation stays null when there is no historical average", async () => {
    routeSelect([
      {
        match: "strftime('%Y-%m', date)",
        rows: [{ month: "2026-03", income: 2000, expenses: 800 }],
      },
    ]);

    const snapshot = await getCartesSnapshot(2026, 3);
    expect(snapshot.seasonality.historicalYears).toHaveLength(0);
    expect(snapshot.seasonality.historicalAverage).toBeNull();
    expect(snapshot.seasonality.deviationPct).toBeNull();
  });

  it("splits top movers by sign and caps each list at 5", async () => {
    // Seven up-movers, three down-movers — verify we get 5 up and 3 down.
    // Cumulative totals mirror the monthly ones in this fixture — the Cartes
    // service only reads the monthly block for top movers, so cumulative
    // values are inert here.
    const momRows = [
      { category_id: 1, category_name: "C1", category_color: "#000", month_current_total: 200, month_previous_total: 100, cumulative_current_total: 200, cumulative_previous_total: 100 },
      { category_id: 2, category_name: "C2", category_color: "#000", month_current_total: 400, month_previous_total: 100, cumulative_current_total: 400, cumulative_previous_total: 100 },
      { category_id: 3, category_name: "C3", category_color: "#000", month_current_total: 500, month_previous_total: 100, cumulative_current_total: 500, cumulative_previous_total: 100 },
      { category_id: 4, category_name: "C4", category_color: "#000", month_current_total: 700, month_previous_total: 100, cumulative_current_total: 700, cumulative_previous_total: 100 },
      { category_id: 5, category_name: "C5", category_color: "#000", month_current_total: 900, month_previous_total: 100, cumulative_current_total: 900, cumulative_previous_total: 100 },
      { category_id: 6, category_name: "C6", category_color: "#000", month_current_total: 1100, month_previous_total: 100, cumulative_current_total: 1100, cumulative_previous_total: 100 },
      { category_id: 7, category_name: "C7", category_color: "#000", month_current_total: 1300, month_previous_total: 100, cumulative_current_total: 1300, cumulative_previous_total: 100 },
      { category_id: 8, category_name: "D1", category_color: "#000", month_current_total: 100, month_previous_total: 500, cumulative_current_total: 100, cumulative_previous_total: 500 },
      { category_id: 9, category_name: "D2", category_color: "#000", month_current_total: 100, month_previous_total: 700, cumulative_current_total: 100, cumulative_previous_total: 700 },
      { category_id: 10, category_name: "D3", category_color: "#000", month_current_total: 100, month_previous_total: 900, cumulative_current_total: 100, cumulative_previous_total: 900 },
    ];
    routeSelect([
      {
        match: "strftime('%Y-%m', date)",
        rows: [{ month: "2026-03", income: 1000, expenses: 500 }],
      },
      {
        // Matches the getCompareMonthOverMonth SQL pattern.
        match: "ORDER BY ABS(month_current_total - month_previous_total) DESC",
        rows: momRows,
      },
    ]);

    const snapshot = await getCartesSnapshot(2026, 3);
    expect(snapshot.topMoversUp).toHaveLength(5);
    expect(snapshot.topMoversDown).toHaveLength(3);
    // Top up is the biggest delta (C7: +1200)
    expect(snapshot.topMoversUp[0].categoryName).toBe("C7");
    // Top down is the biggest negative delta (D3: -800)
    expect(snapshot.topMoversDown[0].categoryName).toBe("D3");
  });

  it("excludes income from top movers so a salary rise is not a red 'increase' (#253)", async () => {
    // Post-#253 COMPARE_DELTA_SQL surfaces income as a signed positive SUM, so
    // revenue rows now reach getCartesSnapshot. Top movers is a spending view
    // (up = red), so income must be filtered out — otherwise a salary jump tops
    // "biggest increases" in red. The category metadata query types id 99 as
    // income; the two expense movers are absent from it and default to expense.
    const momRows = [
      { category_id: 99, category_name: "Salaire", category_color: "#000", month_current_total: 5000, month_previous_total: 100, cumulative_current_total: 5000, cumulative_previous_total: 100 },
      { category_id: 1, category_name: "Épicerie", category_color: "#000", month_current_total: 300, month_previous_total: 100, cumulative_current_total: 300, cumulative_previous_total: 100 },
      { category_id: 2, category_name: "Resto", category_color: "#000", month_current_total: 100, month_previous_total: 400, cumulative_current_total: 100, cumulative_previous_total: 400 },
    ];
    routeSelect([
      { match: "strftime('%Y-%m', date)", rows: [{ month: "2026-03", income: 5000, expenses: 400 }] },
      { match: "ORDER BY ABS(month_current_total - month_previous_total) DESC", rows: momRows },
      { match: "parent_id FROM categories", rows: [{ id: 99, name: "Salaire", color: null, type: "income", parent_id: null }] },
    ]);

    const snapshot = await getCartesSnapshot(2026, 3);
    const upNames = snapshot.topMoversUp.map((m) => m.categoryName);
    // Income excluded despite the biggest delta (+4900).
    expect(upNames).not.toContain("Salaire");
    // The biggest EXPENSE increase leads instead.
    expect(snapshot.topMoversUp[0].categoryName).toBe("Épicerie");
    // Expense decrease still surfaces.
    expect(snapshot.topMoversDown[0].categoryName).toBe("Resto");
  });

  it("computes YTD KPIs correctly when mode=ytd (sums Jan→refMonth of refYear)", async () => {
    // Reference = 2026-03, YTD = Jan + Feb + Mar of 2026.
    routeSelect([
      {
        match: "strftime('%Y-%m', date)",
        rows: [
          // Previous year YTD: Jan + Feb + Mar 2025 = income 3000, expenses 1500.
          { month: "2025-01", income: 1000, expenses: 500 },
          { month: "2025-02", income: 1000, expenses: 500 },
          { month: "2025-03", income: 1000, expenses: 500 },
          // Current YTD: Jan + Feb + Mar 2026.
          { month: "2026-01", income: 2000, expenses: 800 },
          { month: "2026-02", income: 2500, expenses: 1000 },
          { month: "2026-03", income: 3000, expenses: 1200 },
        ],
      },
    ]);

    const snapshot = await getCartesSnapshot(2026, 3, "ytd");

    // Current = sum Jan+Feb+Mar 2026
    expect(snapshot.kpis.income.current).toBe(7500);
    expect(snapshot.kpis.expenses.current).toBe(3000);
    expect(snapshot.kpis.net.current).toBe(4500);
    // Savings rate YTD = 4500 / 7500 = 60%
    expect(snapshot.kpis.savingsRate.current).toBe(60);

    // MoM in YTD = current YTD vs Jan→Feb 2026 = income 4500, expenses 1800.
    expect(snapshot.kpis.income.previousMonth).toBe(4500);
    expect(snapshot.kpis.income.deltaMoMAbs).toBe(3000);
    expect(snapshot.kpis.expenses.previousMonth).toBe(1800);

    // YoY in YTD = Jan→Mar 2026 vs Jan→Mar 2025.
    expect(snapshot.kpis.income.previousYear).toBe(3000);
    expect(snapshot.kpis.income.deltaYoYAbs).toBe(4500);
    expect(snapshot.kpis.expenses.previousYear).toBe(1500);
  });

  it("YTD savings rate is null when YTD income is zero", async () => {
    routeSelect([
      {
        match: "strftime('%Y-%m', date)",
        rows: [
          { month: "2026-01", income: 0, expenses: 200 },
          { month: "2026-02", income: 0, expenses: 150 },
          { month: "2026-03", income: 0, expenses: 300 },
        ],
      },
    ]);

    const snapshot = await getCartesSnapshot(2026, 3, "ytd");
    expect(snapshot.kpis.income.current).toBe(0);
    expect(snapshot.kpis.expenses.current).toBe(650);
    expect(snapshot.kpis.savingsRate.current).toBeNull();
  });

  it("YTD MoM delta is null when reference month is January (no prior YTD window in same year)", async () => {
    routeSelect([
      {
        match: "strftime('%Y-%m', date)",
        rows: [
          { month: "2025-01", income: 500, expenses: 200 },
          { month: "2026-01", income: 1000, expenses: 400 },
        ],
      },
    ]);

    const snapshot = await getCartesSnapshot(2026, 1, "ytd");

    expect(snapshot.kpis.income.current).toBe(1000);
    // MoM previous YTD for January is empty (no prior month in same year).
    expect(snapshot.kpis.income.previousMonth).toBeNull();
    expect(snapshot.kpis.income.deltaMoMAbs).toBeNull();
    expect(snapshot.kpis.income.deltaMoMPct).toBeNull();
    // YoY still works — Jan 2025.
    expect(snapshot.kpis.income.previousYear).toBe(500);
    expect(snapshot.kpis.income.deltaYoYAbs).toBe(500);
  });

  it("YTD YoY delta uses Jan→refMonth of the previous year", async () => {
    routeSelect([
      {
        match: "strftime('%Y-%m', date)",
        rows: [
          // Prev year YTD Jan→Apr 2025
          { month: "2025-01", income: 100, expenses: 50 },
          { month: "2025-02", income: 100, expenses: 50 },
          { month: "2025-03", income: 100, expenses: 50 },
          { month: "2025-04", income: 100, expenses: 50 },
          // Prev year outside window — must be ignored.
          { month: "2025-05", income: 999, expenses: 999 },
          // Current YTD Jan→Apr 2026
          { month: "2026-01", income: 300, expenses: 100 },
          { month: "2026-02", income: 300, expenses: 100 },
          { month: "2026-03", income: 300, expenses: 100 },
          { month: "2026-04", income: 300, expenses: 100 },
        ],
      },
    ]);

    const snapshot = await getCartesSnapshot(2026, 4, "ytd");

    expect(snapshot.kpis.income.current).toBe(1200);
    expect(snapshot.kpis.income.previousYear).toBe(400); // 4 * 100
    expect(snapshot.kpis.expenses.previousYear).toBe(200); // 4 * 50
    expect(snapshot.kpis.income.deltaYoYAbs).toBe(800);
  });

  it("defaults to mode=month and produces monthly KPIs (back-compat)", async () => {
    routeSelect([
      {
        match: "strftime('%Y-%m', date)",
        rows: [
          { month: "2026-01", income: 1000, expenses: 400 },
          { month: "2026-02", income: 1000, expenses: 400 },
          { month: "2026-03", income: 5000, expenses: 2500 },
        ],
      },
    ]);

    // No mode argument — default "month".
    const snapshot = await getCartesSnapshot(2026, 3);
    // Monthly ref only, not YTD sum.
    expect(snapshot.kpis.income.current).toBe(5000);
    expect(snapshot.kpis.expenses.current).toBe(2500);
  });

  it("counts budgeted expense categories even though monthBudget is stored signed-negative (#112)", async () => {
    // Regression: before the fix, `r.monthBudget > 0` rejected every expense
    // row because budgetService signs expense budgets as negative. The card
    // then claimed "no budgeted categories" even when the user had set
    // budgets on expense categories.
    routeSelect([
      {
        match: "FROM categories WHERE is_active = 1 ORDER BY sort_order",
        rows: [
          {
            id: 10,
            name: "Alimentation",
            parent_id: null,
            color: "#f59e0b",
            icon: null,
            type: "expense",
            is_active: 1,
            is_inputable: 1,
            sort_order: 1,
            created_at: "",
          },
          {
            id: 11,
            name: "Restaurants",
            parent_id: null,
            color: "#ef4444",
            icon: null,
            type: "expense",
            is_active: 1,
            is_inputable: 1,
            sort_order: 2,
            created_at: "",
          },
          {
            id: 12,
            name: "Loyer",
            parent_id: null,
            color: "#6366f1",
            icon: null,
            type: "expense",
            is_active: 1,
            is_inputable: 1,
            sort_order: 3,
            created_at: "",
          },
        ],
      },
      {
        match: "FROM budget_entries WHERE year",
        rows: [
          { id: 1, category_id: 10, year: 2026, month: 3, amount: 500 },
          { id: 2, category_id: 11, year: 2026, month: 3, amount: 200 },
          // Category 12 has no budget entry.
        ],
      },
      {
        // Matches both the monthly and YTD actuals queries; for this test
        // the YTD slice can mirror the monthly one.
        match: "FROM transactions\n     WHERE date BETWEEN",
        rows: [
          { category_id: 10, actual: -400 }, // 400 spent against 500 budget -> in target
          { category_id: 11, actual: -350 }, // 350 spent against 200 budget -> overrun
          { category_id: 12, actual: -150 }, // no budget, should not appear
        ],
      },
    ]);

    const snapshot = await getCartesSnapshot(2026, 3);

    expect(snapshot.budgetAdherence.categoriesTotal).toBe(2);
    expect(snapshot.budgetAdherence.categoriesInTarget).toBe(1);
    expect(snapshot.budgetAdherence.worstOverruns).toHaveLength(1);

    const [worst] = snapshot.budgetAdherence.worstOverruns;
    expect(worst.categoryName).toBe("Restaurants");
    expect(worst.actual).toBe(350);
    expect(worst.budget).toBe(200);
    expect(worst.overrunAbs).toBe(150);
    expect(worst.overrunPct).toBeCloseTo(75, 5);
  });

  // --- accountIds filter (Issues #273 / #279) ---
  //
  // getCartesSnapshot must forward accountIds to EVERY sub-query so the whole
  // dashboard honours an active account filter: the two sub-reports that
  // support it (getCompareMonthOverMonth for top movers, getBudgetVsActualData
  // for budget adherence) AND the KPI/sparkline/seasonality series
  // (fetchMonthlyFlows, fetchSeasonality). Omitting it anywhere would let the
  // dashboard silently mix filtered and unfiltered figures on the same screen.

  it("forwards accountIds to the compare (top movers) sub-report", async () => {
    mockSelect.mockImplementation(() => Promise.resolve([]));

    await getCartesSnapshot(2026, 3, "month", [3, 8]);

    const momCall = mockSelect.mock.calls.find(([sql]) =>
      (sql as string).includes("ORDER BY ABS(month_current_total - month_previous_total) DESC"),
    )!;
    expect(momCall[0]).toContain("AND t.source_id IN ($9, $10)");
    expect(momCall[1]).toEqual(expect.arrayContaining([3, 8]));
  });

  it("forwards accountIds to the budget-vs-actual (budget adherence) sub-report", async () => {
    mockSelect.mockImplementation(() => Promise.resolve([]));

    await getCartesSnapshot(2026, 3, "month", [3, 8]);

    const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
      (sql as string).includes("FROM transactions\n     WHERE date BETWEEN"),
    );
    // Both the month and YTD actuals queries (getBudgetVsActualData) receive it.
    expect(actualsCalls.length).toBeGreaterThanOrEqual(2);
    for (const [sql, params] of actualsCalls) {
      expect(sql as string).toContain("source_id IN ($3, $4)");
      expect(params as unknown[]).toEqual(expect.arrayContaining([3, 8]));
    }
  });

  it("forwards accountIds to the monthly-flows series (KPIs, sparklines, overlay)", async () => {
    mockSelect.mockImplementation(() => Promise.resolve([]));

    await getCartesSnapshot(2026, 3, "month", [3, 8]);

    // fetchMonthlyFlows is the only snapshot query selecting a "%Y-%m" month.
    const flowCall = mockSelect.mock.calls.find(([sql]) =>
      (sql as string).includes("strftime('%Y-%m', date) AS month"),
    )!;
    expect(flowCall[0]).toContain("AND source_id IN ($3, $4)");
    expect(flowCall[1]).toEqual(expect.arrayContaining([3, 8]));
  });

  it("forwards accountIds to the seasonality series", async () => {
    mockSelect.mockImplementation(() => Promise.resolve([]));

    await getCartesSnapshot(2026, 3, "month", [3, 8]);

    // fetchSeasonality is the only snapshot query filtering on a "%m" month.
    const seasonalityCall = mockSelect.mock.calls.find(([sql]) =>
      (sql as string).includes("strftime('%m', date) = $1"),
    )!;
    expect(seasonalityCall[0]).toContain("AND source_id IN ($4, $5)");
    expect(seasonalityCall[1]).toEqual(expect.arrayContaining([3, 8]));
  });

  it("without accountIds, no sub-report SQL carries a source_id clause (regression)", async () => {
    mockSelect.mockImplementation(() => Promise.resolve([]));

    await getCartesSnapshot(2026, 3);

    for (const [sql] of mockSelect.mock.calls) {
      expect(sql as string).not.toContain("source_id");
    }
  });
});
