/**
 * T-server-wire — integration tests for the A1 push / lock-screen-decision wiring.
 *
 * Covers (against a real startServer):
 *   - GET  /push/vapid-key       → 503 when VAPID unset, {publicKey} when set
 *   - POST /push/subscribe       → Origin guard (403), 204 on success, 400 bad body,
 *                                   per-IP rate limit (429)
 *   - DELETE /push/subscribe     → Origin guard (403), 204 on success
 *   - POST /hook/decision        → Origin guard (403), bad/stale token (403),
 *                                   missing fields (400), rate limit (429), and the
 *                                   positive token path resolving a held approval
 *   - C1 hold gate (SEC-C2)      → zero clients but ≥1 subscription → /hook/permission HELD
 *   - WS approve mode (SEC-M5)   → ALLOW_AUTO_MODE gate downgrades mode:'auto' → 'default'
 *
 * web-push is mocked so the push fan-out "succeeds" and we can read the signed
 * payload to recover the per-decision capability token for the positive path.
 */

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

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

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

// ── web-push mock (captures every signed payload string) ───────────────────────
const { sentPayloads } = vi.hoisted(() => ({ sentPayloads: [] as string[] }))
vi.mock('web-push', () => ({
  default: {
    setVapidDetails: (): void => {},
    sendNotification: async (_sub: unknown, payload: string): Promise<void> => {
      sentPayloads.push(payload)
    },
  },
}))

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

// ── helpers ────────────────────────────────────────────────────────────────────

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> }[] = []
const tmpFiles: string[] = []

async function spawnServer(
  overrides: Record<string, string | undefined> = {},
): Promise<{ port: number; origin: string }> {
  const port = await getFreePort()
  const storePath = path.join(os.tmpdir(), `webterm-push-${port}-${Date.now()}.json`)
  tmpFiles.push(storePath)
  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',
    PUSH_STORE_PATH: storePath,
    ...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}` }
}

const VAPID_ON = {
  VAPID_PUBLIC_KEY: 'test-public-key',
  VAPID_PRIVATE_KEY: 'test-private-key',
  VAPID_SUBJECT: 'mailto:test@localhost',
}

function validSubscriptionBody(endpoint = 'https://push.example/ep-1'): string {
  return JSON.stringify({ endpoint, keys: { p256dh: 'p256dh-value', auth: 'auth-value' } })
}

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()
  for (const f of tmpFiles.splice(0)) await fs.rm(f, { force: true }).catch(() => undefined)
  sentPayloads.length = 0
  await new Promise<void>((r) => setTimeout(r, 30))
})

// ── GET /push/vapid-key ─────────────────────────────────────────────────────────

describe('GET /push/vapid-key', () => {
  it('returns 503 when VAPID is not configured', async () => {
    const { port } = await spawnServer()
    const res = await fetch(`http://127.0.0.1:${port}/push/vapid-key`)
    expect(res.status).toBe(503)
  })

  it('returns the public key when VAPID is configured', async () => {
    const { port } = await spawnServer(VAPID_ON)
    const res = await fetch(`http://127.0.0.1:${port}/push/vapid-key`)
    expect(res.status).toBe(200)
    const body = (await res.json()) as { publicKey?: string }
    expect(body.publicKey).toBe('test-public-key')
  })
})

// ── POST/DELETE /push/subscribe ──────────────────────────────────────────────────

describe('POST /push/subscribe', () => {
  it('rejects a foreign Origin with 403 (SEC-C4)', async () => {
    const { port } = await spawnServer(VAPID_ON)
    const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
      body: validSubscriptionBody(),
    })
    expect(res.status).toBe(403)
  })

  it('rejects a missing Origin with 403 (default-deny)', async () => {
    const { port } = await spawnServer(VAPID_ON)
    const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: validSubscriptionBody(),
    })
    expect(res.status).toBe(403)
  })

  it('stores a valid subscription with 204 for an allowed Origin', async () => {
    const { port, origin } = await spawnServer(VAPID_ON)
    const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: validSubscriptionBody(),
    })
    expect(res.status).toBe(204)
  })

  it('rejects an invalid subscription body with 400', async () => {
    const { port, origin } = await spawnServer(VAPID_ON)
    const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({ endpoint: '' }),
    })
    expect(res.status).toBe(400)
  })

  it('rate-limits more than 5 subscribe calls/min from one IP (429, SEC-H9)', async () => {
    const { port, origin } = await spawnServer(VAPID_ON)
    const codes: number[] = []
    for (let i = 0; i < 6; i++) {
      const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Origin: origin },
        body: validSubscriptionBody(`https://push.example/ep-${i}`),
      })
      codes.push(res.status)
    }
    expect(codes.slice(0, 5).every((c) => c === 204)).toBe(true)
    expect(codes[5]).toBe(429)
  })
})

describe('DELETE /push/subscribe', () => {
  it('rejects a foreign Origin with 403', async () => {
    const { port } = await spawnServer(VAPID_ON)
    const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
      body: JSON.stringify({ endpoint: 'https://push.example/ep-1' }),
    })
    expect(res.status).toBe(403)
  })

  it('removes a subscription with 204 for an allowed Origin', async () => {
    const { port, origin } = await spawnServer(VAPID_ON)
    await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: validSubscriptionBody(),
    })
    const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({ endpoint: 'https://push.example/ep-1' }),
    })
    expect(res.status).toBe(204)
  })
})

