import { describe, it, expect } from "vitest";
import {
  newLeaf,
  newSplit,
  replaceById,
  splitLeaf,
  closeLeaf,
  findLeaf,
  leafCount,
  walkLeaves,
  changeDistro,
  changeLabel,
  toggleBroadcast,
  serialize,
  deserialize,
  presetSingle,
  presetTwoColumns,
  presetThreeColumns,
  presetTwoRows,
  presetTwoByTwo,
  type TreeNode,
  type LeafNode,
  type SplitNode,
} from "./tree";

function leafIds(root: TreeNode): string[] {
  return Array.from(walkLeaves(root)).map((l) => l.id);
}

function leafDistros(root: TreeNode): (string | undefined)[] {
  return Array.from(walkLeaves(root)).map((l) => l.distro);
}

describe("newLeaf", () => {
  it("returns a leaf with a unique id and no extra metadata", () => {
    const a = newLeaf();
    const b = newLeaf();
    expect(a.kind).toBe("leaf");
    expect(typeof a.id).toBe("string");
    expect(a.id).not.toEqual(b.id);
    expect(a.distro).toBeUndefined();
    expect(a.cwd).toBeUndefined();
    expect(a.label).toBeUndefined();
    expect(a.broadcast).toBeUndefined();
  });

  it("applies provided props", () => {
    const leaf = newLeaf({ distro: "Ubuntu", cwd: "/home", label: "ml" });
    expect(leaf.distro).toBe("Ubuntu");
    expect(leaf.cwd).toBe("/home");
    expect(leaf.label).toBe("ml");
  });
});

describe("newSplit", () => {
  it("defaults ratio to 0.5", () => {
    const split = newSplit("h", newLeaf(), newLeaf());
    expect(split.ratio).toBe(0.5);
    expect(split.kind).toBe("split");
    expect(split.orientation).toBe("h");
  });

  it("respects an explicit ratio", () => {
    const split = newSplit("v", newLeaf(), newLeaf(), 0.33);
    expect(split.ratio).toBeCloseTo(0.33);
  });
});

describe("replaceById", () => {
  it("replaces the root if its id matches", () => {
    const a = newLeaf({ label: "old" });
    const next = replaceById(a, a.id, () => newLeaf({ label: "new" }));
    expect((next as LeafNode).label).toBe("new");
  });

  it("returns the same root reference when no match", () => {
    const a = newSplit("h", newLeaf(), newLeaf());
    const same = replaceById(a, "no-such-id", () => newLeaf());
    expect(same).toBe(a);
  });

  it("returns a new root when a nested node is replaced (immutability)", () => {
    const target = newLeaf({ label: "target" });
    const sibling = newLeaf({ label: "sibling" });
    const root = newSplit("h", target, sibling);
    const next = replaceById(root, target.id, (n) => ({
      ...(n as LeafNode),
      label: "edited",
    }));
    expect(next).not.toBe(root);
    expect((next as SplitNode).b).toBe(sibling); // untouched branch reused
    expect(((next as SplitNode).a as LeafNode).label).toBe("edited");
  });
});

describe("splitLeaf", () => {
  it("replaces a leaf with a horizontal split", () => {
    const leaf = newLeaf({ distro: "Ubuntu" });
    const next = splitLeaf(leaf, leaf.id, "h");
    expect(next.kind).toBe("split");
    const s = next as SplitNode;
    expect(s.orientation).toBe("h");
    expect(s.a).toBe(leaf);
    expect((s.b as LeafNode).kind).toBe("leaf");
  });

  it("propagates inherited distro / cwd to the new leaf", () => {
    const leaf = newLeaf({ distro: "Ubuntu", cwd: "/projects/x" });
    const next = splitLeaf(leaf, leaf.id, "v", {
      distro: leaf.distro,
      cwd: leaf.cwd,
    });
    const newSide = (next as SplitNode).b as LeafNode;
    expect(newSide.distro).toBe("Ubuntu");
    expect(newSide.cwd).toBe("/projects/x");
  });

  it("is a no-op when the leaf id is not found", () => {
    const leaf = newLeaf();
    const same = splitLeaf(leaf, "no-such-id", "h");
    expect(same).toBe(leaf);
  });

  it("preserves siblings when splitting a nested leaf", () => {
    const target = newLeaf();
    const sibling = newLeaf();
    const root = newSplit("h", target, sibling);
    const next = splitLeaf(root, target.id, "v") as SplitNode;
    expect(next.b).toBe(sibling);
    expect(next.a.kind).toBe("split");
  });
});

