/**
 * T-server-wire — integration tests for the A4 timeline, B2 statusLine ingest,
 * and the GET /config/ui route.
 *
 * Covers (against a real startServer):
 *   - GET  /live-sessions/:id/events → 404 unknown id; [] when TIMELINE_ENABLED=0;
 *                                       real timeline entries after a /hook POST
 *   - POST /hook/status (loopback)   → 400 on garbage; 204 + telemetry broadcast
 *   - GET  /config/ui                → mirrors cfg.allowAutoMode
 */

import net from 'node:net'

import { afterEach, 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'

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

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 === null || typeof addr === 'string') {
        srv.close()
        reject(new Error('bad addr'))
        return
      }
      const port = addr.port
      srv.close(() => resolve(port))
    })
    srv.on('error', reject)
  })
}

const handles: { close(): Promise<void> }[] = []

async function spawnServer(
  overrides: Record<string, string | undefined> = {},
): Promise<{ port: number; origin: string }> {
  const port = await getFreePort()
  const cfg = loadConfig({
    PORT: String(port),
    BIND_HOST: '127.0.0.1',
    SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
    ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
    USE_TMUX: '0',
    IDLE_TTL: '86400',
    ...overrides,
  })
  const handle = startServer(cfg)
  handles.push(handle)
  await new Promise<void>((r) => setTimeout(r, 80))
  return { port, origin: `http://127.0.0.1:${port}` }
}

function waitForOpen(ws: WebSocket): Promise<void> {
  return new Promise((resolve, reject) => {
    const t = setTimeout(() => reject(new Error('open timeout')), 3000)
    ws.once('open', () => {
      clearTimeout(t)
      resolve()
    })
    ws.once('error', (e) => {
      clearTimeout(t)
      reject(e)
    })
  })
}

function waitForMessage(ws: WebSocket, pred: (m: Record<string, unknown>) => boolean): Promise<Record<string, unknown>> {
  return new Promise((resolve, reject) => {
    const t = setTimeout(() => {
      ws.off('message', onMsg)
      reject(new Error('message timeout'))
    }, 5000)
    const onMsg = (raw: WebSocket.RawData): void => {
      let m: Record<string, unknown>
      try {
        m = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
      } catch {
        return
      }
      if (pred(m)) {
        clearTimeout(t)
        ws.off('message', onMsg)
        resolve(m)
      }
    }
    ws.on('message', onMsg)
  })
}

const isAttached = (m: Record<string, unknown>): boolean => m['type'] === 'attached'

afterEach(async () => {
  while (handles.length > 0) await handles.pop()?.close()
  await new Promise<void>((r) => setTimeout(r, 30))
})

const UNKNOWN = '00000000-0000-4000-8000-000000000000'

// ── GET /live-sessions/:id/events ────────────────────────────────────────────────

describe('GET /live-sessions/:id/events (A4)', () => {
  it('returns 404 for an unknown session id when the timeline is enabled', async () => {
    const { port } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/live-sessions/${UNKNOWN}/events`)
    expect(res.status).toBe(404)
  })

  it('returns an empty array when TIMELINE_ENABLED=0 (AC-A4.6)', async () => {
    const { port } = await spawnServer({ TIMELINE_ENABLED: '0' })
    const res = await fetch(`http://127.0.0.1:${port}/live-sessions/${UNKNOWN}/events`)
    expect(res.status).toBe(200)
    expect(await res.json()).toEqual([])
  })

  itPty('returns derived timeline entries after a /hook POST', async () => {
    const { port, origin } = await spawnServer()
    const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } })
    await waitForOpen(ws)
    ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
    const attached = await waitForMessage(ws, isAttached)
    const sessionId = attached['sessionId'] as string

    const hookRes = 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: 'PreToolUse', tool_name: 'Bash' }),
    })
    expect(hookRes.status).toBe(204)

    const evRes = await fetch(`http://127.0.0.1:${port}/live-sessions/${sessionId}/events`)
    expect(evRes.status).toBe(200)
    const events = (await evRes.json()) as { class: string; label: string; toolName?: string }[]
    expect(Array.isArray(events)).toBe(true)
    const tool = events.find((e) => e.class === 'tool')
    expect(tool).toBeDefined()
    expect(tool!.label).toContain('Bash')

    ws.close()
  })
})

// ── POST /hook/status (B2) ───────────────────────────────────────────────────────

describe('POST /hook/status (B2)', () => {
  it('rejects a non-object body with 400', async () => {
    const { port } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/hook/status`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': UNKNOWN },
      body: JSON.stringify('not-an-object'),
    })
    expect(res.status).toBe(400)
  })

  itPty('ingests telemetry from loopback and broadcasts it to the attached client', async () => {
    const { port, origin } = await spawnServer()
    const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } })
    await waitForOpen(ws)
    ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
    const attached = await waitForMessage(ws, isAttached)
    const sessionId = attached['sessionId'] as string

    const telemetryPromise = waitForMessage(ws, (m) => m['type'] === 'telemetry')

    const res = await fetch(`http://127.0.0.1:${port}/hook/status`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
      body: JSON.stringify({
        context_window: { used_percentage: 42 },
        cost: { total_cost_usd: 1.23 },
        model: { display_name: 'opus' },
      }),
    })
    expect(res.status).toBe(204)

    const msg = (await telemetryPromise) as { telemetry: Record<string, unknown> }
    expect(msg.telemetry['contextUsedPct']).toBe(42)
    expect(msg.telemetry['costUsd']).toBe(1.23)
    expect(msg.telemetry['model']).toBe('opus')

    ws.close()
  })
})

// ── GET /config/ui (review #4) ────────────────────────────────────────────────────

describe('GET /config/ui', () => {
  it('reports allowAutoMode:false by default', async () => {
    const { port } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({ allowAutoMode: false, maxFanoutLanes: 6 })
  })

  it('reports allowAutoMode:true when ALLOW_AUTO_MODE=1', async () => {
    const { port } = await spawnServer({ ALLOW_AUTO_MODE: '1' })
    const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
    expect(await res.json()).toEqual({ allowAutoMode: true, maxFanoutLanes: 6 })
  })

  it('omits costBudgetUsd when the budget is unset (W3 b)', async () => {
    const { port } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
    const body = (await res.json()) as Record<string, unknown>
    expect(body).not.toHaveProperty('costBudgetUsd')
  })

  it('reports costBudgetUsd when COST_BUDGET_USD is set (W3 b)', async () => {
    const { port } = await spawnServer({ COST_BUDGET_USD: '7.5' })
    const res = await fetch(`http://127.0.0.1:${port}/config/ui`)
    expect(await res.json()).toEqual({ allowAutoMode: false, costBudgetUsd: 7.5, maxFanoutLanes: 6 })
  })
})
