/**
 * T15 — Integration / E2E tests for src/server.ts (startServer).
 *
 * Drives the real HTTP + WS server (no mocks) via a true `ws` WebSocket client.
 * Config is built with loadConfig so all paths are code-real.
 *
 * ── Sandbox-runnable tests (must all PASS in CI / sandbox) ─────────────────
 *   ① Bad Origin → WS handshake rejected with 401
 *   ② Wrong path (not cfg.wsPath) → socket destroyed (connection refused/closed)
 *   ③ Oversized WS frame (> maxPayload) → server closes connection with code 1009
 *   ④ Spawn failure → server sends {type:'exit',code:-1,reason} and closes the WS
 *      (shell path set to '/no/such/shell'; node-pty throws synchronously → M4 path)
 *
 * ── Require real PTY — sandbox-off (posix_spawn is blocked in sandbox) ─────
 *   ⑤ attach → attached → output sequence (real shell produces prompt output)
 *   ⑥ Reconnect replay (F5/F6): send echo command, disconnect, reconnect with same
 *      sessionId, assert replayed output contains the marker; ANSI/CJK must not corrupt
 *
 * Architecture references:
 *   M4 (spawn failure → exit(-1) + close connection only)
 *   L3 (noServer:true; wrong path → destroy)
 *   L5 (maxPayload cap; over-size frame → 1009)
 *   F9 (bad Origin → 401)
 *   ARCHITECTURE §3.6 / §7
 */

import net from 'node:net'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { execFileSync } from 'node:child_process'

import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import WebSocket from 'ws'
import * as nodePty from 'node-pty'

import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
import type { Config } from '../../src/types.js'

/**
 * Detect whether real PTY spawn works here. The Bash sandbox blocks posix_spawn,
 * so the real-PTY E2E cases (⑤⑥) auto-skip there but RUN on any normal machine/CI.
 */
const PTY_AVAILABLE = (() => {
  try {
    const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 })
    p.kill()
    return true
  } catch {
    return false
  }
})()
const itPty = PTY_AVAILABLE ? it : it.skip

/**
 * How long a real-PTY case waits for something that MUST arrive (handshake,
 * attached frame, shell output). These are the bounds that actually catch a hang;
 * the per-test ceiling above them is only a backstop. They were 3–5 s, which is
 * fine for this file alone but not while ~8 workers hammer the machine — a real
 * shell simply takes longer to boot and echo then, and the case failed with
 * "timeout waiting for matching message" rather than any real defect.
 * The deliberate "must be rejected" waits in ①② keep their short bounds.
 */
const PTY_WAIT_MS = 20_000

/** H1 tmux tests need real PTY + the tmux binary. */
const TMUX_OK =
  PTY_AVAILABLE &&
  (() => {
    try {
      execFileSync('tmux', ['-V'], { stdio: 'ignore' })
      return true
    } catch {
      return false
    }
  })()
const itTmux = TMUX_OK ? it : it.skip

// ── Helpers ──────────────────────────────────────────────────────────────────

/** Pick a free port on 127.0.0.1 by briefly binding to port 0. */
function getFreePort(): Promise<number> {
  return new Promise((resolve, reject) => {
    const srv = net.createServer()
    srv.listen(0, '127.0.0.1', () => {
      const addr = srv.address()
      if (!addr || typeof addr === 'string') {
        srv.close()
        reject(new Error('unexpected address type'))
        return
      }
      const port = addr.port
      srv.close(() => resolve(port))
    })
    srv.on('error', reject)
  })
}

/**
 * Build a minimal Config that starts the server on 127.0.0.1:<port> with
 * the given shellPath and registers http://127.0.0.1:<port> as an allowed origin.
 *
 * maxPayloadBytes is set small (512 bytes) for the oversized-frame test so we
 * don't have to send megabytes of data.
 */
function makeTestConfig(port: number, shellPath: string, maxPayloadBytes = 512 * 1024): Config {
  return loadConfig({
    PORT: String(port),
    BIND_HOST: '127.0.0.1',
    SHELL_PATH: shellPath,
    // Inject the loopback origin explicitly so the WS handshake is accepted.
    ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
    // Keep scroll-back small to reduce memory overhead in tests.
    SCROLLBACK_BYTES: String(64 * 1024),
    // Keep idle TTL tiny so stray sessions don't linger.
    IDLE_TTL: '86400',
    MAX_PAYLOAD_BYTES: String(maxPayloadBytes),
    USE_TMUX: '0', // default off so most cases don't spawn/leak tmux sessions
    // W1: /open-in-editor spawns EDITOR_CMD. Use the POSIX no-op so tests never
    // pop a real editor (and it's a non-goto basename → bare-file argv).
    EDITOR_CMD: 'true',
  })
}

/**
 * Wait until a WebSocket is in OPEN state (or rejects on error/close before open).
 * Times out after `timeoutMs`.
 */
