import { describe, it, expect } from "vitest";
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
import { visibleRows } from "../../utils/collapsibleRows";
import { computeOverTimeResults } from "./overTimeResults";
import {
  OVERTIME_COLLAPSE_ACCESSORS,
  OVERTIME_EXPANDED_STORAGE_KEY,
  groupOverTimeSections,
} from "./overTimeTableModel";

type RowType = "income" | "expense" | "transfer";

/** Builds one id-keyed tree row; `monthly` is index-aligned to the months list. */
function mkRow(
  categoryId: number,
  categoryName: string,
  monthly: number[],
  category_type: RowType,
  extra: Partial<OverTimeRow> = {},
): 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,
    ...extra,
  };
}

function makeData(months: string[], tree: OverTimeRow[]): CategoryOverTimeData {
  return {
    categories: [],
    data: months.map((month) => ({ month })),
    colors: {},
    categoryIds: {},
    types: {},
    tree,
  };
}

const MONTHS = ["2025-01", "2025-02"];

// A realistic parent-first depth-first tree, three levels deep on the expense
// side (income → expense → transfer):
//   Revenus (parent, id 1)
//     Paie          (leaf, id 11)
//     Bonus         (leaf, id 12)
//   Dépenses (parent, id 2)
//     Alimentation  (INTERMEDIATE parent, id 20)
//       Épicerie    (leaf, id 21)
//       Resto       (leaf, id 22)
//   Loyer           (top-level leaf, id 23)
//   Épargne         (transfer leaf, id 3)
function buildTree(): OverTimeRow[] {
  return [
    mkRow(1, "Revenus", [3000, 3500], "income", { is_parent: true, depth: 0 }),
    mkRow(11, "Paie", [3000, 3000], "income", { parent_id: 1, depth: 1 }),
    mkRow(12, "Bonus", [0, 500], "income", { parent_id: 1, depth: 1 }),
    mkRow(2, "Dépenses", [500, 800], "expense", { is_parent: true, depth: 0 }),
    mkRow(20, "Alimentation", [500, 800], "expense", { is_parent: true, parent_id: 2, depth: 1 }),
    mkRow(21, "Épicerie", [400, 600], "expense", { parent_id: 20, depth: 2 }),
    mkRow(22, "Resto", [100, 200], "expense", { parent_id: 20, depth: 2 }),
    mkRow(23, "Loyer", [1000, 1000], "expense", { depth: 0 }),
    mkRow(3, "Épargne", [200, 200], "transfer", { depth: 0 }),
  ];
}

const acc = OVERTIME_COLLAPSE_ACCESSORS;
/** Mirrors the hook: a group is collapsed unless its key is in the expanded set. */
const isCollapsed = (expanded: Set<string>) => (row: OverTimeRow) => !expanded.has(acc.keyOf(row));
const names = (rows: OverTimeRow[]) => rows.map((r) => r.categoryName);

describe("overTimeTableModel — storage key", () => {
  it("uses a trends-specific key, distinct from the comparable tables", () => {
    expect(OVERTIME_EXPANDED_STORAGE_KEY).toBe("reports-trends-expanded");
    expect(OVERTIME_EXPANDED_STORAGE_KEY).not.toBe("reports-compare-expanded");
  });
});

describe("overTimeTableModel — collapse accessors", () => {
  it("keys parents/leaves injectively (p:<id>) and climbs parent_id", () => {
    const revenus = buildTree()[0]; // parent
    const paie = buildTree()[1]; // leaf under Revenus
    const alimentation = buildTree()[4]; // intermediate parent
    expect(acc.keyOf(revenus)).toBe("p:1");
    expect(acc.parentKeyOf(revenus)).toBeNull(); // root
    expect(acc.parentKeyOf(paie)).toBe("p:1"); // climbs to Revenus
    expect(acc.parentKeyOf(alimentation)).toBe("p:2"); // climbs to Dépenses
    expect(acc.depthOf(paie)).toBe(1);
    expect(acc.isParent(revenus)).toBe(true);
    expect(acc.isParent(paie)).toBe(false);
    expect(acc.isParent(alimentation)).toBe(true);
  });
});

