#!/usr/bin/env bash
set -Eeuo pipefail
umask 027

DEPLOYMENT_PROFILE="${LEARNITIVE_DEPLOYMENT_PROFILE:-staging}"
PREVIEW_DOMAIN='preview.learnitive.com'
STAGING_SITE_ROOT='/var/www/edu.learnitive.com'
LEGACY_OS_STAGING_SITE_ROOT='/var/www/os.learnitive.com'
PRODUCTION_SITE_ROOT='/var/www/learnitive.com'
STAGING_APP_ROOT="$STAGING_SITE_ROOT/public_html"
LEGACY_OS_STAGING_APP_ROOT="$LEGACY_OS_STAGING_SITE_ROOT/public_html"
CONFIG_ROOT='/etc/learnitive'
case "$DEPLOYMENT_PROFILE" in
  staging)
    SITE_ROOT="$STAGING_SITE_ROOT"
    TARGET_DOMAIN='edu.learnitive.com'
    DEPLOYMENT_ENV='staging'
    APACHE_HTTP_SITE='edu.learnitive.com-http.conf'
    APACHE_HTTPS_SITE='edu.learnitive.com-https.conf'
    INSTALL_LOCK_FILE='/run/lock/learnitive-staging-installer.lock'
    ;;
  www)
    SITE_ROOT="$PRODUCTION_SITE_ROOT"
    TARGET_DOMAIN='www.learnitive.com'
    DEPLOYMENT_ENV='production'
    APACHE_HTTP_SITE='www.learnitive.com-http.conf'
    APACHE_HTTPS_SITE='www.learnitive.com-https.conf'
    INSTALL_LOCK_FILE='/run/lock/learnitive-www-installer.lock'
    ;;
  *)
    printf '[Learnitive installer] ERROR: unsupported deployment profile %s\n' "$DEPLOYMENT_PROFILE" >&2
    exit 2
    ;;
esac
APP_ROOT="$SITE_ROOT/public_html"
DATA_ROOT="$SITE_ROOT/learnitive-data"
MEDIA_ROOT="$SITE_ROOT/learnitive-media"
FONT_SOURCE_ROOT="$SITE_ROOT/fonts"
DIST_ROOT="$APP_ROOT/dist"
TEMPLATE_ROOT="$DATA_ROOT/templates"
TEMPLATE_PUBLIC_COPY="$DIST_ROOT/learnitive-templates"
QUEUE_ROOT="$DATA_ROOT/tmp/queue"
RUNTIME_IMAGE='learnitive/development:2026.08'
HOST_RUNTIME_ROOT="$SITE_ROOT/.learnitive-runtime"
HOST_NODE_BIN="$HOST_RUNTIME_ROOT/bin/node"
RUNNER_PORT=9890
CONSOLE_PORT=9887
EGRESS_PROXY_PORT=9892
REDIS_HOST_PORT=7387
RUNNER_BASE_URL="http://127.0.0.1:$RUNNER_PORT"
RUNNER_JOBS_URL="$RUNNER_BASE_URL/v1/jobs"
CONSOLE_BASE_URL="http://127.0.0.1:$CONSOLE_PORT"
REDIS_CONTAINER='learnitive-os-redis'
RUNTIME_DEPLOYMENT_OWNER="$TARGET_DOMAIN"
MAIN_USER='learnitive-os'
CONSOLE_USER='learnitive-os-console'
RUNNER_USER='learnitive-os-runner'
REDIS_SOURCE_IMAGE="${LEARNITIVE_REDIS_SOURCE_IMAGE:-redis:7.4-alpine}"
DEV_BASE_SOURCE_IMAGE="${LEARNITIVE_DEV_BASE_SOURCE_IMAGE:-ubuntu:24.04}"
PREVIEW_APACHE_HTTP_SITE='preview.learnitive.com-http.conf'
PREVIEW_APACHE_HTTPS_SITE='preview.learnitive.com-https.conf'
MARKER_FILE="$CONFIG_ROOT/.deployment"
SUCCESS_MARKER="$CONFIG_ROOT/.successful-release"
SERVICE_ACCOUNT_MARKER="$SITE_ROOT/.learnitive-staging-service-accounts"
NETWORK_OWNER_COMPAT=''
SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=deploy/learnitive/protected-production.sh
source "$SOURCE_ROOT/deploy/learnitive/protected-production.sh"

PROTECTED_BASELINE_FILE="/run/lock/learnitive-protected-production-before-${DEPLOYMENT_PROFILE}.json"
PROTECTED_BASELINE_CAPTURED=0

SERVICES=(
  learnitive.service
  learnitive-secret-helper.service
  learnitive-console-gateway.service
  learnitive-console-egress.service
  learnitive-egress-proxy.service
  learnitive-universal-runner.service
  learnitive-redis.service
)
APP_SERVICES=(
  learnitive.service
  learnitive-secret-helper.service
  learnitive-console-gateway.service
  learnitive-egress-proxy.service
  learnitive-universal-runner.service
)
PREVIOUSLY_RUNNING_APP_SERVICES=()
SERVICES_QUIESCED_FOR_BUILD=0

log() { printf '[Learnitive installer] %s\n' "$*"; }
warn() { printf '[Learnitive installer] WARNING: %s\n' "$*" >&2; }

