import { readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { APIRoute } from 'astro'

export const prerender = false

const DEFAULT_COLLECTOR_URL = 'http://127.0.0.1:4980'

/** Read-only endpoints the dashboard is allowed to reach. Mutating routes are added
 * with their own handlers, never by widening this list. */
const PROXYABLE_READ_PATHS = new Set(['state', 'items', 'events', 'digest', 'config/projects', 'config/hooks', 'activity', 'incidents', 'hooks'])
const ACTIVITY_SOURCE_ENTRIES_PATH = /^activity\/sources\/[^/]+\/entries$/
const HOST_LOGS_PATH = /^hosts\/[^/]+\/logs$/
const SESSION_SCREEN_PATH = /^sessions\/[^/]+\/screen$/
const CLUSTER_NODE_PATH = /^cluster\/nodes\/[^/]+$/
const CLUSTER_WORKLOAD_PATH = /^cluster\/workloads\/[^/]+$/
const INCIDENT_PATH = /^incidents\/[^/]+$/
const INCIDENT_DISPATCH_PATH = /^incidents\/[^/]+\/dispatch$/
const INCIDENT_LIFECYCLE_PATH = /^incidents\/[^/]+\/(stop|delete)$/
const FACTORY_RUN_PATH = /^factory\/runs\/[^/]+$/
const FACTORY_ARTIFACT_PATH = 'factory/artifact'
const HARNESS_READ_PATHS = [
  /^harness\/runs\/[^/]+\/events$/,
  /^harness\/runs\/[^/]+\/tasks\/[^/]+\/stream$/,
  /^harness\/runs\/[^/]+\/config$/,
  /^harness\/runs\/[^/]+\/plan$/,
  /^harness\/runs\/[^/]+\/decisions$/,
  /^harness\/runs\/[^/]+$/,
]

/** Exactly one mutating route — decision answers. Default-deny everything else. */
const DECISION_ANSWER_PATH = /^decisions\/[^/]+\/answer$/
const WEB_ACTION_VERBS = new Set([
  'reap', 'ci-rerun', 'ci.rerunFailed', 'ci.cancelRun', 'steer', 'snooze', 'decision', 'abandon', 'restore',
  'box-drain', 'box-restore', 'host-quarantine', 'host-unquarantine', 'admission-reconcile', 'job-retry',
  'ci-reconcile', 'recall-spill', 'harness.task.pause', 'harness.task.resume', 'harness.task.kill',
  'harness.run.pause', 'harness.run.resume', 'harness.run.kill', 'harness.config.patch',
  'factory.decision.answer', 'factory.run.stop', 'sessions.sendKeys',
  'permission.answer', 'permission.arm',
])
const ACTION_PATH = /^actions\/([^/]+)$/
const CONFIG_PROJECTS_PATH = 'config/projects'
const CONFIG_ROUTING_PATH = /^config\/routing\/(codex|claude)$/
const MAX_PROXY_POST_BYTES = 64 * 1024
const HOOK_CONTROL_PATH = 'config/hooks/background-jobs-blocker'
const HOOK_CONTROL_REPAIR_PATH = 'config/hooks/repair'

function authorizeMutation(request: Request): Response | undefined {
  const origin = request.headers.get('origin')
  if (origin !== new URL(request.url).origin) return Response.json({ error: 'forbidden-origin' }, { status: 403 })
  return undefined
}

function isJson(request: Request): boolean {
  // Compare the media-type essence, not a prefix: application/jsonp is not JSON.
  return request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() === 'application/json'
}

function validHookControlBody(path: string, raw: string): boolean {
  try {
    const value: unknown = JSON.parse(raw)
    if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== 1) return false
    return path === HOOK_CONTROL_PATH
      ? typeof (value as { enabled?: unknown }).enabled === 'boolean'
      : (value as { confirm?: unknown }).confirm === 'replace-invalid-config'
  } catch { return false }
}

async function readBoundedBody(request: Request): Promise<string> {
  const contentLength = request.headers.get('content-length')
  if (contentLength !== null && (!/^\d+$/.test(contentLength) || Number(contentLength) > MAX_PROXY_POST_BYTES)) throw new RangeError('request body exceeds limit')
  if (!request.body) return ''
  const reader = request.body.getReader()
  const chunks: Uint8Array[] = []
  let size = 0
  try {
    while (true) {
      const chunk = await reader.read()
      if (chunk.done) break
      size += chunk.value.byteLength
      if (size > MAX_PROXY_POST_BYTES) throw new RangeError('request body exceeds limit')
      chunks.push(chunk.value)
    }
  } finally {
    reader.releaseLock()
  }
  return new TextDecoder().decode(Buffer.concat(chunks))
}

/** Mirrors collector/src/paths.ts — the collector generates this file (mode 600) on
 * first start, so reading it keeps one token in one place instead of a copy in .env. */
function collectorTokenFile(): string {
  const configDir = process.env.OVERDECK_CONFIG_DIR ?? join(homedir(), '.config', 'overdeck')
  return join(configDir, 'token')
}

function resolveToken(): string {
  const fromEnv = import.meta.env.COLLECTOR_TOKEN
  if (fromEnv) return fromEnv
  try {
    return readFileSync(collectorTokenFile(), 'utf8').trim()
  } catch {
    return ''
  }
}

function collectorBaseUrl(): string {
  return process.env.COLLECTOR_URL || import.meta.env.COLLECTOR_URL || DEFAULT_COLLECTOR_URL
}

