#!/usr/bin/env bash
#
# hsc-setup.sh — install and operate the headless-seo-check audit API.
#
#   sudo ./hsc-setup.sh install --domain hsc.example.com --email you@example.com
#   sudo ./hsc-setup.sh status
#   sudo ./hsc-setup.sh rotate elevaseo
#
# Everything it does is idempotent: run it again after a bun upgrade, a
# template change or a new site, and it converges without touching what is
# already right. Existing API keys are never regenerated by `install` — that is
# what `rotate` is for.
#
# Target: Debian/Ubuntu with systemd, nginx and mise. Runtimes come from mise,
# never from a bare curl-into-shell.
set -euo pipefail

# --------------------------------------------------------------------------
# What gets installed
# --------------------------------------------------------------------------

# site name | the origins that site's key may audit, space separated.
# The name travels as X-HSC-Site, keys the nginx map and keys keys.json, so it
# must mean the same thing in all three: lowercase, digits, - and _.
SITES=(
  "elevaseo|https://elevaseo.com https://www.elevaseo.com"
  "seoinsight|https://seoinsight.app https://www.seoinsight.app"
  "dash-seoinsight|https://dash.seoinsight.app"
  # The public form on elevaseo.com. "*" means any public origin: a visitor
  # types their own domain, so there is no allowlist to write. What replaces it
  # is the SSRF guard in server.ts and IPAddressDeny= on the unit — see
  # selfhost/README.md, "The public key".
  "public-demo|*"
)

# The one site whose key is public-facing, so nginx can rate-limit it per
# visitor instead of per key. Named once, here.
PUBLIC_SITE=public-demo

SERVICE_USER=hsc
DIR=/opt/hsc
PORT=8169
BUN_VERSION=1
NGINX_SITE=/etc/nginx/sites-available/hsc
NGINX_KEYS=/etc/nginx/conf.d/hsc-keys.conf
NGINX_CF=/etc/nginx/snippets/hsc-cloudflare.conf
UNIT=/etc/systemd/system/hsc.service
KEY_BACKUP=/root/hsc-keys.txt
CF_IPS_V4=https://www.cloudflare.com/ips-v4
CF_IPS_V6=https://www.cloudflare.com/ips-v6

# Defaults for the deployment this repository is written for. Every one of them
# is overridable; none of them is guessed at runtime.
DOMAIN=hsc.elevaseo.com
EMAIL=bastien@elevaseo.com
CLOUDFLARE=1

BINARY=""
SKIP_CERTBOT=0
SKIP_NGINX=0
FORCE_NGINX=0
CF_LOCK=1
TIMEOUT_MS=""

# `ssh host 'bash -s -- uninstall' < hsc-setup.sh` is the natural way to reach a
# machine that no longer has the bundle — and reading from stdin leaves
# BASH_SOURCE unset, which `set -u` turns into a crash before a line runs.
SELF="${BASH_SOURCE[0]:-}"
if [ -n "$SELF" ]; then
  HERE="$(cd -- "$(dirname -- "$SELF")" && pwd)"
else
  HERE="$PWD" # piped: only `install` needs the files next to the script
fi

# Resolved above, so from here on nothing needs the caller's directory — and
# the caller's directory may not exist: re-extracting the bundle deletes the
# one an operator is standing in, and `runuser` cannot hand a deleted directory
# to another user. Stand somewhere every user can.
cd / || die "cannot even cd to /"

# --------------------------------------------------------------------------
# Output
# --------------------------------------------------------------------------

if [ -t 1 ]; then
  B=$'\033[1m'; DIM=$'\033[2m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m'; RED=$'\033[31m'; OFF=$'\033[0m'
else
  B=""; DIM=""; GREEN=""; YELLOW=""; RED=""; OFF=""
fi

# Progress goes to stderr, the way the tool this installs does it: stdout is
# reserved for the one thing a caller may want to capture — the keys.
step()  { printf '\n%s==> %s%s\n' "$B" "$*" "$OFF" >&2; }
ok()    { printf '    %s✓%s %s\n' "$GREEN" "$OFF" "$*" >&2; }
info()  { printf '    %s%s%s\n' "$DIM" "$*" "$OFF" >&2; }
warn()  { printf '    %s!%s %s\n' "$YELLOW" "$OFF" "$*" >&2; }
die()   { printf '\n%serror:%s %s\n' "$RED" "$OFF" "$*" >&2; exit 1; }

trap 'die "failed at line $LINENO — nothing was rolled back, fix and re-run"' ERR

