import { describe, test, expect, afterAll } from 'bun:test';
import type { ServerWebSocket, Subprocess } from 'bun';

// Integration test for the things about the pty sidecar that cannot be reasoned about from the code alone:
// that it serves its own transport, that a shell outlives both its viewer and officer, and that re-attach
// replays rather than re-spawns.
//
// The fake officer exists only to receive the announced port — officer is not in the data path any more,
// so the browser side is spoken directly to the sidecar's own listener. Nothing here touches the running
// officer: the sidecar dials API_URL, overridden per spawn.

const SIDECAR = 'src/servers/sidecar/pty/index.mjs';

type Frame = Record<string, any>;

function collector() {
  const frames: Frame[] = [];
  const waiters: Array<{ match: (f: Frame) => boolean; resolve: (f: Frame) => void }> = [];

  const push = (frame: Frame) => {
    frames.push(frame);
    for (let i = waiters.length - 1; i >= 0; i--) {
      if (waiters[i]!.match(frame)) waiters.splice(i, 1)[0]!.resolve(frame);
    }
  };

  const await_ = (match: (f: Frame) => boolean, what: string, timeoutMs = 15_000) =>
    new Promise<Frame>((resolve, reject) => {
      const seen = frames.find(match);
      if (seen) return resolve(seen);
      const timer = setTimeout(() => reject(new Error(`timed out waiting for ${what}`)), timeoutMs);
      waiters.push({
        match,
        resolve: (f) => {
          clearTimeout(timer);
          resolve(f);
        },
      });
    });

  return { frames, push, await_ };
}

/** A fake officer: just enough registration socket to receive `pty:server`. */
function fakeOfficer(port?: number) {
  let socket: ServerWebSocket<unknown> | null = null;
  const { push, await_ } = collector();

  const server = Bun.serve({
    port: port ?? 0,
    fetch(req, srv) {
      if (new URL(req.url).pathname === '/api/sidecar/register' && srv.upgrade(req)) return undefined;
      return new Response('no', { status: 404 });
    },
    websocket: {
      open(ws) {
        socket = ws;
        ws.send(JSON.stringify({ type: 'registered', id: 'sc_test' }));
      },
      message(_ws, raw) {
        push(JSON.parse(String(raw)) as Frame);
      },
      close() {
        socket = null;
      },
    },
  });

  return {
    port: server.port,
    await: (match: (f: Frame) => boolean, what: string) => await_(match, what),
    stop: () => server.stop(true),
    get connected() {
      return socket !== null;
    },
  };
}

/** A fake browser. The sidecar's socket contract IS the one the browser already speaks. */
function client(wsBase: string, query: string) {
  const ws = new WebSocket(`${wsBase}/ws?${query}`);
  const { frames, push, await_ } = collector();

  ws.addEventListener('message', (ev) => push(JSON.parse(String(ev.data)) as Frame));

  return {
    open: () =>
      new Promise<void>((resolve) =>
        ws.readyState === WebSocket.OPEN ? resolve() : ws.addEventListener('open', () => resolve()),
      ),
    send: (msg: Frame) => ws.send(JSON.stringify(msg)),
    await: (match: (f: Frame) => boolean, what: string) => await_(match, what),
    outputSoFar: () =>
      frames
        .filter((f) => f.type === 'output')
        .map((f) => String(f.data))
        .join(''),
    close: () => ws.close(),
  };
}

const sawOutput = (needle: string) => (f: Frame) => f.type === 'output' && String(f.data).includes(needle);

let child: Subprocess | null = null;
afterAll(() => child?.kill());

describe('pty sidecar', () => {
  test('serves its own transport; a shell outlives both its viewer and officer', async () => {
    const sessionId = 'test-transport';
    let officer = fakeOfficer();
    const officerPort = officer.port;

    child = Bun.spawn(['node', SIDECAR], {
      env: {
        ...process.env,
        API_URL: `ws://127.0.0.1:${officerPort}`,
        // The sidecar chooses the shell and the home itself, so pin both rather than spawning the owner's
        // interactive zsh, which would read their rc files and their history.
        SHELL: '/bin/sh',
        HOME_DIR: '/tmp',
        ENV: '/dev/null',
      },
      stdout: 'ignore',
      stderr: 'ignore',
    });

    // The only thing that crosses the registration socket now.
    const announced = await officer.await((f) => f.type === 'pty:server' && typeof f.port === 'number', 'pty:server');
    const httpBase = `http://127.0.0.1:${announced.port}`;
    const wsBase = `ws://127.0.0.1:${announced.port}`;

    const a = client(wsBase, `sessionId=${sessionId}&cwd=~&cols=80&rows=24`);
    await a.open();

    // `~` is resolved by the sidecar against its own HOME_DIR.
    a.send({ type: 'input', data: 'pwd\n' });
    await a.await(sawOutput('/tmp'), 'pwd output');

    a.send({ type: 'input', data: 'echo before-restart\n' });
    await a.await(sawOutput('before-restart'), 'before-restart');

    // ── officer restarts ──
    // It is not in the data path, so this is a non-event for the shell. Under the old translating bridge
    // every keystroke and every byte of output crossed the registration socket.
    officer.stop();
    a.send({ type: 'input', data: 'echo during-outage\n' });
    await a.await(sawOutput('during-outage'), 'output while officer is down');

    officer = fakeOfficer(officerPort);
    await officer.await((f) => f.type === 'pty:server', 're-announced port');

    // ── the viewer goes away, a new one attaches ──
    a.close();
    const b = client(wsBase, `sessionId=${sessionId}&cols=80&rows=24`);
    await b.open();

    // Re-attach replays scrollback as history rather than ordinary output, so the client rebuilds instead
    // of appending a second copy — and it spans everything, including what happened while officer was down.
    const replay = await b.await((f) => f.type === 'replay', 'replay');
    expect(String(replay.data)).toContain('before-restart');
    expect(String(replay.data)).toContain('during-outage');
    expect(b.outputSoFar()).not.toContain('before-restart');

    // Same shell, not a second one.
    b.send({ type: 'input', data: 'echo after-reattach\n' });
    await b.await(sawOutput('after-reattach'), 'after-reattach');

    // ── the sidecar's own HTTP surface ──
    const listed = (await fetch(`${httpBase}/_officer/sessions`).then((r) => r.json())) as { sessions: any[] };
    const entry = listed.sessions.find((s) => s.sessionId === sessionId);
    expect(entry).toBeTruthy();
    expect(entry.pid).toBeGreaterThan(0);
    expect(entry.clients).toBe(1);

    const killed = await fetch(`${httpBase}/_officer/sessions/${sessionId}`, { method: 'DELETE' });
    expect(killed.status).toBe(200);
    await b.await((f) => f.type === 'exit', 'exit after kill');

    const after = (await fetch(`${httpBase}/_officer/sessions`).then((r) => r.json())) as { sessions: any[] };
    expect(after.sessions.find((s) => s.sessionId === sessionId)).toBeUndefined();

    b.close();
    officer.stop();
  }, 30_000);
});
