/**
 * L4 — unit tests for the pure, framework-free layout logic
 * (`@/features/layout/layout.ts`): `normalizeWeights`, `resizeAdjacent`,
 * `applyOperation`, plus `singleLeafTree`/`leaves`/`splitOp`.
 *
 * These mirror the pure domain operations and carry no DOM/Tauri dependency.
 */
import { describe, it, expect } from "vitest";

import type { LayoutTree } from "@/domain";
import {
  applyOperation,
  droppedSessions,
  leaves,
  normalizeWeights,
  replaceCustomPluginLayoutWithTerminal,
  resizeAdjacent,
  setCustomPluginLayoutState,
  singleLeafTree,
  splitOp,
} from "./layout";

const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0);

describe("normalizeWeights", () => {
  it("returns [] for an empty list", () => {
    expect(normalizeWeights([])).toEqual([]);
  });

  it("sums to ~100 for equal weights", () => {
    const out = normalizeWeights([1, 1, 1, 1]);
    expect(sum(out)).toBeCloseTo(100, 5);
    expect(out).toEqual([25, 25, 25, 25]);
  });

  it("preserves proportions for unequal weights", () => {
    const out = normalizeWeights([3, 1]);
    expect(sum(out)).toBeCloseTo(100, 5);
    expect(out[0]).toBeCloseTo(75, 5);
    expect(out[1]).toBeCloseTo(25, 5);
  });

  it("floors non-positive / NaN weights to an epsilon (still renders)", () => {
    const out = normalizeWeights([0, -5, Number.NaN, 1]);
    expect(sum(out)).toBeCloseTo(100, 5);
    // The last (only real) track dominates; the floored ones stay > 0.
    expect(out.every((p) => p > 0)).toBe(true);
    expect(out[3]).toBeGreaterThan(99);
  });

  it("falls back to even shares when every weight is degenerate", () => {
    const out = normalizeWeights([0, 0]);
    expect(sum(out)).toBeCloseTo(100, 5);
    expect(out[0]).toBeCloseTo(out[1], 5);
  });
});

describe("resizeAdjacent", () => {
  it("transfers extent between two adjacent tracks", () => {
    const out = resizeAdjacent([50, 50], 0, 0.1); // grow track 0 by 10%
    expect(out[0]).toBeCloseTo(60, 5);
    expect(out[1]).toBeCloseTo(40, 5);
    expect(sum(out)).toBeCloseTo(100, 5); // total preserved
  });

  it("only affects the two adjacent tracks, leaving others untouched", () => {
    const out = resizeAdjacent([40, 40, 20], 0, 0.1);
    expect(out[2]).toBe(20);
  });

  it("returns a fresh array (does not mutate the input)", () => {
    const input = [50, 50];
    const out = resizeAdjacent(input, 0, 0.1);
    expect(out).not.toBe(input);
    expect(input).toEqual([50, 50]);
  });

  it("refuses a drag that would crush a track below the minimum", () => {
    const input = [50, 50];
    // A near-total drag would push track 1 below the 5% floor → no change.
    const out = resizeAdjacent(input, 0, 0.49);
    expect(out).toEqual([50, 50]);
  });

  it("is a no-op for an out-of-range index", () => {
    expect(resizeAdjacent([50, 50], 1, 0.1)).toEqual([50, 50]);
    expect(resizeAdjacent([50, 50], -1, 0.1)).toEqual([50, 50]);
  });
});

describe("singleLeafTree / leaves", () => {
  it("builds a single empty leaf with the given id", () => {
    const tree = singleLeafTree("abc");
    expect(tree.root.type).toBe("leaf");
    expect(leaves(tree)).toEqual([{ id: "abc", session: null }]);
  });
});

