/**
 * L6 — agents feature wired to the stateful `MockAgentGateway` (and a seeded
 * `MockProfileGateway`) via the real `DIProvider`.
 *
 * Covers:
 * - create → agent appears in list
 * - select → context is shown; edit + save → re-reading shows new content
 * - delete → agent removed from list
 * - launch → `launchAgent` is called and mounts a terminal view; stop unmounts it
 * - empty name → Create button disabled
 * - MockAgentGateway unit behaviour: slug deduplication, NOT_FOUND errors
 */

import { describe, it, expect, vi } from "vitest";
import {
  render,
  screen,
  waitFor,
  fireEvent,
} from "@testing-library/react";

import {
  MockAgentGateway,
  MockProfileGateway,
  MockSystemGateway,
  MockTemplateGateway,
} from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { AgentsPanel } from "./AgentsPanel";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const PROJECT_ID = "proj-test-001";

/**
 * Renders `AgentsPanel` behind a `DIProvider` with an isolated
 * `MockAgentGateway` (and optionally a seeded `MockProfileGateway`).
 */
function renderPanel(
  agent: MockAgentGateway = new MockAgentGateway(),
  profile: MockProfileGateway = new MockProfileGateway(),
  projectRoot = "/home/me/proj",
  template?: MockTemplateGateway,
) {
  const tmpl = template ?? new MockTemplateGateway(agent);
  const gateways = { agent, profile, template: tmpl } as unknown as Gateways;
  return {
    agent,
    profile,
    template: tmpl,
    ...render(
      <DIProvider gateways={gateways}>
        <AgentsPanel projectId={PROJECT_ID} projectRoot={projectRoot} />
      </DIProvider>,
    ),
  };
}

/** Waits until no Spinner is visible (busy → idle). */
async function waitForIdle() {
  // The Create button is enabled when not busy and name is non-empty.
  // We wait for the panel to stabilise by waiting for "No agents yet." or
  // for the agent list to be rendered.
  await waitFor(() => {
    // Panel has finished its initial load when the form is accessible.
    expect(screen.getByLabelText("agent name")).toBeTruthy();
  });
}

