#!/usr/bin/env bash
set -euo pipefail

TEXISE_SOURCE_ROOT="${TEXISE_SOURCE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
TEXISE_SITE_ROOT="${TEXISE_SITE_ROOT:-/var/www/texise.com}"
TEXISE_INSTALL_ROOT="${TEXISE_INSTALL_ROOT:-${TEXISE_SITE_ROOT}/public_html}"
TEXISE_DIST_DIR="${TEXISE_DIST_DIR:-${TEXISE_INSTALL_ROOT}/dist}"
TEXISE_PREVIOUS_DIST_DIR="${TEXISE_PREVIOUS_DIST_DIR:-${TEXISE_INSTALL_ROOT}/dist.previous}"
TEXISE_CONFIG_DIR="${TEXISE_CONFIG_DIR:-/etc/texise}"
TEXISE_DATA_DIR="${TEXISE_DATA_DIR:-${TEXISE_INSTALL_ROOT}/texise-data}"
TEXISE_AUDIT_DIR="${TEXISE_AUDIT_DIR:-${TEXISE_DATA_DIR}/audit}"
TEXISE_CACHE_DIR="${TEXISE_CACHE_DIR:-${TEXISE_DATA_DIR}/cache}"
TEXISE_TMP_DIR="${TEXISE_TMP_DIR:-${TEXISE_DATA_DIR}/tmp}"
TEXISE_LOG_DIR="${TEXISE_LOG_DIR:-${TEXISE_DATA_DIR}/log}"
TEXISE_COMPILER_CACHE_DIR="${TEXISE_COMPILER_CACHE_DIR:-${TEXISE_CACHE_DIR}/compiler}"
TEXISE_DEPLOY_CERTIFY="${TEXISE_DEPLOY_CERTIFY:-0}"
TEXISE_APACHE_SITE="${TEXISE_APACHE_SITE:-/etc/apache2/sites-available/texise.com.conf}"
TEXISE_APP_REDIRECT_SITE="${TEXISE_APP_REDIRECT_SITE:-/etc/apache2/sites-available/app.texise.com-redirect.conf}"
TEXISE_SYSTEMD_UNIT="${TEXISE_SYSTEMD_UNIT:-/etc/systemd/system/texise.service}"
TEXISE_CLOUD_COMPILE_SYSTEMD_UNIT="${TEXISE_CLOUD_COMPILE_SYSTEMD_UNIT:-/etc/systemd/system/texise-latex-compile.service}"
TEXISE_CONSOLE_SYSTEMD_UNIT="${TEXISE_CONSOLE_SYSTEMD_UNIT:-/etc/systemd/system/texise-console-gateway.service}"
TEXISE_RUNTIME_DIR="${TEXISE_RUNTIME_DIR:-${TEXISE_DATA_DIR}/runtime}"
TEXISE_RUNTIME_CURRENT="${TEXISE_RUNTIME_CURRENT:-${TEXISE_RUNTIME_DIR}/current}"
TEXISE_RUNTIME_PREVIOUS="${TEXISE_RUNTIME_PREVIOUS:-${TEXISE_RUNTIME_DIR}/previous}"
TEXISE_RUNTIME_NEXT="${TEXISE_RUNTIME_NEXT:-${TEXISE_RUNTIME_DIR}/next}"
TEXISE_TEMPLATE_ROOT="${TEXISE_TEMPLATE_ROOT:-${TEXISE_SITE_ROOT}/templates}"
TEXISE_TEMPLATE_PUBLIC_COPY="${TEXISE_TEMPLATE_PUBLIC_COPY:-${TEXISE_DIST_DIR}/texise-templates}"
TEXISE_FONT_SOURCE_DIR="${TEXISE_FONT_SOURCE_DIR:-${TEXISE_SITE_ROOT}/fonts}"
TEXISE_MEDIA_STAGE_ROOT="${TEXISE_MEDIA_STAGE_ROOT:-${TEXISE_SITE_ROOT}/texise-media}"
TEXISE_MEDIA_PUBLISHED_ROOT="${TEXISE_MEDIA_PUBLISHED_ROOT:-${TEXISE_DIST_DIR}/texise-media}"
# Compatibility aliases used by the trusted CMS runtime and older deployment
# checks. The authoritative copy is now the persistent staging tree outside
# public_html; dist/texise-media is a replaceable published projection.
TEXISE_BLOG_MEDIA_ROOT="${TEXISE_BLOG_MEDIA_ROOT:-${TEXISE_MEDIA_STAGE_ROOT}}"
TEXISE_BLOG_MEDIA_DIR="${TEXISE_BLOG_MEDIA_DIR:-${TEXISE_MEDIA_STAGE_ROOT}/blog}"
TEXISE_DOCS_MEDIA_DIR="${TEXISE_DOCS_MEDIA_DIR:-${TEXISE_MEDIA_STAGE_ROOT}/docs}"
TEXISE_CLOUD_COMPILE_DIR="${TEXISE_CLOUD_COMPILE_DIR:-${TEXISE_INSTALL_ROOT}/texise-services/cloud-compile}"
TEXISE_CLOUD_COMPILE_TMP="${TEXISE_CLOUD_COMPILE_TMP:-${TEXISE_TMP_DIR}/cloud-compile}"
TEXISE_CLOUD_COMPILE_ENV="${TEXISE_CLOUD_COMPILE_ENV:-${TEXISE_CONFIG_DIR}/cloud-compile.env}"
TEXISE_CONSOLE_DIR="${TEXISE_CONSOLE_DIR:-${TEXISE_INSTALL_ROOT}/texise-services/console-gateway}"
TEXISE_CONSOLE_TMP="${TEXISE_CONSOLE_TMP:-${TEXISE_TMP_DIR}/console}"
TEXISE_CONSOLE_ENV="${TEXISE_CONSOLE_ENV:-${TEXISE_CONFIG_DIR}/console-gateway.env}"
TEXISE_CONSOLE_IMAGE_CONTEXT="${TEXISE_CONSOLE_IMAGE_CONTEXT:-${TEXISE_SOURCE_ROOT}/deploy/docker/console}"
TEXISE_CONSOLE_IMAGE_SCHEMA="${TEXISE_CONSOLE_IMAGE_SCHEMA:-r14.28.14-bf12}"
# Historical TeXise-owned locations are read only during verified upgrade migration.
TEXISE_LEGACY_DATA_DIR="${TEXISE_LEGACY_DATA_DIR:-/var/lib/texise}"
TEXISE_LEGACY_BLOG_MEDIA_ROOT="${TEXISE_LEGACY_BLOG_MEDIA_ROOT:-/var/lib/texise-media}"

texise_log() {
  printf '[TeXise] %s\n' "$*"
}

texise_die() {
  printf '[TeXise error] %s\n' "$*" >&2
  exit 1
}

texise_require_root() {
  [[ "${EUID}" -eq 0 ]] || texise_die 'Run this command as root or with sudo.'
}

texise_require_command() {
  command -v "$1" >/dev/null 2>&1 || texise_die "Required command not found: $1"
}

texise_require_node_version() {
  local major
  major="$(node -p 'Number(process.versions.node.split(".")[0])')"
  [[ "${major}" -ge 22 ]] || texise_die "TeXise requires Node.js 22 or newer; found $(node --version)."
}

texise_pnpm() {
  if command -v pnpm >/dev/null 2>&1; then
    pnpm "$@"
    return
  fi

  if command -v corepack >/dev/null 2>&1; then
    corepack pnpm "$@"
    return
  fi

  texise_die 'PNPM is required. Install pnpm or provide Corepack with the pinned pnpm version.'
}

texise_require_package_manager() {
  if command -v pnpm >/dev/null 2>&1; then
    return
  fi
  if command -v corepack >/dev/null 2>&1; then
    texise_log 'Enabling the Corepack pnpm shim required by nested workspace scripts.'
    corepack enable
    command -v pnpm >/dev/null 2>&1 || texise_die 'Corepack could not enable the pnpm shim.'
    return
  fi
  texise_die 'PNPM is required. Install pnpm or Corepack before installing TeXise.'
}

texise_require_source_location() {
  local source_real install_real
  [[ ! -L "${TEXISE_INSTALL_ROOT}" ]] || texise_die \
    "${TEXISE_INSTALL_ROOT} must be a real source directory, not an R12 release symlink."
  source_real="$(readlink -f "${TEXISE_SOURCE_ROOT}")"
  install_real="$(readlink -m "${TEXISE_INSTALL_ROOT}")"

  [[ "${source_real}" == "${install_real}" ]] || texise_die \
    "TeXise source must be located at ${TEXISE_INSTALL_ROOT}. Current source: ${source_real}"
  [[ -f "${TEXISE_SOURCE_ROOT}/package.json" ]] || texise_die 'TeXise package.json is missing from the source root.'
  [[ -f "${TEXISE_SOURCE_ROOT}/server/index.mjs" ]] || texise_die 'TeXise trusted API source is missing from server/index.mjs.'
}

texise_create_service_user() {
  if ! getent group texise >/dev/null 2>&1; then
    groupadd --system texise
  fi

  if ! id texise >/dev/null 2>&1; then
    useradd --system --gid texise --home-dir "${TEXISE_DATA_DIR}" --shell /usr/sbin/nologin texise
  fi
  getent group docker >/dev/null 2>&1 || texise_die 'Docker is installed but its docker group is missing; TeXise execution services require a dedicated Docker group.'
  if ! getent group texise-compile >/dev/null 2>&1; then
    groupadd --system texise-compile
  fi
  if ! id texise-compile >/dev/null 2>&1; then
    useradd --system --gid texise-compile --home-dir "${TEXISE_CLOUD_COMPILE_TMP}" --shell /usr/sbin/nologin texise-compile
  fi
  usermod -a -G docker texise-compile
  if ! getent group texise-console >/dev/null 2>&1; then
    groupadd --system texise-console
  fi
  if ! id texise-console >/dev/null 2>&1; then
    useradd --system --gid texise-console --home-dir "${TEXISE_CONSOLE_TMP}" --shell /usr/sbin/nologin texise-console
  fi
  usermod -a -G docker texise-console
}

texise_create_directories() {
  install -d -o root -g texise -m 0750 "${TEXISE_CONFIG_DIR}"
  install -d -o root -g root -m 0755 "${TEXISE_SITE_ROOT}" "${TEXISE_INSTALL_ROOT}"
  # Runtime and persistent server data live beside the source under public_html,
  # but the executable runtime remains root-owned/read-only to the service.
  # Execution-service users need execute-only traversal through texise-data/tmp
  # to reach their own private workspaces. 0751/0711 deliberately do not grant
  # directory listing or read access to sibling runtime/audit/cache/log data.
  install -d -o root -g texise -m 0751 "${TEXISE_DATA_DIR}"
  install -d -o root -g root -m 0755 "${TEXISE_RUNTIME_DIR}"
  install -d -o texise -g texise -m 0750 "${TEXISE_AUDIT_DIR}" "${TEXISE_CACHE_DIR}" "${TEXISE_LOG_DIR}"
  install -d -o texise -g texise -m 0711 "${TEXISE_TMP_DIR}"
  install -d -o texise -g texise -m 0750 "${TEXISE_COMPILER_CACHE_DIR}"
  # The complete Admin-managed template library is persistent outside public_html.
  install -d -o texise -g texise -m 0750 "${TEXISE_TEMPLATE_ROOT}" "${TEXISE_TEMPLATE_ROOT}/free" "${TEXISE_TEMPLATE_ROOT}/pro"
  install -d -o texise-compile -g texise-compile -m 0750 "${TEXISE_CLOUD_COMPILE_TMP}" "${TEXISE_CLOUD_COMPILE_TMP}/jobs"
  install -d -o texise-console -g texise-console -m 0750 "${TEXISE_CONSOLE_TMP}"
  # Admin-authored Blog/Docs media is persistent staging data outside
  # public_html. Every production build republishes this tree into dist, so a
  # Vite replacement cannot erase the authoritative copy.
  install -d -o root -g texise -m 0755 "${TEXISE_MEDIA_STAGE_ROOT}"
  install -d -o texise -g texise -m 0755 "${TEXISE_BLOG_MEDIA_DIR}" "${TEXISE_DOCS_MEDIA_DIR}"
  # Upgrade bridge: BF50 and older installs kept Blog media below public_html.
  # Copy it into the new persistent staging root before the frontend build so
  # prepare-production-dist can republish it in this very same installation.
  if [[ -d "${TEXISE_INSTALL_ROOT}/texise-media/blog" && "${TEXISE_INSTALL_ROOT}/texise-media/blog" != "${TEXISE_BLOG_MEDIA_DIR}" ]]; then
    texise_log "Migrating previous public_html Blog media into persistent staging ${TEXISE_BLOG_MEDIA_DIR}."
    texise_copy_verify_tree "${TEXISE_INSTALL_ROOT}/texise-media/blog" "${TEXISE_BLOG_MEDIA_DIR}"
  fi
  if [[ -d /var/www/app.texise.com/public_html/texise-media/blog ]]; then
    texise_copy_verify_tree /var/www/app.texise.com/public_html/texise-media/blog "${TEXISE_BLOG_MEDIA_DIR}"
  fi
  if [[ -d "${TEXISE_LEGACY_BLOG_MEDIA_ROOT}/blog" ]]; then
    texise_copy_verify_tree "${TEXISE_LEGACY_BLOG_MEDIA_ROOT}/blog" "${TEXISE_BLOG_MEDIA_DIR}"
  fi
  # texise.service intentionally runs with UMask=0027. Historical Blog cover
  # uploads therefore may have landed as 0640 even though they are public
  # immutable media, causing Apache/www-data to return HTTP 403. Normalize only
  # the supported public cover extensions; private texise-data remains denied.
  find "${TEXISE_BLOG_MEDIA_DIR}" -maxdepth 1 -type f \
    \( -name '*.png' -o -name '*.jpg' -o -name '*.webp' \) \
    -exec chmod 0644 {} +
  find "${TEXISE_DOCS_MEDIA_DIR}" -maxdepth 1 -type f \
    \( -name '*.png' -o -name '*.jpg' -o -name '*.webp' \) \
    -exec chmod 0644 {} +
}

texise_prepare_persistent_template_library() {
  local packaged_root="${TEXISE_SOURCE_ROOT}/texise-templates"
  local marker="${TEXISE_SITE_ROOT}/.texise-template-library-initialized"
  local legacy_config_root="${TEXISE_SITE_ROOT}/template-config"

  [[ -d "${packaged_root}/free" && -d "${packaged_root}/pro" ]] || texise_die \
    "Packaged template seed is incomplete: ${packaged_root}"
  [[ ! -L "${TEXISE_TEMPLATE_ROOT}" ]] || texise_die \
    "Persistent template root must be a real directory: ${TEXISE_TEMPLATE_ROOT}"

  install -d -o texise -g texise -m 0750 \
    "${TEXISE_TEMPLATE_ROOT}" "${TEXISE_TEMPLATE_ROOT}/free" "${TEXISE_TEMPLATE_ROOT}/pro"

  if [[ ! -f "${marker}" ]]; then
    texise_log "Initialising persistent template library at ${TEXISE_TEMPLATE_ROOT} without overwriting existing files."
    cp -a --no-clobber "${packaged_root}/." "${TEXISE_TEMPLATE_ROOT}/"

    # One-time migration from BF50.6's JSON-only persistence directory.
    # Those files were the authoritative Admin copies in that release, so let
    # them win over the packaged seed when they are present.
    local file_name legacy_file
    for file_name in categories.json institutions.json redirects.json; do
      legacy_file="${legacy_config_root}/${file_name}"
      if [[ -f "${legacy_file}" && ! -L "${legacy_file}" ]]; then
        cp -f -- "${legacy_file}" "${TEXISE_TEMPLATE_ROOT}/${file_name}"
        texise_log "Migrated previous persistent ${file_name} into the full template library."
      fi
    done
    touch "${marker}"
  else
    # BF50.9 recovery: BF50.6's old external run.sh treated
    # /var/www/texise.com/templates as a consumable staging inbox. If that
    # stale script was retained during an upgrade it could empty free/ and
    # pro/ before this authoritative-library installer ran. Recover only when
    # the persistent library has zero template directories; --no-clobber keeps
    # any surviving Admin registry files and other persistent data authoritative.
    local persistent_template_count packaged_template_count
    persistent_template_count="$(find "${TEXISE_TEMPLATE_ROOT}/free" "${TEXISE_TEMPLATE_ROOT}/pro" -mindepth 1 -maxdepth 1 -type d ! -name '.*' -print 2>/dev/null | wc -l | tr -d '[:space:]')"
    if [[ "${persistent_template_count:-0}" == "0" ]]; then
      packaged_template_count="$(find "${packaged_root}/free" "${packaged_root}/pro" -mindepth 1 -maxdepth 1 -type d ! -name '.*' -print 2>/dev/null | wc -l | tr -d '[:space:]')"
      if [[ "${packaged_template_count:-0}" != "0" ]]; then
        texise_log "Persistent template library is empty; recovering ${packaged_template_count} packaged templates without overwriting surviving Admin data."
        cp -a --no-clobber "${packaged_root}/." "${TEXISE_TEMPLATE_ROOT}/"
      fi
    fi
  fi

  for file_name in categories.json institutions.json redirects.json; do
    [[ -f "${TEXISE_TEMPLATE_ROOT}/${file_name}" && ! -L "${TEXISE_TEMPLATE_ROOT}/${file_name}" ]] || texise_die \
      "Persistent template registry is missing or unsafe: ${TEXISE_TEMPLATE_ROOT}/${file_name}"
  done
}

