const crypto = require('node:crypto')
const fs = require('node:fs')
const http = require('node:http')
const https = require('node:https')
const os = require('node:os')
const path = require('node:path')
const { spawn } = require('node:child_process')

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.WEBHOOK_PORT || 3011)
const SECRET = process.env.GITHUB_WEBHOOK_SECRET || ''
const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL || ''
const DISCORD_INCLUDE_PATCH = /^(1|true|yes)$/i.test(String(process.env.DISCORD_INCLUDE_PATCH || ''))
const RESTART_TARGETS = (process.env.RESTART_TARGETS || 'tssbot-web,tssbot-backend')
  .split(',')
  .map((target) => target.trim())
  .filter((target) => /^[A-Za-z0-9_.:-]{1,80}$/.test(target))
  .filter(Boolean)

// This webhook's own systemd unit name — never restart it inline during its own deploy.
const SELF_SERVICE_NAME = process.env.WEBHOOK_SERVICE_NAME || 'tssbot-webhook'
const DIST_DIR = path.join(__dirname, 'dist')
const NEXT_DIST_DIR = path.join(__dirname, 'dist-next')
const PREVIOUS_DIST_DIR = path.join(__dirname, 'dist-previous')
const MAX_WEBHOOK_BODY_BYTES = Number(process.env.WEBHOOK_MAX_BODY_BYTES || 1024 * 1024)
const WEBHOOK_REQUEST_TIMEOUT_MS = Number(process.env.WEBHOOK_REQUEST_TIMEOUT_MS || 30000)
const WEBHOOK_HEADERS_TIMEOUT_MS = Number(process.env.WEBHOOK_HEADERS_TIMEOUT_MS || 10000)
// No deploy step may hang forever. A stalled `npm ci` (a native postinstall that
// never returns) would otherwise block for hours with node_modules already
// deleted — which is exactly what took the site down. These cap each step so a
// hang fails fast and aborts the deploy before any systemctl restart.
const DEPLOY_STEP_TIMEOUT_MS = Number(process.env.DEPLOY_STEP_TIMEOUT_MS || 15 * 60 * 1000)
const DEPLOY_INSTALL_TIMEOUT_MS = Number(process.env.DEPLOY_INSTALL_TIMEOUT_MS || 8 * 60 * 1000)
const ALLOWED_REFS = new Set(
  (process.env.GITHUB_WEBHOOK_REFS || 'refs/heads/main')
    .split(',')
    .map((ref) => ref.trim())
    .filter(Boolean),
)
const ALLOWED_REPOSITORY = (process.env.GITHUB_WEBHOOK_REPOSITORY || '').trim()
const RESTART_AFTER_MS = 24 * 60 * 60 * 1000

let deploying = false
let queuedPush = null

function json(res, status, body) {
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
  res.end(JSON.stringify(body))
}

function safeJsonParse(rawBody) {
  try {
    return JSON.parse(rawBody.toString('utf8'))
  } catch {
    return null
  }
}

function shortSha(value) {
  return value ? String(value).slice(0, 7) : 'unknown'
}

