/**
 * F5 — the live web surfaces: a `event.domain` event refreshes the displayed
 * work-state; background tasks render with status + cancel/retry wired to the
 * gateway; a WS reconnect re-synchronises the read-model.
 */
import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";

import { FitAddon } from "@xterm/addon-fit";

import type { Gateways } from "@/ports";
import type { BackgroundCompletion, ProjectWorkState } from "@/domain";
import { DIProvider } from "@/app/di";
import { createMockGateways, MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
import {
  WsLiveClient,
  setWebLiveClient,
  WebSession,
} from "@/adapters/http";
import { WebApp } from "./WebApp";

afterEach(() => setWebLiveClient(null));

const okFetch = async () => ({
  ok: true,
  status: 200,
  json: async () => ({ ok: true }),
  text: async () => "{}",
});

function memStore() {
  const map = new Map<string, string>();
  return {
    getItem: (k: string) => map.get(k) ?? null,
    setItem: (k: string, v: string) => void map.set(k, v),
    removeItem: (k: string) => void map.delete(k),
  };
}

function state(agents: ProjectWorkState["agents"]): ProjectWorkState {
  return { agents, conversations: [] };
}

async function seeded(initial: ProjectWorkState): Promise<{ gateways: Gateways; projectId: string }> {
  const gateways = createMockGateways();
  const project = await gateways.project.createProject("Demo", "/srv/demo");
  (gateways.workState as MockWorkStateGateway)._setProjectWorkState(project.id, initial);
  return { gateways, projectId: project.id };
}

function renderPaired(gateways: Gateways) {
  const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
  session.markPaired();
  return render(
    <DIProvider gateways={gateways}>
      <WebApp session={session} />
    </DIProvider>,
  );
}

const AGENT_IDLE = { agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" as const }, tickets: [] };
const AGENT_FRONT = { agentId: "a2", name: "Front", profileId: "p2", busy: { state: "idle" as const }, tickets: [] };

function backgroundTask(
  taskId: string,
  ownerAgentId: string,
  updatedAtMs = 1,
): BackgroundCompletion {
  return {
    taskId,
    ownerAgentId,
    projectId: "p",
    kind: "shell",
    status: "running",
    exitCode: null,
    summary: null,
    stdoutTail: null,
    stderrTail: null,
    updatedAtMs,
  };
}

async function openLivePanel(gateways: Gateways) {
  renderPaired(gateways);
  fireEvent.click(await screen.findByText("Demo"));
  return screen.findByTestId("web-workstate");
}

describe("WebWorkspace live surfaces (F5)", () => {
  it("refreshes the work-state on a relevant event.domain event", async () => {
    const { gateways, projectId } = await seeded(state([AGENT_IDLE]));
    renderPaired(gateways);

    fireEvent.click(await screen.findByText("Demo"));
    const panel = await screen.findByTestId("web-workstate");
    expect(panel.textContent).toContain("Idle");

    // The agent goes busy server-side; the read-model now reflects it…
    (gateways.workState as MockWorkStateGateway)._setProjectWorkState(
      projectId,
      state([{ ...AGENT_IDLE, busy: { state: "busy", ticket: "t-1", sinceMs: 1 } }]),
    );
    // …and an `agentBusyChanged` domain event drives a live refresh.
    act(() => {
      (gateways.system as MockSystemGateway).emit({ type: "agentBusyChanged", agentId: "a1", busy: true });
    });

    await waitFor(() =>
      expect(screen.getByTestId("web-workstate").textContent).toContain("Busy"),
    );
  });

  it("renders background tasks and wires cancel through the gateway", async () => {
    const task = backgroundTask("bt-1", "a1");
    const { gateways } = await seeded(state([{ ...AGENT_IDLE, backgroundTasks: [task] }]));
    const cancelSpy = vi.spyOn(gateways.workState, "cancelBackgroundTask");
    await openLivePanel(gateways);

    fireEvent.click(
      await screen.findByRole("button", { name: "Afficher les background tasks de Archi" }),
    );
    const tasks = await screen.findByLabelText("Archi background tasks");
    expect(tasks.textContent).toContain("Running");
    expect(tasks.textContent).toContain("shell");

    fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
    await waitFor(() => expect(cancelSpy).toHaveBeenCalledWith("bt-1"));
  });

  it("keeps background task groups collapsed by default and toggles them per agent", async () => {
    const { gateways } = await seeded(state([
      { ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-a", "a1")] },
      { ...AGENT_FRONT, backgroundTasks: [backgroundTask("bt-f", "a2")] },
    ]));
    await openLivePanel(gateways);

    const archiToggle = await screen.findByRole("button", {
      name: "Afficher les background tasks de Archi",
    });
    const frontToggle = await screen.findByRole("button", {
      name: "Afficher les background tasks de Front",
    });
    expect(archiToggle.getAttribute("aria-expanded")).toBe("false");
    expect(frontToggle.getAttribute("aria-expanded")).toBe("false");
    expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
    expect(screen.queryByLabelText("Front background tasks")).toBeNull();

    fireEvent.click(archiToggle);
    expect(
      screen
        .getByRole("button", { name: "Masquer les background tasks de Archi" })
        .getAttribute("aria-expanded"),
    ).toBe("true");
    expect(
      screen
        .getByRole("button", { name: "Afficher les background tasks de Front" })
        .getAttribute("aria-expanded"),
    ).toBe("false");
    expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy();
    expect(screen.queryByLabelText("Front background tasks")).toBeNull();

    fireEvent.click(screen.getByRole("button", { name: "Afficher les background tasks de Front" }));
    expect(screen.getByLabelText("Archi background tasks")).toBeTruthy();
    expect(screen.getByLabelText("Front background tasks")).toBeTruthy();
  });

  it("updates task counters without changing the current expanded state", async () => {
    const { gateways, projectId } = await seeded(state([
      { ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-1", "a1")] },
    ]));
    await openLivePanel(gateways);

    const collapsedToggle = await screen.findByRole("button", {
      name: "Afficher les background tasks de Archi",
    });
    expect(collapsedToggle.getAttribute("aria-expanded")).toBe("false");
    expect(collapsedToggle.textContent).toContain("1");
    expect(screen.queryByLabelText("Archi background tasks")).toBeNull();

    (gateways.workState as MockWorkStateGateway)._setProjectWorkState(
      projectId,
      state([
        {
          ...AGENT_IDLE,
          backgroundTasks: [backgroundTask("bt-1", "a1", 1), backgroundTask("bt-2", "a1", 2)],
        },
      ]),
    );
    act(() => {
      (gateways.system as MockSystemGateway).emit({
        type: "backgroundTaskChanged",
        projectId,
        taskId: "bt-2",
        agentId: "a1",
        state: "running",
      });
    });

    await waitFor(() =>
      expect(
        screen.getByRole("button", { name: "Afficher les background tasks de Archi" }).textContent,
      ).toContain("2"),
    );
    expect(
      screen
        .getByRole("button", { name: "Afficher les background tasks de Archi" })
        .getAttribute("aria-expanded"),
    ).toBe("false");
    expect(screen.queryByLabelText("Archi background tasks")).toBeNull();

    fireEvent.click(screen.getByRole("button", { name: "Afficher les background tasks de Archi" }));
    expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy();

    (gateways.workState as MockWorkStateGateway)._setProjectWorkState(
      projectId,
      state([{ ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-1", "a1", 3)] }]),
    );
    act(() => {
      (gateways.system as MockSystemGateway).emit({
        type: "backgroundTaskChanged",
        projectId,
        taskId: "bt-2",
        agentId: "a1",
        state: "completed",
      });
    });

    await waitFor(() =>
      expect(
        screen.getByRole("button", { name: "Masquer les background tasks de Archi" }).textContent,
      ).toContain("1"),
    );
    expect(
      screen
        .getByRole("button", { name: "Masquer les background tasks de Archi" })
        .getAttribute("aria-expanded"),
    ).toBe("true");
    expect(screen.getByLabelText("Archi background tasks")).toBeTruthy();
  });

  it("hides and resets an open background task group when its task count reaches zero", async () => {
    const { gateways, projectId } = await seeded(state([
      { ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-1", "a1")] },
    ]));
    await openLivePanel(gateways);
    fireEvent.click(await screen.findByRole("button", {
      name: "Afficher les background tasks de Archi",
    }));
    expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy();

    (gateways.workState as MockWorkStateGateway)._setProjectWorkState(
      projectId,
      state([{ ...AGENT_IDLE, backgroundTasks: [] }]),
    );
    act(() => {
      (gateways.system as MockSystemGateway).emit({
        type: "backgroundTaskChanged",
        projectId,
        taskId: "bt-1",
        agentId: "a1",
        state: "completed",
      });
    });

    await waitFor(() =>
      expect(screen.queryByRole("button", { name: /background tasks de Archi/ })).toBeNull(),
    );
    expect(screen.queryByLabelText("Archi background tasks")).toBeNull();

    (gateways.workState as MockWorkStateGateway)._setProjectWorkState(
      projectId,
      state([{ ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-2", "a1", 2)] }]),
    );
    act(() => {
      (gateways.system as MockSystemGateway).emit({
        type: "backgroundTaskChanged",
        projectId,
        taskId: "bt-2",
        agentId: "a1",
        state: "running",
      });
    });

    await waitFor(() =>
      expect(
        screen
          .getByRole("button", { name: "Afficher les background tasks de Archi" })
          .getAttribute("aria-expanded"),
      ).toBe("false"),
    );
    expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
  });

  it("re-synchronises the read-model when the WS reconnects", async () => {
    const { gateways, projectId } = await seeded(state([AGENT_IDLE]));
    // Register a live client whose connection state we can drive.
    let notify: ((s: "connecting" | "connected" | "reconnecting" | "closed") => void) | null = null;
    const fakeClient = {
      getConnectionState: () => "connected" as const,
      onConnectionStateChange: (l: (s: "connecting" | "connected" | "reconnecting" | "closed") => void) => {
        notify = l;
        return () => {
          notify = null;
        };
      },
    };
    setWebLiveClient(fakeClient as unknown as WsLiveClient);

    const refreshSpy = vi.spyOn(gateways.workState, "getProjectWorkState");
    renderPaired(gateways);
    fireEvent.click(await screen.findByText("Demo"));
    await screen.findByTestId("web-workstate");
    const callsAfterOpen = refreshSpy.mock.calls.length;
    expect(projectId).toBeTruthy();

    // Simulate a reconnect: reconnecting → connected must re-fetch the read-model.
    act(() => {
      notify?.("reconnecting");
      notify?.("connected");
    });
    await waitFor(() => expect(refreshSpy.mock.calls.length).toBeGreaterThan(callsAfterOpen));
  });
});

/**
 * Ticket #61 (web regression) — `LiveProjectPanel`'s `cellLayoutVersion`.
 *
 * The web shell has no `useLayout`/`LayoutGrid`, so nothing ever bumped
 * `refitSignal` on the web agent cell. `LiveProjectPanel` now keeps a local
 * counter, bumped on every cell open/close, forwarded as `refitSignal` to the
 * currently-visible `WebAgentCell`. The panel currently shows at most one cell
 * at a time (opening a different agent replaces, rather than adds to, the
 * visible cell) — so "the cell already displayed keeps a stale scaling" is
 * exercised here as: opening a *second*, different agent's cell must still
 * carry a bumped, defined `refitSignal` through to the freshly-mounted
 * `TerminalView`, producing an explicit `fit()` call beyond the one guaranteed
 * at mount — not just whatever the (here stubbed-inert) `ResizeObserver` would
 * have produced on its own. That is the exact mechanism a real multi-cell
 * surface would rely on for a survivor cell (item 4 of the plan).
 *
 * Needs the same `matchMedia`/`ResizeObserver` polyfills as
 * `TerminalView.test.tsx` for xterm to actually mount under jsdom — scoped to
 * this describe block only.
 */
describe("LiveProjectPanel — cellLayoutVersion (ticket #61 web regression)", () => {
  const w = window as unknown as { matchMedia?: (q: string) => MediaQueryList };
  const savedMatchMedia = w.matchMedia;
  const savedResizeObserver = globalThis.ResizeObserver;

  beforeAll(() => {
    w.matchMedia = (query: string) =>
      ({
        matches: false,
        media: query,
        onchange: null,
        addEventListener: () => {},
        removeEventListener: () => {},
        addListener: () => {},
        removeListener: () => {},
        dispatchEvent: () => false,
      }) as unknown as MediaQueryList;
    // A no-op observer: real resize events must not be what drives the extra
    // fit() below — only the explicit `refitSignal` bump should.
    globalThis.ResizeObserver = class {
      observe() {}
      unobserve() {}
      disconnect() {}
    } as unknown as typeof ResizeObserver;
  });

  afterAll(() => {
    w.matchMedia = savedMatchMedia;
    globalThis.ResizeObserver = savedResizeObserver;
  });

  it("opening a second cell carries a bumped refitSignal, producing an extra fit()", async () => {
    const { gateways } = await seeded(state([AGENT_IDLE, AGENT_FRONT]));
    const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
    renderPaired(gateways);

    fireEvent.click(await screen.findByText("Demo"));
    await screen.findByTestId("web-workstate");

    const archiRow = screen.getByText("Archi").closest("li")!;
    fireEvent.click(within(archiRow).getByRole("button", { name: "Ouvrir" }));
    await screen.findByTestId("web-agent-cell");
    // First cell mounted: at least the one guaranteed fit-on-mount happened.
    expect(fitSpy.mock.calls.length).toBeGreaterThan(0);

    // Switch to a different agent's cell — the panel shows one cell at a time,
    // so this replaces (unmounts A, mounts B) rather than adding a second.
    const frontRow = screen.getByText("Front").closest("li")!;
    fireEvent.click(within(frontRow).getByRole("button", { name: "Ouvrir" }));

    const cellB = await screen.findByTestId("web-agent-cell");
    const fitCallsAtBMount = fitSpy.mock.calls.length;

    // jsdom reports a zero-size layout box, which the refit deliberately skips
    // — give the freshly-mounted cell's inner xterm container a real size so
    // the refitSignal-driven refit actually reaches `fit.fit()`.
    const containerB = cellB.querySelector('[data-testid="terminal-view"]')!
      .firstElementChild as HTMLElement;
    Object.defineProperty(containerB, "clientWidth", { value: 400, configurable: true });
    Object.defineProperty(containerB, "clientHeight", { value: 200, configurable: true });

    // The bumped `cellLayoutVersion` reaching `refitSignal` schedules this
    // extra refit — with the no-op ResizeObserver stubbed above, nothing else
    // could have produced it.
    await waitFor(() =>
      expect(fitSpy.mock.calls.length).toBeGreaterThan(fitCallsAtBMount),
    );

    fitSpy.mockRestore();
  });
});
