/**
 * "One live session per agent" invariant (UI side).
 *
 * - The mock agent gateway mirrors the backend guard: launching an agent already
 *   live in another cell is refused with `AGENT_ALREADY_RUNNING`, and the same
 *   node is idempotent; `listLiveAgents` reports who runs where (T5).
 * - The per-cell dropdown blocks an agent already visible in another cell, but
 *   can rebind a background live session when the backend exposes its session id.
 */
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";

import type { Gateways, LiveAgent } from "@/ports";
import {
  MockLayoutGateway,
  MockAgentGateway,
  type GatewayError,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { LayoutGrid } from "./LayoutGrid";
import { leaves } from "./layout";

const noop = () => {};

describe("singleton agent — mock gateway guard (T5)", () => {
  it("refuses launching an agent already running in another cell", async () => {
    const agent = new MockAgentGateway();
    const a = await agent.createAgent("p1", { name: "Solo", profileId: "p" });

    // Launch in cell A.
    await agent.launchAgent(
      "p1",
      a.id,
      { cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" },
      noop,
    );

    // Launching the SAME agent in cell B is refused.
    let caught: GatewayError | null = null;
    await agent
      .launchAgent(
        "p1",
        a.id,
        { cwd: "/x", rows: 24, cols: 80, nodeId: "node-B" },
        noop,
      )
      .catch((e) => {
        caught = e as GatewayError;
      });
    expect(caught).not.toBeNull();
    expect(caught!.code).toBe("AGENT_ALREADY_RUNNING");
  });

  it("listLiveAgents reports the cell hosting each live agent", async () => {
    const agent = new MockAgentGateway();
    const a = await agent.createAgent("p1", { name: "Solo", profileId: "p" });

    expect(await agent.listLiveAgents("p1")).toEqual([]);

    await agent.launchAgent(
      "p1",
      a.id,
      { cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" },
      noop,
    );

    const live: LiveAgent[] = await agent.listLiveAgents("p1");
    expect(live).toEqual([
      {
        agentId: a.id,
        nodeId: "node-A",
        sessionId: "mock-agent-session-1",
        kind: "pty",
      },
    ]);
  });

  it("relaunching in the SAME node is allowed (idempotent), not refused", async () => {
    const agent = new MockAgentGateway();
    const a = await agent.createAgent("p1", { name: "Solo", profileId: "p" });

    await agent.launchAgent(
      "p1",
      a.id,
      { cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" },
      noop,
    );
    // Same node again must NOT throw.
    await expect(
      agent.launchAgent(
        "p1",
        a.id,
        { cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" },
        noop,
      ),
    ).resolves.toBeDefined();
  });
});

describe("singleton agent — dropdown guard (T6)", () => {
  function renderGrid(layout: MockLayoutGateway, agent: MockAgentGateway) {
    // A system gateway stub so LeafView's event subscription is a no-op.
    const system = {
      onDomainEvent: vi.fn().mockResolvedValue(() => {}),
    };
    const gateways = { layout, agent, system } as unknown as Gateways;
    return render(
      <DIProvider gateways={gateways}>
        <LayoutGrid projectId="p1" cwd="/home/me/proj" />
      </DIProvider>,
    );
  }

  it("opens an agent already running elsewhere by rebinding its live session", async () => {
    const layout = new MockLayoutGateway();
    const agent = new MockAgentGateway();
    const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
    await agent.launchAgent(
      "p1",
      a.id,
      { cwd: "/x", rows: 24, cols: 80, nodeId: "old-node" },
      noop,
    );

    const tree = await layout.loadLayout("p1");
    const leafId = leaves(tree)[0].id;

    renderGrid(layout, agent);

    // The option is selectable: choosing it rebinds the running session here.
    const option = await screen.findByRole("option", {
      name: /Busy/,
    });
    expect((option as HTMLOptionElement).disabled).toBe(false);

    const select = screen.getByRole("combobox") as HTMLSelectElement;
    fireEvent.change(select, { target: { value: a.id } });

    await waitFor(async () => {
      const updated = await layout.loadLayout("p1");
      const leaf = leaves(updated).find((l) => l.id === leafId)!;
      expect(leaf.agent).toBe(a.id);
      expect(leaf.session).toBe("mock-agent-session-1");
    });
    expect(await agent.listLiveAgents("p1")).toEqual([
      {
        agentId: a.id,
        nodeId: leafId,
        sessionId: "mock-agent-session-1",
        kind: "pty",
      },
    ]);
  });

  it("explains the missing backend contract when a live agent has no attachable session", async () => {
    const layout = new MockLayoutGateway();
    const agent = new MockAgentGateway();
    const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" });

    vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
      { agentId: a.id, nodeId: "some-other-node", kind: "pty" } as LiveAgent,
    ]);

    renderGrid(layout, agent);

    const option = await screen.findByRole("option", {
      name: /Busy/,
    });
    expect((option as HTMLOptionElement).disabled).toBe(false);

    const select = screen.getByRole("combobox") as HTMLSelectElement;
    fireEvent.change(select, { target: { value: a.id } });

    expect((await screen.findByRole("status")).textContent).toMatch(
      /Session active introuvable/,
    );
  });

  it("does not reattach a structured/headless session as an xterm PTY", async () => {
    const layout = new MockLayoutGateway();
    const agent = new MockAgentGateway();
    const a = await agent.createAgent("p1", { name: "Headless", profileId: "p" });
    const attach = vi.spyOn(agent, "attachLiveAgent");

    vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
      {
        agentId: a.id,
        nodeId: "headless-node",
        sessionId: "structured-session",
        kind: "structured",
      },
    ]);

    const tree = await layout.loadLayout("p1");
    const leafId = leaves(tree)[0].id;

    renderGrid(layout, agent);

    const option = await screen.findByRole("option", { name: /Headless/ });
    expect((option as HTMLOptionElement).disabled).toBe(false);

    const select = screen.getByRole("combobox") as HTMLSelectElement;
    fireEvent.change(select, { target: { value: a.id } });

    await waitFor(async () => {
      const updated = await layout.loadLayout("p1");
      const leaf = leaves(updated).find((l) => l.id === leafId)!;
      expect(leaf.agent).toBe(a.id);
      expect(leaf.session).toBeNull();
    });
    expect(attach).not.toHaveBeenCalled();
  });

  it("the agent pinned on THIS cell stays selectable even while live (same node)", async () => {
    const layout = new MockLayoutGateway();
    const agent = new MockAgentGateway();
    const a = await agent.createAgent("p1", { name: "Mine", profileId: "p" });

    // Pin the agent on the single leaf.
    const tree = await layout.loadLayout("p1");
    const leafId = leaves(tree)[0].id;
    await layout.mutateLayout("p1", {
      type: "setCellAgent",
      target: leafId,
      agent: a.id,
    });

    // The agent is live in THIS very cell (same node id as the leaf).
    vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
      { agentId: a.id, nodeId: leafId, sessionId: "s-own", kind: "pty" },
    ]);

    renderGrid(layout, agent);

    const option = await screen.findByRole("option", { name: "Mine" });
    // Live on its own cell ⇒ NOT disabled (it can keep running here).
    expect((option as HTMLOptionElement).disabled).toBe(false);
  });
});

