const fs = require('node:fs')
const { execFile } = require('node:child_process')
const crypto = require('node:crypto')
const http = require('node:http')
const https = require('node:https')
const os = require('node:os')
const path = require('node:path')
const Database = require('better-sqlite3')

function loadEnvFile() {
  const envPath = path.join(__dirname, '.env')
  if (!fs.existsSync(envPath)) return

  const lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/)
  for (const line of lines) {
    const trimmed = line.trim()
    if (!trimmed || trimmed.startsWith('#')) continue

    const separatorIndex = trimmed.indexOf('=')
    if (separatorIndex === -1) continue

    const key = trimmed.slice(0, separatorIndex).trim()
    let value = trimmed.slice(separatorIndex + 1).trim()

    if (
      (value.startsWith('"') && value.endsWith('"')) ||
      (value.startsWith("'") && value.endsWith("'"))
    ) {
      value = value.slice(1, -1)
    }

    if (key && (!process.env[key] || process.env[key] === '')) {
      process.env[key] = value
    }
  }
}

loadEnvFile()

const PORT = Number(process.env.PORT || 3001)
const API_UPSTREAM = process.env.API_UPSTREAM || 'http://127.0.0.1:6000'
const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || ''
const UPTIME_STORAGE_DIR = process.env.UPTIME_STORAGE_DIR || '~/tsswebstorage'
const UPTIME_DATABASE_FILE = process.env.UPTIME_DATABASE_FILE || 'uptime.sqlite'
const UPTIME_SAMPLE_INTERVAL_MS = Number(process.env.UPTIME_SAMPLE_INTERVAL_MS || 30 * 60 * 1000)
const UPTIME_HISTORY_LIMIT = Number(process.env.UPTIME_HISTORY_LIMIT || 336)
const ANALYTICS_DATABASE_FILE = process.env.ANALYTICS_DATABASE_FILE || 'viewers.sqlite'
const ANALYTICS_RETENTION_DAYS = Number(process.env.ANALYTICS_RETENTION_DAYS || 30)
const ANALYTICS_ACTIVE_WINDOW_SECONDS = Number(process.env.ANALYTICS_ACTIVE_WINDOW_SECONDS || 75)
const API_CACHE_TTL_MS = Number(process.env.API_CACHE_TTL_MS || 15000)
const PUBLIC_DATA_CACHE_DIR = path.resolve(
  expandHome(process.env.PUBLIC_DATA_CACHE_DIR || path.join(UPTIME_STORAGE_DIR, 'public-data')),
)
const PUBLIC_DATA_CACHE_FRESH_MS = Number(process.env.PUBLIC_DATA_CACHE_FRESH_MS || 5 * 60 * 1000)
const PUBLIC_DATA_CACHE_STALE_MS = Number(process.env.PUBLIC_DATA_CACHE_STALE_MS || 24 * 60 * 60 * 1000)
const PUBLIC_DATA_PREWARM_INTERVAL_MS = Number(process.env.PUBLIC_DATA_PREWARM_INTERVAL_MS || PUBLIC_DATA_CACHE_FRESH_MS)
const PUBLIC_DATA_COLD_TIMEOUT_MS = Number(process.env.PUBLIC_DATA_COLD_TIMEOUT_MS || 8000)
const PUBLIC_DATA_CACHE_MAX_AGE_SECONDS = Math.max(0, Math.floor(PUBLIC_DATA_CACHE_FRESH_MS / 1000))
const PUBLIC_DATA_STALE_REVALIDATE_SECONDS = Math.max(0, Math.floor(PUBLIC_DATA_CACHE_STALE_MS / 1000))
const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000)
const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120)
const TURNSTILE_SECRET_KEY = process.env.TURNSTILE_SECRET_KEY || ''
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
const TURNSTILE_VERIFY_TIMEOUT_MS = Number(process.env.TURNSTILE_VERIFY_TIMEOUT_MS || 5000)
const TURNSTILE_MAX_TOKEN_LENGTH = 2048
const TURNSTILE_TOKEN_MAX_AGE_MS = 5 * 60 * 1000
const DIST_DIR = path.join(__dirname, 'dist')
const VEHICLE_ICONS_DIR = path.resolve(
  __dirname,
  process.env.VEHICLE_ICONS_DIR || path.join('dist', 'vehicle-icons'),
)
const BOTS_REPO_DIR = path.resolve(
  expandHome(process.env.BOTS_REPO_DIR || path.join(__dirname, '..', 'BOTS')),
)
const TSSBOT_REPO_DIR = path.resolve(process.env.TSSBOT_REPO_DIR || path.join(BOTS_REPO_DIR, 'TSSBOT'))
const TSS_REPLAY_SAMPLE_DIR = path.join(TSSBOT_REPO_DIR, 'replays_sample')
const TSS_REPLAYS_DIR = path.resolve(
  expandHome(
    process.env.TSS_REPLAYS_DIR ||
      (process.env.STORAGE_VOL_PATH
        ? path.join(expandHome(process.env.STORAGE_VOL_PATH), 'REPLAYS', 'TSS')
        : TSS_REPLAY_SAMPLE_DIR),
  ),
)
const SHARED_DIR = path.resolve(process.env.SHARED_DIR || path.join(BOTS_REPO_DIR, 'SHARED'))
const TSS_REPLAY_PYTHON = path.resolve(
  expandHome(process.env.TSS_REPLAY_PYTHON || path.join(SHARED_DIR, '.venv', 'bin', 'python')),
)
const TSS_REPLAY_RENDER_TIMEOUT_MS = Number(process.env.TSS_REPLAY_RENDER_TIMEOUT_MS || 30000)
const MAX_TEAM_NAME_LENGTH = 80
const MAX_CACHE_ENTRIES = 200
const MAX_RATE_LIMIT_KEYS = 1000
const MAX_ANALYTICS_BODY_BYTES = 16 * 1024
const MAX_UPSTREAM_BODY_BYTES = Number(process.env.MAX_UPSTREAM_BODY_BYTES || 1024 * 1024)
const SERVER_REQUEST_TIMEOUT_MS = Number(process.env.SERVER_REQUEST_TIMEOUT_MS || 30000)
const SERVER_HEADERS_TIMEOUT_MS = Number(process.env.SERVER_HEADERS_TIMEOUT_MS || 10000)
const RUN_BACKGROUND_JOBS = !process.env.NODE_APP_INSTANCE || process.env.NODE_APP_INSTANCE === '0'

const TRUST_PROXY = (() => {
  const raw = String(process.env.TRUST_PROXY ?? 'cloudflare').trim().toLowerCase()
  if (raw === '' || raw === 'false' || raw === '0' || raw === 'none') return 'none'
  if (raw === 'cloudflare' || raw === 'cf') return 'cloudflare'
  if (raw === 'true' || raw === '1' || raw === 'any' || raw === 'generic') return 'generic'
  return 'none'
})()

const TRUSTED_UPSTREAM_IPS = new Set(
  String(process.env.TRUSTED_UPSTREAM_IPS ?? '127.0.0.1,::1,::ffff:127.0.0.1')
    .split(',')
    .map((ip) => ip.trim())
    .filter(Boolean),
)

function requestPeerIsTrusted(req) {
  if (TRUST_PROXY === 'none') return false
  if (!TRUSTED_UPSTREAM_IPS.size) return true
  const peer = req.socket.remoteAddress || ''
  return TRUSTED_UPSTREAM_IPS.has(peer)
}

const ANALYTICS_METADATA_ALLOWED_KEYS = new Set([
  'preferences',
  'request',
  'screen',
  'viewport',
  'pixelRatio',
  'colorDepth',
  'network',
  'privacy',
  'capability',
  'capabilities',
  'memory',
  'cpu',
  'touch',
  'hardware',
  'locale',
  'referrer',
])

const SECURITY_HEADERS_BASE = {
  'x-content-type-options': 'nosniff',
  'x-frame-options': 'DENY',
  'referrer-policy': 'strict-origin-when-cross-origin',
  'permissions-policy': 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()',
  'cross-origin-opener-policy': 'same-origin',
  'cross-origin-resource-policy': 'same-origin',
}

const CSP_DIRECTIVES = [
  "default-src 'none'",
  "base-uri 'self'",
  "form-action 'self'",
  "frame-ancestors 'none'",
  "object-src 'none'",
  "script-src 'self' https://challenges.cloudflare.com",
  "script-src-elem 'self' https://challenges.cloudflare.com",
  "style-src 'self'",
  "style-src-elem 'self'",
  "style-src-attr 'unsafe-inline'",
  "img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com",
  "media-src 'self' https://*.dzcdn.net https://*.deezer.com",
  "font-src 'self' data:",
  "connect-src 'self' https://challenges.cloudflare.com",
  "frame-src https://challenges.cloudflare.com",
  "worker-src 'self' blob:",
  "manifest-src 'self'",
  'upgrade-insecure-requests',
].join('; ')

function securityHeaders(req, { html = false } = {}) {
  const headers = { ...SECURITY_HEADERS_BASE }
  if (isHttpsRequest(req)) {
    headers['strict-transport-security'] = 'max-age=31536000; includeSubDomains'
  }
  if (html) {
    headers['content-security-policy'] = CSP_DIRECTIVES
  }
  return headers
}

const mimeTypes = {
  '.css': 'text/css; charset=utf-8',
  '.html': 'text/html; charset=utf-8',
  '.ico': 'image/x-icon',
  '.jpeg': 'image/jpeg',
  '.jpg': 'image/jpeg',
  '.js': 'text/javascript; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.map': 'application/json; charset=utf-8',
  '.png': 'image/png',
  '.svg': 'image/svg+xml',
  '.webp': 'image/webp',
  '.mp4': 'video/mp4',
  '.webm': 'video/webm',
  '.ogg': 'video/ogg',
  '.mp3': 'audio/mpeg',
  '.wav': 'audio/wav',
}

function send(res, status, body, headers = {}) {
  const merged = { ...securityHeaders(res.req || { socket: {}, headers: {} }), ...headers }
  res.writeHead(status, merged)
  res.end(body)
}

const jsonHeaders = {
  'content-type': 'application/json; charset=utf-8',
  'cache-control': 'no-store',
  'x-content-type-options': 'nosniff',
}

const apiCache = new Map()
const publicDataRefreshes = new Set()
const rateLimits = new Map()
let uptimeDb = null
let analyticsDb = null
let latestUptimeSnapshot = null
let publicDataPrewarmTimer = null
let publicDataStartupStatus = { ready: false, checked_at: null, results: [] }

function sendJson(res, status, body, headers = {}) {
  send(res, status, JSON.stringify(body), { ...jsonHeaders, ...headers })
}

function publicDataKey(value) {
  return encodeURIComponent(String(value || '').trim()).replace(/%/g, '~')
}

function publicDataValue(value) {
  try {
    return decodeURIComponent(String(value || '').replace(/~/g, '%'))
  } catch {
    return ''
  }
}

function publicDataCachePathForUrl(requestUrl) {
  const pathname = requestUrl.pathname
  const params = requestUrl.searchParams

  if (pathname === '/api/tss/leaderboard/teams') {
    const limit = Number(params.get('limit') || 100)
    if (limit === 4) return path.join(PUBLIC_DATA_CACHE_DIR, 'home-teams.json')
    if (limit === 100) return path.join(PUBLIC_DATA_CACHE_DIR, 'leaderboard-teams.json')
    return null
  }

  if (pathname === '/api/tss/leaderboard/players') {
    const limit = Number(params.get('limit') || 100)
    return limit === 100 ? path.join(PUBLIC_DATA_CACHE_DIR, 'leaderboard-players.json') : null
  }

  if (pathname === '/api/tss/games/recent') {
    const limit = Number(params.get('limit') || 50)
    return limit === 50 ? path.join(PUBLIC_DATA_CACHE_DIR, 'recent-games.json') : null
  }

  let match = pathname.match(/^\/api\/tss\/teams\/([^/]+)$/)
  if (match && ![...params.keys()].length) {
    return path.join(PUBLIC_DATA_CACHE_DIR, 'teams', `${publicDataKey(decodeURIComponent(match[1]))}.json`)
  }

  match = pathname.match(/^\/api\/tss\/teams\/([^/]+)\/games$/)
  if (match && ![...params.keys()].length) {
    return path.join(PUBLIC_DATA_CACHE_DIR, 'teams', `${publicDataKey(decodeURIComponent(match[1]))}.games.json`)
  }

  match = pathname.match(/^\/api\/tss\/player\/([0-9]{1,32})$/)
  if (match && ![...params.keys()].length) {
    return path.join(PUBLIC_DATA_CACHE_DIR, 'players', `${publicDataKey(match[1])}.json`)
  }

  match = pathname.match(/^\/api\/tss\/games\/([A-Za-z0-9_-]{1,96})$/)
  if (match && (params.get('lang') || 'en') === 'en' && [...params.keys()].every((key) => key === 'lang')) {
    return path.join(PUBLIC_DATA_CACHE_DIR, 'games', `${publicDataKey(match[1])}.json`)
  }

  match = pathname.match(/^\/api\/tss\/games\/([A-Za-z0-9_-]{1,96})\/logs$/)
  if (match && ![...params.keys()].length) {
    return path.join(PUBLIC_DATA_CACHE_DIR, 'games', `${publicDataKey(match[1])}.logs.json`)
  }

  return null
}