texise_sync_persistent_template_source_mirror() {
  local mirror="${TEXISE_SOURCE_ROOT}/texise-templates"
  local persistent_real mirror_real
  persistent_real="$(readlink -m "${TEXISE_TEMPLATE_ROOT}")"
  mirror_real="$(readlink -m "${mirror}")"
  [[ "${persistent_real}" != "${mirror_real}" ]] || return 0
  [[ ! -L "${mirror}" ]] || texise_die "Refusing symlinked source template mirror: ${mirror}"

  rm -rf -- "${mirror}"
  install -d -o root -g root -m 0755 "${mirror}"
  cp -a "${TEXISE_TEMPLATE_ROOT}/." "${mirror}/"
  find "${mirror}" -type d -exec chmod 0755 {} +
  find "${mirror}" -type f -exec chmod 0644 {} +
  texise_log "Synchronized persistent template library into replaceable source mirror ${mirror}."
}

texise_prepare_source_permissions() {
  chmod 0755 "${TEXISE_INSTALL_ROOT}" "${TEXISE_INSTALL_ROOT}/server"
  chmod 0644 "${TEXISE_INSTALL_ROOT}"/server/*.mjs
}

texise_prepare_template_runtime_permissions() {
  [[ -d "${TEXISE_TEMPLATE_ROOT}" ]] || texise_die "Canonical template source is missing: ${TEXISE_TEMPLATE_ROOT}"
  [[ -d "${TEXISE_TEMPLATE_PUBLIC_COPY}" ]] || texise_die "Built template catalog is missing: ${TEXISE_TEMPLATE_PUBLIC_COPY}"

  # Admin template operations run as the unprivileged texise service account.
  # Keep the authoritative source writable only by that account while allowing
  # the generated browser catalog to remain world-readable through Apache.
  chown -R texise:texise "${TEXISE_TEMPLATE_ROOT}"
  find "${TEXISE_TEMPLATE_ROOT}" -type d -exec chmod 0750 {} +
  find "${TEXISE_TEMPLATE_ROOT}" -type f -exec chmod 0640 {} +


  chown -R texise:texise "${TEXISE_TEMPLATE_PUBLIC_COPY}"
  find "${TEXISE_TEMPLATE_PUBLIC_COPY}" -type d -exec chmod 0755 {} +
  find "${TEXISE_TEMPLATE_PUBLIC_COPY}" -type f -exec chmod 0644 {} +

  [[ -d "${TEXISE_MEDIA_STAGE_ROOT}" ]] || texise_die "Persistent CMS media staging root is missing: ${TEXISE_MEDIA_STAGE_ROOT}"
  [[ -d "${TEXISE_MEDIA_PUBLISHED_ROOT}" ]] || texise_die "Built CMS media projection is missing: ${TEXISE_MEDIA_PUBLISHED_ROOT}"
  chown -R texise:texise "${TEXISE_MEDIA_STAGE_ROOT}" "${TEXISE_MEDIA_PUBLISHED_ROOT}"
  find "${TEXISE_MEDIA_STAGE_ROOT}" "${TEXISE_MEDIA_PUBLISHED_ROOT}" -type d -exec chmod 0755 {} +
  find "${TEXISE_MEDIA_STAGE_ROOT}" "${TEXISE_MEDIA_PUBLISHED_ROOT}" -type f -exec chmod 0644 {} +
  texise_log 'Prepared writable filesystem-first template/CMS media sources and live published projections for the trusted Admin runtime.'
}

texise_normalize_config_permissions() {
  # /etc/texise is the root-owned, service-readable configuration trust boundary.
  # Normalize only TeXise-owned well-known files so an upgrade never changes
  # ownership or mode of unrelated administrator files in this directory.
  install -d -o root -g texise -m 0750 "${TEXISE_CONFIG_DIR}"

  local file_name file_path
  for file_name in \
    texise.env \
    config.json \
    license-keys \
    entitlement-private.pem \
    entitlement-public.pem \
    entitlement-refresh.key; do
    file_path="${TEXISE_CONFIG_DIR}/${file_name}"
    [[ -e "${file_path}" ]] || continue
    [[ ! -L "${file_path}" ]] || texise_die "Refusing to normalize symlinked TeXise config file: ${file_path}"
    [[ -f "${file_path}" ]] || texise_die "Expected a regular TeXise config file: ${file_path}"
    chown root:texise "${file_path}"
    chmod 0640 "${file_path}"
  done

  texise_verify_config_permissions
  texise_log "Normalized and verified ${TEXISE_CONFIG_DIR} ownership and protected file modes"
}

texise_verify_config_permissions() {
  local dir_owner dir_mode
  dir_owner="$(stat -c '%U:%G' "${TEXISE_CONFIG_DIR}")"
  dir_mode="$(stat -c '%a' "${TEXISE_CONFIG_DIR}")"
  [[ "${dir_owner}" == 'root:texise' ]] || texise_die \
    "${TEXISE_CONFIG_DIR} owner/group verification failed: expected root:texise, found ${dir_owner}"
  [[ "${dir_mode}" == '750' ]] || texise_die \
    "${TEXISE_CONFIG_DIR} mode verification failed: expected 750, found ${dir_mode}"

  local file_name file_path owner mode
  for file_name in \
    texise.env \
    config.json \
    license-keys \
    entitlement-private.pem \
    entitlement-public.pem \
    entitlement-refresh.key; do
    file_path="${TEXISE_CONFIG_DIR}/${file_name}"
    [[ -e "${file_path}" ]] || continue
    owner="$(stat -c '%U:%G' "${file_path}")"
    mode="$(stat -c '%a' "${file_path}")"
    [[ "${owner}" == 'root:texise' ]] || texise_die \
      "${file_path} owner/group verification failed: expected root:texise, found ${owner}"
    [[ "${mode}" == '640' ]] || texise_die \
      "${file_path} mode verification failed: expected 640, found ${mode}"
  done
}

texise_ensure_env_default() {
  local env_file="$1"
  local key="$2"
  local value="$3"
  if ! grep -q "^${key}=" "${env_file}"; then
    printf '\n%s=%s\n' "${key}" "${value}" >> "${env_file}"
  fi
}

texise_env_value() {
  local env_file="$1"
  local key="$2"
  grep -E "^${key}=" "${env_file}" | tail -n 1 | cut -d= -f2- || true
}

# Return a simple env value with one matching pair of shell quotes and a
# trailing CR removed. This is intentionally used only for URL validation;
# the trusted env file itself is still sourced normally during the build.
texise_env_scalar_value() {
  local env_file="$1"
  local key="$2"
  local value
  value="$(texise_env_value "${env_file}" "${key}")"
  value="${value%$'\r'}"
  if [[ ${#value} -ge 2 ]]; then
    if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then
      value="${value:1:${#value}-2}"
    elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
      value="${value:1:${#value}-2}"
    fi
  fi
  printf '%s' "${value}"
}

texise_is_http_url() {
  local value="${1:-}"
  [[ "${value}" =~ ^https?://[^[:space:]]+$ ]]
}

texise_set_env_value() {
  local env_file="$1"
  local key="$2"
  local value="$3"
  if grep -q "^${key}=" "${env_file}"; then
    # Environment values generated by TeXise are deliberately restricted to
    # URL/path/token-safe characters. Escape sed's replacement delimiters.
    local escaped="${value//\/\\}"
    escaped="${escaped//&/\&}"
    escaped="${escaped//|/\|}"
    sed -i "s|^${key}=.*$|${key}=${escaped}|" "${env_file}"
  else
    printf '\n%s=%s\n' "${key}" "${value}" >> "${env_file}"
  fi
}

texise_saas_requested() {
  local env_file="${1:-${TEXISE_CONFIG_DIR}/texise.env}"
  [[ "$(texise_env_value "${env_file}" TEXISE_SAAS_ENABLED)" == "1" ]] && return 0
  local key value
  # MYSQL_DATABASE is installed with the harmless default texise_db and must
  # not activate SaaS on an otherwise editor-only upgrade.
  for key in MYSQL_HOST MYSQL_USER MYSQL_PASSWORD; do
    value="$(texise_env_value "${env_file}" "${key}")"
    [[ -n "${value}" ]] && return 0
  done
  [[ "$(texise_env_value "${env_file}" TEXISE_MYSQL_CREATE_DATABASE)" == "1" ]] && return 0
  return 1
}

texise_prepare_and_validate_saas_config() {
  local env_file="$1"
  if ! texise_saas_requested "${env_file}"; then
    return 0
  fi

  local create_database
  create_database="$(texise_env_value "${env_file}" TEXISE_MYSQL_CREATE_DATABASE)"
  [[ -z "${create_database}" || "${create_database}" == "0" || "${create_database}" == "1" ]] || texise_die \
    'TEXISE_MYSQL_CREATE_DATABASE must be 0 or 1.'

  local secret
  secret="$(texise_env_value "${env_file}" SESSION_SECRET)"
  if [[ ${#secret} -lt 32 ]]; then
    secret="$(openssl rand -hex 48)"
    sed -i "s|^SESSION_SECRET=.*$|SESSION_SECRET=${secret}|" "${env_file}"
    texise_log 'Generated a new TeXise SESSION_SECRET for the SaaS account layer.'
  fi

  local missing=() key value
  for key in MYSQL_HOST MYSQL_USER MYSQL_PASSWORD; do
    value="$(texise_env_value "${env_file}" "${key}")"
    [[ -n "${value}" ]] || missing+=("${key}")
  done
  if (( ${#missing[@]} > 0 )); then
    texise_die "SaaS is enabled/configured but required MySQL values are missing: ${missing[*]}"
  fi

  local google_id google_secret smtp_host smtp_port smtp_from
  google_id="$(texise_env_value "${env_file}" GOOGLE_CLIENT_ID)"
  google_secret="$(texise_env_value "${env_file}" GOOGLE_CLIENT_SECRET)"
  if [[ -n "${google_id}" || -n "${google_secret}" ]]; then
    [[ -n "${google_id}" && -n "${google_secret}" ]] || texise_die \
      'Google OAuth requires both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.'
  fi
  smtp_host="$(texise_env_value "${env_file}" SMTP_HOST)"
  smtp_port="$(texise_env_value "${env_file}" SMTP_PORT)"
  smtp_from="$(texise_env_value "${env_file}" SMTP_FROM)"
  if [[ -n "${smtp_host}" || -n "${smtp_from}" ]]; then
    [[ -n "${smtp_host}" && -n "${smtp_port}" && -n "${smtp_from}" ]] || texise_die \
      'SMTP requires SMTP_HOST, SMTP_PORT and SMTP_FROM when configured.'
  fi
}

texise_migrate_env_known_default() {
  local env_file="$1"
  local key="$2"
  local old_value="$3"
  local new_value="$4"
  if grep -Fxq "${key}=${old_value}" "${env_file}"; then
    sed -i "s|^${key}=${old_value}$|${key}=${new_value}|" "${env_file}"
    texise_log "Migrated ${key} from retired TeXise default to ${new_value}"
  fi
}

texise_install_config() {
  local env_file="${TEXISE_CONFIG_DIR}/texise.env"
  local config_file="${TEXISE_CONFIG_DIR}/config.json"
  local license_file="${TEXISE_CONFIG_DIR}/license-keys"

  if [[ ! -e "${env_file}" ]]; then
    install -o root -g texise -m 0640 "${TEXISE_SOURCE_ROOT}/deploy/config/texise.env.example" "${env_file}"
  else
    chown root:texise "${env_file}"
    chmod 0640 "${env_file}"
    texise_log "Preserving ${env_file}"
  fi

  # Preserve administrator values while introducing compiler settings added by
  # newer releases. Existing installations therefore gain R14.2 defaults
  # without replacing their trusted server configuration.
  texise_ensure_env_default "${env_file}" TEXISE_TEXLIVE_REMOTE_URL 'https://texlive2026.texlyre.org/'
  texise_ensure_env_default "${env_file}" TEXISE_TEXLIVE_STATIC_MIRROR_URL 'https://cdn.jsdelivr.net/gh/SachaNevsky/latexdiff-texmf-dist@aca46fc00975feebf98e038976790805c3e932df'
  texise_ensure_env_default "${env_file}" TEXISE_TEXLIVE_LSR_URL '/texlive/texlive-lsr.txt'
  texise_ensure_env_default "${env_file}" TEXISE_COMPILER_PROFILE 'full'
  texise_ensure_env_default "${env_file}" TEXISE_BUSY_COMPILER_VERSION '1.4.0'
  texise_ensure_env_default "${env_file}" TEXISE_BUSY_COMPILER_MODULE_URL ''
  texise_ensure_env_default "${env_file}" TEXISE_BUSY_COMPILER_ASSET_URL ''
  texise_ensure_env_default "${env_file}" TEXISE_BUSY_TEXLIVE_ENDPOINT 'https://texlive2026.texlyre.org/'
  texise_ensure_env_default "${env_file}" TEXISE_SIGNALING_PORT '3101'
  texise_ensure_env_default "${env_file}" TEXISE_FONT_SOURCE_DIR '/var/www/texise.com/fonts'
  texise_ensure_env_default "${env_file}" TEXISE_SAAS_ENABLED '0'
  texise_ensure_env_default "${env_file}" MYSQL_HOST ''
  texise_ensure_env_default "${env_file}" MYSQL_PORT '3306'
  texise_ensure_env_default "${env_file}" MYSQL_DATABASE 'texise_db'
  texise_ensure_env_default "${env_file}" MYSQL_USER ''
  texise_ensure_env_default "${env_file}" MYSQL_PASSWORD ''
  texise_ensure_env_default "${env_file}" TEXISE_MYSQL_CREATE_DATABASE '0'
  texise_ensure_env_default "${env_file}" SESSION_SECRET ''
  texise_ensure_env_default "${env_file}" TEXISE_SESSION_DAYS '30'
  texise_ensure_env_default "${env_file}" TEXISE_FREE_PLAN_LIMIT '5'
  texise_ensure_env_default "${env_file}" GOOGLE_CLIENT_ID ''
  texise_ensure_env_default "${env_file}" GOOGLE_CLIENT_SECRET ''
  texise_ensure_env_default "${env_file}" TEXISE_GOOGLE_TAG_ID ''
  texise_ensure_env_default "${env_file}" TEXISE_INDEXNOW_KEY ''
  texise_ensure_env_default "${env_file}" SMTP_HOST ''
  texise_ensure_env_default "${env_file}" SMTP_PORT '587'
  texise_ensure_env_default "${env_file}" SMTP_USER ''
  texise_ensure_env_default "${env_file}" SMTP_PASSWORD ''
  texise_ensure_env_default "${env_file}" SMTP_FROM ''
  texise_ensure_env_default "${env_file}" SMTP_SECURE 'false'
  texise_ensure_env_default "${env_file}" TEXISE_LIFETIME_PRICE ''
  texise_ensure_env_default "${env_file}" TEXISE_SHOP ''
  texise_ensure_env_default "${env_file}" TEXISE_PRO_MONTHLY_SHOP ''
  texise_ensure_env_default "${env_file}" TEXISE_AI_REVIEW_AUDIT_DB '/var/www/texise.com/public_html/texise-data/audit/ai-review-audit.sqlite3'
  texise_ensure_env_default "${env_file}" TEXISE_MEDIA_STAGE_ROOT '/var/www/texise.com/texise-media'
  texise_ensure_env_default "${env_file}" TEXISE_MEDIA_PUBLISHED_ROOT '/var/www/texise.com/public_html/dist/texise-media'
  texise_ensure_env_default "${env_file}" TEXISE_BLOG_MEDIA_DIR '/var/www/texise.com/texise-media/blog'
  texise_ensure_env_default "${env_file}" TEXISE_DOCS_MEDIA_DIR '/var/www/texise.com/texise-media/docs'
  texise_ensure_env_default "${env_file}" TEXISE_PUBLIC_URL 'https://www.texise.com'
  texise_ensure_env_default "${env_file}" TEXISE_CLOUD_COMPILE_IMAGE 'texlive/texlive:latest-full'
  texise_ensure_env_default "${env_file}" TEXISE_CONSOLE_IMAGE 'texlive/texlive-console:pro'
  texise_ensure_env_default "${env_file}" TEXISE_LINUX_TERMINAL_ENABLED '1'
  texise_ensure_env_default "${env_file}" TEXISE_CONSOLE_GATEWAY_URL 'http://127.0.0.1:8787'
  texise_ensure_env_default "${env_file}" TEXISE_CONSOLE_GATEWAY_SECRET ''
  texise_ensure_env_default "${env_file}" TEXISE_CONSOLE_MAX_UPLOAD_BYTES '50000000'
  texise_ensure_env_default "${env_file}" TEXISE_CONSOLE_MAX_DOWNLOAD_BYTES '100000000'
  texise_ensure_env_default "${env_file}" TEXISE_CONSOLE_SESSION_MINUTES '240'
  texise_ensure_env_default "${env_file}" TEXISE_CLOUD_COMPILE_ENABLED '0'
  texise_ensure_env_default "${env_file}" TEXISE_CLOUD_COMPILE_ALLOWED_ENGINES 'pdflatex,xelatex,lualatex'
  texise_ensure_env_default "${env_file}" TEXISE_CLOUD_COMPILE_MAX_FILES '450'
  texise_ensure_env_default "${env_file}" TEXISE_CLOUD_COMPILE_MAX_PROJECT_BYTES '25000000'
  texise_ensure_env_default "${env_file}" TEXISE_CLOUD_COMPILE_MAX_SECONDS '120'
  texise_ensure_env_default "${env_file}" TEXISE_CLOUD_COMPILE_MAX_OUTPUT_BYTES '60000000'
  texise_ensure_env_default "${env_file}" TEXISE_CLOUD_COMPILE_CONCURRENCY '2'
  texise_ensure_env_default "${env_file}" TEXISE_CLOUD_COMPILE_DAILY_ALLOWANCE '0'
  texise_ensure_env_default "${env_file}" TEXISE_CLOUD_COMPILE_MONTHLY_ALLOWANCE '0'
  texise_ensure_env_default "${env_file}" TEXISE_CLOUD_COMPILE_FREE_FALLBACK '1'
  texise_ensure_env_default "${env_file}" TEXISE_CLOUD_COMPILE_WORKER_URL 'http://127.0.0.1:3001/api/compile'
  texise_ensure_env_default "${env_file}" TEXISE_CLOUD_COMPILE_WORKER_SECRET ''
  texise_ensure_env_default "${env_file}" TEXISE_CLOUD_COMPILE_AUDIT_FILE '/var/www/texise.com/public_html/texise-data/audit/cloud-compile-quota.json'
  texise_prepare_and_validate_saas_config "${env_file}"
  if [[ -n "$(texise_env_value "${env_file}" TEXISE_GOOGLE_TAG_ID)" ]]; then
    texise_log 'Google Analytics tag configured.'
  else
    texise_log 'Google Analytics tag: not configured'
  fi
  local indexnow_key
  indexnow_key="$(texise_env_scalar_value "${env_file}" TEXISE_INDEXNOW_KEY)"
  if [[ -z "${indexnow_key}" ]]; then
    indexnow_key="$(openssl rand -hex 16)"
    texise_set_env_value "${env_file}" TEXISE_INDEXNOW_KEY "${indexnow_key}"
    texise_log 'Generated a persistent IndexNow ownership key.'
  elif [[ ! "${indexnow_key}" =~ ^[A-Za-z0-9-]{8,128}$ ]]; then
    texise_die 'TEXISE_INDEXNOW_KEY must contain 8-128 letters, numbers or hyphens.'
  fi
  local free_plan_limit
  free_plan_limit="$(texise_env_scalar_value "${env_file}" TEXISE_FREE_PLAN_LIMIT)"
  if [[ ! "${free_plan_limit}" =~ ^[1-9][0-9]*$ ]]; then
    texise_log "Invalid TEXISE_FREE_PLAN_LIMIT=${free_plan_limit}; restoring safe default 5."
    texise_set_env_value "${env_file}" TEXISE_FREE_PLAN_LIMIT '5'
    free_plan_limit='5'
  fi
  texise_log "Free plan project limit: ${free_plan_limit}"

  # R14.23.0: migrate only TeXise-owned historical defaults. Administrator
  # overrides remain untouched. BusyTeX 1.4.0 and the TeX Live 2026 endpoint
  # are required for the high-level engine/Biber integration.
  # R14.28.12 build fix 5: both 1.3.0 and 1.3.1 were historical
  # TeXise-owned BusyTeX defaults. BusyTeX 1.4.0 added the Biber runtime that
  # TeXise's full compiler profile now requires, so preserved 1.3.x defaults
  # must be upgraded before the expensive compiler asset download starts.
  texise_migrate_env_known_default "${env_file}" TEXISE_BUSY_COMPILER_VERSION '1.3.0' '1.4.0'
  texise_migrate_env_known_default "${env_file}" TEXISE_BUSY_COMPILER_VERSION '1.3.1' '1.4.0'
  texise_migrate_env_known_default "${env_file}" MYSQL_DATABASE 'texisecom_db' 'texise_db'
  texise_migrate_env_known_default "${env_file}" TEXISE_PUBLIC_URL 'https://app.texise.com' 'https://www.texise.com'
  texise_migrate_env_known_default "${env_file}" TEXISE_APP_URL 'https://app.texise.com' 'https://www.texise.com'
  texise_migrate_env_known_default "${env_file}" TEXISE_FONT_SOURCE_DIR '/var/www/app.texise.com/fonts' '/var/www/texise.com/fonts'
  texise_migrate_env_known_default "${env_file}" TEXISE_AI_REVIEW_AUDIT_DB '/var/www/app.texise.com/public_html/texise-data/audit/ai-review-audit.sqlite3' '/var/www/texise.com/public_html/texise-data/audit/ai-review-audit.sqlite3'
  texise_migrate_env_known_default "${env_file}" TEXISE_BLOG_MEDIA_DIR '/var/www/app.texise.com/public_html/texise-media/blog' '/var/www/texise.com/texise-media/blog'
  texise_migrate_env_known_default "${env_file}" TEXISE_BLOG_MEDIA_DIR '/var/www/texise.com/public_html/texise-media/blog' '/var/www/texise.com/texise-media/blog'
  texise_migrate_env_known_default "${env_file}" TEXISE_TEXLIVE_REMOTE_URL 'https://texlive.texlyre.org/' 'https://texlive2026.texlyre.org/'
  texise_migrate_env_known_default "${env_file}" TEXISE_BUSY_TEXLIVE_ENDPOINT 'https://texlive.texlyre.org/' 'https://texlive2026.texlyre.org/'
  texise_migrate_env_known_default "${env_file}" TEXISE_CLOUD_COMPILE_IMAGE 'texlive/texlive:latest' 'texlive/texlive:latest-full'
  # BF50.7: `full` means full TeX Live coverage. BusyTeX publishes the
  # basic/recommended/extra WASM bundles locally and resolves everything else
  # from a full TeX Live tree on demand, so a blank endpoint would silently
  # downgrade complex-template compatibility. Preserve custom endpoints, but
  # repair an explicitly blank historical value when the full profile is used.
  local compiler_profile busytex_full_endpoint texlive_remote_endpoint full_texlive_default
  compiler_profile="$(texise_env_scalar_value "${env_file}" TEXISE_COMPILER_PROFILE)"
  busytex_full_endpoint="$(texise_env_scalar_value "${env_file}" TEXISE_BUSY_TEXLIVE_ENDPOINT)"
  texlive_remote_endpoint="$(texise_env_scalar_value "${env_file}" TEXISE_TEXLIVE_REMOTE_URL)"
  full_texlive_default='https://texlive2026.texlyre.org/'
  if [[ "${compiler_profile:-full}" == 'full' ]]; then
    # BF50.13: a preserved historical/custom value can be non-empty yet still
    # unusable as an HTTP endpoint (for example `false`, `null`, a local path,
    # or a placeholder). Full mode must self-heal that state before the env is
    # exported to generate-compiler-status.mjs. Prefer whichever sibling URL is
    # already valid, otherwise restore the TeXise-owned full-tree default.
    if ! texise_is_http_url "${busytex_full_endpoint}"; then
      if texise_is_http_url "${texlive_remote_endpoint}"; then
        busytex_full_endpoint="${texlive_remote_endpoint}"
      else
        busytex_full_endpoint="${full_texlive_default}"
      fi
      texise_set_env_value "${env_file}" TEXISE_BUSY_TEXLIVE_ENDPOINT "${busytex_full_endpoint}"
      texise_log "Full compiler profile repaired TEXISE_BUSY_TEXLIVE_ENDPOINT=${busytex_full_endpoint}"
    fi
    if ! texise_is_http_url "${texlive_remote_endpoint}"; then
      if texise_is_http_url "${busytex_full_endpoint}"; then
        texlive_remote_endpoint="${busytex_full_endpoint}"
      else
        texlive_remote_endpoint="${full_texlive_default}"
      fi
      texise_set_env_value "${env_file}" TEXISE_TEXLIVE_REMOTE_URL "${texlive_remote_endpoint}"
      texise_log "Full compiler profile repaired TEXISE_TEXLIVE_REMOTE_URL=${texlive_remote_endpoint}"
    fi
  fi
  # The former shared TEXISE_EXECUTION_IMAGE architecture is retired. The two
  # execution services now have intentionally independent images. Remove the
  # legacy key after the dedicated defaults have been installed so it cannot
  # silently override either runtime on upgrade.
  if grep -q '^TEXISE_EXECUTION_IMAGE=' "${env_file}"; then
    sed -i '/^TEXISE_EXECUTION_IMAGE=/d' "${env_file}"
    texise_log 'Retired legacy TEXISE_EXECUTION_IMAGE; Cloud Compile and Linux Terminal now use separate images.'
  fi
  if grep -q '^TEXISE_CONSOLE_REQUIRE_SCIENTIFIC_STACK=' "${env_file}"; then
    sed -i '/^TEXISE_CONSOLE_REQUIRE_SCIENTIFIC_STACK=/d' "${env_file}"
    texise_log 'Retired legacy TEXISE_CONSOLE_REQUIRE_SCIENTIFIC_STACK; the dedicated Pro console image is fully certified as one toolchain.'
  fi
  # Writable runtime state must not live under /etc/texise: systemd intentionally
  # exposes that directory read-only to the texise service. Migrate only the
  # historical TeXise-owned default; administrator custom paths are preserved.
  texise_migrate_env_known_default "${env_file}" TEXISE_AI_REVIEW_AUDIT_DB '/etc/texise/ai-review-audit.sqlite3' '/var/www/texise.com/public_html/texise-data/audit/ai-review-audit.sqlite3'
  texise_migrate_env_known_default "${env_file}" TEXISE_AI_REVIEW_AUDIT_DB '/var/lib/texise/ai-review-audit.sqlite3' '/var/www/texise.com/public_html/texise-data/audit/ai-review-audit.sqlite3'
  texise_migrate_env_known_default "${env_file}" TEXISE_BLOG_MEDIA_DIR '/var/lib/texise-media/blog' '/var/www/texise.com/texise-media/blog'

  if [[ ! -e "${config_file}" ]]; then
    install -o root -g texise -m 0640 "${TEXISE_SOURCE_ROOT}/deploy/config/config.json" "${config_file}"
  else
    chown root:texise "${config_file}"
    chmod 0640 "${config_file}"
    texise_log "Preserving ${config_file}"
  fi

  if [[ ! -e "${license_file}" ]]; then
    install -o root -g texise -m 0640 /dev/null "${license_file}"
  else
    chown root:texise "${license_file}"
    chmod 0640 "${license_file}"
    texise_log "Preserving ${license_file}"
  fi
}

texise_install_signing_keys() {
  local private_key="${TEXISE_CONFIG_DIR}/entitlement-private.pem"
  local public_key="${TEXISE_CONFIG_DIR}/entitlement-public.pem"

  if [[ -e "${private_key}" ]]; then
    texise_log "Preserving ${private_key}"
    if [[ -e "${public_key}" ]]; then
      texise_log "Preserving ${public_key}"
    else
      texise_log "Regenerating missing ${public_key} from the preserved private key"
      openssl pkey -in "${private_key}" -pubout -out "${public_key}"
    fi
  else
    texise_log 'Generating TeXise Ed25519 entitlement signing keypair'
    umask 027
    openssl genpkey -algorithm Ed25519 -out "${private_key}"
    openssl pkey -in "${private_key}" -pubout -out "${public_key}"
  fi

  chown root:texise "${private_key}"
  chmod 0640 "${private_key}"
  # Keep every /etc/texise cryptographic/configuration artifact within the
  # same root:texise trust boundary. The public key is not secret, but 0640
  # avoids a misleading world-readable exception and keeps upgrades
  # deterministic when administrators audit this directory.
  chown root:texise "${public_key}"
  chmod 0640 "${public_key}"

  local refresh_key="${TEXISE_CONFIG_DIR}/entitlement-refresh.key"
  if [[ ! -e "${refresh_key}" ]]; then
    texise_log 'Generating TeXise stateless licence refresh encryption key'
    umask 027
    openssl rand -hex 32 > "${refresh_key}"
  else
    texise_log "Preserving ${refresh_key}"
  fi
  chown root:texise "${refresh_key}"
  chmod 0640 "${refresh_key}"
}


texise_compiler_profile() (
  if [[ -r "${TEXISE_CONFIG_DIR}/texise.env" ]]; then
    set -a
    # shellcheck disable=SC1090
    source "${TEXISE_CONFIG_DIR}/texise.env"
    set +a
  fi
  printf '%s' "${TEXISE_COMPILER_PROFILE:-full}"
)

texise_validate_font_file() {
  local font_file="$1"
  node - "${font_file}" <<'NODE' || texise_die "Invalid or unreadable font file: ${font_file}"
const fs = require('node:fs');
const file = process.argv[2];
const bytes = fs.readFileSync(file);
if (bytes.length < 12) process.exit(1);
const tag = bytes.subarray(0, 4).toString('latin1');
const scalar = bytes.readUInt32BE(0);
const valid = tag === 'wOF2' || tag === 'OTTO' || tag === 'true' || tag === 'typ1' || scalar === 0x00010000;
if (!valid) process.exit(1);
NODE
}

texise_font_source() {
  local source_dir="$1"
  local preferred="$2"
  shift 2
  if [[ -f "${source_dir}/${preferred}" && ! -L "${source_dir}/${preferred}" ]]; then
    printf '%s' "${source_dir}/${preferred}"
    return 0
  fi
  local fallback
  for fallback in "$@"; do
    if [[ -f "${source_dir}/${fallback}" && ! -L "${source_dir}/${fallback}" ]]; then
      printf '%s' "${source_dir}/${fallback}"
      return 0
    fi
  done
  return 1
}

texise_install_browser_fonts() {
  local configured_source=''
  if [[ -r "${TEXISE_CONFIG_DIR}/texise.env" ]]; then
    configured_source="$(texise_env_value "${TEXISE_CONFIG_DIR}/texise.env" TEXISE_FONT_SOURCE_DIR)"
  fi
  local source_dir="${configured_source:-${TEXISE_FONT_SOURCE_DIR}}"
  local target_dir="${TEXISE_DIST_DIR}/texise-fonts"
  [[ -d "${source_dir}" ]] || texise_die \
    "Required external font directory is missing: ${source_dir}. Restore the TeXise font set beside public_html before installing."

  local inter jetbrains moranga_light moranga_regular moranga_italic moranga_bold stix_normal stix_italic
  inter="$(texise_font_source "${source_dir}" 'Inter-Variable.woff2')" || texise_die "Missing required font: ${source_dir}/Inter-Variable.woff2"
  jetbrains="$(texise_font_source "${source_dir}" 'JetBrainsMono-Variable.woff2')" || texise_die "Missing required font: ${source_dir}/JetBrainsMono-Variable.woff2"
  moranga_light="$(texise_font_source "${source_dir}" 'moranga-light.otf' 'Moranga-Light.otf')" || texise_die "Missing required font: ${source_dir}/moranga-light.otf"
  moranga_regular="$(texise_font_source "${source_dir}" 'Moranga-Regular.otf' 'Latinotype - Moranga Regular.otf')" || texise_die "Missing required font: ${source_dir}/Moranga-Regular.otf"
  moranga_italic="$(texise_font_source "${source_dir}" 'moranga-regular-italic.otf' 'Moranga-RegularItalic.otf')" || texise_die "Missing required font: ${source_dir}/moranga-regular-italic.otf"
  moranga_bold="$(texise_font_source "${source_dir}" 'moranga-bold.otf' 'Moranga-Bold.otf')" || texise_die "Missing required font: ${source_dir}/moranga-bold.otf"
  stix_normal="$(texise_font_source "${source_dir}" 'STIXTwoText-VariableFont.ttf' 'STIXTwoText-VariableFont_wght.ttf')" || texise_die "Missing required font: ${source_dir}/STIXTwoText-VariableFont.ttf"
  stix_italic="$(texise_font_source "${source_dir}" 'STIXTwoText-Italic-Variable.ttf' 'STIXTwoText-Italic-VariableFont_wght.ttf')" || texise_die "Missing required font: ${source_dir}/STIXTwoText-Italic-Variable.ttf"

  local source_font
  for source_font in "${inter}" "${jetbrains}" "${moranga_light}" "${moranga_regular}" "${moranga_italic}" "${moranga_bold}" "${stix_normal}" "${stix_italic}"; do
    texise_validate_font_file "${source_font}"
  done

  install -d -o root -g root -m 0755 "${target_dir}"
  install -o root -g root -m 0644 "${inter}" "${target_dir}/Inter-Variable.woff2"
  install -o root -g root -m 0644 "${jetbrains}" "${target_dir}/JetBrainsMono-Variable.woff2"
  install -o root -g root -m 0644 "${moranga_light}" "${target_dir}/Moranga-Light.otf"
  install -o root -g root -m 0644 "${moranga_regular}" "${target_dir}/Moranga-Regular.otf"
  install -o root -g root -m 0644 "${moranga_italic}" "${target_dir}/Moranga-RegularItalic.otf"
  install -o root -g root -m 0644 "${moranga_bold}" "${target_dir}/Moranga-Bold.otf"
  install -o root -g root -m 0644 "${stix_normal}" "${target_dir}/STIXTwoText-VariableFont.ttf"
  install -o root -g root -m 0644 "${stix_italic}" "${target_dir}/STIXTwoText-Italic-Variable.ttf"

  local deployed_font
  for deployed_font in \
    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; do
    texise_validate_font_file "${target_dir}/${deployed_font}"
  done
  texise_log "Installed and verified all 8 TeXise browser fonts from ${source_dir}."
}

texise_install_dependencies() {
  texise_log 'Installing repository dependencies from the pinned lockfile'
  (
    cd "${TEXISE_SOURCE_ROOT}"
    node scripts/verify-pnpm-lock-sync.mjs
    node scripts/verify-workspace-dependency-versions.mjs
    node scripts/verify-workspace-public-barrels.mjs
    texise_pnpm install --frozen-lockfile
  )
}

texise_validate_production_dist() {
  local required_file required_dir
  for required_file in \
    index.html \
    site.webmanifest \
    manifest.webmanifest \
    favicon.svg \
    favicon-96x96.png \
    favicon.ico \
    apple-touch-icon.png \
    web-app-manifest-192x192.png \
    web-app-manifest-512x512.png \
    learnitive-texlive-sw.js \
    texise-icons/app-icon-180.png \
    texise-icons/app-icon-192.png \
    texise-icons/app-icon-512.png \
    texise-icons/lucide-category-icons.svg \
    texise-icons/lucide-category-icons.json \
    texise-icons/lucide/file-text.svg \
    compiler/swiftlatex-r3-v1/swiftlatexpdftex.js \
    compiler/swiftlatex-r3-v1/swiftlatexpdftex.wasm \
    compiler/status.json \
    release.json \
    release/sbom.spdx.json \
    release/provenance.json \
    release/SHA256SUMS \
    texise-templates/templates.json \
    texise-templates/categories.json \
    texise-fonts/Inter-Variable.woff2 \
    texise-fonts/JetBrainsMono-Variable.woff2 \
    texise-fonts/Moranga-Light.otf \
    texise-fonts/Moranga-Regular.otf \
    texise-fonts/Moranga-RegularItalic.otf \
    texise-fonts/Moranga-Bold.otf \
    texise-fonts/STIXTwoText-VariableFont.ttf \
    texise-fonts/STIXTwoText-Italic-Variable.ttf \
    texlive/bundle.json \
    texlive/cache-manifest-text.txt \
    texlive/cache-manifest-binary.txt \
    texlive/cache/cm-super-ts1.enc \
    texlive/cache/cm-super-ts1.map; do
    [[ -f "${TEXISE_DIST_DIR}/${required_file}" ]] || texise_die \
      "Production browser build is missing ${TEXISE_DIST_DIR}/${required_file}"
  done
  for required_dir in assets locales mathlive-fonts texlive/cache; do
    [[ -d "${TEXISE_DIST_DIR}/${required_dir}" ]] || texise_die \
      "Production browser build is missing ${TEXISE_DIST_DIR}/${required_dir}/"
  done
  if [[ "$(texise_compiler_profile)" == "full" ]]; then
    local busytex_required
    for busytex_required in \
      index.js \
      busytex_pipeline.js \
      busytex_worker.js \
      busytex.js \
      busytex.wasm \
      texlive-basic.js \
      texlive-basic.data \
      texlive-recommended.js \
      texlive-recommended.data \
      texlive-extra.js \
      texlive-extra.data; do
      [[ -f "${TEXISE_DIST_DIR}/compiler/busytex/${busytex_required}" ]] || texise_die \
        "Full compiler profile is missing dist/compiler/busytex/${busytex_required}"
    done
    node "${TEXISE_SOURCE_ROOT}/scripts/patch-busytex-runtime.mjs" \
      "${TEXISE_DIST_DIR}/compiler/busytex" || texise_die \
      'Production BusyTeX runtime does not satisfy the TeXise v1.4 Biber ABI contract.'
    if [[ "${TEXISE_DEPLOY_CERTIFY}" == "1" ]]; then
      [[ -f "${TEXISE_DIST_DIR}/release/certification.json" ]] || texise_die \
        'Certified deploy is missing dist/release/certification.json; pnpm run release:quality must pass before activation.'
      node - "${TEXISE_DIST_DIR}/release.json" "${TEXISE_DIST_DIR}/release/certification.json" <<'NODE' || texise_die 'Runtime browser certification does not match the production release.'
const fs = require('node:fs');
const release = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const certification = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
const browsers = new Set(certification.browsers || []);
if (certification.status !== 'passed' || certification.certificationLevel !== 'production-browser') process.exit(1);
if (!release.releaseId || certification.releaseId !== release.releaseId) process.exit(1);
if (!["edge", "chromium", "firefox", "mobile-chromium", "mobile-webkit"].every((browser) => browsers.has(browser))) process.exit(1);
NODE
    fi
  fi
  [[ ! -e "${TEXISE_DIST_DIR}/templates" ]] || texise_die     'Production dist contains legacy dist/templates, which would shadow the /templates SPA route.'
  [[ ! -e "${TEXISE_TEMPLATE_PUBLIC_COPY}/free" && ! -e "${TEXISE_TEMPLATE_PUBLIC_COPY}/pro" ]] || texise_die \
    'Production template catalog contains raw free/pro source trees; only generated catalog JSON may be public.'
  local entitlement_key_id
  entitlement_key_id="$(node -e 'const fs=require("node:fs");const c=require("node:crypto");const p=fs.readFileSync(process.argv[1],"utf8");const der=c.createPublicKey(p).export({type:"spki",format:"der"});process.stdout.write("ed25519:"+c.createHash("sha256").update(der).digest("hex").slice(0,16));' "${TEXISE_CONFIG_DIR}/entitlement-public.pem")"
  grep -R --fixed-strings --quiet "${entitlement_key_id}" "${TEXISE_DIST_DIR}/assets" || texise_die \
    'Production browser build does not contain the pinned entitlement key identifier.'
}

texise_migrate_legacy_template_namespace() {
  local canonical_root="${TEXISE_SOURCE_ROOT}/texise-templates"
  local canonical_public="${TEXISE_SOURCE_ROOT}/packages/tooling/browser-entry/public/texise-templates"
  [[ -d "${canonical_root}" ]] || texise_die "Canonical template source is missing: ${canonical_root}"
  [[ -d "${canonical_public}" ]] || texise_die "Canonical browser template source is missing: ${canonical_public}"

  local legacy
  for legacy in \
    "${TEXISE_SOURCE_ROOT}/templates" \
    "${TEXISE_SOURCE_ROOT}/packages/tooling/browser-entry/public/templates"; do
    if [[ -L "${legacy}" ]]; then
      texise_die "Refusing to remove symlinked legacy template namespace: ${legacy}"
    fi
    if [[ -e "${legacy}" ]]; then
      [[ -d "${legacy}" ]] || texise_die "Legacy template namespace is not a directory: ${legacy}"
      texise_log "Removing legacy template namespace ${legacy}; canonical assets live under texise-templates/."
      rm -rf -- "${legacy}"
    fi
  done
}

texise_publish_indexnow_key_file() {
  local key
  key="$(texise_env_scalar_value "${TEXISE_CONFIG_DIR}/texise.env" TEXISE_INDEXNOW_KEY)"
  [[ "${key}" =~ ^[A-Za-z0-9-]{8,128}$ ]] || texise_die 'Cannot publish invalid TEXISE_INDEXNOW_KEY.'
  printf '%s' "${key}" > "${TEXISE_DIST_DIR}/${key}.txt"
  chmod 0644 "${TEXISE_DIST_DIR}/${key}.txt"
  texise_log "Published IndexNow ownership file /${key}.txt."
}

texise_build_frontend() {
  texise_log "Building browser application into ${TEXISE_DIST_DIR}"
  (
    cd "${TEXISE_SOURCE_ROOT}"
    # Compiler provisioning is deployment configuration. Source the trusted,
    # root-owned env file so preserved /etc/texise settings actually control
    # BusyTeX assets and TeX Live remote resolution.
    set -a
    # shellcheck disable=SC1090
    source "${TEXISE_CONFIG_DIR}/texise.env"
    set +a
    export TEXISE_ENTITLEMENT_PUBLIC_KEY_PEM
    TEXISE_ENTITLEMENT_PUBLIC_KEY_PEM="$(cat "${TEXISE_CONFIG_DIR}/entitlement-public.pem")"
    export TEXISE_ENTITLEMENT_KEY_ID
    TEXISE_ENTITLEMENT_KEY_ID="$(node -e 'const fs=require("node:fs");const c=require("node:crypto");const p=fs.readFileSync(process.argv[1],"utf8");const der=c.createPublicKey(p).export({type:"spki",format:"der"});process.stdout.write("ed25519:"+c.createHash("sha256").update(der).digest("hex").slice(0,16));' "${TEXISE_CONFIG_DIR}/entitlement-public.pem")"
    [[ -n "${TEXISE_ENTITLEMENT_PUBLIC_KEY_PEM}" ]] || texise_die 'Unable to load entitlement public key for browser trust pinning.'
    [[ -n "${TEXISE_ENTITLEMENT_KEY_ID}" ]] || texise_die 'Unable to derive entitlement key identifier.'
    export TEXISE_BUSY_COMPILER_CACHE_ABI="${TEXISE_BUSY_COMPILER_CACHE_ABI:-v14-biber-abi2}"
    export TEXISE_BUSY_COMPILER_CACHE_DIR="${TEXISE_BUSY_COMPILER_CACHE_DIR:-${TEXISE_COMPILER_CACHE_DIR}/busytex-${TEXISE_BUSY_COMPILER_VERSION:-1.4.0}-${TEXISE_BUSY_COMPILER_CACHE_ABI}}"

    # BF50.7: /var/www/texise.com/templates is the complete persistent,
    # Admin-authoritative library. Validate/index it first, then refresh the
    # replaceable source mirror used by static release guards and the build.
    texise_prepare_persistent_template_library
    TEXISE_TEMPLATE_ROOT="${TEXISE_TEMPLATE_ROOT}" \
      node scripts/generate-template-manifest.mjs \
        --root="${TEXISE_TEMPLATE_ROOT}" \
        --public-copy="${TEXISE_SOURCE_ROOT}/packages/tooling/browser-entry/public/texise-templates"
    texise_sync_persistent_template_source_mirror

    # Build Fix 11: repair/verify the one stale Build Fix 9 ABI-parser source
    # shape before any strict TypeScript gate runs. This is deliberately
    # narrow and idempotent so an overlaid upgrade cannot keep compiling the
    # old `return match[1]` implementation under noUncheckedIndexedAccess.
    node scripts/repair-buildfix11-source.mjs
    # Build Fix 48: verify BF47 plus institution logos, paginated template discovery, atomic template preflight and prompt-free Firefox storage before building.
    # Refuse partial overlays before lint/typecheck rather than mixing releases.
    node scripts/repair-buildfix50-source.mjs "$PWD"
    node scripts/repair-buildfix51-source.mjs "$PWD"
    node scripts/repair-buildfix51-1-source.mjs "$PWD"
    node scripts/repair-buildfix51-2-source.mjs "$PWD"
    node scripts/repair-buildfix51-3-source.mjs "$PWD"
    node scripts/repair-buildfix51-4-source.mjs "$PWD"
    # Remove the historical physical `templates/` namespace before Vite copies
    # public assets. Overlay upgrades do not delete renamed directories, and a
    # stale public/templates directory would recreate dist/templates and shadow
    # the `/templates` SPA route in Apache.
    texise_migrate_legacy_template_namespace
    # BF50.5: refresh generated Lucide SVG assets before lint. Overlay upgrades
    # can retain a previous generated lucide/ directory, and Biome scans those
    # files before the browser build would otherwise regenerate them.
    node scripts/generate-lucide-category-assets.mjs

    if [[ "${TEXISE_DEPLOY_CERTIFY}" == "1" ]]; then
      # Formal release certification remains available as an explicit deploy
      # mode. It runs the complete unit + desktop Edge/Chromium/Firefox and targeted mobile Chromium/WebKit matrix and
      # writes release/certification.json only after every gate succeeds.
      node scripts/run-release-quality.mjs
    else
      # Normal install/upgrade is a production build, not a CI rerun. Keep the
      # inexpensive source/type/static gates, provision the requested compiler
      # profile, and build the browser app without running ~3900 unit tests and
      # three Playwright browsers on every deployment.
      texise_pnpm run lint:ci
      if [[ "${TEXISE_COMPILER_PROFILE:-full}" == "full" ]]; then
        node scripts/fetch-busytex-assets.mjs
      else
        node scripts/fetch-busytex-assets.mjs --optional
      fi
      node scripts/generate-compiler-status.mjs
      node scripts/verify-compiler-profile.mjs
      node scripts/verify-template-compiler-requirements.mjs
      node scripts/learnitive-current-release-audit.mjs
      export TEXISE_APP_ENV=production
      texise_pnpm run build
    fi
    if [[ "${TEXISE_COMPILER_PROFILE:-full}" == "full" ]]; then
      # BusyTeX 1.4 runtime assets are copied into dist by Vite/release-quality.
      # Verify the actual deploy tree at the final boundary so an old v1.3-style
      # pipeline can never be activated beside the pinned v1.4 API module.
      node scripts/patch-busytex-runtime.mjs "${TEXISE_DIST_DIR}/compiler/busytex"
    fi
  )
  # Font binaries are supplied outside the redistributable source archive.
  # Install them only after Vite has finished replacing dist, otherwise an
  # administrator pre-copy is silently deleted by the build.
  texise_install_browser_fonts
  texise_publish_indexnow_key_file
  texise_validate_production_dist
  chmod -R a-w "${TEXISE_DIST_DIR}"
}


texise_saas_preflight_runtime() {
  local runtime_server="$1"
  if ! texise_saas_requested "${TEXISE_CONFIG_DIR}/texise.env"; then
    return 0
  fi

  [[ -f "${runtime_server}/saas-preflight.mjs" ]] || texise_die \
    "Staged trusted runtime is missing saas-preflight.mjs: ${runtime_server}"
  texise_require_command systemd-run
  texise_log 'Running SaaS MySQL/migrations/admin preflight before trusted-runtime activation.'
  local unit="texise-saas-preflight-$$-${RANDOM}"
  if ! systemd-run --quiet --wait --collect --pipe \
      --unit="${unit}" \
      --property=User=texise \
      --property=Group=texise \
      --property="WorkingDirectory=${runtime_server}" \
      --property="EnvironmentFile=${TEXISE_CONFIG_DIR}/texise.env" \
      --property=NoNewPrivileges=yes \
      /usr/bin/node "${runtime_server}/saas-preflight.mjs"; then
    texise_die 'SaaS preflight failed before activation. Verify MYSQL_HOST/MYSQL_PORT/MYSQL_DATABASE/MYSQL_USER/MYSQL_PASSWORD and database grants in /etc/texise/texise.env. Run `journalctl -u texise.service -n 100 --no-pager` for the current service and retry after correcting the database configuration.'
  fi
}

texise_activate_runtime_server() {
  local release_id
  release_id="$(node -e 'const fs=require("node:fs");const p=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));process.stdout.write(p.releaseId)' "${TEXISE_DIST_DIR}/release.json")"
  [[ -n "${release_id}" ]] || texise_die 'Production release metadata does not contain releaseId.'

  rm -rf "${TEXISE_RUNTIME_NEXT}"
  install -d -o root -g root -m 0755 "${TEXISE_RUNTIME_NEXT}/server"
  cp -a "${TEXISE_SOURCE_ROOT}/server/." "${TEXISE_RUNTIME_NEXT}/server/"
  # BF51.4 Fix2: public-comparison-registry is used by the trusted API for
  # SSR/sitemap/IndexNow metadata. Stage its canonical data beside the server
  # modules so the activated immutable runtime never reaches back into the
  # replaceable packages/ source tree.
  install -m 0644 \
    "${TEXISE_SOURCE_ROOT}/packages/core/app/src/saas/compare-content.json" \
    "${TEXISE_RUNTIME_NEXT}/server/compare-content.json"
  install -m 0644 \
    "${TEXISE_SOURCE_ROOT}/packages/core/app/src/saas/migration-content.json" \
    "${TEXISE_RUNTIME_NEXT}/server/migration-content.json"
  # BF44: prove the exact staged runtime is self-contained before activation.
  # Hotfix10's template library must not import build-only scripts that are not
  # part of the trusted server snapshot.
  TEXISE_SOURCE_ROOT="${TEXISE_SOURCE_ROOT}" \
    TEXISE_TEMPLATE_ROOT="${TEXISE_TEMPLATE_ROOT}" \
    TEXISE_TEMPLATE_PUBLIC_COPY="${TEXISE_TEMPLATE_PUBLIC_COPY}" \
    node "${TEXISE_SOURCE_ROOT}/scripts/check-staged-trusted-runtime.mjs" "${TEXISE_RUNTIME_NEXT}/server"
  # Keep the trusted API runtime independent from a future source-tree replacement.
  # SaaS deployments install the exact dependencies pinned in server/package.json
  # into the staged runtime; editor-only upgrades stage the root collaboration
  # runtime dependencies explicitly below without requiring a network install.
  # runtime before activation, so a failed install cannot corrupt current runtime.
  if texise_saas_requested "${TEXISE_CONFIG_DIR}/texise.env"; then
    texise_log 'Installing pinned trusted-server SaaS runtime dependencies.'
    (
      cd "${TEXISE_RUNTIME_NEXT}/server"
      npm install --omit=dev --ignore-scripts --no-audit --no-fund --no-package-lock
    )
    texise_saas_preflight_runtime "${TEXISE_RUNTIME_NEXT}/server"
  else
    # Collaboration now uses the standard Yjs sync/awareness protocol on the
    # trusted relay. Editor-only installs still avoid a network npm install,
    # but must stage the exact root dependencies required by signaling.mjs.
    install -d -o root -g root -m 0755 "${TEXISE_RUNTIME_NEXT}/server/node_modules"
    local runtime_dependency
    for runtime_dependency in ws lib0 isomorphic.js yjs y-protocols; do
      [[ -d "${TEXISE_SOURCE_ROOT}/node_modules/${runtime_dependency}" ]] || texise_die \
        "Trusted API runtime dependency ${runtime_dependency} is missing from node_modules."
      cp -aL \
        "${TEXISE_SOURCE_ROOT}/node_modules/${runtime_dependency}" \
        "${TEXISE_RUNTIME_NEXT}/server/node_modules/${runtime_dependency}"
    done
  fi
  {
    printf 'TEXISE_RELEASE_ID=%s\n' "${release_id}"
    printf 'TEXISE_SOURCE_ROOT=%s\n' "${TEXISE_SOURCE_ROOT}"
    printf 'TEXISE_TEMPLATE_ROOT=%s\n' "${TEXISE_TEMPLATE_ROOT}"
    printf 'TEXISE_TEMPLATE_PUBLIC_COPY=%s\n' "${TEXISE_TEMPLATE_PUBLIC_COPY}"
  } > "${TEXISE_RUNTIME_NEXT}/release.env"
  chmod 0644 "${TEXISE_RUNTIME_NEXT}/release.env"

  rm -rf "${TEXISE_RUNTIME_PREVIOUS}"
  if [[ -d "${TEXISE_RUNTIME_CURRENT}" ]]; then
    mv "${TEXISE_RUNTIME_CURRENT}" "${TEXISE_RUNTIME_PREVIOUS}"
  fi
  mv "${TEXISE_RUNTIME_NEXT}" "${TEXISE_RUNTIME_CURRENT}"
  chmod -R a-w "${TEXISE_RUNTIME_CURRENT}/server" "${TEXISE_RUNTIME_CURRENT}/release.env"
  texise_log "Activated trusted API runtime ${release_id}."
}

texise_restore_previous_runtime() {
  if [[ ! -d "${TEXISE_RUNTIME_PREVIOUS}" ]]; then
    texise_log 'No previous trusted API runtime is available for rollback.'
    return 1
  fi
  texise_log 'Restoring previous trusted API runtime after failed deployment health checks.'
  rm -rf "${TEXISE_RUNTIME_CURRENT}"
  mv "${TEXISE_RUNTIME_PREVIOUS}" "${TEXISE_RUNTIME_CURRENT}"
  systemctl restart texise.service >/dev/null 2>&1 || true
  return 0
}

texise_install_admin_cli() {
  install -o root -g root -m 0755 "${TEXISE_SOURCE_ROOT}/scripts/texise-license" /usr/local/bin/texise-license
  install -o root -g root -m 0755 "${TEXISE_SOURCE_ROOT}/scripts/texise-saas" /usr/local/bin/texise-saas
}

texise_quiesce_service_for_dependency_install() {
  local service_name="$1"
  if systemctl cat "${service_name}" >/dev/null 2>&1; then
    # Stop even a failed/auto-restarting unit. With Restart=always, checking only
    # is-active leaves a race window while systemd is between restart attempts.
    texise_log "Quiescing ${service_name} while its locked Node dependencies are replaced."
    systemctl stop "${service_name}" >/dev/null 2>&1 || true
  fi
}

texise_verify_execution_workspace_access() {
  local user_name="$1"
  local group_name="$2"
  local workspace="$3"
  local label="$4"
  local unit="texise-workspace-probe-${user_name//[^a-zA-Z0-9]/-}-$$-${RANDOM}"
  if ! systemd-run --quiet --wait --collect --pipe \
      --unit="${unit}" \
      --property="User=${user_name}" \
      --property="Group=${group_name}" \
      --setenv="TEXISE_WORKSPACE_PROBE=${workspace}" \
      /usr/bin/python3 -c 'import os; from pathlib import Path; root=Path(os.environ["TEXISE_WORKSPACE_PROBE"]); probe=root/(".texise-write-probe-"+str(os.getpid())); probe.write_bytes(b"ok"); probe.unlink()'; then
    texise_die "${label} service account ${user_name} cannot write its workspace ${workspace}. Check parent traversal ownership/modes before retrying."
  fi
}

texise_harden_service_node_modules() {
  local service_dir="$1"
  local dependency_marker="$2"
  local label="$3"
  [[ -f "${service_dir}/node_modules/${dependency_marker}/package.json" ]] || texise_die \
    "${label} dependency installation is incomplete: node_modules/${dependency_marker}/package.json is missing."
  chown -R root:root "${service_dir}/node_modules"
  # Preserve npm/native-module executable bits (notably node-pty's helper) while
  # removing group/other write access. Blanket chmod 0644 would break node-pty.
  chmod -R go-w "${service_dir}/node_modules"
  chmod 0755 "${service_dir}"
  chmod 0644 "${service_dir}/server.js" "${service_dir}/package.json" "${service_dir}/package-lock.json"
}

texise_ensure_docker_image() {
  local image="$1"
  local label="$2"
  [[ -n "${image}" ]] || texise_die "${label} Docker image is empty."
  if docker image inspect "${image}" >/dev/null 2>&1; then
    texise_log "${label} Docker image is available locally: ${image}"
    return 0
  fi
  texise_log "Pulling ${label} Docker image: ${image}"
  docker pull "${image}" >/dev/null || texise_die "Unable to pull ${label} Docker image ${image}. If it is private, authenticate Docker or preload the image before retrying."
  docker image inspect "${image}" >/dev/null 2>&1 || texise_die "${label} Docker image is still unavailable after pull: ${image}"
}

texise_ensure_console_image() {
  local image="$1"
  [[ -n "${image}" ]] || texise_die 'TexLab Docker image is empty.'

  # texlive/texlive-console:pro is a TeXise-local production image name, not a
  # Docker Hub repository. Build it reproducibly from the checked-in context.
  if [[ "${image}" == 'texlive/texlive-console:pro' ]]; then
    local current_schema=''
    if docker image inspect "${image}" >/dev/null 2>&1; then
      current_schema="$(docker image inspect --format '{{ index .Config.Labels "com.texise.image.schema" }}' "${image}" 2>/dev/null || true)"
      if [[ "${current_schema}" == "${TEXISE_CONSOLE_IMAGE_SCHEMA}" ]]; then
        texise_log "TexLab Docker image is already built and current: ${image} (${current_schema})"
        return 0
      fi
      texise_log "Rebuilding TexLab Docker image ${image}; installed schema=${current_schema:-unlabeled}, required=${TEXISE_CONSOLE_IMAGE_SCHEMA}."
    else
      texise_log "Building TeXise-local TexLab Docker image: ${image}"
    fi

    [[ -f "${TEXISE_CONSOLE_IMAGE_CONTEXT}/Dockerfile" ]] || texise_die       "TexLab Dockerfile is missing: ${TEXISE_CONSOLE_IMAGE_CONTEXT}/Dockerfile"
    [[ -f "${TEXISE_CONSOLE_IMAGE_CONTEXT}/requirements-scientific.txt" ]] || texise_die       "TexLab scientific requirements are missing: ${TEXISE_CONSOLE_IMAGE_CONTEXT}/requirements-scientific.txt"

    # The dedicated console image derives from the public TeX Live image but is
    # built as a distinct local tag containing the complete Pro toolchain.
    texise_ensure_docker_image 'texlive/texlive:latest' 'TexLab base'
    docker build       --pull=false       --build-arg BASE_IMAGE='texlive/texlive:latest'       --tag "${image}"       "${TEXISE_CONSOLE_IMAGE_CONTEXT}" || texise_die         "Unable to build TexLab Docker image ${image}. Inspect the Docker build output above."

    local built_schema
    built_schema="$(docker image inspect --format '{{ index .Config.Labels "com.texise.image.schema" }}' "${image}" 2>/dev/null || true)"
    [[ "${built_schema}" == "${TEXISE_CONSOLE_IMAGE_SCHEMA}" ]] || texise_die       "TexLab image ${image} built without the expected schema label ${TEXISE_CONSOLE_IMAGE_SCHEMA}."
    texise_log "TexLab Docker image built and verified locally: ${image} (${built_schema})"
    return 0
  fi

  # Operator-supplied custom image names remain external and may be preloaded
  # or pulled from an authenticated registry.
  texise_ensure_docker_image "${image}" 'TexLab'
}

texise_install_cloud_compile() {
  local trusted_env="${TEXISE_CONFIG_DIR}/texise.env"
  local legacy_env="/etc/texise-latex-compile.env"
  local worker_env="${TEXISE_CLOUD_COMPILE_ENV}"

  [[ -f "${TEXISE_CLOUD_COMPILE_DIR}/server.js" ]] || texise_die \
    "Cloud Compile source is missing: ${TEXISE_CLOUD_COMPILE_DIR}/server.js"
  [[ -f "${TEXISE_CLOUD_COMPILE_DIR}/package-lock.json" ]] || texise_die \
    "Cloud Compile lockfile is missing: ${TEXISE_CLOUD_COMPILE_DIR}/package-lock.json"

  if [[ ! -e "${worker_env}" ]]; then
    if [[ -r "${legacy_env}" ]]; then
      install -o root -g texise-compile -m 0640 "${legacy_env}" "${worker_env}"
      texise_log "Migrated the existing Cloud Compile environment from ${legacy_env}; the legacy file was preserved for rollback."
    else
      install -o root -g texise-compile -m 0640 "${TEXISE_SOURCE_ROOT}/deploy/config/cloud-compile.env.example" "${worker_env}"
    fi
  else
    chown root:texise-compile "${worker_env}"
    chmod 0640 "${worker_env}"
    texise_log "Preserving ${worker_env}"
  fi

  texise_ensure_env_default "${worker_env}" NODE_ENV 'production'
  texise_ensure_env_default "${worker_env}" BIND_HOST '127.0.0.1'
  texise_ensure_env_default "${worker_env}" PORT '3001'
  texise_ensure_env_default "${worker_env}" COMPILE_API_SECRET ''
  texise_ensure_env_default "${worker_env}" COMPILE_BACKEND 'docker'
  texise_ensure_env_default "${worker_env}" COMPILE_DOCKER_IMAGE 'texlive/texlive:latest-full'
  texise_ensure_env_default "${worker_env}" COMPILE_ALLOWED_ENGINES 'pdflatex,xelatex,lualatex'
  texise_ensure_env_default "${worker_env}" COMPILE_TIMEOUT_MS '120000'
  texise_ensure_env_default "${worker_env}" COMPILE_MAX_FILES '450'
  texise_ensure_env_default "${worker_env}" COMPILE_MAX_TOTAL_BYTES '25000000'
  texise_ensure_env_default "${worker_env}" COMPILE_MAX_OUTPUT_BYTES '60000000'
  texise_ensure_env_default "${worker_env}" COMPILE_CONCURRENCY '2'
  texise_ensure_env_default "${worker_env}" COMPILE_RATE_LIMIT_PER_MINUTE '60'
  texise_ensure_env_default "${worker_env}" COMPILE_CPU '1.5'
  texise_ensure_env_default "${worker_env}" COMPILE_MEMORY '1g'
  texise_ensure_env_default "${worker_env}" COMPILE_PIDS_LIMIT '256'
  texise_ensure_env_default "${worker_env}" COMPILE_WORK_DIR "${TEXISE_CLOUD_COMPILE_TMP}/jobs"
  texise_ensure_env_default "${worker_env}" COMPILE_JOB_TTL_MS '1800000'
  texise_ensure_env_default "${worker_env}" COMPILE_SCAVENGE_INTERVAL_MS '600000'
  texise_ensure_env_default "${worker_env}" COMPILE_AUTO_FALLBACK_XELATEX 'false'
  texise_ensure_env_default "${worker_env}" COMPILE_KEEP_FAILED 'false'
  texise_migrate_env_known_default "${worker_env}" COMPILE_WORK_DIR '/var/lib/texise-latex-compile/jobs' "${TEXISE_CLOUD_COMPILE_TMP}/jobs"
  texise_migrate_env_known_default "${worker_env}" COMPILE_DOCKER_IMAGE 'texise/console-texlive-full:latest' 'texlive/texlive:latest-full'
  texise_migrate_env_known_default "${worker_env}" COMPILE_DOCKER_IMAGE 'texlive/texlive:latest' 'texlive/texlive:latest-full'
  local cloud_image
  cloud_image="$(texise_env_value "${trusted_env}" TEXISE_CLOUD_COMPILE_IMAGE)"
  [[ -n "${cloud_image}" ]] || cloud_image='texlive/texlive:latest-full'
  texise_set_env_value "${trusted_env}" TEXISE_CLOUD_COMPILE_IMAGE "${cloud_image}"
  texise_set_env_value "${worker_env}" COMPILE_DOCKER_IMAGE "${cloud_image}"
  texise_ensure_docker_image "${cloud_image}" 'Cloud Compile'

  local trusted_secret worker_secret
  trusted_secret="$(texise_env_value "${trusted_env}" TEXISE_CLOUD_COMPILE_WORKER_SECRET)"
  worker_secret="$(texise_env_value "${worker_env}" COMPILE_API_SECRET)"
  if [[ -n "${trusted_secret}" && -n "${worker_secret}" && "${trusted_secret}" != "${worker_secret}" ]]; then
    texise_die 'Cloud Compile secret mismatch between /etc/texise/texise.env and /etc/texise/cloud-compile.env. Reconcile the existing operator values before upgrading.'
  fi
  if [[ -z "${trusted_secret}" && -z "${worker_secret}" ]]; then
    trusted_secret="$(openssl rand -hex 48)"
    worker_secret="${trusted_secret}"
    texise_log 'Generated a private trusted-API ↔ Cloud Compile worker secret.'
  elif [[ -z "${trusted_secret}" ]]; then
    trusted_secret="${worker_secret}"
  else
    worker_secret="${trusted_secret}"
  fi
  texise_set_env_value "${trusted_env}" TEXISE_CLOUD_COMPILE_WORKER_SECRET "${trusted_secret}"
  texise_set_env_value "${worker_env}" COMPILE_API_SECRET "${worker_secret}"
  chown root:texise "${trusted_env}"
  chmod 0640 "${trusted_env}"
  chown root:texise-compile "${worker_env}"
  chmod 0640 "${worker_env}"

  # The worker is an independently locked Node service. Stop an already-running
  # copy before npm ci replaces node_modules; Restart=always otherwise races the
  # install and can produce transient ERR_MODULE_NOT_FOUND crashes.
  texise_quiesce_service_for_dependency_install texise-latex-compile.service
  (
    cd "${TEXISE_CLOUD_COMPILE_DIR}"
    npm ci --omit=dev --ignore-scripts
  )
  texise_harden_service_node_modules "${TEXISE_CLOUD_COMPILE_DIR}" express 'Cloud Compile'
  texise_verify_execution_workspace_access texise-compile texise-compile "${TEXISE_CLOUD_COMPILE_TMP}/jobs" 'Cloud Compile'

  install -o root -g root -m 0644 "${TEXISE_SOURCE_ROOT}/deploy/systemd/texise-latex-compile.service" "${TEXISE_CLOUD_COMPILE_SYSTEMD_UNIT}"
}

texise_ensure_console_egress_network() {
  local network_name='learnitive-egress'
  local subnet='172.31.250.0/24'
  texise_require_command docker
  texise_require_command iptables
  if ! docker network inspect "${network_name}" >/dev/null 2>&1; then
    texise_log "Creating controlled Learnitive terminal egress network ${network_name}."
    docker network create --driver bridge --subnet "${subnet}" --opt com.docker.network.bridge.enable_icc=false "${network_name}" >/dev/null
  fi
  # All terminal egress traverses a dedicated chain. Block host/private/link-local
  # destinations before allowing only public web traffic, DNS and ICMP. This
  # keeps Git HTTPS/package registries/public APIs and ping useful while SSH,
  # metadata endpoints, RFC1918 and Learnitive internal networks fail closed.
  iptables -N LEARNITIVE-EGRESS 2>/dev/null || true
  iptables -F LEARNITIVE-EGRESS
  for cidr in 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4; do
    iptables -A LEARNITIVE-EGRESS -d "${cidr}" -j REJECT
  done
  iptables -A LEARNITIVE-EGRESS -p tcp -m multiport --dports 80,443 -j ACCEPT
  iptables -A LEARNITIVE-EGRESS -p udp --dport 53 -j ACCEPT
  iptables -A LEARNITIVE-EGRESS -p tcp --dport 53 -j ACCEPT
  iptables -A LEARNITIVE-EGRESS -p icmp -j ACCEPT
  iptables -A LEARNITIVE-EGRESS -j REJECT
  iptables -C DOCKER-USER -s "${subnet}" -j LEARNITIVE-EGRESS 2>/dev/null || iptables -I DOCKER-USER 1 -s "${subnet}" -j LEARNITIVE-EGRESS
}

texise_install_console_gateway() {
  local trusted_env="${TEXISE_CONFIG_DIR}/texise.env"
  local legacy_env="/opt/texise-console-gateway/.env"
  local gateway_env="${TEXISE_CONSOLE_ENV}"
  [[ -f "${TEXISE_CONSOLE_DIR}/server.js" ]] || texise_die "Console Gateway source is missing: ${TEXISE_CONSOLE_DIR}/server.js"
  [[ -f "${TEXISE_CONSOLE_DIR}/package-lock.json" ]] || texise_die "Console Gateway lockfile is missing: ${TEXISE_CONSOLE_DIR}/package-lock.json"
  if [[ ! -e "${gateway_env}" ]]; then
    if [[ -r "${legacy_env}" ]]; then
      install -o root -g texise-console -m 0640 "${legacy_env}" "${gateway_env}"
      texise_log "Migrated existing Console Gateway environment from ${legacy_env}; legacy file preserved."
    else
      install -o root -g texise-console -m 0640 "${TEXISE_SOURCE_ROOT}/deploy/config/console-gateway.env.example" "${gateway_env}"
    fi
  fi
  texise_ensure_env_default "${gateway_env}" NODE_ENV 'production'
  texise_ensure_env_default "${gateway_env}" BIND_HOST '127.0.0.1'
  texise_ensure_env_default "${gateway_env}" PORT '8787'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_GATEWAY_SECRET ''
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_IMAGE 'texlive/texlive-console:pro'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_DOCKER_BIN '/usr/bin/docker'
  texise_migrate_env_known_default "${gateway_env}" TEXISE_CONSOLE_NETWORK 'bridge' 'learnitive-egress'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_NETWORK 'learnitive-egress'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_MEMORY '2g'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_CPUS '2'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_PIDS_LIMIT '256'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_WORK_DIR "${TEXISE_CONSOLE_TMP}"
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_SESSION_SECONDS '14400'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_IDLE_SECONDS '1800'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_TOKEN_SECONDS '300'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_MAX_SESSIONS '8'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_MAX_SESSIONS_PER_USER '2'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_MAX_FILES '1000'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_MAX_UPLOAD_BYTES '50000000'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_MAX_DOWNLOAD_BYTES '100000000'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_MAX_WORKSPACE_BYTES '1000000000'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_RATE_LIMIT_PER_MINUTE '120'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_RATE_BUCKET_CAP '1000'
  texise_ensure_env_default "${gateway_env}" LEARNITIVE_CONSOLE_QUOTA_CHECK_SECONDS '5'

  local trusted_secret gateway_secret console_image
  trusted_secret="$(texise_env_value "${trusted_env}" LEARNITIVE_CONSOLE_GATEWAY_SECRET)"
  [[ -n "${trusted_secret}" ]] || trusted_secret="$(texise_env_value "${trusted_env}" TEXISE_CONSOLE_GATEWAY_SECRET)"
  gateway_secret="$(texise_env_value "${gateway_env}" LEARNITIVE_CONSOLE_GATEWAY_SECRET)"
  [[ -n "${gateway_secret}" ]] || gateway_secret="$(texise_env_value "${gateway_env}" TEXISE_CONSOLE_GATEWAY_SECRET)"
  if [[ -n "${trusted_secret}" && -n "${gateway_secret}" && "${trusted_secret}" != "${gateway_secret}" ]]; then
    texise_die 'Console Gateway secret mismatch between trusted API and gateway environment.'
  fi
  if [[ -z "${trusted_secret}" && -z "${gateway_secret}" ]]; then
    trusted_secret="$(openssl rand -hex 48)"; gateway_secret="${trusted_secret}"
    texise_log 'Generated a private trusted-API ↔ Console Gateway secret.'
  elif [[ -z "${trusted_secret}" ]]; then trusted_secret="${gateway_secret}"; else gateway_secret="${trusted_secret}"; fi
  console_image="$(texise_env_value "${trusted_env}" LEARNITIVE_CONSOLE_IMAGE)"
  [[ -n "${console_image}" ]] || console_image="$(texise_env_value "${trusted_env}" TEXISE_CONSOLE_IMAGE)"
  [[ -n "${console_image}" ]] || console_image='texlive/texlive-console:pro'
  # Write both names while older trusted APIs/gateways may still exist during a rolling migration.
  texise_set_env_value "${trusted_env}" LEARNITIVE_CONSOLE_GATEWAY_SECRET "${trusted_secret}"
  texise_set_env_value "${trusted_env}" TEXISE_CONSOLE_GATEWAY_SECRET "${trusted_secret}"
  texise_set_env_value "${gateway_env}" LEARNITIVE_CONSOLE_GATEWAY_SECRET "${gateway_secret}"
  texise_set_env_value "${gateway_env}" TEXISE_CONSOLE_GATEWAY_SECRET "${gateway_secret}"
  texise_set_env_value "${gateway_env}" LEARNITIVE_CONSOLE_IMAGE "${console_image}"
  texise_set_env_value "${gateway_env}" TEXISE_CONSOLE_IMAGE "${console_image}"
  sed -i '/^TEXISE_EXECUTION_IMAGE=/d' "${gateway_env}"
  texise_ensure_console_image "${console_image}"
  texise_ensure_console_egress_network
  chown root:texise "${trusted_env}"; chmod 0640 "${trusted_env}"
  chown root:texise-console "${gateway_env}"; chmod 0640 "${gateway_env}"
  # node-pty includes native/executable artifacts, so preserve npm's executable
  # modes. Quiesce an existing gateway first to avoid Restart=always racing npm ci.
  texise_quiesce_service_for_dependency_install texise-console-gateway.service
  (
    cd "${TEXISE_CONSOLE_DIR}"
    npm ci --omit=dev
  )
  texise_harden_service_node_modules "${TEXISE_CONSOLE_DIR}" node-pty 'Console Gateway'
  texise_verify_execution_workspace_access texise-console texise-console "${TEXISE_CONSOLE_TMP}" 'Console Gateway'
  install -o root -g root -m 0644 "${TEXISE_SOURCE_ROOT}/deploy/systemd/texise-console-gateway.service" "${TEXISE_CONSOLE_SYSTEMD_UNIT}"
}

texise_install_systemd() {
  install -o root -g root -m 0644 "${TEXISE_SOURCE_ROOT}/deploy/systemd/texise.service" "${TEXISE_SYSTEMD_UNIT}"
  texise_install_cloud_compile
  texise_install_console_gateway
  systemctl daemon-reload
  systemctl enable texise.service >/dev/null
  systemctl enable texise-latex-compile.service >/dev/null
  systemctl enable texise-console-gateway.service >/dev/null
}

texise_retire_legacy_services() {
  local service
  for service in texise-collab.service texise-reverb.service; do
    if systemctl cat "${service}" >/dev/null 2>&1; then
      if systemctl is-enabled "${service}" >/dev/null 2>&1 || systemctl is-active "${service}" >/dev/null 2>&1; then
        texise_log "Retiring legacy ${service}; its unit file and source tree are preserved for rollback."
        systemctl disable --now "${service}" >/dev/null 2>&1 || texise_log "Warning: ${service} could not be stopped/disabled automatically; inspect it manually."
      else
        texise_log "Legacy ${service} is already inactive; preserving its unit/source as rollback material."
      fi
    fi
  done
}

texise_certificate_covers_host() {
  local cert="$1"
  local host="$2"
  [[ -r "${cert}" ]] || return 1
  local host_re="${host//./\\.}"
  openssl x509 -in "${cert}" -noout -ext subjectAltName 2>/dev/null | \
    grep -Eq "DNS:${host_re}([,[:space:]]|$)"
}

texise_find_certificate_for_host() {
  local host="$1"
  local cert key
  for cert in /etc/letsencrypt/live/*/fullchain.pem; do
    [[ -r "${cert}" ]] || continue
    key="$(dirname "${cert}")/privkey.pem"
    [[ -r "${key}" ]] || continue
    if texise_certificate_covers_host "${cert}" "${host}"; then
      printf '%s|%s\n' "${cert}" "${key}"
      return 0
    fi
  done
  return 1
}