restore_quiesced_services_if_needed() {
  (( SERVICES_QUIESCED_FOR_BUILD == 1 )) || return 0
  if (( ${#PREVIOUSLY_RUNNING_APP_SERVICES[@]} == 0 )); then
    SERVICES_QUIESCED_FOR_BUILD=0
    return 0
  fi
  warn 'Installation failed while staging services were quiesced; attempting to restore the previously running Learnitive services.'
  local unit restored=0 failed=0
  # Restore runtime dependencies before the main API so systemd startup does not race
  # a stopped runner/gateway/proxy after a failed activation.
  local -a recovery_order=(
    learnitive-universal-runner.service
    learnitive-egress-proxy.service
    learnitive-console-gateway.service
    learnitive-secret-helper.service
    learnitive.service
  )
  for unit in "${recovery_order[@]}"; do
    [[ " ${PREVIOUSLY_RUNNING_APP_SERVICES[*]} " == *" $unit "* ]] || continue
    if systemctl start "$unit" >/dev/null 2>&1 && timeout 15 bash -c "until systemctl is-active --quiet '$unit'; do sleep .25; done"; then
      restored=$((restored + 1))
    else
      failed=$((failed + 1))
      warn "Could not restore $unit to an active state. Recent journal follows:"
      journalctl --no-pager -u "$unit" -n 40 >&2 || true
    fi
  done
  SERVICES_QUIESCED_FOR_BUILD=0
  if (( failed == 0 )); then log "Failure recovery restored $restored previously running application service(s).";
  else warn "Failure recovery restored $restored service(s); $failed service(s) still require operator attention."; fi
}

die() {
  printf '[Learnitive installer] ERROR: %s\n' "$*" >&2
  restore_quiesced_services_if_needed || true
  exit 1
}

verify_protected_baseline_on_exit() {
  local status=$?
  trap - EXIT
  if (( PROTECTED_BASELINE_CAPTURED == 1 )); then
    if node "$SOURCE_ROOT/scripts/learnitive-protected-production-baseline.mjs" verify "$PROTECTED_BASELINE_FILE"; then
      rm -f -- "$PROTECTED_BASELINE_FILE" "${PROTECTED_BASELINE_FILE}.after.json" || true
    else
      printf '[Learnitive installer] ERROR: read-only post-deployment verification detected a change to protected Interactive Shell production state.\n' >&2
      printf '[Learnitive installer] ERROR: forensic baseline retained at %s (current snapshot: %s.after.json).\n' "$PROTECTED_BASELINE_FILE" "$PROTECTED_BASELINE_FILE" >&2
      status=1
    fi
    PROTECTED_BASELINE_CAPTURED=0
  fi
  exit "$status"
}

capture_protected_baseline() {
  rm -f -- "${PROTECTED_BASELINE_FILE}.after.json" || true
  node "$SOURCE_ROOT/scripts/learnitive-protected-production-baseline.mjs" capture "$PROTECTED_BASELINE_FILE" || die 'Unable to capture the read-only Interactive Shell production baseline.'
  PROTECTED_BASELINE_CAPTURED=1
}

verify_protected_baseline_now() {
  (( PROTECTED_BASELINE_CAPTURED == 1 )) || die 'Protected Interactive Shell baseline was not captured.'
  if ! node "$SOURCE_ROOT/scripts/learnitive-protected-production-baseline.mjs" verify "$PROTECTED_BASELINE_FILE"; then
    PROTECTED_BASELINE_CAPTURED=0
    die "Protected Interactive Shell production state changed during Learnitive deployment. Baseline retained at $PROTECTED_BASELINE_FILE and current snapshot at ${PROTECTED_BASELINE_FILE}.after.json."
  fi
  rm -f -- "$PROTECTED_BASELINE_FILE" "${PROTECTED_BASELINE_FILE}.after.json"
  PROTECTED_BASELINE_CAPTURED=0
}

on_error() {
  local status=$?
  printf '[Learnitive installer] FAILED at line %s (status %s).\n' "${BASH_LINENO[0]:-unknown}" "$status" >&2
  print_service_diagnostics || true
  restore_quiesced_services_if_needed || true
  exit "$status"
}
trap on_error ERR
trap verify_protected_baseline_on_exit EXIT

require_root() { [[ ${EUID:-$(id -u)} -eq 0 ]] || die 'Run installer.sh as root.'; }
require_command() { command -v "$1" >/dev/null 2>&1 || die "Required command is missing: $1"; }

source_is_staging_tree() {
  [[ "$SOURCE_ROOT" == "$APP_ROOT" ]] || die "This $DEPLOYMENT_PROFILE installer must run from $APP_ROOT (found $SOURCE_ROOT)."
  [[ -f "$SOURCE_ROOT/package.json" && -d "$SOURCE_ROOT/server" && -d "$SOURCE_ROOT/deploy" ]] || die 'Release tree is incomplete.'
}

promotion_from_staging_marker_is_authorized() {
  [[ "$DEPLOYMENT_PROFILE" == 'www' && "${LEARNITIVE_ALLOW_WWW_PROMOTION:-0}" == '1' ]] || return 1
  [[ -f "$MARKER_FILE" && ! -L "$MARKER_FILE" ]] || return 1
  [[ "$(stat -c %U:%G "$MARKER_FILE" 2>/dev/null || true)" == 'root:root' ]] || return 1
  { grep -Fxq 'domain=edu.learnitive.com' "$MARKER_FILE" || grep -Fxq 'domain=os.learnitive.com' "$MARKER_FILE" || grep -Fxq 'domain=www.learnitive.com' "$MARKER_FILE"; } || return 1
  { grep -Fxq "root=$STAGING_APP_ROOT" "$MARKER_FILE" || grep -Fxq "root=$LEGACY_OS_STAGING_APP_ROOT" "$MARKER_FILE"; } || return 1
  return 0
}

assert_protected_products_untouched() {
  local database
  database="$(env_get "$CONFIG_ROOT/learnitive.env" MYSQL_DATABASE 2>/dev/null || true)"
  database="${database:-learnitive_os}"
  learnitive_assert_interactive_shell_isolation "$APP_ROOT" "$SITE_ROOT" "$CONFIG_ROOT" "$database" || \
    die 'Refusing a target that overlaps the protected Interactive Shell production installation.'
  local port
  for port in 4210 4211 "$REDIS_HOST_PORT" "$CONSOLE_PORT" "$RUNNER_PORT" "$EGRESS_PROXY_PORT"; do
    learnitive_port_is_protected "$port" && die "Learnitive port $port collides with a protected Interactive Shell production port."
  done
  # A safe port intentionally makes learnitive_port_is_protected return 1.
  # Without an explicit success return here, the function itself inherited that
  # final status and every safe staging install aborted at the preflight line.
  return 0
}

config_root_is_staging_owned() {
  [[ ! -e "$CONFIG_ROOT" ]] && return 0
  if [[ -f "$MARKER_FILE" ]]; then
    if grep -Fxq "domain=$TARGET_DOMAIN" "$MARKER_FILE" && grep -Fxq "root=$APP_ROOT" "$MARKER_FILE"; then
      return 0
    fi
    if promotion_from_staging_marker_is_authorized; then
      return 0
    fi
    return 1
  fi
  if [[ ! -d "$CONFIG_ROOT" ]] || [[ -z "$(find "$CONFIG_ROOT" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then
    return 0
  fi
  if grep -RqsE "${TARGET_DOMAIN//./\\.}|${SITE_ROOT//./\\.}" "$CONFIG_ROOT"; then return 0; fi
  promotion_from_staging_marker_is_authorized && return 0
  [[ "$DEPLOYMENT_PROFILE" == 'www' && "${LEARNITIVE_ALLOW_WWW_PROMOTION:-0}" == '1' ]] && grep -RqsE 'edu\.learnitive\.com|/var/www/edu\.learnitive\.com|os\.learnitive\.com|/var/www/os\.learnitive\.com' "$CONFIG_ROOT"
}

verify_config_ownership() {
  config_root_is_staging_owned || die "$CONFIG_ROOT exists but is not provably owned by $TARGET_DOMAIN. Refusing to modify it."
}

detect_network_owner_compat() {
  NETWORK_OWNER_COMPAT=''
  [[ "$DEPLOYMENT_PROFILE" == 'www' ]] || return 0

  # A completed/interrupted www promotion can legitimately keep Docker bridge
  # networks created by the prior edu/os Learnitive deployment. Docker does not
  # support relabelling a network in place, and recreating these networks would
  # be destructive. Persist the exact legacy Learnitive owner in the deployment
  # marker only after the read-only host preflight has already validated the
  # fixed network names/subnets. Network controllers additionally validate their
  # full security signature before use.
  if [[ -f "$MARKER_FILE" && ! -L "$MARKER_FILE" ]]; then
    local persisted
    persisted="$(awk -F= '$1 == "network_owner_compat" {print $2; exit}' "$MARKER_FILE" 2>/dev/null || true)"
    case "$persisted" in
      edu.learnitive.com|os.learnitive.com) NETWORK_OWNER_COMPAT="$persisted"; return 0 ;;
    esac
  fi

  local observed='' candidate label name
  for name in learnitive-os-egress learnitive-os-runtime learnitive-os-internet; do
    docker network inspect "$name" >/dev/null 2>&1 || continue
    label="$(docker network inspect --format '{{index .Labels "learnitive.deployment"}}' "$name" 2>/dev/null || true)"
    case "$label" in
      edu.learnitive.com|os.learnitive.com) ;;
      "$TARGET_DOMAIN") continue ;;
      *) return 0 ;;
    esac
    if [[ -z "$observed" ]]; then observed="$label"; elif [[ "$observed" != "$label" ]]; then return 0; fi
  done
  candidate="$observed"
  if [[ -n "$candidate" ]]; then
    NETWORK_OWNER_COMPAT="$candidate"
    log "Preserving verified legacy Docker network ownership compatibility: $candidate -> $TARGET_DOMAIN."
  fi
}

write_deployment_marker() {
  install -d -m 0750 -o root -g root "$CONFIG_ROOT"
  cat >"$MARKER_FILE.tmp" <<MARKER
domain=$TARGET_DOMAIN
root=$APP_ROOT
release=r12
MARKER
  if [[ -n "$NETWORK_OWNER_COMPAT" ]]; then
    printf 'network_owner_compat=%s\n' "$NETWORK_OWNER_COMPAT" >>"$MARKER_FILE.tmp"
  fi
  chmod 0640 "$MARKER_FILE.tmp"
  mv -f "$MARKER_FILE.tmp" "$MARKER_FILE"
}

acquire_install_lock() {
  command -v flock >/dev/null 2>&1 || die 'Required command is missing: flock'
  install -d -m 0755 -o root -g root /run/lock
  exec {LEARNITIVE_INSTALL_LOCK_FD}>"$INSTALL_LOCK_FILE"
  if ! flock -n "$LEARNITIVE_INSTALL_LOCK_FD"; then
    die "Another Learnitive $DEPLOYMENT_PROFILE installer is already running (lock: $INSTALL_LOCK_FILE). Refusing concurrent activation."
  fi
}

compute_release_fingerprint() {
  local root="${1:-$APP_ROOT}"
  python3 - "$root" <<'PYFINGERPRINT'
from pathlib import Path
import hashlib
import os
import sys

root = Path(sys.argv[1]).resolve()
if not root.is_dir():
    raise SystemExit(f'release root is missing: {root}')

# Hash stable release inputs only. The installer/build intentionally creates or
# rewrites these paths, so they must not turn a successful identical rerun into
# a false "new release" activation.
excluded_exact = {
    'packages/tooling/browser-entry/public/compiler/status.json',
    'packages/tooling/browser-entry/public/learnitive-icons/lucide-category-icons.svg',
    'packages/tooling/browser-entry/public/learnitive-icons/lucide-category-icons.json',
    # Historical installs created this even though the release does not ship it.
    'server/package-lock.json',
}
excluded_prefixes = (
    'dist/',
    'dist.next/',
    'dist.previous/',
    'packages/tooling/browser-entry/dist/',
    'packages/tooling/browser-entry/public/compiler/busytex/',
    'packages/tooling/browser-entry/public/learnitive-fonts/',
    'packages/tooling/browser-entry/public/learnitive-icons/lucide/',
)

def excluded(rel: str) -> bool:
    if rel in excluded_exact or rel.startswith(excluded_prefixes):
        return True
    return '/node_modules/' in f'/{rel}/' or rel.startswith('node_modules/')

items = []
for path in root.rglob('*'):
    try:
        rel = path.relative_to(root).as_posix()
    except ValueError:
        continue
    if excluded(rel):
        continue
    if path.is_symlink():
        items.append((rel, b'L\0' + os.readlink(path).encode('utf-8', 'surrogateescape')))
    elif path.is_file():
        items.append((rel, b'F\0' + path.read_bytes()))

h = hashlib.sha256()
for rel, payload in sorted(items, key=lambda item: item[0]):
    h.update(rel.encode('utf-8', 'surrogateescape'))
    h.update(b'\0')
    h.update(hashlib.sha256(payload).digest())
    h.update(b'\n')
print(h.hexdigest())
PYFINGERPRINT
}

successful_release_marker_matches() {
  local fingerprint="$1" observed
  [[ -f "$SUCCESS_MARKER" && ! -L "$SUCCESS_MARKER" ]] || return 1
  [[ "$(stat -c %U:%G "$SUCCESS_MARKER" 2>/dev/null || true)" == 'root:root' ]] || die "Successful-release marker has unexpected ownership: $SUCCESS_MARKER"
  grep -Fxq "domain=$TARGET_DOMAIN" "$SUCCESS_MARKER" || return 1
  grep -Fxq "root=$APP_ROOT" "$SUCCESS_MARKER" || return 1
  observed="$(awk -F= '$1 == "fingerprint" {print $2; exit}' "$SUCCESS_MARKER")"
  [[ "$observed" =~ ^[a-f0-9]{64}$ ]] || return 1
  [[ "$observed" == "$fingerprint" ]]
}

write_successful_release_marker() {
  local fingerprint="$1" version
  version="$(node -e "process.stdout.write(require('$APP_ROOT/package.json').version || 'unknown')" 2>/dev/null || printf 'unknown')"
  cat >"$SUCCESS_MARKER.tmp" <<MARKER
domain=$TARGET_DOMAIN
root=$APP_ROOT
fingerprint=$fingerprint
version=$version
completed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
MARKER
  chown root:root "$SUCCESS_MARKER.tmp"
  chmod 0640 "$SUCCESS_MARKER.tmp"
  mv -f "$SUCCESS_MARKER.tmp" "$SUCCESS_MARKER"
}

existing_successful_release_is_healthy() {
  local unit password label
  for unit in "${SERVICES[@]}"; do
    unit_exists "$unit" || return 1
    unit_is_staging_owned "$unit" || return 1
    systemctl is-active --quiet "$unit" || return 1
  done

  label="$(docker inspect --format '{{index .Config.Labels "learnitive.deployment"}}' "$REDIS_CONTAINER" 2>/dev/null || true)"
  [[ "$label" == "$TARGET_DOMAIN" || "$label" == "$RUNTIME_DEPLOYMENT_OWNER" ]] || return 1
  password="$(env_get "$CONFIG_ROOT/redis.env" LEARNITIVE_REDIS_PASSWORD)"
  [[ -n "$password" ]] || return 1
  docker exec "$REDIS_CONTAINER" redis-cli --no-auth-warning -a "$password" ping 2>/dev/null | grep -qx PONG || return 1

  curl --fail --silent --max-time 3 "$RUNNER_BASE_URL/health" >/dev/null 2>&1 || return 1
  curl --fail --silent --max-time 3 "http://127.0.0.1:$EGRESS_PROXY_PORT/healthz" >/dev/null 2>&1 || return 1
  curl --fail --silent --max-time 3 "$CONSOLE_BASE_URL/healthz" >/dev/null 2>&1 || return 1
  curl --fail --silent --max-time 3 'http://127.0.0.1:4210/api/health' >/dev/null 2>&1 || return 1
  [[ -S /run/learnitive-secret-helper/control.sock ]] || return 1
  LEARNITIVE_DEPLOYMENT="$TARGET_DOMAIN" "$APP_ROOT/deploy/learnitive/terminal-network-control.sh" check >/dev/null 2>&1 || return 1
  LEARNITIVE_DEPLOYMENT="$TARGET_DOMAIN" "$APP_ROOT/deploy/learnitive/console-egress-control.sh" check >/dev/null 2>&1 || return 1
  curl --fail --silent --max-time 5 --resolve "$TARGET_DOMAIN:443:127.0.0.1" "https://$TARGET_DOMAIN/favicon.svg" >/dev/null 2>&1 || return 1
  production_apache_state_is_healthy || return 1
  return 0
}

maybe_skip_identical_successful_release() {
  local fingerprint="$1"
  [[ "${LEARNITIVE_FORCE_REINSTALL:-0}" != '1' ]] || {
    log 'LEARNITIVE_FORCE_REINSTALL=1 requested; bypassing identical-release fast path.'
    return 1
  }
  successful_release_marker_matches "$fingerprint" || return 1
  if existing_successful_release_is_healthy; then
    log "Release fingerprint $fingerprint is already successfully installed and healthy. Safe repeated invocation is a no-op."
    log 'No services, databases, Docker networks, firewall rules, dependencies, or browser assets were changed.'
    return 0
  fi
  warn 'The same release was previously installed, but its current health check is not clean; running the normal repair/re-activation path.'
  return 1
}

unit_exists() { systemctl cat "$1" >/dev/null 2>&1; }
unit_is_staging_owned() {
  local unit="$1" content
  content="$(systemctl cat "$unit" 2>/dev/null || true)"
  [[ -z "$content" ]] && return 1

  # Ownership is profile-aware. Staging must still reject production units.
  # The www profile, however, must accept units already rendered from the
  # production root after an interrupted activation; otherwise a safe rerun
  # deadlocks on the units that the previous attempt installed itself.
  if [[ "$DEPLOYMENT_PROFILE" == 'www' ]]; then
    if grep -Fq "$PRODUCTION_SITE_ROOT" <<<"$content" || grep -Fq 'www.learnitive.com' <<<"$content"; then
      return 0
    fi
    # During an explicit www promotion, the same known Learnitive unit names
    # may still point at the approved edu/os staging roots. Accept only those
    # exact Learnitive staging identities, never arbitrary units or roots.
    if [[ "${LEARNITIVE_ALLOW_WWW_PROMOTION:-0}" == '1' ]]; then
      if grep -Fq "$STAGING_SITE_ROOT" <<<"$content" || grep -Fq 'edu.learnitive.com' <<<"$content"; then
        return 0
      fi
      if grep -Fq "$LEGACY_OS_STAGING_SITE_ROOT" <<<"$content" || grep -Fq 'os.learnitive.com' <<<"$content"; then
        return 0
      fi
    fi
    return 1
  fi

  # Staging must never adopt a www/production-owned unit merely because it
  # shares the generic Learnitive service names.
  if grep -Fq "$PRODUCTION_SITE_ROOT" <<<"$content" || grep -Fq 'www.learnitive.com' <<<"$content"; then
    return 1
  fi
  if grep -Fq "$STAGING_SITE_ROOT" <<<"$content" || grep -Fq 'edu.learnitive.com' <<<"$content"; then
    return 0
  fi
  if grep -Fq "$LEGACY_OS_STAGING_SITE_ROOT" <<<"$content" || grep -Fq 'os.learnitive.com' <<<"$content"; then
    return 0
  fi
  return 1
}

verify_current_service_ownership() {
  local unit
  for unit in "${SERVICES[@]}"; do
    if unit_exists "$unit" && ! unit_is_staging_owned "$unit"; then
      die "Existing $unit is not provably owned by the active Learnitive $DEPLOYMENT_PROFILE profile; refusing to replace it."
    fi
  done
}

retire_old_staging_units() {
  local unit
  local candidates=(
    learnitive-os.service learnitive-os-latex-compile.service learnitive-os-console-gateway.service learnitive-os-universal-runner.service
    learnitive-os.service learnitive-os-latex-compile.service learnitive-os-console-gateway.service learnitive-os-universal-runner.service
  )
  for unit in "${candidates[@]}"; do
    if unit_exists "$unit"; then
      if unit_is_staging_owned "$unit"; then
        log "Retiring verified prior staging unit $unit"
        systemctl disable --now "$unit" >/dev/null 2>&1 || true
      else
        die "Legacy-looking unit $unit exists but is not provably staging-owned."
      fi
    fi
  done
}

retire_split_latex_service() {
  local unit='learnitive-latex-compile.service'
  if ! unit_exists "$unit"; then return 0; fi
  unit_is_staging_owned "$unit" || die "Existing $unit is not provably staging-owned; refusing to retire it."
  log "Retiring superseded split LaTeX service $unit; LaTeX now uses the shared Universal Runner."
  systemctl disable --now "$unit" >/dev/null 2>&1 || true
  if [[ -f "/etc/systemd/system/$unit" ]]; then rm -f -- "/etc/systemd/system/$unit"; fi
  systemctl daemon-reload
}

migrate_legacy_staging_config() {
  local legacy='/etc/learnitive-os'
  [[ -d "$legacy" ]] || return 0
  grep -RqsE "os\.learnitive\.com|/var/www/os\.learnitive\.com" "$legacy" || die "$legacy exists but cannot be proven staging-owned."
  log "Migrating missing staging configuration from $legacy"
  local item target
  while IFS= read -r -d '' item; do
    target="$CONFIG_ROOT/$(basename "$item")"
    [[ -e "$target" ]] || cp -a "$item" "$target"
  done < <(find "$legacy" -mindepth 1 -maxdepth 1 -print0)
}

existing_service_user_is_staging_owned() {
  local user="$1" candidate unit
  local -a candidates=() units=()
  case "$user" in
    "$MAIN_USER")
      candidates=("$DATA_ROOT/audit" "$MEDIA_ROOT" "$SITE_ROOT/templates")
      units=(learnitive-os.service learnitive-os.service)
      ;;
    learnitive-os-compile)
      candidates=("$DATA_ROOT/tmp/compile")
      units=(learnitive-os-latex-compile.service learnitive-os-latex-compile.service)
      ;;
    "$CONSOLE_USER")
      candidates=("$DATA_ROOT/tmp/console")
      units=(learnitive-os-console-gateway.service learnitive-os-console-gateway.service)
      ;;
    "$RUNNER_USER")
      candidates=("$DATA_ROOT/tmp/runner")
      units=(learnitive-os-universal-runner.service learnitive-os-universal-runner.service)
      ;;
    *) return 1 ;;
  esac

  # Direct production promotion may start from an already-running staging
  # installation while the new release tree has been unpacked separately under
  # /var/www/learnitive.com. The root-owned staging deployment marker is
  # explicit provenance for these exact Learnitive service identities.
  if promotion_from_staging_marker_is_authorized; then
    case "$user" in
      "$MAIN_USER"|"$CONSOLE_USER"|"$RUNNER_USER") return 0 ;;
    esac
  fi

  # Once this installer has successfully established the staging identities,
  # a root-owned provenance marker becomes the strongest upgrade proof.
  if [[ -f "$SERVICE_ACCOUNT_MARKER" && ! -L "$SERVICE_ACCOUNT_MARKER" ]] &&
     [[ "$(stat -c %U:%G "$SERVICE_ACCOUNT_MARKER" 2>/dev/null || true)" == 'root:root' ]] &&
     grep -Fxq "domain=$TARGET_DOMAIN" "$SERVICE_ACCOUNT_MARKER" &&
     grep -Fxq "root=$APP_ROOT" "$SERVICE_ACCOUNT_MARKER" &&
     grep -Fxq "user=$user" "$SERVICE_ACCOUNT_MARKER"; then
    return 0
  fi

  # Existing R11 staging trees can prove ownership directly through their
  # private runtime/media directories.  This deliberately does not consider
  # the generic `learnitive` production account equivalent.
  for candidate in "${candidates[@]}"; do
    if [[ -e "$candidate" ]] && [[ "$(stat -c %U "$candidate" 2>/dev/null || true)" == "$user" ]]; then
      return 0
    fi
  done

  # A verified legacy staging unit is also strong proof when its User= field
  # names the account. This covers upgrades where a worker directory has not
  # yet been created, without weakening the production-account boundary.
  for unit in "${units[@]}"; do
    if unit_exists "$unit" && unit_is_staging_owned "$unit"; then
      systemctl cat "$unit" 2>/dev/null | grep -Fxq "User=$user" && return 0
    fi
  done
  return 1
}

ensure_system_group() {
  local group="$1" user_exists="$2"
  if getent group "$group" >/dev/null 2>&1; then
    # Staging-qualified groups are reusable only with an already-proven
    # staging service account. Never infer ownership from the group name alone.
    if [[ "$user_exists" != 1 ]] || ! existing_service_user_is_staging_owned "$group"; then
      die "System group $group already exists without a provably staging-owned service account. Refusing to reuse it."
    fi
    return 0
  fi
  groupadd --system "$group"
}

ensure_system_user() {
  local user="$1" user_exists=0
  if id "$user" >/dev/null 2>&1; then
    user_exists=1
    existing_service_user_is_staging_owned "$user" || die "Existing staging service account $user is not provably owned by $TARGET_DOMAIN. Refusing to modify or reuse it."
  fi
  ensure_system_group "$user" "$user_exists"
  if (( user_exists == 0 )); then
    useradd --system --gid "$user" --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin "$user"
  else
    # Preserve an existing staging account's primary group on upgrades; only
    # ensure it can use the matching staging-owned group.
    if ! id -nG "$user" | tr ' ' '\n' | grep -Fxq "$user"; then
      usermod -a -G "$user" "$user"
    fi
  fi
  id -nG "$user" | tr ' ' '\n' | grep -Fxq "$user" || die "Service account $user is not a member of its required matching group."
}