function waitForOpen(ws: WebSocket, timeoutMs = 3_000): Promise<void> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('WS open timeout')), timeoutMs)
    ws.once('open', () => {
      clearTimeout(timer)
      resolve()
    })
    ws.once('error', (err) => {
      clearTimeout(timer)
      reject(err)
    })
    ws.once('close', () => {
      clearTimeout(timer)
      reject(new Error('WS closed before open'))
    })
  })
}

/**
 * Collect the next N JSON messages from the WebSocket. Rejects on close or error
 * before N messages have arrived, or after `timeoutMs`.
 */
function collectMessages(ws: WebSocket, count: number, timeoutMs = 5_000): Promise<unknown[]> {
  return new Promise((resolve, reject) => {
    const msgs: unknown[] = []
    const timer = setTimeout(
      () => reject(new Error(`timeout waiting for ${count} messages; got ${msgs.length}`)),
      timeoutMs,
    )

    const onMsg = (raw: WebSocket.RawData): void => {
      try {
        msgs.push(JSON.parse(raw.toString('utf8')))
      } catch {
        msgs.push(raw.toString('utf8'))
      }
      if (msgs.length >= count) {
        clearTimeout(timer)
        ws.off('message', onMsg)
        ws.off('close', onClose)
        ws.off('error', onError)
        resolve(msgs)
      }
    }

    const onClose = (): void => {
      clearTimeout(timer)
      ws.off('message', onMsg)
      ws.off('error', onError)
      reject(new Error(`WS closed before receiving ${count} messages; got ${msgs.length}`))
    }

    const onError = (err: Error): void => {
      clearTimeout(timer)
      ws.off('message', onMsg)
      ws.off('close', onClose)
      reject(err)
    }

    ws.on('message', onMsg)
    ws.on('close', onClose)
    ws.on('error', onError)
  })
}

/**
 * Wait for the WebSocket to close, returning { code, reason }.
 */
function waitForClose(ws: WebSocket, timeoutMs = 5_000): Promise<{ code: number; reason: string }> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('WS close timeout')), timeoutMs)
    ws.once('close', (code, reason) => {
      clearTimeout(timer)
      resolve({ code, reason: reason.toString('utf8') })
    })
    ws.once('error', (err) => {
      clearTimeout(timer)
      reject(err)
    })
  })
}

/**
 * Wait for at least one message matching `predicate`, collecting all messages
 * up to `timeoutMs`. Returns the first matching message.
 */
function waitForMessage(
  ws: WebSocket,
  predicate: (msg: unknown) => boolean,
  timeoutMs = 5_000,
): Promise<unknown> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      ws.off('message', onMsg)
      reject(new Error('timeout waiting for matching message'))
    }, timeoutMs)

    const onMsg = (raw: WebSocket.RawData): void => {
      let msg: unknown
      try {
        msg = JSON.parse(raw.toString('utf8'))
      } catch {
        msg = raw.toString('utf8')
      }
      if (predicate(msg)) {
        clearTimeout(timer)
        ws.off('message', onMsg)
        resolve(msg)
      }
    }
    ws.on('message', onMsg)
  })
}

// ── Test suite ────────────────────────────────────────────────────────────────