describe("closeLeaf", () => {
  it("returns null when closing the only (root) leaf", () => {
    const leaf = newLeaf();
    expect(closeLeaf(leaf, leaf.id)).toBeNull();
  });

  it("collapses to the sibling when closing one of two children", () => {
    const target = newLeaf();
    const sibling = newLeaf();
    const root = newSplit("h", target, sibling);
    expect(closeLeaf(root, target.id)).toBe(sibling);
    expect(closeLeaf(root, sibling.id)).toBe(target);
  });

  it("preserves the rest of the tree when removing a nested leaf", () => {
    const target = newLeaf({ label: "close-me" });
    const sib = newLeaf({ label: "stay" });
    const farRight = newLeaf({ label: "right" });
    const root = newSplit("h", newSplit("v", target, sib), farRight);
    const next = closeLeaf(root, target.id) as SplitNode;
    // The inner split collapses to `sib`; the outer split is rebuilt.
    expect(next.kind).toBe("split");
    expect(next.b).toBe(farRight);
    expect((next.a as LeafNode).label).toBe("stay");
  });

  it("is a no-op when the leaf id is not found", () => {
    const a = newLeaf();
    const b = newLeaf();
    const root = newSplit("h", a, b);
    expect(closeLeaf(root, "no-such-id")).toBe(root);
  });
});

describe("findLeaf", () => {
  it("finds the root leaf", () => {
    const leaf = newLeaf({ label: "x" });
    expect(findLeaf(leaf, leaf.id)).toBe(leaf);
  });

  it("finds a deeply nested leaf", () => {
    const target = newLeaf({ label: "deep" });
    const root = newSplit(
      "h",
      newSplit("v", newLeaf(), newSplit("h", target, newLeaf())),
      newLeaf(),
    );
    const found = findLeaf(root, target.id);
    expect(found).toBe(target);
  });

  it("returns null for unknown id", () => {
    const root = newSplit("h", newLeaf(), newLeaf());
    expect(findLeaf(root, "no-such-id")).toBeNull();
  });

  it("returns null when searching for a split node's id", () => {
    const root = newSplit("h", newLeaf(), newLeaf());
    expect(findLeaf(root, root.id)).toBeNull();
  });
});

describe("leafCount", () => {
  it("counts a single leaf as 1", () => {
    expect(leafCount(newLeaf())).toBe(1);
  });

  it("counts leaves across nested splits", () => {
    const tree = newSplit(
      "h",
      newSplit("v", newLeaf(), newLeaf()),
      newSplit("h", newLeaf(), newSplit("v", newLeaf(), newLeaf())),
    );
    expect(leafCount(tree)).toBe(5);
  });
});

describe("walkLeaves", () => {
  it("yields a single leaf for a leaf root", () => {
    const leaf = newLeaf();
    expect(Array.from(walkLeaves(leaf))).toEqual([leaf]);
  });

  it("yields leaves in left-to-right (a-first) order", () => {
    const l1 = newLeaf({ label: "1" });
    const l2 = newLeaf({ label: "2" });
    const l3 = newLeaf({ label: "3" });
    const l4 = newLeaf({ label: "4" });
    const root = newSplit("h", newSplit("v", l1, l2), newSplit("h", l3, l4));
    const labels = Array.from(walkLeaves(root)).map((l) => l.label);
    expect(labels).toEqual(["1", "2", "3", "4"]);
  });
});

describe("changeDistro", () => {
  it("sets the distro on the leaf", () => {
    const leaf = newLeaf({ distro: "Ubuntu" });
    const next = changeDistro(leaf, leaf.id, "Debian");
    expect((next as LeafNode).distro).toBe("Debian");
  });

  it("MUST swap the leaf id (so {#key} remounts XtermPane and kills the PTY)", () => {
    const leaf = newLeaf({ distro: "Ubuntu" });
    const next = changeDistro(leaf, leaf.id, "Debian") as LeafNode;
    expect(next.id).not.toBe(leaf.id);
  });

  it("preserves other leaves in a nested tree", () => {
    const target = newLeaf({ distro: "Ubuntu" });
    const other = newLeaf({ distro: "Ubuntu" });
    const root = newSplit("h", target, other);
    const next = changeDistro(root, target.id, "Debian") as SplitNode;
    expect(next.b).toBe(other);
    expect((next.a as LeafNode).distro).toBe("Debian");
  });
});