function publicDataApiPathForRelative(relativePath) {
  if (relativePath === 'leaderboard-teams.json') return '/api/tss/leaderboard/teams?limit=100'
  if (relativePath === 'leaderboard-players.json') return '/api/tss/leaderboard/players?limit=100'
  if (relativePath === 'home-teams.json') return '/api/tss/leaderboard/teams?limit=4'
  if (relativePath === 'recent-games.json') return '/api/tss/games/recent?limit=50'

  let match = relativePath.match(/^teams\/(.+)\.games\.json$/)
  if (match) {
    const team = publicDataValue(match[1])
    return team ? `/api/tss/teams/${encodeURIComponent(team)}/games` : ''
  }

  match = relativePath.match(/^teams\/(.+)\.json$/)
  if (match) {
    const team = publicDataValue(match[1])
    return team ? `/api/tss/teams/${encodeURIComponent(team)}` : ''
  }

  match = relativePath.match(/^players\/(.+)\.json$/)
  if (match) {
    const uid = publicDataValue(match[1])
    return /^[0-9]{1,32}$/.test(uid) ? `/api/tss/player/${encodeURIComponent(uid)}` : ''
  }

  match = relativePath.match(/^games\/(.+)\.logs\.json$/)
  if (match) {
    const gameId = publicDataValue(match[1])
    return /^[A-Za-z0-9_-]{1,96}$/.test(gameId) ? `/api/tss/games/${encodeURIComponent(gameId)}/logs` : ''
  }

  match = relativePath.match(/^games\/(.+)\.json$/)
  if (match) {
    const gameId = publicDataValue(match[1])
    return /^[A-Za-z0-9_-]{1,96}$/.test(gameId) ? `/api/tss/games/${encodeURIComponent(gameId)}` : ''
  }

  return ''
}

function safePublicDataPath(requestPath) {
  let decoded = '/'
  try {
    decoded = decodeURIComponent(requestPath)
  } catch {
    return null
  }

  const relative = decoded.replace(/^\/data\/?/, '')
  if (!relative || relative.includes('\0')) return null

  const filePath = path.resolve(PUBLIC_DATA_CACHE_DIR, relative)
  const relativeToCache = path.relative(PUBLIC_DATA_CACHE_DIR, filePath)
  if (relativeToCache.startsWith('..') || path.isAbsolute(relativeToCache)) return null
  return filePath
}

function publicDataRelativePath(requestPath) {
  try {
    return decodeURIComponent(requestPath).replace(/^\/data\/?/, '')
  } catch {
    return ''
  }
}

function sendPublicDataFile(req, res, filePath, status = 200, extraHeaders = {}) {
  fs.readFile(filePath, (error, data) => {
    if (error) {
      sendJson(res, 404, { error: 'Snapshot not found' })
      return
    }

    send(res, status, data, {
      'content-type': 'application/json; charset=utf-8',
      'cache-control': `public, max-age=${PUBLIC_DATA_CACHE_MAX_AGE_SECONDS}, stale-while-revalidate=${PUBLIC_DATA_STALE_REVALIDATE_SECONDS}`,
      ...extraHeaders,
    })
  })
}

function sendPublicDataHead(req, res, filePath, status = 200, extraHeaders = {}) {
  fs.stat(filePath, (error, stat) => {
    if (error || !stat.isFile()) {
      send(res, 404, '', {
        'content-type': 'application/json; charset=utf-8',
        'cache-control': 'no-store',
      })
      return
    }

    send(res, status, '', {
      'content-type': 'application/json; charset=utf-8',
      'content-length': String(stat.size),
      'cache-control': `public, max-age=${PUBLIC_DATA_CACHE_MAX_AGE_SECONDS}, stale-while-revalidate=${PUBLIC_DATA_STALE_REVALIDATE_SECONDS}`,
      ...extraHeaders,
    })
  })
}

async function servePublicData(req, res, pathname) {
  const filePath = safePublicDataPath(pathname)
  const relativePath = publicDataRelativePath(pathname)
  if (!filePath || !relativePath) {
    sendJson(res, 400, { error: 'Bad request' })
    return
  }

  const current = cachedPublicData(filePath)
  if (current?.fresh) {
    const sender = req.method === 'HEAD' ? sendPublicDataHead : sendPublicDataFile
    sender(req, res, filePath, 200, { 'x-tssbot-cache': 'data-hit' })
    return
  }

  const apiPath = publicDataApiPathForRelative(relativePath)
  if (!apiPath) {
    sendJson(res, 404, { error: 'Snapshot not found' })
    return
  }

  if (current) {
    refreshPublicData(filePath, new URL(apiPath, API_UPSTREAM))
    const sender = req.method === 'HEAD' ? sendPublicDataHead : sendPublicDataFile
    sender(req, res, filePath, 200, { 'x-tssbot-cache': 'data-stale' })
    return
  }

  if (req.method === 'HEAD') {
    send(res, 404, '', {
      'content-type': 'application/json; charset=utf-8',
      'cache-control': 'no-store',
      'x-tssbot-cache': 'data-miss',
    })
    return
  }

  try {
    await fillPublicData(filePath, new URL(apiPath, API_UPSTREAM), PUBLIC_DATA_COLD_TIMEOUT_MS)
    sendPublicDataFile(req, res, filePath, 200, { 'x-tssbot-cache': 'data-filled' })
  } catch (error) {
    sendJson(res, 504, { error: 'Snapshot unavailable', detail: error.message })
  }
}

function cachedPublicData(filePath) {
  if (!filePath) return null

  try {
    const stat = fs.statSync(filePath)
    if (!stat.isFile()) return null
    const age = Date.now() - stat.mtimeMs
    if (age > PUBLIC_DATA_CACHE_STALE_MS) return null
    return { filePath, age, fresh: age <= PUBLIC_DATA_CACHE_FRESH_MS }
  } catch {
    return null
  }
}

function writePublicDataFile(filePath, body) {
  fs.mkdirSync(path.dirname(filePath), { recursive: true })
  const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
  fs.writeFileSync(tmpPath, body)
  fs.renameSync(tmpPath, filePath)
}

async function fillPublicData(filePath, target, timeoutMs = SERVER_REQUEST_TIMEOUT_MS) {
  if (!filePath) return false
  if (publicDataRefreshes.has(filePath)) return false
  publicDataRefreshes.add(filePath)

  try {
    const body = await fetchUpstreamJsonBuffer(target, timeoutMs)
    writePublicDataFile(filePath, body)
    return true
  } finally {
    publicDataRefreshes.delete(filePath)
  }
}

function refreshPublicData(filePath, target) {
  fillPublicData(filePath, target).catch((error) => {
    console.warn(`public data refresh failed for ${target.pathname}: ${error.message}`)
  })
}

function publicDataPrewarmTargets() {
  return [
    { name: 'team leaderboard', path: '/api/tss/leaderboard/teams?limit=100' },
    { name: 'player leaderboard', path: '/api/tss/leaderboard/players?limit=100' },
    { name: 'home teams', path: '/api/tss/leaderboard/teams?limit=4' },
    { name: 'recent games', path: '/api/tss/games/recent?limit=50' },
  ].map(({ name, path: requestPath }) => {
    const requestUrl = new URL(requestPath, 'http://localhost')
    return {
      name,
      requestUrl,
      target: new URL(requestPath, API_UPSTREAM),
      filePath: publicDataCachePathForUrl(requestUrl),
    }
  }).filter((entry) => entry.filePath)
}

async function prewarmPublicDataCache({ force = false, log = false } = {}) {
  const jobs = []
  for (const { name, filePath, target } of publicDataPrewarmTargets()) {
    const current = cachedPublicData(filePath)
    if (current?.fresh && !force) {
      jobs.push(Promise.resolve({ name, filePath, ok: true, cached: true, bytes: fs.statSync(filePath).size }))
      continue
    }

    const startedAt = Date.now()
    jobs.push(
      fillPublicData(filePath, target, PUBLIC_DATA_COLD_TIMEOUT_MS)
        .then((ok) => ({
          name,
          filePath,
          ok,
          cached: false,
          ms: Date.now() - startedAt,
          bytes: fs.existsSync(filePath) ? fs.statSync(filePath).size : 0,
        }))
        .catch((error) => ({
          name,
          filePath,
          ok: false,
          cached: false,
          ms: Date.now() - startedAt,
          error: error.message,
          bytes: fs.existsSync(filePath) ? fs.statSync(filePath).size : 0,
        })),
    )
  }
  const results = await Promise.all(jobs)
  publicDataStartupStatus = {
    ready: results.every((result) => result.ok || result.bytes > 0),
    checked_at: new Date().toISOString(),
    results: results.map((result) => ({
      name: result.name,
      ok: Boolean(result.ok || result.bytes > 0),
      cached: Boolean(result.cached),
      bytes: result.bytes || 0,
      ms: result.ms || 0,
      error: result.error || '',
    })),
  }

  if (log) {
    for (const result of publicDataStartupStatus.results) {
      const state = result.ok ? (result.cached ? 'cached' : 'warmed') : 'failed'
      const detail = result.error ? ` (${result.error})` : ''
      console.log(`public data ${state}: ${result.name} ${result.bytes} bytes in ${result.ms}ms${detail}`)
    }
  }

  return publicDataStartupStatus
}

function startPublicDataPrewarmer() {
  fs.mkdirSync(PUBLIC_DATA_CACHE_DIR, { recursive: true })
  publicDataPrewarmTimer = setInterval(() => {
    prewarmPublicDataCache().catch((error) => {
      console.warn(`public data prewarm failed: ${error.message}`)
    })
  }, PUBLIC_DATA_PREWARM_INTERVAL_MS)
  publicDataPrewarmTimer.unref?.()
}

function fetchUpstreamJsonBuffer(target, timeoutMs = SERVER_REQUEST_TIMEOUT_MS) {
  return new Promise((resolve, reject) => {
    const client = target.protocol === 'https:' ? https : http
    const chunks = []
    let bytes = 0
    const proxy = client.request(
      target,
      {
        method: 'GET',
        headers: {
          accept: 'application/json',
          host: target.host,
          'user-agent': 'tssbot-web-cache',
        },
      },
      (proxyRes) => {
        const status = proxyRes.statusCode || 502
        const contentType = String(proxyRes.headers['content-type'] || '')
        if (status < 200 || status >= 300 || !contentType.includes('application/json')) {
          proxyRes.resume()
          reject(new Error(`Upstream returned ${status}`))
          return
        }

        proxyRes.on('data', (chunk) => {
          bytes += chunk.length
          if (bytes > MAX_UPSTREAM_BODY_BYTES) {
            proxy.destroy(new Error('Upstream response too large'))
            return
          }
          chunks.push(chunk)
        })
        proxyRes.on('end', () => resolve(Buffer.concat(chunks)))
      },
    )

    proxy.setTimeout(timeoutMs, () => proxy.destroy(new Error(`Upstream request timed out after ${timeoutMs}ms`)))
    proxy.on('error', reject)
    proxy.end()
  })
}

function requestJson(url, timeoutMs = 10000) {
  return new Promise((resolve, reject) => {
    const target = new URL(url)
    const client = target.protocol === 'https:' ? https : http
    const startedAt = Date.now()
    const req = client.request(
      target,
      {
        method: 'GET',
        headers: {
          accept: 'application/json',
          'user-agent': 'tssbot-uptime-sampler',
        },
        timeout: timeoutMs,
      },
      (response) => {
        const chunks = []
        let size = 0

        response.on('data', (chunk) => {
          size += chunk.length
          if (size > MAX_UPSTREAM_BODY_BYTES) {
            req.destroy(new Error('Upstream response too large'))
            return
          }
          chunks.push(chunk)
        })
        response.on('end', () => {
          const body = Buffer.concat(chunks).toString('utf8')
          const latency = Date.now() - startedAt

          if ((response.statusCode || 0) < 200 || (response.statusCode || 0) >= 300) {
            reject(new Error(`HTTP ${response.statusCode || 0}`))
            return
          }

          try {
            resolve({ body: body ? JSON.parse(body) : null, latency })
          } catch {
            resolve({ body: null, latency })
          }
        })
      },
    )

    req.on('timeout', () => {
      req.destroy(new Error(`Request timed out after ${timeoutMs}ms`))
    })
    req.on('error', reject)
    req.end()
  })
}

function expandHome(filePath) {
  if (filePath === '~') return os.homedir()
  if (filePath.startsWith(`~${path.sep}`)) return path.join(os.homedir(), filePath.slice(2))
  if (filePath.startsWith('~/')) return path.join(os.homedir(), filePath.slice(2))
  return filePath
}