ensure_users_and_directories() {
  local user
  for user in "$MAIN_USER" "$CONSOLE_USER" "$RUNNER_USER"; do ensure_system_user "$user"; done
  getent group docker >/dev/null || die 'Docker group is missing. Install Docker before deploying Learnitive.'
  for user in "$CONSOLE_USER" "$RUNNER_USER"; do usermod -a -G docker "$user"; done

  install -d -m 0755 "$SITE_ROOT" "$APP_ROOT"
  install -d -m 0711 -o "$MAIN_USER" -g "$MAIN_USER" "$DATA_ROOT"
  install -d -m 0750 -o "$MAIN_USER" -g "$MAIN_USER" "$DATA_ROOT/audit" "$DATA_ROOT/cache" "$DATA_ROOT/log" "$TEMPLATE_ROOT" "$TEMPLATE_ROOT/free" "$TEMPLATE_ROOT/pro"
  # Normalize the staging-only quota ledger if it survived an older deployment
  # under a different owner. This exact path is owned by Learnitive
  # staging; never recurse outside it or alter any production service data.
  if [[ -f "$DATA_ROOT/audit/cloud-compile-quota.json" && ! -L "$DATA_ROOT/audit/cloud-compile-quota.json" ]]; then
    chown "$MAIN_USER:$MAIN_USER" "$DATA_ROOT/audit/cloud-compile-quota.json"
    chmod 0640 "$DATA_ROOT/audit/cloud-compile-quota.json"
  fi
  if [[ -d "$DATA_ROOT/audit/cloud-compile-quota.json.lock" && ! -L "$DATA_ROOT/audit/cloud-compile-quota.json.lock" ]]; then
    chown "$MAIN_USER:$MAIN_USER" "$DATA_ROOT/audit/cloud-compile-quota.json.lock"
    chmod 0750 "$DATA_ROOT/audit/cloud-compile-quota.json.lock"
  fi
  install -d -m 0711 -o "$MAIN_USER" -g "$MAIN_USER" "$DATA_ROOT/tmp"
  install -d -m 0700 -o "$MAIN_USER" -g "$MAIN_USER" "$QUEUE_ROOT" "$QUEUE_ROOT/jobs"
  install -d -m 0700 -o "$CONSOLE_USER" -g "$CONSOLE_USER" "$DATA_ROOT/tmp/console"
  install -d -m 0700 -o "$RUNNER_USER" -g "$RUNNER_USER" "$DATA_ROOT/tmp/runner" "$DATA_ROOT/tmp/runner/jobs"
  install -d -m 0750 -o "$MAIN_USER" -g "$MAIN_USER" "$MEDIA_ROOT" "$MEDIA_ROOT/blog" "$MEDIA_ROOT/docs" "$MEDIA_ROOT/learn" "$MEDIA_ROOT/learn-private"

  # An already-installed hardened learnitive.service can still be active
  # while this installer validates the replacement runner image. systemd resolves
  # every ReadWritePaths entry before ExecStartPre, so a previous interrupted
  # build that removed dist/learnitive-media or dist/learnitive-templates can
  # otherwise cause 226/NAMESPACE restart storms long before the new build is
  # activated. Keep only these generated staging projections present up front;
  # the real build/template/media publishing steps replace/populate them later.
  install -d -m 0755 "$DIST_ROOT"
  install -d -m 0755 -o "$MAIN_USER" -g "$MAIN_USER" \
    "$DIST_ROOT/learnitive-media" \
    "$DIST_ROOT/learnitive-media/blog" \
    "$DIST_ROOT/learnitive-media/docs" \
    "$DIST_ROOT/learnitive-media/learn" \
    "$TEMPLATE_PUBLIC_COPY"

  # Persist explicit service-account provenance only after every identity has
  # passed ownership verification or has been freshly created by this staging
  # installer. This marker is never used to bless accounts before that point.
  {
    printf 'domain=%s\n' "$TARGET_DOMAIN"
    printf 'root=%s\n' "$APP_ROOT"
    printf 'user=%s\n' "$MAIN_USER"
    printf 'user=%s\n' "$CONSOLE_USER"
    printf 'user=%s\n' "$RUNNER_USER"
  } >"$SERVICE_ACCOUNT_MARKER.tmp"
  chown root:root "$SERVICE_ACCOUNT_MARKER.tmp"
  chmod 0640 "$SERVICE_ACCOUNT_MARKER.tmp"
  mv -f "$SERVICE_ACCOUNT_MARKER.tmp" "$SERVICE_ACCOUNT_MARKER"
}

write_example_if_missing() {
  local source="$1" target="$2" mode="${3:-0640}"
  if [[ ! -e "$target" ]]; then install -m "$mode" -o root -g root "$source" "$target"; fi
}

env_get() {
  local file="$1" key="$2"
  [[ -f "$file" ]] || return 0
  awk -F= -v key="$key" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "$file"
}

env_unquote_value() {
  local value="$1" first last
  if [[ ${#value} -ge 2 ]]; then
    first="${value:0:1}"
    last="${value: -1}"
    if [[ ( "$first" == '"' && "$last" == '"' ) || ( "$first" == "'" && "$last" == "'" ) ]]; then
      value="${value:1:${#value}-2}"
    fi
  fi
  printf '%s' "$value"
}
env_set() {
  local file="$1" key="$2" value="$3"
  python3 - "$file" "$key" "$value" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1]); key = sys.argv[2]; value = sys.argv[3]
lines = path.read_text().splitlines() if path.exists() else []
out = []
found = False
for line in lines:
    if line.startswith(key + '='):
        if not found: out.append(f'{key}={value}'); found = True
    else:
        out.append(line)
if not found: out.append(f'{key}={value}')
path.write_text('\n'.join(out).rstrip() + '\n')
PY
  chmod 0640 "$file"
}

env_ensure_default() {
  local file="$1" key="$2" value="$3"
  [[ -n "$(env_get "$file" "$key")" || -n "$(awk -F= -v key="$key" '$1 == key { print "present"; exit }' "$file" 2>/dev/null)" ]] && return 0
  env_set "$file" "$key" "$value"
}

env_unset() {
  local file="$1" key="$2"
  [[ -f "$file" ]] || return 0
  python3 - "$file" "$key" <<'PYENV'
from pathlib import Path
import sys
path = Path(sys.argv[1]); key = sys.argv[2]
lines = path.read_text().splitlines()
path.write_text("\n".join(line for line in lines if not line.startswith(f"{key}=")) + "\n")
PYENV
}

random_secret() { openssl rand -hex 32; }

repair_config_schema() {
  local file="$CONFIG_ROOT/config.json"
  node - "$file" "$TARGET_DOMAIN" "$DEPLOYMENT_PROFILE" "${LEARNITIVE_ALLOW_WWW_PROMOTION:-0}" <<'NODE'
const fs = require('node:fs');
const [file, host, profile, allowPromotion] = process.argv.slice(2);
const raw = fs.readFileSync(file, 'utf8');
let value;
try { value = JSON.parse(raw); } catch (error) { throw new Error(`Invalid JSON in ${file}: ${error.message}`); }
const schema = value.schemaVersion;
const publicUrl = String(value.publicUrl || `https://${host}`);
let parsed;
try { parsed = new URL(publicUrl); } catch { throw new Error(`Invalid publicUrl in ${file}`); }
let changed = false;
if (parsed.hostname !== host) {
  const approvedHosts = new Set(['edu.learnitive.com', 'www.learnitive.com', 'os.learnitive.com']);
  const isApprovedLearnitiveHost = approvedHosts.has(parsed.hostname);
  const isPromotion = profile === 'www' && allowPromotion === '1' && isApprovedLearnitiveHost;
  const isFreshOrLegacyStagingNormalization = profile === 'staging' && isApprovedLearnitiveHost;
  if (isPromotion || isFreshOrLegacyStagingNormalization) {
    value.publicUrl = `https://${host}`;
    changed = true;
    console.log(`[Learnitive installer] Normalizing config publicUrl ${publicUrl} -> https://${host}.`);
  } else {
    throw new Error(`Refusing non-${profile} publicUrl in ${file}: ${publicUrl}`);
  }
}
if (schema === 2) {
  value.schemaVersion = 1;
  changed = true;
  console.log('[Learnitive installer] Repaired known schemaVersion 2 -> 1 while preserving settings.');
} else if (schema !== 1) {
  throw new Error(`Unsupported Learnitive config schemaVersion: ${String(schema)}`);
}
if (changed) fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o640 });
NODE
}

resolve_worker_secrets() {
  local main="$CONFIG_ROOT/learnitive.env"
  local runner="$CONFIG_ROOT/universal-runner.env"
  local console="$CONFIG_ROOT/console-gateway.env"
  local runner_secret console_secret redis_password

  runner_secret="$(env_get "$main" LEARNITIVE_RUNNER_SECRET)"
  [[ -n "$runner_secret" ]] || runner_secret="$(env_get "$runner" LEARNITIVE_RUNNER_SECRET)"
  [[ -n "$runner_secret" ]] || runner_secret="$(random_secret)"
  env_set "$main" LEARNITIVE_RUNNER_SECRET "$runner_secret"
  env_set "$runner" LEARNITIVE_RUNNER_SECRET "$runner_secret"

  console_secret="$(env_get "$main" LEARNITIVE_CONSOLE_GATEWAY_SECRET)"
  [[ -n "$console_secret" ]] || console_secret="$(env_get "$console" LEARNITIVE_CONSOLE_GATEWAY_SECRET)"
  [[ -n "$console_secret" ]] || console_secret="$(random_secret)"
  env_set "$main" LEARNITIVE_CONSOLE_GATEWAY_SECRET "$console_secret"
  env_set "$console" LEARNITIVE_CONSOLE_GATEWAY_SECRET "$console_secret"

  # Cloud LaTeX is a profile of the same queued Universal Runner. Keep the
  # legacy broker fields pointed at the runner/runner secret for configuration
  # validation, but no separate LaTeX service or secret is provisioned.
  env_set "$main" LEARNITIVE_CLOUD_COMPILE_ENABLED 1
  # Full/SyncTeX browser fallback is part of the Learnitive product contract.
  # Persist it explicitly so upgrades cannot inherit a stale legacy disabled value.
  env_set "$main" LEARNITIVE_CLOUD_COMPILE_FREE_FALLBACK 1
  env_set "$main" LEARNITIVE_CLOUD_COMPILE_WORKER_URL "$RUNNER_JOBS_URL"
  env_set "$main" LEARNITIVE_CLOUD_COMPILE_WORKER_SECRET "$runner_secret"
  env_set "$main" LEARNITIVE_CLOUD_COMPILE_ALLOWED_ENGINES 'pdflatex,xelatex,lualatex'
  # Learnitive's current product contract is unlimited Cloud Compile.
  # Persist explicit zero allowances so stale legacy EnvironmentFile values
  # cannot reactivate a filesystem quota gate during staging upgrades.
  env_set "$main" LEARNITIVE_CLOUD_COMPILE_DAILY_ALLOWANCE 0
  env_set "$main" LEARNITIVE_CLOUD_COMPILE_MONTHLY_ALLOWANCE 0

  redis_password="$(env_get "$CONFIG_ROOT/redis.env" LEARNITIVE_REDIS_PASSWORD)"
  [[ -n "$redis_password" ]] || redis_password="$(random_secret)"
  [[ "$redis_password" =~ ^[a-f0-9]{64}$ ]] || die 'Redis queue password must be a 64-character hexadecimal secret.'
  env_set "$CONFIG_ROOT/redis.env" LEARNITIVE_REDIS_PASSWORD "$redis_password"
  env_set "$main" LEARNITIVE_RUNTIME_REDIS_URL "redis://:${redis_password}@127.0.0.1:${REDIS_HOST_PORT}/0"
}