texise_install_apache() {
  local primary_cert="/etc/letsencrypt/live/texise.com/fullchain.pem"
  local primary_key="/etc/letsencrypt/live/texise.com/privkey.pem"
  local primary_https=0 app_https=0 app_pair='' app_cert='' app_key=''
  local main_stage="${TEXISE_TMP_DIR}/texise.com.conf.next"
  local app_stage="${TEXISE_TMP_DIR}/app.texise.com-redirect.conf.next"

  cat "${TEXISE_SOURCE_ROOT}/deploy/apache/texise.com-http.conf" > "${main_stage}"
  if [[ -r "${primary_cert}" && -r "${primary_key}" ]]; then
    texise_certificate_covers_host "${primary_cert}" texise.com || texise_die \
      "Primary TLS certificate ${primary_cert} does not contain texise.com."
    texise_certificate_covers_host "${primary_cert}" www.texise.com || texise_die \
      "Primary TLS certificate ${primary_cert} does not contain www.texise.com."
    printf '\n' >> "${main_stage}"
    cat "${TEXISE_SOURCE_ROOT}/deploy/apache/texise.com-https.conf" >> "${main_stage}"
    primary_https=1
  else
    texise_log 'Primary texise.com certificate is not readable; installing the direct HTTP→www redirect only. Production certification requires TLS.'
  fi

  cat "${TEXISE_SOURCE_ROOT}/deploy/apache/app.texise.com-redirect-http.conf" > "${app_stage}"
  if app_pair="$(texise_find_certificate_for_host app.texise.com)"; then
    app_cert="${app_pair%%|*}"
    app_key="${app_pair#*|}"
    printf '\n' >> "${app_stage}"
    sed -e "s|__TEXISE_APP_CERT__|${app_cert}|g" -e "s|__TEXISE_APP_KEY__|${app_key}|g" \
      "${TEXISE_SOURCE_ROOT}/deploy/apache/app.texise.com-redirect-https.conf" >> "${app_stage}"
    app_https=1
    texise_log "Discovered an existing app.texise.com TLS certificate at ${app_cert}; the legacy hostname will be redirect-only."
  else
    texise_log 'No certificate covering app.texise.com was discovered. HTTP compatibility redirect will be installed; an HTTPS redirect cannot be certified safely without a matching certificate.'
  fi

  install -o root -g root -m 0644 "${main_stage}" "${TEXISE_APACHE_SITE}"
  install -o root -g root -m 0644 "${app_stage}" "${TEXISE_APP_REDIRECT_SITE}"
  rm -f "${main_stage}" "${app_stage}"

  a2enmod rewrite proxy proxy_http proxy_wstunnel headers mime alias dir >/dev/null
  if (( primary_https || app_https )); then
    a2enmod ssl >/dev/null
  fi

  # Retire old vhosts that can still win name-based virtual-host selection.
  # In particular, Certbot's historical texise.com-le-ssl.conf serves the
  # source root (/var/www/texise.com/public_html) rather than dist. If it is
  # left enabled alongside the canonical vhost, www.texise.com:443 can be
  # handled by that older vhost and return 403 because the source root has no
  # public directory index. Preserve all files/certificates for rollback; only
  # disable the conflicting enabled-site symlinks.
  local legacy_site
  for legacy_site in \
    texise.com-le-ssl.conf \
    app.texise.com.conf \
    app.texise.com-le-ssl.conf; do
    if [[ -e "/etc/apache2/sites-enabled/${legacy_site}" ]]; then
      texise_log "Disabling legacy/conflicting Apache site ${legacy_site}."
      a2dissite "${legacy_site}" >/dev/null
    fi
  done
  a2ensite "$(basename "${TEXISE_APACHE_SITE}")" >/dev/null
  a2ensite "$(basename "${TEXISE_APP_REDIRECT_SITE}")" >/dev/null
  apache2ctl configtest
}


