/**
 * F3 — the WS terminal gateway + handle round-trip against the B5 frame contract:
 * open→attached (empty scrollback), attach→attached (scrollback returned for the
 * view to repaint, not double-pushed), input/resize emit conforming frames,
 * detach ≠ close, and `terminal.output` bytes are base64-decoded into the sink.
 */
import { describe, it, expect, vi } from "vitest";

import { HttpTerminalGateway } from "./streamGateways";
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) });
  }
}

/** Client whose socket auto-opens on the next microtask. */
function client(): { ws: WsLiveClient; 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;
    },
  });
  return { ws, sockets };
}

/** Awaits the frame the client just sent, then replies with `reply(id)`. */
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, scrollbackBytes?: Uint8Array) {
  return {
    kind: "terminal.attached",
    replyTo: id,
    payload: {
      session: { sessionId, rows: 30, cols: 120 },
      scrollback: scrollbackBytes
        ? [{ seq: 0, bytesBase64: bytesToBase64(scrollbackBytes) }]
        : [],
      nextSeq: scrollbackBytes ? 1 : 0,
      status: "running",
      gap: false,
    },
  };
}

describe("HttpTerminalGateway round-trip (B5 frames)", () => {
  it("open → attached with empty scrollback; output is decoded into the sink", async () => {
    const { ws, sockets } = client();
    const gw = new HttpTerminalGateway(ws);
    const chunks: Uint8Array[] = [];

    const handlePromise = gw.openTerminal({ cwd: "/srv/app", rows: 30, cols: 120 }, (b) =>
      chunks.push(b),
    );
    const sent = await replyToLast(sockets[0], 0, (id) => attachedAck(id, "s1"));
    const handle = await handlePromise;

    // Conforming open frame (no projectId; cwd is server-validated).
    expect(sent.kind).toBe("terminal.open");
    expect(sent.payload).toMatchObject({ cwd: "/srv/app", rows: 30, cols: 120 });
    expect(handle.sessionId).toBe("s1");
    // No scrollback pushed on a fresh open.
    expect(chunks).toHaveLength(0);

    // A terminal.output frame is base64-decoded and written to the sink.
    sockets[0].receive({
      kind: "terminal.output",
      payload: { sessionId: "s1", seq: 1, bytesBase64: bytesToBase64(new Uint8Array([104, 105])) },
    });
    expect(chunks).toEqual([new Uint8Array([104, 105])]);
  });

  it("attach → attached returns the scrollback for the view (not double-pushed)", async () => {
    const { ws, sockets } = client();
    const gw = new HttpTerminalGateway(ws);
    const chunks: Uint8Array[] = [];
    const scroll = new Uint8Array([65, 66, 67]);

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

    expect(sent.kind).toBe("terminal.attach");
    expect(sent.payload).toMatchObject({ sessionId: "s7" });
    // Scrollback is returned for TerminalView to repaint…
    expect(res.scrollback).toEqual(scroll);
    // …and NOT also pushed through onData (no double paint on the initial attach).
    expect(chunks).toHaveLength(0);
  });

  it("input and resize emit conforming frames; detach ≠ close", async () => {
    const { ws, sockets } = client();
    const gw = new HttpTerminalGateway(ws);

    const handlePromise = gw.openTerminal({ cwd: "/srv", rows: 24, cols: 80 }, () => {});
    await replyToLast(sockets[0], 0, (id) => attachedAck(id, "s1"));
    const handle = await handlePromise;
    const base = sockets[0].sent.length;

    await handle.write(new Uint8Array([13]));
    await handle.resize(40, 140);

    const input = JSON.parse(sockets[0].sent[base]);
    expect(input.kind).toBe("terminal.input");
    expect(input.payload).toEqual({ sessionId: "s1", bytesBase64: bytesToBase64(new Uint8Array([13])) });

    const resize = JSON.parse(sockets[0].sent[base + 1]);
    expect(resize.kind).toBe("terminal.resize");
    expect(resize.payload).toEqual({ sessionId: "s1", rows: 40, cols: 140 });

    // detach keeps the PTY alive (terminal.detach); close kills it (terminal.close).
    handle.detach();
    await vi.waitFor(() =>
      expect(JSON.parse(sockets[0].sent[base + 2]).kind).toBe("terminal.detach"),
    );
  });
});