function uptimeStoragePath() {
  return path.resolve(expandHome(UPTIME_STORAGE_DIR))
}

function ensureAnalyticsDb() {
  if (analyticsDb) return analyticsDb

  const storageDir = uptimeStoragePath()
  fs.mkdirSync(storageDir, { recursive: true })

  analyticsDb = new Database(path.join(storageDir, ANALYTICS_DATABASE_FILE))
  analyticsDb.pragma('journal_mode = WAL')
  analyticsDb.pragma('busy_timeout = 5000')
  analyticsDb.exec(`
    create table if not exists viewer_events (
      id integer primary key autoincrement,
      occurred_at text not null default (datetime('now')),
      visitor_id text not null,
      session_id text not null,
      ip_hash text not null,
      event_type text not null,
      page_path text not null,
      page_title text not null,
      referrer text not null default '',
      user_agent text not null default '',
      browser text not null default 'Unknown',
      os text not null default 'Unknown',
      device text not null default 'Desktop',
      screen text not null default '',
      theme text not null default 'light',
      language text not null default '',
      timezone text not null default '',
      country text not null default '',
      region text not null default '',
      city text not null default '',
      latitude real,
      longitude real,
      consent text not null default 'analytics',
      metadata text not null default '{}'
    );

    create table if not exists active_viewers (
      session_id text primary key,
      visitor_id text not null,
      ip_hash text not null,
      first_seen_at text not null,
      last_seen_at text not null,
      page_path text not null,
      page_title text not null,
      referrer text not null default '',
      user_agent text not null default '',
      browser text not null default 'Unknown',
      os text not null default 'Unknown',
      device text not null default 'Desktop',
      screen text not null default '',
      theme text not null default 'light',
      language text not null default '',
      timezone text not null default '',
      country text not null default '',
      region text not null default '',
      city text not null default '',
      latitude real,
      longitude real
    );

    create index if not exists viewer_events_occurred_at_idx
      on viewer_events (occurred_at desc);

    create index if not exists viewer_events_page_path_idx
      on viewer_events (page_path, occurred_at desc);

    create index if not exists active_viewers_last_seen_at_idx
      on active_viewers (last_seen_at desc);
  `)

  for (const statement of [
    `alter table viewer_events add column country text not null default ''`,
    `alter table active_viewers add column country text not null default ''`,
    `alter table viewer_events add column region text not null default ''`,
    `alter table active_viewers add column region text not null default ''`,
    `alter table viewer_events add column city text not null default ''`,
    `alter table active_viewers add column city text not null default ''`,
    `alter table viewer_events add column latitude real`,
    `alter table active_viewers add column latitude real`,
    `alter table viewer_events add column longitude real`,
    `alter table active_viewers add column longitude real`,
    `alter table viewer_events add column theme text not null default 'light'`,
    `alter table active_viewers add column theme text not null default 'light'`,
  ]) {
    try {
      analyticsDb.exec(statement)
    } catch (error) {
      if (!String(error.message || '').includes('duplicate column name')) throw error
    }
  }

  return analyticsDb
}

function ensureUptimeDb() {
  if (uptimeDb) return uptimeDb

  const storageDir = uptimeStoragePath()
  fs.mkdirSync(storageDir, { recursive: true })

  uptimeDb = new Database(path.join(storageDir, UPTIME_DATABASE_FILE))
  uptimeDb.pragma('journal_mode = WAL')
  uptimeDb.pragma('busy_timeout = 5000')
  uptimeDb.exec(`
    create table if not exists uptime_snapshots (
      id integer primary key autoincrement,
      checked_at text not null default (datetime('now')),
      website_ok integer not null,
      health_ok integer not null,
      tss_ok integer not null,
      ok integer not null,
      latency_ms integer not null,
      details text not null default '{}'
    );

    create index if not exists uptime_snapshots_checked_at_idx
      on uptime_snapshots (checked_at desc);
  `)

  return uptimeDb
}

async function takeUptimeSnapshot() {
  const startedAt = Date.now()
  const details = {
    website: { label: 'Online' },
    health: { label: 'Operational' },
    tss: { label: 'Not checked' },
  }

  const websiteOk = fs.existsSync(path.join(DIST_DIR, 'index.html'))
  if (!websiteOk) details.website.label = 'Build not found'

  const healthOk = true

  let tssOk = false
  try {
    const tssUrl = new URL('/api/tss/leaderboard/teams?limit=1', API_UPSTREAM)
    const result = await requestJson(tssUrl.toString())
    const teamCount = result.body?.teams?.length || result.body?.squadrons?.length || 0
    tssOk = true
    details.tss = {
      label: `${teamCount} sample team${teamCount === 1 ? '' : 's'} returned`,
      latency_ms: result.latency,
    }
  } catch (error) {
    details.tss = { label: error.message }
  }

  const snapshot = {
    checked_at: new Date().toISOString(),
    website_ok: websiteOk,
    health_ok: healthOk,
    tss_ok: tssOk,
    ok: websiteOk && healthOk && tssOk,
    latency_ms: Date.now() - startedAt,
    details,
  }

  latestUptimeSnapshot = snapshot

  const db = ensureUptimeDb()
  db.prepare(`
    insert into uptime_snapshots
      (checked_at, website_ok, health_ok, tss_ok, ok, latency_ms, details)
    values
      (@checked_at, @website_ok, @health_ok, @tss_ok, @ok, @latency_ms, @details)
  `).run({
    checked_at: snapshot.checked_at,
    website_ok: snapshot.website_ok ? 1 : 0,
    health_ok: snapshot.health_ok ? 1 : 0,
    tss_ok: snapshot.tss_ok ? 1 : 0,
    ok: snapshot.ok ? 1 : 0,
    latency_ms: snapshot.latency_ms,
    details: JSON.stringify(snapshot.details),
  })

  return snapshot
}

async function uptimeHistory() {
  const db = ensureUptimeDb()
  const rows = db.prepare(`
    select checked_at, website_ok, health_ok, tss_ok, ok, latency_ms, details
      from uptime_snapshots
     order by checked_at desc
     limit ?
  `).all(UPTIME_HISTORY_LIMIT)
  const history = rows.reverse().map((row) => ({
    checked_at: row.checked_at,
    website_ok: Boolean(row.website_ok),
    health_ok: Boolean(row.health_ok),
    tss_ok: Boolean(row.tss_ok),
    ok: Boolean(row.ok),
    latency_ms: row.latency_ms,
    details: JSON.parse(row.details || '{}'),
  }))

  return {
    configured: true,
    latest: history.at(-1) || latestUptimeSnapshot,
    history,
  }
}

let uptimeSamplerTimer = null

function startUptimeSampler() {
  takeUptimeSnapshot().catch((error) => {
    console.error('Initial uptime snapshot failed:', error)
  })

  uptimeSamplerTimer = setInterval(() => {
    takeUptimeSnapshot().catch((error) => {
      console.error('Uptime snapshot failed:', error)
    })
  }, UPTIME_SAMPLE_INTERVAL_MS).unref()
}

function trustedHeaderFirst(req, name) {
  const value = req.headers[name]
  if (!value) return ''
  return String(Array.isArray(value) ? value[0] : value).split(',')[0].trim()
}

function trustedForwardedProto(req) {
  if (!requestPeerIsTrusted(req)) {
    return req.socket.encrypted ? 'https' : 'http'
  }
  if (TRUST_PROXY === 'cloudflare') {
    const cfVisitor = trustedHeaderFirst(req, 'cf-visitor')
    if (cfVisitor) {
      try {
        const parsed = JSON.parse(cfVisitor)
        if (parsed && typeof parsed.scheme === 'string') return parsed.scheme.toLowerCase()
      } catch {
        // ignore malformed cf-visitor
      }
    }
  }
  return (
    trustedHeaderFirst(req, 'x-forwarded-proto').toLowerCase() ||
    (req.socket.encrypted ? 'https' : 'http')
  )
}

function trustedForwardedHost(req) {
  if (requestPeerIsTrusted(req) && TRUST_PROXY === 'generic') {
    const value = trustedHeaderFirst(req, 'x-forwarded-host')
    if (value) return value
  }
  return trustedHeaderFirst(req, 'host')
}

function isHttpsRequest(req) {
  if (req.socket.encrypted) return true
  return trustedForwardedProto(req) === 'https'
}

function publicOrigins(req) {
  const origins = PUBLIC_ORIGIN.split(',')
    .map((origin) => origin.trim())
    .filter(Boolean)

  if (!origins.length) {
    const host = trustedForwardedHost(req)
    const proto = trustedForwardedProto(req) || (req.socket.encrypted ? 'https' : 'http')

    if (host) {
      origins.push(`${proto}://${host}`)
    }
  }

  return origins
}

function isSameOriginRequest(req) {
  const origins = publicOrigins(req)
  const origin = req.headers.origin
  const referer = req.headers.referer
  const fetchSite = req.headers['sec-fetch-site']
  const fetchDest = req.headers['sec-fetch-dest']

  if (fetchDest === 'document') return false
  if (origin && !origins.includes(origin)) return false

  if (referer) {
    try {
      if (!origins.includes(new URL(referer).origin)) return false
    } catch {
      return false
    }
  }

  if (fetchSite) {
    return fetchSite === 'same-origin'
  }

  return Boolean(origin || referer)
}

function clientIp(req) {
  if (requestPeerIsTrusted(req)) {
    if (TRUST_PROXY === 'cloudflare') {
      const cf = trustedHeaderFirst(req, 'cf-connecting-ip')
      if (cf) return cf
    }
    const forwarded = trustedHeaderFirst(req, 'x-forwarded-for')
    if (forwarded) return forwarded
  }
  return req.socket.remoteAddress || 'unknown'
}