// ── POST /hook/decision (security paths) ─────────────────────────────────────────

describe('POST /hook/decision — guards (SEC-C1/M1/H9)', () => {
  const sid = '00000000-0000-4000-8000-000000000000'

  it('rejects a foreign Origin with 403', async () => {
    const { port } = await spawnServer(VAPID_ON)
    const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
      body: JSON.stringify({ sessionId: sid, decision: 'allow', token: 'x' }),
    })
    expect(res.status).toBe(403)
  })

  it('rejects a missing Origin with 403', async () => {
    const { port } = await spawnServer(VAPID_ON)
    const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ sessionId: sid, decision: 'allow', token: 'x' }),
    })
    expect(res.status).toBe(403)
  })

  it('rejects an unknown/stale token with 403', async () => {
    const { port, origin } = await spawnServer(VAPID_ON)
    const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({ sessionId: sid, decision: 'allow', token: 'not-a-real-token' }),
    })
    expect(res.status).toBe(403)
  })

  it('rejects a malformed body with 400', async () => {
    const { port, origin } = await spawnServer(VAPID_ON)
    const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Origin: origin },
      body: JSON.stringify({ sessionId: sid }),
    })
    expect(res.status).toBe(400)
  })

  it('rate-limits more than 10 decision calls/min from one IP (429)', async () => {
    const { port, origin } = await spawnServer(VAPID_ON)
    const codes: number[] = []
    for (let i = 0; i < 11; i++) {
      const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Origin: origin },
        body: JSON.stringify({ sessionId: sid, decision: 'allow', token: 'x' }),
      })
      codes.push(res.status)
    }
    // First 10 hit the handler (403 stale token); the 11th is rate-limited.
    expect(codes.slice(0, 10).every((c) => c === 403)).toBe(true)
    expect(codes[10]).toBe(429)
  })
})

// ── C1 hold gate + decision positive path (needs real PTY) ──────────────────────

describe('C1 hold gate + /hook/decision positive path', () => {
  itPty(
    'holds a permission with zero clients but ≥1 subscription, then resolves via /hook/decision',
    async () => {
      const { port, origin } = await spawnServer({ ...VAPID_ON, PERM_TIMEOUT_MS: '4000' })

      // 1. Register a push subscription (so push is "enabled and has a target").
      await fetch(`http://127.0.0.1:${port}/push/subscribe`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Origin: origin },
        body: validSubscriptionBody(),
      })

      // 2. Create a session via WS, then detach (zero clients, PTY alive).
      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
      ws.close()
      await new Promise<void>((r) => setTimeout(r, 150))

      // 3. Fire the held permission hook (loopback). Do NOT await it.
      sentPayloads.length = 0
      let resolved = false
      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' }),
      }).then(async (r) => {
        resolved = true
        return (await r.json()) as { hookSpecificOutput?: { decision?: { behavior?: string } } }
      })

      // 4. The push must have fired (carrying the capability token) and the hook
      //    must still be HELD (C1: it did not immediately fall through to {}).
      await new Promise<void>((r) => setTimeout(r, 250))
      expect(sentPayloads.length).toBe(1)
      expect(resolved).toBe(false)
      const token = (JSON.parse(sentPayloads[0]!) as { token?: string }).token
      expect(typeof token).toBe('string')

      // 5. Resolve it from a "remote" device via /hook/decision with the token.
      const decRes = await fetch(`http://127.0.0.1:${port}/hook/decision`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Origin: origin },
        body: JSON.stringify({ sessionId, decision: 'allow', token }),
      })
      expect(decRes.status).toBe(204)

      const decision = await permPromise
      expect(decision.hookSpecificOutput?.decision?.behavior).toBe('allow')
    },
  )
})

// ── WS approve mode + ALLOW_AUTO_MODE gate (needs real PTY) ──────────────────────

describe('WS approve with permission mode (SEC-M5)', () => {
  async function approveWithMode(
    autoAllowed: boolean,
    mode: string,
  ): Promise<{ behavior?: string; mode?: string }> {
    const { port, origin } = await spawnServer({ ALLOW_AUTO_MODE: autoAllowed ? '1' : '0' })
    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 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: 'ExitPlanMode' }),
    })
    await waitForMessage(ws, (m) => m['pending'] === true)
    ws.send(JSON.stringify({ type: 'approve', mode }))
    const decision = (await (await permPromise).json()) as {
      hookSpecificOutput?: { decision?: { behavior?: string; mode?: string } }
    }
    ws.close()
    return decision.hookSpecificOutput?.decision ?? {}
  }

  itPty('keeps mode:auto when ALLOW_AUTO_MODE=1', async () => {
    const d = await approveWithMode(true, 'auto')
    expect(d.behavior).toBe('allow')
    expect(d.mode).toBe('auto')
  })

  itPty('downgrades mode:auto → default when ALLOW_AUTO_MODE=0', async () => {
    const d = await approveWithMode(false, 'auto')
    expect(d.behavior).toBe('allow')
    expect(d.mode).toBe('default')
  })

  itPty('passes through mode:acceptEdits unchanged', async () => {
    const d = await approveWithMode(false, 'acceptEdits')
    expect(d.behavior).toBe('allow')
    expect(d.mode).toBe('acceptEdits')
  })
})