texise_copy_verify_tree() {
  local source_dir="$1"
  local destination_dir="$2"
  [[ -d "${source_dir}" ]] || return 0
  install -d -o texise -g texise -m 0755 "${destination_dir}"
  local source_file relative destination_file destination_parent
  while IFS= read -r -d '' source_file; do
    relative="${source_file#${source_dir}/}"
    destination_file="${destination_dir}/${relative}"
    destination_parent="$(dirname "${destination_file}")"
    install -d -o texise -g texise -m 0755 "${destination_parent}"
    if [[ -e "${destination_file}" ]]; then
      cmp -s "${source_file}" "${destination_file}" || texise_die \
        "Legacy media migration conflict: ${destination_file} already exists with different bytes."
    else
      cp -a "${source_file}" "${destination_file}"
      chown texise:texise "${destination_file}"
    fi
    cmp -s "${source_file}" "${destination_file}" || texise_die \
      "Legacy media migration verification failed for ${relative}."
  done < <(find "${source_dir}" -type f -print0)
}

texise_migrate_legacy_server_storage() {
  local env_file="${TEXISE_CONFIG_DIR}/texise.env"
  local configured_db
  configured_db="$(texise_env_value "${env_file}" TEXISE_AI_REVIEW_AUDIT_DB)"
  local new_db="${TEXISE_AUDIT_DIR}/ai-review-audit.sqlite3"

  # Freeze the old runtime at the migration boundary so SQLite/media cannot
  # change while they are being copied into the public_html-owned data tree.
  systemctl stop texise.service >/dev/null 2>&1 || true

  if [[ "${configured_db}" == "${new_db}" || "${configured_db}" == "/var/www/app.texise.com/public_html/texise-data/audit/ai-review-audit.sqlite3" ]]; then
    local old_db suffix
    for old_db in "${TEXISE_CONFIG_DIR}/ai-review-audit.sqlite3" "${TEXISE_LEGACY_DATA_DIR}/ai-review-audit.sqlite3"; do
      [[ -e "${old_db}" ]] || continue
      if [[ ! -e "${new_db}" ]]; then
        install -d -o texise -g texise -m 0750 "${TEXISE_AUDIT_DIR}"
        cp -a "${old_db}" "${new_db}"
        chown texise:texise "${new_db}"
        chmod 0640 "${new_db}"
        cmp -s "${old_db}" "${new_db}" || texise_die "AI Review audit DB migration verification failed: ${old_db}"
        for suffix in -wal -shm; do
          if [[ -e "${old_db}${suffix}" ]]; then
            cp -a "${old_db}${suffix}" "${new_db}${suffix}"
            chown texise:texise "${new_db}${suffix}"
            chmod 0640 "${new_db}${suffix}"
          fi
        done
        texise_log "Migrated AI Review audit DB into ${new_db}."
      elif ! cmp -s "${old_db}" "${new_db}"; then
        texise_log "Legacy AI Review DB ${old_db} differs from the active ${new_db}; preserving both and leaving the active public_html DB authoritative."
      fi
    done
  fi

  if [[ -d /var/www/app.texise.com/public_html/texise-media/blog ]]; then
    texise_log "Migrating legacy Blog media into ${TEXISE_BLOG_MEDIA_DIR}."
    texise_copy_verify_tree /var/www/app.texise.com/public_html/texise-media/blog "${TEXISE_BLOG_MEDIA_DIR}"
    texise_log 'Legacy Blog media migration verified byte-for-byte.'
  fi
}