function sanitizeText(value, maxLength = 200) {
  return String(value || '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength)
}

function sanitizePath(value) {
  const raw = sanitizeText(value, 300)
  if (!raw.startsWith('/')) return '/'
  return raw
}

function headerValue(req, name, maxLength = 200) {
  const value = req.headers[name]
  if (Array.isArray(value)) return sanitizeText(value.join(', '), maxLength)
  return sanitizeText(value, maxLength)
}

function countryFromHeaders(req) {
  if (!requestPeerIsTrusted(req)) return ''
  const raw =
    headerValue(req, 'cf-ipcountry', 12) ||
    headerValue(req, 'x-vercel-ip-country', 12) ||
    headerValue(req, 'x-appengine-country', 12) ||
    headerValue(req, 'cloudfront-viewer-country', 12)
  const country = raw.toUpperCase()
  if (!/^[A-Z]{2}$/.test(country) || country === 'XX') return ''
  return country
}

function numberHeader(req, name, min, max) {
  const value = Number(headerValue(req, name, 40))
  if (!Number.isFinite(value) || value < min || value > max) return null
  return value
}

function sanitizeTheme(value) {
  return value === 'dark' ? 'dark' : 'light'
}

const CITY_COORDINATE_OVERRIDES = new Map([
  ['GB|ENGLAND|MILTON KEYNES', { latitude: 52.0406, longitude: -0.7594 }],
])

function normalizedLocationKey(location) {
  return [
    String(location.country || '').trim().toUpperCase(),
    String(location.region || '').trim().toUpperCase(),
    String(location.city || '').trim().toUpperCase(),
  ].join('|')
}

function normalizeLocationSignal(location) {
  const override = CITY_COORDINATE_OVERRIDES.get(normalizedLocationKey(location))
  if (!override) return location
  return {
    ...location,
    latitude: override.latitude,
    longitude: override.longitude,
  }
}

function locationFromHeaders(req) {
  if (!requestPeerIsTrusted(req) || TRUST_PROXY !== 'cloudflare') {
    return normalizeLocationSignal({ country: countryFromHeaders(req), region: '', city: '', latitude: null, longitude: null })
  }
  return normalizeLocationSignal({
    country: countryFromHeaders(req),
    region: headerValue(req, 'cf-region', 120),
    city: headerValue(req, 'cf-ipcity', 120),
    latitude: numberHeader(req, 'cf-iplatitude', -90, 90),
    longitude: numberHeader(req, 'cf-iplongitude', -180, 180),
  })
}

function sanitizeAnalyticsValue(value, depth = 0) {
  if (depth > 3) return null
  if (value == null) return null
  if (typeof value === 'string') return value.slice(0, 500)
  if (typeof value === 'number') return Number.isFinite(value) ? value : null
  if (typeof value === 'boolean') return value
  if (Array.isArray(value)) {
    return value.slice(0, 24).map((item) => sanitizeAnalyticsValue(item, depth + 1)).filter((item) => item !== null)
  }
  if (typeof value === 'object') {
    const out = {}
    let kept = 0
    for (const [key, val] of Object.entries(value)) {
      if (kept >= 32) break
      const cleanKey = String(key).replace(/[^A-Za-z0-9_\-]/g, '').slice(0, 60)
      if (!cleanKey) continue
      const cleanVal = sanitizeAnalyticsValue(val, depth + 1)
      if (cleanVal === null) continue
      out[cleanKey] = cleanVal
      kept += 1
    }
    return out
  }
  return null
}

function pruneAnalyticsMetadata(raw) {
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
  const out = {}
  for (const [key, value] of Object.entries(raw)) {
    if (!ANALYTICS_METADATA_ALLOWED_KEYS.has(key)) continue
    const sanitized = sanitizeAnalyticsValue(value, 0)
    if (sanitized === null) continue
    out[key] = sanitized
  }
  return out
}

function analyticsMetadata(req, payload) {
  const metadata = pruneAnalyticsMetadata(payload.metadata)
  const preferences = metadata.preferences && typeof metadata.preferences === 'object'
    ? metadata.preferences
    : {}

  if (!preferences.diagnostics) return metadata

  return {
    ...metadata,
    request: {
      http_version: req.httpVersion || '',
      method: req.method || '',
      url_path: sanitizePath(req.url?.split('?')[0] || ''),
      protocol: headerValue(req, 'x-forwarded-proto', 40) || (req.socket.encrypted ? 'https' : 'http'),
      host: headerValue(req, 'x-forwarded-host', 160) || headerValue(req, 'host', 160),
      content_type: headerValue(req, 'content-type', 120),
      content_length: headerValue(req, 'content-length', 40),
      accept: headerValue(req, 'accept', 300),
      accept_encoding: headerValue(req, 'accept-encoding', 160),
      accept_language: preferences.locale ? headerValue(req, 'accept-language', 200) : '',
      sec_fetch_site: headerValue(req, 'sec-fetch-site', 40),
      sec_fetch_mode: headerValue(req, 'sec-fetch-mode', 40),
      sec_fetch_dest: headerValue(req, 'sec-fetch-dest', 40),
      forwarded_port: headerValue(req, 'x-forwarded-port', 20),
      forwarded_host_present: Boolean(req.headers['x-forwarded-host']),
      forwarded_proto_present: Boolean(req.headers['x-forwarded-proto']),
    },
  }
}

function parseClient(userAgent = '') {
  const ua = String(userAgent)
  let browser = 'Unknown'
  let osName = 'Unknown'
  let device = 'Desktop'

  if (/Edg\//.test(ua)) browser = 'Microsoft Edge'
  else if (/OPR\//.test(ua)) browser = 'Opera'
  else if (/Firefox\//.test(ua)) browser = 'Firefox'
  else if (/Chrome\//.test(ua) && !/Chromium\//.test(ua)) browser = 'Chrome'
  else if (/Safari\//.test(ua) && /Version\//.test(ua)) browser = 'Safari'

  if (/Windows NT/.test(ua)) osName = 'Windows'
  else if (/Android/.test(ua)) osName = 'Android'
  else if (/(iPhone|iPad|iPod)/.test(ua)) osName = 'iOS'
  else if (/Mac OS X/.test(ua)) osName = 'macOS'
  else if (/Linux/.test(ua)) osName = 'Linux'

  if (/Mobi|Android|iPhone|iPod/.test(ua)) device = 'Mobile'
  else if (/iPad|Tablet/.test(ua)) device = 'Tablet'

  return { browser, os: osName, device }
}

function readJsonBody(req) {
  return new Promise((resolve, reject) => {
    const chunks = []
    let size = 0

    req.on('data', (chunk) => {
      size += chunk.length
      if (size > MAX_ANALYTICS_BODY_BYTES) {
        reject(new Error('Request body too large'))
        req.destroy()
        return
      }
      chunks.push(chunk)
    })

    req.on('end', () => {
      try {
        const body = Buffer.concat(chunks).toString('utf8')
        resolve(body ? JSON.parse(body) : {})
      } catch {
        reject(new Error('Invalid JSON body'))
      }
    })
    req.on('error', reject)
  })
}

const TURNSTILE_SESSION_COOKIE_BASE = 'tssbot_turnstile'
const TURNSTILE_SESSION_COOKIE_HOST = `__Host-${TURNSTILE_SESSION_COOKIE_BASE}`
const TURNSTILE_SESSION_TTL_SECONDS = 30 * 60
const TURNSTILE_SESSION_HMAC_KEY = TURNSTILE_SECRET_KEY
  ? crypto
      .createHash('sha256')
      .update(`tssbot-turnstile-session|${TURNSTILE_SECRET_KEY}`)
      .digest()
  : null

function signTurnstileSession(expiresAt) {
  if (!TURNSTILE_SESSION_HMAC_KEY) return ''
  return crypto
    .createHmac('sha256', TURNSTILE_SESSION_HMAC_KEY)
    .update(String(expiresAt))
    .digest('base64url')
}

function buildTurnstileSessionCookie(req) {
  if (!TURNSTILE_SESSION_HMAC_KEY) return ''
  const expiresAt = Math.floor(Date.now() / 1000) + TURNSTILE_SESSION_TTL_SECONDS
  const signature = signTurnstileSession(expiresAt)
  const value = `${expiresAt}.${signature}`
  const isHttps = isHttpsRequest(req)
  const name = isHttps ? TURNSTILE_SESSION_COOKIE_HOST : TURNSTILE_SESSION_COOKIE_BASE
  const parts = [
    `${name}=${value}`,
    'Path=/',
    `Max-Age=${TURNSTILE_SESSION_TTL_SECONDS}`,
    'HttpOnly',
    'SameSite=Strict',
  ]
  if (isHttps) parts.push('Secure')
  return parts.join('; ')
}

function parseRequestCookies(req) {
  const header = req.headers.cookie || ''
  const out = {}
  for (const part of header.split(/;\s*/)) {
    if (!part) continue
    const eq = part.indexOf('=')
    if (eq < 1) continue
    out[part.slice(0, eq)] = part.slice(eq + 1)
  }
  return out
}

function isTurnstileSessionVerified(req) {
  if (!TURNSTILE_SECRET_KEY) return true
  if (!TURNSTILE_SESSION_HMAC_KEY) return false
  const cookies = parseRequestCookies(req)
  const cookie =
    cookies[TURNSTILE_SESSION_COOKIE_HOST] || cookies[TURNSTILE_SESSION_COOKIE_BASE]
  if (!cookie) return false
  const dot = cookie.indexOf('.')
  if (dot < 1) return false
  const expiresAtStr = cookie.slice(0, dot)
  const signature = cookie.slice(dot + 1)
  const expiresAt = Number(expiresAtStr)
  if (!Number.isFinite(expiresAt) || expiresAt < Math.floor(Date.now() / 1000)) return false
  const expected = signTurnstileSession(expiresAt)
  if (!expected || signature.length !== expected.length) return false
  try {
    return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  } catch {
    return false
  }
}

function callTurnstileSiteverify(token, remoteIp, idempotencyKey) {
  return new Promise((resolve) => {
    const params = new URLSearchParams()
    params.set('secret', TURNSTILE_SECRET_KEY)
    params.set('response', token)
    if (remoteIp && remoteIp !== 'unknown') params.set('remoteip', remoteIp)
    if (idempotencyKey) params.set('idempotency_key', idempotencyKey)
    const payload = params.toString()

    const verifyUrl = new URL(TURNSTILE_VERIFY_URL)
    const request = https.request(
      {
        method: 'POST',
        hostname: verifyUrl.hostname,
        path: verifyUrl.pathname,
        headers: {
          'content-type': 'application/x-www-form-urlencoded',
          'content-length': Buffer.byteLength(payload),
        },
        timeout: TURNSTILE_VERIFY_TIMEOUT_MS,
      },
      (response) => {
        const chunks = []
        response.on('data', (chunk) => chunks.push(chunk))
        response.on('end', () => {
          try {
            const data = JSON.parse(Buffer.concat(chunks).toString('utf8'))
            resolve({ ok: true, data })
          } catch {
            resolve({ ok: false, error: 'Invalid Turnstile response' })
          }
        })
        response.on('error', (error) => {
          resolve({ ok: false, error: error.message || 'Turnstile read failed' })
        })
      },
    )

    request.on('timeout', () => {
      request.destroy(new Error('Turnstile verification timed out'))
    })
    request.on('error', (error) => {
      resolve({ ok: false, error: error.message || 'Turnstile request failed' })
    })

    request.write(payload)
    request.end()
  })
}

async function verifyTurnstileToken(token, remoteIp, options = {}) {
  if (!TURNSTILE_SECRET_KEY) {
    return { success: true, data: { skipped: true } }
  }
  if (!token || typeof token !== 'string') {
    return { success: false, error: 'Missing Turnstile token', codes: ['missing-input-response'] }
  }
  if (token.length > TURNSTILE_MAX_TOKEN_LENGTH) {
    return { success: false, error: 'Turnstile token too long', codes: ['invalid-input-response'] }
  }

  const { expectedAction, expectedHostname, maxRetries = 2 } = options
  const idempotencyKey = crypto.randomUUID()

  let attempt = 0
  let lastResult = null
  while (attempt < maxRetries) {
    attempt += 1
    const call = await callTurnstileSiteverify(token, remoteIp, idempotencyKey)
    lastResult = call

    if (call.ok) {
      const data = call.data || {}
      if (!data.success) {
        const codes = Array.isArray(data['error-codes']) ? data['error-codes'] : []
        const transient = codes.includes('internal-error')
        if (transient && attempt < maxRetries) {
          await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 250))
          continue
        }
        return { success: false, error: 'Turnstile verification failed', codes }
      }

      if (expectedAction && data.action && data.action !== expectedAction) {
        console.warn(`Turnstile action mismatch: expected ${expectedAction}, got ${data.action}`)
        return { success: false, error: 'Action mismatch', codes: ['action-mismatch'] }
      }

      if (expectedHostname && data.hostname && data.hostname !== expectedHostname) {
        console.warn(`Turnstile hostname mismatch: expected ${expectedHostname}, got ${data.hostname}`)
        return { success: false, error: 'Hostname mismatch', codes: ['hostname-mismatch'] }
      }

      if (data.challenge_ts) {
        const ageMs = Date.now() - Date.parse(data.challenge_ts)
        if (Number.isFinite(ageMs) && ageMs > TURNSTILE_TOKEN_MAX_AGE_MS) {
          console.warn(`Turnstile token age ${(ageMs / 1000).toFixed(1)}s exceeds limit`)
          return { success: false, error: 'Token expired', codes: ['stale-token'] }
        }
      }

      return { success: true, data }
    }

    if (attempt < maxRetries) {
      await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 250))
    }
  }

  console.error('Turnstile verification failed after retries:', lastResult?.error)
  return { success: false, error: 'Turnstile verification unavailable', codes: ['internal-error'] }
}

function expectedTurnstileHostname() {
  if (!PUBLIC_ORIGIN) return ''
  const first = PUBLIC_ORIGIN.split(',').map((origin) => origin.trim()).filter(Boolean)[0]
  if (!first) return ''
  try {
    return new URL(first).hostname
  } catch {
    return ''
  }
}

function purgeOldAnalytics(db) {
  const eventCutoff = new Date(Date.now() - ANALYTICS_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString()
  const activeCutoff = new Date(Date.now() - ANALYTICS_ACTIVE_WINDOW_SECONDS * 3 * 1000).toISOString()

  db.transaction(() => {
    db.prepare(`
      delete from viewer_events
       where occurred_at < ?
    `).run(eventCutoff)

    db.prepare(`
      delete from active_viewers
       where last_seen_at < ?
    `).run(activeCutoff)
  })()
}

function recordViewerEvent(req, payload) {
  const db = ensureAnalyticsDb()
  purgeOldAnalytics(db)

  const serverClient = parseClient(req.headers['user-agent'] || '')
  const location = locationFromHeaders(req)
  const shareUserAgent = payload.user_agent !== 'Not shared'
  const event = {
    visitor_id: sanitizeText(payload.visitor_id, 80) || crypto.randomUUID(),
    session_id: sanitizeText(payload.session_id, 80) || crypto.randomUUID(),
    ip_hash: '',
    event_type: ['page_view', 'heartbeat', 'consent'].includes(payload.event_type)
      ? payload.event_type
      : 'heartbeat',
    page_path: sanitizePath(payload.page_path),
    page_title: sanitizeText(payload.page_title, 160),
    referrer: sanitizeText(payload.referrer, 300),
    user_agent: sanitizeText(
      shareUserAgent ? payload.user_agent || req.headers['user-agent'] : 'Not shared',
      500,
    ),
    browser: sanitizeText(payload.browser || (shareUserAgent ? serverClient.browser : 'Not shared'), 80),
    os: sanitizeText(payload.os || (shareUserAgent ? serverClient.os : 'Not shared'), 80),
    device: sanitizeText(payload.device || (shareUserAgent ? serverClient.device : 'Not shared'), 80),
    screen: sanitizeText(payload.screen, 40),
    theme: sanitizeTheme(payload.theme),
    language: sanitizeText(payload.language, 40),
    timezone: sanitizeText(payload.timezone, 80),
    country: location.country,
    region: location.region,
    city: location.city,
    latitude: location.latitude,
    longitude: location.longitude,
    consent: payload.consent === 'analytics' ? 'analytics' : '',
    metadata: JSON.stringify(analyticsMetadata(req, payload)),
  }

  if (event.consent !== 'analytics') {
    throw new Error('Analytics consent is required')
  }

  const now = new Date().toISOString()
  const writeViewerEvent = db.transaction(() => {
    db.prepare(`
      insert into viewer_events
        (occurred_at, visitor_id, session_id, ip_hash, event_type, page_path, page_title,
         referrer, user_agent, browser, os, device, screen, theme, language, timezone,
         country, region, city, latitude, longitude, consent, metadata)
      values
        (@occurred_at, @visitor_id, @session_id, @ip_hash, @event_type, @page_path, @page_title,
         @referrer, @user_agent, @browser, @os, @device, @screen, @theme, @language, @timezone,
         @country, @region, @city, @latitude, @longitude, @consent, @metadata)
    `).run({ ...event, occurred_at: now })

    db.prepare(`
      insert into active_viewers
        (session_id, visitor_id, ip_hash, first_seen_at, last_seen_at, page_path, page_title,
         referrer, user_agent, browser, os, device, screen, theme, language, timezone,
         country, region, city, latitude, longitude)
      values
        (@session_id, @visitor_id, @ip_hash, @now, @now, @page_path, @page_title,
         @referrer, @user_agent, @browser, @os, @device, @screen, @theme, @language, @timezone,
         @country, @region, @city, @latitude, @longitude)
      on conflict(session_id) do update set
        last_seen_at = excluded.last_seen_at,
        page_path = excluded.page_path,
        page_title = excluded.page_title,
        referrer = excluded.referrer,
        user_agent = excluded.user_agent,
        browser = excluded.browser,
        os = excluded.os,
        device = excluded.device,
        screen = excluded.screen,
        theme = excluded.theme,
        language = excluded.language,
        timezone = excluded.timezone,
        country = excluded.country,
        region = excluded.region,
        city = excluded.city,
        latitude = excluded.latitude,
        longitude = excluded.longitude
    `).run({ ...event, now })
  })

  writeViewerEvent()
}

function deleteViewerData(payload) {
  const db = ensureAnalyticsDb()
  const visitorId = sanitizeText(payload.visitor_id, 80)
  const sessionId = sanitizeText(payload.session_id, 80)

  if (!visitorId && !sessionId) {
    throw new Error('A visitor or session identifier is required')
  }

  const result = db.transaction(() => {
    let eventsDeleted = 0
    let sessionsDeleted = 0

    if (visitorId) {
      eventsDeleted += db.prepare('delete from viewer_events where visitor_id = ?').run(visitorId).changes
      sessionsDeleted += db.prepare('delete from active_viewers where visitor_id = ?').run(visitorId).changes
    }

    if (sessionId) {
      eventsDeleted += db.prepare('delete from viewer_events where session_id = ?').run(sessionId).changes
      sessionsDeleted += db.prepare('delete from active_viewers where session_id = ?').run(sessionId).changes
    }

    return { events_deleted: eventsDeleted, sessions_deleted: sessionsDeleted }
  })()

  return result
}

function viewerDashboard() {
  const db = ensureAnalyticsDb()
  purgeOldAnalytics(db)

  const activeSince = new Date(Date.now() - ANALYTICS_ACTIVE_WINDOW_SECONDS * 1000).toISOString()
  const daySince = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
  const thirtyDaysSince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
  const active = db.prepare(`
    select
      visitor_id,
      page_path,
      page_title,
      min(first_seen_at) as first_seen_at,
      max(last_seen_at) as last_seen_at,
      max(referrer) as referrer,
      max(browser) as browser,
      max(os) as os,
      max(device) as device,
      max(screen) as screen,
      max(theme) as theme,
      max(language) as language,
      max(timezone) as timezone,
      max(country) as country,
      max(region) as region,
      max(city) as city,
      avg(latitude) as latitude,
      avg(longitude) as longitude,
      count(*) as sessions
      from active_viewers
     where last_seen_at >= ?
     group by visitor_id, page_path, page_title
     order by last_seen_at desc
     limit 100
  `).all(activeSince).map((row) => ({
    visitor: row.visitor_id.slice(0, 8),
    sessions: row.sessions || 1,
    first_seen_at: row.first_seen_at,
    last_seen_at: row.last_seen_at,
    page_path: row.page_path,
    page_title: row.page_title,
    referrer: row.referrer,
    browser: row.browser,
    os: row.os,
    device: row.device,
    screen: row.screen,
    theme: sanitizeTheme(row.theme),
    language: row.language,
    timezone: row.timezone,
    country: row.country,
    region: row.region,
    city: row.city,
    latitude: row.latitude,
    longitude: row.longitude,
  })).map(normalizeLocationSignal)

  const activePageMap = new Map()
  for (const viewer of active) {
    const key = `${viewer.page_path}|${viewer.page_title}`
    const existing = activePageMap.get(key) || {
      page_path: viewer.page_path,
      page_title: viewer.page_title,
      viewers: 0,
      visitors: new Set(),
      clients: new Map(),
      countries: new Set(),
      themes: new Map(),
      last_seen_at: viewer.last_seen_at,
    }

    existing.viewers += viewer.sessions || 1
    existing.visitors.add(viewer.visitor)
    if (viewer.country) existing.countries.add(viewer.country)
    existing.themes.set(viewer.theme, (existing.themes.get(viewer.theme) || 0) + (viewer.sessions || 1))
    const clientKey = `${viewer.browser} on ${viewer.os}`
    existing.clients.set(clientKey, (existing.clients.get(clientKey) || 0) + 1)
    if (new Date(viewer.last_seen_at).getTime() > new Date(existing.last_seen_at).getTime()) {
      existing.last_seen_at = viewer.last_seen_at
    }
    activePageMap.set(key, existing)
  }

  const activePages = Array.from(activePageMap.values())
    .map((page) => ({
      page_path: page.page_path,
      page_title: page.page_title,
      viewers: page.viewers,
      visitors: page.visitors.size,
      countries: Array.from(page.countries).sort(),
      themes: Array.from(page.themes.entries())
        .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
        .map(([theme, count]) => ({ theme, count })),
      clients: Array.from(page.clients.entries())
        .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
        .slice(0, 4)
        .map(([label, count]) => ({ label, count })),
      last_seen_at: page.last_seen_at,
    }))
    .sort((a, b) => b.viewers - a.viewers || new Date(b.last_seen_at) - new Date(a.last_seen_at))

  const topPages = db.prepare(`
    select page_path, page_title, count(*) as views
      from viewer_events
     where event_type = 'page_view'
       and occurred_at >= ?
     group by page_path, page_title
     order by views desc, page_path asc
     limit 12
  `).all(daySince)

  const topPages30d = db.prepare(`
    select page_path, page_title, count(*) as views
      from viewer_events
     where event_type = 'page_view'
       and occurred_at >= ?
     group by page_path, page_title
     order by views desc, page_path asc
     limit 12
  `).all(thirtyDaysSince)

  const clients = db.prepare(`
    select browser, os, device, count(*) as events, count(distinct visitor_id) as visitors
      from viewer_events
     where occurred_at >= ?
     group by browser, os, device
     order by events desc
     limit 12
  `).all(daySince)

  const clients30d = db.prepare(`
    select browser, os, device, count(*) as events, count(distinct visitor_id) as visitors
      from viewer_events
     where occurred_at >= ?
     group by browser, os, device
     order by events desc
     limit 12
  `).all(thirtyDaysSince)

  const themes = db.prepare(`
    select theme, count(*) as events, count(distinct visitor_id) as visitors
      from viewer_events
     where occurred_at >= ?
     group by theme
     order by events desc
  `).all(daySince).map((row) => ({ ...row, theme: sanitizeTheme(row.theme) }))

  const themes30d = db.prepare(`
    select theme, count(*) as events, count(distinct visitor_id) as visitors
      from viewer_events
     where occurred_at >= ?
     group by theme
     order by events desc
  `).all(thirtyDaysSince).map((row) => ({ ...row, theme: sanitizeTheme(row.theme) }))

  const activity24h = db.prepare(`
    select
      substr(occurred_at, 1, 13) || ':00:00.000Z' as date,
      count(*) as events,
      count(distinct visitor_id) as visitors,
      sum(case when event_type = 'page_view' then 1 else 0 end) as page_views,
      count(distinct browser || '|' || os || '|' || device) as clients,
      count(distinct case
        when country != '' then country
        when timezone != '' and timezone != 'Not shared' then timezone
        else null
      end) as locations
    from viewer_events
    where occurred_at >= ?
    group by substr(occurred_at, 1, 13)
    order by date asc
  `).all(daySince)

  const activity30d = db.prepare(`
    select
      date(occurred_at) as date,
      count(*) as events,
      count(distinct visitor_id) as visitors,
      sum(case when event_type = 'page_view' then 1 else 0 end) as page_views,
      count(distinct browser || '|' || os || '|' || device) as clients,
      count(distinct case
        when country != '' then country
        when timezone != '' and timezone != 'Not shared' then timezone
        else null
      end) as locations
    from viewer_events
    where occurred_at >= ?
    group by date(occurred_at)
    order by date asc
  `).all(thirtyDaysSince)

  const activityLocationRows24h = db.prepare(`
    select
      substr(occurred_at, 1, 13) || ':00:00.000Z' as date,
      coalesce(nullif(country, ''), '') as country,
      coalesce(nullif(city, ''), '') as city,
      coalesce(nullif(region, ''), '') as region,
      coalesce(nullif(timezone, ''), '') as timezone,
      count(distinct visitor_id) as visitors
    from viewer_events
    where occurred_at >= ?
      and (
        country != ''
        or city != ''
        or region != ''
        or (timezone != '' and timezone != 'Not shared')
      )
    group by substr(occurred_at, 1, 13), coalesce(nullif(country, ''), ''), coalesce(nullif(city, ''), ''), coalesce(nullif(region, ''), ''), coalesce(nullif(timezone, ''), '')
    order by date asc, visitors desc
  `).all(daySince)

  const activityLocationRows = db.prepare(`
    select
      date(occurred_at) as date,
      coalesce(nullif(country, ''), '') as country,
      coalesce(nullif(city, ''), '') as city,
      coalesce(nullif(region, ''), '') as region,
      coalesce(nullif(timezone, ''), '') as timezone,
      count(distinct visitor_id) as visitors
    from viewer_events
    where occurred_at >= ?
      and (
        country != ''
        or city != ''
        or region != ''
        or (timezone != '' and timezone != 'Not shared')
      )
    group by date(occurred_at), coalesce(nullif(country, ''), ''), coalesce(nullif(city, ''), ''), coalesce(nullif(region, ''), ''), coalesce(nullif(timezone, ''), '')
    order by date asc, visitors desc
  `).all(thirtyDaysSince)

  const locationsByDate = new Map()
  const locations24hByDate = new Map()

  for (const row of activityLocationRows24h) {
    const label = [row.city, row.region, row.country || row.timezone]
      .filter(Boolean)
      .join(', ')
    if (!label) continue
    const current = locations24hByDate.get(row.date) || []
    current.push({ label, visitors: row.visitors || 0 })
    locations24hByDate.set(row.date, current)
  }

  for (const row of activityLocationRows) {
    const label = [row.city, row.region, row.country || row.timezone]
      .filter(Boolean)
      .join(', ')
    if (!label) continue
    const current = locationsByDate.get(row.date) || []
    current.push({ label, visitors: row.visitors || 0 })
    locationsByDate.set(row.date, current)
  }

  const activityClientRows24h = db.prepare(`
    select
      substr(occurred_at, 1, 13) || ':00:00.000Z' as date,
      browser,
      os,
      device,
      count(distinct visitor_id) as visitors,
      count(*) as events
    from viewer_events
    where occurred_at >= ?
    group by substr(occurred_at, 1, 13), browser, os, device
    order by date asc, visitors desc, events desc
  `).all(daySince)

  const activityClientRows = db.prepare(`
    select
      date(occurred_at) as date,
      browser,
      os,
      device,
      count(distinct visitor_id) as visitors,
      count(*) as events
    from viewer_events
    where occurred_at >= ?
    group by date(occurred_at), browser, os, device
    order by date asc, visitors desc, events desc
  `).all(thirtyDaysSince)

  const clientsByDate = new Map()
  const clients24hByDate = new Map()

  for (const row of activityClientRows24h) {
    const label = `${row.browser} on ${row.os}${row.device ? ` (${row.device})` : ''}`
    const current = clients24hByDate.get(row.date) || []
    if (current.length < 4) current.push({ label, visitors: row.visitors || 0, events: row.events || 0 })
    clients24hByDate.set(row.date, current)
  }

  for (const row of activityClientRows) {
    const label = `${row.browser} on ${row.os}${row.device ? ` (${row.device})` : ''}`
    const current = clientsByDate.get(row.date) || []
    if (current.length < 4) current.push({ label, visitors: row.visitors || 0, events: row.events || 0 })
    clientsByDate.set(row.date, current)
  }

  const activity24hWithLabels = activity24h.map((row) => ({
    ...row,
    client_labels: clients24hByDate.get(row.date) || [],
    location_labels: locations24hByDate.get(row.date) || [],
  }))

  const activityWithLocations = activity30d.map((row) => ({
    ...row,
    client_labels: clientsByDate.get(row.date) || [],
    location_labels: locationsByDate.get(row.date) || [],
  }))

  const countries24h = db.prepare(`
    select
      country,
      avg(latitude) as latitude,
      avg(longitude) as longitude,
      count(*) as events,
      count(distinct visitor_id) as visitors
      from viewer_events
     where occurred_at >= ?
       and country != ''
       and latitude is not null
       and longitude is not null
     group by country
     order by visitors desc, events desc
     limit 80
  `).all(daySince)

  const countries = db.prepare(`
    select
      country,
      avg(latitude) as latitude,
      avg(longitude) as longitude,
      count(*) as events,
      count(distinct visitor_id) as visitors
      from viewer_events
     where occurred_at >= ?
       and country != ''
       and latitude is not null
       and longitude is not null
     group by country
     order by visitors desc, events desc
     limit 80
  `).all(thirtyDaysSince)

  const locations24h = db.prepare(`
    select
      country,
      region,
      city,
      latitude,
      longitude,
      timezone,
      language,
      count(*) as events,
      count(distinct visitor_id) as visitors
      from viewer_events
     where occurred_at >= ?
       and latitude is not null
       and longitude is not null
     group by country, region, city, latitude, longitude, timezone, language
     order by visitors desc, events desc
     limit 32
  `).all(daySince).map(normalizeLocationSignal)

  const locations = db.prepare(`
    select
      country,
      region,
      city,
      latitude,
      longitude,
      timezone,
      language,
      count(*) as events,
      count(distinct visitor_id) as visitors
      from viewer_events
     where occurred_at >= ?
       and latitude is not null
       and longitude is not null
     group by country, region, city, latitude, longitude, timezone, language
     order by visitors desc, events desc
     limit 32
  `).all(thirtyDaysSince).map(normalizeLocationSignal)

  const totals = db.prepare(`
    select
      count(*) as events_24h,
      count(distinct visitor_id) as visitors_24h,
      count(distinct session_id) as sessions_24h,
      sum(case when event_type = 'page_view' then 1 else 0 end) as page_views_24h
    from viewer_events
    where occurred_at >= ?
  `).get(daySince)

  const totals30d = db.prepare(`
    select
      count(*) as events_30d,
      count(distinct visitor_id) as visitors_30d,
      count(distinct session_id) as sessions_30d,
      sum(case when event_type = 'page_view' then 1 else 0 end) as page_views_30d
    from viewer_events
    where occurred_at >= ?
  `).get(thirtyDaysSince)

  return {
    active_window_seconds: ANALYTICS_ACTIVE_WINDOW_SECONDS,
    generated_at: new Date().toISOString(),
    active,
    active_pages: activePages,
    top_pages: topPages,
    top_pages_30d: topPages30d,
    clients,
    clients_30d: clients30d,
    themes,
    themes_30d: themes30d,
    activity_24h: activity24hWithLabels,
    activity_30d: activityWithLocations,
    countries_24h: countries24h,
    countries,
    locations_24h: locations24h,
    locations,
    totals: {
      active_now: active.length,
      events_24h: totals?.events_24h || 0,
      visitors_24h: totals?.visitors_24h || 0,
      sessions_24h: totals?.sessions_24h || 0,
      page_views_24h: totals?.page_views_24h || 0,
      events_30d: totals30d?.events_30d || 0,
      visitors_30d: totals30d?.visitors_30d || 0,
      sessions_30d: totals30d?.sessions_30d || 0,
      page_views_30d: totals30d?.page_views_30d || 0,
    },
    data_types: [
      { key: 'page', label: 'Page activity', detail: 'Page path, title, page views, and heartbeat state' },
      { key: 'browser', label: 'Browser and device', detail: 'Browser, operating system, broad device type, and user-agent only when allowed' },
      { key: 'display', label: 'Display', detail: 'Screen size, viewport size, pixel ratio, and colour depth when allowed' },
      { key: 'locale', label: 'Language and coarse location', detail: 'Browser language and timezone when allowed, plus Cloudflare country/city coordinates when supplied by the edge' },
      { key: 'referrer', label: 'Referrer', detail: 'The referring page when allowed and provided by the browser' },
      { key: 'diagnostics', label: 'Diagnostics', detail: 'Privacy signals, network hints, request headers, and browser capability details when allowed' },
    ],
    privacy: {
      retention_days: ANALYTICS_RETENTION_DAYS,
      stores_ip_hashes: false,
      exposes_raw_ip: false,
      exposes_precise_location: false,
    },
  }
}

function isRateLimited(req) {
  const now = Date.now()
  const ip = clientIp(req)
  const current = rateLimits.get(ip)

  if (!current || current.resetAt <= now) {
    rateLimits.set(ip, { count: 1, resetAt: now + API_RATE_LIMIT_WINDOW_MS })
    return false
  }

  current.count += 1
  return current.count > API_RATE_LIMIT_MAX
}

function pruneMaps() {
  const now = Date.now()

  for (const [key, value] of apiCache) {
    if (value.expiresAt <= now || apiCache.size > MAX_CACHE_ENTRIES) apiCache.delete(key)
  }

  for (const [key, value] of rateLimits) {
    if (value.resetAt <= now || rateLimits.size > MAX_RATE_LIMIT_KEYS) rateLimits.delete(key)
  }
}

function allowedApiTarget(req) {
  if (req.method !== 'GET' && req.method !== 'HEAD') return null

  const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
  const url = new URL(`${requestUrl.pathname}${requestUrl.search}`, API_UPSTREAM)
  const params = requestUrl.searchParams
  const pathname = requestUrl.pathname

  if (pathname === '/api/tss/leaderboard/teams') {
    const keys = [...params.keys()]
    const limit = Number(params.get('limit') || 100)
    if (keys.some((key) => key !== 'limit') || !Number.isInteger(limit) || limit < 1 || limit > 100) {
      return null
    }
    return url
  }

  if (pathname === '/api/tss/leaderboard/players') {
    const keys = [...params.keys()]
    const limit = Number(params.get('limit') || 100)
    if (keys.some((key) => key !== 'limit') || !Number.isInteger(limit) || limit < 1 || limit > 100) {
      return null
    }
    return url
  }

  if (pathname === '/api/tss/games/recent') {
    const keys = [...params.keys()]
    const limit = Number(params.get('limit') || 50)
    if (keys.some((key) => key !== 'limit') || !Number.isInteger(limit) || limit < 1 || limit > 100) {
      return null
    }
    return url
  }

  if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}$/.test(pathname)) {
    const keys = [...params.keys()]
    const lang = params.get('lang') || 'en'
    if (keys.some((key) => key !== 'lang') || !/^[A-Za-z-]{2,8}$/.test(lang)) {
      return null
    }
    return url
  }

  if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}\/logs$/.test(pathname)) {
    if ([...params.keys()].length) return null
    return url
  }

  if (pathname === '/api/tss/tournaments') {
    if ([...params.keys()].length) return null
    return url
  }

  if (/^\/api\/tss\/tournaments\/[0-9]{1,18}$/.test(pathname)) {
    if ([...params.keys()].length) return null
    return url
  }

  if (pathname === '/api/tss/teams/resolve') {
    const keys = [...params.keys()]
    const name = params.get('name') || ''
    if (keys.some((key) => key !== 'name') || name.length < 2 || name.length > MAX_TEAM_NAME_LENGTH) {
      return null
    }
    return url
  }

  if (pathname === '/api/tss/teams/search') {
    const keys = [...params.keys()]
    const query = params.get('q') || params.get('name') || ''
    const limit = Number(params.get('limit') || 10)
    if (
      keys.some((key) => !['q', 'name', 'limit'].includes(key)) ||
      query.length < 2 ||
      query.length > MAX_TEAM_NAME_LENGTH ||
      !Number.isInteger(limit) ||
      limit < 1 ||
      limit > 20
    ) {
      return null
    }
    return url
  }

  if (pathname === '/api/tss/players/resolve') {
    const keys = [...params.keys()]
    const name = params.get('name') || ''
    if (keys.some((key) => key !== 'name') || name.length < 2 || name.length > MAX_TEAM_NAME_LENGTH) {
      return null
    }
    return url
  }

  if (pathname === '/api/tss/players/search') {
    const keys = [...params.keys()]
    const query = params.get('q') || params.get('name') || ''
    const limit = Number(params.get('limit') || 25)
    if (
      keys.some((key) => !['q', 'name', 'limit'].includes(key)) ||
      query.length < 2 ||
      query.length > MAX_TEAM_NAME_LENGTH ||
      !Number.isInteger(limit) ||
      limit < 1 ||
      limit > 25
    ) {
      return null
    }
    return url
  }

  if (/^\/api\/tss\/player\/[0-9]{1,32}$/.test(pathname)) {
    if ([...params.keys()].length) return null
    return url
  }

  const teamMatch = pathname.match(/^\/api\/tss\/teams\/([^/]+)(?:\/(history|games))?$/)
  if (!teamMatch || [...params.keys()].length) return null

  try {
    const teamName = decodeURIComponent(teamMatch[1])
    if (!teamName || teamName.length > MAX_TEAM_NAME_LENGTH) return null
  } catch {
    return null
  }

  return url
}

