/**
 * Integration test for GET /live-sessions/grouped (W5 fan-out board) and the
 * /config/ui maxFanoutLanes field.
 *
 * Starts a real HTTP server. The grouped route is read-only (no Origin guard) and
 * derives groups purely from cwd — the empty-manager path needs no PTY. The
 * real-PTY case (two sessions under one *-worktrees dir clustering into one group)
 * is itPty-gated: posix_spawn is blocked in the sandbox, so it auto-skips there
 * and runs in CI.
 */

import net from 'node:net'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'

import { afterAll, afterEach, beforeAll, 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 { SessionGroup, UiConfig } from '../../src/types.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('unexpected address type'))
        return
      }
      const port = addr.port
      srv.close(() => resolve(port))
    })
    srv.on('error', reject)
  })
}

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)
    })
  })
}

/** Resolve once an 'attached' frame arrives (output frames may interleave). */
function waitForAttached(ws: WebSocket, timeoutMs = 4_000): Promise<void> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('attached timeout')), timeoutMs)
    const onMsg = (raw: WebSocket.RawData): void => {
      let msg: { type?: string }
      try {
        msg = JSON.parse(raw.toString('utf8'))
      } catch {
        return
      }
      if (msg.type === 'attached') {
        clearTimeout(timer)
        ws.off('message', onMsg)
        resolve()
      }
    }
    ws.on('message', onMsg)
  })
}

describe('GET /live-sessions/grouped + /config/ui maxFanoutLanes — integration', () => {
  let port: number
  let serverHandle: { close(): Promise<void> }
  const origin = (): string => `http://127.0.0.1:${port}`

  beforeAll(async () => {
    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',
      MAX_FANOUT_LANES: '5',
    })
    serverHandle = startServer(cfg)
    await new Promise<void>((r) => setTimeout(r, 100))
  })

  afterAll(async () => {
    await serverHandle.close()
  })

  it('returns 200 [] on an empty manager, WITHOUT requiring an Origin header', async () => {
    const res = await fetch(`${origin()}/live-sessions/grouped`)
    expect(res.status).toBe(200) // read-only: not 403 even with no Origin
    const body = (await res.json()) as SessionGroup[]
    expect(Array.isArray(body)).toBe(true)
    expect(body).toHaveLength(0)
  })

  it('does not shadow "grouped" as a session :id (returns an array, not an error)', async () => {
    const res = await fetch(`${origin()}/live-sessions/grouped`)
    const body = await res.json()
    expect(Array.isArray(body)).toBe(true)
  })

  it('exposes maxFanoutLanes on /config/ui', async () => {
    const res = await fetch(`${origin()}/config/ui`)
    expect(res.status).toBe(200)
    const body = (await res.json()) as UiConfig
    expect(body.maxFanoutLanes).toBe(5)
  })

  // ── real-PTY: two sessions under one *-worktrees dir cluster into one group ──
  let tmpRepo: string | null = null
  const sockets: WebSocket[] = []

  afterEach(() => {
    for (const ws of sockets.splice(0)) {
      try {
        ws.close()
      } catch {
        // ignore
      }
    }
  })

  itPty('clusters two worktree-lane sessions into a single group', async () => {
    tmpRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'fanout-'))
    const laneDir = path.join(tmpRepo, 'proj-worktrees')
    const lane1 = path.join(laneDir, 'lane-1')
    const lane2 = path.join(laneDir, 'lane-2')
    await fs.mkdir(lane1, { recursive: true })
    await fs.mkdir(lane2, { recursive: true })

    for (const cwd of [lane1, lane2]) {
      const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { origin: origin() })
      sockets.push(ws)
      await waitForOpen(ws)
      ws.send(JSON.stringify({ type: 'attach', sessionId: null, cwd }))
      await waitForAttached(ws)
    }
    // Give the sessions a beat to register their cwd.
    await new Promise<void>((r) => setTimeout(r, 100))

    const res = await fetch(`${origin()}/live-sessions/grouped`)
    const groups = (await res.json()) as SessionGroup[]
    const proj = groups.find((g) => g.repoRoot === path.join(tmpRepo!, 'proj'))
    expect(proj).toBeDefined()
    expect(proj!.label).toBe('proj')
    expect(proj!.sessions.length).toBe(2)
  })
})
