/**
 * test/integration/auth.test.ts — real-server integration for the optional
 * access-token gate (w5-access-token). Pattern mirrors integration/server.test.ts.
 *
 * Two axes:
 *   - DISABLED (no WEBTERM_TOKEN): everything works byte-identically to today —
 *     WS connects on Origin alone, state-changing routes gated only by Origin.
 *   - ENABLED: the gate + WS cookie check activate ALONGSIDE (never replacing)
 *     the Origin/CSRF model.
 *
 * All traffic here is loopback (127.0.0.1). The gate's loopback bypass is scoped
 * to the /hook* ingest paths only, so non-hook routes are genuinely gated even
 * from 127.0.0.1 — which is what makes the "unauthed → 401/302" cases testable.
 *
 * Each test gets its own port + server (beforeEach) so the per-IP auth rate
 * limiter never leaks across cases.
 */

import net from 'node:net'

import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import WebSocket from 'ws'

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

const TOKEN = 'integration-secret-token-01' // ≥16, safe charset
const COOKIE = `webterm_auth=${TOKEN}`

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 config, optionally with WEBTERM_TOKEN set (auth enabled). */
function makeCfg(port: number, withToken: boolean): Config {
  const env: Record<string, string> = {
    PORT: String(port),
    BIND_HOST: '127.0.0.1',
    SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
    ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
    SCROLLBACK_BYTES: String(64 * 1024),
    IDLE_TTL: '86400',
    USE_TMUX: '0',
  }
  if (withToken) env['WEBTERM_TOKEN'] = TOKEN
  return loadConfig(env)
}

/** Resolve iff the WS opens; reject if it errors/closes before open. */
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'))
    })
  })
}

// ── DISABLED: zero behaviour change (the regression proof) ──────────────────────
describe('access-token gate — DISABLED (WEBTERM_TOKEN unset)', () => {
  let port: number
  let handle: { close(): Promise<void> }

  beforeEach(async () => {
    port = await getFreePort()
    handle = startServer(makeCfg(port, false))
    await new Promise<void>((r) => setTimeout(r, 80))
  })
  afterEach(async () => {
    await handle.close()
    await new Promise<void>((r) => setTimeout(r, 50))
  })

  it('WS handshake succeeds with a valid Origin and NO cookie (unchanged)', async () => {
    const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, {
      headers: { Origin: `http://127.0.0.1:${port}` },
    })
    await expect(waitForOpen(ws)).resolves.toBeUndefined()
    ws.terminate()
  })

  it('GET /live-sessions is open (no cookie required)', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/live-sessions`)
    expect(res.status).toBe(200)
    expect(Array.isArray(await res.json())).toBe(true)
  })

  it('GET / serves the app (200, no login redirect)', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/`, {
      headers: { Accept: 'text/html' },
      redirect: 'manual',
    })
    expect(res.status).toBe(200)
  })

  it('DELETE /live-sessions works with a valid Origin and no cookie (Origin-only, as today)', 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)
  })

  it('DELETE /live-sessions still 403s a foreign Origin (CSRF layer intact)', 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)
  })
})