function branchName(ref) {
  return String(ref || '').replace(/^refs\/heads\//, '') || 'unknown'
}

function truncate(value, maxLength = 900) {
  const text = String(value || '')
  if (text.length <= maxLength) return text
  return `${text.slice(0, maxLength - 3)}...`
}

function codeBlock(value, language = '', maxLength = 1000) {
  const fence = language ? `\`\`\`${language}\n` : '```\n'
  const suffix = '\n```'
  const body = truncate(value || 'No diff returned', maxLength - fence.length - suffix.length)
  return `${fence}${body}${suffix}`
}

function validGitSha(value) {
  return /^[0-9a-f]{7,40}$/i.test(String(value || ''))
}

function verifySignature(rawBody, signature) {
  if (!SECRET) {
    console.error('GITHUB_WEBHOOK_SECRET is not set — rejecting webhook')
    return false
  }
  if (!signature || !signature.startsWith('sha256=')) return false

  const expected = `sha256=${crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex')}`
  const signatureBuffer = Buffer.from(signature)
  const expectedBuffer = Buffer.from(expected)

  return (
    signatureBuffer.length === expectedBuffer.length &&
    crypto.timingSafeEqual(signatureBuffer, expectedBuffer)
  )
}

function executableExists(filePath) {
  try {
    return fs.existsSync(filePath) && fs.statSync(filePath).isFile()
  } catch {
    return false
  }
}

function cargoExecutableName() {
  return process.platform === 'win32' ? 'cargo.exe' : 'cargo'
}

function pathDirectories() {
  const pathValue = process.env.PATH || process.env.Path || ''
  return pathValue.split(path.delimiter).filter(Boolean)
}

function cargoCandidates() {
  const executableName = cargoExecutableName()
  const candidates = []

  if (process.env.CARGO) {
    candidates.push(process.env.CARGO)
  }

  if (process.env.CARGO_HOME) {
    candidates.push(path.join(process.env.CARGO_HOME, 'bin', executableName))
  }

  candidates.push(path.join(os.homedir(), '.cargo', 'bin', executableName))

  if (process.platform !== 'win32') {
    try {
      for (const homeEntry of fs.readdirSync('/home', { withFileTypes: true })) {
        if (homeEntry.isDirectory()) {
          candidates.push(path.join('/home', homeEntry.name, '.cargo', 'bin', executableName))
        }
      }
    } catch {
      // Some hosts do not expose /home to this process; the fixed paths below still cover system installs.
    }

    candidates.push(
      '/root/.cargo/bin/cargo',
      '/usr/local/cargo/bin/cargo',
      '/usr/local/bin/cargo',
      '/usr/bin/cargo',
    )
  }

  for (const directory of pathDirectories()) {
    candidates.push(path.join(directory, executableName))
  }

  return [...new Set(candidates)]
}

function cargoCommand() {
  for (const candidate of cargoCandidates()) {
    if (executableExists(candidate)) return candidate
  }

  return null
}

function commandFor(command) {
  if (command === 'cargo') {
    const resolvedCommand = cargoCommand()
    if (!resolvedCommand) throw new Error(commandNotFoundMessage(command))
    return resolvedCommand
  }
  if (process.platform !== 'win32') return command
  if (command === 'npm') return 'npm.cmd'
  return command
}

function commandNotFoundMessage(command) {
  if (command !== 'cargo') return `${command} was not found`
  return [
    'cargo was not found by the deploy webhook',
    'Install Rust on the host, or set CARGO to the absolute cargo binary path',
    `Checked ${cargoCandidates().join(', ')}`,
  ].join('. ')
}

function restartTargetsInclude(target) {
  return RESTART_TARGETS.some((candidate) => candidate === target)
}

function pushTouchesWebhookRuntime(push) {
  const runtimeFiles = new Set(['webhook.cjs', 'systemd/tssbot-webhook.service'])
  const commits = Array.isArray(push?.commits) ? push.commits : []
  return commits.some((commit) => {
    const changed = [
      ...(Array.isArray(commit.added) ? commit.added : []),
      ...(Array.isArray(commit.modified) ? commit.modified : []),
      ...(Array.isArray(commit.removed) ? commit.removed : []),
    ]
    return changed.some((file) => runtimeFiles.has(String(file || '').replace(/^\/+/, '')))
  })
}

function scheduleSelfRestart(reason) {
  console.log(`scheduling ${SELF_SERVICE_NAME} restart: ${reason}`)
  // Delayed + detached: `systemctl --user restart` sends SIGTERM to this very
  // process once it starts, so fire it after this tick unrefs and let the
  // deploy's response/notifications land first.
  setTimeout(() => {
    const child = spawn('systemctl', ['--user', 'restart', `${SELF_SERVICE_NAME}.service`], {
      cwd: __dirname,
      env: process.env,
      detached: true,
      stdio: 'ignore',
    })
    child.unref()
  }, 1000).unref()
}

function run(command, args, options = {}) {
  return new Promise((resolve, reject) => {
    const label = [command, ...args].join(' ')
    console.log(`deploy step started: ${label}`)

    let resolvedCommand
    try {
      resolvedCommand = commandFor(command)
    } catch (error) {
      reject(error)
      return
    }
    const child = spawn(resolvedCommand, args, {
      cwd: __dirname,
      env: { ...process.env, ...options.env },
      shell: process.platform === 'win32',
      stdio: 'inherit',
    })

    // Kill the step if it hangs so deploy() aborts before any systemctl restart instead
    // of wedging here indefinitely (see DEPLOY_STEP_TIMEOUT_MS above).
    const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : DEPLOY_STEP_TIMEOUT_MS
    let timedOut = false
    const timer = setTimeout(() => {
      timedOut = true
      console.error(`deploy step timed out after ${timeoutMs}ms, killing: ${label}`)
      child.kill('SIGTERM')
      setTimeout(() => child.kill('SIGKILL'), 5000).unref()
    }, timeoutMs)
    timer.unref()

    child.on('error', (error) => {
      clearTimeout(timer)
      if (error.code === 'ENOENT') {
        reject(new Error(commandNotFoundMessage(command)))
        return
      }
      reject(error)
    })
    child.on('exit', (code, signal) => {
      clearTimeout(timer)
      if (timedOut) {
        reject(new Error(`${label} timed out after ${timeoutMs}ms and was killed`))
      } else if (code === 0) {
        console.log(`deploy step completed: ${label}`)
        resolve()
      } else {
        reject(new Error(`${label} exited with ${code}${signal ? ` via ${signal}` : ''}`))
      }
    })
  })
}

function runCapture(command, args, options = {}) {
  return new Promise((resolve, reject) => {
    const label = [command, ...args].join(' ')
    let resolvedCommand
    try {
      resolvedCommand = commandFor(command)
    } catch (error) {
      reject(error)
      return
    }
    const child = spawn(resolvedCommand, args, {
      cwd: __dirname,
      env: { ...process.env, ...options.env },
      shell: process.platform === 'win32',
      stdio: ['ignore', 'pipe', 'pipe'],
    })
    let stdout = ''
    let stderr = ''

    child.stdout.on('data', (chunk) => {
      stdout += chunk
    })
    child.stderr.on('data', (chunk) => {
      stderr += chunk
    })
    child.on('error', reject)
    child.on('exit', (code) => {
      if (code === 0) {
        resolve(stdout.trim())
      } else {
        reject(new Error(`${label} exited with ${code}: ${truncate(stderr || stdout, 500)}`))
      }
    })
  })
}

// Files that, when changed, require a fresh `npm ci`. package.json is included
// because npm ci refuses to run against a package.json/lockfile that are out of
// sync, so a change to either should trigger a reinstall.
const DEPENDENCY_FILES = ['package.json', 'package-lock.json']

// Resolve the modules the build relies on. Returns the first missing module's
// name, or null if all are present. Used to validate after install.
function missingBuildDependency() {
  try {
    require.resolve('@vitejs/plugin-react')
  } catch {
    return '@vitejs/plugin-react'
  }

  if (process.platform === 'linux' && process.arch === 'x64') {
    const libc = process.report?.getReport?.().header?.glibcVersionRuntime ? 'gnu' : 'musl'
    const rollup = `@rollup/rollup-linux-x64-${libc}`
    try {
      require.resolve(rollup)
    } catch {
      return rollup
    }
  }

  return null
}

// True when any dependency file changed between previousHead and the current
// HEAD. Defaults to true (reinstall) whenever we can't determine the range, so
// an uncertain state never skips a needed install.
async function dependencyFilesChanged(previousHead) {
  if (!validGitSha(previousHead)) return true
  try {
    const changed = await runCapture('git', [
      'diff',
      '--name-only',
      `${previousHead}..HEAD`,
      '--',
      ...DEPENDENCY_FILES,
    ])
    return changed.trim().length > 0
  } catch {
    return true
  }
}

// True when the native better-sqlite3 addon actually LOADS. require.resolve()
// isn't enough — the package can exist while its compiled .node binary is
// missing, which is the "Could not locate the bindings file" crash that
// repeatedly took tssbot-web down. Load it in a child process so this reflects
// exactly what server.cjs does at startup.
async function betterSqliteLoads() {
  try {
    await run(
      process.execPath,
      ['-e', "new (require('better-sqlite3'))(':memory:').close()"],
      { env: { NODE_ENV: 'production' }, timeoutMs: 60000 },
    )
    return true
  } catch {
    return false
  }
}

async function ensureBuildDependencies(previousHead) {
  const depsChanged = await dependencyFilesChanged(previousHead)
  // Skip the clean reinstall (which wipes node_modules and rebuilds native
  // modules like better-sqlite3 — the fragile step that took the site down) only
  // when nothing dependency-related changed AND the existing node_modules already
  // has everything the build and runtime need. A broken/missing better-sqlite3
  // binary forces the reinstall so a skip can never leave a dead tree.
  const canSkip =
    !depsChanged && missingBuildDependency() === null && (await betterSqliteLoads())

  if (canSkip) {
    console.log('deploy step skipped: npm ci (no package.json/package-lock.json changes, node_modules intact)')
    return
  }

  await run('npm', ['ci'], {
    env: {
      NODE_ENV: 'development',
      npm_config_omit: '',
      npm_config_production: 'false',
      npm_config_fund: 'false',
      npm_config_audit: 'false',
    },
    timeoutMs: DEPLOY_INSTALL_TIMEOUT_MS,
  })

  const missing = missingBuildDependency()
  if (missing) {
    throw new Error(`${missing} is missing after npm install; dependencies were not installed`)
  }

  // Hard gate: better-sqlite3 must actually load after the install, or abort the
  // deploy here — before promoteBuiltDist()/systemctl restart — so a broken native build
  // can never be promoted to the running workers (which still hold a good binary).
  if (!(await betterSqliteLoads())) {
    throw new Error(
      'better-sqlite3 failed to load after npm ci — native binary missing or broken; aborting deploy before reload',
    )
  }
}

function copyMissingFiles(fromDir, toDir) {
  if (!fs.existsSync(fromDir) || !fs.existsSync(toDir)) return

  for (const entry of fs.readdirSync(fromDir, { withFileTypes: true })) {
    const source = path.join(fromDir, entry.name)
    const target = path.join(toDir, entry.name)

    if (entry.isDirectory()) {
      fs.mkdirSync(target, { recursive: true })
      copyMissingFiles(source, target)
      continue
    }

    if (!fs.existsSync(target)) {
      fs.copyFileSync(source, target)
    }
  }
}

function promoteBuiltDist() {
  const previousAssetsDir = path.join(DIST_DIR, 'assets')
  const nextAssetsDir = path.join(NEXT_DIST_DIR, 'assets')
  let movedCurrentDist = false

  copyMissingFiles(previousAssetsDir, nextAssetsDir)

  fs.rmSync(PREVIOUS_DIST_DIR, { recursive: true, force: true })

  try {
    if (fs.existsSync(DIST_DIR)) {
      fs.renameSync(DIST_DIR, PREVIOUS_DIST_DIR)
      movedCurrentDist = true
    }

    fs.renameSync(NEXT_DIST_DIR, DIST_DIR)
  } catch (error) {
    if (movedCurrentDist && !fs.existsSync(DIST_DIR) && fs.existsSync(PREVIOUS_DIST_DIR)) {
      fs.renameSync(PREVIOUS_DIST_DIR, DIST_DIR)
    }
    throw error
  }
}

function syncVehicleIcons() {
  // Point the served icon dir at the bot-managed SHARED/ICONS/VEHICLES so the
  // scoreboard can render vehicle icons without bloating this repo. Symlink when
  // possible; fall back to skipping (icons hide gracefully) if the source is absent.
  const src = process.env.VEHICLE_ICONS_SRC || '/home/deploy/BOTS/SHARED/ICONS/VEHICLES'
  const dst = path.resolve(
    __dirname,
    process.env.VEHICLE_ICONS_DIR || path.join('dist', 'vehicle-icons'),
  )
  if (!fs.existsSync(src)) {
    console.warn(`vehicle icons source missing, skipping: ${src}`)
    return
  }
  try {
    fs.rmSync(dst, { recursive: true, force: true })
    fs.mkdirSync(path.dirname(dst), { recursive: true })
    fs.symlinkSync(src, dst)
    console.log(`linked vehicle icons ${dst} -> ${src}`)
  } catch (error) {
    console.error(`vehicle icon sync failed: ${error.message}`)
  }
}

function validateBuiltDist() {
  const indexPath = path.join(NEXT_DIST_DIR, 'index.html')
  if (!fs.existsSync(indexPath)) {
    throw new Error('Frontend build did not produce dist-next/index.html')
  }

  const html = fs.readFileSync(indexPath, 'utf8')
  if (/\sintegrity=(["'])sha(?:256|384|512)-[^"']+\1/i.test(html)) {
    throw new Error('Frontend build contains unsupported integrity attributes')
  }

  const assetPaths = [...html.matchAll(/(?:src|href)=(["'])\/(assets\/[^"']+)\1/g)]
    .map((match) => match[2])

  for (const assetPath of assetPaths) {
    if (!fs.existsSync(path.join(NEXT_DIST_DIR, assetPath))) {
      throw new Error(`Frontend build references missing asset: /${assetPath}`)
    }
  }
}

function postDiscordWebhook(payload) {
  if (!DISCORD_WEBHOOK_URL) return Promise.resolve()

  return new Promise((resolve) => {
    let url
    try {
      url = new URL(DISCORD_WEBHOOK_URL)
    } catch (error) {
      console.error('Discord restart webhook URL is invalid:', error.message)
      resolve()
      return
    }

    const body = Buffer.from(JSON.stringify(payload))
    const client = url.protocol === 'http:' ? http : https
    const req = client.request(
      url,
      {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          'content-length': body.length,
          'user-agent': 'tssbot-webhook',
        },
        timeout: 5000,
      },
      (res) => {
        res.resume()
        res.on('end', resolve)
      },
    )

    req.on('timeout', () => {
      req.destroy(new Error('Discord restart webhook timed out'))
    })
    req.on('error', (error) => {
      console.error('Discord restart webhook failed:', error.message)
      resolve()
    })
    req.end(body)
  })
}

function notifyDiscordRestart() {
  const startedAt = new Date()

  sendDiscordEmbed({
    title: 'Webhook listener restarted',
    color: 0x00f2ff,
    fields: [
      { name: 'Host', value: os.hostname(), inline: true },
      { name: 'Port', value: String(PORT), inline: true },
      { name: 'Restart targets', value: RESTART_TARGETS.join(', ') || 'none', inline: false },
    ],
    timestamp: startedAt.toISOString(),
  })
}

function sendDiscordEmbed(embed) {
  return postDiscordWebhook({
    username: 'tssbot webhook',
    embeds: [embed],
  })
}

function deployFields(push) {
  const headCommit = push?.head_commit
  const fields = [
    { name: 'Host', value: os.hostname(), inline: true },
    { name: 'Branch', value: branchName(push?.ref), inline: true },
    { name: 'Commit', value: shortSha(push?.after || headCommit?.id), inline: true },
    { name: 'Restart targets', value: RESTART_TARGETS.join(', ') || 'none', inline: false },
  ]

  if (push?.sender?.login) {
    fields.push({ name: 'Sender', value: push.sender.login, inline: true })
  }

  if (headCommit?.message) {
    fields.push({ name: 'Message', value: truncate(headCommit.message), inline: false })
  }

  return fields
}

async function deployDiff(push) {
  const before = push?.before
  const after = push?.after

  if (!validGitSha(before) || !validGitSha(after) || /^0+$/.test(before)) {
    return {
      summary: 'Diff unavailable for this push range',
      patch: push?.compare ? `Compare: ${push.compare}` : '',
    }
  }

  const range = `${before}..${after}`
  const [nameStatus, shortStat, patch] = await Promise.all([
    runCapture('git', ['diff', '--name-status', '--find-renames', range]),
    runCapture('git', ['diff', '--shortstat', range]),
    runCapture('git', ['diff', '--find-renames', '--unified=1', range]),
  ])

  return {
    summary: [shortStat, nameStatus].filter(Boolean).join('\n'),
    patch,
  }
}

function diffFields(diff) {
  if (!diff) return []

  const fields = []
  if (diff.summary) {
    fields.push({ name: 'Diff summary', value: codeBlock(diff.summary, 'diff'), inline: false })
  }
  if (DISCORD_INCLUDE_PATCH && diff.patch) {
    fields.push({ name: 'Patch preview', value: codeBlock(diff.patch, 'diff'), inline: false })
  }

  return fields
}

async function notifyDeployStarted(push) {
  await sendDiscordEmbed({
    title: 'GitHub push deploy started',
    color: 0xf4ee3e,
    fields: deployFields(push),
    timestamp: new Date().toISOString(),
  })
}

async function notifyDeployCompleted(push, diff) {
  await sendDiscordEmbed({
    title: 'GitHub push deploy completed',
    color: 0x00f2ff,
    fields: [...deployFields(push), ...diffFields(diff)],
    timestamp: new Date().toISOString(),
  })
}

async function notifyDeployFailed(push, error, diff) {
  await sendDiscordEmbed({
    title: 'GitHub push deploy failed',
    color: 0xe82517,
    fields: [
      ...deployFields(push),
      ...diffFields(diff),
      { name: 'Error', value: truncate(error?.message || error), inline: false },
    ],
    timestamp: new Date().toISOString(),
  })
}

async function deploy(push) {
  let diff = null

  try {
    await notifyDeployStarted(push)
    // Capture HEAD before the pull so we can tell whether this push actually
    // changed dependency files (and thus needs a fresh npm ci).
    const previousHead = await runCapture('git', ['rev-parse', 'HEAD']).catch(() => null)
    await run('git', ['pull', '--ff-only'])
    diff = await deployDiff(push)
    await ensureBuildDependencies(previousHead)
    await run('npm', ['run', 'build', '--', '--outDir', '../dist-next'])
    validateBuiltDist()
    promoteBuiltDist()
    syncVehicleIcons()

    // Each restarted service re-reads .env itself on startup, so a plain
    // `systemctl restart` always picks up the committed env changes.
    // Exclude this webhook process from the awaited restart: killing the process
    // running this deploy mid-command can interrupt the remaining restarts.
    const restartTargets = RESTART_TARGETS.filter((t) => t !== SELF_SERVICE_NAME)
    if (restartTargets.length) {
      await run('systemctl', ['--user', 'restart', ...restartTargets.map((t) => `${t}.service`)])
    }

    await notifyDeployCompleted(push, diff)
    if (restartTargetsInclude(SELF_SERVICE_NAME) || pushTouchesWebhookRuntime(push)) {
      scheduleSelfRestart(
        restartTargetsInclude(SELF_SERVICE_NAME)
          ? `${SELF_SERVICE_NAME} is listed in RESTART_TARGETS`
          : 'webhook runtime files changed',
      )
    }
  } catch (error) {
    error.deployDiff = diff
    throw error
  }
}

async function runDeployQueue(initialPush) {
  let push = initialPush

  while (push) {
    queuedPush = null

    try {
      await deploy(push)
      console.log('GitHub push deploy completed')
    } catch (error) {
      console.error('GitHub push deploy failed:', error)
      try {
        await notifyDeployFailed(push, error, error.deployDiff)
      } catch (notificationError) {
        console.error('Failed to send deploy failure notification:', notificationError)
      }
    }

    push = queuedPush
  }
}

const webhookServer = http.createServer((req, res) => {
    if (req.method === 'GET' && req.url === '/health') {
      json(res, 200, {
        ok: true,
        deploying,
        queued: Boolean(queuedPush),
        restart_targets: RESTART_TARGETS,
      })
      return
    }

    if (req.method !== 'POST' || req.url !== '/github') {
      json(res, 404, { error: 'Not found' })
      return
    }

    const chunks = []
    let size = 0
    let tooLarge = false
    req.on('data', (chunk) => {
      size += chunk.length
      if (size > MAX_WEBHOOK_BODY_BYTES) {
        tooLarge = true
        json(res, 413, { error: 'Webhook body too large' })
        req.destroy()
        return
      }
      chunks.push(chunk)
    })
    req.on('end', () => {
      if (tooLarge) return
      const rawBody = Buffer.concat(chunks)
      const push = safeJsonParse(rawBody)
      const event = String(req.headers['x-github-event'] || '')
      const pushRef = String(push?.ref || '')
      const repoFullName = String(push?.repository?.full_name || '')
      const delivery = String(req.headers['x-github-delivery'] || 'unknown')

      if (!verifySignature(rawBody, req.headers['x-hub-signature-256'])) {
        console.warn(`webhook delivery rejected: invalid signature delivery=${delivery} event=${event || '(missing)'}`)
        json(res, 401, { error: 'Invalid signature' })
        return
      }

      console.log(`webhook delivery received: delivery=${delivery} event=${event || '(missing)'} ref=${pushRef || '(missing)'} repo=${repoFullName || '(missing)'}`)

      if (event !== 'push') {
        console.log(`webhook delivery skipped: delivery=${delivery} reason=non-push event=${event || '(missing)'}`)
        json(res, 202, { skipped: true, reason: 'Only push events trigger deploys' })
        return
      }

      if (!ALLOWED_REFS.has(pushRef)) {
        console.log(`webhook delivery skipped: delivery=${delivery} reason=ref ref=${pushRef || '(missing)'} allowed=${[...ALLOWED_REFS].join(',')}`)
        json(res, 202, { skipped: true, reason: `Ignoring ref ${pushRef || '(missing)'}` })
        return
      }

      if (ALLOWED_REPOSITORY) {
        if (repoFullName !== ALLOWED_REPOSITORY) {
          console.log(`webhook delivery skipped: delivery=${delivery} reason=repository repo=${repoFullName || '(missing)'} allowed=${ALLOWED_REPOSITORY}`)
          json(res, 202, { skipped: true, reason: `Ignoring repository ${repoFullName || '(missing)'}` })
          return
        }
      }

      if (push?.deleted) {
        console.log(`webhook delivery skipped: delivery=${delivery} reason=branch-delete ref=${pushRef}`)
        json(res, 202, { skipped: true, reason: 'Ignoring branch-delete push' })
        return
      }

      if (deploying) {
        queuedPush = push
        console.log(`webhook deploy queued: delivery=${delivery} ref=${pushRef} repo=${repoFullName || '(missing)'}`)
        json(res, 202, { queued: true, deploying: true })
        return
      }

      deploying = true
      console.log(`webhook deploy accepted: delivery=${delivery} ref=${pushRef} repo=${repoFullName || '(missing)'} restart_targets=${RESTART_TARGETS.join(',') || 'none'}`)
      json(res, 202, { accepted: true, restart_targets: RESTART_TARGETS })

      runDeployQueue(push)
        .finally(() => {
          deploying = false
        })
    })
})

webhookServer.requestTimeout = WEBHOOK_REQUEST_TIMEOUT_MS
webhookServer.headersTimeout = WEBHOOK_HEADERS_TIMEOUT_MS

webhookServer.listen(PORT, '0.0.0.0', () => {
  console.log(`tssbot webhook listening on http://localhost:${PORT}/github`)
  console.log(`restart targets: ${RESTART_TARGETS.join(', ')}`)
  notifyDiscordRestart()
})

setTimeout(() => {
  console.log('24 hour webhook refresh reached; exiting for systemd restart')
  process.exit(0)
}, RESTART_AFTER_MS).unref()