// These cases boot a real server, spawn a real shell and wait for prompt output —
// and H1 restarts the server and waits for tmux to hand the session back. None of
// that fits vitest's 5 s default, so under a full parallel run they were failing as
// `Test timed out in 5000ms`. The `[needs sandbox-off]` labels are about whether
// PTY_AVAILABLE lets them RUN, which is a separate axis from how long they need.
describe('startServer — integration', { timeout: 60_000 }, () => {
  // NOTE: no process.setMaxListeners() bump here. The signal-handler-leak fix
  // (CQ H1) means close() removes the SIGINT/SIGTERM/uncaughtException listeners
  // it registered, so repeated startServer()/close() cycles don't accumulate.

  let port: number
  let cfg: Config
  let serverHandle: { close(): Promise<void> }

  // Each test gets its own port to avoid cross-test interference.
  beforeEach(async () => {
    port = await getFreePort()
    // Default cfg uses the real shell (process.env.SHELL / /bin/zsh).
    cfg = makeTestConfig(port, process.env['SHELL'] ?? '/bin/zsh')
    serverHandle = startServer(cfg)
    // Small delay to let the server start listening before tests connect.
    await new Promise<void>((r) => setTimeout(r, 80))
  })

  afterEach(async () => {
    await serverHandle.close()
    // Extra delay to let the OS release the port before the next test.
    await new Promise<void>((r) => setTimeout(r, 50))
    // Shutting a server down after a real-PTY case can exceed vitest's 10 s hook
    // default under a loaded full-suite run; the close itself is what we wait on.
  }, 30_000)

  // ── ① Bad Origin → 401 ─────────────────────────────────────────────────────

  it('① rejects a WS handshake from a bad Origin with 401', async () => {
    // ARCHITECTURE §3.3 / TECH_DOC §7: CSWSH defence — foreign origins get 401.
    const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
      headers: { Origin: 'http://evil.com' },
    })

    // The server writes HTTP/1.1 401 and destroys the socket.
    // The `ws` client surfaces this as an 'unexpected server response 401' error.
    await expect(waitForOpen(ws, 3_000)).rejects.toThrow()
    // Ensure we don't leave a dangling socket.
    ws.terminate()
  })

  // ── ② Wrong path → connection destroyed ────────────────────────────────────

  it('② destroys the socket for an upgrade request to a non-wsPath URL (L3)', async () => {
    // Server calls socket.destroy() for any upgrade to a path other than cfg.wsPath.
    const ws = new WebSocket(`ws://127.0.0.1:${port}/not-a-terminal`, {
      headers: { Origin: `http://127.0.0.1:${port}` },
    })

    // The socket destruction surfaces as an error or abrupt close before open.
    await expect(waitForOpen(ws, 3_000)).rejects.toThrow()
    ws.terminate()
  })

  // ── ③ Oversized frame → 1009 ───────────────────────────────────────────────

  it('③ closes the connection with code 1009 when a frame exceeds maxPayloadBytes (L5)', async () => {
    // Use a config with a very small maxPayload so we can test cheaply.
    const smallMaxPort = await getFreePort()
    const smallMaxCfg = makeTestConfig(smallMaxPort, process.env['SHELL'] ?? '/bin/zsh', 256)
    const smallServer = startServer(smallMaxCfg)
    await new Promise<void>((r) => setTimeout(r, 80))

    try {
      const ws = new WebSocket(`ws://127.0.0.1:${smallMaxPort}${smallMaxCfg.wsPath}`, {
        headers: { Origin: `http://127.0.0.1:${smallMaxPort}` },
      })
      await waitForOpen(ws, 3_000)

      // Send a frame that exceeds the 256-byte maxPayload.
      // (The `ws` library sends the raw buffer; the server's WebSocketServer
      //  will reject it and close with code 1009 Message Too Big.)
      const oversize = Buffer.alloc(512, 'x')
      ws.send(oversize)

      const closed = await waitForClose(ws, 5_000)
      // RFC 6455 §7.4.1: status code 1009 = Message Too Big.
      expect(closed.code).toBe(1009)

      ws.terminate()
    } finally {
      await smallServer.close()
      await new Promise<void>((r) => setTimeout(r, 50))
    }
  })

  // ── ④ Spawn failure → exit(-1) + connection closed (M4) ──────────────────
  //
  // Sandbox behaviour (posix_spawn blocked):
  //   node-pty.spawn() throws synchronously → server.ts M4 catch → sends
  //   {type:'exit', code:-1, reason} → ws.close().  NO 'attached' frame is sent.
  //
  // Real-system behaviour (posix_spawn available):
  //   node-pty spawns the helper process; the shell binary doesn't exist so
  //   exec() fails and the helper fires onExit with exitCode=1.  Server sends
  //   {type:'attached'} first, then {type:'exit', code:1} via the onExit handler.
  //   The WS stays open (correct: server doesn't auto-close on shell exit).
  //
  // Both paths confirm the server correctly handles a nonexistent shell path.
  // The strict assertions (code=-1, WS close) are the SANDBOX path (M4); the
  // lenient assertions (any exit frame) cover the real-system path.

  it('④ handles a nonexistent shell path: sends exit frame and (in sandbox) closes the WS (M4)', async () => {
    const badPort = await getFreePort()
    const badCfg = makeTestConfig(badPort, '/no/such/shell/does/not/exist')
    const badServer = startServer(badCfg)
    await new Promise<void>((r) => setTimeout(r, 80))

    try {
      const ws = new WebSocket(`ws://127.0.0.1:${badPort}${badCfg.wsPath}`, {
        headers: { Origin: `http://127.0.0.1:${badPort}` },
      })
      await waitForOpen(ws, 3_000)

      // Accumulate all messages and the first close event concurrently.
      const receivedMsgs: Record<string, unknown>[] = []
      let wsClosedCode: number | null = null

      const allDonePromise = new Promise<void>((resolve) => {
        // Resolve after 2 s max so the test doesn't hang on real systems.
        const timer = setTimeout(resolve, 2_000)

        ws.on('message', (raw) => {
          try {
            receivedMsgs.push(JSON.parse(raw.toString('utf8')) as Record<string, unknown>)
          } catch {
            // ignore parse errors
          }
        })
        ws.once('close', (code) => {
          clearTimeout(timer)
          wsClosedCode = code
          resolve()
        })
      })

      // Trigger the attach.  In sandbox this causes a synchronous spawn throw
      // (M4).  On real systems the shell fails to exec and fires onExit async.
      ws.send(JSON.stringify({ type: 'attach', sessionId: null }))

      await allDonePromise
      ws.terminate()

      // ── Assertions common to both paths ────────────────────────────────────
      // At least one message must have been received.
      expect(receivedMsgs.length).toBeGreaterThan(0)

      // There must be an exit frame somewhere in the received messages.
      const exitFrame = receivedMsgs.find((m) => m['type'] === 'exit')
      expect(exitFrame).toBeDefined()
      expect(typeof (exitFrame as Record<string, unknown>)['code']).toBe('number')

      // ── Sandbox-specific (M4) assertions ───────────────────────────────────
      // When posix_spawn is blocked (sandbox), node-pty throws synchronously.
      // In that case the server must send ONLY {type:'exit', code:-1, reason}
      // (no 'attached' frame) and must close the WS connection.
      if (wsClosedCode !== null) {
        // WS was closed — this is the sandbox (M4) path.
        const m4ExitFrame = receivedMsgs.find((m) => m['type'] === 'exit') as
          | Record<string, unknown>
          | undefined
        expect(m4ExitFrame).toBeDefined()
        expect(m4ExitFrame!['code']).toBe(-1)
        expect(typeof m4ExitFrame!['reason']).toBe('string')
        expect((m4ExitFrame!['reason'] as string).length).toBeGreaterThan(0)
        // The WS must be closed with a clean status code.
        expect(wsClosedCode).toBeLessThanOrEqual(1001)
        // No 'attached' frame should have been sent on the M4 path.
        const hasAttached = receivedMsgs.some((m) => m['type'] === 'attached')
        expect(hasAttached).toBe(false)
      }
      // (If wsClosedCode is null, we are on a real system: assertions above are sufficient.)
    } finally {
      await badServer.close()
      await new Promise<void>((r) => setTimeout(r, 50))
    }
  }, 15_000)

  // ── W1: POST /open-in-editor file mode + CSRF guard ────────────────────────
  // EDITOR_CMD is 'true' (see makeTestConfig) so no real editor is launched.
  // These don't need a PTY — they exercise the plain HTTP route + Origin guard.

  it('W1: opens a FILE at a line with a valid Origin (204)', async () => {
    const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oie-file-'))
    const file = path.join(dir, 'x.ts')
    await fs.writeFile(file, 'hi')
    try {
      const res = await fetch(`http://127.0.0.1:${port}/open-in-editor`, {
        method: 'POST',
        headers: { 'content-type': 'application/json', Origin: `http://127.0.0.1:${port}` },
        body: JSON.stringify({ file, line: 3 }),
      })
      expect(res.status).toBe(204)
    } finally {
      await fs.rm(dir, { recursive: true, force: true })
    }
  })

  it('W1: rejects file mode from a foreign Origin (403 — CSRF guard covers the new branch)', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/open-in-editor`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', Origin: 'http://evil.com' },
      body: JSON.stringify({ file: '/tmp/x.ts', line: 1 }),
    })
    expect(res.status).toBe(403)
  })

  it('W1: still supports directory mode (regression — Projects panel intact)', async () => {
    const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oie-dir-'))
    try {
      const res = await fetch(`http://127.0.0.1:${port}/open-in-editor`, {
        method: 'POST',
        headers: { 'content-type': 'application/json', Origin: `http://127.0.0.1:${port}` },
        body: JSON.stringify({ path: dir }),
      })
      expect(res.status).toBe(204)
    } finally {
      await fs.rm(dir, { recursive: true, force: true })
    }
  })

  it('W1: 404 for a file that does not exist (validated server-side)', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/open-in-editor`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', Origin: `http://127.0.0.1:${port}` },
      body: JSON.stringify({ file: '/no/such/file/here.ts', line: 1 }),
    })
    expect(res.status).toBe(404)
  })

  // ── ⑤ attach → attached → output (needs real PTY — sandbox-off) ──────────
  //
  // NOTE (sandbox-off): This test spawns a real shell process via node-pty.
  // posix_spawn is blocked in the sandbox environment. This test is intentionally
  // skipped in sandbox CI; it MUST pass when run with --sandbox=off (real PTY
  // available).
  //
  // To run locally:  npx vitest run integration
  // To skip in CI:   (mark with .skip or filter via env var below)

  itPty(
    '⑤ [needs real PTY (sandbox-off)] attach → attached → shell prompt output',
    async () => {
      const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
        headers: { Origin: `http://127.0.0.1:${port}` },
      })
      await waitForOpen(ws, PTY_WAIT_MS)

      // Send first frame: attach with sessionId=null (new session).
      ws.send(JSON.stringify({ type: 'attach', sessionId: null }))

      // 1. Expect 'attached' confirming a new session was created.
      const attached = await waitForMessage(
        ws,
        (m) => typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached',
        5_000,
      ) as Record<string, unknown>
      expect(attached['type']).toBe('attached')
      expect(typeof attached['sessionId']).toBe('string')
      // sessionId must be a UUID v4.
      expect(attached['sessionId']).toMatch(
        /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
      )

      // 2. Expect at least one 'output' message (shell prompt).
      const output = await waitForMessage(
        ws,
        (m) => typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'output',
        5_000,
      ) as Record<string, unknown>
      expect(output['type']).toBe('output')
      expect(typeof output['data']).toBe('string')

      ws.close()
      await waitForClose(ws, 3_000).catch(() => undefined)
    },
  )

  // ── ⑥ Reconnect replay / F5+F6 (needs real PTY — sandbox-off) ─────────────
  //
  // NOTE (sandbox-off): Requires a live shell to produce output containing
  // __MARK__. posix_spawn is blocked in the sandbox. Run locally without sandbox.
  //
  // Verifies:
  //   - Client sends `echo __MARK__\r`, then disconnects.
  //   - While disconnected, the PTY keeps running (invariant #2 — WS close ≠ PTY kill).
  //   - Client reconnects with the same sessionId.
  //   - Server replays the ring buffer; the reconnecting client sees __MARK__ in the output.
  //   - The replay must not corrupt ANSI sequences or multi-byte UTF-8 (Chinese characters).

  itPty(
    '⑥ [needs real PTY (sandbox-off)] reconnect replays ring buffer; ANSI/CJK intact (F5/F6)',
    async () => {
      // ── Step A: First connection — attach and send a marker command ──────────
      const ws1 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
        headers: { Origin: `http://127.0.0.1:${port}` },
      })
      await waitForOpen(ws1, PTY_WAIT_MS)
      ws1.send(JSON.stringify({ type: 'attach', sessionId: null }))

      // Wait for 'attached' to capture the sessionId.
      const attached = await waitForMessage(
        ws1,
        (m) => typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached',
        5_000,
      ) as Record<string, unknown>
      const sessionId = attached['sessionId'] as string
      expect(typeof sessionId).toBe('string')

      // Wait for at least one output (shell ready).
      await waitForMessage(
        ws1,
        (m) => typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'output',
        5_000,
      )

      // Send a marker command that will appear in the ring buffer.
      // Also include a CJK character to verify UTF-8 replay integrity (M2).
      const MARKER = '__MARK_测试__'
      ws1.send(JSON.stringify({ type: 'input', data: `echo ${MARKER}\r` }))

      // Give the shell time to process the command and write output.
      await new Promise<void>((r) => setTimeout(r, 500))

      // ── Step B: Disconnect (must NOT kill PTY — invariant #2) ───────────────
      ws1.close()
      await waitForClose(ws1, 3_000).catch(() => undefined)

      // Small pause to ensure the server processes the WS close → detach.
      await new Promise<void>((r) => setTimeout(r, 100))

      // ── Step C: Reconnect with the same sessionId ────────────────────────────
      const ws2 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
        headers: { Origin: `http://127.0.0.1:${port}` },
      })
      await waitForOpen(ws2, PTY_WAIT_MS)
      ws2.send(JSON.stringify({ type: 'attach', sessionId }))

      // ── Step D: Collect replay output and assert marker is present ───────────
      // Collect messages for up to 3 seconds; concatenate all 'output' data.
      const allOutput: string[] = []
      const replayTimeout = 3_000

      await new Promise<void>((resolve) => {
        const timer = setTimeout(resolve, replayTimeout)
        ws2.on('message', (raw) => {
          try {
            const msg = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
            if (msg['type'] === 'output' && typeof msg['data'] === 'string') {
              allOutput.push(msg['data'])
            }
          } catch {
            // ignore parse errors
          }
        })
        ws2.once('close', () => {
          clearTimeout(timer)
          resolve()
        })
      })

      const combined = allOutput.join('')

      // The marker must appear in the replayed output.
      expect(combined).toContain(MARKER)

      // The CJK portion of the marker must appear verbatim (no UTF-8 corruption).
      expect(combined).toContain('测试')

      // Basic ANSI sanity: if any ANSI CSI sequence is present, it must be well-formed
      // (starts with ESC + '[', ends with a letter). Broken sequences would appear as
      // partial ESC bytes or malformed numbers — check that we have no lone ESC chars
      // that are not followed by '[' or ']' (simplified check for cut sequences).
      // A truly broken buffer would produce something like "\x1b[1;3" with no terminator.
      // We verify that every ESC in the output is followed by at least one more byte.
      const escPositions: number[] = []
      for (let i = 0; i < combined.length; i++) {
        if (combined[i] === '\x1b') escPositions.push(i)
      }
      for (const pos of escPositions) {
        expect(combined.length).toBeGreaterThan(pos)
      }

      ws2.close()
      await waitForClose(ws2, 3_000).catch(() => undefined)
    },
  )

  // ── ⑦ Hook side-channel → status push (H2, needs real PTY — sandbox-off) ───
  itPty(
    '⑦ [needs real PTY (sandbox-off)] POST /hook pushes a status frame to the attached ws (H2)',
    async () => {
      const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
        headers: { Origin: `http://127.0.0.1:${port}` },
      })
      await waitForOpen(ws, PTY_WAIT_MS)
      ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
      const attached = (await waitForMessage(
        ws,
        (m) =>
          typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached',
        5_000,
      )) as Record<string, unknown>
      const sessionId = attached['sessionId'] as string

      // Arm the status listener BEFORE firing the hook — the server pushes the
      // status frame synchronously (before the POST returns 204), so attaching
      // the listener after the fetch would race past it.
      const statusPromise = waitForMessage(
        ws,
        (m) =>
          typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'status',
        5_000,
      )

      // Simulate a Claude Code PermissionRequest hook firing for this session.
      const res = await fetch(`http://127.0.0.1:${port}/hook`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
        body: JSON.stringify({ hook_event_name: 'PermissionRequest', tool_name: 'Bash' }),
      })
      expect(res.status).toBe(204)

      const status = (await statusPromise) as Record<string, unknown>
      expect(status['status']).toBe('waiting')
      expect(status['detail']).toBe('Bash')

      ws.close()
      await waitForClose(ws, 3_000).catch(() => undefined)
    },
  )

  // ── ⑧ Held PermissionRequest resolves on approve (H3, real PTY — sandbox-off) ─
  itPty(
    '⑧ [needs real PTY (sandbox-off)] POST /hook/permission is held until approve → allow decision',
    async () => {
      const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
        headers: { Origin: `http://127.0.0.1:${port}` },
      })
      await waitForOpen(ws, PTY_WAIT_MS)
      ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
      const attached = (await waitForMessage(
        ws,
        (m) =>
          typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached',
        5_000,
      )) as Record<string, unknown>
      const sessionId = attached['sessionId'] as string

      // Arm the pending-status listener before firing (push is synchronous).
      const pendingPromise = waitForMessage(
        ws,
        (m) =>
          typeof m === 'object' && m !== null && (m as Record<string, unknown>)['pending'] === true,
        5_000,
      )

      // Fire the PermissionRequest hook — this POST is HELD by the server until
      // we approve. Do NOT await it yet.
      const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
        body: JSON.stringify({ tool_name: 'Bash' }),
      })

      const pending = (await pendingPromise) as Record<string, unknown>
      expect(pending['status']).toBe('waiting')
      expect(pending['pending']).toBe(true)

      // Approve over the ws → the held POST should resolve with an allow decision.
      ws.send(JSON.stringify({ type: 'approve' }))
      const decision = (await (await permPromise).json()) as {
        hookSpecificOutput?: { decision?: { behavior?: string } }
      }
      expect(decision.hookSpecificOutput?.decision?.behavior).toBe('allow')

      ws.close()
      await waitForClose(ws, 3_000).catch(() => undefined)
    },
  )

  // ── W1: /hook/permission carries a bounded preview + re-sends to late joiners ─
  itPty(
    'W1 [needs real PTY (sandbox-off)] /hook/permission broadcasts a preview and re-sends it to a late joiner',
    async () => {
      const ws1 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
        headers: { Origin: `http://127.0.0.1:${port}` },
      })
      await waitForOpen(ws1, PTY_WAIT_MS)
      ws1.send(JSON.stringify({ type: 'attach', sessionId: null }))
      const attached = (await waitForMessage(
        ws1,
        (m) =>
          typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached',
        5_000,
      )) as Record<string, unknown>
      const sessionId = attached['sessionId'] as string

      // Arm the pending-status listener on ws1 before firing (push is synchronous).
      const pendingPromise = waitForMessage(
        ws1,
        (m) =>
          typeof m === 'object' && m !== null && (m as Record<string, unknown>)['pending'] === true,
        5_000,
      )

      // Fire a Bash PermissionRequest carrying tool_input — HELD until approve.
      const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
        body: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'echo hi' } }),
      })

      const pending = (await pendingPromise) as Record<string, unknown>
      expect(pending['status']).toBe('waiting')
      const preview = pending['preview'] as Record<string, unknown> | undefined
      expect(preview).toBeDefined()
      expect(preview!['kind']).toBe('command')
      expect(String(preview!['text'])).toContain('echo hi')

      // Late joiner: a SECOND ws attaching to the same session while the approval
      // is held must receive the preview on attach (re-sent like `gate`).
      const ws2 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
        headers: { Origin: `http://127.0.0.1:${port}` },
      })
      await waitForOpen(ws2, PTY_WAIT_MS)
      const joinPending = waitForMessage(
        ws2,
        (m) =>
          typeof m === 'object' && m !== null && (m as Record<string, unknown>)['pending'] === true,
        5_000,
      )
      ws2.send(JSON.stringify({ type: 'attach', sessionId }))
      const joined = (await joinPending) as Record<string, unknown>
      const joinedPreview = joined['preview'] as Record<string, unknown> | undefined
      expect(joinedPreview).toBeDefined()
      expect(joinedPreview!['kind']).toBe('command')
      expect(String(joinedPreview!['text'])).toContain('echo hi')

      // Approve over ws1 to release the held POST, then clean up.
      ws1.send(JSON.stringify({ type: 'approve' }))
      await permPromise.then((r) => r.json()).catch(() => undefined)
      ws1.close()
      ws2.close()
      await waitForClose(ws1, 3_000).catch(() => undefined)
      await waitForClose(ws2, 3_000).catch(() => undefined)
    },
  )

  // ── H1: tmux keepalive — a shell survives a full server restart ────────────
  itTmux(
    'H1 [needs tmux + real PTY] shell state survives a server restart',
    async () => {
      const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
      const isAttached = (m: unknown): boolean =>
        typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached'

      const tport = await getFreePort()
      const tcfg = loadConfig({
        PORT: String(tport),
        BIND_HOST: '127.0.0.1',
        SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
        ALLOWED_ORIGINS: `http://127.0.0.1:${tport}`,
        IDLE_TTL: '86400',
        USE_TMUX: '1',
      })

      let sid = ''
      let srv = startServer(tcfg)
      await delay(150)
      try {
        // Attach and set a shell variable inside the tmux shell.
        const ws1 = new WebSocket(`ws://127.0.0.1:${tport}/term`, {
          headers: { Origin: `http://127.0.0.1:${tport}` },
        })
        await waitForOpen(ws1, PTY_WAIT_MS)
        ws1.send(JSON.stringify({ type: 'attach', sessionId: null }))
        const att = (await waitForMessage(ws1, isAttached, PTY_WAIT_MS)) as Record<string, unknown>
        sid = att['sessionId'] as string
        await delay(500)
        ws1.send(JSON.stringify({ type: 'input', data: 'WEBTERM_MARK=tmuxlives\r' }))
        await delay(500)
        ws1.close()
        await waitForClose(ws1, 3_000).catch(() => undefined)

        // Restart the server — shutdown must keep the tmux session alive (H1).
        await srv.close()
        srv = startServer(tcfg)
        await delay(150)

        // Reconnect with the SAME sessionId → re-attach to the surviving shell.
        const ws2 = new WebSocket(`ws://127.0.0.1:${tport}/term`, {
          headers: { Origin: `http://127.0.0.1:${tport}` },
        })
        await waitForOpen(ws2, PTY_WAIT_MS)
        const out: string[] = []
        ws2.on('message', (raw) => {
          try {
            const m = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
            if (m['type'] === 'output' && typeof m['data'] === 'string') out.push(m['data'])
          } catch {
            /* ignore */
          }
        })
        ws2.send(JSON.stringify({ type: 'attach', sessionId: sid }))
        await delay(400)
        ws2.send(JSON.stringify({ type: 'input', data: 'echo MARK-$WEBTERM_MARK\r' }))
        await delay(900)
        // The variable set before the restart is still set → same shell survived.
        expect(out.join('')).toContain('MARK-tmuxlives')
        ws2.close()
        await waitForClose(ws2, 3_000).catch(() => undefined)
      } finally {
        // Bound the shutdown. An unbounded close() here swallows whatever really
        // went wrong in the body: the inner waits throw, control jumps to this
        // finally, close() blocks, and the case reports "timed out" instead of
        // the actual error. The test must fail with its own diagnosis.
        await Promise.race([srv.close(), delay(5_000)])
        if (sid !== '') {
          try {
            execFileSync('tmux', ['kill-session', '-t', `web_${sid}`], { stdio: 'ignore' })
          } catch {
            /* already gone */
          }
        }
      }
    },
    // The inner waits are already individually bounded (3+5+3+3+3 s) and the fixed
    // delays add ~2.6 s, so the worst case is ~19.6 s — the old 20 s ceiling had
    // no headroom at all and tripped whenever the full suite ran in parallel.
    // The real guards against a hang are those inner bounds, not this number.
    60_000,
  )

  // ── Signal-handler leak fix (CQ H1) ────────────────────────────────────────
  it('does not leak SIGINT listeners across startServer()/close() cycles', async () => {
    const before = process.listenerCount('SIGINT')
    for (let i = 0; i < 3; i++) {
      const p = await getFreePort()
      const h = startServer(makeTestConfig(p, process.env['SHELL'] ?? '/bin/zsh'))
      await new Promise<void>((r) => setTimeout(r, 30))
      await h.close()
    }
    expect(process.listenerCount('SIGINT')).toBe(before)
    expect(process.listenerCount('SIGTERM')).toBe(before)
  })

  // ── GET /live-sessions (route, no PTY needed when empty) ────────────────────
  it('GET /live-sessions returns an array (empty when none running)', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/live-sessions`)
    expect(res.status).toBe(200)
    const body = (await res.json()) as unknown
    expect(Array.isArray(body)).toBe(true)
  })

  // ── GET /live-sessions/:id/preview — unknown id → 404 ───────────────────────
  it('GET /live-sessions/:id/preview returns 404 for an unknown session id', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000/preview`)
    expect(res.status).toBe(404)
  })

  // ── Security headers present on responses ───────────────────────────────────
  it('sets conservative security headers (CSP / X-Frame-Options / nosniff)', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/live-sessions`)
    expect(res.headers.get('x-frame-options')).toBe('DENY')
    expect(res.headers.get('x-content-type-options')).toBe('nosniff')
    expect(res.headers.get('content-security-policy')).toContain("default-src 'self'")
  })

  // ── DELETE /live-sessions Origin guard (Sec H2 / Arch 5b) ───────────────────
  it('DELETE /live-sessions → 403 with a foreign Origin', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
      method: 'DELETE',
      headers: { Origin: 'http://evil.example' },
    })
    expect(res.status).toBe(403)
  })

  it('DELETE /live-sessions → 403 with NO Origin header (default-deny)', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { method: 'DELETE' })
    expect(res.status).toBe(403)
  })

  it('DELETE /live-sessions → 200 with an allowed (same-host) Origin', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
      method: 'DELETE',
      headers: { Origin: `http://127.0.0.1:${port}` },
    })
    expect(res.status).toBe(200)
    const body = (await res.json()) as { killed: number }
    expect(typeof body.killed).toBe('number')
  })

  it('DELETE /live-sessions/:id → 403 with a foreign Origin (before 404 lookup)', async () => {
    const res = await fetch(
      `http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000`,
      { method: 'DELETE', headers: { Origin: 'http://evil.example' } },
    )
    expect(res.status).toBe(403)
  })

  it('DELETE /live-sessions/:id → 404 (allowed Origin, unknown id)', async () => {
    const res = await fetch(
      `http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000`,
      { method: 'DELETE', headers: { Origin: `http://127.0.0.1:${port}` } },
    )
    expect(res.status).toBe(404)
  })

  // ── Hook side-channel endpoints (route-level, no PTY needed) ────────────────
  it('POST /hook from loopback with an unknown session → 204 (no-op broadcast)', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/hook`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': '00000000-0000-4000-8000-000000000000' },
      body: JSON.stringify({ hook_event_name: 'Stop' }),
    })
    expect(res.status).toBe(204)
  })

  it('POST /hook with an unparseable event body → 400', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/hook`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' }, // no session header / no event name
      body: JSON.stringify({}),
    })
    expect(res.status).toBe(400)
  })

  it('POST /hook/permission with no matching session falls back to {} (Claude prompts itself)', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/hook/permission`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': '00000000-0000-4000-8000-000000000000' },
      body: JSON.stringify({ tool_name: 'Bash' }),
    })
    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({})
  })

  // ── Per-connection WS rate limit (Sec M3) ───────────────────────────────────
  // Fires BEFORE attach, so we can flood pre-attach frames without a real PTY.
  // A tiny MAX_MSGS_PER_SEC makes the cap observable: the connection survives a
  // flood (frames are dropped, not closed).
  it('drops frames over MAX_MSGS_PER_SEC but keeps the connection open (no close)', async () => {
    const rlPort = await getFreePort()
    const rlCfg = loadConfig({
      PORT: String(rlPort),
      BIND_HOST: '127.0.0.1',
      SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
      ALLOWED_ORIGINS: `http://127.0.0.1:${rlPort}`,
      IDLE_TTL: '86400',
      USE_TMUX: '0',
      MAX_MSGS_PER_SEC: '5',
    })
    const rlServer = startServer(rlCfg)
    await new Promise<void>((r) => setTimeout(r, 80))
    try {
      const ws = new WebSocket(`ws://127.0.0.1:${rlPort}${rlCfg.wsPath}`, {
        headers: { Origin: `http://127.0.0.1:${rlPort}` },
      })
      await waitForOpen(ws, PTY_WAIT_MS)

      // Flood 50 invalid frames in one tick — far over the 5/s cap.
      for (let i = 0; i < 50; i++) ws.send('not-json')

      // The connection must NOT be closed by the rate limiter.
      let closed = false
      ws.once('close', () => {
        closed = true
      })
      await new Promise<void>((r) => setTimeout(r, 200))
      expect(closed).toBe(false)
      expect(ws.readyState).toBe(WebSocket.OPEN)

      ws.close()
      await waitForClose(ws, 3_000).catch(() => undefined)
    } finally {
      await rlServer.close()
      await new Promise<void>((r) => setTimeout(r, 50))
    }
  })
})