describe("overTimeTableModel.groupOverTimeSections", () => {
  it("splits the tree into ordered sections carrying the FULL hierarchy (parents + leaves)", () => {
    const data = makeData(MONTHS, buildTree());
    const analysis = computeOverTimeResults(data);
    const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);

    // Income-first, transfer split out for interleaving.
    expect(nonTransferSections.map((s) => s.type)).toEqual(["income", "expense"]);
    expect(transferSection?.type).toBe("transfer");

    // Full hierarchy: the parent row is present (unlike the leaves-only reducer sections).
    const income = nonTransferSections.find((s) => s.type === "income")!;
    expect(names(income.rows)).toEqual(["Revenus", "Paie", "Bonus"]);
    expect(income.rows[0].is_parent).toBe(true);

    // Expense carries the intermediate parent (Alimentation) too, parent-first.
    const expense = nonTransferSections.find((s) => s.type === "expense")!;
    expect(names(expense.rows)).toEqual([
      "Dépenses",
      "Alimentation",
      "Épicerie",
      "Resto",
      "Loyer",
    ]);
    expect(transferSection?.rows.map((r) => r.categoryName)).toEqual(["Épargne"]);
  });

  it("keeps subtotals as the reducer's leaf sums — parents (incl. intermediate) are never double-counted", () => {
    const data = makeData(MONTHS, buildTree());
    const analysis = computeOverTimeResults(data);
    const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);

    const income = nonTransferSections.find((s) => s.type === "income")!;
    // Paie 6000 + Bonus 500 = 6500 (the Revenus parent's own 6500 is NOT added again).
    expect(income.total).toBe(6500);
    expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3500 });

    const expense = nonTransferSections.find((s) => s.type === "expense")!;
    // Épicerie 1000 + Resto 300 + Loyer 2000 = 3300 (Dépenses AND Alimentation
    // parents excluded, even though Alimentation carries a [500,800] series).
    expect(expense.total).toBe(3300);
  });

  it("drops empty sections: no transfers → transferSection is null", () => {
    const tree = buildTree().filter((r) => r.category_type !== "transfer");
    const data = makeData(MONTHS, tree);
    const analysis = computeOverTimeResults(data);
    const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);

    expect(transferSection).toBeNull();
    expect(nonTransferSections.map((s) => s.type)).toEqual(["income", "expense"]);
  });
});

describe("overTimeTableModel — collapse behaviour (via visibleRows)", () => {
  it("is collapsed by default: only depth-0 rows survive an empty expanded set", () => {
    const data = makeData(MONTHS, buildTree());
    const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);

    const income = nonTransferSections.find((s) => s.type === "income")!;
    const visIncome = visibleRows(income.rows, acc, isCollapsed(new Set()));
    // Revenus stays (its own subtotal row); Paie/Bonus are folded away.
    expect(names(visIncome)).toEqual(["Revenus"]);

    const expense = nonTransferSections.find((s) => s.type === "expense")!;
    const visExpense = visibleRows(expense.rows, acc, isCollapsed(new Set()));
    // Dépenses folds its whole subtree (Alimentation + its leaves); the top-level
    // leaf Loyer always stays.
    expect(names(visExpense)).toEqual(["Dépenses", "Loyer"]);
  });

  it("reveals Paie (revenue) under Revenus once its group is expanded", () => {
    const data = makeData(MONTHS, buildTree());
    const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
    const income = nonTransferSections.find((s) => s.type === "income")!;

    const visible = visibleRows(income.rows, acc, isCollapsed(new Set(["p:1"])));
    expect(names(visible)).toEqual(["Revenus", "Paie", "Bonus"]);
    expect(visible.some((r) => r.categoryName === "Paie")).toBe(true);
  });

  it("cascades across three levels: a root reveals only its direct child parent, not the leaves", () => {
    const data = makeData(MONTHS, buildTree());
    const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree);
    const expense = nonTransferSections.find((s) => s.type === "expense")!;

    // Expand Dépenses only: Alimentation (direct child) shows, Loyer stays, but
    // Épicerie/Resto (grandchildren under the still-collapsed Alimentation) do NOT.
    const rootOnly = visibleRows(expense.rows, acc, isCollapsed(new Set(["p:2"])));
    expect(names(rootOnly)).toEqual(["Dépenses", "Alimentation", "Loyer"]);

    // Expand Dépenses AND Alimentation: the leaves finally appear.
    const bothOpen = visibleRows(expense.rows, acc, isCollapsed(new Set(["p:2", "p:20"])));
    expect(names(bothOpen)).toEqual(["Dépenses", "Alimentation", "Épicerie", "Resto", "Loyer"]);

    // Expand the intermediate but NOT the root: a collapsed ancestor wins, nothing
    // under Dépenses is revealed.
    const intermediateOnly = visibleRows(expense.rows, acc, isCollapsed(new Set(["p:20"])));
    expect(names(intermediateOnly)).toEqual(["Dépenses", "Loyer"]);
  });

  it("collapse is purely visual: subtotals, before-transfers and net are unchanged", () => {
    const data = makeData(MONTHS, buildTree());
    const analysis = computeOverTimeResults(data);
    const { nonTransferSections } = groupOverTimeSections(analysis, data.tree);
    const income = nonTransferSections.find((s) => s.type === "income")!;
    const expense = nonTransferSections.find((s) => s.type === "expense")!;

    // Figures come from the raw tree (never the visible rows), so they hold in
    // every collapse state.
    expect(analysis.beforeTransfers.total).toBe(3200); // 6500 income − 3300 expense
    expect(analysis.net.total).toBe(3600); // 3200 + 400 transfer
    expect(income.total).toBe(6500);
    expect(expense.total).toBe(3300);

    // Folding vs expanding a group only changes how many rows are visible.
    const collapsed = visibleRows(income.rows, acc, isCollapsed(new Set()));
    const expanded = visibleRows(income.rows, acc, isCollapsed(new Set(["p:1"])));
    expect(collapsed.length).toBeLessThan(expanded.length);

    // Expanding the intermediate expense level does not move the section subtotal.
    const expenseAllOpen = visibleRows(expense.rows, acc, isCollapsed(new Set(["p:2", "p:20"])));
    expect(expenseAllOpen.length).toBeGreaterThan(
      visibleRows(expense.rows, acc, isCollapsed(new Set())).length,
    );
    expect(expense.total).toBe(3300);
  });
});