texise_cleanup_legacy_server_storage() {
  # The whole /var/www/app.texise.com tree remains rollback material after the
  # www migration. Never delete it automatically. Historical /var/lib paths may
  # be removed only when their active copies have already been verified.
  texise_log 'Preserving /var/www/app.texise.com as rollback material; no legacy app-root files were deleted.'
  local old_db="${TEXISE_LEGACY_DATA_DIR}/ai-review-audit.sqlite3"
  local new_db="${TEXISE_AUDIT_DIR}/ai-review-audit.sqlite3"
  if [[ -f "${old_db}" && -f "${new_db}" ]] && cmp -s "${old_db}" "${new_db}"; then
    rm -f "${old_db}" "${old_db}-wal" "${old_db}-shm"
  fi
  rmdir "${TEXISE_LEGACY_DATA_DIR}" >/dev/null 2>&1 || true
  if [[ -d "${TEXISE_CACHE_DIR}/console-science" ]]; then
    rm -rf "${TEXISE_CACHE_DIR}/console-science"
    texise_log 'Removed retired host-side Linux Terminal scientific overlay cache.'
  fi
}

texise_restart_services() {
  texise_migrate_legacy_server_storage
  systemctl restart texise-latex-compile.service
  systemctl restart texise-console-gateway.service
  systemctl restart texise.service
  systemctl reload apache2
}