// ── ENABLED: the gate + WS cookie check activate ────────────────────────────────
describe('access-token gate — ENABLED (WEBTERM_TOKEN set)', () => {
  let port: number
  let handle: { close(): Promise<void> }

  beforeEach(async () => {
    port = await getFreePort()
    handle = startServer(makeCfg(port, true))
    await new Promise<void>((r) => setTimeout(r, 80))
  })
  afterEach(async () => {
    await handle.close()
    await new Promise<void>((r) => setTimeout(r, 50))
  })

  // ── WS handshake ──────────────────────────────────────────────────────────
  it('WS: valid Origin + NO cookie → handshake rejected (401)', async () => {
    const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, {
      headers: { Origin: `http://127.0.0.1:${port}` },
    })
    await expect(waitForOpen(ws)).rejects.toThrow()
    ws.terminate()
  })

  it('WS: valid Origin + WRONG cookie → handshake rejected (401)', async () => {
    const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, {
      headers: { Origin: `http://127.0.0.1:${port}`, Cookie: 'webterm_auth=wrong-value-000000' },
    })
    await expect(waitForOpen(ws)).rejects.toThrow()
    ws.terminate()
  })

  it('WS: valid Origin + VALID cookie → handshake accepted (opens)', async () => {
    const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, {
      headers: { Origin: `http://127.0.0.1:${port}`, Cookie: COOKIE },
    })
    await expect(waitForOpen(ws)).resolves.toBeUndefined()
    ws.terminate()
  })

  it('WS: the Origin check still runs FIRST — bad Origin + valid cookie → 401 (additive, not replacing)', async () => {
    const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, {
      headers: { Origin: 'http://evil.example', Cookie: COOKIE },
    })
    await expect(waitForOpen(ws)).rejects.toThrow()
    ws.terminate()
  })

  // ── POST /auth ────────────────────────────────────────────────────────────
  it('POST /auth with the WRONG token → 401 JSON (XHR)', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/auth`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ token: 'nope-nope-nope-nope-00' }),
    })
    expect(res.status).toBe(401)
  })

  it('POST /auth with the CORRECT token (form) → 302 + Set-Cookie with HttpOnly/SameSite/Path/Max-Age, NO Secure over http', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/auth`, {
      method: 'POST',
      headers: { 'content-type': 'application/x-www-form-urlencoded', Accept: 'text/html' },
      body: `token=${encodeURIComponent(TOKEN)}`,
      redirect: 'manual',
    })
    expect(res.status).toBe(302)
    const setCookie = res.headers.get('set-cookie') ?? ''
    expect(setCookie).toContain(`webterm_auth=${TOKEN}`)
    expect(setCookie).toContain('HttpOnly')
    expect(setCookie).toContain('SameSite=Strict')
    expect(setCookie).toContain('Path=/')
    expect(setCookie).toContain('Max-Age=')
    expect(setCookie).not.toContain('Secure')
  })

  it('POST /auth with the CORRECT token (XHR) → 204 + Set-Cookie', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/auth`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ token: TOKEN }),
    })
    expect(res.status).toBe(204)
    expect(res.headers.get('set-cookie') ?? '').toContain('webterm_auth=')
  })

  it('POST /auth over x-forwarded-proto: https → Set-Cookie INCLUDES Secure', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/auth`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', 'x-forwarded-proto': 'https' },
      body: JSON.stringify({ token: TOKEN }),
    })
    expect(res.status).toBe(204)
    expect(res.headers.get('set-cookie') ?? '').toContain('Secure')
  })

  it('POST /auth is rate-limited: >10 bad attempts/min → 429', async () => {
    let saw429 = false
    for (let i = 0; i < 12; i++) {
      const res = await fetch(`http://127.0.0.1:${port}/auth`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ token: 'bad-attempt-value-000' }),
      })
      if (res.status === 429) saw429 = true
    }
    expect(saw429).toBe(true)
  })

  // ── GET /?token= bootstrap ──────────────────────────────────────────────────
  it('GET /?token=<valid> → 302 to a path with NO token + Set-Cookie', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/?token=${encodeURIComponent(TOKEN)}`, {
      redirect: 'manual',
    })
    expect(res.status).toBe(302)
    const loc = res.headers.get('location') ?? ''
    expect(loc).not.toContain('token')
    expect(res.headers.get('set-cookie') ?? '').toContain('webterm_auth=')
  })

  it('GET /?token=<invalid> → 302 to /login, NO cookie', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/?token=wrong-token-value-00`, {
      redirect: 'manual',
    })
    expect(res.status).toBe(302)
    expect(res.headers.get('location')).toBe('/login')
    expect(res.headers.get('set-cookie')).toBeNull()
  })

  // ── /login reachable + unauthed routing ─────────────────────────────────────
  it('GET /login → 200 HTML, reachable while unauthed', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/login`)
    expect(res.status).toBe(200)
    expect(res.headers.get('content-type') ?? '').toContain('text/html')
    expect(await res.text()).toContain('<form')
  })

  it('GET /login?e=1 → the error banner is revealed (show-error)', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/login?e=1`)
    expect(await res.text()).toContain('show-error')
  })

  it('unauthed HTML navigation (GET /, no cookie) → 302 /login', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/`, {
      headers: { Accept: 'text/html' },
      redirect: 'manual',
    })
    expect(res.status).toBe(302)
    expect(res.headers.get('location')).toBe('/login')
  })

  it('unauthed XHR (GET /live-sessions, no cookie) → 401 JSON', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/live-sessions`)
    expect(res.status).toBe(401)
    expect(await res.json()).toEqual({ error: 'authentication required' })
  })

  it('authed XHR (GET /live-sessions with valid cookie) → 200', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
      headers: { Cookie: COOKIE },
    })
    expect(res.status).toBe(200)
    expect(Array.isArray(await res.json())).toBe(true)
  })

  // ── Gate + CSRF stacking on a state-changing route ──────────────────────────
  it('DELETE /live-sessions, valid Origin, NO cookie → 401 (gate blocks before CSRF)', 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(401)
  })

  it('DELETE /live-sessions, valid Origin + valid cookie → 200 (passes gate, then CSRF)', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
      method: 'DELETE',
      headers: { Origin: `http://127.0.0.1:${port}`, Cookie: COOKIE },
    })
    expect(res.status).toBe(200)
  })

  it('DELETE /live-sessions, valid cookie but FOREIGN Origin → 403 (CSRF layer still independent)', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
      method: 'DELETE',
      headers: { Origin: 'http://evil.example', Cookie: COOKIE },
    })
    expect(res.status).toBe(403)
  })

  // ── Loopback hook ingest bypass (guard against regressing the side-channel) ──
  it('POST /hook from loopback with NO cookie still returns 204 (hooks unaffected)', 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)
  })
})
