/**
 * Ticket #54 F1 — model-server download overlay on agent cells.
 *
 * When a pinned agent's OpenCode profile binds a local model server and that
 * server emits `downloading` / `starting` / `probing` (via the existing
 * `modelServerStatusChanged` stream), a full-cell overlay covers the cell — so
 * the boot no longer looks like a hung/timed-out empty terminal. The overlay
 * retracts at `ready`/`failed`.
 *
 * Correlation is `agentId → profileId → opencode.localModelServerId → serverId`:
 * two cells whose agents share the same bound server are BOTH veiled by a single
 * event on that server. xterm is stubbed so the terminal mounts under jsdom.
 */
import { beforeEach, describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, configure } from "@testing-library/react";

// Delivery of a `modelServerStatusChanged` event is made deterministic by
// `trackSubscriptions` (no lost event). What remains is *propagation* latency:
// the overlay only shows once the hook's async profile + agent loads resolve, so
// the assertion is a guaranteed-eventual state. Under the full-suite parallel
// run (22 files) the worker's event loop can be starved past the 1000 ms default
// async-util budget — a scheduling delay, not a logic race. A generous timeout
// removes that flake without weakening any assertion (the state is guaranteed to
// arrive; we only wait long enough for it under load).
configure({ asyncUtilTimeout: 5000 });

vi.mock("@xterm/xterm", () => ({
  Terminal: class {
    loadAddon() {}
    open() {}
    onData() {
      return { dispose() {} };
    }
    onResize() {
      return { dispose() {} };
    }
    write() {}
    dispose() {}
    get cols() {
      return 80;
    }
    get rows() {
      return 24;
    }
  },
}));
vi.mock("@xterm/addon-fit", () => ({
  FitAddon: class {
    fit() {}
  },
}));
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));

if (typeof globalThis.ResizeObserver === "undefined") {
  globalThis.ResizeObserver = class {
    observe() {}
    unobserve() {}
    disconnect() {}
  } as unknown as typeof ResizeObserver;
}

