import { describe, it, expect } from "vitest";
import {
  type CollapseAccessors,
  MAX_TREE_DEPTH,
  collapsibleKeys,
  isCollapsedFor,
  parseStoredExpanded,
  serializeExpanded,
  visibleRows,
} from "./collapsibleRows";
import { reorderRows } from "./reorderRows";

/** Tiny hierarchy row: id, parent id, is-parent flag, indentation depth. */
interface Row {
  id: number;
  parent: number | null;
  is_parent: boolean;
  depth: number;
}

const acc: CollapseAccessors<Row> = {
  keyOf: (r) => `p:${r.id}`,
  parentKeyOf: (r) => (r.parent === null ? null : `p:${r.parent}`),
  isParent: (r) => r.is_parent,
  depthOf: (r) => r.depth,
};

const r = (id: number, parent: number | null, is_parent: boolean, depth: number): Row => ({
  id,
  parent,
  is_parent,
  depth,
});

// A three-level tree (income → expense → transfer shape is irrelevant here; the
// point is the depth):
//   Housing (parent, id 1)
//     Rent (leaf, id 11)
//     Utilities (INTERMEDIATE parent, id 12)
//       Power (leaf, id 121)
//       Water (leaf, id 122)
//   Salary (parent, id 2)
//     Paycheck (leaf, id 21)
//   Misc (top-level leaf, id 3)
const dfsTree: Row[] = [
  r(1, null, true, 0), // Housing
  r(11, 1, false, 1), // Rent
  r(12, 1, true, 1), // Utilities (intermediate)
  r(121, 12, false, 2), // Power
  r(122, 12, false, 2), // Water
  r(2, null, true, 0), // Salary
  r(21, 2, false, 1), // Paycheck
  r(3, null, false, 0), // Misc
];

// The EXACT same tree, emitted level-order (BFS) — the order the budget grid
// produces. The ancestor-walk must mask identically to the DFS order.
const bfsTree: Row[] = [
  r(1, null, true, 0), // Housing
  r(2, null, true, 0), // Salary
  r(3, null, false, 0), // Misc
  r(11, 1, false, 1), // Rent
  r(12, 1, true, 1), // Utilities
  r(21, 2, false, 1), // Paycheck
  r(121, 12, false, 2), // Power
  r(122, 12, false, 2), // Water
];

/** Report polarity: a group is collapsed unless its key is in the expanded set. */
const isCollapsed = (expanded: Set<string>) => (row: Row) => !expanded.has(acc.keyOf(row));
const ids = (rows: Row[]) => rows.map((row) => row.id);
const idSet = (rows: Row[]) => new Set(ids(rows));

describe("collapsibleRows.visibleRows — ancestor-walk visibility", () => {
  it("collapses every level by default (empty set) ⇒ only roots survive", () => {
    const visible = visibleRows(dfsTree, acc, isCollapsed(new Set()));
    // Housing + Salary (root parents) + Misc (root leaf); everything deeper folds.
    expect(ids(visible)).toEqual([1, 2, 3]);
  });

  it("expanding a root reveals ONLY its direct children, not its grandchildren", () => {
    const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:1"])));
    // Rent + Utilities appear; Power/Water (grandchildren) stay folded because the
    // intermediate Utilities is still collapsed.
    expect(ids(visible)).toEqual([1, 11, 12, 2, 3]);
    expect(visible.some((row) => row.id === 121)).toBe(false);
    expect(visible.some((row) => row.id === 122)).toBe(false);
  });

  it("expanding a root AND its intermediate reveals the leaves", () => {
    const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:1", "p:12"])));
    expect(ids(visible)).toEqual([1, 11, 12, 121, 122, 2, 3]);
  });

  it("a collapsed ancestor always wins: expanding an intermediate whose root is collapsed reveals nothing", () => {
    const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:12"])));
    // Housing is collapsed, so Utilities (and thus Power/Water) stay hidden even
    // though Utilities itself is expanded.
    expect(ids(visible)).toEqual([1, 2, 3]);
  });

  it("keeps groups independent — expanding Salary leaves Housing folded", () => {
    const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:2"])));
    expect(ids(visible)).toEqual([1, 2, 21, 3]);
  });

  it("shows every row once all parents are expanded", () => {
    const visible = visibleRows(dfsTree, acc, isCollapsed(new Set(["p:1", "p:12", "p:2"])));
    expect(visible).toHaveLength(dfsTree.length);
  });

  it("BFS (level-order) rows mask IDENTICALLY to DFS rows — the test that would have killed v1", () => {
    const states: Set<string>[] = [
      new Set(),
      new Set(["p:1"]),
      new Set(["p:1", "p:12"]),
      new Set(["p:12"]),
      new Set(["p:2"]),
      new Set(["p:1", "p:12", "p:2"]),
    ];
    for (const expanded of states) {
      const dfs = idSet(visibleRows(dfsTree, acc, isCollapsed(expanded)));
      const bfs = idSet(visibleRows(bfsTree, acc, isCollapsed(expanded)));
      expect(bfs).toEqual(dfs);
    }
  });

  it("masking is unchanged when subtotals are moved to the bottom (reorderRows)", () => {
    const expanded = new Set(["p:1", "p:12"]);
    const normal = idSet(visibleRows(dfsTree, acc, isCollapsed(expanded)));
    // Order-independent: run reorder FIRST (subtotals below their children), then
    // filter. v1's depth-cursor algorithm broke exactly here.
    const bottomFirst = reorderRows(dfsTree, false);
    const afterReorder = idSet(visibleRows(bottomFirst, acc, isCollapsed(expanded)));
    expect(afterReorder).toEqual(normal);
  });

  it("always keeps a top-level leaf that has no parent", () => {
    const visible = visibleRows(dfsTree, acc, isCollapsed(new Set()));
    expect(visible.some((row) => row.id === 3)).toBe(true);
  });
});