texise_restore_previous_dist() {
  if [[ ! -d "${TEXISE_PREVIOUS_DIST_DIR}" ]]; then
    texise_log 'No previous production dist is available for frontend rollback.'
    return 1
  fi

  texise_log 'Restoring the previous production dist after failed deployment health checks.'
  rm -rf "${TEXISE_DIST_DIR}"
  mv "${TEXISE_PREVIOUS_DIST_DIR}" "${TEXISE_DIST_DIR}"
  systemctl reload apache2 >/dev/null 2>&1 || true
  return 0
}

texise_api_health_check() {
  local url="http://127.0.0.1:3100/api/health"
  local attempts=20

  for ((i = 1; i <= attempts; i += 1)); do
    if curl --fail --silent "${url}" >/dev/null 2>&1; then
      texise_log 'Trusted API health check passed.'
      return 0
    fi
    sleep 0.25
  done

  texise_log 'Trusted API failed to become healthy on 127.0.0.1:3100.' >&2
  curl --fail --silent --show-error "${url}" >/dev/null || true
  texise_log 'Recent texise.service journal follows:' >&2
  journalctl -u texise.service -n 80 --no-pager -o cat >&2 || true
  return 1
}

texise_frontend_font_signature_check() {
  local path="$1"
  local cert="/etc/letsencrypt/live/texise.com/fullchain.pem"
  local key="/etc/letsencrypt/live/texise.com/privkey.pem"
  local curl_args=(--fail --silent --show-error --range 0-3)
  if [[ -r "${cert}" && -r "${key}" ]]; then
    curl "${curl_args[@]}" --insecure --resolve www.texise.com:443:127.0.0.1 \
      "https://www.texise.com${path}" | node -e 'let chunks=[];process.stdin.on("data",c=>chunks.push(c)).on("end",()=>{const b=Buffer.concat(chunks);if(b.length<4)process.exit(1);const tag=b.subarray(0,4).toString("latin1");const scalar=b.readUInt32BE(0);if(!(tag==="wOF2"||tag==="OTTO"||tag==="true"||tag==="typ1"||scalar===0x00010000))process.exit(1)})'
  else
    curl "${curl_args[@]}" --resolve www.texise.com:80:127.0.0.1 \
      "http://www.texise.com${path}" | node -e 'let chunks=[];process.stdin.on("data",c=>chunks.push(c)).on("end",()=>{const b=Buffer.concat(chunks);if(b.length<4)process.exit(1);const tag=b.subarray(0,4).toString("latin1");const scalar=b.readUInt32BE(0);if(!(tag==="wOF2"||tag==="OTTO"||tag==="true"||tag==="typ1"||scalar===0x00010000))process.exit(1)})'
  fi
}