async function proxyToCollector(target: URL, init: RequestInit): Promise<Response> {
  const requestHeaders = new Headers(init.headers)
  requestHeaders.set('authorization', `Bearer ${resolveToken()}`)
  let upstream: Response
  try {
    upstream = await fetch(target, { ...init, headers: requestHeaders })
  } catch {
    return new Response('collector unreachable', { status: 502 })
  }

  const contentType = upstream.headers.get('content-type') ?? 'application/json'
  const headers: Record<string, string> = { 'content-type': contentType }
  const retryAfter = upstream.headers.get('retry-after')
  if (retryAfter !== null) headers['retry-after'] = retryAfter
  if (target.pathname === `/${FACTORY_ARTIFACT_PATH}`) {
    for (const name of ['accept-ranges', 'content-length', 'content-range', 'x-overdeck-artifact-size', 'x-overdeck-artifact-tail', 'x-overdeck-artifact-truncated']) {
      const value = upstream.headers.get(name)
      if (value !== null) headers[name] = value
    }
  }
  if (contentType.includes('event-stream')) {
    headers['cache-control'] = 'no-cache'
    headers.connection = 'keep-alive'
  }

  return new Response(upstream.body, { status: upstream.status, headers })
}

async function proxyPost(path: string, request: Request, body?: string): Promise<Response> {
  const target = new URL(`/${path}`, collectorBaseUrl())
  return proxyToCollector(target, {
    method: 'POST',
    headers: { 'content-type': request.headers.get('content-type') ?? 'application/json' },
    body: body ?? await readBoundedBody(request),
  })
}

/**
 * Same-origin server-side proxy to the collector. Keeps the bearer token and the
 * collector's address out of the browser bundle, and sidesteps CORS — the collector
 * sets no CORS headers and its origin differs from the dashboard's.
 */
export const GET: APIRoute = async ({ params, url, request }) => {
  const path = params.path ?? ''
  if (path === HOOK_CONTROL_PATH || path === HOOK_CONTROL_REPAIR_PATH) return Response.json({ error: 'method-not-allowed' }, { status: 405 })
  if (!PROXYABLE_READ_PATHS.has(path) && path !== 'cluster' && !CLUSTER_NODE_PATH.test(path) && !CLUSTER_WORKLOAD_PATH.test(path) && !FACTORY_RUN_PATH.test(path) && !CONFIG_ROUTING_PATH.test(path) && path !== FACTORY_ARTIFACT_PATH && !ACTIVITY_SOURCE_ENTRIES_PATH.test(path) && !HOST_LOGS_PATH.test(path) && !SESSION_SCREEN_PATH.test(path) && !INCIDENT_PATH.test(path) && !HARNESS_READ_PATHS.some((pattern) => pattern.test(path))) {
    return new Response('not found', { status: 404 })
  }

  const target = new URL(`/${path}`, collectorBaseUrl())
  target.search = url.search
  const headers = new Headers()
  const forwardedCursor = request.headers.get('last-event-id')
  if (forwardedCursor) headers.set('last-event-id', forwardedCursor)
  if (path === FACTORY_ARTIFACT_PATH) {
    const range = request.headers.get('range')
    if (range) headers.set('range', range)
  }
  return proxyToCollector(target, { method: 'GET', headers, signal: request.signal })
}

function isKnownHookControlPath(path: string): boolean {
  return path === 'config/hooks' || path === HOOK_CONTROL_PATH || path === HOOK_CONTROL_REPAIR_PATH
}

export const POST: APIRoute = async ({ params, request }) => {
  const path = params.path ?? ''
  if (path === 'config/hooks') return Response.json({ error: 'method-not-allowed' }, { status: 405 })
  const denied = authorizeMutation(request)
  if (denied) return denied
  if (path === HOOK_CONTROL_PATH || path === HOOK_CONTROL_REPAIR_PATH) {
    if (!isJson(request)) return Response.json({ error: 'unsupported-media-type' }, { status: 415 })
    try {
      const body = await readBoundedBody(request)
      if (!validHookControlBody(path, body)) return Response.json({ error: 'invalid-hook-control-request' }, { status: 400 })
      return await proxyPost(path, request, body)
    } catch { return Response.json({ error: 'payload-too-large' }, { status: 413 }) }
  }
  if (DECISION_ANSWER_PATH.test(path)) {
    try { return await proxyPost(path, request) } catch { return new Response('payload too large', { status: 413 }) }
  }

  if (path === 'incidents' || INCIDENT_DISPATCH_PATH.test(path) || INCIDENT_LIFECYCLE_PATH.test(path)) {
    try { return await proxyPost(path, request) } catch { return new Response('payload too large', { status: 413 }) }
  }

  const actionMatch = ACTION_PATH.exec(path)
  if (actionMatch && WEB_ACTION_VERBS.has(actionMatch[1]!)) {
    try { return await proxyPost(path, request) } catch { return new Response('payload too large', { status: 413 }) }
  }

  if (path === CONFIG_PROJECTS_PATH || CONFIG_ROUTING_PATH.test(path)) {
    try { return await proxyPost(path, request) } catch { return new Response('payload too large', { status: 413 }) }
  }

  if (path === 'incidents') {
    try { return await proxyPost(path, request) } catch { return new Response('payload too large', { status: 413 }) }
  }

  return new Response('not found', { status: 404 })
}

// Astro dispatches unsupported verbs here instead of allowing a framework-specific 404.
export const ALL: APIRoute = async ({ params }) => {
  if (isKnownHookControlPath(params.path ?? '')) return Response.json({ error: 'method-not-allowed' }, { status: 405 })
  return new Response('not found', { status: 404 })
}