describe("applyOperation", () => {
  const base = (): LayoutTree => singleLeafTree("a");

  it("split increases the leaf count", () => {
    const before = base();
    const op = { ...splitOp("a", "row"), newLeaf: "b", container: "c" } as const;
    const after = applyOperation(before, op);
    expect(leaves(before)).toHaveLength(1); // original untouched (immutable)
    expect(leaves(after)).toHaveLength(2);
    expect(after.root.type).toBe("split");
  });

  it("resize changes the child weights", () => {
    const split = applyOperation(base(), {
      type: "split",
      target: "a",
      direction: "row",
      newLeaf: "b",
      container: "c",
    });
    const resized = applyOperation(split, {
      type: "resize",
      container: "c",
      weights: [3, 1],
    });
    expect(resized.root.type).toBe("split");
    if (resized.root.type === "split") {
      expect(resized.root.node.children.map((c) => c.weight)).toEqual([3, 1]);
    }
  });

  it("setSession attaches the session id to the leaf", () => {
    const after = applyOperation(base(), {
      type: "setSession",
      target: "a",
      session: "sess-1",
    });
    expect(leaves(after)[0]).toEqual({ id: "a", session: "sess-1" });
  });

  it("setSession with null clears the session", () => {
    const attached = applyOperation(base(), {
      type: "setSession",
      target: "a",
      session: "sess-1",
    });
    const cleared = applyOperation(attached, {
      type: "setSession",
      target: "a",
      session: null,
    });
    expect(leaves(cleared)[0].session ?? null).toBeNull();
  });

  it("merge collapses a split back to a kept child", () => {
    const split = applyOperation(base(), {
      type: "split",
      target: "a",
      direction: "row",
      newLeaf: "b",
      container: "c",
    });
    const merged = applyOperation(split, {
      type: "merge",
      container: "c",
      keepIndex: 1,
    });
    expect(merged.root.type).toBe("leaf");
    expect(leaves(merged)).toEqual([{ id: "b", session: null }]);
  });

  it("throws a NOT_FOUND-like error on an unknown target", () => {
    expect(() =>
      applyOperation(base(), {
        type: "setSession",
        target: "missing",
        session: null,
      }),
    ).toThrowError(/not found/);
  });
});

describe("applyOperation — conversationId / agentWasRunning persistence", () => {
  const base = (): LayoutTree => singleLeafTree("a");
  // A single leaf "a" carrying both new fields (the T8 invariant under test).
  const seeded = (): LayoutTree => ({
    root: {
      type: "leaf",
      node: { id: "a", session: null, conversationId: "conv-1", agentWasRunning: true },
    },
  });

  // --- Non-regression of the propagation trap (#1) ---------------------------
  it("setSession preserves conversationId and agentWasRunning", () => {
    const after = applyOperation(seeded(), {
      type: "setSession",
      target: "a",
      session: "sess-1",
    });
    const leaf = leaves(after)[0];
    expect(leaf.session).toBe("sess-1");
    expect(leaf.conversationId).toBe("conv-1");
    expect(leaf.agentWasRunning).toBe(true);
  });

  it("setCellAgent preserves conversationId and agentWasRunning", () => {
    const after = applyOperation(seeded(), {
      type: "setCellAgent",
      target: "a",
      agent: "agent-7",
    });
    const leaf = leaves(after)[0];
    expect(leaf.agent).toBe("agent-7");
    expect(leaf.conversationId).toBe("conv-1");
    expect(leaf.agentWasRunning).toBe(true);
  });

  it("move preserves conversationId and agentWasRunning on both ends", () => {
    // from = "a" (seeded, with a session to move); to = "b" (empty), both carrying
    // the new fields. Build a split so both leaves coexist.
    let tree: LayoutTree = {
      root: {
        type: "split",
        node: {
          id: "c",
          direction: "row",
          children: [
            {
              node: {
                type: "leaf",
                node: {
                  id: "a",
                  session: "sess-a",
                  conversationId: "conv-a",
                  agentWasRunning: true,
                },
              },
              weight: 1,
            },
            {
              node: {
                type: "leaf",
                node: {
                  id: "b",
                  session: null,
                  conversationId: "conv-b",
                  agentWasRunning: true,
                },
              },
              weight: 1,
            },
          ],
        },
      },
    };
    tree = applyOperation(tree, { type: "move", from: "a", to: "b" });
    const byId = Object.fromEntries(leaves(tree).map((l) => [l.id, l]));
    // Session moved a → b…
    expect(byId.a.session ?? null).toBeNull();
    expect(byId.b.session).toBe("sess-a");
    // …but the new fields stay put on both leaves.
    expect(byId.a.conversationId).toBe("conv-a");
    expect(byId.a.agentWasRunning).toBe(true);
    expect(byId.b.conversationId).toBe("conv-b");
    expect(byId.b.agentWasRunning).toBe(true);
  });

  // --- setCellConversation ---------------------------------------------------
  it("setCellConversation sets the conversationId on the target leaf", () => {
    const after = applyOperation(base(), {
      type: "setCellConversation",
      target: "a",
      conversationId: "conv-9",
    });
    expect(leaves(after)[0].conversationId).toBe("conv-9");
  });

  it("setCellConversation with null clears (omits) the conversationId", () => {
    const set = applyOperation(base(), {
      type: "setCellConversation",
      target: "a",
      conversationId: "conv-9",
    });
    const cleared = applyOperation(set, {
      type: "setCellConversation",
      target: "a",
      conversationId: null,
    });
    expect(leaves(cleared)[0].conversationId).toBeUndefined();
    expect("conversationId" in leaves(cleared)[0]).toBe(false);
  });

  it("setCellConversation throws NOT_FOUND on an unknown target", () => {
    expect(() =>
      applyOperation(base(), {
        type: "setCellConversation",
        target: "missing",
        conversationId: "x",
      }),
    ).toThrowError(/not found/);
  });

  // --- setAgentRunning -------------------------------------------------------
  it("setAgentRunning true sets the flag", () => {
    const after = applyOperation(base(), {
      type: "setAgentRunning",
      target: "a",
      running: true,
    });
    expect(leaves(after)[0].agentWasRunning).toBe(true);
  });

  it("setAgentRunning false clears (omits) the flag", () => {
    const set = applyOperation(base(), {
      type: "setAgentRunning",
      target: "a",
      running: true,
    });
    const cleared = applyOperation(set, {
      type: "setAgentRunning",
      target: "a",
      running: false,
    });
    expect(leaves(cleared)[0].agentWasRunning).toBeUndefined();
    expect("agentWasRunning" in leaves(cleared)[0]).toBe(false);
  });

  it("setAgentRunning throws NOT_FOUND on an unknown target", () => {
    expect(() =>
      applyOperation(base(), {
        type: "setAgentRunning",
        target: "missing",
        running: true,
      }),
    ).toThrowError(/not found/);
  });

  // --- independence of conversationId and session ----------------------------
  it("a leaf can carry conversationId without a session, and vice versa", () => {
    // conversationId without session.
    const convOnly = applyOperation(base(), {
      type: "setCellConversation",
      target: "a",
      conversationId: "conv-only",
    });
    expect(leaves(convOnly)[0].conversationId).toBe("conv-only");
    expect(leaves(convOnly)[0].session ?? null).toBeNull();

    // session without conversationId.
    const sessOnly = applyOperation(base(), {
      type: "setSession",
      target: "a",
      session: "sess-only",
    });
    expect(leaves(sessOnly)[0].session).toBe("sess-only");
    expect(leaves(sessOnly)[0].conversationId).toBeUndefined();
  });
});

