import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import type { AddressInfo } from 'node:net'
import type { AdapterStatus, Delta, HookControlsIssue, Item, Panel } from '../src/lib/collector-types'
import type { RoutingProvider, RoutingRules } from '../src/lib/collector-client'

export interface FixtureCollectorState {
  panels: Panel[]
  adapters: AdapterStatus[]
  items: Item[]
  projectColors?: Record<string, string>
  hookControls?: {
    enabled: boolean
    issue?: HookControlsIssue | null
    persistence?: 'indeterminate'
    persistenceDetail?: string
  }
  failNextHookControlToggle?: boolean
  delayNextHookControlToggleMs?: number
}

export interface DecisionAnswerCall {
  id: string
  body: Record<string, unknown>
}

export interface ActionCall {
  verb: string
  body: Record<string, unknown>
}

export interface FixtureCollector {
  url: string
  token: string
  /** Recorded POST /decisions/:id/answer bodies in arrival order. */
  answerCalls: DecisionAnswerCall[]
  /** Recorded POST /actions/:verb bodies in arrival order. */
  actionCalls: ActionCall[]
  /** When set, the next answer for this item id returns HTTP 500. */
  failAnswerFor(id: string): void
  failNextHookControlToggle(): void
  delayNextHookControlToggle(delayMs: number): void
  resetHookControls(): void
  /** Delays the next matching action response while still recording its request. */
  delayNextAction(verb: string, delayMs: number): void
  /** Pushes an SSE frame to every currently-connected `/events` client. */
  emit(delta: Delta): void
  /** Replaces fixture responses for subsequent requests. */
  replaceState(nextState: FixtureCollectorState): void
  /** Resolves once at least one `/events` client is connected, or rejects after timeoutMs. */
  waitForClient(timeoutMs?: number): Promise<void>
  close(): Promise<void>
}

const TOKEN = 'overdeck-dev'
const DEFAULT_PORT = 14980
const fixtureRoutingRules = (account: string): RoutingRules => ({
  version: 'routing/v2',
  projects: {},
  default: account,
  fallback_chain: [account],
  fallback_trigger: 'broken_or_quota_exhausted',
  missing_health_is_available: false,
  quota_exhausted_threshold_pct: 95,
  account_caps: {},
})
const ALLOWED_ACTION_VERBS = new Set([
  'reap',
  'ci-rerun',
  'steer',
  'snooze',
  'decision',
  'ci.rerunFailed',
  'ci.cancelRun',
  'sessions.sendKeys',
])

function orphanCandidatePids(items: Item[]): Set<number> {
  const pids = new Set<number>()
  for (const item of items) {
    for (const action of item.actions) {
      if (action.verb !== 'reap') continue
      const raw = action.args.pid
      if (raw && /^\d+$/.test(raw)) pids.add(Number(raw))
    }
  }
  return pids
}

function ciRunIds(panels: Panel[]): Set<number> {
  const panel = panels.find((entry) => entry.id === 'ci')
  if (!panel) return new Set()
  const data = panel.data as { repos?: Array<{ runs?: Array<{ id: number }> }> }
  const ids = new Set<number>()
  for (const repo of data.repos ?? []) {
    for (const run of repo.runs ?? []) ids.add(run.id)
  }
  return ids
}

function eligibleTrainAction(
  panels: Panel[],
  verb: string,
  args: Record<string, string>,
): boolean {
  if (Object.keys(args).sort().join(',') !== 'repo,runId') return false
  const panel = panels.find((entry) => entry.id === 'ci')
  const data = panel?.data as {
    repos?: Array<{
      repo?: string
      refsComplete?: boolean
      trainRunsComplete?: boolean
      prsComplete?: boolean
      trainsComplete?: boolean
      trains?: Array<{
        repo?: string
        state?: string | null
        gateRun?: { id?: number; status?: string } | null
      }>
    }>
  } | undefined
  const repo = data?.repos?.find((entry) => entry.repo === args.repo)
  if (!repo?.refsComplete || !repo.trainRunsComplete || !repo.prsComplete || !repo.trainsComplete) return false
  const runId = Number(args.runId)
  if (!Number.isSafeInteger(runId) || runId <= 0 || String(runId) !== args.runId) return false
  const train = repo.trains?.find((entry) => entry.gateRun?.id === runId)
  if (verb === 'ci.rerunFailed') return train?.state === 'failed'
  return (train?.state === 'gating' || train?.state === 'rerunning')
    && (train.gateRun?.status === 'queued' || train.gateRun?.status === 'in_progress')
}

