/**
 * F4 — the WS agent gateway round-trip against the B6 frame contract:
 * `agent.launch` → unified `terminal.attached` (sessionId + assignedConversationId
 * consumed), then output/input/resize reuse the terminal mechanics; agent
 * re-attach uses `terminal.attach` (no relaunch) and returns scrollback; a
 * structured agent (`UNSUPPORTED`) and a dead session (`NOT_FOUND`) reject clearly.
 */
import { describe, it, expect, vi } from "vitest";

import { HttpAgentGateway } from "./streamGateways";
import { HttpInvoker } from "./httpInvoker";
import { WsLiveClient, type WebSocketLike } from "./wsLiveClient";
import { bytesToBase64 } from "./frames";

class FakeSocket implements WebSocketLike {
  sent: string[] = [];
  onopen: (() => void) | null = null;
  onmessage: ((event: { data: string }) => void) | null = null;
  onerror: ((event: unknown) => void) | null = null;
  onclose: (() => void) | null = null;
  send(data: string): void {
    this.sent.push(data);
  }
  close(): void {
    this.onclose?.();
  }
  receive(frame: unknown): void {
    this.onmessage?.({ data: JSON.stringify(frame) });
  }
}

function gateway(): { gw: HttpAgentGateway; sockets: FakeSocket[] } {
  const sockets: FakeSocket[] = [];
  const ws = new WsLiveClient({
    wsUrl: "wss://host",
    socketFactory: () => {
      const s = new FakeSocket();
      sockets.push(s);
      queueMicrotask(() => s.onopen?.());
      return s;
    },
  });
  // The HTTP invoker is unused by the WS paths under test.
  const gw = new HttpAgentGateway(new HttpInvoker({ baseUrl: "https://host" }), ws);
  return { gw, sockets };
}

async function replyToLast(
  socket: FakeSocket,
  index: number,
  reply: (id: string) => unknown,
): Promise<Record<string, unknown>> {
  await vi.waitFor(() => expect(socket.sent.length).toBeGreaterThan(index));
  const sent = JSON.parse(socket.sent[index]);
  socket.receive(reply(sent.id));
  return sent;
}

function attachedAck(
  id: string,
  sessionId: string,
  opts: { scroll?: Uint8Array; assignedConversationId?: string } = {},
) {
  return {
    kind: "terminal.attached",
    replyTo: id,
    payload: {
      session: { sessionId, rows: 24, cols: 80 },
      scrollback: opts.scroll ? [{ seq: 0, bytesBase64: bytesToBase64(opts.scroll) }] : [],
      nextSeq: opts.scroll ? 1 : 0,
      status: "running",
      gap: false,
      assignedConversationId: opts.assignedConversationId,
    },
  };
}

const OPTS = { cwd: "/srv/app", rows: 24, cols: 80, nodeId: "node-1" };

describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
  it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => {
    const { gw, sockets } = gateway();
    const chunks: Uint8Array[] = [];

    const handlePromise = gw.launchAgent("proj-1", "agent-1", { ...OPTS, conversationId: "resume-9" }, (b) =>
      chunks.push(b),
    );
    const sent = await replyToLast(sockets[0], 0, (id) =>
      attachedAck(id, "sess-1", { assignedConversationId: "conv-1" }),
    );
    const handle = await handlePromise;

    // B6 payload: flat camelCase, no cwd (agent launch resolves the project).
    expect(sent.kind).toBe("agent.launch");
    expect(sent.payload).toEqual({
      projectId: "proj-1",
      agentId: "agent-1",
      nodeId: "node-1",
      rows: 24,
      cols: 80,
      conversationId: "resume-9",
    });
    expect(handle.sessionId).toBe("sess-1");
    // The conversation id minted by the launch is surfaced on the handle.
    expect(handle.assignedConversationId).toBe("conv-1");
    // Fresh launch: nothing repainted.
    expect(chunks).toHaveLength(0);

    // Output/input/resize reuse the terminal mechanics.
    sockets[0].receive({
      kind: "terminal.output",
      payload: { sessionId: "sess-1", seq: 1, bytesBase64: bytesToBase64(new Uint8Array([65])) },
    });
    expect(chunks).toEqual([new Uint8Array([65])]);

    const base = sockets[0].sent.length;
    await handle.write(new Uint8Array([13]));
    await handle.resize(30, 100);
    expect(JSON.parse(sockets[0].sent[base]).kind).toBe("terminal.input");
    expect(JSON.parse(sockets[0].sent[base + 1])).toMatchObject({
      kind: "terminal.resize",
      payload: { sessionId: "sess-1", rows: 30, cols: 100 },
    });
  });

  it("omits assignedConversationId on the handle when the launch minted none", async () => {
    const { gw, sockets } = gateway();
    const handlePromise = gw.launchAgent("p", "a", OPTS, () => {});
    await replyToLast(sockets[0], 0, (id) => attachedAck(id, "sess-x"));
    const handle = await handlePromise;
    expect(handle.assignedConversationId).toBeUndefined();
  });

  it("re-attaches to an existing agent via terminal.attach (no relaunch) and returns scrollback", async () => {
    const { gw, sockets } = gateway();
    const chunks: Uint8Array[] = [];
    const scroll = new Uint8Array([104, 105]);

    const resPromise = gw.reattach("sess-7", (b) => chunks.push(b));
    const sent = await replyToLast(sockets[0], 0, (id) => attachedAck(id, "sess-7", { scroll }));
    const res = await resPromise;

    // Re-attach must NOT relaunch: the frame is terminal.attach, not agent.launch.
    expect(sent.kind).toBe("terminal.attach");
    expect(sent.payload).toMatchObject({ sessionId: "sess-7" });
    expect(res.scrollback).toEqual(scroll);
    // Scrollback returned for the view to repaint, not double-pushed via onData.
    expect(chunks).toHaveLength(0);
  });

  it("rejects a structured agent with UNSUPPORTED (no crash)", async () => {
    const { gw, sockets } = gateway();
    const handlePromise = gw.launchAgent("p", "a", OPTS, () => {});
    await replyToLast(sockets[0], 0, (id) => ({
      kind: "error",
      replyTo: id,
      payload: { code: "UNSUPPORTED", message: "structured agent sessions do not stream over the PTY websocket" },
    }));
    await expect(handlePromise).rejects.toMatchObject({ code: "UNSUPPORTED" });
  });

  it("rejects re-attach to a missing/exited session with NOT_FOUND", async () => {
    const { gw, sockets } = gateway();
    const resPromise = gw.reattach("gone", () => {});
    await replyToLast(sockets[0], 0, (id) => ({
      kind: "error",
      replyTo: id,
      payload: { code: "NOT_FOUND", message: "terminal session not found" },
    }));
    await expect(resPromise).rejects.toMatchObject({ code: "NOT_FOUND" });
  });
});