describe("droppedSessions", () => {
  // A split "c" with children a (kept) and b (dropped), each holding a session.
  const split = (): LayoutTree => {
    let tree = applyOperation(singleLeafTree("a"), {
      type: "split",
      target: "a",
      direction: "row",
      newLeaf: "b",
      container: "c",
    });
    tree = applyOperation(tree, { type: "setSession", target: "a", session: "sess-a" });
    tree = applyOperation(tree, { type: "setSession", target: "b", session: "sess-b" });
    return tree;
  };

  it("returns the sessions of the children other than keepIndex", () => {
    // Keep child 0 (a) → b is dropped.
    expect(droppedSessions(split(), "c", 0)).toEqual(["sess-b"]);
    // Keep child 1 (b) → a is dropped.
    expect(droppedSessions(split(), "c", 1)).toEqual(["sess-a"]);
  });

  it("ignores leaves with no session", () => {
    const tree = applyOperation(singleLeafTree("a"), {
      type: "split",
      target: "a",
      direction: "row",
      newLeaf: "b",
      container: "c",
    });
    // Neither leaf has a session yet.
    expect(droppedSessions(tree, "c", 0)).toEqual([]);
  });

  it("returns [] for an unknown container", () => {
    expect(droppedSessions(split(), "missing", 0)).toEqual([]);
  });
});

describe("splitOp", () => {
  it("builds a split op with fresh ids", () => {
    const op = splitOp("a", "column");
    expect(op).toMatchObject({ type: "split", target: "a", direction: "column" });
    if (op.type === "split") {
      expect(typeof op.newLeaf).toBe("string");
      expect(typeof op.container).toBe("string");
      expect(op.newLeaf).not.toEqual(op.container);
    }
  });
});