describe("changeLabel", () => {
  it("sets a label", () => {
    const leaf = newLeaf();
    const next = changeLabel(leaf, leaf.id, "my pane") as LeafNode;
    expect(next.label).toBe("my pane");
  });

  it("MUST NOT swap the leaf id (metadata-only — pane should not respawn)", () => {
    const leaf = newLeaf({ label: "old" });
    const next = changeLabel(leaf, leaf.id, "new") as LeafNode;
    expect(next.id).toBe(leaf.id);
  });

  it("trims whitespace from the label", () => {
    const leaf = newLeaf();
    const next = changeLabel(leaf, leaf.id, "  spaced  ") as LeafNode;
    expect(next.label).toBe("spaced");
  });

  it("clears the label when given empty / whitespace / undefined", () => {
    const leaf = newLeaf({ label: "had-a-name" });
    expect((changeLabel(leaf, leaf.id, "") as LeafNode).label).toBeUndefined();
    expect((changeLabel(leaf, leaf.id, "   ") as LeafNode).label).toBeUndefined();
    expect(
      (changeLabel(leaf, leaf.id, undefined) as LeafNode).label,
    ).toBeUndefined();
  });
});

describe("toggleBroadcast", () => {
  it("toggles undefined -> true", () => {
    const leaf = newLeaf();
    const next = toggleBroadcast(leaf, leaf.id) as LeafNode;
    expect(next.broadcast).toBe(true);
  });

  it("toggles true -> false", () => {
    const leaf = newLeaf({ distro: "Ubuntu" });
    const on = toggleBroadcast(leaf, leaf.id) as LeafNode;
    const off = toggleBroadcast(on, on.id) as LeafNode;
    expect(off.broadcast).toBe(false);
  });

  it("MUST NOT swap the leaf id (metadata-only)", () => {
    const leaf = newLeaf();
    const next = toggleBroadcast(leaf, leaf.id) as LeafNode;
    expect(next.id).toBe(leaf.id);
  });
});

describe("presets", () => {
  it("presetSingle returns a single leaf with the provided distro", () => {
    const t = presetSingle({ distro: "Ubuntu" });
    expect(t.kind).toBe("leaf");
    expect((t as LeafNode).distro).toBe("Ubuntu");
  });

  it("presetTwoColumns returns a horizontal split with two leaves", () => {
    const t = presetTwoColumns({ distro: "Ubuntu" }) as SplitNode;
    expect(t.kind).toBe("split");
    expect(t.orientation).toBe("h");
    expect(leafCount(t)).toBe(2);
    expect(leafDistros(t)).toEqual(["Ubuntu", "Ubuntu"]);
  });

  it("presetThreeColumns has 3 leaves with the outer split at 1/3", () => {
    const t = presetThreeColumns({ distro: "Ubuntu" }) as SplitNode;
    expect(leafCount(t)).toBe(3);
    expect(t.orientation).toBe("h");
    expect(t.ratio).toBeCloseTo(1 / 3);
  });

  it("presetTwoRows returns a vertical split", () => {
    const t = presetTwoRows() as SplitNode;
    expect(t.orientation).toBe("v");
    expect(leafCount(t)).toBe(2);
  });

  it("presetTwoByTwo returns 4 leaves in a v(h, h) shape, distro propagated", () => {
    const t = presetTwoByTwo({ distro: "Ubuntu" }) as SplitNode;
    expect(leafCount(t)).toBe(4);
    expect(t.orientation).toBe("v");
    expect((t.a as SplitNode).orientation).toBe("h");
    expect((t.b as SplitNode).orientation).toBe("h");
    expect(leafDistros(t)).toEqual(["Ubuntu", "Ubuntu", "Ubuntu", "Ubuntu"]);
  });

  it("each preset call yields fresh ids (no collisions across applications)", () => {
    const a = presetTwoByTwo();
    const b = presetTwoByTwo();
    const intersection = leafIds(a).filter((id) => leafIds(b).includes(id));
    expect(intersection).toEqual([]);
  });
});

describe("serialize / deserialize", () => {
  it("roundtrips a complex tree", () => {
    const leaf1 = newLeaf({ distro: "Ubuntu", label: "left", broadcast: true });
    const leaf2 = newLeaf({ distro: "Debian", cwd: "/projects/y" });
    const leaf3 = newLeaf();
    const tree = newSplit("h", leaf1, newSplit("v", leaf2, leaf3, 0.7), 0.4);
    const back = deserialize(serialize(tree));
    expect(back).toEqual(tree);
  });

  it("returns null on syntactically invalid JSON", () => {
    expect(deserialize("not json")).toBeNull();
  });

  it("returns null on JSON that doesn't match the tree shape", () => {
    expect(deserialize('{"not": "a tree"}')).toBeNull();
    expect(deserialize('{"kind": "leaf"}')).toBeNull(); // missing id
    expect(
      deserialize('{"kind": "split", "id": "x", "orientation": "h"}'),
    ).toBeNull(); // missing ratio + children
  });

  it("accepts a minimal leaf shape", () => {
    expect(deserialize('{"kind": "leaf", "id": "x"}')).toEqual({
      kind: "leaf",
      id: "x",
    });
  });
});