texise_frontend_request() {
  local path="$1"
  local cert="/etc/letsencrypt/live/texise.com/fullchain.pem"
  local key="/etc/letsencrypt/live/texise.com/privkey.pem"

  if [[ -r "${cert}" && -r "${key}" ]]; then
    curl --fail --insecure --silent --show-error \
      --resolve www.texise.com:443:127.0.0.1 \
      "https://www.texise.com${path}" >/dev/null
  else
    curl --fail --silent --show-error \
      --resolve www.texise.com:80:127.0.0.1 \
      "http://www.texise.com${path}" >/dev/null
  fi
}


texise_frontend_failure_diagnostics() {
  texise_log 'Apache virtual-host diagnostics follow:' >&2
  apache2ctl -S >&2 || true
  if compgen -G '/etc/apache2/sites-enabled/*texise*' >/dev/null; then
    texise_log 'Enabled TeXise Apache site links:' >&2
    ls -l /etc/apache2/sites-enabled/*texise* >&2 || true
  fi
  texise_log 'TeXise frontend route diagnostics follow:' >&2
  if [[ -f "${TEXISE_DIST_DIR}/index.html" ]]; then
    ls -l "${TEXISE_DIST_DIR}/index.html" >&2 || true
  else
    texise_log "Missing production shell: ${TEXISE_DIST_DIR}/index.html" >&2
  fi
  local cert="/etc/letsencrypt/live/texise.com/fullchain.pem"
  local key="/etc/letsencrypt/live/texise.com/privkey.pem"
  local probe_path
  for probe_path in / /index.html /about; do
    if [[ -r "${cert}" && -r "${key}" ]]; then
      curl --insecure --silent --show-error --output /dev/null --write-out "${probe_path} -> HTTP %{http_code} (%{url_effective})\n" \
        --resolve www.texise.com:443:127.0.0.1 "https://www.texise.com${probe_path}" >&2 || true
    else
      curl --silent --show-error --output /dev/null --write-out "${probe_path} -> HTTP %{http_code} (%{url_effective})\n" \
        --resolve www.texise.com:80:127.0.0.1 "http://www.texise.com${probe_path}" >&2 || true
    fi
  done
}


texise_frontend_head_request() {
  local path="$1"
  local cert="/etc/letsencrypt/live/texise.com/fullchain.pem"
  local key="/etc/letsencrypt/live/texise.com/privkey.pem"

  if [[ -r "${cert}" && -r "${key}" ]]; then
    curl --fail --insecure --silent --show-error --head \
      --resolve www.texise.com:443:127.0.0.1 \
      "https://www.texise.com${path}" >/dev/null
  else
    curl --fail --silent --show-error --head \
      --resolve www.texise.com:80:127.0.0.1 \
      "http://www.texise.com${path}" >/dev/null
  fi
}

texise_frontend_static_asset_health_check() {
  local path="$1"
  local content_type_regex="$2"
  local signature_kind="$3"
  local user_agent="${4:-TeXiseHealth/1.0}"
  local cert="/etc/letsencrypt/live/texise.com/fullchain.pem"
  local key="/etc/letsencrypt/live/texise.com/privkey.pem"
  local headers body
  headers="$(mktemp)" || return 1
  body="$(mktemp)" || { rm -f -- "${headers}"; return 1; }

  if [[ -r "${cert}" && -r "${key}" ]]; then
    curl --fail --insecure --silent --show-error \
      --user-agent "${user_agent}" \
      --dump-header "${headers}" \
      --output "${body}" \
      --resolve www.texise.com:443:127.0.0.1 \
      "https://www.texise.com${path}" || { rm -f -- "${headers}" "${body}"; return 1; }
  else
    curl --fail --silent --show-error \
      --user-agent "${user_agent}" \
      --dump-header "${headers}" \
      --output "${body}" \
      --resolve www.texise.com:80:127.0.0.1 \
      "http://www.texise.com${path}" || { rm -f -- "${headers}" "${body}"; return 1; }
  fi

  if ! tr -d '\r' < "${headers}" | grep -Eqi "^Content-Type:[[:space:]]*${content_type_regex}([[:space:]]*;|[[:space:]]*$)"; then
    texise_log "Static asset ${path} returned an unexpected Content-Type for ${user_agent}." >&2
    rm -f -- "${headers}" "${body}"
    return 1
  fi

  case "${signature_kind}" in
    ico)
      [[ "$(od -An -tx1 -N4 "${body}" | tr -d ' \n')" == '00000100' ]] || { texise_log "${path} did not return ICO bytes; possible SPA fallback." >&2; rm -f -- "${headers}" "${body}"; return 1; }
      ;;
    png)
      [[ "$(od -An -tx1 -N8 "${body}" | tr -d ' \n')" == '89504e470d0a1a0a' ]] || { texise_log "${path} did not return PNG bytes; possible SPA fallback." >&2; rm -f -- "${headers}" "${body}"; return 1; }
      ;;
    svg)
      node -e 'const fs=require("node:fs");const s=fs.readFileSync(process.argv[1],"utf8").slice(0,8192);if(!/<svg(?:\s|>)/iu.test(s)||/<html(?:\s|>)/iu.test(s))process.exit(1)' "${body}" || { texise_log "${path} did not return SVG content; possible SPA fallback." >&2; rm -f -- "${headers}" "${body}"; return 1; }
      ;;
    manifest)
      node -e 'const fs=require("node:fs");const s=fs.readFileSync(process.argv[1],"utf8");if(/<html(?:\s|>)/iu.test(s))process.exit(1);const j=JSON.parse(s);if(!j||typeof j!=="object")process.exit(1)' "${body}" || { texise_log "${path} did not return a valid web manifest; possible SPA fallback." >&2; rm -f -- "${headers}" "${body}"; return 1; }
      ;;
    *)
      rm -f -- "${headers}" "${body}"
      texise_log "Unknown static asset signature kind: ${signature_kind}" >&2
      return 1
      ;;
  esac

  rm -f -- "${headers}" "${body}"
  return 0
}

texise_frontend_redirect_health_check() {
  local path="$1"
  local expected_location="$2"
  local cert="/etc/letsencrypt/live/texise.com/fullchain.pem"
  local key="/etc/letsencrypt/live/texise.com/privkey.pem"
  local headers status location
  if [[ -r "${cert}" && -r "${key}" ]]; then
    headers="$(curl --insecure --silent --show-error --head --resolve www.texise.com:443:127.0.0.1 "https://www.texise.com${path}")" || return 1
  else
    headers="$(curl --silent --show-error --head --resolve www.texise.com:80:127.0.0.1 "http://www.texise.com${path}")" || return 1
  fi
  headers="$(printf '%s\n' "${headers}" | tr -d '\r')"
  status="$(printf '%s\n' "${headers}" | awk 'toupper($1) ~ /^HTTP\// {code=$2} END {print code}')"
  location="$(printf '%s\n' "${headers}" | awk 'BEGIN{IGNORECASE=1} /^Location:/ {sub(/^[^:]+:[[:space:]]*/,""); value=$0} END{print value}')"
  [[ "${status}" == '301' && "${location}" == "${expected_location}" ]] || {
    texise_log "Legacy URL ${path} returned HTTP ${status:-missing} Location=${location:-missing}; expected 301 ${expected_location}." >&2
    return 1
  }
}

texise_frontend_security_headers_health_check() {
  local cert="/etc/letsencrypt/live/texise.com/fullchain.pem"
  local key="/etc/letsencrypt/live/texise.com/privkey.pem"
  local headers
  if [[ -r "${cert}" && -r "${key}" ]]; then
    headers="$(curl --fail --insecure --silent --show-error --head \
      --resolve www.texise.com:443:127.0.0.1 https://www.texise.com/)" || return 1
  else
    headers="$(curl --fail --silent --show-error --head \
      --resolve www.texise.com:80:127.0.0.1 http://www.texise.com/)" || return 1
  fi
  printf '%s\n' "${headers}" | grep -qi '^Content-Security-Policy:' || return 1
  printf '%s\n' "${headers}" | grep -Eqi '^Cross-Origin-Opener-Policy:[[:space:]]*same-origin' || return 1
  printf '%s\n' "${headers}" | grep -Eqi '^Cross-Origin-Embedder-Policy:[[:space:]]*(credentialless|require-corp)'
}

# Retain the legacy CSP health-check entry point for deployment tooling/contracts;
# the implementation now verifies the complete CSP/COOP/COEP browser runtime boundary.
texise_frontend_csp_health_check() {
  texise_frontend_security_headers_health_check
}

texise_template_runtime_health_check() {
  local source_probe="${TEXISE_TEMPLATE_ROOT}/.texise-runtime-write-${RANDOM}-$$"
  local public_probe="${TEXISE_TEMPLATE_PUBLIC_COPY}/.texise-runtime-write-${RANDOM}-$$"
  if ! systemd-run --quiet --wait --collect --pipe \
      --unit="texise-template-probe-$$-${RANDOM}" \
      --property=User=texise \
      --property=Group=texise \
      --property=NoNewPrivileges=yes \
      --property=ProtectSystem=strict \
      --property="ReadWritePaths=${TEXISE_TEMPLATE_ROOT} ${TEXISE_TEMPLATE_PUBLIC_COPY}" \
      /usr/bin/node -e 'const fs=require("node:fs");for(const p of process.argv.slice(1)){fs.writeFileSync(p,"ok",{flag:"wx",mode:0o600});if(fs.readFileSync(p,"utf8")!=="ok")process.exit(2);fs.unlinkSync(p)}' \
      "${source_probe}" "${public_probe}"; then
    rm -f -- "${source_probe}" "${public_probe}" 2>/dev/null || true
    texise_log 'Template Admin runtime write/read probe failed under the service sandbox.' >&2
    return 1
  fi
  [[ ! -e "${TEXISE_TEMPLATE_PUBLIC_COPY}/free" && ! -e "${TEXISE_TEMPLATE_PUBLIC_COPY}/pro" ]] || {
    texise_log 'Live template catalog unexpectedly contains raw free/pro source trees.' >&2
    return 1
  }
  texise_log 'Persistent template Admin write boundary and safe live catalog health check passed.'
}

texise_blog_media_health_check() {
  local cert="/etc/letsencrypt/live/texise.com/fullchain.pem"
  local key="/etc/letsencrypt/live/texise.com/privkey.pem"
  local kind stage_dir probe_name probe_file published_probe_file public_path signature

  # The authoritative CMS copy lives in persistent staging, while Apache serves
  # only the replaceable dist projection. Exercise the same dual-write boundary
  # as saveCmsMedia(): a successful Admin upload is not healthy unless both the
  # durable staged file and the immediately public projection can be written.
  for kind in blog docs; do
    if [[ "${kind}" == 'blog' ]]; then
      stage_dir="${TEXISE_BLOG_MEDIA_DIR}"
    else
      stage_dir="${TEXISE_DOCS_MEDIA_DIR}"
    fi
    probe_name=".texise-health-${kind}-${RANDOM}-$$.png"
    probe_file="${stage_dir}/${probe_name}"
    published_probe_file="${TEXISE_MEDIA_PUBLISHED_ROOT}/${kind}/${probe_name}"
    public_path="/texise-media/${kind}/${probe_name}"

    if ! systemd-run --quiet --wait --collect --pipe \
        --unit="texise-media-probe-$$-${RANDOM}" \
        --property=User=texise \
        --property=Group=texise \
        --property=UMask=0027 \
        --property=NoNewPrivileges=yes \
        --property=ProtectSystem=strict \
        --property="ReadWritePaths=${stage_dir} ${TEXISE_MEDIA_PUBLISHED_ROOT}" \
        /usr/bin/node -e 'const fs=require("node:fs");const b=Buffer.from("89504e470d0a1a0a","hex");for(const p of process.argv.slice(1)){fs.mkdirSync(require("node:path").dirname(p),{recursive:true,mode:0o755});fs.writeFileSync(p,b,{flag:"wx",mode:0o644});fs.chmodSync(p,0o644);const r=fs.readFileSync(p);if(!r.equals(b)||(fs.statSync(p).mode&0o777)!==0o644)process.exit(2)}' \
        "${probe_file}" "${published_probe_file}"; then
      rm -f -- "${probe_file}" "${published_probe_file}" 2>/dev/null || true
      texise_log "${kind} media staging/live-projection write/read probe failed." >&2
      return 1
    fi

    if [[ -r "${cert}" && -r "${key}" ]]; then
      signature="$(curl --fail --insecure --silent --show-error --range 0-7 --resolve www.texise.com:443:127.0.0.1 "https://www.texise.com${public_path}" | od -An -tx1 | tr -d ' \n')" || { rm -f -- "${probe_file}" "${published_probe_file}"; return 1; }
    else
      signature="$(curl --fail --silent --show-error --range 0-7 --resolve www.texise.com:80:127.0.0.1 "http://www.texise.com${public_path}" | od -An -tx1 | tr -d ' \n')" || { rm -f -- "${probe_file}" "${published_probe_file}"; return 1; }
    fi
    rm -f -- "${probe_file}" "${published_probe_file}"
    [[ "${signature}" == "89504e470d0a1a0a" ]] || {
      texise_log "${kind} media URL returned unexpected bytes or SPA fallback." >&2
      return 1
    }
  done

  local protected_status
  if [[ -r "${cert}" && -r "${key}" ]]; then
    protected_status="$(curl --insecure --silent --output /dev/null --write-out '%{http_code}' --resolve www.texise.com:443:127.0.0.1 https://www.texise.com/texise-data/runtime/probe)"
  else
    protected_status="$(curl --silent --output /dev/null --write-out '%{http_code}' --resolve www.texise.com:80:127.0.0.1 http://www.texise.com/texise-data/runtime/probe)"
  fi
  [[ "${protected_status}" == "403" ]] || {
    texise_log "Protected /texise-data path returned HTTP ${protected_status}, expected 403." >&2
    return 1
  }
  texise_log 'Blog/Docs media staging, live projection, static serving and texise-data denial checks passed.'
  return 0
}

texise_collaboration_live_health_check() (
  # BF37 new-room admission requires a signed host grant. Export the trusted
  # root-owned SaaS environment only into this health-check subprocess so the
  # checker can mint a short-lived synthetic grant without placing SESSION_SECRET
  # on the command line or weakening the public relay.
  if [[ -r "${TEXISE_CONFIG_DIR}/texise.env" ]]; then
    set -a
    # shellcheck disable=SC1090
    source "${TEXISE_CONFIG_DIR}/texise.env"
    set +a
  fi
  node "${TEXISE_SOURCE_ROOT}/scripts/check-collaboration-live.mjs" "$@"
)