describe("R0d — dropdown disables agents live elsewhere + go-to-cell", () => {
  function renderGrid(layout: MockLayoutGateway, agent: MockAgentGateway) {
    const system = {
      onDomainEvent: vi.fn().mockResolvedValue(() => {}),
    };
    const gateways = { layout, agent, system } as unknown as Gateways;
    return render(
      <DIProvider gateways={gateways}>
        <LayoutGrid projectId="p1" cwd="/home/me/proj" />
      </DIProvider>,
    );
  }

  /** Splits the project's single leaf into two visible leaves; returns both ids. */
  async function splitInTwo(
    layout: MockLayoutGateway,
  ): Promise<{ a: string; b: string }> {
    const initial = await layout.loadLayout("p1");
    const a = leaves(initial)[0].id;
    await layout.mutateLayout("p1", {
      type: "split",
      target: a,
      direction: "row",
      newLeaf: "b",
      container: "c",
    });
    const after = leaves(await layout.loadLayout("p1"));
    return { a, b: after.find((l) => l.id !== a)!.id };
  }

  it("an agent live in another VISIBLE cell ⇒ option disabled + 'aller à la cellule'", async () => {
    const layout = new MockLayoutGateway();
    const agent = new MockAgentGateway();
    const ag = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
    const { a, b } = await splitInTwo(layout);

    // The agent is DISPLAYED in cell B (a currently-visible leaf) and live there.
    // "Visible ailleurs" is derived from the layout, so the cell must actually
    // pin the agent, not merely have the live registry point at it (ticket #56).
    await layout.mutateLayout("p1", {
      type: "setCellAgent",
      target: b,
      agent: ag.id,
    });
    vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
      { agentId: ag.id, nodeId: b, sessionId: "s-1", kind: "pty" },
    ]);

    renderGrid(layout, agent);

    // In cell A's dropdown the option is DISABLED (cannot run in two cells).
    const selectA = (await screen.findByLabelText(
      `agent selector ${a}`,
    )) as HTMLSelectElement;
    await waitFor(() => {
      const opt = Array.from(selectA.options).find((o) => o.value === ag.id)!;
      expect(opt.disabled).toBe(true);
      expect(opt.textContent).toMatch(/visible ailleurs/);
    });

    // Selecting it surfaces a clear notice + an "aller à la cellule" action.
    fireEvent.change(selectA, { target: { value: ag.id } });
    expect((await screen.findByRole("status")).textContent).toMatch(
      /déjà actif dans une autre cellule/,
    );
    expect(
      screen.getByRole("button", { name: "aller à la cellule" }),
    ).toBeTruthy();
  });

  it("an agent NOT live anywhere stays selectable (no regression)", async () => {
    const layout = new MockLayoutGateway();
    const agent = new MockAgentGateway();
    const ag = await agent.createAgent("p1", { name: "Free", profileId: "p" });

    // No live agents at all.
    vi.spyOn(agent, "listLiveAgents").mockResolvedValue([]);

    renderGrid(layout, agent);

    const option = await screen.findByRole("option", { name: "Free" });
    expect((option as HTMLOptionElement).disabled).toBe(false);

    // And selecting it pins the agent on the cell (no notice, no block).
    const leafId = leaves(await layout.loadLayout("p1"))[0].id;
    const select = screen.getByRole("combobox") as HTMLSelectElement;
    fireEvent.change(select, { target: { value: ag.id } });
    await waitFor(async () => {
      const leaf = leaves(await layout.loadLayout("p1")).find(
        (l) => l.id === leafId,
      )!;
      expect(leaf.agent).toBe(ag.id);
    });
    expect(screen.queryByRole("status")).toBeNull();
  });

});