prepare_configuration() {
  local runner_concurrency
  write_example_if_missing "$SOURCE_ROOT/deploy/config/learnitive.config.json" "$CONFIG_ROOT/config.json"
  if [[ "$DEPLOYMENT_PROFILE" == 'www' ]]; then
    write_example_if_missing "$SOURCE_ROOT/deploy/config/learnitive-www.env.example" "$CONFIG_ROOT/learnitive.env"
  else
    write_example_if_missing "$SOURCE_ROOT/deploy/config/learnitive-edu.env.example" "$CONFIG_ROOT/learnitive.env"
  fi
  # Upgrade existing hosts without clobbering operator-managed Payhip secrets.
  # Missing secret fields are added blank; missing/blank product links receive
  # the approved Lifetime and Monthly storefront defaults.
  node "$SOURCE_ROOT/scripts/reconcile-learnitive-payhip-env.mjs" "$CONFIG_ROOT/learnitive.env"
  write_example_if_missing "$SOURCE_ROOT/deploy/config/console-gateway.env.example" "$CONFIG_ROOT/console-gateway.env"
  write_example_if_missing "$SOURCE_ROOT/deploy/config/egress-proxy.env.example" "$CONFIG_ROOT/egress-proxy.env"
  write_example_if_missing "$SOURCE_ROOT/deploy/config/universal-runner.env.example" "$CONFIG_ROOT/universal-runner.env"
  write_example_if_missing "$SOURCE_ROOT/deploy/config/redis.env.example" "$CONFIG_ROOT/redis.env"
  repair_config_schema
  resolve_worker_secrets
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_ENV "$DEPLOYMENT_ENV"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_PUBLIC_URL "https://$TARGET_DOMAIN"
  if [[ "$DEPLOYMENT_PROFILE" == 'www' ]]; then
    env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_RELEASE_ID 'www-production'
  else
    env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_RELEASE_ID 'r12-staging'
  fi

  # Preserve operator-managed authentication secrets while adding new optional providers.
  # Existing SMTP deployments are untouched. A previously blank staging SMTP block receives
  # Learnitive's PrivateEmail defaults, with the password remaining host-managed.
  local auth_env="$CONFIG_ROOT/learnitive.env"
  if [[ -z "$(env_get "$auth_env" SMTP_HOST)" ]]; then
    env_set "$auth_env" SMTP_HOST 'smtp.privateemail.com'
    env_set "$auth_env" SMTP_PORT '465'
    env_set "$auth_env" SMTP_USER 'hello@learnitive.com'
    env_set "$auth_env" SMTP_FROM 'hello@learnitive.com'
    env_set "$auth_env" SMTP_SECURE 'true'
  fi
  env_ensure_default "$auth_env" SMTP_PASSWORD ''
  local smtp_port smtp_secure
  smtp_port="$(env_unquote_value "$(env_get "$auth_env" SMTP_PORT)")"
  smtp_secure="$(env_unquote_value "$(env_get "$auth_env" SMTP_SECURE)")"
  if [[ "$smtp_port" == '465' && ! "$smtp_secure" =~ ^([Tt][Rr][Uu][Ee]|1|[Yy][Ee][Ss])$ ]]; then
    warn 'SMTP_PORT=465 uses implicit TLS; normalizing stale SMTP_SECURE value to true.'
    env_set "$auth_env" SMTP_SECURE 'true'
  fi
  env_ensure_default "$auth_env" GITHUB_OAUTH_ENABLED 'false'
  env_ensure_default "$auth_env" GITHUB_OAUTH_CLIENT_ID ''
  env_ensure_default "$auth_env" GITHUB_OAUTH_CLIENT_SECRET ''
  env_set "$auth_env" GITHUB_OAUTH_CALLBACK_URL "https://$TARGET_DOMAIN/api/auth/github/callback"
  if [[ "$DEPLOYMENT_PROFILE" == 'staging' ]]; then
    env_ensure_default "$auth_env" MAGIC_EMAIL_ENABLED 'false'
  else
    env_ensure_default "$auth_env" MAGIC_EMAIL_ENABLED 'true'
  fi
  env_ensure_default "$auth_env" MAGIC_LINK_TTL_SECONDS '600'
  env_ensure_default "$auth_env" MAGIC_LINK_RATE_IP_PER_10_MIN '5'
  env_ensure_default "$auth_env" MAGIC_LINK_RATE_EMAIL_PER_15_MIN '3'
  env_ensure_default "$auth_env" MAGIC_LINK_RATE_IP_PER_DAY '20'
  env_ensure_default "$auth_env" MAGIC_LINK_RATE_EMAIL_PER_DAY '10'
  env_ensure_default "$auth_env" MAGIC_LINK_RATE_GLOBAL_PER_HOUR '500'
  env_ensure_default "$auth_env" CAPTCHA_PROVIDER 'turnstile'
  env_ensure_default "$auth_env" CAPTCHA_SITE_KEY ''
  env_ensure_default "$auth_env" CAPTCHA_SECRET_KEY ''
  local preview_public_url
  preview_public_url="$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_PREVIEW_PUBLIC_URL)"
  if [[ -z "$preview_public_url" && -s "/etc/letsencrypt/live/$PREVIEW_DOMAIN/fullchain.pem" && -s "/etc/letsencrypt/live/$PREVIEW_DOMAIN/privkey.pem" ]]; then
    preview_public_url="https://$PREVIEW_DOMAIN"
  fi
  if [[ -n "$preview_public_url" ]]; then
    [[ "$preview_public_url" == "https://$PREVIEW_DOMAIN" ]] || die "Staging isolated Preview URL must be https://$PREVIEW_DOMAIN"
    [[ -s "/etc/letsencrypt/live/$PREVIEW_DOMAIN/fullchain.pem" && -s "/etc/letsencrypt/live/$PREVIEW_DOMAIN/privkey.pem" ]] || die "TLS certificate for $PREVIEW_DOMAIN is required when isolated Preview is enabled."
    env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_PREVIEW_PUBLIC_URL "$preview_public_url"
    env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_PREVIEW_REQUIRE_ISOLATED_ORIGIN 1
  else
    env_unset "$CONFIG_ROOT/learnitive.env" LEARNITIVE_PREVIEW_PUBLIC_URL
    env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_PREVIEW_REQUIRE_ISOLATED_ORIGIN 0
  fi
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_CONFIG_FILE "$CONFIG_ROOT/config.json"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_WEB_ROOT "$DIST_ROOT"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_AI_REVIEW_AUDIT_DB "$DATA_ROOT/audit/ai-review-audit.sqlite3"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_RUNTIME_QUEUE_ENABLED 1
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_RUNTIME_QUEUE_WORK_ROOT "$QUEUE_ROOT/jobs"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_RUNTIME_QUEUE_MAX_SNAPSHOT_BYTES 50000000
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_RUNTIME_QUEUE_MAX_RESULT_BYTES 100000000
  # Remove obsolete split-LaTeX queue settings from upgrades. LaTeX now
  # shares the same runtime queue and Universal Runner as every other language.
  local obsolete_key
  for obsolete_key in \
    LEARNITIVE_LATEX_QUEUE_ENABLED \
    LEARNITIVE_LATEX_REDIS_URL \
    LEARNITIVE_LATEX_QUEUE_NAME \
    LEARNITIVE_LATEX_QUEUE_PREFIX \
    LEARNITIVE_LATEX_QUEUE_WORK_ROOT \
    LEARNITIVE_LATEX_QUEUE_MAX_QUEUED_GLOBAL \
    LEARNITIVE_LATEX_QUEUE_MAX_SNAPSHOT_BYTES \
    LEARNITIVE_LATEX_QUEUE_MAX_RESULT_BYTES \
    LEARNITIVE_LATEX_QUEUE_WAIT_TIMEOUT_MS \
    LEARNITIVE_LATEX_QUEUE_RESULT_RETENTION_SECONDS; do
    env_unset "$CONFIG_ROOT/learnitive.env" "$obsolete_key"
  done
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_MEDIA_STAGE_ROOT "$MEDIA_ROOT"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_MEDIA_PUBLISHED_ROOT "$DIST_ROOT/learnitive-media"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_BLOG_MEDIA_DIR "$MEDIA_ROOT/blog"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_DOCS_MEDIA_DIR "$MEDIA_ROOT/docs"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_LEARN_PUBLIC_MEDIA_DIR "$MEDIA_ROOT/learn"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_LEARN_MEDIA_DIR "$MEDIA_ROOT/learn-private"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_LEARN_VIDEO_MAX_BYTES 134217728
  youtube_api_key="$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_YOUTUBE_API_KEY)"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_YOUTUBE_API_KEY "$youtube_api_key"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_SECRET_HELPER_SOCKET '/run/learnitive-secret-helper/control.sock'
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_LEARN_IMAGE_MAX_BYTES 16777216
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_LEARN_CAPTION_MAX_BYTES 2097152
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TEMPLATE_ROOT "$TEMPLATE_ROOT"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TEMPLATE_PUBLIC_COPY "$TEMPLATE_PUBLIC_COPY"
  # Canonical staging-only loopback ports. These deliberately avoid the legacy
  # service ports used by older deployments on the same host. Keep every
  # broker, health probe, systemd unit and Apache websocket proxy aligned.
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_RUNNER_URL "$RUNNER_JOBS_URL"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_CLOUD_COMPILE_WORKER_URL "$RUNNER_JOBS_URL"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_CONSOLE_GATEWAY_URL "$CONSOLE_BASE_URL"
  env_set "$CONFIG_ROOT/universal-runner.env" LEARNITIVE_RUNNER_BIND_HOST '127.0.0.1'
  env_set "$CONFIG_ROOT/universal-runner.env" LEARNITIVE_RUNNER_PORT "$RUNNER_PORT"
  env_set "$CONFIG_ROOT/console-gateway.env" BIND_HOST '127.0.0.1'
  env_set "$CONFIG_ROOT/console-gateway.env" PORT "$CONSOLE_PORT"
  env_set "$CONFIG_ROOT/universal-runner.env" LEARNITIVE_DEVELOPMENT_IMAGE "$RUNTIME_IMAGE"
  env_set "$CONFIG_ROOT/universal-runner.env" LEARNITIVE_RUNNER_WORK_ROOT "$DATA_ROOT/tmp/runner/jobs"
  env_set "$CONFIG_ROOT/universal-runner.env" LEARNITIVE_RUNNER_MAX_ARTIFACT_BYTES 100000000
  env_set "$CONFIG_ROOT/universal-runner.env" LEARNITIVE_CONTROLLED_EGRESS_NETWORK 'learnitive-os-egress'
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_RUNNER_MAX_REQUEST_BYTES 50000000
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_RUNNER_MAX_RESPONSE_BYTES 100000000
  runner_concurrency="$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_RUNNER_CONCURRENCY)"
  [[ "$runner_concurrency" =~ ^[1-9][0-9]*$ ]] || runner_concurrency=4
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_RUNNER_CONCURRENCY "$runner_concurrency"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_CLOUD_COMPILE_CONCURRENCY "$runner_concurrency"
  env_set "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_CONSOLE_IMAGE "$RUNTIME_IMAGE"
  env_set "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_CONSOLE_OFF_NETWORK 'learnitive-os-runtime'
  env_set "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_CONSOLE_PACKAGES_GIT_NETWORK 'learnitive-os-egress'
  env_set "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_CONSOLE_INTERNET_NETWORK 'learnitive-os-internet'
  env_set "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_PACKAGES_GIT_PROXY_PORT "$EGRESS_PROXY_PORT"
  env_set "$CONFIG_ROOT/egress-proxy.env" LEARNITIVE_EGRESS_PROXY_PORT "$EGRESS_PROXY_PORT"
  env_set "$CONFIG_ROOT/egress-proxy.env" LEARNITIVE_EGRESS_PROXY_CLIENT_CIDRS '172.30.240.0/24,172.30.241.0/24'
  env_set "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_CONSOLE_WORK_DIR "$DATA_ROOT/tmp/console"
  local sandbox_free_storage sandbox_pro1_storage sandbox_pro2_storage sandbox_pro3_storage
  sandbox_free_storage="$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_SANDBOX_FREE_STORAGE_BYTES)"
  sandbox_pro1_storage="$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_SANDBOX_PRO1_STORAGE_BYTES)"
  sandbox_pro2_storage="$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_SANDBOX_PRO2_STORAGE_BYTES)"
  sandbox_pro3_storage="$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_SANDBOX_PRO3_STORAGE_BYTES)"
  [[ "$sandbox_free_storage" =~ ^[1-9][0-9]*$ ]] || sandbox_free_storage=1000000000
  [[ "$sandbox_pro1_storage" =~ ^[1-9][0-9]*$ ]] || sandbox_pro1_storage=5000000000
  [[ "$sandbox_pro2_storage" =~ ^[1-9][0-9]*$ ]] || sandbox_pro2_storage=10000000000
  [[ "$sandbox_pro3_storage" =~ ^[1-9][0-9]*$ ]] || sandbox_pro3_storage=25000000000
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_SANDBOX_FREE_STORAGE_BYTES "$sandbox_free_storage"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_SANDBOX_PRO1_STORAGE_BYTES "$sandbox_pro1_storage"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_SANDBOX_PRO2_STORAGE_BYTES "$sandbox_pro2_storage"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_SANDBOX_PRO3_STORAGE_BYTES "$sandbox_pro3_storage"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_ENABLED 1
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_HOMEPAGE_TERMINAL_ENABLED 1
  local terminal_mutation_rate terminal_guest_mutation_rate
  terminal_mutation_rate="$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_MUTATION_RATE_PER_MINUTE)"
  terminal_guest_mutation_rate="$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_GUEST_MUTATION_RATE_PER_MINUTE)"
  [[ "$terminal_mutation_rate" =~ ^[0-9]+$ ]] || terminal_mutation_rate=240
  [[ "$terminal_guest_mutation_rate" =~ ^[0-9]+$ ]] || terminal_guest_mutation_rate=60
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_MUTATION_RATE_PER_MINUTE "$terminal_mutation_rate"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_GUEST_MUTATION_RATE_PER_MINUTE "$terminal_guest_mutation_rate"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_HARD_MAX_LIFETIME_SECONDS 31536000
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_HARD_MAX_IDLE_TIMEOUT_SECONDS 86400
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_HARD_MAX_WORKSPACE_RETENTION_SECONDS 604800
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_HARD_MAX_WORKSPACE_BYTES "$sandbox_pro3_storage"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_HARD_MAX_UPLOAD_BYTES "$sandbox_pro3_storage"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_HARD_MAX_DOWNLOAD_BYTES "$sandbox_pro3_storage"
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_HARD_MAX_MEMORY_MB 8192
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_HARD_MAX_CPU 8
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_HARD_MAX_PID_LIMIT 2048
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_HARD_MAX_CONCURRENT 8
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_HARD_MAX_CREATIONS_PER_HOUR 100
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_NETWORK_ENABLED 1
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_NETWORK_ALLOWED_MODES 'off,packages-git,internet'
  env_set "$CONFIG_ROOT/learnitive.env" LEARNITIVE_TERMINAL_NETWORK_DEFAULT_MODE 'packages-git'
  env_set "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_TERMINAL_NETWORK_DEFAULT_MODE 'packages-git'
  env_set "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_CONSOLE_SESSION_SECONDS 3600
  # Guest homepage environments are disposable. These server-enforced lease values
  # make browser/tab loss release containers promptly without affecting authenticated retention.
  env_set "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_CONSOLE_MAX_SESSIONS_PER_USER 8
  env_set "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_GUEST_DISCONNECT_GRACE_SECONDS 45
  env_set "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_GUEST_LEASE_SECONDS 90
  env_set "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_GUEST_SCAVENGE_SECONDS 30
  env_set "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_CONSOLE_WS_LIVENESS_SECONDS 30
  env_set "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_CONSOLE_MAX_WORKSPACE_BYTES "$sandbox_pro3_storage"
  if [[ -f "$CONFIG_ROOT/latex-compile.env" && ! -L "$CONFIG_ROOT/latex-compile.env" ]]; then
    rm -f -- "$CONFIG_ROOT/latex-compile.env"
  fi
  chmod 0640 "$CONFIG_ROOT"/*.env "$CONFIG_ROOT/config.json"
}

verify_staging_port_contract() {
  [[ "$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_RUNNER_URL)" == "$RUNNER_JOBS_URL" ]] || die 'Trusted API runner URL is not the canonical staging runner endpoint.'
  [[ "$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_CLOUD_COMPILE_WORKER_URL)" == "$RUNNER_JOBS_URL" ]] || die 'Cloud Compile compatibility URL is not the canonical staging runner endpoint.'
  [[ "$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_CLOUD_COMPILE_FREE_FALLBACK)" == '1' ]] || die 'Full/SyncTeX cloud fallback must be explicitly enabled on staging.'
  [[ "$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_CONSOLE_GATEWAY_URL)" == "$CONSOLE_BASE_URL" ]] || die 'Trusted API console URL is not the canonical staging console endpoint.'
  [[ "$(env_get "$CONFIG_ROOT/universal-runner.env" LEARNITIVE_RUNNER_BIND_HOST)" == '127.0.0.1' ]] || die 'Universal Runner must bind only to loopback.'
  [[ "$(env_get "$CONFIG_ROOT/universal-runner.env" LEARNITIVE_RUNNER_PORT)" == "$RUNNER_PORT" ]] || die 'Universal Runner port is not the canonical staging port.'
  [[ "$(env_get "$CONFIG_ROOT/console-gateway.env" BIND_HOST)" == '127.0.0.1' ]] || die 'Console gateway must bind only to loopback.'
  [[ "$(env_get "$CONFIG_ROOT/console-gateway.env" PORT)" == "$CONSOLE_PORT" ]] || die 'Console gateway port is not the canonical staging port.'
  [[ "$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_RUNTIME_REDIS_URL)" == redis://:*@127.0.0.1:${REDIS_HOST_PORT}/0 ]] || die 'Runtime Redis URL is not the canonical staging queue endpoint.'
  log "Canonical staging service ports verified: runner=$RUNNER_PORT console=$CONSOLE_PORT redis=$REDIS_HOST_PORT."
}

repair_existing_entitlement_permissions() {
  local file owner mode repaired=0
  local -a trust_files=(
    "$CONFIG_ROOT/config.json"
    "$CONFIG_ROOT/license-keys"
    "$CONFIG_ROOT/entitlement-private.pem"
    "$CONFIG_ROOT/entitlement-public.pem"
    "$CONFIG_ROOT/entitlement-refresh.key"
  )

  [[ -e "$CONFIG_ROOT" ]] || return 0
  [[ -d "$CONFIG_ROOT" && ! -L "$CONFIG_ROOT" ]] || die "$CONFIG_ROOT must be a real directory, not a symlink or non-directory path."
  getent passwd "$MAIN_USER" >/dev/null 2>&1 || return 0
  getent group "$MAIN_USER" >/dev/null 2>&1 || die "Service group $MAIN_USER is missing while the service account exists."

  owner="$(stat -c %U:%G "$CONFIG_ROOT" 2>/dev/null || true)"
  mode="$(stat -c %a "$CONFIG_ROOT" 2>/dev/null || true)"
  if [[ "$owner" != "root:$MAIN_USER" || "$mode" != '750' ]]; then repaired=1; fi
  chown root:"$MAIN_USER" "$CONFIG_ROOT"
  chmod 0750 "$CONFIG_ROOT"

  for file in "${trust_files[@]}"; do
    [[ -e "$file" ]] || continue
    [[ ! -L "$file" ]] || die "Refusing symlinked security material: $file"
    [[ -f "$file" ]] || die "Security material must be a regular file: $file"
    owner="$(stat -c %U:%G "$file" 2>/dev/null || true)"
    mode="$(stat -c %a "$file" 2>/dev/null || true)"
    if [[ "$owner" != "root:$MAIN_USER" || "$mode" != '640' ]]; then repaired=1; fi
    chown root:"$MAIN_USER" "$file"
    chmod 0640 "$file"
    runuser -u "$MAIN_USER" -- test -r "$file" || die "$MAIN_USER cannot read $file after permission repair."
  done

  if (( repaired == 1 )); then
    log "Repaired entitlement/config trust-file ownership and read permissions for $MAIN_USER."
  fi
}

ensure_entitlement_material() {
  local private_key="$CONFIG_ROOT/entitlement-private.pem"
  local public_key="$CONFIG_ROOT/entitlement-public.pem"
  local refresh_key="$CONFIG_ROOT/entitlement-refresh.key"
  local license_file="$CONFIG_ROOT/license-keys"
  local file refresh_value derived_public existing_public

  for file in "$private_key" "$public_key" "$refresh_key" "$license_file"; do
    [[ ! -L "$file" ]] || die "Refusing symlinked security material: $file"
    [[ ! -e "$file" || -f "$file" ]] || die "Security material must be a regular file: $file"
  done

  if [[ -f "$private_key" ]]; then
    openssl pkey -in "$private_key" -check -noout >/dev/null 2>&1 || die 'Existing entitlement private key is invalid.'
    if [[ ! -f "$public_key" ]]; then
      log 'Regenerating missing staging entitlement public key from the preserved private key.'
      openssl pkey -in "$private_key" -pubout -out "$public_key"
    fi
  elif [[ -f "$public_key" ]]; then
    die 'Entitlement public key exists without its matching private key. Refusing to replace the trust root automatically.'
  else
    log 'Generating staging Ed25519 entitlement signing keypair.'
    openssl genpkey -algorithm Ed25519 -out "$private_key"
    openssl pkey -in "$private_key" -pubout -out "$public_key"
  fi

  # Validate the public key independently, then compare normalized DER bytes
  # against the public key derived from the preserved private key.  Never
  # silently replace a mismatched established trust root.
  openssl pkey -in "$private_key" -text -noout 2>/dev/null | grep -qi 'ED25519' || die 'Entitlement private key must be Ed25519.'
  openssl pkey -pubin -in "$public_key" -text -noout 2>/dev/null | grep -qi 'ED25519' || die 'Entitlement public key must be Ed25519.'
  openssl pkey -pubin -in "$public_key" -pubout -outform DER >/dev/null 2>&1 || die 'Existing entitlement public key is invalid.'
  derived_public="$(mktemp)"
  existing_public="$(mktemp)"
  trap 'rm -f -- "$derived_public" "$existing_public"' RETURN
  openssl pkey -in "$private_key" -pubout -outform DER -out "$derived_public"
  openssl pkey -pubin -in "$public_key" -pubout -outform DER -out "$existing_public"
  cmp -s "$derived_public" "$existing_public" || die 'Entitlement private/public keypair is inconsistent. Refusing to replace the established trust root.'
  rm -f -- "$derived_public" "$existing_public"
  trap - RETURN

  if [[ ! -f "$refresh_key" ]]; then
    random_secret >"$refresh_key"
  fi
  refresh_value="$(tr -d '\\r\\n' <"$refresh_key")"
  [[ "$refresh_value" =~ ^[A-Fa-f0-9]{64}$ ]] || die 'Existing entitlement refresh key must contain exactly 64 hexadecimal characters.'
  [[ -f "$license_file" ]] || : >"$license_file"

  repair_existing_entitlement_permissions
  # Environment files are consumed by systemd as root and remain root-only.
  chown root:root "$CONFIG_ROOT"/*.env "$MARKER_FILE"
  chmod 0640 "$CONFIG_ROOT"/*.env "$MARKER_FILE"

  runuser -u "$MAIN_USER" -- test -r "$CONFIG_ROOT/config.json" || die 'Main service user cannot read config.json.'
  runuser -u "$MAIN_USER" -- test -r "$public_key" || die 'Main service user cannot read the entitlement public key.'
}


stop_staging_services_for_build() {
  local unit
  PREVIOUSLY_RUNNING_APP_SERVICES=()
  for unit in "${APP_SERVICES[@]}"; do
    if unit_exists "$unit"; then
      unit_is_staging_owned "$unit" || die "Refusing to stop non-staging unit $unit"
      if systemctl is-active --quiet "$unit"; then PREVIOUSLY_RUNNING_APP_SERVICES+=("$unit"); fi
    fi
  done
  SERVICES_QUIESCED_FOR_BUILD=1
  for unit in "${APP_SERVICES[@]}"; do
    if unit_exists "$unit"; then systemctl stop "$unit" || true; fi
  done
}

pnpm_cmd() {
  if command -v pnpm >/dev/null 2>&1; then pnpm "$@"; else corepack pnpm "$@"; fi
}

normalize_runtime_node_modules() {
  local path="$1" group="$2"
  [[ -d "$path" ]] || die "Runtime dependency directory missing after install: $path"
  chown -R root:"$group" "$path"
  find "$path" -type d -exec chmod 0750 {} +
  find "$path" -type f -exec chmod g+r,o-rwx {} +
}

install_dependencies_and_build() {
  log 'Installing workspace dependencies and building staging browser assets.'
  cd "$APP_ROOT"
  pnpm_cmd install --frozen-lockfile
  stage_fonts_for_browser_build
  if ! LEARNITIVE_APP_ENV="$DEPLOYMENT_ENV" LEARNITIVE_MEDIA_STAGE_ROOT="$MEDIA_ROOT" pnpm_cmd build; then
    cleanup_staged_browser_fonts
    die 'Browser production build failed.'
  fi
  cleanup_staged_browser_fonts
  [[ -s "$DIST_ROOT/index.html" ]] || die 'Build completed without dist/index.html.'

  # The installer uses umask 027 for secrets. Public build artifacts must be
  # deliberately normalized after Vite atomically replaces dist/.
  find "$DIST_ROOT" -type d -exec chmod 0755 {} +
  find "$DIST_ROOT" -type f -exec chmod 0644 {} +
  find "$DIST_ROOT" -mindepth 1 -exec chown -h root:root {} +
  chown root:root "$DIST_ROOT"
  runuser -u "$MAIN_USER" -- test -r "$DIST_ROOT/index.html" || die 'Main service user cannot read generated dist/index.html.'
  getent passwd www-data >/dev/null || die 'Apache service account www-data is missing.'
  runuser -u www-data -- test -r "$DIST_ROOT/index.html" || die 'Apache service user cannot read generated dist/index.html.'

}

install_runtime_dependencies() {
  # Do not rewrite runtime node_modules while the currently healthy API/gateway
  # are still serving requests. This phase runs only after they are quiesced.
  log 'Installing trusted-server and private worker runtime dependencies after service quiesce.'
  (cd "$APP_ROOT/server" && npm install --omit=dev --no-audit --no-fund --package-lock=false)
  normalize_runtime_node_modules "$APP_ROOT/server/node_modules" "$MAIN_USER"

  (cd "$APP_ROOT/learnitive-services/console-gateway" && npm ci --omit=dev --no-audit --no-fund)
  normalize_runtime_node_modules "$APP_ROOT/learnitive-services/console-gateway/node_modules" "$CONSOLE_USER"

  (
    cd "$APP_ROOT/server"
    runuser -u "$MAIN_USER" -- "$HOST_NODE_BIN" --input-type=module -e "Promise.all([import('mysql2'),import('bullmq'),import('ioredis'),import('ws'),import('yjs'),import('nodemailer'),import('bcryptjs')]).then(()=>console.log('trusted-server-import-preflight-ok'))"
  )
  (
    cd "$APP_ROOT/learnitive-services/console-gateway"
    runuser -u "$CONSOLE_USER" -- "$HOST_NODE_BIN" --input-type=module -e "Promise.all([import('node-pty'),import('ws')]).then(()=>console.log('console-gateway-import-preflight-ok'))"
  )
}

merge_media_tree() {
  local source="$1"
  [[ -d "$source" ]] || return 0
  local real
  real="$(realpath -m "$source")"
  [[ "$real" == "$SITE_ROOT"/* ]] || die "Refusing media migration outside staging root: $source"
  [[ "$real" != "$(realpath -m "$MEDIA_ROOT")" ]] || return 0
  log "Merging historical staging media from $source"
  cp -a --update=none "$source"/. "$MEDIA_ROOT"/ || true
}

prepare_template_library() {
  local packaged="$APP_ROOT/learnitive-templates"
  [[ -d "$packaged/free" && -d "$packaged/pro" ]] || die "Packaged template library is incomplete: $packaged"
  [[ ! -L "$TEMPLATE_ROOT" ]] || die "Persistent staging template root must not be a symlink: $TEMPLATE_ROOT"
  install -d -m 0750 -o "$MAIN_USER" -g "$MAIN_USER" "$TEMPLATE_ROOT" "$TEMPLATE_ROOT/free" "$TEMPLATE_ROOT/pro"
  # Seed only missing packaged files. Existing Admin-managed staging templates
  # are persistent data and must never be overwritten by a release install.
  cp -a --update=none "$packaged"/. "$TEMPLATE_ROOT"/ || true
  # Product-owned built-ins from earlier releases may contain collapsed/minified
  # starter source. Refresh only files whose bytes exactly match the known old
  # bundled version; unknown/Admin-customized files remain untouched.
  log 'Refreshing safely recognized bundled template source formatting without overwriting Admin customizations.'
  "$HOST_NODE_BIN" "$APP_ROOT/scripts/migrate-bundled-template-formatting.mjs" \
    --packaged="$packaged" \
    --target="$TEMPLATE_ROOT" \
    || die 'Unable to migrate bundled template source formatting safely.'
  chown -R "$MAIN_USER:$MAIN_USER" "$TEMPLATE_ROOT"
  find "$TEMPLATE_ROOT" -type d -exec chmod 0750 {} +
  find "$TEMPLATE_ROOT" -type f -exec chmod 0640 {} +
  install -d -m 0755 -o "$MAIN_USER" -g "$MAIN_USER" "$TEMPLATE_PUBLIC_COPY"
}

prune_removed_bundled_templates() {
  log 'Pruning bundled legacy templates retired after isolated runtime validation.'
  runuser -u "$MAIN_USER" -- env     LEARNITIVE_TEMPLATE_ROOT="$TEMPLATE_ROOT"     "$HOST_NODE_BIN" "$APP_ROOT/server/prune-removed-bundled-templates.mjs" --root="$TEMPLATE_ROOT"     || die 'Unable to prune retired bundled templates safely.'
}

validate_packages_git_dependency_egress() {
  local network proxy_url runner_uid runner_gid output smoke_container smoke_script
  network="$(env_get "$CONFIG_ROOT/universal-runner.env" LEARNITIVE_CONTROLLED_EGRESS_NETWORK)"
  proxy_url="$(env_get "$CONFIG_ROOT/universal-runner.env" LEARNITIVE_PACKAGES_GIT_PROXY_URL)"
  [[ -n "$network" ]] || die 'Packages + Git smoke requires LEARNITIVE_CONTROLLED_EGRESS_NETWORK.'
  [[ "$proxy_url" == http://*:* ]] || die 'Packages + Git smoke requires a valid LEARNITIVE_PACKAGES_GIT_PROXY_URL.'
  runner_uid="$(id -u "$RUNNER_USER")"
  runner_gid="$(id -g "$RUNNER_USER")"
  smoke_script="$APP_ROOT/scripts/packages-git-egress-smoke.mjs"
  [[ -r "$smoke_script" ]] || die "Packages + Git smoke helper is missing: $smoke_script"
  smoke_container="learnitive-egress-smoke-$$"

  log 'Validating Packages + Git registry egress from the unified runtime before template validation.'
  # Keep this preflight intentionally tiny: it proves container -> filtered
  # proxy -> npm registry HTTPS connectivity without invoking npm itself. npm
  # has its own retry/cache lifecycle and is validated end-to-end by the Vite
  # templates immediately afterwards; using it as the health probe made a bad
  # network path appear to hang the installer.
  set +e
  output="$(timeout --signal=TERM --kill-after=5s 30s docker run --rm \
    --name "$smoke_container" \
    --network "$network" \
    --read-only \
    --cap-drop ALL \
    --security-opt no-new-privileges \
    --user "$runner_uid:$runner_gid" \
    --env HOME=/tmp/learnitive-home \
    --env "LEARNITIVE_PACKAGES_GIT_PROXY_URL=$proxy_url" \
    --env LEARNITIVE_PACKAGES_GIT_SMOKE_TIMEOUT_MS=15000 \
    --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \
    --mount "type=bind,src=$smoke_script,dst=/opt/learnitive/packages-git-egress-smoke.mjs,readonly" \
    "$RUNTIME_IMAGE" node /opt/learnitive/packages-git-egress-smoke.mjs 2>&1)"
  local status=$?
  set -e
  # `timeout` can kill the Docker CLI before it completes its own --rm path.
  # Always remove the named disposable probe container explicitly.
  docker rm -f "$smoke_container" >/dev/null 2>&1 || true
  if (( status != 0 )); then
    warn "Packages + Git registry egress smoke failed (exit=$status). Output follows:"
    printf '%s\n' "$output" >&2
    service_failure_diagnostics learnitive-egress-proxy.service
    docker network inspect "$network" >&2 || true
    LEARNITIVE_DEPLOYMENT="$TARGET_DOMAIN" "$APP_ROOT/deploy/learnitive/console-egress-control.sh" check >&2 || true
    die 'Packages + Git egress is not usable from runtime containers; refusing to begin slow Vite template validation.'
  fi
  [[ "$(printf '%s' "$output" | tr -d '\r' | tail -n 1)" == 'packages-git-egress-ok' ]] || {
    warn "Packages + Git registry egress returned an unexpected response: $output"
    die 'Packages + Git registry egress smoke returned unexpected output.'
  }
  log 'Packages + Git registry egress validated from the unified runtime.'
}

validate_template_library() {
  local runner_secret
  runner_secret="$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_RUNNER_SECRET)"
  [[ -n "$runner_secret" ]] || die 'Template validation requires the staging Universal Runner secret.'
  log 'Validating bundled universal templates in the isolated Universal Runner.'
  if ! runuser -u "$MAIN_USER" -- env \
    LEARNITIVE_TEMPLATE_ROOT="$TEMPLATE_ROOT" \
    LEARNITIVE_TEMPLATE_PUBLIC_COPY="$TEMPLATE_PUBLIC_COPY" \
    LEARNITIVE_RUNNER_URL="$RUNNER_JOBS_URL" \
    LEARNITIVE_RUNNER_SECRET="$runner_secret" \
    LEARNITIVE_TEMPLATE_VALIDATION_TIMEOUT_MS=960000 \
    "$HOST_NODE_BIN" "$APP_ROOT/server/validate-template-library.mjs" \
      --root="$TEMPLATE_ROOT" --public-copy="$TEMPLATE_PUBLIC_COPY" --publish-builtins --validate-legacy --require-builtins; then
    service_failure_diagnostics learnitive-universal-runner.service
    service_failure_diagnostics learnitive-egress-proxy.service
    die 'Template certification failed (metadata, file integrity or isolated runtime validation); see template diagnostics above.'
  fi
  find "$TEMPLATE_PUBLIC_COPY" -type d -exec chmod 0755 {} +
  find "$TEMPLATE_PUBLIC_COPY" -type f -exec chmod 0644 {} +
  chown -R "$MAIN_USER:$MAIN_USER" "$TEMPLATE_PUBLIC_COPY"
}

validate_learn_library() {
  local item
  local -a env_args=(NODE_ENV=production)
  # Learn activation validates persisted course structure and publish integrity
  # only. Author-time/runtime pre-validation was intentionally removed from
  # Learn v2, so this deployment gate must not require or invoke the Universal
  # Runner. The validator still receives the complete main EnvironmentFile so
  # it can initialize the same SaaS/MySQL/media configuration as the service.
  while IFS= read -r -d '' item; do env_args+=("$item"); done < <(main_service_env_args)
  log 'Validating published Learn course structure and publish integrity before activation; draft and archived authoring courses are deferred.'
  runuser -u "$MAIN_USER" -- env "${env_args[@]}" \
    "$HOST_NODE_BIN" "$APP_ROOT/server/validate-learn-library.mjs" \
    || die 'One or more Learn courses failed structural or publish integrity validation.'
}

publish_media() {
  merge_media_tree "$SITE_ROOT/interactive-media"
  merge_media_tree "$APP_ROOT/interactive-media"
  merge_media_tree "$APP_ROOT/learnitive-media"
  install -d -m 0755 -o "$MAIN_USER" -g "$MAIN_USER" "$DIST_ROOT/learnitive-media" "$DIST_ROOT/learnitive-media/blog" "$DIST_ROOT/learnitive-media/docs" "$DIST_ROOT/learnitive-media/learn"
  cp -a "$MEDIA_ROOT/blog"/. "$DIST_ROOT/learnitive-media/blog"/ 2>/dev/null || true
  cp -a "$MEDIA_ROOT/docs"/. "$DIST_ROOT/learnitive-media/docs"/ 2>/dev/null || true
  cp -a "$MEDIA_ROOT/learn"/. "$DIST_ROOT/learnitive-media/learn"/ 2>/dev/null || true
  find "$DIST_ROOT/learnitive-media" -type d -exec chmod 0755 {} +
  find "$DIST_ROOT/learnitive-media" -type f -exec chmod 0644 {} +
  chown -R "$MAIN_USER:$MAIN_USER" "$DIST_ROOT/learnitive-media"
}

font_candidate() {
  local canonical="$1"
  case "$canonical" in
    Moranga-Light.otf) printf '%s\n' "$FONT_SOURCE_ROOT/Moranga-Light.otf" "$FONT_SOURCE_ROOT/moranga-light.otf" ;;
    Moranga-Regular.otf) printf '%s\n' "$FONT_SOURCE_ROOT/Moranga-Regular.otf" "$FONT_SOURCE_ROOT/moranga-regular.otf" ;;
    Moranga-RegularItalic.otf) printf '%s\n' "$FONT_SOURCE_ROOT/Moranga-RegularItalic.otf" "$FONT_SOURCE_ROOT/moranga-regularitalic.otf" "$FONT_SOURCE_ROOT/moranga-regular-italic.otf" ;;
    Moranga-Bold.otf) printf '%s\n' "$FONT_SOURCE_ROOT/Moranga-Bold.otf" "$FONT_SOURCE_ROOT/moranga-bold.otf" ;;
    *) printf '%s\n' "$FONT_SOURCE_ROOT/$canonical" ;;
  esac
}

declare -a STAGED_BROWSER_FONT_FILES=()

required_font_names() {
  printf '%s\n' \
    Inter-Variable.woff2 \
    JetBrainsMono-Variable.woff2 \
    Moranga-Light.otf \
    Moranga-Regular.otf \
    Moranga-RegularItalic.otf \
    Moranga-Bold.otf \
    STIXTwoText-VariableFont.ttf \
    STIXTwoText-Italic-Variable.ttf
}

resolve_font_source() {
  local name="$1" candidate
  while IFS= read -r candidate; do
    if [[ -s "$candidate" ]]; then
      printf '%s\n' "$candidate"
      return 0
    fi
  done < <(font_candidate "$name")
  return 1
}

stage_fonts_for_browser_build() {
  local target="$APP_ROOT/packages/tooling/browser-entry/public/learnitive-fonts"
  local name source missing=0
  STAGED_BROWSER_FONT_FILES=()
  if [[ ! -d "$FONT_SOURCE_ROOT" ]]; then
    warn "Optional external font source is absent: $FONT_SOURCE_ROOT. Browser build will use the packaged CSS fallback stack."
    return 0
  fi
  install -d -m 0755 "$target"
  while IFS= read -r name; do
    [[ -n "$name" ]] || continue
    if [[ -s "$target/$name" ]]; then
      continue
    fi
    if ! source="$(resolve_font_source "$name")"; then
      warn "Optional external font is missing: $name. The browser will fall back for that face."
      missing=$((missing + 1))
      continue
    fi
    install -m 0644 "$source" "$target/$name"
    STAGED_BROWSER_FONT_FILES+=("$target/$name")
  done < <(required_font_names)
  log "Staged ${#STAGED_BROWSER_FONT_FILES[@]} optional external font asset(s) for browser build; missing=$missing."
}

cleanup_staged_browser_fonts() {
  local file target="$APP_ROOT/packages/tooling/browser-entry/public/learnitive-fonts"
  for file in "${STAGED_BROWSER_FONT_FILES[@]:-}"; do
    [[ -n "$file" ]] && rm -f -- "$file"
  done
  STAGED_BROWSER_FONT_FILES=()
  rmdir "$target" 2>/dev/null || true
}

publish_fonts() {
  local target="$DIST_ROOT/learnitive-fonts"
  local name found size published=0 missing=0
  install -d -m 0755 "$target"
  if [[ ! -d "$FONT_SOURCE_ROOT" ]]; then
    warn "Optional external font source is absent: $FONT_SOURCE_ROOT. Continuing with browser fallback fonts."
    return 0
  fi
  while IFS= read -r name; do
    [[ -n "$name" ]] || continue
    if ! found="$(resolve_font_source "$name")"; then
      warn "Optional external font is missing: $name."
      missing=$((missing + 1))
      continue
    fi
    install -m 0644 "$found" "$target/$name"
    size="$(stat -c %s "$target/$name")"
    if (( size <= 1024 )); then
      rm -f -- "$target/$name"
      warn "Ignoring invalid optional font $name (unexpectedly small)."
      missing=$((missing + 1))
      continue
    fi
    published=$((published + 1))
  done < <(required_font_names)
  log "Published $published optional external font asset(s); missing=$missing."
}

image_repo_digest() {
  local image="$1" digest
  digest="$(docker image inspect --format '{{index .RepoDigests 0}}' "$image" 2>/dev/null || true)"
  [[ "$digest" == *@sha256:* ]] || die "Could not resolve immutable digest for $image"
  printf '%s\n' "$digest"
}


prune_stale_generated_namespace_assets() {
  # Build the retired pre-rebrand generated directory name without reintroducing
  # its obsolete product namespace into active source (the namespace audit is
  # intentionally strict about that token even inside cleanup code).
  local legacy_icon_dir stale expected
  legacy_icon_dir="$(printf '\164\145\170\151\163\145-icons')"
  stale="$APP_ROOT/packages/tooling/browser-entry/public/$legacy_icon_dir"
  expected="$APP_ROOT/packages/tooling/browser-entry/public/$legacy_icon_dir"
  [[ -e "$stale" ]] || return 0
  [[ ! -L "$stale" ]] || die "Refusing to remove symlinked stale generated icon path: $stale"
  [[ "$(realpath -m "$stale")" == "$expected" ]] || die 'Stale generated icon path escaped the staging source root.'
  log 'Removing obsolete generated pre-rebrand icon directory left by an earlier failed browser build.'
  rm -rf -- "$stale"
}

verify_source_preflight() {
  log 'Running dependency-free workspace namespace and frozen-lock preflight.'
  cd "$APP_ROOT"
  node scripts/verify-pnpm-lock-sync.mjs
  node scripts/verify-trusted-runtime-manifest.mjs
  # Source preflight must not be poisoned by a previously generated dist tree.
  # The freshly built dist is audited separately immediately after the build.
  LEARNITIVE_BRAND_AUDIT_SOURCE_ONLY=1 node scripts/learnitive-r12-release-audit.mjs
}

prepare_runtime_images() {
  docker info >/dev/null 2>&1 || die 'Docker daemon is unavailable. The installer will not start or reconfigure a shared Docker daemon.'

  log "Resolving private Redis image from $REDIS_SOURCE_IMAGE"
  docker pull "$REDIS_SOURCE_IMAGE" >/dev/null
  local redis_id
  redis_id="$(docker image inspect --format '{{.Id}}' "$REDIS_SOURCE_IMAGE")"
  [[ "$redis_id" == sha256:* ]] || die 'Unable to resolve immutable Redis image ID.'
  env_set "$CONFIG_ROOT/redis.env" LEARNITIVE_REDIS_IMAGE "$redis_id"

  local definition_sha existing_sha base_ref sandbox_uid sandbox_gid
  sandbox_uid="$(id -u "$CONSOLE_USER")"
  sandbox_gid="$(id -g "$CONSOLE_USER")"
  [[ "$sandbox_uid" =~ ^[0-9]+$ && "$sandbox_gid" =~ ^[0-9]+$ ]] || die 'Unable to resolve the console sandbox UID/GID.'
  (( sandbox_uid > 0 && sandbox_gid > 0 )) || die 'Console sandbox UID/GID must be non-root.'
  definition_sha="$({ cat "$APP_ROOT/deploy/docker/development/Dockerfile" "$APP_ROOT/deploy/docker/development/toolchain-smoke.sh" "$APP_ROOT/deploy/docker/development/sandbox-init.sh"; printf '\ndeveloper_uid=%s\ndeveloper_gid=%s\n' "$sandbox_uid" "$sandbox_gid"; } | sha256sum | awk '{print $1}')"
  existing_sha="$(docker image inspect --format '{{index .Config.Labels "learnitive.runtime.definition-sha"}}' "$RUNTIME_IMAGE" 2>/dev/null || true)"
  if [[ "$existing_sha" != "$definition_sha" ]]; then
    log "Building $RUNTIME_IMAGE from the current staging Docker definition."
    docker pull "$DEV_BASE_SOURCE_IMAGE" >/dev/null
    base_ref="$(image_repo_digest "$DEV_BASE_SOURCE_IMAGE")"
    docker build --pull=false \
      --build-arg "BASE_IMAGE=$base_ref" \
      --build-arg "DEVELOPER_UID=$sandbox_uid" \
      --build-arg "DEVELOPER_GID=$sandbox_gid" \
      --label "learnitive.deployment=$TARGET_DOMAIN" \
      --label "learnitive.runtime.definition-sha=$definition_sha" \
      -t "$RUNTIME_IMAGE" "$APP_ROOT/deploy/docker/development"
  else
    log "$RUNTIME_IMAGE already matches the current runtime definition."
  fi
  docker image inspect "$RUNTIME_IMAGE" >/dev/null
  local runtime_id
  runtime_id="$(docker image inspect --format '{{.Id}}' "$RUNTIME_IMAGE")"
  [[ "$runtime_id" == sha256:* ]] || die 'Unable to resolve immutable Learnitive development image ID.'
  env_set "$CONFIG_ROOT/universal-runner.env" LEARNITIVE_DEVELOPMENT_IMAGE "$RUNTIME_IMAGE"
  env_set "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_CONSOLE_IMAGE "$RUNTIME_IMAGE"
  log "Using one unified execution image for code, console, and LaTeX: $RUNTIME_IMAGE ($runtime_id)"
  docker run --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \
    --env "EXPECTED_UID=$sandbox_uid" --env "EXPECTED_GID=$sandbox_gid" \
    --tmpfs "/home/developer:rw,nosuid,nodev,size=64m,uid=$sandbox_uid,gid=$sandbox_gid" \
    --tmpfs /tmp:rw,nosuid,nodev,size=128m \
    "$RUNTIME_IMAGE" /bin/bash --noprofile -c 'test "$(id -u)" = "$EXPECTED_UID" && test "$(id -g)" = "$EXPECTED_GID" && test "$(id -un)" = developer && test "$(id -gn)" = developer && test "$HOME" = /home/developer && test -w "$HOME" && /usr/local/bin/learnitive-toolchain-smoke' \
    || die 'Unified runtime image user/home contract does not match the staging console service identity.'


  # Exercise the actual authenticated sandbox home/cache contract as used by
  # the Console Gateway. This catches root-owned bind/tmpfs regressions before
  # staging is restarted and before npm/pnpm/pip can fail for end users.
  local sandbox_smoke_root
  sandbox_smoke_root="$DATA_ROOT/tmp/console/.runtime-home-smoke-$$"
  rm -rf -- "$sandbox_smoke_root"
  install -d -m 0700 -o "$CONSOLE_USER" -g "$CONSOLE_USER" \
    "$sandbox_smoke_root" \
    "$sandbox_smoke_root/projects" \
    "$sandbox_smoke_root/.learnitive/home" \
    "$sandbox_smoke_root/.learnitive/cache/xdg" \
    "$sandbox_smoke_root/.learnitive/cache/npm" \
    "$sandbox_smoke_root/.learnitive/cache/pip" \
    "$sandbox_smoke_root/.learnitive/cache/cargo" \
    "$sandbox_smoke_root/.learnitive/cache/corepack"
  if ! docker run --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \
    --user "$sandbox_uid:$sandbox_gid" \
    --workdir /workspace \
    --env HOME=/workspace/.learnitive/home \
    --env XDG_CACHE_HOME=/workspace/.learnitive/cache/xdg \
    --env NPM_CONFIG_CACHE=/workspace/.learnitive/cache/npm \
    --env PIP_CACHE_DIR=/workspace/.learnitive/cache/pip \
    --env CARGO_HOME=/workspace/.learnitive/cache/cargo \
    --env COREPACK_HOME=/workspace/.learnitive/cache/corepack \
    --tmpfs /tmp:rw,nosuid,nodev,size=128m \
    -v "$sandbox_smoke_root:/workspace:rw" \
    "$RUNTIME_IMAGE" /bin/bash --noprofile --norc -c \
      'test "$(id -u)" = "'"$sandbox_uid"'" && test "$(id -g)" = "'"$sandbox_gid"'" && test "$(id -un)" = developer && test "$(id -gn)" = developer && test -w "$HOME" && test -w "$NPM_CONFIG_CACHE" && test "$(npm config get cache)" = "$NPM_CONFIG_CACHE" && touch "$HOME/.learnitive-write-test" "$NPM_CONFIG_CACHE/.learnitive-write-test"'; then
    rm -rf -- "$sandbox_smoke_root"
    die 'Authenticated sandbox workspace home/cache contract failed.'
  fi

  # Exercise the exact long-lived sandbox init used by the Console Gateway.
  # `docker run -d` returning zero only proves Docker accepted the container;
  # this probe also proves PID 1 stayed alive and the PTY rcfile is usable.
  local sandbox_bootstrap_name bootstrap_running
  sandbox_bootstrap_name="learnitive-install-sandbox-smoke-$$"
  docker rm -f "$sandbox_bootstrap_name" >/dev/null 2>&1 || true
  if ! docker run -d --name "$sandbox_bootstrap_name" --network none --read-only --cap-drop ALL --security-opt no-new-privileges \
    --user "$sandbox_uid:$sandbox_gid" \
    --workdir /workspace \
    --env HOME=/workspace/.learnitive/home \
    --env XDG_CACHE_HOME=/workspace/.learnitive/cache/xdg \
    --env NPM_CONFIG_CACHE=/workspace/.learnitive/cache/npm \
    --env PIP_CACHE_DIR=/workspace/.learnitive/cache/pip \
    --env CARGO_HOME=/workspace/.learnitive/cache/cargo \
    --env COREPACK_HOME=/workspace/.learnitive/cache/corepack \
    --tmpfs /tmp:rw,nosuid,nodev,mode=1777,size=128m \
    -v "$sandbox_smoke_root:/workspace:rw" \
    "$RUNTIME_IMAGE" /usr/local/bin/learnitive-sandbox-init >/dev/null; then
    rm -rf -- "$sandbox_smoke_root"
    die 'Long-lived sandbox bootstrap failed to start.'
  fi
  bootstrap_running='false'
  for _ in {1..10}; do
    bootstrap_running="$(docker inspect --format '{{.State.Running}}' "$sandbox_bootstrap_name" 2>/dev/null || true)"
    [[ "$bootstrap_running" == 'true' ]] && break
    sleep 0.1
  done
  if [[ "$bootstrap_running" != 'true' ]]; then
    docker logs --tail 80 "$sandbox_bootstrap_name" >&2 2>/dev/null || true
    docker rm -f "$sandbox_bootstrap_name" >/dev/null 2>&1 || true
    rm -rf -- "$sandbox_smoke_root"
    die 'Long-lived sandbox bootstrap exited before PTY readiness.'
  fi
  if ! docker exec \
    --env HOME=/workspace/.learnitive/home \
    --env XDG_CACHE_HOME=/workspace/.learnitive/cache/xdg \
    --env NPM_CONFIG_CACHE=/workspace/.learnitive/cache/npm \
    --env PIP_CACHE_DIR=/workspace/.learnitive/cache/pip \
    --env CARGO_HOME=/workspace/.learnitive/cache/cargo \
    --env COREPACK_HOME=/workspace/.learnitive/cache/corepack \
    "$sandbox_bootstrap_name" /bin/bash --noprofile --rcfile /tmp/learnitive-bashrc -ic \
      'alias ll >/dev/null && test -w "$HOME" && test -w "$NPM_CONFIG_CACHE"'; then
    docker logs --tail 80 "$sandbox_bootstrap_name" >&2 2>/dev/null || true
    docker rm -f "$sandbox_bootstrap_name" >/dev/null 2>&1 || true
    rm -rf -- "$sandbox_smoke_root"
    die 'Long-lived sandbox bootstrap PTY shell contract failed.'
  fi
  docker rm -f "$sandbox_bootstrap_name" >/dev/null
  log 'Long-lived sandbox bootstrap and PTY shell contract verified.'
  rm -rf -- "$sandbox_smoke_root"

}


ensure_staging_node_runtime() {
  local container tmp_node version major minor
  install -d -m 0755 -o root -g root "$HOST_RUNTIME_ROOT" "$HOST_RUNTIME_ROOT/bin"
  tmp_node="$HOST_NODE_BIN.tmp"
  rm -f -- "$tmp_node"
  container="$(docker create "$RUNTIME_IMAGE" /bin/true)"
  [[ -n "$container" ]] || die 'Unable to create temporary container for staging Node runtime extraction.'
  if ! docker cp "$container:/usr/local/bin/node" "$tmp_node"; then
    docker rm -f "$container" >/dev/null 2>&1 || true
    rm -f -- "$tmp_node"
    die 'Unable to extract Node.js from the validated Learnitive runtime image.'
  fi
  docker rm -f "$container" >/dev/null 2>&1 || true
  chown root:root "$tmp_node"
  chmod 0755 "$tmp_node"
  version="$($tmp_node --version 2>/dev/null || true)"
  [[ "$version" =~ ^v([0-9]+)\.([0-9]+)\. ]] || { rm -f -- "$tmp_node"; die 'Extracted staging Node runtime did not report a valid version.'; }
  major="${BASH_REMATCH[1]}"
  minor="${BASH_REMATCH[2]}"
  if (( major < 22 || (major == 22 && minor < 13) )); then
    rm -f -- "$tmp_node"
    die "Extracted staging Node runtime $version is below the required Node 22.13 baseline."
  fi
  mv -f "$tmp_node" "$HOST_NODE_BIN"
  runuser -u "$RUNNER_USER" -- "$HOST_NODE_BIN" --version >/dev/null || die 'Universal Runner service user cannot execute the staging Node runtime.'
  runuser -u "$CONSOLE_USER" -- "$HOST_NODE_BIN" --version >/dev/null || die 'Console service user cannot execute the staging Node runtime.'
  runuser -u "$MAIN_USER" -- "$HOST_NODE_BIN" --version >/dev/null || die 'Main service user cannot execute the staging Node runtime.'
  log "Staging services will use $HOST_NODE_BIN ($version), extracted from the validated unified runtime image."
}

runner_env_args() {
  local file="$CONFIG_ROOT/universal-runner.env" key value
  local keys=(
    LEARNITIVE_RUNNER_BIND_HOST
    LEARNITIVE_RUNNER_PORT
    LEARNITIVE_RUNNER_SECRET
    LEARNITIVE_DEVELOPMENT_IMAGE
    LEARNITIVE_RUNNER_WORK_ROOT
    LEARNITIVE_RUNNER_MAX_FILES
    LEARNITIVE_RUNNER_MAX_PROJECT_BYTES
    LEARNITIVE_RUNNER_MAX_OUTPUT_BYTES
    LEARNITIVE_RUNNER_MAX_ARTIFACT_BYTES
    LEARNITIVE_RUNNER_ALLOW_CUSTOM
    LEARNITIVE_CONTROLLED_EGRESS_NETWORK
    LEARNITIVE_BUILD_CPU
    LEARNITIVE_BUILD_MEMORY
    LEARNITIVE_BUILD_DISK_BYTES
    LEARNITIVE_BUILD_PIDS
    LEARNITIVE_BUILD_TIMEOUT_SECONDS
  )
  for key in "${keys[@]}"; do
    value="$(env_get "$file" "$key")"
    if [[ -n "$value" ]]; then
      printf '%s=%s\0' "$key" "$value"
    fi
  done
  # This function is consumed through process substitution. Missing optional
  # runner settings are normal; never let the last empty value make the
  # subshell return 1 and inherit the installer's fatal ERR trap.
  return 0
}

verify_universal_runner_process_preflight() {
  local -a env_args=(NODE_ENV=production)
  local item
  while IFS= read -r -d '' item; do env_args+=("$item"); done < <(runner_env_args)
  env_args+=(LEARNITIVE_RUNNER_BIND_HOST=127.0.0.1)
  log 'Validating Universal Runner startup as the real staging runner account before systemd activation.'
  runuser -u "$RUNNER_USER" -- env "${env_args[@]}" "$HOST_NODE_BIN" --input-type=module -e "import('$APP_ROOT/learnitive-services/universal-runner/server.mjs').then(m=>{const c=m.normalizeRunnerConfig(process.env);const s=m.createRunnerServer(c);s.listen(0,c.bindHost,()=>s.close(()=>process.exit(0)));}).catch(e=>{console.error(e?.stack||e);process.exit(1);})"     || die 'Universal Runner process preflight failed before systemd activation.'
}


verify_universal_runner_container_preflight() {
  local -a env_args=(NODE_ENV=production)
  local item
  while IFS= read -r -d '' item; do env_args+=("$item"); done < <(runner_env_args)
  log 'Validating the real Universal Runner Docker bind-mount contract before staging service retirement.'
  runuser -u "$RUNNER_USER" -- env "${env_args[@]}" "$HOST_NODE_BIN" --input-type=module -e "import('$APP_ROOT/learnitive-services/universal-runner/server.mjs').then(async m=>{const c=m.normalizeRunnerConfig(process.env);const r=await m.executeJob({executionId:'exec_install_direct_preflight',provider:'node',operation:'RUN',entry:'main.js',files:[{path:'main.js',encoding:'utf8',content:'console.log(\\\"learnitive-runner-preflight-ok\\\")'}]},{config:c});if(r.status!=='success'||r.exitCode!==0||!r.stdout.includes('learnitive-runner-preflight-ok'))throw new Error('unexpected direct runner result');}).catch(e=>{console.error(e?.cause?.stack||e?.stack||e);process.exit(1);})" \
    || die 'Universal Runner Docker container preflight failed before staging service retirement.'
}

service_failure_diagnostics() {
  local unit="$1"
  printf '\n--- %s status ---\n' "$unit" >&2
  systemctl --no-pager --full status "$unit" >&2 || true
  printf '\n--- %s journal ---\n' "$unit" >&2
  journalctl --no-pager -u "$unit" -n 80 >&2 || true
}

verify_console_egress_preflight() {
  LEARNITIVE_DEPLOYMENT="$TARGET_DOMAIN" "$APP_ROOT/deploy/learnitive/terminal-network-control.sh" check
  LEARNITIVE_DEPLOYMENT="$TARGET_DOMAIN" "$APP_ROOT/deploy/learnitive/console-egress-control.sh" check
}

ensure_console_egress_network() {
  LEARNITIVE_DEPLOYMENT="$TARGET_DOMAIN" "$APP_ROOT/deploy/learnitive/terminal-network-control.sh" prepare
  LEARNITIVE_DEPLOYMENT="$TARGET_DOMAIN" "$APP_ROOT/deploy/learnitive/console-egress-control.sh" prepare
  local proxy_gateway
  proxy_gateway="$(docker network inspect --format '{{(index .IPAM.Config 0).Gateway}}' learnitive-os-egress)"
  [[ "$proxy_gateway" =~ ^[0-9]+(\.[0-9]+){3}$ ]] || die 'Unable to resolve the Packages + Git proxy gateway address.'
  env_set "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_PACKAGES_GIT_PROXY_URL "http://$proxy_gateway:$EGRESS_PROXY_PORT"
  env_set "$CONFIG_ROOT/universal-runner.env" LEARNITIVE_PACKAGES_GIT_PROXY_URL "http://$proxy_gateway:$EGRESS_PROXY_PORT"
}

install_systemd_units() {
  local unit tmp
  verify_current_service_ownership
  for unit in "${SERVICES[@]}"; do
    tmp="$(mktemp)"
    sed -e "s|@@LEARNITIVE_SITE_ROOT@@|$SITE_ROOT|g" -e "s|@@LEARNITIVE_DEPLOYMENT@@|$TARGET_DOMAIN|g" -e "s|/var/www/edu\.learnitive\.com|$SITE_ROOT|g" -e "s|/var/www/os\.learnitive\.com|$SITE_ROOT|g" "$APP_ROOT/deploy/systemd/$unit" >"$tmp"
    install -m 0644 "$tmp" "/etc/systemd/system/$unit"
    rm -f -- "$tmp"
  done
  systemctl daemon-reload
  for unit in "${SERVICES[@]}"; do systemctl enable "$unit" >/dev/null; done
}


main_service_env_args() {
  local file="$CONFIG_ROOT/learnitive.env" line key value
  [[ -f "$file" ]] || die "Main service environment file is missing: $file"
  while IFS= read -r line || [[ -n "$line" ]]; do
    [[ -z "$line" || "$line" == \#* ]] && continue
    [[ "$line" == *=* ]] || die "Invalid main service environment entry: $line"
    key="${line%%=*}"
    value="${line#*=}"
    [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "Invalid main service environment key: $key"
    value="$(env_unquote_value "$value")"
    printf '%s=%s\0' "$key" "$value"
  done <"$file"
}

reset_staging_learn_schema() {
  local -a env_args=(NODE_ENV=production)
  local item
  while IFS= read -r -d '' item; do env_args+=("$item"); done < <(main_service_env_args)
  log 'Resetting the disposable staging Learn schema before canonical Learn migrations.'
  runuser -u "$MAIN_USER" -- env "${env_args[@]}" "$HOST_NODE_BIN" "$APP_ROOT/server/reset-staging-saas-schema.mjs" \
    || die 'Fresh staging Learn schema reset failed.'
}

run_saas_schema_preflight() {
  local -a env_args=(NODE_ENV=production)
  local item
  while IFS= read -r -d '' item; do env_args+=("$item"); done < <(main_service_env_args)
  log 'Running canonical SaaS migrations and Learn integrity preflight before main service activation.'
  runuser -u "$MAIN_USER" -- env "${env_args[@]}" "$HOST_NODE_BIN" "$APP_ROOT/server/saas-preflight.mjs" \
    || die 'SaaS database/schema preflight failed before main service activation.'
}

verify_main_service_media_sandbox() {
  local installed='/etc/systemd/system/learnitive.service'
  [[ -f "$installed" ]] || die 'Installed main systemd unit is missing.'
  grep -Eq '^ProtectSystem=strict$' "$installed" || die 'Main service must retain ProtectSystem=strict.'
  grep -Fq "$MEDIA_ROOT/learn" "$installed" || die 'Main service sandbox does not grant the public Learn media directory.'
  grep -Fq "$MEDIA_ROOT/learn-private" "$installed" || die 'Main service sandbox does not grant the protected Learn media directory.'
  runuser -u "$MAIN_USER" -- test -w "$MEDIA_ROOT/learn" || die 'Main service account cannot write the public Learn media staging directory.'
  runuser -u "$MAIN_USER" -- test -w "$MEDIA_ROOT/learn-private" || die 'Main service account cannot write the protected Learn media directory.'
  log 'Main service Learn media sandbox paths verified.'
}

production_apache_state_is_healthy() {
  [[ "${LEARNITIVE_INGRESS_MODE:-external}" == 'external' ]]
}

prepare_apache() {
  [[ "${LEARNITIVE_INGRESS_MODE:-external}" == 'external' ]] || \
    die 'The standard Learnitive installer only supports LEARNITIVE_INGRESS_MODE=external so the existing shared web server is never modified.'
  log 'External/isolated ingress mode: Apache/Nginx configuration is intentionally untouched.'
}

wait_http() {
  local name="$1" url="$2" tries="${3:-30}" delay="${4:-1}"
  local i
  for ((i=1;i<=tries;i++)); do
    if curl --fail --silent --show-error --max-time 3 "$url" >/dev/null 2>&1; then log "$name ready: $url"; return 0; fi
    sleep "$delay"
  done
  warn "$name did not become ready at $url"
  return 1
}

wait_redis() {
  local i password
  password="$(env_get "$CONFIG_ROOT/redis.env" LEARNITIVE_REDIS_PASSWORD)"
  [[ -n "$password" ]] || return 1
  for ((i=1;i<=30;i++)); do
    if docker exec "$REDIS_CONTAINER" redis-cli --no-auth-warning -a "$password" ping 2>/dev/null | grep -qx PONG; then log 'Redis queue ready.'; return 0; fi
    sleep 1
  done
  return 1
}

wait_secret_helper() {
  local i socket='/run/learnitive-secret-helper/control.sock'
  for ((i=1;i<=30;i++)); do
    if systemctl is-active --quiet learnitive-secret-helper.service && [[ -S "$socket" ]]; then
      log 'Integration secret helper ready.'
      return 0
    fi
    sleep 1
  done
  return 1
}

run_console_terminal_smoke() {
  local console_secret
  local -a public_origin_args=()
  console_secret="$(env_get "$CONFIG_ROOT/console-gateway.env" LEARNITIVE_CONSOLE_GATEWAY_SECRET)"
  [[ -n "$console_secret" ]] || die 'Console terminal smoke requires the Console Gateway secret.'

  # The production root launcher intentionally installs the new Learnitive-only
  # www/apex/edu Apache vhosts only after the application installer has passed.
  # Therefore a www install must not require the not-yet-activated public WSS
  # route here. Certify the private Console Gateway -> Docker -> PTY path first;
  # install-www-vhosts.sh runs the same smoke through https://www.learnitive.com
  # immediately after the Learnitive-only Apache cutover and rolls it back if WSS
  # is not functional. Staging already has its ingress, so retain the end-to-end
  # public HTTPS WebSocket smoke there.
  if [[ "$DEPLOYMENT_PROFILE" == 'www' ]]; then
    log 'Running functional loopback WebSocket -> Console Gateway -> Docker -> PTY terminal smoke before production Apache cutover.'
  else
    public_origin_args+=(LEARNITIVE_CONSOLE_PUBLIC_ORIGIN="https://$TARGET_DOMAIN")
    log 'Running functional HTTPS WebSocket -> Console Gateway -> Docker -> PTY terminal smoke.'
  fi

  if ! runuser -u "$CONSOLE_USER" -- env \
    LEARNITIVE_CONSOLE_GATEWAY_URL="$CONSOLE_BASE_URL" \
    LEARNITIVE_CONSOLE_GATEWAY_SECRET="$console_secret" \
    "${public_origin_args[@]}" \
    "$HOST_NODE_BIN" "$APP_ROOT/learnitive-services/console-gateway/terminal-smoke.mjs"; then
    service_failure_diagnostics learnitive-console-gateway.service
    die 'Functional Cloud Terminal PTY smoke failed.'
  fi
  if [[ "$DEPLOYMENT_PROFILE" == 'www' ]]; then
    log 'Functional private Cloud Terminal WebSocket + PTY smoke passed; public WSS will be certified after the production vhost cutover.'
  else
    log 'Functional public Cloud Terminal WebSocket + PTY smoke passed.'
  fi
}

start_services_and_verify_health() {
  systemctl restart learnitive-redis.service
  wait_redis || die 'Redis queue did not become ready.'
  systemctl restart learnitive-universal-runner.service
  systemctl restart learnitive-egress-proxy.service
  systemctl restart learnitive-console-gateway.service
  systemctl restart learnitive-secret-helper.service
  wait_secret_helper || { service_failure_diagnostics learnitive-secret-helper.service; die 'Integration secret helper readiness failed.'; }
  systemctl restart learnitive.service

  wait_http 'Universal Runner' "$RUNNER_BASE_URL/health" || { service_failure_diagnostics learnitive-universal-runner.service; die 'Universal Runner readiness failed.'; }
  wait_http 'Packages + Git egress proxy' "http://127.0.0.1:$EGRESS_PROXY_PORT/healthz" || { service_failure_diagnostics learnitive-egress-proxy.service; die 'Packages + Git egress proxy readiness failed.'; }
  wait_http 'Console gateway' "$CONSOLE_BASE_URL/healthz" || { service_failure_diagnostics learnitive-console-gateway.service; die 'Console gateway readiness failed.'; }
  wait_http 'Main application' 'http://127.0.0.1:4210/api/health' || { service_failure_diagnostics learnitive.service; die 'Main application readiness failed.'; }
  if [[ "$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_PREVIEW_REQUIRE_ISOLATED_ORIGIN)" == '1' ]]; then
    local preview_code
    preview_code="$(curl --silent --show-error --output /tmp/learnitive-preview-origin-smoke.json --write-out '%{http_code}' --resolve "$PREVIEW_DOMAIN:443:127.0.0.1" "https://$PREVIEW_DOMAIN/")"
    [[ "$preview_code" == '404' ]] || die "Isolated Preview origin did not fail closed on its root path (HTTP $preview_code)."
    grep -q 'not_found' /tmp/learnitive-preview-origin-smoke.json || die 'Isolated Preview root did not reach the trusted Preview-only handler.'
    log "Isolated Preview origin ready: https://$PREVIEW_DOMAIN"
  fi
  SERVICES_QUIESCED_FOR_BUILD=0
  PREVIOUSLY_RUNNING_APP_SERVICES=()
}

run_runtime_queue_smoke() {
  log 'Running real concurrent queue -> runner -> Docker smoke jobs (Node, Python, C++).'
  runuser -u "$MAIN_USER" -- env \
    LEARNITIVE_RUNTIME_REDIS_URL="$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_RUNTIME_REDIS_URL)" \
    LEARNITIVE_RUNTIME_QUEUE_PREFIX='learnitive:runtime:' \
    LEARNITIVE_RUNTIME_QUEUE_NAME='learnitive-runtime' \
    LEARNITIVE_RUNTIME_QUEUE_WORK_ROOT="$QUEUE_ROOT/jobs" \
    LEARNITIVE_RUNTIME_MAX_QUEUED_PER_USER=3 \
    LEARNITIVE_RUNTIME_MAX_QUEUED_GLOBAL=200 \
    LEARNITIVE_RUNNER_CONCURRENCY="$(env_get "$CONFIG_ROOT/learnitive.env" LEARNITIVE_RUNNER_CONCURRENCY)" \
    "$HOST_NODE_BIN" "$APP_ROOT/scripts/runtime-queue-smoke.mjs"
}

run_latex_smoke() {
  log 'Running representative LaTeX API -> shared Redis queue -> Universal Runner smoke.'
  local payload_file output_file
  payload_file="$(mktemp)"
  output_file="$(mktemp)"
  trap 'rm -f -- "$payload_file" "$output_file"' RETURN
  node - "$payload_file" <<'NODE'
const fs = require('node:fs');
const file = process.argv[2];
const source = Buffer.from('\\documentclass{article}\\begin{document}Learnitive R12 smoke\\end{document}', 'utf8').toString('base64');
fs.writeFileSync(file, JSON.stringify({
  engine: 'pdflatex',
  rootFile: 'main.tex',
  fallback: true,
  fallbackClientId: 'installer-smoke-r12-0123456789',
  files: [{ name: 'main.tex', content: source }],
}));
NODE
  local http_status
  http_status="$(curl --silent --show-error --max-time 180 \
    -o "$output_file" -w '%{http_code}' \
    -H 'Content-Type: application/json' \
    -H "Origin: https://$TARGET_DOMAIN" \
    --data-binary "@$payload_file" \
    'http://127.0.0.1:4210/api/compile/cloud')" || {
      warn 'LaTeX smoke HTTP request failed before receiving a response.'
      service_failure_diagnostics learnitive.service
      service_failure_diagnostics learnitive-universal-runner.service
      return 1
    }
  if [[ "$http_status" != '200' ]]; then
    warn "LaTeX smoke returned HTTP $http_status. Response body follows:"
    sed -n '1,40p' "$output_file" >&2 || true
    service_failure_diagnostics learnitive.service
    service_failure_diagnostics learnitive-universal-runner.service
    return 1
  fi
  node - "$output_file" <<'NODE'
const fs = require('node:fs');
const value = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
if (value.success !== true || typeof value.pdf !== 'string' || typeof value.synctex !== 'string') process.exit(1);
const pdf = Buffer.from(value.pdf, 'base64');
const synctex = Buffer.from(value.synctex, 'base64');
if (pdf.length < 100 || pdf.subarray(0, 4).toString('ascii') !== '%PDF' || synctex.length < 20) process.exit(1);
NODE
  rm -f -- "$payload_file" "$output_file"
  trap - RETURN
}



cleanup_legacy_guest_sandbox_containers() {
  # A short-lived bug generated guest names as learnitive-dev-uguest-*
  # while the gateway cleanup allowlist only accepted learnitive-guest-*.
  # Remove only exact, deployment-owned containers whose sandbox-user label
  # proves they are anonymous Learnitive allocations from that bug.
  local name deployment principal expected
  while IFS=$'	' read -r name deployment principal; do
    [[ "$name" == learnitive-dev-uguest-* ]] || continue
    [[ "$deployment" == "$TARGET_DOMAIN" ]] || { warn "Leaving legacy-looking guest container $name because its deployment label is not $TARGET_DOMAIN."; continue; }
    [[ "$principal" =~ ^guest-[a-f0-9-]{20,80}$ ]] || { warn "Leaving legacy-looking guest container $name because its sandbox-user label is not a recognized guest principal."; continue; }
    expected="learnitive-dev-u${principal}"
    [[ "$name" == "$expected" ]] || { warn "Leaving legacy-looking guest container $name because its name does not match its sandbox-user label."; continue; }
    log "Removing deployment-owned orphan guest sandbox from the previous naming bug: $name"
    docker rm -f "$name" >/dev/null
  done < <(docker ps -a --format '{{.Names}}	{{.Label "learnitive.deployment"}}	{{.Label "learnitive.sandbox-user"}}')
}

verify_web_preview_contract() {
  node "$APP_ROOT/scripts/learnitive-r12-release-audit.mjs" --preview-only
}

verify_apache_public_routes() {
  [[ "${LEARNITIVE_INGRESS_MODE:-external}" == 'external' ]] || die 'Unexpected ingress mode.'
  curl --fail --silent --show-error --max-time 8 "http://127.0.0.1:${LEARNITIVE_PORT:-4210}/api/health" >/dev/null
  log 'Local Learnitive application health passed; public ingress is managed independently and was not reloaded or reconfigured.'
}


finalize_external_ingress_handoff() {
  if [[ "$DEPLOYMENT_PROFILE" == 'www' ]]; then
    log 'Core application/runtime acceptance is complete and Apache is still untouched by the core installer; the guarded production wrapper will now commit the Learnitive-only www/apex/edu vhosts.'
  else
    log "External ingress remains an operator-managed boundary for https://$TARGET_DOMAIN; no shared web-server configuration was modified."
  fi
}

print_service_diagnostics() {
  local unit
  for unit in "${SERVICES[@]}"; do
    if unit_exists "$unit"; then
      printf '\n--- %s ---\n' "$unit" >&2
      systemctl --no-pager --full status "$unit" >&2 || true
      journalctl -u "$unit" -n 60 --no-pager >&2 || true
    fi
  done
}

main() {
  require_root
  acquire_install_lock
  for command in bash node npm python3 openssl curl sha256sum realpath awk grep sed find stat install cp mv mktemp systemctl journalctl docker iptables ss runuser getent groupadd useradd usermod cmp tr timeout date; do require_command "$command"; done
  source_is_staging_tree
  assert_protected_products_untouched
  LEARNITIVE_DEPLOYMENT="$TARGET_DOMAIN" LEARNITIVE_SITE_ROOT="$SITE_ROOT" LEARNITIVE_APP_ROOT="$APP_ROOT" LEARNITIVE_CONFIG_ROOT="$CONFIG_ROOT" LEARNITIVE_ALLOW_WWW_PROMOTION="${LEARNITIVE_ALLOW_WWW_PROMOTION:-0}" \
    node "$SOURCE_ROOT/scripts/learnitive-host-preflight.mjs" || die 'Read-only Learnitive host preflight failed. No existing process or resource was changed.'
  capture_protected_baseline
  verify_config_ownership
  repair_existing_entitlement_permissions

  local release_fingerprint
  release_fingerprint="$(compute_release_fingerprint "$APP_ROOT")"
  [[ "$release_fingerprint" =~ ^[a-f0-9]{64}$ ]] || die 'Unable to compute a stable release fingerprint.'
  if maybe_skip_identical_successful_release "$release_fingerprint"; then
    return 0
  fi

  command -v pnpm >/dev/null 2>&1 || require_command corepack
  detect_network_owner_compat
  write_deployment_marker
  if [[ "$DEPLOYMENT_PROFILE" == 'staging' ]]; then migrate_legacy_staging_config; fi
  ensure_users_and_directories
  prepare_configuration
  verify_staging_port_contract
  ensure_entitlement_material
  verify_current_service_ownership
  # Reject stale workspace manifests/lockfiles and legacy package namespaces
  # before spending time on Docker builds or touching any active service.
  # A failed build from the pre-rebrand generator may have left only this exact
  # generated public directory behind; remove it before the namespace audit.
  prune_stale_generated_namespace_assets
  verify_source_preflight
  # Validate dedicated Learnitive network/firewall ownership before any service
  # is retired or stopped. Existing unowned resources are hard conflicts and are
  # never adopted, removed or rewritten by this installer.
  verify_console_egress_preflight
  # Build/validate the unified execution image while every currently installed
  # staging service is still available. A Dockerfile/toolchain failure must not
  # stop either the main site or the superseded split LaTeX fallback.
  prepare_runtime_images
  ensure_staging_node_runtime
  verify_universal_runner_process_preflight
  verify_universal_runner_container_preflight
  # Only after the replacement execution image and actual runner Docker contract are proven healthy may staging-
  # owned legacy/split services be retired and the active app be quiesced.
  install_dependencies_and_build
  log 'Auditing the freshly generated production browser output for obsolete branding.'
  node scripts/learnitive-brand-audit.mjs
  prepare_template_library
  # Quiesce only after dependencies and browser assets have built successfully.
  # This keeps the previously healthy release online when a pre-activation build fails.
  retire_old_staging_units
  retire_split_latex_service
  stop_staging_services_for_build
  install_runtime_dependencies
  prune_removed_bundled_templates
  publish_media
  publish_fonts
  ensure_console_egress_network
  cleanup_legacy_guest_sandbox_containers
  install_systemd_units
  verify_main_service_media_sandbox
  if [[ "${LEARNITIVE_RESET_LEARN:-0}" == "1" ]]; then
    [[ "$DEPLOYMENT_PROFILE" == 'staging' ]] || die 'LEARNITIVE_RESET_LEARN=1 is forbidden on the www production profile.'
    warn 'LEARNITIVE_RESET_LEARN=1 requested: performing the explicit destructive staging Learn reset.'
    reset_staging_learn_schema
  else
    log 'Preserving existing Learn courses, lessons, progress and media relationships; applying forward migrations only.'
  fi
  run_saas_schema_preflight
  prepare_apache
  start_services_and_verify_health
  run_console_terminal_smoke
  validate_packages_git_dependency_egress
  validate_template_library
  validate_learn_library
  run_runtime_queue_smoke
  run_latex_smoke
  verify_web_preview_contract
  verify_apache_public_routes
  finalize_external_ingress_handoff
  verify_protected_baseline_now
  write_successful_release_marker "$release_fingerprint"
  if [[ "$DEPLOYMENT_PROFILE" == 'www' ]]; then
    log "R12 www application/runtime acceptance passed for https://$TARGET_DOMAIN; production Apache cutover is the next mandatory wrapper step."
  else
    log "R12 $DEPLOYMENT_PROFILE installation and real execution acceptance passed for https://$TARGET_DOMAIN"
  fi
}

if [[ "${LEARNITIVE_INSTALLER_LIB_ONLY:-0}" != '1' ]]; then
  main "$@"
fi