// #43, F4 — the `customPluginLayout` top-level `LayoutNode` variant, cadré
// (carnet v2 §3.2) as the exact backend serde shape
// (`#[serde(tag = "type", content = "node")]`, camelCase). These JSON literals
// are the carnet's own worked examples, parsed with `JSON.parse` + a type
// assertion (never hand-built as TS object literals) so this test actually
// exercises "a layout JSON produced by Rust", not just the TS type shape.
describe("customPluginLayout — parsing a backend-shaped JSON tree", () => {
  const ROOT_PLUGIN_LAYOUT_JSON = `{
    "root": {
      "type": "customPluginLayout",
      "node": {
        "id": "018f0c5a-2b4b-70d4-a7c2-300000000001",
        "pluginId": "dev.acme.gitgraph",
        "layoutType": "dev.acme.gitgraph.layout",
        "state": { "branchFilter": "main" }
      }
    }
  }`;

  const SPLIT_WITH_PLUGIN_LAYOUT_JSON = `{
    "root": {
      "type": "split",
      "node": {
        "id": "split-1",
        "direction": "row",
        "children": [
          {
            "weight": 1,
            "node": { "type": "leaf", "node": { "id": "terminal-1" } }
          },
          {
            "weight": 1,
            "node": {
              "type": "customPluginLayout",
              "node": {
                "id": "plugin-cell-1",
                "pluginId": "dev.acme.gitgraph",
                "layoutType": "dev.acme.gitgraph.layout",
                "state": {}
              }
            }
          }
        ]
      }
    }
  }`;

  it("parses a root-level customPluginLayout node with its opaque state intact", () => {
    const tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree;
    expect(tree.root.type).toBe("customPluginLayout");
    if (tree.root.type !== "customPluginLayout") throw new Error("unreachable");
    expect(tree.root.node).toEqual({
      id: "018f0c5a-2b4b-70d4-a7c2-300000000001",
      pluginId: "dev.acme.gitgraph",
      layoutType: "dev.acme.gitgraph.layout",
      state: { branchFilter: "main" },
    });
  });

  it("parses a customPluginLayout node nested as a split child alongside a terminal leaf", () => {
    const tree = JSON.parse(SPLIT_WITH_PLUGIN_LAYOUT_JSON) as LayoutTree;
    expect(tree.root.type).toBe("split");
    if (tree.root.type !== "split") throw new Error("unreachable");
    const [terminalChild, pluginChild] = tree.root.node.children;
    expect(terminalChild.node).toEqual({ type: "leaf", node: { id: "terminal-1" } });
    expect(pluginChild.node.type).toBe("customPluginLayout");
    if (pluginChild.node.type !== "customPluginLayout") throw new Error("unreachable");
    expect(pluginChild.node.node.pluginId).toBe("dev.acme.gitgraph");
  });

  it("leaves() does not pick up a customPluginLayout node as a terminal leaf", () => {
    const tree = JSON.parse(SPLIT_WITH_PLUGIN_LAYOUT_JSON) as LayoutTree;
    expect(leaves(tree).map((l) => l.id)).toEqual(["terminal-1"]);
  });

  it("setCustomPluginLayoutState patches only the matching node's state, leaving the rest of the tree untouched", () => {
    const tree = JSON.parse(SPLIT_WITH_PLUGIN_LAYOUT_JSON) as LayoutTree;
    const updated = setCustomPluginLayoutState(tree, "plugin-cell-1", { branchFilter: "feature/x" });
    if (updated.root.type !== "split") throw new Error("unreachable");
    const [terminalChild, pluginChild] = updated.root.node.children;
    expect(terminalChild.node).toEqual({ type: "leaf", node: { id: "terminal-1" } });
    if (pluginChild.node.type !== "customPluginLayout") throw new Error("unreachable");
    expect(pluginChild.node.node.state).toEqual({ branchFilter: "feature/x" });
    expect(pluginChild.node.node.id).toBe("plugin-cell-1");
    expect(pluginChild.node.node.pluginId).toBe("dev.acme.gitgraph");
  });

  it("setCustomPluginLayoutState is a no-op when the node id isn't found", () => {
    const tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree;
    const updated = setCustomPluginLayoutState(tree, "does-not-exist", { x: 1 });
    expect(updated).toEqual(tree);
  });

  it("applyOperation persists plugin layout state via setPluginLayoutState", () => {
    const tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree;
    const updated = applyOperation(tree, {
      type: "setPluginLayoutState",
      target: "018f0c5a-2b4b-70d4-a7c2-300000000001",
      state: { branchFilter: "release/1" },
    });
    expect(updated.root.type).toBe("customPluginLayout");
    if (updated.root.type !== "customPluginLayout") throw new Error("unreachable");
    expect(updated.root.node.state).toEqual({ branchFilter: "release/1" });
    expect(updated.root.node.pluginId).toBe("dev.acme.gitgraph");
    expect(updated.root.node.layoutType).toBe("dev.acme.gitgraph.layout");
  });

  it("replaceCustomPluginLayoutWithTerminal swaps the node for a blank terminal leaf of the same id", () => {
    const tree = JSON.parse(ROOT_PLUGIN_LAYOUT_JSON) as LayoutTree;
    const updated = replaceCustomPluginLayoutWithTerminal(
      tree,
      "018f0c5a-2b4b-70d4-a7c2-300000000001",
    );
    expect(updated.root).toEqual({
      type: "leaf",
      node: { id: "018f0c5a-2b4b-70d4-a7c2-300000000001" },
    });
  });
});