/** Types a name into the agent name field, optionally selects a profile, and clicks Create. */
async function createAgent(name: string, profileId?: string) {
  await waitForIdle();

  fireEvent.change(screen.getByLabelText("agent name"), {
    target: { value: name },
  });

  if (profileId) {
    const profileInput = screen.queryByLabelText("agent profile");
    if (profileInput) {
      fireEvent.change(profileInput, { target: { value: profileId } });
    }
  }

  const createBtn = screen.getByRole("button", { name: "create agent" });
  expect((createBtn as HTMLButtonElement).disabled).toBe(false);
  fireEvent.click(createBtn);
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("AgentsPanel (with MockAgentGateway)", () => {
  it("shows 'No agents yet.' when the project has no agents", async () => {
    renderPanel();
    await waitForIdle();
    expect(screen.getByText("No agents yet.")).toBeTruthy();
  });

  it("creating an agent adds it to the list", async () => {
    renderPanel();
    await createAgent("My Worker");
    const agent = await screen.findByText("My Worker");
    expect(agent).toBeTruthy();
  });

  it("the Create button is disabled when the name is empty", async () => {
    renderPanel();
    await waitForIdle();
    // Name field is empty by default → disabled
    const btn = screen.getByRole("button", { name: "create agent" });
    expect((btn as HTMLButtonElement).disabled).toBe(true);
  });

  it("selecting an agent displays its context", async () => {
    const agent = new MockAgentGateway();
    // Pre-seed an agent with initial content.
    await agent.createAgent(PROJECT_ID, {
      name: "Alpha",
      profileId: "p1",
      initialContent: "## Alpha context",
    });
    renderPanel(agent);

    await waitForIdle();

    // Click on the agent row button (aria-pressed) to select it — not the
    // "launch Alpha" action button.
    const buttons = screen.getAllByRole("button", { name: /alpha/i });
    const rowBtn = buttons.find(
      (b) => b.hasAttribute("aria-pressed"),
    )!;
    fireEvent.click(rowBtn);

    const textarea = await screen.findByLabelText("agent context");
    expect((textarea as HTMLTextAreaElement).value).toBe("## Alpha context");
  });

  it("editing and saving context persists the new content", async () => {
    const agent = new MockAgentGateway();
    await agent.createAgent(PROJECT_ID, {
      name: "Beta",
      profileId: "p1",
      initialContent: "initial",
    });
    renderPanel(agent);
    await waitForIdle();

    // Select the agent row button (aria-pressed), not the action buttons.
    const buttons = screen.getAllByRole("button", { name: /beta/i });
    const rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
    fireEvent.click(rowBtn);

    const textarea = await screen.findByLabelText("agent context");
    fireEvent.change(textarea, { target: { value: "updated content" } });

    fireEvent.click(screen.getByRole("button", { name: "Save" }));

    // After save the gateway should hold the new content.
    await waitFor(async () => {
      const agents = await agent.listAgents(PROJECT_ID);
      const ctx = await agent.readContext(PROJECT_ID, agents[0].id);
      expect(ctx).toBe("updated content");
    });
  });

  it("deleting an agent removes it from the list", async () => {
    const agent = new MockAgentGateway();
    await agent.createAgent(PROJECT_ID, {
      name: "Gamma",
      profileId: "p1",
    });
    renderPanel(agent);
    await waitForIdle();

    await screen.findByText("Gamma");

    fireEvent.click(screen.getByRole("button", { name: "delete Gamma" }));

    await waitFor(() => {
      expect(screen.queryByText("Gamma")).toBeNull();
    });

    // Gateway-level check.
    const remaining = await agent.listAgents(PROJECT_ID);
    expect(remaining).toHaveLength(0);
  });

  it("launching an agent calls launchAgent and mounts the terminal view", async () => {
    const agent = new MockAgentGateway();
    const createdAgent = await agent.createAgent(PROJECT_ID, {
      name: "Delta",
      profileId: "p1",
    });

    // Spy on launchAgent.
    const launchSpy = vi.spyOn(agent, "launchAgent");

    renderPanel(agent);
    await waitForIdle();

    fireEvent.click(screen.getByRole("button", { name: "launch Delta" }));

    // The terminal container div (data-testid="terminal-view") should be mounted.
    await waitFor(() => {
      expect(screen.getByTestId("terminal-view")).toBeTruthy();
    });

    // launchAgent should have been called with the correct projectId and agentId.
    await waitFor(() => {
      if (launchSpy.mock.calls.length > 0) {
        expect(launchSpy.mock.calls[0][0]).toBe(PROJECT_ID);
        expect(launchSpy.mock.calls[0][1]).toBe(createdAgent.id);
        // options (rows/cols) and onData callback
        expect(typeof launchSpy.mock.calls[0][2]).toBe("object");
        expect(typeof launchSpy.mock.calls[0][3]).toBe("function");
      }
    });
  });

  it("stopping an agent unmounts the terminal view", async () => {
    const agent = new MockAgentGateway();
    await agent.createAgent(PROJECT_ID, { name: "Echo", profileId: "p1" });

    renderPanel(agent);
    await waitForIdle();

    // Launch
    fireEvent.click(screen.getByRole("button", { name: "launch Echo" }));
    await waitFor(() => {
      expect(screen.getByTestId("terminal-view")).toBeTruthy();
    });

    // Stop
    fireEvent.click(screen.getByRole("button", { name: "stop Echo" }));
    await waitFor(() => {
      expect(screen.queryByTestId("terminal-view")).toBeNull();
    });
  });

  it("selecting a template calls createAgentFromTemplate instead of createAgent", async () => {
    const agent = new MockAgentGateway();
    const tmpl = new MockTemplateGateway(agent);
    // Seed a template so the selector has something to choose
    const t = await tmpl.createTemplate({
      name: "My Template",
      content: "# ctx",
      defaultProfileId: "p1",
    });

    const createFromTemplateSpy = vi.spyOn(tmpl, "createAgentFromTemplate");

    renderPanel(agent, new MockProfileGateway(), "/home/me/proj", tmpl);
    await waitForIdle();

    // Select the template in the dropdown
    const templateSelect = screen.getByLabelText("agent template") as HTMLSelectElement;
    fireEvent.change(templateSelect, { target: { value: t.id } });

    // Fill agent name
    fireEvent.change(screen.getByLabelText("agent name"), {
      target: { value: "From Template" },
    });

    // Click Create
    fireEvent.click(screen.getByRole("button", { name: "create agent" }));

    // createAgentFromTemplate should have been called with the correct args
    await waitFor(() => {
      expect(createFromTemplateSpy).toHaveBeenCalledWith(
        PROJECT_ID,
        t.id,
        { name: "From Template", synchronized: true },
      );
    });

    // Agent should appear in the list
    await screen.findByText("From Template");
  });

  it("shows profiles in the dropdown when profiles are configured", async () => {
    const profile = new MockProfileGateway();
    await profile.configureProfiles([
      {
        id: "prof-1",
        name: "Claude Code",
        command: "claude",
        args: [],
        contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
        detect: null,
        cwdTemplate: "{projectRoot}",
      },
    ]);
    renderPanel(new MockAgentGateway(), profile);
    await waitForIdle();

    // The select element should contain the profile option.
    const select = screen.getByLabelText("agent profile") as HTMLSelectElement;
    const options = Array.from(select.options).map((o) => o.text);
    expect(options).toContain("Claude Code");
  });
});

// ---------------------------------------------------------------------------
// MockAgentGateway unit tests
// ---------------------------------------------------------------------------

/** Helper: minimal valid options for launchAgent. */
const LAUNCH_OPTS = { cwd: "/tmp", rows: 24, cols: 80 };
const NOOP_ON_DATA = (_bytes: Uint8Array) => {};

describe("MockAgentGateway (unit)", () => {
  it("creates agents with unique slugified contextPaths", async () => {
    const gw = new MockAgentGateway();
    const a1 = await gw.createAgent("proj", { name: "My Agent", profileId: "p" });
    const a2 = await gw.createAgent("proj", { name: "My Agent", profileId: "p" });
    const a3 = await gw.createAgent("proj", { name: "My Agent", profileId: "p" });

    expect(a1.contextPath).toBe("agents/my-agent.md");
    expect(a2.contextPath).toBe("agents/my-agent-2.md");
    expect(a3.contextPath).toBe("agents/my-agent-3.md");
  });

  it("readContext throws NOT_FOUND for unknown agent", async () => {
    const gw = new MockAgentGateway();
    await expect(gw.readContext("proj", "ghost-id")).rejects.toMatchObject({
      code: "NOT_FOUND",
    });
  });

  it("deleteAgent throws NOT_FOUND for unknown agent", async () => {
    const gw = new MockAgentGateway();
    await expect(gw.deleteAgent("proj", "ghost-id")).rejects.toMatchObject({
      code: "NOT_FOUND",
    });
  });

  it("launchAgent returns a TerminalHandle with sequential session ids", async () => {
    const gw = new MockAgentGateway();
    const a1 = await gw.createAgent("proj", { name: "A1", profileId: "p" });
    const a2 = await gw.createAgent("proj", { name: "A2", profileId: "p" });
    const h1 = await gw.launchAgent("proj", a1.id, LAUNCH_OPTS, NOOP_ON_DATA);
    const h2 = await gw.launchAgent("proj", a2.id, LAUNCH_OPTS, NOOP_ON_DATA);
    expect(h1.sessionId).toBe("mock-agent-session-1");
    expect(h2.sessionId).toBe("mock-agent-session-2");
  });

  it("launchAgent throws NOT_FOUND for an agent not in the project", async () => {
    const gw = new MockAgentGateway();
    await expect(
      gw.launchAgent("proj", "unknown-id", LAUNCH_OPTS, NOOP_ON_DATA),
    ).rejects.toMatchObject({ code: "NOT_FOUND" });
  });

  it("launchAgent greeting is delivered via onData", async () => {
    const gw = new MockAgentGateway();
    const a = await gw.createAgent("proj", { name: "Greeter", profileId: "p" });
    const received: Uint8Array[] = [];
    await gw.launchAgent("proj", a.id, LAUNCH_OPTS, (bytes) => received.push(bytes));
    // Wait for the queueMicrotask greeting
    await new Promise((r) => setTimeout(r, 0));
    const dec = new TextDecoder();
    const msg = received.map((b) => dec.decode(b)).join("");
    expect(msg).toContain(`agent ${a.id}`);
  });

  it("launchAgent handle write echoes bytes back via onData", async () => {
    const gw = new MockAgentGateway();
    const a = await gw.createAgent("proj", { name: "Echo", profileId: "p" });
    const received: Uint8Array[] = [];
    const handle = await gw.launchAgent("proj", a.id, LAUNCH_OPTS, (bytes) =>
      received.push(bytes),
    );
    await handle.write(new TextEncoder().encode("hi"));
    const dec = new TextDecoder();
    const echoed = received.map((b) => dec.decode(b)).join("");
    // The greeting (via queueMicrotask) and the echo both arrive; just check echo is there
    expect(echoed).toContain("hi");
  });

  it("projects are isolated — agents in one project don't appear in another", async () => {
    const gw = new MockAgentGateway();
    await gw.createAgent("proj-A", { name: "A Agent", profileId: "p" });
    const listB = await gw.listAgents("proj-B");
    expect(listB).toHaveLength(0);
  });
});

// ---------------------------------------------------------------------------
// A2 — profile hot-swap selector + confirmation dialog
// ---------------------------------------------------------------------------

/** Seeds two profiles so an agent's engine can be swapped to a different one. */
async function seededProfiles(): Promise<MockProfileGateway> {
  const profile = new MockProfileGateway();
  await profile.configureProfiles([
    {
      id: "prof-1",
      name: "Claude Code",
      command: "claude",
      args: [],
      contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
      detect: null,
      cwdTemplate: "{projectRoot}",
    },
    {
      id: "prof-2",
      name: "Codex CLI",
      command: "codex",
      args: [],
      contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
      detect: null,
      cwdTemplate: "{projectRoot}",
    },
  ]);
  return profile;
}

describe("AgentsPanel profile hot-swap (A2)", () => {
  it("changing the profile select does NOT call changeAgentProfile immediately — it opens a confirmation dialog", async () => {
    const agent = new MockAgentGateway();
    await agent.createAgent(PROJECT_ID, { name: "Swap", profileId: "prof-1" });
    const swapSpy = vi.spyOn(agent, "changeAgentProfile");

    renderPanel(agent, await seededProfiles());
    await waitForIdle();
    await screen.findByText("Swap");

    const select = screen.getByLabelText("profile for Swap") as HTMLSelectElement;
    fireEvent.change(select, { target: { value: "prof-2" } });

    // No gateway call yet — only the dialog appears.
    expect(swapSpy).not.toHaveBeenCalled();
    expect(screen.getByRole("dialog")).toBeTruthy();
  });

  it("Cancel closes the dialog without calling changeAgentProfile", async () => {
    const agent = new MockAgentGateway();
    await agent.createAgent(PROJECT_ID, { name: "Swap", profileId: "prof-1" });
    const swapSpy = vi.spyOn(agent, "changeAgentProfile");

    renderPanel(agent, await seededProfiles());
    await waitForIdle();
    await screen.findByText("Swap");

    fireEvent.change(screen.getByLabelText("profile for Swap"), {
      target: { value: "prof-2" },
    });
    fireEvent.click(
      screen.getByRole("button", { name: "cancel profile change" }),
    );

    await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
    expect(swapSpy).not.toHaveBeenCalled();
  });

  it("Continue confirms and calls changeAgentProfile with the chosen profile", async () => {
    const agent = new MockAgentGateway();
    const created = await agent.createAgent(PROJECT_ID, {
      name: "Swap",
      profileId: "prof-1",
    });
    const swapSpy = vi.spyOn(agent, "changeAgentProfile");

    renderPanel(agent, await seededProfiles());
    await waitForIdle();
    await screen.findByText("Swap");

    fireEvent.change(screen.getByLabelText("profile for Swap"), {
      target: { value: "prof-2" },
    });
    fireEvent.click(
      screen.getByRole("button", { name: "confirm profile change" }),
    );

    await waitFor(() => {
      expect(swapSpy).toHaveBeenCalled();
      expect(swapSpy.mock.calls[0][0]).toBe(PROJECT_ID);
      expect(swapSpy.mock.calls[0][1]).toBe(created.id);
      expect(swapSpy.mock.calls[0][2]).toBe("prof-2");
    });
    // Dialog closes after confirmation.
    await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
  });

  it("the confirmation dialog message is in FRENCH and conveys the spec §15.1 wording", async () => {
    const agent = new MockAgentGateway();
    await agent.createAgent(PROJECT_ID, { name: "Swap", profileId: "prof-1" });

    renderPanel(agent, await seededProfiles());
    await waitForIdle();
    await screen.findByText("Swap");

    fireEvent.change(screen.getByLabelText("profile for Swap"), {
      target: { value: "prof-2" },
    });

    const dialog = screen.getByRole("dialog");
    const text = dialog.textContent ?? "";
    // Spec §15.1: « Changer le moteur abandonne l'historique de conversation
    // (le contexte et la mémoire sont conservés). Continuer ? » — must be FRENCH.
    expect(text).toMatch(/abandonne l'historique de conversation/i);
    expect(text).toMatch(/contexte et la mémoire sont conservés/i);
    expect(text).toMatch(/Continuer\s*\?/);
  });
});

describe("AgentsPanel live refresh on domain events", () => {
  it("refreshes the list when an `agentLaunched` event fires (out-of-band creation)", async () => {
    const agent = new MockAgentGateway();
    const profile = new MockProfileGateway();
    const system = new MockSystemGateway();
    const template = new MockTemplateGateway(agent);
    const gateways = { agent, profile, system, template } as unknown as Gateways;

    render(
      <DIProvider gateways={gateways}>
        <AgentsPanel projectId={PROJECT_ID} projectRoot="/home/me/proj" />
      </DIProvider>,
    );

    // Initially empty.
    await waitFor(() => expect(screen.getByText("No agents yet.")).toBeTruthy());

    // Simulate the orchestrator creating an agent directly in the store
    // (bypassing the UI's createAgent path), then emitting `agentLaunched`.
    const created = await agent.createAgent(PROJECT_ID, {
      name: "Orchestrated",
      profileId: "p1",
    });
    system.emit({
      type: "agentLaunched",
      agentId: created.id,
      sessionId: "sess-1",
    });

    // The panel must reflect the new agent without any manual reload.
    expect(await screen.findByText("Orchestrated")).toBeTruthy();
  });

  it("refreshes the list when an `agentProfileChanged` event fires (A2 hot-swap)", async () => {
    const agent = new MockAgentGateway();
    const profile = new MockProfileGateway();
    const system = new MockSystemGateway();
    const template = new MockTemplateGateway(agent);
    const gateways = { agent, profile, system, template } as unknown as Gateways;

    const created = await agent.createAgent(PROJECT_ID, {
      name: "Swapper",
      profileId: "p1",
    });

    render(
      <DIProvider gateways={gateways}>
        <AgentsPanel projectId={PROJECT_ID} projectRoot="/home/me/proj" />
      </DIProvider>,
    );
    await waitFor(() => expect(screen.getByText("Swapper")).toBeTruthy());

    // Mutate the profile out of band, then emit the event the backend relays.
    await agent.changeAgentProfile(PROJECT_ID, created.id, "p2", 24, 80);
    const refreshSpy = vi.spyOn(agent, "listAgents");
    system.emit({
      type: "agentProfileChanged",
      agentId: created.id,
      profileId: "p2",
    });

    // The hook re-fetches the list on the event.
    await waitFor(() => expect(refreshSpy).toHaveBeenCalled());
  });

  it("does not crash when the system gateway is absent", async () => {
    // The existing renderPanel helper injects no `system` gateway; the
    // subscription effect must no-op rather than throw.
    renderPanel();
    await waitForIdle();
    expect(screen.getByText("No agents yet.")).toBeTruthy();
  });
});

// ---------------------------------------------------------------------------
// M4 — orchestration source badge (mcp / file) on `orchestratorRequestProcessed`
// ---------------------------------------------------------------------------

describe("AgentsPanel orchestration source badge (M4)", () => {
  /** Mounts the panel with a live `MockSystemGateway`, returning all the pieces. */
  async function renderWithSystem() {
    const agent = new MockAgentGateway();
    const profile = new MockProfileGateway();
    const system = new MockSystemGateway();
    const template = new MockTemplateGateway(agent);
    const gateways = { agent, profile, system, template } as unknown as Gateways;
    // The badge keys on `a.id === requesterId`, so create the agent first and use
    // its id as the requester in the emitted event.
    const created = await agent.createAgent(PROJECT_ID, {
      name: "Requester",
      profileId: "p1",
    });
    render(
      <DIProvider gateways={gateways}>
        <AgentsPanel projectId={PROJECT_ID} projectRoot="/home/me/proj" />
      </DIProvider>,
    );
    await waitFor(() => expect(screen.getByText("Requester")).toBeTruthy());
    return { agent, system, created };
  }

  it("shows an `mcp` badge when a delegation arrives via the MCP door", async () => {
    const { system, created } = await renderWithSystem();

    system.emit({
      type: "orchestratorRequestProcessed",
      requesterId: created.id,
      action: "idea_ask_agent",
      ok: true,
      source: "mcp",
    });

    const badge = await screen.findByLabelText("delegation source mcp");
    expect(badge.textContent?.toLowerCase()).toContain("mcp");
  });

  it("shows a `file` badge when a delegation arrives via the .ideai/requests door", async () => {
    const { system, created } = await renderWithSystem();

    system.emit({
      type: "orchestratorRequestProcessed",
      requesterId: created.id,
      action: "spawn_agent",
      ok: true,
      source: "file",
    });

    const badge = await screen.findByLabelText("delegation source file");
    expect(badge.textContent?.toLowerCase()).toContain("file");
  });

  it("renders NO badge when the event carries no `source` (older backend)", async () => {
    const { system, created } = await renderWithSystem();

    // Same requester, but the event omits `source` entirely.
    system.emit({
      type: "orchestratorRequestProcessed",
      requesterId: created.id,
      action: "spawn_agent",
      ok: true,
    });

    // Give the effect a tick; the map must stay empty → no badge for any source.
    await waitFor(() => expect(screen.getByText("Requester")).toBeTruthy());
    expect(screen.queryByLabelText("delegation source mcp")).toBeNull();
    expect(screen.queryByLabelText("delegation source file")).toBeNull();
  });

  it("does not badge an agent whose id never matched a processed requester", async () => {
    const { system } = await renderWithSystem();

    // A processed event for a *different* requester id leaves our agent un-badged.
    system.emit({
      type: "orchestratorRequestProcessed",
      requesterId: "some-other-requester",
      action: "idea_ask_agent",
      ok: true,
      source: "mcp",
    });

    await waitFor(() => expect(screen.getByText("Requester")).toBeTruthy());
    expect(screen.queryByLabelText("delegation source mcp")).toBeNull();
  });
});