describe("ticket #56 — 'visible ailleurs' derives from the layout, not stale liveAgents.nodeId", () => {
  function renderGrid(layout: MockLayoutGateway, agent: MockAgentGateway) {
    const system = {
      onDomainEvent: vi.fn().mockResolvedValue(() => {}),
    };
    const gateways = { layout, agent, system } as unknown as Gateways;
    return render(
      <DIProvider gateways={gateways}>
        <LayoutGrid projectId="p1" cwd="/home/me/proj" />
      </DIProvider>,
    );
  }

  /** Splits the project's single leaf into two visible leaves; returns both ids. */
  async function splitInTwo(
    layout: MockLayoutGateway,
  ): Promise<{ a: string; b: string }> {
    const initial = await layout.loadLayout("p1");
    const a = leaves(initial)[0].id;
    await layout.mutateLayout("p1", {
      type: "split",
      target: a,
      direction: "row",
      newLeaf: "b",
      container: "c",
    });
    const after = leaves(await layout.loadLayout("p1"));
    return { a, b: after.find((l) => l.id !== a)!.id };
  }

  it("an agent whose former host cell now shows ANOTHER agent is selectable (background, not 'visible ailleurs')", async () => {
    const layout = new MockLayoutGateway();
    const agent = new MockAgentGateway();
    const x = await agent.createAgent("p1", { name: "Xavier", profileId: "p" });
    const y = await agent.createAgent("p1", { name: "Yann", profileId: "p" });
    const { a, b } = await splitInTwo(layout);

    // X was launched in cell A → the live registry records X → nodeId A.
    await agent.launchAgent(
      "p1",
      x.id,
      { cwd: "/x", rows: 24, cols: 80, nodeId: a },
      noop,
    );
    // …then cell A was switched to display agent Y. The live registry still
    // points X at A (stale), but A no longer displays X — X is displayed nowhere.
    await layout.mutateLayout("p1", {
      type: "setCellAgent",
      target: a,
      agent: y.id,
    });

    const attach = vi.spyOn(agent, "attachLiveAgent");
    renderGrid(layout, agent);

    // In cell B's dropdown, X is ENABLED and flagged as a background session —
    // NOT "visible ailleurs" (regression: it used to be disabled via the stale
    // live nodeId still pointing at the now-visible cell A).
    const selectB = (await screen.findByLabelText(
      `agent selector ${b}`,
    )) as HTMLSelectElement;
    await waitFor(() => {
      const opt = Array.from(selectB.options).find((o) => o.value === x.id)!;
      expect(opt.disabled).toBe(false);
      expect(opt.textContent).toMatch(/arrière-plan/);
      expect(opt.textContent).not.toMatch(/visible ailleurs/);
    });

    // Selecting X in B re-attaches its live session (no re-spawn) and pins it here.
    fireEvent.change(selectB, { target: { value: x.id } });
    await waitFor(async () => {
      const leaf = leaves(await layout.loadLayout("p1")).find((l) => l.id === b)!;
      expect(leaf.agent).toBe(x.id);
      expect(leaf.session).toBe("mock-agent-session-1");
    });
    expect(attach).toHaveBeenCalledWith("p1", x.id, b);
  });

  it("an agent STILL pinned in another visible cell stays disabled ('visible ailleurs')", async () => {
    const layout = new MockLayoutGateway();
    const agent = new MockAgentGateway();
    const x = await agent.createAgent("p1", { name: "Xavier", profileId: "p" });
    const { a, b } = await splitInTwo(layout);

    // Cell A still displays X (and X is live there): the singleton display
    // invariant must keep X non-selectable in cell B.
    await layout.mutateLayout("p1", {
      type: "setCellAgent",
      target: a,
      agent: x.id,
    });
    await agent.launchAgent(
      "p1",
      x.id,
      { cwd: "/x", rows: 24, cols: 80, nodeId: a },
      noop,
    );

    renderGrid(layout, agent);

    const selectB = (await screen.findByLabelText(
      `agent selector ${b}`,
    )) as HTMLSelectElement;
    await waitFor(() => {
      const opt = Array.from(selectB.options).find((o) => o.value === x.id)!;
      expect(opt.disabled).toBe(true);
      expect(opt.textContent).toMatch(/visible ailleurs/);
    });
  });
});