function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = []
    req.on('data', (chunk) => chunks.push(Buffer.from(chunk)))
    req.on('end', () => {
      try {
        resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<string, unknown>)
      } catch (err) {
        reject(err)
      }
    })
    req.on('error', reject)
  })
}

/**
 * Standalone Node http server for Playwright tests — mirrors collector/src/server.ts's
 * contract (Bearer auth, GET /state, GET /items?kind=, GET /events SSE) without depending
 * on Bun or the real collector package, which sits outside the pnpm workspace glob.
 */
export function startFixtureCollector(initialState: FixtureCollectorState, port = DEFAULT_PORT): Promise<FixtureCollector> {
  const state: FixtureCollectorState = { ...initialState }
  const sseClients = new Set<ServerResponse>()
  const answerCalls: DecisionAnswerCall[] = []
  const actionCalls: ActionCall[] = []
  const failIds = new Set<string>()
  const actionDelays = new Map<string, number>()
  let projectColors = { ...(state.projectColors ?? {}) }
  let hookControls = state.hookControls ?? { enabled: true, issue: null }
  let failNextHookControlToggle = state.failNextHookControlToggle ?? false
  let delayNextHookControlToggleMs = state.delayNextHookControlToggleMs ?? 0
  const hookControlsResponse = () => ({
    controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': hookControls.enabled } },
    issue: hookControls.issue ?? null,
    ...(hookControls.persistence === 'indeterminate'
      ? { persistence: 'indeterminate' as const, persistenceDetail: hookControls.persistenceDetail ?? 'fixture indeterminate persistence' }
      : {}),
  })

  function emit(delta: Delta) {
    const payload = `data: ${JSON.stringify(delta)}\n\n`
    for (const client of sseClients) client.write(payload)
  }

  function isAuthorized(req: IncomingMessage): boolean {
    return req.headers.authorization === `Bearer ${TOKEN}`
  }

  const server = createServer((req, res) => {
    if (!isAuthorized(req)) {
      res.writeHead(401)
      res.end('unauthorized')
      return
    }

    const url = new URL(req.url ?? '/', 'http://fixture-collector.internal')

    if (url.pathname === '/state' && req.method === 'GET') {
      res.writeHead(200, { 'content-type': 'application/json' })
      res.end(JSON.stringify({ panels: state.panels, adapters: state.adapters }))
      return
    }

    if (url.pathname === '/items' && req.method === 'GET') {
      const kind = url.searchParams.get('kind')
      const items = kind ? state.items.filter((item) => item.kind === kind) : state.items
      res.writeHead(200, { 'content-type': 'application/json' })
      res.end(JSON.stringify({ items }))
      return
    }

    if (url.pathname === '/hooks' && req.method === 'GET') {
      res.writeHead(200, { 'content-type': 'application/json' })
      res.end(JSON.stringify({ generatedAt: new Date().toISOString(), sources: [], sourceErrors: [], hooks: [] }))
      return
    }

    if (url.pathname === '/config/hooks' && req.method === 'GET') {
      res.writeHead(200, { 'content-type': 'application/json' })
      res.end(JSON.stringify(hookControlsResponse()))
      return
    }

    if (url.pathname === '/config/hooks/background-jobs-blocker' && req.method === 'POST') {
      void readJsonBody(req).then((body) => {
        if (typeof body.enabled !== 'boolean' || Object.keys(body).length !== 1) { res.writeHead(400); res.end('bad shape'); return }
        const respond = () => {
          hookControls = { enabled: body.enabled as boolean, issue: null }
          res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(hookControlsResponse()))
        }
        if (failNextHookControlToggle) {
          failNextHookControlToggle = false
          res.writeHead(500, { 'content-type': 'application/json' })
          res.end(JSON.stringify({ error: 'hook-controls-write-failed', detail: 'forced failure' }))
          return
        }
        const delayMs = delayNextHookControlToggleMs
        delayNextHookControlToggleMs = 0
        if (delayMs > 0) setTimeout(respond, delayMs)
        else respond()
      }).catch(() => { res.writeHead(400); res.end('bad json') })
      return
    }

    if (url.pathname === '/config/hooks/repair' && req.method === 'POST') {
      void readJsonBody(req).then((body) => {
        if (body.confirm !== 'replace-invalid-config' || Object.keys(body).length !== 1) { res.writeHead(400); res.end('bad shape'); return }
        hookControls = { enabled: true, issue: null }
        res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(hookControlsResponse()))
      }).catch(() => { res.writeHead(400); res.end('bad json') })
      return
    }

    const routingMatch = url.pathname.match(/^\/config\/routing\/(codex|claude)$/)
    if (routingMatch && req.method === 'GET') {
      const provider = routingMatch[1] as RoutingProvider
      const account = `${provider}-fixture`
      res.writeHead(200, { 'content-type': 'application/json' })
      res.end(JSON.stringify({ provider, accounts: [account], rules: fixtureRoutingRules(account) }))
      return
    }

    if (url.pathname === '/config/projects' && req.method === 'GET') {
      res.writeHead(200, { 'content-type': 'application/json' })
      res.end(JSON.stringify({ projects: projectColors }))
      return
    }

    if (url.pathname === '/config/projects' && req.method === 'POST') {
      void readJsonBody(req)
        .then((body) => {
          const projects = body.projects
          if (!projects || typeof projects !== 'object' || Array.isArray(projects)) {
            res.writeHead(400)
            res.end('bad shape')
            return
          }
          for (const [key, value] of Object.entries(projects as Record<string, unknown>)) {
            if (typeof key !== 'string' || typeof value !== 'string') continue
            projectColors[key] = value
          }
          state.projectColors = projectColors
          res.writeHead(200, { 'content-type': 'application/json' })
          res.end(JSON.stringify({ ok: true, projects: projectColors }))
        })
        .catch(() => {
          res.writeHead(400)
          res.end('bad json')
        })
      return
    }

    if (url.pathname === '/digest' && req.method === 'GET') {
      res.writeHead(200, { 'content-type': 'application/json' })
      res.end(
        JSON.stringify({
          day: '2026-07-17',
          paragraph: 'Fixture overnight digest: 0 runs, scoreboard unchanged, 0 new failures, 0 decisions waiting.',
        }),
      )
      return
    }

    if (url.pathname === '/events' && req.method === 'GET') {
      res.writeHead(200, {
        'content-type': 'text/event-stream',
        'cache-control': 'no-cache',
        connection: 'keep-alive',
      })
      res.write(': connected\n\n')
      sseClients.add(res)
      req.on('close', () => sseClients.delete(res))
      return
    }

    const hostLogsMatch = /^\/hosts\/([^/]+)\/logs$/.exec(url.pathname)
    if (hostLogsMatch && req.method === 'GET') {
      const host = decodeURIComponent(hostLogsMatch[1]!)
      const lines =
        host === 'debian1'
          ? [
              '2026-07-21T10:00:00Z builder ready',
              '2026-07-21T10:01:00Z dispatch ok',
            ]
          : []
      res.writeHead(200, { 'content-type': 'application/json' })
      res.end(JSON.stringify({ lines }))
      return
    }

    const sessionScreenMatch = /^\/sessions\/([^/]+)\/screen$/.exec(url.pathname)
    if (sessionScreenMatch && req.method === 'GET') {
      const id = decodeURIComponent(sessionScreenMatch[1]!)
      res.writeHead(200, { 'content-type': 'application/json' })
      res.end(
        JSON.stringify({
          ok: true,
          screen: `fixture screen for ${id}\n> waiting`,
          capturedAt: '2026-08-07T02:00:00.000Z',
        }),
      )
      return
    }

    const answerMatch = /^\/decisions\/([^/]+)\/answer$/.exec(url.pathname)
    if (answerMatch && req.method === 'POST') {
      void readJsonBody(req)
        .then((body) => {
          const id = decodeURIComponent(answerMatch[1]!)
          answerCalls.push({ id, body })
          if (failIds.delete(id)) {
            res.writeHead(500, { 'content-type': 'application/json' })
            res.end(JSON.stringify({ error: 'forced failure' }))
            return
          }
          state.items = state.items.filter((item) => item.id !== id)
          emit({ type: 'item-resolved', id })
          res.writeHead(200, { 'content-type': 'application/json' })
          res.end(JSON.stringify({ ok: true }))
        })
        .catch(() => {
          res.writeHead(400)
          res.end('bad json')
        })
      return
    }

    const actionMatch = /^\/actions\/([^/]+)$/.exec(url.pathname)
    if (actionMatch && req.method === 'POST') {
      const verb = decodeURIComponent(actionMatch[1]!)
      if (!ALLOWED_ACTION_VERBS.has(verb)) {
        res.writeHead(404)
        res.end('not found')
        return
      }
      void readJsonBody(req)
        .then((body) => {
          actionCalls.push({ verb, body })
          const args = (body.args ?? {}) as Record<string, string>
          if (verb === 'reap') {
            const pid = Number(args.pid)
            if (!Number.isInteger(pid) || !orphanCandidatePids(state.items).has(pid)) {
              res.writeHead(400, { 'content-type': 'application/json' })
              res.end(JSON.stringify({ ok: false, error: 'pid is not an orphan candidate' }))
              return
            }
          }
          if (verb === 'ci-rerun') {
            const runId = Number(args.id)
            if (!Number.isInteger(runId) || !ciRunIds(state.panels).has(runId)) {
              res.writeHead(400, { 'content-type': 'application/json' })
              res.end(JSON.stringify({ ok: false, error: 'run id is not in the ci panel' }))
              return
            }
          }
          if ((verb === 'ci.rerunFailed' || verb === 'ci.cancelRun') && !eligibleTrainAction(state.panels, verb, args)) {
            res.writeHead(400, { 'content-type': 'application/json' })
            res.end(JSON.stringify({ error: 'train action is not eligible' }))
            return
          }
          if (verb === 'decision') {
            const requestedBy = typeof body.requestedBy === 'string' ? body.requestedBy : ''
            state.items = state.items.filter((item) => item.id !== requestedBy)
            if (requestedBy) emit({ type: 'item-resolved', id: requestedBy })
          }
          const delayMs = actionDelays.get(verb) ?? 0
          actionDelays.delete(verb)
          setTimeout(() => {
            res.writeHead(200, { 'content-type': 'application/json' })
            res.end(JSON.stringify({ ok: true, result: 'mock executor ok' }))
          }, delayMs)
        })
        .catch(() => {
          res.writeHead(400)
          res.end('bad json')
        })
      return
    }

    res.writeHead(404)
    res.end('not found')
  })

  return new Promise((resolve, reject) => {
    server.once('error', reject)
    server.listen(port, '127.0.0.1', () => {
      const address = server.address() as AddressInfo
      resolve({
        url: `http://127.0.0.1:${address.port}`,
        token: TOKEN,
        answerCalls,
        actionCalls,
      failAnswerFor(id: string) {
        failIds.add(id)
      },
      failNextHookControlToggle() {
        failNextHookControlToggle = true
      },
      delayNextHookControlToggle(delayMs: number) {
        delayNextHookControlToggleMs = delayMs
      },
      resetHookControls() {
        hookControls = { enabled: true, issue: null }
        failNextHookControlToggle = false
        delayNextHookControlToggleMs = 0
      },
      delayNextAction(verb: string, delayMs: number) {
        actionDelays.set(verb, delayMs)
      },
        emit,
        replaceState(nextState) {
          state.panels = nextState.panels
          state.adapters = nextState.adapters
          state.items = nextState.items
          projectColors = { ...(nextState.projectColors ?? {}) }
          state.projectColors = projectColors
          hookControls = nextState.hookControls ?? { enabled: true, issue: null }
          failNextHookControlToggle = nextState.failNextHookControlToggle ?? false
          delayNextHookControlToggleMs = nextState.delayNextHookControlToggleMs ?? 0
        },
        waitForClient(timeoutMs = 5000) {
          const start = Date.now()
          return new Promise<void>((res, rej) => {
            const check = () => {
              if (sseClients.size > 0) {
                res()
                return
              }
              if (Date.now() - start > timeoutMs) {
                rej(new Error('timed out waiting for an /events client to connect'))
                return
              }
              setTimeout(check, 50)
            }
            check()
          })
        },
        close() {
          for (const client of sseClients) client.end()
          return new Promise<void>((res) => server.close(() => res()))
        },
      })
    })
  })
}