function proxyRequest(req, res) {
  pruneMaps()

  if (!isSameOriginRequest(req)) {
    return sendJson(res, 403, { error: 'API access is restricted to this site' })
  }

  if (isRateLimited(req)) {
    return sendJson(res, 429, { error: 'Too many API requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
  }

  const target = allowedApiTarget(req)
  if (!target) {
    return sendJson(res, 404, { error: 'API route not found' })
  }

  const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
  const publicDataFile = req.method === 'GET' ? publicDataCachePathForUrl(requestUrl) : null
  const publicData = cachedPublicData(publicDataFile)
  if (publicData?.fresh) {
    return sendPublicDataFile(req, res, publicData.filePath, 200, { 'x-tssbot-cache': 'public-data-hit' })
  }
  if (publicData) {
    refreshPublicData(publicData.filePath, target)
    return sendPublicDataFile(req, res, publicData.filePath, 200, { 'x-tssbot-cache': 'public-data-stale' })
  }

  const cacheKey = req.method === 'GET' ? target.toString() : ''
  const cached = cacheKey ? apiCache.get(cacheKey) : null
  if (cached && cached.expiresAt > Date.now()) {
    return send(res, 200, cached.body, cached.headers)
  }

  const responseChunks = []
  let proxiedBytes = 0
  const proxy = http.request(
    target,
    {
      method: req.method,
      headers: {
        accept: 'application/json',
        host: target.host,
        'user-agent': req.headers['user-agent'] || 'tssbot-web',
      },
    },
    (proxyRes) => {
      const statusCode = proxyRes.statusCode || 502
      const contentType = String(proxyRes.headers['content-type'] || '')
      const shouldCacheJson = statusCode >= 200 && statusCode < 300 && contentType.includes('application/json')
      const headers = {
        ...proxyRes.headers,
        ...securityHeaders(req),
        'cache-control': publicDataFile
          ? `public, max-age=${PUBLIC_DATA_CACHE_MAX_AGE_SECONDS}, stale-while-revalidate=${PUBLIC_DATA_STALE_REVALIDATE_SECONDS}`
          : 'private, max-age=15',
      }
      if (publicDataFile) headers['x-tssbot-cache'] = 'public-data-miss'

      delete headers['access-control-allow-origin']
      delete headers['access-control-allow-credentials']
      delete headers['access-control-allow-methods']
      delete headers['access-control-allow-headers']
      delete headers['access-control-expose-headers']

      res.writeHead(statusCode, headers)

      proxyRes.on('data', (chunk) => {
        proxiedBytes += chunk.length
        if (proxiedBytes > MAX_UPSTREAM_BODY_BYTES) {
          proxy.destroy(new Error('Upstream response too large'))
          res.destroy()
          return
        }
        if (cacheKey && shouldCacheJson) {
          responseChunks.push(chunk)
        }
      })

      proxyRes.on('end', () => {
        if (cacheKey && responseChunks.length) {
          const body = Buffer.concat(responseChunks)
          apiCache.set(cacheKey, {
            body,
            headers,
            expiresAt: Date.now() + API_CACHE_TTL_MS,
          })
          if (publicDataFile) {
            try {
              writePublicDataFile(publicDataFile, body)
            } catch (error) {
              console.warn(`could not write public data cache for ${requestUrl.pathname}: ${error.message}`)
            }
          }
        }
      })

      proxyRes.pipe(res)
    },
  )

  proxy.on('error', (error) => {
    if (res.destroyed || res.headersSent) return
    sendJson(res, 502, { error: 'API proxy failed', detail: error.message })
  })

  req.pipe(proxy)
}

function pagePublicOrigin(req) {
  const configured = PUBLIC_ORIGIN.split(',').map((origin) => origin.trim()).filter(Boolean)[0]
  if (configured) return configured.replace(/\/$/, '')

  const host = trustedForwardedHost(req) || `localhost:${PORT}`
  const proto = trustedForwardedProto(req) || (req.socket.encrypted ? 'https' : 'http')
  return `${String(proto).split(',')[0].trim()}://${String(host).split(',')[0].trim()}`.replace(/\/$/, '')
}

function escapeHtml(value) {
  return String(value || '')
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
}

function decodeRouteSegment(value) {
  try {
    return decodeURIComponent(value || '').replace(/\+/g, ' ').trim()
  } catch {
    return ''
  }
}

function routeSeo(pathname) {
  const cleanPath = pathname.replace(/\/+$/, '') || '/'

  if (cleanPath.startsWith('/teams/')) {
    const teamName = decodeRouteSegment(cleanPath.slice('/teams/'.length))
    if (teamName) {
      return {
        title: `${teamName} TSS Team Profile | Toothless' TSS Bot`,
        description: `${teamName} TSS team profile with roster details, battle history, and recent War Thunder squadron activity.`,
        robots: 'index, follow',
        path: `/teams/${encodeURIComponent(teamName)}`,
      }
    }
  }

  if (cleanPath.startsWith('/players/')) {
    const uid = decodeRouteSegment(cleanPath.slice('/players/'.length))
    if (uid) {
      return {
        title: `Player ${uid} | Toothless' TSS Bot`,
        description: `TSS career stats for player ${uid} — battles, win rate, kills, and teams seen with.`,
        robots: 'noindex, follow',
        path: `/players/${encodeURIComponent(uid)}`,
      }
    }
  }

  const byPath = {
    '/': {
      title: "Toothless' TSS Bot | Live TSS Leaderboards and Battle Logs",
      description: 'Live War Thunder TSS team leaderboards, battle logs, team profiles, uptime, and privacy-safe viewer analytics from Toothless TSS Bot.',
      robots: 'index, follow',
      path: '/',
    },
    '/teams': {
      title: "TSS Team Leaderboard | Toothless' TSS Bot",
      description: 'Browse the live TSS team leaderboard, compare War Thunder squadron rankings, points, players, and recent team activity.',
      robots: 'index, follow',
      path: '/teams',
    },
    '/players': {
      title: "TSS Player Leaderboard | Toothless' TSS Bot",
      description: 'Browse the TSS player leaderboard, compare War Thunder player score, kills, win rate, KDR, and battle activity.',
      robots: 'index, follow',
      path: '/players',
    },
    '/battle-logs': {
      title: "TSS Battle Logs | Toothless' TSS Bot",
      description: 'Read recent TSS battle logs with team names, map history, player counts, battle times, and War Thunder match context.',
      robots: 'index, follow',
      path: '/battle-logs',
    },
    '/live': {
      title: "TSS Battle Logs | Toothless' TSS Bot",
      description: 'Read recent TSS battle logs with team names, map history, player counts, battle times, and War Thunder match context.',
      robots: 'index, follow',
      path: '/battle-logs',
    },
    '/uptime': {
      title: "TSS Bot Uptime Status | Toothless' TSS Bot",
      description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.',
      robots: 'index, follow',
      path: '/uptime',
    },
    '/viewers': {
      title: "Viewer Analytics | Toothless' TSS Bot",
      description: 'Opt-in viewer analytics for Toothless TSS Bot, including active pages, broad device signals, and privacy-safe activity trends.',
      robots: 'noindex, follow',
      path: '/viewers',
    },
    '/privacy': {
      title: "Privacy Notice | Toothless' TSS Bot",
      description: 'How Toothless TSS Bot handles cookies, opt-in analytics, viewer data, retention, deletion, and privacy rights.',
      robots: 'index, follow',
      path: '/privacy',
    },
    '/docs': {
      title: "Docs | Toothless' TSS Bot",
      description: 'Documentation and command reference for Toothless TSS Bot.',
      robots: 'noindex, follow',
      path: '/docs',
    },
  }

  return byPath[cleanPath] || byPath['/']
}

function routeStructuredData(origin, seo, canonicalUrl) {
  return JSON.stringify([
    {
      '@context': 'https://schema.org',
      '@type': 'WebSite',
      name: "Toothless' TSS Bot",
      url: `${origin}/`,
      description: 'Live War Thunder TSS leaderboards, battle logs, and team profiles.',
      potentialAction: {
        '@type': 'SearchAction',
        target: `${origin}/teams/{search_term_string}`,
        'query-input': 'required name=search_term_string',
      },
    },
    {
      '@context': 'https://schema.org',
      '@type': 'WebPage',
      name: seo.title,
      url: canonicalUrl,
      description: seo.description,
      isPartOf: {
        '@type': 'WebSite',
        name: "Toothless' TSS Bot",
        url: `${origin}/`,
      },
    },
  ])
}

function htmlWithSeo(req, data) {
  const origin = pagePublicOrigin(req)
  let pathname = '/'
  try {
    pathname = new URL(req.url, origin).pathname
  } catch {
    pathname = '/'
  }

  const seo = routeSeo(pathname)
  const canonicalUrl = `${origin}${seo.path}`

  return data.toString('utf8')
    .replace(/\s+integrity=(["'])sha(?:256|384|512)-[^"']+\1/g, '')
    .replaceAll('__PUBLIC_ORIGIN__', origin)
    .replaceAll('__SEO_TITLE__', escapeHtml(seo.title))
    .replaceAll('__SEO_DESCRIPTION__', escapeHtml(seo.description))
    .replaceAll('__SEO_ROBOTS__', escapeHtml(seo.robots))
    .replaceAll('__SEO_CANONICAL__', escapeHtml(canonicalUrl))
    .replaceAll('__SEO_JSON_LD__', routeStructuredData(origin, seo, canonicalUrl).replace(/</g, '\\u003c'))
    .replaceAll('__TURNSTILE_SESSION__', isTurnstileSessionVerified(req) ? 'verified' : 'required')
}

function sendHtml(req, res, data, status = 200) {
  const html = htmlWithSeo(req, data)
  send(res, status, html, {
    ...securityHeaders(req, { html: true }),
    'content-type': mimeTypes['.html'],
    'cache-control': 'no-cache',
  })
}

function sendRobotsTxt(req, res) {
  const origin = pagePublicOrigin(req)
  const body = [
    'User-agent: *',
    'Allow: /',
    'Disallow: /api/',
    'Disallow: /viewers',
    `Sitemap: ${origin}/sitemap.xml`,
    '',
  ].join('\n')

  send(res, 200, body, {
    'content-type': 'text/plain; charset=utf-8',
    'cache-control': 'public, max-age=3600',
  })
}

function sendSitemapXml(req, res) {
  const origin = pagePublicOrigin(req)
  const today = new Date().toISOString().slice(0, 10)
  const urls = [
    { path: '/', priority: '1.0', changefreq: 'hourly' },
    { path: '/teams', priority: '0.9', changefreq: 'hourly' },
    { path: '/battle-logs', priority: '0.9', changefreq: 'hourly' },
    { path: '/uptime', priority: '0.5', changefreq: 'daily' },
    { path: '/privacy', priority: '0.3', changefreq: 'monthly' },
  ]
  const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map((url) => `  <url>
    <loc>${escapeHtml(`${origin}${url.path}`)}</loc>
    <lastmod>${today}</lastmod>
    <changefreq>${url.changefreq}</changefreq>
    <priority>${url.priority}</priority>
  </url>`).join('\n')}
</urlset>
`

  send(res, 200, body, {
    'content-type': 'application/xml; charset=utf-8',
    'cache-control': 'public, max-age=3600',
  })
}

function serveStatic(req, res) {
  let requestPath = '/'
  try {
    requestPath = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname)
  } catch {
    return send(res, 400, 'Bad request', { 'content-type': 'text/plain; charset=utf-8' })
  }
  const relativePath = requestPath === '/' ? '/index.html' : requestPath
  const filePath = path.resolve(DIST_DIR, `.${relativePath}`)
  const relativeToDist = path.relative(DIST_DIR, filePath)

  if (relativeToDist.startsWith('..') || path.isAbsolute(relativeToDist)) {
    return send(res, 403, 'Forbidden', { 'content-type': 'text/plain; charset=utf-8' })
  }

  fs.stat(filePath, (error, stat) => {
    if (error || !stat.isFile()) {
      fs.readFile(path.join(DIST_DIR, 'index.html'), (indexError, indexData) => {
        if (indexError) {
          return send(res, 404, 'Build not found. Run npm run build first.', {
            'content-type': 'text/plain; charset=utf-8',
          })
        }

        sendHtml(req, res, indexData)
      })
      return
    }

    const ext = path.extname(filePath)
    if (ext === '.html') {
      fs.readFile(filePath, (htmlError, htmlData) => {
        if (htmlError) {
          return send(res, 500, 'Error reading file', { 'content-type': 'text/plain; charset=utf-8' })
        }
        sendHtml(req, res, htmlData)
      })
      return
    }

    const contentType = mimeTypes[ext] || 'application/octet-stream'
    const range = req.headers.range

    if (range) {
      const parts = range.replace(/bytes=/, '').split('-')
      const start = parseInt(parts[0], 10)
      const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1

      if (isNaN(start) || start < 0 || start >= stat.size || end >= stat.size || start > end) {
        res.writeHead(416, {
          ...securityHeaders(req),
          'Content-Range': `bytes */${stat.size}`,
          'Accept-Ranges': 'bytes',
          'content-type': 'text/plain',
        })
        return res.end('Requested Range Not Satisfiable')
      }

      const chunksize = end - start + 1
      const fileStream = fs.createReadStream(filePath, { start, end })
      const headers = {
        ...securityHeaders(req),
        'Content-Range': `bytes ${start}-${end}/${stat.size}`,
        'Accept-Ranges': 'bytes',
        'Content-Length': String(chunksize),
        'content-type': contentType,
        'cache-control': 'public, max-age=31536000, immutable',
      }
      res.writeHead(206, headers)
      fileStream.pipe(res)
      fileStream.on('error', (err) => {
        console.error(`Stream error serving ${filePath}:`, err)
      })
    } else {
      const fileStream = fs.createReadStream(filePath)
      const headers = {
        ...securityHeaders(req),
        'Accept-Ranges': 'bytes',
        'Content-Length': String(stat.size),
        'content-type': contentType,
        'cache-control': 'public, max-age=31536000, immutable',
      }
      res.writeHead(200, headers)
      fileStream.pipe(res)
      fileStream.on('error', (err) => {
        console.error(`Stream error serving ${filePath}:`, err)
      })
    }
  })
}

function serveVehicleIcon(req, res) {
  let requestPath = '/'
  try {
    requestPath = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname)
  } catch {
    return send(res, 400, 'Bad request', { 'content-type': 'text/plain; charset=utf-8' })
  }
  const name = requestPath.slice('/vehicle-icons/'.length)
  const filePath = path.resolve(VEHICLE_ICONS_DIR, `./${name}`)
  const relative = path.relative(VEHICLE_ICONS_DIR, filePath)
  if (relative.startsWith('..') || path.isAbsolute(relative) || path.extname(filePath) !== '.png') {
    return send(res, 403, 'Forbidden', { 'content-type': 'text/plain; charset=utf-8' })
  }
  fs.readFile(filePath, (error, data) => {
    if (error) {
      return send(res, 404, 'Not found', { 'content-type': 'text/plain; charset=utf-8' })
    }
    send(res, 200, data, {
      'content-type': 'image/png',
      'cache-control': 'public, max-age=604800',
    })
  })
}

function safeUniquePaths(paths) {
  return [...new Set(paths.filter(Boolean).map((value) => path.resolve(value)))]
}

function resolveTssReplaySessionDir(sessionId) {
  const sid = String(sessionId || '').toLowerCase()
  const candidates = safeUniquePaths([
    path.join(TSS_REPLAYS_DIR, sid),
    path.join(TSS_REPLAYS_DIR, `0${sid}`),
    path.join(TSS_REPLAY_SAMPLE_DIR, sid),
    path.join(TSS_REPLAY_SAMPLE_DIR, `0${sid}`),
  ])

  for (const dir of candidates) {
    try {
      if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) return dir
    } catch {
      // Keep trying the remaining replay roots.
    }
  }

  return path.join(TSS_REPLAYS_DIR, sid)
}

function findTssReplayDataPath(sessionDir) {
  const candidates = [
    path.join(sessionDir, 'replay_data.json.gz'),
    path.join(sessionDir, 'replay_data.json'),
  ]
  for (const candidate of candidates) {
    try {
      if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate
    } catch {
      // Ignore unreadable candidates and let the caller return 404.
    }
  }
  return null
}

function readFileResponse(req, res, filePath, headers = {}) {
  fs.readFile(filePath, (error, data) => {
    if (error) {
      sendJson(res, 404, { error: 'File not found' })
      return
    }
    send(res, 200, data, headers)
  })
}

function runReplayCanvasRenderer(replayPath, jsonPath) {
  const pythonBin = fs.existsSync(TSS_REPLAY_PYTHON) ? TSS_REPLAY_PYTHON : 'python3'
  return new Promise((resolve, reject) => {
    execFile(
      pythonBin,
      ['-m', 'BOT.render_replay', replayPath, jsonPath],
      {
        cwd: TSSBOT_REPO_DIR,
        timeout: TSS_REPLAY_RENDER_TIMEOUT_MS,
        env: process.env,
      },
      (error, stdout, stderr) => {
        if (error) {
          reject(new Error(String(stderr || stdout || error.message || 'Replay renderer failed').trim()))
          return
        }
        resolve()
      },
    )
  })
}

let tssCanvasRenderCount = 0
const TSS_CANVAS_RENDER_MAX = 3

async function serveTssReplayCanvas(req, res, sessionId) {
  if (!sessionId || !/^[A-Za-z0-9_-]{1,96}$/.test(sessionId)) {
    sendJson(res, 400, { error: 'Invalid game ID' })
    return
  }
  if (!isSameOriginRequest(req)) {
    sendJson(res, 403, { error: 'API access is restricted to this site' })
    return
  }
  if (isRateLimited(req)) {
    sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
    return
  }

  const sessionDir = resolveTssReplaySessionDir(sessionId)
  const replayPath = findTssReplayDataPath(sessionDir)
  const jsonPath = path.join(sessionDir, 'replay_canvas.json')

  if (!replayPath) {
    sendJson(res, 404, { available: false, reason: 'No replay data available' })
    return
  }

  try {
    const jsonStat = fs.existsSync(jsonPath) ? fs.statSync(jsonPath) : null
    const replayStat = fs.statSync(replayPath)
    if (jsonStat && jsonStat.size > 0 && jsonStat.mtimeMs >= replayStat.mtimeMs) {
      readFileResponse(req, res, jsonPath, {
        'content-type': 'application/json; charset=utf-8',
        'cache-control': 'public, max-age=86400',
      })
      return
    }
  } catch {
    // Fall through and attempt regeneration.
  }

  if (tssCanvasRenderCount >= TSS_CANVAS_RENDER_MAX) {
    sendJson(res, 503, { available: false, reason: 'Too many replays processing; try again shortly' })
    return
  }

  tssCanvasRenderCount += 1
  try {
    await runReplayCanvasRenderer(replayPath, jsonPath)
    if (!fs.existsSync(jsonPath)) {
      sendJson(res, 500, { available: false, reason: 'Replay JSON generation produced no output' })
      return
    }
    readFileResponse(req, res, jsonPath, {
      'content-type': 'application/json; charset=utf-8',
      'cache-control': 'public, max-age=86400',
    })
  } catch (error) {
    try {
      if (fs.existsSync(jsonPath)) fs.unlinkSync(jsonPath)
    } catch {
      // Ignore cache cleanup errors.
    }
    sendJson(res, 500, { available: false, reason: 'Replay JSON generation failed', detail: error.message })
  } finally {
    tssCanvasRenderCount -= 1
  }
}

function serveReplayIcon(req, res) {
  let iconName = ''
  try {
    const requestPath = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname)
    iconName = requestPath.slice('/api/icons/type/'.length)
  } catch {
    sendJson(res, 400, { error: 'Bad request' })
    return
  }

  if (!iconName || !/^[A-Za-z0-9_-]+$/.test(iconName)) {
    sendJson(res, 400, { error: 'Invalid icon name' })
    return
  }

  const iconsBase = path.join(SHARED_DIR, 'ICONS')
  const candidates = [
    path.join(iconsBase, `${iconName}.png`),
    path.join(iconsBase, 'FALLBACKS', `${iconName}.png`),
    path.join(iconsBase, 'MINIS', `${iconName}.png`),
  ]
  for (const candidate of candidates) {
    const relative = path.relative(iconsBase, candidate)
    if (relative.startsWith('..') || path.isAbsolute(relative)) continue
    if (fs.existsSync(candidate)) {
      readFileResponse(req, res, candidate, {
        'content-type': 'image/png',
        'cache-control': 'public, max-age=604800',
      })
      return
    }
  }
  sendJson(res, 404, { error: 'Icon not found' })
}

function serveReplayMinimap(req, res) {
  let level = ''
  let fullMap = false
  try {
    const url = new URL(req.url, `http://localhost:${PORT}`)
    const requestPath = decodeURIComponent(url.pathname)
    level = requestPath.slice('/api/match/minimap/'.length)
    fullMap = url.searchParams.get('type') === 'full'
  } catch {
    sendJson(res, 400, { error: 'Bad request' })
    return
  }

  if (!level || !/^[A-Za-z0-9_]+$/.test(level)) {
    sendJson(res, 400, { error: 'Invalid level name' })
    return
  }

  const minimapsDir = path.join(SHARED_DIR, 'MAPS', 'MINIMAPS')
  const names = fullMap
    ? [`${level}.png`, `${level}_map.png`]
    : [`${level}_tankmap.png`, `${level}.png`, `${level}_map.png`]

  for (const name of [...new Set(names)]) {
    const candidate = path.resolve(minimapsDir, name)
    const relative = path.relative(minimapsDir, candidate)
    if (relative.startsWith('..') || path.isAbsolute(relative)) continue
    if (fs.existsSync(candidate)) {
      readFileResponse(req, res, candidate, {
        'content-type': 'image/png',
        'cache-control': 'public, max-age=604800',
      })
      return
    }
  }

  sendJson(res, 404, { error: 'Minimap not found' })
}

const server = http.createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/robots.txt') {
    sendRobotsTxt(req, res)
    return
  }

  if (req.method === 'GET' && req.url === '/sitemap.xml') {
    sendSitemapXml(req, res)
    return
  }

  if (req.url === '/health') {
    sendJson(res, 200, { ok: true, public_data: publicDataStartupStatus })
    return
  }

  if (req.method === 'GET' && req.url === '/api/uptime') {
    if (isRateLimited(req)) {
      sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
      return
    }
    uptimeHistory()
      .then((data) => sendJson(res, 200, data))
      .catch((error) => sendJson(res, 500, { error: 'Uptime history unavailable', detail: error.message }))
    return
  }

  if (req.method === 'POST' && req.url === '/api/cache/prewarm') {
    if (!isSameOriginRequest(req)) {
      sendJson(res, 403, { error: 'Cache prewarm is restricted to this site' })
      return
    }
    if (isRateLimited(req)) {
      sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
      return
    }
    prewarmPublicDataCache({ force: true, log: true })
      .then((status) => sendJson(res, status.ready ? 200 : 207, status))
      .catch((error) => sendJson(res, 500, { error: 'Cache prewarm failed', detail: error.message }))
    return
  }

  if (req.method === 'GET' && req.url === '/api/viewers') {
    if (isRateLimited(req)) {
      sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
      return
    }
    try {
      sendJson(res, 200, viewerDashboard())
    } catch (error) {
      sendJson(res, 500, { error: 'Viewer analytics unavailable', detail: error.message })
    }
    return
  }

  if (req.method === 'POST' && req.url === '/api/viewers/event') {
    if (!isSameOriginRequest(req)) {
      sendJson(res, 403, { error: 'Analytics events are restricted to this site' })
      return
    }

    if (isRateLimited(req)) {
      sendJson(res, 429, { error: 'Too many analytics events' })
      return
    }

    readJsonBody(req)
      .then((payload) => {
        recordViewerEvent(req, payload)
        send(res, 204, '', { 'cache-control': 'no-store' })
      })
      .catch((error) => sendJson(res, 400, { error: error.message }))
    return
  }

  if (req.method === 'POST' && req.url === '/api/viewers/delete') {
    if (!isSameOriginRequest(req)) {
      sendJson(res, 403, { error: 'Analytics deletion is restricted to this site' })
      return
    }

    if (!isTurnstileSessionVerified(req)) {
      sendJson(res, 403, { error: 'Turnstile session required', detail: 'Solve the site challenge first' })
      return
    }

    readJsonBody(req)
      .then((payload) => {
        const result = deleteViewerData(payload)
        sendJson(res, 200, result)
      })
      .catch((error) => sendJson(res, 400, { error: error.message }))
    return
  }

  if (req.method === 'POST' && req.url === '/api/turnstile/verify') {
    if (!isSameOriginRequest(req)) {
      sendJson(res, 403, { error: 'Turnstile verification is restricted to this site' })
      return
    }

    if (isRateLimited(req)) {
      sendJson(res, 429, { error: 'Too many verification attempts' })
      return
    }

    readJsonBody(req)
      .then(async (payload) => {
        const verification = await verifyTurnstileToken(payload.token, clientIp(req), {
          expectedAction: 'site-gate',
          expectedHostname: expectedTurnstileHostname(),
        })
        if (!verification.success) {
          sendJson(res, 403, { error: 'Turnstile verification failed', detail: verification.error })
          return
        }
        const headers = TURNSTILE_SECRET_KEY ? { 'set-cookie': buildTurnstileSessionCookie(req) } : {}
        send(res, 200, JSON.stringify({ success: true, ttl: TURNSTILE_SESSION_TTL_SECONDS }), {
          ...jsonHeaders,
          ...headers,
        })
      })
      .catch((error) => sendJson(res, 400, { error: error.message }))
    return
  }

  if (req.method === 'GET' && req.url === '/api/turnstile/session') {
    if (!isSameOriginRequest(req)) {
      sendJson(res, 403, { error: 'Turnstile session check is restricted to this site' })
      return
    }
    sendJson(res, 200, { verified: isTurnstileSessionVerified(req) })
    return
  }

  if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) {
    sendJson(res, 403, { error: 'CORS requests are not allowed' })
    return
  }

  if (req.method === 'GET' || req.method === 'HEAD') {
    let pathname = ''
    try {
      pathname = new URL(req.url, `http://${req.headers.host || 'localhost'}`).pathname
    } catch {
      pathname = ''
    }

    const replayMatch = pathname.match(/^\/api\/tss\/games\/([A-Za-z0-9_-]{1,96})\/replay-canvas$/)
    if (replayMatch) {
      serveTssReplayCanvas(req, res, replayMatch[1])
      return
    }

    if (pathname.startsWith('/api/icons/type/')) {
      serveReplayIcon(req, res)
      return
    }

    if (pathname.startsWith('/api/match/minimap/')) {
      serveReplayMinimap(req, res)
      return
    }

    if (pathname.startsWith('/data/')) {
      servePublicData(req, res, pathname)
      return
    }
  }

  if (req.method === 'GET' && req.url.startsWith('/vehicle-icons/')) {
    serveVehicleIcon(req, res)
    return
  }

  if (req.url.startsWith('/api/')) {
    proxyRequest(req, res)
    return
  }

  serveStatic(req, res)
})

