import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';

// v1.44 Commit 3 — detach state machine + ring buffer + re-attach replay.
//
// The state machine is what turns a single network blip from "fall through
// to ENDED state, click Restart" into "silent re-attach with scrollback
// intact, keep typing." Live WS cycles + buffer-overflow exercises belong
// in the e2e tier; these static-grep tripwires defend the load-bearing
// protocol + correctness properties.

const AGENT_TS = path.resolve(import.meta.path, '..', '..', 'src', 'terminal-agent.ts');

describe('terminal-agent detach + re-attach (v1.44+ Commit 3)', () => {
  test('1. PtySession carries ring buffer + alt-screen + detach state', () => {
    const src = fs.readFileSync(AGENT_TS, 'utf-8');
    const i = src.indexOf('interface PtySession {');
    const j = src.indexOf('\n}', i);
    const block = src.slice(i, j);
    expect(block).toContain('liveWs: any | null');
    expect(block).toContain('ringBuffer: Buffer[]');
    expect(block).toContain('ringBufferBytes: number');
    expect(block).toContain('altScreenActive: boolean');
    expect(block).toContain('detached: boolean');
    expect(block).toContain('detachTimer:');
  });

  test('2. RING_BUFFER_MAX_BYTES default is 1 MB, env-overridable', () => {
    const src = fs.readFileSync(AGENT_TS, 'utf-8');
    expect(src).toContain('GSTACK_PTY_RING_BUFFER_BYTES');
    expect(src).toContain('1024 * 1024');
  });

  test('3. DETACH_WINDOW_MS default is 60s, env-overridable', () => {
    const src = fs.readFileSync(AGENT_TS, 'utf-8');
    expect(src).toContain('GSTACK_PTY_DETACH_WINDOW_MS');
    expect(src).toContain("'60000'");
  });

  test('4. appendToRingBuffer evicts oldest frames past the cap', () => {
    const src = fs.readFileSync(AGENT_TS, 'utf-8');
    expect(src).toMatch(/function appendToRingBuffer\(/);
    // Eviction loop: must keep at least one frame even at extreme caps
    // (otherwise a single oversized frame would empty the buffer).
    expect(src).toMatch(/session\.ringBufferBytes > RING_BUFFER_MAX_BYTES/);
    expect(src).toContain('session.ringBuffer.length > 1');
    expect(src).toContain('session.ringBuffer.shift()');
  });

  test('5. alt-screen tracking watches for CSI ?1049h / CSI ?1049l', () => {
    const src = fs.readFileSync(AGENT_TS, 'utf-8');
    // Canonical xterm enter/exit alt-screen sequences. Must update
    // session.altScreenActive so the replay prelude knows.
    expect(src).toContain('\\x1b[?1049h');
    expect(src).toContain('\\x1b[?1049l');
    expect(src).toContain('session.altScreenActive');
  });

  test('6. buildReplayPayload prefixes soft-reset (+ alt-screen if active)', () => {
    const src = fs.readFileSync(AGENT_TS, 'utf-8');
    expect(src).toMatch(/function buildReplayPayload\(/);
    // DECSTR soft reset — re-defaults character attributes after the
    // client's RIS clears the xterm buffer.
    expect(src).toContain('\\x1b[!p');
    // Conditionally re-enter alt-screen if claude was in a tool-call
    // (alt-screen mode) at detach.
    expect(src).toContain('session.altScreenActive');
  });

  test('7. WS open() re-attaches when sessionId already lives in sessionsById', () => {
    const src = fs.readFileSync(AGENT_TS, 'utf-8');
    const block = sliceBetween(src, 'open(ws) {', 'message(ws, raw) {');
    expect(block).toContain('sessionsById.get(sessionId)');
    expect(block).toContain('existing.liveWs = ws');
    expect(block).toContain('clearTimeout(existing.detachTimer)');
    // Tells the client to write RIS before treating the next binary
    // frame as replay.
    expect(block).toContain("type: 'reattach-begin'");
    expect(block).toContain('sendBinary(buildReplayPayload(existing))');
  });

  test('8. WS close starts detach timer for non-intentional close codes', () => {
    const src = fs.readFileSync(AGENT_TS, 'utf-8');
    const i = src.indexOf('close(ws');
    const j = src.indexOf('function handleTabState', i);
    const block = src.slice(i, j);
    // 4001 = intentional restart (Commit 2), 4404 = no-claude, 1000 = clean
    // exit. Any other code (1006 abnormal, 1001 going-away, etc.) gets the
    // 60s detach grace.
    expect(block).toContain('code === 4001');
    expect(block).toContain('code === 4404');
    expect(block).toContain('code === 1000');
    expect(block).toContain('session.detached = true');
    expect(block).toContain('session.detachTimer = setTimeout');
    expect(block).toContain('DETACH_WINDOW_MS');
    // Detach timer must unref so the bun process can exit cleanly.
    expect(block).toContain('detachTimer as any)?.unref?.()');
  });

  test('9. /internal/restart cancels detach timer before disposal', () => {
    const src = fs.readFileSync(AGENT_TS, 'utf-8');
    const block = sliceBetween(src, "url.pathname === '/internal/restart'", "// /claude-available");
    // Without the cancellation, a later detach-timer fire would dispose a
    // session that's already been disposed by the explicit restart path.
    expect(block).toContain('clearTimeout(session.detachTimer)');
  });

  test('10. PTY on-data writes through session.liveWs (not the original ws closure)', () => {
    const src = fs.readFileSync(AGENT_TS, 'utf-8');
    // Critical for re-attach correctness: the PTY's on-data callback
    // closes over `session`, not the original `ws`, so after re-attach
    // it routes to the new liveWs automatically.
    expect(src).toContain('session.liveWs.sendBinary');
    // Always append to the ring buffer regardless of attach state — so
    // a detached session still captures output for the next re-attach.
    expect(src).toContain('appendToRingBuffer(session, flush)');
  });
});

function sliceBetween(source: string, start: string, end: string): string {
  const i = source.indexOf(start);
  if (i === -1) throw new Error(`marker not found: ${start}`);
  const j = source.indexOf(end, i + start.length);
  if (j === -1) throw new Error(`end marker not found: ${end}`);
  return source.slice(i, j);
}