describe('collapsibleRows.visibleRows — "(direct)" leaf', () => {
  // A parent that also holds direct transactions: its "(direct)" leaf shares the
  // parent's category id but is a leaf, and points back at the parent subtotal.
  const withDirect: Row[] = [
    r(1, null, true, 0), // Housing subtotal   -> key p:1
    r(1, 1, false, 1), // Housing (direct)   -> key p:1, parentKey p:1
    r(11, 1, false, 1), // Rent
  ];

  it("hides the (direct) leaf when its parent is collapsed", () => {
    const visible = visibleRows(withDirect, acc, isCollapsed(new Set()));
    // Only the Housing subtotal (a root) survives.
    expect(visible).toHaveLength(1);
    expect(visible[0].is_parent).toBe(true);
  });

  it("shows the (direct) leaf when its parent is expanded", () => {
    const visible = visibleRows(withDirect, acc, isCollapsed(new Set(["p:1"])));
    expect(visible).toHaveLength(3);
  });

  it("never treats the (direct) leaf as a collapsible group", () => {
    // Both the subtotal and the (direct) leaf carry key p:1, but only the parent
    // subtotal is collapsible — the leaf is not a parent.
    expect(collapsibleKeys(withDirect, acc)).toEqual(["p:1"]);
  });
});

describe("collapsibleRows.visibleRows — corrupt / cross-section chains", () => {
  it("guards a cyclic parent_id with MAX_TREE_DEPTH (no infinite loop)", () => {
    // A <-> B cycle, with a leaf hanging under A. Neither A nor B is collapsed, so
    // the walk keeps climbing until the hop guard trips.
    const cyclic: Row[] = [
      r(100, 101, true, 0), // A, parent B
      r(101, 100, true, 1), // B, parent A
      r(1001, 100, false, 2), // leaf under A
    ];
    const visible = visibleRows(cyclic, acc, isCollapsed(new Set(["p:100", "p:101"])));
    // Terminates and keeps the leaf visible (no collapsed ancestor was found).
    expect(visible.some((row) => row.id === 1001)).toBe(true);
    // Sanity: the guard is a finite, positive cap.
    expect(MAX_TREE_DEPTH).toBeGreaterThan(3);
  });

  it("keeps a row whose ancestor is absent from the section (child of another type)", () => {
    // Orphan points at id 999, which is not in these rows.
    const orphaned: Row[] = [r(55, 999, false, 1)];
    const visible = visibleRows(orphaned, acc, isCollapsed(new Set()));
    // Missing ancestor ⇒ visible (better an orphan shown than a row hidden by a
    // parent that lives in another section).
    expect(ids(visible)).toEqual([55]);
  });
});

describe("collapsibleRows.collapsibleKeys", () => {
  it("lists EVERY parent, intermediates included, in encounter order", () => {
    // Housing (p:1), Utilities (p:12, intermediate), Salary (p:2) — NOT Misc/leaves.
    expect(collapsibleKeys(dfsTree, acc)).toEqual(["p:1", "p:12", "p:2"]);
  });

  it("returns [] when there is no hierarchy", () => {
    const flat = [r(1, null, false, 0), r(2, null, false, 0)];
    expect(collapsibleKeys(flat, acc)).toEqual([]);
  });
});

describe("collapsibleRows.isCollapsedFor — polarity", () => {
  it("defaultExpanded=false: empty set ⇒ collapsed, a flipped key ⇒ expanded", () => {
    expect(isCollapsedFor(new Set(), "p:1", false)).toBe(true);
    expect(isCollapsedFor(new Set(["p:1"]), "p:1", false)).toBe(false);
    expect(isCollapsedFor(new Set(["p:2"]), "p:1", false)).toBe(true);
  });

  it("defaultExpanded=true: empty set ⇒ expanded, a flipped key ⇒ collapsed", () => {
    expect(isCollapsedFor(new Set(), "p:1", true)).toBe(false);
    expect(isCollapsedFor(new Set(["p:1"]), "p:1", true)).toBe(true);
    expect(isCollapsedFor(new Set(["p:2"]), "p:1", true)).toBe(false);
  });
});

describe("collapsibleRows.parseStoredExpanded / serializeExpanded", () => {
  it("treats absent storage as the default state (empty set)", () => {
    expect(parseStoredExpanded(null).size).toBe(0);
    expect(parseStoredExpanded("").size).toBe(0);
  });

  it("parses a JSON string array back into a set", () => {
    expect([...parseStoredExpanded('["p:1","p:2"]')].sort()).toEqual(["p:1", "p:2"]);
  });

  it("falls back to empty on corrupt or non-array JSON", () => {
    expect(parseStoredExpanded("{not json").size).toBe(0);
    expect(parseStoredExpanded('{"a":1}').size).toBe(0);
    expect(parseStoredExpanded("42").size).toBe(0);
  });

  it("drops non-string members defensively", () => {
    expect([...parseStoredExpanded('["ok", 3, null, "yes"]')].sort()).toEqual(["ok", "yes"]);
  });

  it("round-trips through serialize", () => {
    const set = new Set(["p:1", "p:12"]);
    expect([...parseStoredExpanded(serializeExpanded(set))].sort()).toEqual(["p:1", "p:12"]);
  });
});