texise_frontend_health_check() {
  # Do not rely on `set -e` here: this function is called from an `&&` list,
  # where Bash suppresses errexit semantics for commands inside the function.
  if ! texise_frontend_request '/'; then
    texise_log 'Apache frontend health check failed.' >&2
    texise_frontend_failure_diagnostics
    return 1
  fi
  if ! texise_frontend_request '/about'; then
    texise_log 'Apache server-rendered public route health check failed.' >&2
    return 1
  fi
  local canonical_route
  for canonical_route in /projects /templates /templates/ /templates/modern-thesis /docs /admin/login /ws; do
    if ! texise_frontend_request "${canonical_route}"; then
      texise_log "Apache canonical route refresh health check failed: ${canonical_route}" >&2
      return 1
    fi
  done
  local examples_headers
  local cert="/etc/letsencrypt/live/texise.com/fullchain.pem"
  local key="/etc/letsencrypt/live/texise.com/privkey.pem"
  if [[ -r "${cert}" && -r "${key}" ]]; then
    examples_headers="$(curl --insecure --silent --show-error --head --resolve www.texise.com:443:127.0.0.1 https://www.texise.com/examples)" || return 1
  else
    examples_headers="$(curl --silent --show-error --head --resolve www.texise.com:80:127.0.0.1 http://www.texise.com/examples)" || return 1
  fi
  if ! printf '%s\n' "${examples_headers}" | grep -Eqi '^Location:[[:space:]]*(https?://[^/]+)?/templates([[:space:]]|$)'; then
    texise_log 'Apache /examples compatibility redirect did not target /templates.' >&2
    return 1
  fi
  if ! texise_frontend_request '/texise-templates/templates.json'; then
    texise_log 'Apache template manifest health check failed.' >&2
    return 1
  fi
  if ! texise_frontend_request '/texise-templates/categories.json'; then
    texise_log 'Apache template category registry health check failed.' >&2
    return 1
  fi
  if ! texise_frontend_request '/texise-icons/lucide-category-icons.json' ||
     ! texise_frontend_request '/texise-icons/lucide-category-icons.svg' ||
     ! texise_frontend_request '/texise-icons/lucide/file-text.svg'; then
    texise_log 'Apache static Lucide category icon assets health check failed.' >&2
    return 1
  fi
  if ! texise_frontend_request '/texise-icons/app-icon-192.png'; then
    texise_log 'Apache PWA icon health check failed.' >&2
    return 1
  fi
  local favicon_ua
  for favicon_ua in 'TeXiseHealth/1.0' 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)'; do
    if ! texise_frontend_static_asset_health_check '/favicon.ico' 'image/(x-icon|vnd\.microsoft\.icon)' ico "${favicon_ua}" ||
       ! texise_frontend_static_asset_health_check '/favicon-96x96.png' 'image/png' png "${favicon_ua}" ||
       ! texise_frontend_static_asset_health_check '/favicon.svg' 'image/svg\+xml' svg "${favicon_ua}" ||
       ! texise_frontend_static_asset_health_check '/apple-touch-icon.png' 'image/png' png "${favicon_ua}" ||
       ! texise_frontend_static_asset_health_check '/site.webmanifest' 'application/(manifest\+json|json)' manifest "${favicon_ua}"; then
      texise_log "Apache favicon/manifest crawler health check failed for ${favicon_ua}." >&2
      return 1
    fi
  done
  if ! texise_frontend_redirect_health_check '/latex-templates' 'https://www.texise.com/templates' ||
     ! texise_frontend_redirect_health_check '/latex-templates/modern-thesis' 'https://www.texise.com/templates/modern-thesis'; then
    texise_log 'Legacy /latex-templates canonical consolidation health check failed.' >&2
    return 1
  fi
  local browser_font
  for browser_font in \
    /texise-fonts/Inter-Variable.woff2 \
    /texise-fonts/JetBrainsMono-Variable.woff2 \
    /texise-fonts/Moranga-Light.otf \
    /texise-fonts/Moranga-Regular.otf \
    /texise-fonts/Moranga-RegularItalic.otf \
    /texise-fonts/Moranga-Bold.otf \
    /texise-fonts/STIXTwoText-VariableFont.ttf \
    /texise-fonts/STIXTwoText-Italic-Variable.ttf; do
    if ! texise_frontend_font_signature_check "${browser_font}"; then
      texise_log "Apache browser-font health check failed or returned a non-font fallback: ${browser_font}" >&2
      return 1
    fi
  done
  if ! texise_frontend_request '/compiler/status.json'; then
    texise_log 'Apache compiler status health check failed.' >&2
    return 1
  fi
  if [[ "$(texise_compiler_profile)" == "full" ]]; then
    local busytex_url
    for busytex_url in \
      /compiler/busytex/index.js \
      /compiler/busytex/busytex_pipeline.js \
      /compiler/busytex/busytex_worker.js \
      /compiler/busytex/busytex.wasm \
      /compiler/busytex/texlive-basic.data \
      /compiler/busytex/texlive-recommended.data \
      /compiler/busytex/texlive-extra.data; do
      if ! texise_frontend_head_request "${busytex_url}"; then
        texise_log "Apache full compiler asset health check failed: ${busytex_url}" >&2
        return 1
      fi
    done
    if [[ "${TEXISE_DEPLOY_CERTIFY}" == "1" ]] && ! texise_frontend_request '/release/certification.json'; then
      texise_log 'Apache runtime certification health check failed.' >&2
      return 1
    fi
  fi
  if ! texise_frontend_request '/texlive/texlive-lsr.txt'; then
    texise_log 'Apache TeX Live ls-R health check failed.' >&2
    return 1
  fi

  if ! texise_frontend_request '/texlive/cache-manifest-text.txt'; then
    texise_log 'Apache TeX Live manifest health check failed.' >&2
    return 1
  fi
  if ! texise_frontend_request '/texlive/cache/amsmath.sty'; then
    texise_log 'Apache bundled TeX Live package health check failed.' >&2
    return 1
  fi
  if ! texise_frontend_request '/texlive/cache/cm-super-ts1.enc'; then
    texise_log 'Apache CM-Super TS1 encoding health check failed.' >&2
    return 1
  fi
  if ! texise_frontend_request '/texlive/cache/cm-super-ts1.map'; then
    texise_log 'Apache CM-Super TS1 map health check failed.' >&2
    return 1
  fi
  if [[ -r /etc/letsencrypt/live/texise.com/fullchain.pem && -r /etc/letsencrypt/live/texise.com/privkey.pem ]]; then
    if ! node "${TEXISE_SOURCE_ROOT}/scripts/check-collaboration-signaling.mjs" 'wss://127.0.0.1/collab-signal' www.texise.com; then
      texise_log 'Collaboration signaling compatibility proxy health check failed.' >&2
      return 1
    fi
    if ! texise_collaboration_live_health_check 'wss://127.0.0.1/collab-live' www.texise.com; then
      texise_log 'Collaboration live document relay health check failed.' >&2
      return 1
    fi
  else
    if ! node "${TEXISE_SOURCE_ROOT}/scripts/check-collaboration-signaling.mjs" 'ws://127.0.0.1:3101/collab-signal' www.texise.com; then
      texise_log 'Collaboration signaling compatibility proxy health check failed.' >&2
      return 1
    fi
    if ! texise_collaboration_live_health_check 'ws://127.0.0.1:3101/collab-live' www.texise.com; then
      texise_log 'Collaboration live document relay health check failed.' >&2
      return 1
    fi
  fi
  if ! texise_frontend_security_headers_health_check; then
    texise_log 'Apache browser security-header health check failed (CSP/COOP/COEP).' >&2
    return 1
  fi
  texise_log 'Apache frontend, canonical SPA routes, legacy template redirects, favicon/manifest crawler responses, template manifest, browser fonts, PWA icon, compiler status, TeX Live, collaboration signaling/live relay, and browser security-header health checks passed.'
  return 0
}

texise_release_pair_health_check() {
  local api_release frontend_release
  api_release="$(curl --fail --silent http://127.0.0.1:3100/api/health | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>process.stdout.write(JSON.parse(s).releaseId||""))')" || return 1
  if [[ -r /etc/letsencrypt/live/texise.com/fullchain.pem && -r /etc/letsencrypt/live/texise.com/privkey.pem ]]; then
    frontend_release="$(curl --fail --insecure --silent --resolve www.texise.com:443:127.0.0.1 https://www.texise.com/release.json | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>process.stdout.write(JSON.parse(s).releaseId||""))')" || return 1
  else
    frontend_release="$(curl --fail --silent --resolve www.texise.com:80:127.0.0.1 http://www.texise.com/release.json | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>process.stdout.write(JSON.parse(s).releaseId||""))')" || return 1
  fi
  [[ -n "${api_release}" && "${api_release}" == "${frontend_release}" ]] || {
    texise_log "Release pair mismatch: API=${api_release:-missing} frontend=${frontend_release:-missing}" >&2
    return 1
  }
  if [[ "${TEXISE_DEPLOY_CERTIFY}" == "1" ]]; then
    local certification_release
    if [[ -r /etc/letsencrypt/live/texise.com/fullchain.pem && -r /etc/letsencrypt/live/texise.com/privkey.pem ]]; then
      certification_release="$(curl --fail --insecure --silent --resolve www.texise.com:443:127.0.0.1 https://www.texise.com/release/certification.json | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const c=JSON.parse(s);if(c.status!=="passed"||c.certificationLevel!=="production-browser")process.exit(2);process.stdout.write(c.releaseId||"")})')" || return 1
    else
      certification_release="$(curl --fail --silent --resolve www.texise.com:80:127.0.0.1 http://www.texise.com/release/certification.json | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const c=JSON.parse(s);if(c.status!=="passed"||c.certificationLevel!=="production-browser")process.exit(2);process.stdout.write(c.releaseId||"")})')" || return 1
    fi
    [[ "${certification_release}" == "${frontend_release}" ]] || {
      texise_log "Certification release mismatch: certification=${certification_release:-missing} frontend=${frontend_release}" >&2
      return 1
    }
    texise_log "Coherent frontend/API/certification release pair verified: ${api_release}."
  else
    texise_log "Coherent frontend/API release pair verified: ${api_release}."
  fi
}

texise_cloud_compile_health_check() {
  local worker_url worker_secret cloud_image root_url response
  worker_url="$(texise_env_value "${TEXISE_CONFIG_DIR}/texise.env" TEXISE_CLOUD_COMPILE_WORKER_URL)"
  worker_secret="$(texise_env_value "${TEXISE_CONFIG_DIR}/texise.env" TEXISE_CLOUD_COMPILE_WORKER_SECRET)"
  cloud_image="$(texise_env_value "${TEXISE_CONFIG_DIR}/texise.env" TEXISE_CLOUD_COMPILE_IMAGE)"
  [[ "${worker_url}" =~ ^http://(127\.0\.0\.1|localhost|\[::1\])(:[0-9]+)?/api/compile$ ]] || {
    texise_log "Cloud Compile worker URL is not loopback-only: ${worker_url}" >&2
    return 1
  }
  root_url="${worker_url%/api/compile}/"
  response=''
  local healthy=0
  for ((i = 1; i <= 20; i += 1)); do
    if response="$(curl --fail --silent "${root_url}" 2>/dev/null)"; then
      healthy=1
      break
    fi
    sleep 0.25
  done
  if [[ "${healthy}" != '1' ]]; then
    texise_log "Cloud Compile worker health endpoint failed after startup retries: ${root_url}" >&2
    curl --fail --silent --show-error "${root_url}" >/dev/null || true
    journalctl -u texise-latex-compile.service -n 80 --no-pager -o cat >&2 || true
    return 1
  fi
  printf '%s' "${response}" | node -e 'let s="";const expected=process.argv[1];process.stdin.on("data",c=>s+=c).on("end",()=>{const x=JSON.parse(s);if(x.status!=="ok"||x.service!=="texise-latex-compile-service"||x.dockerImage!==expected||!Array.isArray(x.engines)||!x.limits)process.exit(2)})' "${cloud_image}" || { texise_log "Cloud Compile health image mismatch; expected ${cloud_image}." >&2; return 1; }
  [[ -n "${worker_secret}" ]] || {
    texise_log 'Cloud Compile worker secret is unexpectedly empty.' >&2
    return 1
  }
  if [[ "$(texise_env_value "${TEXISE_CONFIG_DIR}/texise.env" TEXISE_CLOUD_COMPILE_ENABLED)" == '1' || "$(texise_env_value "${TEXISE_CONFIG_DIR}/texise.env" TEXISE_CLOUD_COMPILE_FREE_FALLBACK)" == '1' ]]; then
    local allowed_engines
    allowed_engines="$(texise_env_value "${TEXISE_CONFIG_DIR}/texise.env" TEXISE_CLOUD_COMPILE_ALLOWED_ENGINES)"
    TEXISE_CLOUD_COMPILE_WORKER_URL="${worker_url}" \
      TEXISE_CLOUD_COMPILE_WORKER_SECRET="${worker_secret}" \
      TEXISE_CLOUD_COMPILE_ALLOWED_ENGINES="${allowed_engines}" \
      node "${TEXISE_SOURCE_ROOT}/scripts/check-cloud-compile-worker.mjs" || {
        texise_log 'Cloud Compile production compile certification failed.' >&2
        return 1
      }
  fi
  texise_log 'Private Cloud Compile worker health check passed.'
}

texise_console_gateway_health_check() {
  local gateway_url gateway_secret console_image response
  gateway_url="$(texise_env_value "${TEXISE_CONFIG_DIR}/texise.env" TEXISE_CONSOLE_GATEWAY_URL)"
  gateway_secret="$(texise_env_value "${TEXISE_CONFIG_DIR}/texise.env" TEXISE_CONSOLE_GATEWAY_SECRET)"
  console_image="$(texise_env_value "${TEXISE_CONFIG_DIR}/texise.env" TEXISE_CONSOLE_IMAGE)"
  [[ "${gateway_url}" =~ ^http://(127\.0\.0\.1|localhost|\[::1\])(:[0-9]+)?$ ]] || {
    texise_log "Console Gateway URL is not loopback-only: ${gateway_url}" >&2; return 1;
  }
  [[ -n "${gateway_secret}" ]] || { texise_log 'Console Gateway secret is unexpectedly empty.' >&2; return 1; }
  response=''
  local healthy=0
  for ((i = 1; i <= 20; i += 1)); do
    if response="$(curl --fail --silent "${gateway_url}/health" 2>/dev/null)"; then
      healthy=1
      break
    fi
    sleep 0.25
  done
  if [[ "${healthy}" != '1' ]]; then
    texise_log 'Console Gateway health endpoint failed after startup retries.' >&2
    curl --fail --silent --show-error "${gateway_url}/health" >/dev/null || true
    journalctl -u texise-console-gateway.service -n 80 --no-pager -o cat >&2 || true
    return 1
  fi
  printf '%s' "${response}" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const x=JSON.parse(s);if(x.ok!==true||x.service!=="texise-console-gateway")process.exit(2)})' || return 1
  if [[ "$(texise_env_value "${TEXISE_CONFIG_DIR}/texise.env" TEXISE_LINUX_TERMINAL_ENABLED)" == '1' ]]; then
    TEXISE_CONSOLE_GATEWAY_URL="${gateway_url}" TEXISE_CONSOLE_GATEWAY_SECRET="${gateway_secret}" TEXISE_CONSOLE_IMAGE="${console_image}" \
      node "${TEXISE_SOURCE_ROOT}/scripts/check-console-gateway.mjs" || {
        texise_log 'TexLab production certification failed.' >&2; return 1;
      }
  fi
  texise_log 'TexLab Console Gateway health check passed.'
}

texise_legacy_origin_redirect_health_check() {
  local status location app_pair app_cert app_key
  status="$(curl --silent --output /dev/null --write-out '%{http_code}' --resolve app.texise.com:80:127.0.0.1 'http://app.texise.com/ws?legacy=1')" || return 1
  location="$(curl --silent --show-error --head --resolve app.texise.com:80:127.0.0.1 'http://app.texise.com/ws?legacy=1' | tr -d '\r' | grep -i '^Location:' | tail -n1 | cut -d' ' -f2-)"
  [[ "${status}" == '301' && "${location}" == 'https://www.texise.com/ws?legacy=1' ]] || {
    texise_log "Legacy HTTP redirect mismatch: status=${status} location=${location}" >&2
    return 1
  }
  if app_pair="$(texise_find_certificate_for_host app.texise.com)"; then
    app_cert="${app_pair%%|*}"; app_key="${app_pair#*|}"
    [[ -r "${app_cert}" && -r "${app_key}" ]] || return 1
    location="$(curl --insecure --silent --show-error --head --resolve app.texise.com:443:127.0.0.1 'https://app.texise.com/ws?legacy=1' | tr -d '\r' | grep -i '^Location:' | tail -n1 | cut -d' ' -f2-)"
    [[ "${location}" == 'https://www.texise.com/ws?legacy=1' ]] || {
      texise_log "Legacy HTTPS redirect mismatch: ${location}" >&2
      return 1
    }
  fi
  # URL fragments never reach Apache; browsers retain the fragment across a
  # same-request HTTP redirect. The vhost preserves path/query verbatim, which
  # therefore preserves /ws#route=... in browser navigation.
  texise_log 'Legacy app.texise.com compatibility redirect checks passed.'
}

texise_indexnow_key_health_check() {
  local key expected actual
  key="$(texise_env_scalar_value "${TEXISE_CONFIG_DIR}/texise.env" TEXISE_INDEXNOW_KEY)"
  [[ "${key}" =~ ^[A-Za-z0-9-]{8,128}$ ]] || {
    texise_log 'IndexNow ownership key is missing or invalid during deployment health check.' >&2
    return 1
  }
  if [[ -r /etc/letsencrypt/live/texise.com/fullchain.pem && -r /etc/letsencrypt/live/texise.com/privkey.pem ]]; then
    actual="$(curl --fail --insecure --silent --show-error --resolve www.texise.com:443:127.0.0.1 "https://www.texise.com/${key}.txt")" || return 1
  else
    actual="$(curl --fail --silent --show-error --resolve www.texise.com:80:127.0.0.1 "http://www.texise.com/${key}.txt")" || return 1
  fi
  expected="${key}"
  [[ "${actual}" == "${expected}" ]] || {
    texise_log 'Published IndexNow ownership file did not return the configured key.' >&2
    return 1
  }
  texise_log 'IndexNow ownership-file health check passed.'
}

texise_indexnow_notify() {
  local key public_url
  key="$(texise_env_scalar_value "${TEXISE_CONFIG_DIR}/texise.env" TEXISE_INDEXNOW_KEY)"
  public_url="$(texise_env_scalar_value "${TEXISE_CONFIG_DIR}/texise.env" TEXISE_PUBLIC_URL)"
  [[ -n "${key}" ]] || return 0
  TEXISE_INDEXNOW_KEY="${key}" TEXISE_PUBLIC_URL="${public_url:-https://www.texise.com}" \
    node "${TEXISE_SOURCE_ROOT}/scripts/submit-indexnow.mjs"
}

texise_health_check() {
  if texise_api_health_check && texise_cloud_compile_health_check && texise_console_gateway_health_check && texise_frontend_health_check && texise_template_runtime_health_check && texise_blog_media_health_check && texise_legacy_origin_redirect_health_check && texise_release_pair_health_check && texise_indexnow_key_health_check; then
    texise_retire_legacy_services
    texise_cleanup_legacy_server_storage
    if ! texise_indexnow_notify; then
      texise_log 'IndexNow notification failed after a healthy deployment; continuing without rollback.' >&2
    fi
    return 0
  fi

  systemctl --no-pager --full status texise.service || true
  texise_restore_previous_dist || true
  texise_restore_previous_runtime || true
  texise_die 'Deployment health check failed. The previous static dist was restored when available; inspect texise.service before retrying.'
}