import type { AgentProfile, ModelServerStatus } from "@/domain";
import type { Gateways } from "@/ports";
import {
  MockAgentGateway,
  MockInputGateway,
  MockLayoutGateway,
  MockProfileGateway,
  MockSystemGateway,
  MockTerminalGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid";

const SERVER_ID = "srv-local-1";

/** A profile that binds the given local model server through OpenCode. */
function localModelProfile(id: string): AgentProfile {
  return {
    id,
    name: "OpenCode + llama.cpp",
    command: "opencode",
    args: [],
    contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
    detect: null,
    cwdTemplate: "{projectRoot}",
    structuredAdapter: "openCode",
    opencode: {
      baseURL: "http://localhost:8080/v1",
      apiKey: "sk-no-key",
      model: "qwen3-coder-30b",
      localModelServerId: SERVER_ID,
    },
  };
}

function makeGateways(
  agentGateway: MockAgentGateway,
  profileGateway: MockProfileGateway,
  systemGateway: MockSystemGateway,
): Gateways {
  return {
    layout: new MockLayoutGateway(),
    agent: agentGateway,
    profile: profileGateway,
    terminal: new MockTerminalGateway(),
    system: systemGateway,
    input: new MockInputGateway(),
  } as unknown as Gateways;
}

function renderGrid(gateways: Gateways) {
  return render(
    <DIProvider gateways={gateways}>
      <LayoutGrid projectId="p1" cwd="/home/me/proj" />
    </DIProvider>,
  );
}

/**
 * Emit `status` and KEEP re-emitting it until `check` passes (the emit runs
 * inside `waitFor`, whose async wrapper already provides `act`). This kills the
 * emit-vs-subscribe race at the root — with no need to identify *which*
 * `onDomainEvent` subscriber is the overlay hook's.
 *
 * Why the previous `waitSubscribed(count > 0)` gate was insufficient: several
 * components subscribe to the event stream, and the ones outside `LeafView`
 * mount *before* the async layout load brings `LeafView` (and its overlay hook)
 * into the tree — so the global subscriber count crosses 0 while the overlay
 * hook's own handler is not yet registered, and a single emit gated on it is
 * still dropped. Re-emitting is safe because the reducer stores an idempotent
 * per-server value: whichever attempt lands *after* the overlay hook subscribed
 * and its profile/agent loads resolved is the one that makes `check` pass; the
 * earlier (possibly dropped) attempts are no-ops. Used for BOTH appearance and
 * retraction assertions so no case can regress into the race.
 */
async function emitUntil(
  systemGateway: MockSystemGateway,
  status: ModelServerStatus,
  check: () => void,
): Promise<void> {
  await waitFor(() => {
    systemGateway.emit({
      type: "modelServerStatusChanged",
      serverId: SERVER_ID,
      status,
    });
    check();
  });
}

beforeEach(() => {
  vi.clearAllMocks();
});

describe("LayoutGrid — model-server launch overlay (ticket #54)", () => {
  it("veils the cell while downloading, then retracts at ready", async () => {
    const profileGateway = new MockProfileGateway();
    await profileGateway.saveProfile(localModelProfile("opencode-local"));
    const agentGateway = new MockAgentGateway();
    const agent = await agentGateway.createAgent("p1", {
      name: "Worker",
      profileId: "opencode-local",
    });
    const systemGateway = new MockSystemGateway();
    const gateways = makeGateways(agentGateway, profileGateway, systemGateway);

    // Pin the agent onto the single default cell.
    const layout = gateways.layout as MockLayoutGateway;
    const leafId = leaves(await layout.loadLayout("p1"))[0].id;
    await layout.mutateLayout("p1", {
      type: "setCellAgent",
      target: leafId,
      agent: agent.id,
    });

    renderGrid(gateways);
    // The cell exists but no status has been emitted yet → no overlay.
    await screen.findByTestId("layout-leaf");
    expect(screen.queryByTestId("model-server-overlay")).toBeNull();

    // Downloading → overlay with the download wording.
    await emitUntil(
      systemGateway,
      {
        state: "downloading",
        downloadedBytes: null,
        totalBytes: null,
        percent: null,
        source: null,
      },
      () =>
        expect(
          screen.getByTestId("model-server-overlay").textContent,
        ).toContain("Téléchargement du modèle"),
    );

    // Ready → overlay gone.
    await emitUntil(systemGateway, { state: "ready", reused: false }, () =>
      expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
    );
  });

  it("shows 'Chargement du serveur…' for starting/probing and hides at failed", async () => {
    const profileGateway = new MockProfileGateway();
    await profileGateway.saveProfile(localModelProfile("opencode-local"));
    const agentGateway = new MockAgentGateway();
    const agent = await agentGateway.createAgent("p1", {
      name: "Worker",
      profileId: "opencode-local",
    });
    const systemGateway = new MockSystemGateway();
    const gateways = makeGateways(agentGateway, profileGateway, systemGateway);

    const layout = gateways.layout as MockLayoutGateway;
    const leafId = leaves(await layout.loadLayout("p1"))[0].id;
    await layout.mutateLayout("p1", {
      type: "setCellAgent",
      target: leafId,
      agent: agent.id,
    });

    renderGrid(gateways);

    await emitUntil(systemGateway, { state: "probing" }, () =>
      expect(
        screen.getByTestId("model-server-overlay").textContent,
      ).toContain("Chargement du serveur"),
    );

    await emitUntil(systemGateway, { state: "starting" }, () =>
      expect(
        screen.getByTestId("model-server-overlay").textContent,
      ).toContain("Chargement du serveur"),
    );
    // No progressbar for a non-download preparing state.
    expect(screen.queryByTestId("model-server-progress")).toBeNull();

    await emitUntil(
      systemGateway,
      { state: "failed", code: "spawn", message: "boom" },
      () => expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
    );
  });

  it("veils EVERY cell whose agent references the downloading server", async () => {
    const profileGateway = new MockProfileGateway();
    await profileGateway.saveProfile(localModelProfile("opencode-local"));
    const agentGateway = new MockAgentGateway();
    const agentA = await agentGateway.createAgent("p1", {
      name: "A",
      profileId: "opencode-local",
    });
    const agentB = await agentGateway.createAgent("p1", {
      name: "B",
      profileId: "opencode-local",
    });
    const systemGateway = new MockSystemGateway();
    const gateways = makeGateways(agentGateway, profileGateway, systemGateway);

    // Split the root into two cells, pin one agent onto each.
    const layout = gateways.layout as MockLayoutGateway;
    const rootLeaf = leaves(await layout.loadLayout("p1"))[0].id;
    await layout.mutateLayout("p1", {
      type: "split",
      target: rootLeaf,
      direction: "row",
      newLeaf: "cell-b",
      container: "split-c",
    });
    await layout.mutateLayout("p1", {
      type: "setCellAgent",
      target: rootLeaf,
      agent: agentA.id,
    });
    await layout.mutateLayout("p1", {
      type: "setCellAgent",
      target: "cell-b",
      agent: agentB.id,
    });

    renderGrid(gateways);

    // Both cells carry the overlay from a single server event (re-emitted until
    // both cells' overlay hooks have subscribed and correlated the server).
    await emitUntil(
      systemGateway,
      {
        state: "downloading",
        downloadedBytes: null,
        totalBytes: null,
        percent: null,
        source: null,
      },
      () =>
        expect(screen.getAllByTestId("model-server-overlay").length).toBe(2),
    );

    await emitUntil(systemGateway, { state: "ready", reused: true }, () =>
      expect(screen.queryAllByTestId("model-server-overlay").length).toBe(0),
    );
  });
});

/** Render a single pinned-agent cell and return its system gateway. */
async function renderSinglePinnedCell(): Promise<MockSystemGateway> {
  const profileGateway = new MockProfileGateway();
  await profileGateway.saveProfile(localModelProfile("opencode-local"));
  const agentGateway = new MockAgentGateway();
  const agent = await agentGateway.createAgent("p1", {
    name: "Worker",
    profileId: "opencode-local",
  });
  const systemGateway = new MockSystemGateway();
  const gateways = makeGateways(agentGateway, profileGateway, systemGateway);
  const layout = gateways.layout as MockLayoutGateway;
  const leafId = leaves(await layout.loadLayout("p1"))[0].id;
  await layout.mutateLayout("p1", {
    type: "setCellAgent",
    target: leafId,
    agent: agent.id,
  });
  renderGrid(gateways);
  // No subscription gate needed: every emission goes through `emitUntil`, which
  // re-emits until the overlay hook has subscribed and correlated the server.
  return systemGateway;
}

describe("LayoutGrid — download progress (ticket #54 F2)", () => {
  it("known percent → determinate progressbar + % + formatted bytes + source", async () => {
    const systemGateway = await renderSinglePinnedCell();

    await emitUntil(
      systemGateway,
      {
        state: "downloading",
        downloadedBytes: 1_500_000,
        totalBytes: 3_000_000,
        percent: 50,
        source: "unsloth/Qwen3-Coder-30B",
      },
      () =>
        expect(
          screen.getByTestId("model-server-progress").getAttribute("aria-valuenow"),
        ).toBe("50"),
    );

    const bar = screen.getByTestId("model-server-progress");
    expect(bar.getAttribute("role")).toBe("progressbar");
    expect(bar.getAttribute("aria-valuemin")).toBe("0");
    expect(bar.getAttribute("aria-valuemax")).toBe("100");

    const label = screen.getByTestId("model-server-progress-label");
    expect(label.textContent).toContain("50 %");
    expect(label.textContent).toContain("1.5 Mo / 3 Mo");

    expect(screen.getByTestId("model-server-source").textContent).toBe(
      "unsloth/Qwen3-Coder-30B",
    );
  });

  it("unknown total → indeterminate bar, NO fake %, shows downloaded bytes", async () => {
    const systemGateway = await renderSinglePinnedCell();

    await emitUntil(
      systemGateway,
      {
        state: "downloading",
        downloadedBytes: 800_000,
        totalBytes: null,
        percent: null,
        source: null,
      },
      () => expect(screen.getByTestId("model-server-progress")).toBeTruthy(),
    );

    const bar = screen.getByTestId("model-server-progress");
    // Indeterminate: no aria-valuenow set.
    expect(bar.hasAttribute("aria-valuenow")).toBe(false);

    const label = screen.getByTestId("model-server-progress-label");
    expect(label.textContent).not.toContain("%");
    expect(label.textContent).toContain("800 Ko");
    // No source line when the backend sends none.
    expect(screen.queryByTestId("model-server-source")).toBeNull();
  });

  it("starting/probing show the title but NO progressbar", async () => {
    const systemGateway = await renderSinglePinnedCell();

    await emitUntil(systemGateway, { state: "starting" }, () =>
      expect(
        screen.getByTestId("model-server-overlay").textContent,
      ).toContain("Chargement du serveur"),
    );
    expect(screen.queryByTestId("model-server-progress")).toBeNull();
  });

  it("later progress events update the bar in place", async () => {
    const systemGateway = await renderSinglePinnedCell();

    await emitUntil(
      systemGateway,
      {
        state: "downloading",
        downloadedBytes: 1_000_000,
        totalBytes: 4_000_000,
        percent: 25,
        source: null,
      },
      () =>
        expect(
          screen.getByTestId("model-server-progress").getAttribute("aria-valuenow"),
        ).toBe("25"),
    );

    await emitUntil(
      systemGateway,
      {
        state: "downloading",
        downloadedBytes: 3_000_000,
        totalBytes: 4_000_000,
        percent: 75,
        source: null,
      },
      () =>
        expect(
          screen.getByTestId("model-server-progress").getAttribute("aria-valuenow"),
        ).toBe("75"),
    );
    expect(
      screen.getByTestId("model-server-progress-label").textContent,
    ).toContain("3 Mo / 4 Mo");
  });
});