server.requestTimeout = SERVER_REQUEST_TIMEOUT_MS
server.headersTimeout = SERVER_HEADERS_TIMEOUT_MS

server.listen(PORT, '0.0.0.0', async () => {
  console.log(`tssbot-web serving http://localhost:${PORT}`)
  console.log(`proxying API requests to ${API_UPSTREAM}`)
  if (RUN_BACKGROUND_JOBS) {
    console.log(`sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes`)
  } else {
    console.log('uptime sampler disabled in this worker')
  }
  console.log(`storing uptime snapshots in ${path.join(uptimeStoragePath(), UPTIME_DATABASE_FILE)}`)
  console.log(`storing viewer analytics in ${path.join(uptimeStoragePath(), ANALYTICS_DATABASE_FILE)}`)
  console.log(`storing public data cache in ${PUBLIC_DATA_CACHE_DIR}`)
  if (!TURNSTILE_SECRET_KEY) {
    console.warn('TURNSTILE_SECRET_KEY is not set — Turnstile verification is disabled and gated endpoints will accept any request')
  }
  if (RUN_BACKGROUND_JOBS) {
    startUptimeSampler()
    fs.mkdirSync(PUBLIC_DATA_CACHE_DIR, { recursive: true })
    await prewarmPublicDataCache({ log: true })
    startPublicDataPrewarmer()
  }
  process.send?.('ready')
})

let shuttingDown = false

function closeDatabase(db, name) {
  if (!db) return

  try {
    db.close()
  } catch (error) {
    console.error(`Failed to close ${name} database:`, error)
  }
}

function shutdown() {
  if (shuttingDown) return
  shuttingDown = true

  if (uptimeSamplerTimer) clearInterval(uptimeSamplerTimer)
  if (publicDataPrewarmTimer) clearInterval(publicDataPrewarmTimer)

  server.close(() => {
    closeDatabase(uptimeDb, 'uptime')
    closeDatabase(analyticsDb, 'analytics')
    process.exit(0)
  })

  setTimeout(() => {
    console.error('Graceful shutdown timed out')
    process.exit(1)
  }, 10000).unref()
}

process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