# Run a command as another user, and say so. Every privilege change in this
# script goes through here, so `install` can be read as a list of who did what.
#
# The environment is pinned rather than inherited: `sudo -E` keeps the
# operator's XDG_* variables, and mise would then try to write its cache into
# /root/.cache as a user that cannot. Every path mise uses is named here, and
# all of them live under $DIR, which the service user owns.
run_as() {
  local user="$1"; shift
  info "→ su $user: $*"
  runuser -u "$user" -- env \
    -u XDG_CACHE_HOME -u XDG_DATA_HOME -u XDG_STATE_HOME -u XDG_CONFIG_HOME \
    HOME="$DIR" \
    MISE_YES=1 \
    MISE_DATA_DIR="$DIR/.local/share/mise" \
    MISE_STATE_DIR="$DIR/.local/state/mise" \
    MISE_CACHE_DIR="$DIR/.cache/mise" \
    MISE_CONFIG_DIR="$DIR/.config/mise" \
    "$@"
}

# --------------------------------------------------------------------------
# Preflight
# --------------------------------------------------------------------------

usage() {
  cat <<EOF
usage: hsc-setup.sh <command> [options]

commands:
  install                 install or converge everything (default)
  status                  what is running, what is configured, cert expiry
  rotate <site>           issue a new key for one site, revoke the old one
  uninstall [--yes]       remove everything this installed; prints the plan and
                          changes nothing until you add --yes
                          (--purge-keys also deletes the key backup)

install options:
  --domain <fqdn>         public name of the API            (default: $DOMAIN)
  --email <address>       certbot registration address      (default: $EMAIL)
  --binary <path>         headless-seo-check to install     (default: next to this script)
  --bun-version <spec>    mise spec for bun                 (default: $BUN_VERSION)
  --port <n>              loopback port for the service     (default: $PORT)
  --dir <path>            install prefix                    (default: $DIR)
  --cloudflare            the domain is proxied by Cloudflare (default): restore
                          the real client IP from CF-Connecting-IP, refuse what
                          did not come through Cloudflare, and keep the audit
                          timeout under Cloudflare's ~100 s cut-off
  --no-cloudflare         the domain points straight at this box
  --cf-no-lock            restore the IP but accept direct traffic too — use it
                          while the DNS record is still grey-clouded
  --timeout-ms <n>        how long an audit may take (default: 90000 behind
                          Cloudflare, 180000 without)
  --skip-nginx            do not write or reload nginx
  --skip-certbot          do not request a certificate
  --force-nginx           overwrite an existing site file (discards certbot's edits)

  sudo ./hsc-setup.sh install --domain hsc.example.com --email ops@example.com --cloudflare
EOF
}

# Re-exec under sudo with the original command line, so `./hsc-setup.sh install
# --domain …` works the same whether or not the caller remembered the sudo.
need_root() {
  if [ "$(id -u)" -ne 0 ]; then
    if [ -z "$SELF" ] || [ ! -r "$SELF" ]; then
      die "run this as root — piped through stdin, it has no file to re-exec under sudo"
    fi
    info "not root — re-executing under sudo"
    exec sudo -E -- "$SELF" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
  fi
}

need_cmd() { command -v "$1" >/dev/null 2>&1 || die "$1 is required but not installed"; }

preflight() {
  [ -r /etc/debian_version ] || warn "this installer targets Debian/Ubuntu — continuing anyway"
  need_cmd systemctl
  need_cmd runuser
  [ -f "$HERE/server.ts" ] || die "server.ts is not next to this script ($HERE)"
  [ -f "$HERE/templates/hsc.service.tmpl" ] || die "templates/ is missing from $HERE"
}

# The binary can come from the deployment bundle, from a repository checkout,
# or from --binary. Whatever it is, it has to run on this machine.
locate_binary() {
  local candidate
  if [ -n "$BINARY" ]; then
    [ -x "$BINARY" ] || die "--binary $BINARY is not an executable file"
  else
    for candidate in \
      "$HERE/../headless-seo-check" \
      "$HERE/headless-seo-check" \
      "$HERE/../target/x86_64-unknown-linux-musl/release/headless-seo-check" \
      "$HERE/../target/release/headless-seo-check"; do
      if [ -x "$candidate" ]; then BINARY="$candidate"; break; fi
    done
    [ -n "$BINARY" ] || die "no headless-seo-check binary found near $HERE — pass --binary <path>"
  fi
  local version
  version="$("$BINARY" --version 2>/dev/null)" ||
    die "$BINARY does not run here (wrong architecture? build for x86_64-unknown-linux-musl)"
  ok "binary: $BINARY ($version)"
}

# --------------------------------------------------------------------------
# Keys
# --------------------------------------------------------------------------

# What one map key of length $1 costs nginx, on a 64-bit build.
#
# From src/core/ngx_hash.c: a bucket must hold
#   NGX_HASH_ELT_SIZE(name) + sizeof(void *)
#   = sizeof(void *) + ngx_align(len + 2, sizeof(void *)) + sizeof(void *)
# — the value pointer, the length-prefixed name padded to a pointer boundary,
# and the NULL that terminates the bucket. With a 64-byte bucket that leaves
# room for a key of 46 characters, not 64: the overhead is 18 bytes, not none.
nginx_hash_entry_size() {
  local len="$1" word=8 aligned
  aligned=$(( ((len + 2) + (word - 1)) / word * word ))
  printf '%d' "$(( word + aligned + word ))"
}

# 20 bytes of openssl randomness — 160 bits — in a header that only ever
# travels over TLS and is compared by nginx against a fixed map.
#
# The length is dictated by that map. nginx hashes its keys in buckets of 64
# bytes, and `map_hash_bucket_size` may be declared only once in an http block,
# so it belongs to whoever declared it first — not to us. `Bearer ` plus this
# key is 38 characters, which costs nginx 56 of the 64 bytes it has. Hex, or a
# site name in the key, or 32 random bytes, each pushed it past the limit.
new_key() {
  printf 'hsc_%s' "$(openssl rand -base64 20 | tr '+/' '-_' | tr -d '=\n')"
}

# The nginx map is where a key lives; this is how we know a site already has
# one. Matched by the site it maps to, since the key no longer names it — and a
# key in an older, longer format does not match, so `install` replaces it with
# one nginx can actually hash.
existing_key() {
  [ -f "$NGINX_KEYS" ] || return 0
  sed -n "s/^[[:space:]]*\"Bearer \(hsc_[A-Za-z0-9_-]\{27\}\)\"[[:space:]]*\"$1\";.*/\1/p" "$NGINX_KEYS" | head -1
}

site_names() { local e; for e in "${SITES[@]}"; do printf '%s\n' "${e%%|*}"; done; }

site_origins() {
  local e
  for e in "${SITES[@]}"; do
    if [ "${e%%|*}" = "$1" ]; then printf '%s' "${e#*|}"; fi
  done
  return 0 # a loop whose last test failed must not fail the caller under `set -e`
}

write_nginx_keys() { # write_nginx_keys <name=key> …
  local pair name key

  # Every key is built to fit a 64-byte bucket (see new_key), so this file
  # declares no global tuning and cannot collide with whatever else the server
  # configures. If that ever stops being true, say it here rather than let
  # nginx fail at reload over a number nobody chose.
  local length needed
  for pair in "$@"; do
    key="${pair#*=}"
    length=$(( ${#key} + 7 )) # "Bearer "
    needed="$(nginx_hash_entry_size "$length")"
    if [ "$needed" -gt 64 ]; then
      warn "the key for ${pair%%=*} needs $needed bytes of a 64-byte hash bucket — nginx will refuse this map"
    fi
  done

  {
    cat <<'EOF'
# /etc/nginx/conf.d/hsc-keys.conf — written by hsc-setup.sh, do not edit here.
#
# The whole authentication system: one line per site, a Bearer key on the left,
# the site name on the right. An unknown key maps to "" and the site block
# answers 401 without ever reaching the service.
#
# Add a site: edit SITES in hsc-setup.sh and re-run `install`.
# Replace a key: `hsc-setup.sh rotate <site>` — it rewrites this file and
# reloads nginx. Deleting a line revokes that key immediately.

EOF
    cat <<'EOF'
map $http_authorization $hsc_site {
    default "";

EOF
    for pair in "$@"; do
      name="${pair%%=*}"; key="${pair#*=}"
      printf '    "Bearer %s" "%s";\n' "$key" "$name"
    done
    printf '}\n'
  } >"$NGINX_KEYS.new"
  install -m 0640 -o root -g root "$NGINX_KEYS.new" "$NGINX_KEYS"
  rm -f "$NGINX_KEYS.new"
}

write_keys_json() {
  local name origin first_site=1 first_origin; local -a origin_list
  {
    printf '{\n'
    for name in $(site_names); do
      [ $first_site -eq 1 ] || printf ',\n'
      first_site=0
      printf '  "%s": [' "$name"
      first_origin=1
      # `read -ra` splits on IFS *without* pathname expansion. Plain
      # `for origin in $origins` would glob, and the public site's single
      # origin is literally "*" — it expanded to the contents of the working
      # directory and wrote them into keys.json as origins.
      read -ra origin_list <<<"$(site_origins "$name")"
      for origin in "${origin_list[@]}"; do
        [ $first_origin -eq 1 ] || printf ', '
        first_origin=0
        printf '"%s"' "$origin"
      done
      printf ']'
    done
    printf '\n}\n'
  } >"$DIR/keys.json.new"
  # Readable by the service, writable by nobody but root.
  install -m 0640 -o root -g "$SERVICE_USER" "$DIR/keys.json.new" "$DIR/keys.json"
  rm -f "$DIR/keys.json.new"
}

# --------------------------------------------------------------------------
# Cloudflare
# --------------------------------------------------------------------------

# Cloudflare terminates TLS for the world and calls this origin itself, so every
# request arrives from a Cloudflare address. Without this, rate limiting would
# bucket the whole internet into a handful of counters and the access log would
# name Cloudflare instead of the caller. The same ranges then become the guest
# list: anything reaching the origin directly never went through Cloudflare's
# WAF, and has no business here.
write_cloudflare_snippet() {
  local v4 v6 range count=0
  v4="$(curl -fsS --max-time 20 "$CF_IPS_V4" || true)"
  v6="$(curl -fsS --max-time 20 "$CF_IPS_V6" || true)"
  if [ -z "$v4" ] || [ -z "$v6" ]; then
    [ -f "$NGINX_CF" ] ||
      die "could not fetch the Cloudflare ranges from $CF_IPS_V4 and none are on file — retry with network, or install with --cf-no-lock"
    warn "could not refresh the Cloudflare ranges — keeping the ones already in $NGINX_CF"
    return 0
  fi
  install -d -m 0755 /etc/nginx/snippets
  {
    printf '# %s — written by hsc-setup.sh, do not edit here.\n#\n' "$NGINX_CF"
    printf '# Fetched from %s and %s on %s.\n' "$CF_IPS_V4" "$CF_IPS_V6" "$(date -u '+%Y-%m-%d')"
    printf '# Cloudflare changes these rarely; re-run `hsc-setup.sh install` to refresh.\n\n'
    printf '# The caller, as Cloudflare saw it — what limit_req counts and the log records.\n'
    for range in $v4 $v6; do printf 'set_real_ip_from %s;\n' "$range"; done
    printf 'real_ip_header CF-Connecting-IP;\nreal_ip_recursive on;\n'
    if [ "$CF_LOCK" -eq 1 ]; then
      printf '\n# Direct traffic did not pass Cloudflare, so it does not pass here either.\n'
      for range in $v4 $v6; do printf 'allow %s;\n' "$range"; done
      printf '# The installer verifies the chain from the box itself.\nallow 127.0.0.1;\nallow ::1;\ndeny all;\n'
    fi
  } >"$NGINX_CF.new"
  install -m 0644 -o root -g root "$NGINX_CF.new" "$NGINX_CF"
  rm -f "$NGINX_CF.new"
  count="$(printf '%s %s' "$v4" "$v6" | wc -w | tr -d ' ')"
  if [ "$CF_LOCK" -eq 1 ]; then
    ok "$NGINX_CF — $count Cloudflare ranges, real IP restored, direct traffic refused"
  else
    ok "$NGINX_CF — $count Cloudflare ranges, real IP restored (no origin lock)"
  fi
}

# `nginx -t`, with its output indented into the run and a refusal that stops
# the install before anything is reloaded.
nginx_check() {
  local out
  if out="$(nginx -t 2>&1)"; then
    printf '%s\n' "$out" | sed 's/^/    /' >&2
    return 0
  fi
  printf '%s\n' "$out" | sed 's/^/    /' >&2
  die "nginx rejected the configuration — nothing was reloaded, so the running one is untouched"
}

record_keys() { # record_keys <name=key> …
  local pair
  {
    printf '# headless-seo-check API keys — %s\n' "$(date -u '+%Y-%m-%d %H:%M:%SZ')"
    for pair in "$@"; do printf '%s\n' "${pair/=/  }"; done
    printf '\n'
  } >>"$KEY_BACKUP"
  chmod 0600 "$KEY_BACKUP"
}

# --------------------------------------------------------------------------
# install
# --------------------------------------------------------------------------

cmd_install() {
  [ -n "$DOMAIN" ] || [ "$SKIP_NGINX" -eq 1 ] || die "--domain is required (or pass --skip-nginx)"
  if [ "$SKIP_CERTBOT" -eq 0 ] && [ "$SKIP_NGINX" -eq 0 ] && [ -z "$EMAIL" ]; then
    die "--email is required for certbot (or pass --skip-certbot)"
  fi

  # Cloudflare's proxy gives up on a slow origin at about 100 seconds and shows
  # the caller a 524 that says nothing. Finish before it does.
  if [ -z "$TIMEOUT_MS" ]; then
    if [ "$CLOUDFLARE" -eq 1 ]; then TIMEOUT_MS=90000; else TIMEOUT_MS=180000; fi
  fi
  local proxy_timeout=$(( TIMEOUT_MS / 1000 + 20 ))s

  step "Preflight"
  preflight
  locate_binary
  info "domain: $DOMAIN · cloudflare: $([ "$CLOUDFLARE" -eq 1 ] && echo yes || echo no) · audit timeout: ${TIMEOUT_MS}ms"

  step "Packages"
  local missing=()
  command -v openssl >/dev/null || missing+=(openssl)
  command -v curl    >/dev/null || missing+=(curl)
  if [ "$SKIP_NGINX" -eq 0 ]; then
    command -v nginx >/dev/null || missing+=(nginx)
    [ "$SKIP_CERTBOT" -eq 1 ] || command -v certbot >/dev/null || missing+=(certbot python3-certbot-nginx)
  fi
  if [ ${#missing[@]} -gt 0 ]; then
    info "apt-get install: ${missing[*]}"
    DEBIAN_FRONTEND=noninteractive apt-get update -qq
    DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${missing[@]}"
  fi
  ok "packages present"

  step "Service user and layout"
  if id "$SERVICE_USER" >/dev/null 2>&1; then
    ok "user $SERVICE_USER exists"
  else
    useradd --system --home-dir "$DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
    ok "created system user $SERVICE_USER (no shell, no login)"
  fi
  install -d -m 0750 -o root -g "$SERVICE_USER" "$DIR" "$DIR/bin"
  # The only paths the service user owns: mise unpacks runtimes into .local and
  # writes its cache, state and lockfiles alongside. Everything else under $DIR
  # stays root's, and the running service writes to none of it.
  install -d -m 0755 -o "$SERVICE_USER" -g "$SERVICE_USER" \
    "$DIR/.local" "$DIR/.local/share" "$DIR/.local/state" "$DIR/.cache" "$DIR/.config"
  install -m 0755 -o root -g "$SERVICE_USER" "$BINARY" "$DIR/bin/headless-seo-check"
  install -m 0644 -o root -g "$SERVICE_USER" "$HERE/server.ts" "$DIR/server.ts"
  ok "$DIR is root-owned, readable by $SERVICE_USER, writable by neither at runtime"
  # mise discovers mise.toml by walking up from the working directory, and the
  # service user can traverse this one. Everything after this point runs here.
  cd "$DIR" || die "$DIR was created but cannot be entered"

  step "Runtime (mise)"
  # Resolved once, then always called by absolute path: this runs under sudo,
  # under runuser and from whatever shell the operator happens to use, and a
  # PATH that lost /usr/local/bin must not turn into a confusing failure.
  local mise_bin
  mise_bin="$(command -v mise 2>/dev/null || true)"
  if [ -z "$mise_bin" ]; then
    if [ -x /usr/local/bin/mise ]; then
      mise_bin=/usr/local/bin/mise
    else
      info "installing mise to /usr/local/bin/mise"
      curl -fsSL https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh
      mise_bin=/usr/local/bin/mise
    fi
  fi
  [ -x "$mise_bin" ] || die "mise is not executable at $mise_bin"
  ok "mise: $mise_bin ($("$mise_bin" --version 2>/dev/null | head -1))"
  printf '[tools]\nbun = "%s"\n' "$BUN_VERSION" >"$DIR/mise.toml"
  chown root:"$SERVICE_USER" "$DIR/mise.toml"; chmod 0644 "$DIR/mise.toml"
  run_as "$SERVICE_USER" "$mise_bin" trust "$DIR/mise.toml"
  run_as "$SERVICE_USER" "$mise_bin" install
  local bun_bin
  bun_bin="$(run_as "$SERVICE_USER" "$mise_bin" which bun | tail -1)"
  [ -x "$bun_bin" ] || die "mise did not produce a usable bun (got: ${bun_bin:-nothing})"
  ok "bun: $bun_bin"

  step "API keys"
  local name key pairs=() fresh=()
  for name in $(site_names); do
    key="$(existing_key "$name")"
    if [ -n "$key" ]; then
      info "$name — keeping the existing key (use \`rotate $name\` to replace it)"
    else
      key="$(new_key "$name")"
      fresh+=("$name=$key")
      ok "$name — new 256-bit key"
    fi
    pairs+=("$name=$key")
  done
  write_keys_json
  ok "$DIR/keys.json — $(site_names | wc -l | tr -d ' ') sites, origins locked down"
  if [ "$SKIP_NGINX" -eq 0 ]; then
    write_nginx_keys "${pairs[@]}"
    ok "$NGINX_KEYS (0640 root:root)"
  fi
  [ ${#fresh[@]} -eq 0 ] || record_keys "${fresh[@]}"

  step "systemd unit"
  sed -e "s|@BUN@|$bun_bin|g" -e "s|@DIR@|$DIR|g" -e "s|@USER@|$SERVICE_USER|g" -e "s|@PORT@|$PORT|g" \
    -e "s|@TIMEOUT_MS@|$TIMEOUT_MS|g" \
    "$HERE/templates/hsc.service.tmpl" >"$UNIT"
  chmod 0644 "$UNIT"
  systemctl daemon-reload
  systemctl enable hsc >/dev/null 2>&1 || true
  systemctl restart hsc
  ok "hsc.service enabled and started"

  step "Waiting for the service"
  local i
  for i in $(seq 1 30); do
    if curl -fsS "http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1; then
      ok "healthz answers on 127.0.0.1:$PORT"
      break
    fi
    [ "$i" -lt 30 ] || { journalctl -u hsc -n 30 --no-pager >&2; die "the service never came up"; }
    sleep 1
  done

  if [ "$SKIP_NGINX" -eq 0 ]; then
    step "nginx"
    local cf_include=""
    if [ "$CLOUDFLARE" -eq 1 ]; then
      write_cloudflare_snippet
      cf_include="    include $NGINX_CF;"
    fi
    if [ -f "$NGINX_SITE" ] && [ "$FORCE_NGINX" -eq 0 ]; then
      info "$NGINX_SITE exists — left alone (--force-nginx to rewrite it)"
      if [ "$CLOUDFLARE" -eq 1 ] && ! grep -q "$NGINX_CF" "$NGINX_SITE"; then
        warn "the existing site file does not include $NGINX_CF — re-run with --force-nginx to apply --cloudflare"
      fi
    else
      sed -e "s|@DOMAIN@|$DOMAIN|g" -e "s|@PORT@|$PORT|g" -e "s|@CF_INCLUDE@|$cf_include|g" \
        -e "s|@PROXY_TIMEOUT@|$proxy_timeout|g" -e "s|@PUBLIC_SITE@|$PUBLIC_SITE|g" \
        "$HERE/templates/nginx-site.conf.tmpl" >"$NGINX_SITE"
      chmod 0644 "$NGINX_SITE"
      ok "wrote $NGINX_SITE for $DOMAIN"
    fi
    ln -sfn "$NGINX_SITE" /etc/nginx/sites-enabled/hsc
    nginx_check
    systemctl reload nginx
    ok "nginx reloaded"

    if [ "$SKIP_CERTBOT" -eq 0 ]; then
      step "TLS certificate"
      if [ -d "/etc/letsencrypt/live/$DOMAIN" ]; then
        ok "certificate for $DOMAIN already present"
      elif ! getent hosts "$DOMAIN" >/dev/null; then
        warn "$DOMAIN does not resolve from this machine — skipping certbot."
        warn "Point the DNS record here, then re-run: sudo $0 install --domain $DOMAIN --email $EMAIL"
      else
        certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos -m "$EMAIL" --redirect
        systemctl reload nginx
        ok "certificate issued and nginx reloaded"
      fi
    fi
  fi

  step "Verification"
  verify_chain "${pairs[0]}"

  step "Done"
  print_keys "${pairs[@]}"
}

# The three answers that prove the chain is wired: the service is up, an
# unknown key never reaches it, and a known key may not audit a foreign site.
verify_chain() { # verify_chain <name=key>
  local pair="$1" name="${1%%=*}" key="${1#*=}" code
  local base="https://$DOMAIN"
  local curl_opts=(-sk -o /dev/null -w '%{http_code}' --max-time 20 --resolve "$DOMAIN:443:127.0.0.1")
  if [ "$SKIP_NGINX" -eq 1 ]; then
    base="http://127.0.0.1:$PORT"
    curl_opts=(-s -o /dev/null -w '%{http_code}' --max-time 20)
  fi

  code="$(curl "${curl_opts[@]}" "$base/healthz" || true)"
  [ "$code" = "200" ] && ok "healthz → 200" || warn "healthz → $code (expected 200)"

  code="$(curl "${curl_opts[@]}" -X POST "$base/audit" -H 'Content-Type: application/json' \
    -d '{"site":"https://example.com"}' || true)"
  if [ "$SKIP_NGINX" -eq 1 ]; then
    [ "$code" = "401" ] && ok "no site header → 401" || warn "no site header → $code (expected 401)"
  else
    [ "$code" = "401" ] && ok "no key → 401 (nginx never asks the service)" || warn "no key → $code (expected 401)"
  fi

  code="$(curl "${curl_opts[@]}" -X POST "$base/audit" \
    -H "Authorization: Bearer $key" -H "X-HSC-Site: $name" -H 'Content-Type: application/json' \
    -d '{"site":"https://not-yours.example"}' || true)"
  [ "$code" = "403" ] && ok "valid key, foreign target → 403" || warn "foreign target → $code (expected 403)"
  printf '%s' "$pair" >/dev/null
}

print_keys() {
  local pair name key first=1
  printf '\n%sAPI keys — copy them now, they are shown once (backup: %s)%s\n\n' "$B" "$KEY_BACKUP" "$OFF"
  for pair in "$@"; do
    name="${pair%%=*}"; key="${pair#*=}"
    printf '  %-16s %s\n' "$name" "$key"
  done
  for pair in "$@"; do
    if [ $first -eq 1 ]; then
      name="${pair%%=*}"; key="${pair#*=}"
      first=0
      printf '\n%sCall it from a site:%s\n\n' "$B" "$OFF"
      printf "  curl -sS -X POST https://%s/audit \\\\\n" "${DOMAIN:-127.0.0.1:$PORT}"
      printf "    -H 'Authorization: Bearer %s' \\\\\n" "$key"
      printf "    -H 'Content-Type: application/json' \\\\\n"
      printf "    -d '{\"site\":\"%s\",\"sample\":10}'\n" "$(site_origins "$name" | awk '{print $1}')"
    fi
  done
  printf '\n  %ssudo %s status%s        what is running\n' "$DIM" "$0" "$OFF"
  printf '  %ssudo %s rotate <site>%s  replace one key\n' "$DIM" "$0" "$OFF"
  printf '  %sjournalctl -u hsc -f%s      one line per request\n\n' "$DIM" "$OFF"
}

# --------------------------------------------------------------------------
# rotate
# --------------------------------------------------------------------------

cmd_rotate() {
  local target="${1:-}"
  [ -n "$target" ] || die "usage: hsc-setup.sh rotate <site>   (one of: $(site_names | tr '\n' ' '))"
  site_names | grep -qx "$target" || die "\"$target\" is not a configured site: $(site_names | tr '\n' ' ')"
  [ -f "$NGINX_KEYS" ] || die "$NGINX_KEYS does not exist — run install first"

  step "Rotating the key for $target"
  local name key pairs=() new=""
  for name in $(site_names); do
    if [ "$name" = "$target" ]; then
      key="$(new_key "$name")"; new="$key"
    else
      key="$(existing_key "$name")"
      [ -n "$key" ] || die "$name has no key on file — run install first"
    fi
    pairs+=("$name=$key")
  done
  write_nginx_keys "${pairs[@]}"
  nginx_check
  systemctl reload nginx
  record_keys "$target=$new"
  ok "the old key stopped working the moment nginx reloaded"
  printf '\n  %-16s %s\n\n' "$target" "$new"
}

# --------------------------------------------------------------------------
# status
# --------------------------------------------------------------------------

# --------------------------------------------------------------------------
# uninstall
# --------------------------------------------------------------------------

# Give the machine back everything this took. The audit now runs inside the
# dashboard's container image, so the whole of /opt/hsc — a bun runtime
# installed by mise, its cache, the service, the keys — has no reason to exist
# here any more, and it is the runtime, not the 2.4 MB binary, that took the
# room.
#
# Prints the plan and changes nothing without --yes: this deletes a working
# service, and an operator who mistyped a command deserves to be told so rather
# than obeyed.
cmd_uninstall() {
  local proceed=0 purge=0 arg
  for arg in "$@"; do
    case "$arg" in
      --yes) proceed=1 ;;
      --purge-keys) purge=1 ;;
    esac
  done

  step "What would be removed"
  local size=""
  if [ -d "$DIR" ]; then
    size="$(du -sh "$DIR" 2>/dev/null | cut -f1)"
    info "$DIR (${size:-unknown}) — binary, server, keys.json, the mise-installed bun"
  fi
  if systemctl list-unit-files hsc.service >/dev/null 2>&1; then info "the hsc service, stopped and disabled"; fi
  if [ -f "$UNIT" ]; then info "$UNIT"; fi
  if [ -f "$NGINX_SITE" ]; then info "$NGINX_SITE (and its sites-enabled link)"; fi
  if [ -f "$NGINX_KEYS" ]; then info "$NGINX_KEYS — every key stops working"; fi
  if [ -f "$NGINX_CF" ]; then info "$NGINX_CF"; fi
  if id "$SERVICE_USER" >/dev/null 2>&1; then info "the $SERVICE_USER user"; fi
  if [ -f "$KEY_BACKUP" ]; then
    if [ "$purge" -eq 1 ]; then info "$KEY_BACKUP"; else info "$KEY_BACKUP stays (--purge-keys to delete it too)"; fi
  fi
  info "kept either way: the Let's Encrypt certificate, mise itself, and any .hsc.bak this wrote"

  if [ "$proceed" -eq 0 ]; then
    printf '\n  %sNothing was changed. Add --yes to go ahead.%s\n\n' "$B" "$OFF" >&2
    return 0
  fi

  step "Removing"
  if systemctl is-active --quiet hsc 2>/dev/null; then systemctl stop hsc; ok "service stopped"; fi
  systemctl disable hsc >/dev/null 2>&1 || true
  if [ -f "$UNIT" ]; then rm -f "$UNIT"; systemctl daemon-reload; ok "unit removed"; fi

  local touched_nginx=0
  for f in /etc/nginx/sites-enabled/hsc "$NGINX_SITE" "$NGINX_KEYS" "$NGINX_CF"; do
    if [ -e "$f" ] || [ -L "$f" ]; then rm -f "$f"; touched_nginx=1; ok "removed $f"; fi
  done
  if [ "$touched_nginx" -eq 1 ] && command -v nginx >/dev/null 2>&1; then
    if nginx -t >/dev/null 2>&1; then
      systemctl reload nginx
      ok "nginx reloaded — the audit API no longer answers"
    else
      warn "nginx -t fails for a reason of its own; not reloading. Run it yourself."
    fi
  fi

  if [ -d "$DIR" ]; then rm -rf "$DIR"; ok "$DIR removed${size:+ — ${size} back}"; fi
  if id "$SERVICE_USER" >/dev/null 2>&1; then
    userdel "$SERVICE_USER" 2>/dev/null || warn "could not delete the $SERVICE_USER user"
    ok "user $SERVICE_USER removed"
  fi
  if [ "$purge" -eq 1 ] && [ -f "$KEY_BACKUP" ]; then rm -f "$KEY_BACKUP"; ok "$KEY_BACKUP removed"; fi

  step "Done"
  printf '  The audit runs in the dashboard image now. Nothing of it is left here.\n\n' >&2
}

cmd_status() {
  step "Service"
  systemctl is-enabled hsc >/dev/null 2>&1 && ok "enabled at boot" || warn "not enabled at boot"
  if systemctl is-active --quiet hsc; then
    ok "running — $(systemctl show hsc -p ActiveEnterTimestamp --value)"
  else
    warn "not running"
  fi
  if curl -fsS "http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1; then
    ok "healthz answers on 127.0.0.1:$PORT"
  else
    warn "healthz does not answer on 127.0.0.1:$PORT"
  fi
  [ -x "$DIR/bin/headless-seo-check" ] && ok "binary: $("$DIR/bin/headless-seo-check" --version)"

  step "Sites"
  local name
  for name in $(site_names); do
    if [ -n "$(existing_key "$name")" ]; then
      printf '    %s✓%s %-16s %s\n' "$GREEN" "$OFF" "$name" "$(site_origins "$name")"
    else
      printf '    %s!%s %-16s no key installed\n' "$YELLOW" "$OFF" "$name"
    fi
  done

  step "nginx"
  if [ -f "$NGINX_SITE" ]; then
    # A report, not a gate: a broken configuration is what status exists to show.
    nginx -t 2>&1 | sed 's/^/    /' || true
    local dom
    dom="$(sed -n 's/^[[:space:]]*server_name[[:space:]]*\([^;]*\);.*/\1/p' "$NGINX_SITE" | head -1)"
    ok "site file present for ${dom:-unknown}"
    if grep -q "$NGINX_CF" "$NGINX_SITE" 2>/dev/null; then
      if grep -q '^deny all;' "$NGINX_CF" 2>/dev/null; then
        ok "Cloudflare: real IP restored, origin locked to Cloudflare ranges"
      else
        ok "Cloudflare: real IP restored, direct traffic still accepted"
      fi
    else
      info "no Cloudflare handling (install with --cloudflare if the domain is proxied)"
    fi
    if [ -n "$dom" ] && [ -f "/etc/letsencrypt/live/$dom/fullchain.pem" ]; then
      ok "certificate $(openssl x509 -enddate -noout -in "/etc/letsencrypt/live/$dom/fullchain.pem" | cut -d= -f2)"
    else
      warn "no Let's Encrypt certificate for ${dom:-this site}"
    fi
  else
    warn "$NGINX_SITE does not exist"
  fi
  printf '\n'
}

# --------------------------------------------------------------------------
# Entry point
# --------------------------------------------------------------------------

ORIGINAL_ARGS=("$@")
command="${1:-install}"
[ $# -gt 0 ] && shift || true
case "$command" in
  -h|--help|help) usage; exit 0 ;;
esac

ROTATE_TARGET=""
while [ $# -gt 0 ]; do
  case "$1" in
    --domain)       DOMAIN="${2:-}"; shift 2 ;;
    --email)        EMAIL="${2:-}"; shift 2 ;;
    --binary)       BINARY="${2:-}"; shift 2 ;;
    --bun-version)  BUN_VERSION="${2:-}"; shift 2 ;;
    --port)         PORT="${2:-}"; shift 2 ;;
    --dir)          DIR="${2:-}"; shift 2 ;;
    --cloudflare)    CLOUDFLARE=1; shift ;;
    --no-cloudflare) CLOUDFLARE=0; shift ;;
    --cf-no-lock)    CF_LOCK=0; shift ;;
    --timeout-ms)    TIMEOUT_MS="${2:-}"; shift 2 ;;
    --yes|--purge-keys) shift ;; # read from ORIGINAL_ARGS by cmd_uninstall
    --skip-nginx)   SKIP_NGINX=1; shift ;;
    --skip-certbot) SKIP_CERTBOT=1; shift ;;
    --force-nginx)  FORCE_NGINX=1; shift ;;
    -h|--help)      usage; exit 0 ;;
    -*)             die "unknown option: $1" ;;
    *)              ROTATE_TARGET="$1"; shift ;;
  esac
done

need_root
case "$command" in
  install) cmd_install ;;
  rotate)  cmd_rotate "$ROTATE_TARGET" ;;
  status)  cmd_status ;;
  uninstall) cmd_uninstall ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"} ;;
  *)       usage; die "unknown command: $command" ;;
esac
