#!/usr/bin/env bash
# =============================================================================
# OrbixPanel Full-Stack Installer
# Installs the complete OrbixPanel stack (backend + agent) on a single server.
# Supports Ubuntu 22.04 / 24.04 and Debian 12.
#
# USAGE:
#   sudo bash install.sh
#   (will prompt for admin email and password interactively)
#
#   Or non-interactively:
#   ORBIX_ADMIN_EMAIL=admin@example.com \
#   ORBIX_ADMIN_PASSWORD=securepassword \
#   sudo bash install.sh
#
# OPTIONAL VARS:
#   ORBIX_SERVER_NAME   — display name (default: hostname)
#   ORBIX_SERVER_IP     — server IP (default: auto-detected)
#   ORBIX_DB_PASSWORD   — PostgreSQL password (default: auto-generated)
#   ORBIX_JWT_SECRET    — JWT signing secret (default: auto-generated)
#   CORE_REPO_URL       — backend Git URL (default: github.com/orbixpanel/orbixpanel-core)
#   CORE_BRANCH         — backend branch (default: main)
#   AGENT_BIN_URL       — download pre-built agent binary (skips Go build)
#   AGENT_REPO_URL      — agent Git URL (default: github.com/orbixpanel/orbixpanel-agent)
#   AGENT_BRANCH        — agent branch (default: main)
#   GO_VERSION          — Go version to install (default: 1.22.5)
#
# SKIP FLAGS (set to 1):
#   SKIP_NGINX=1        — skip nginx install
#   SKIP_POSTGRESQL=1   — skip postgresql install
#   SKIP_REDIS=1        — skip redis install
#   SKIP_NODE=1         — skip Node.js install
#   SKIP_CORE=1         — skip backend deployment (backend already running)
#   SKIP_FRONTEND=1     — skip frontend deployment
#   SKIP_GO=1           — skip Go install (requires AGENT_BIN_URL)
# =============================================================================
set -euo pipefail

# ─── Colour helpers ───────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'

info()    { echo -e "${CYAN}[INFO]${RESET}  $*"; }
ok()      { echo -e "${GREEN}[OK]${RESET}    $*"; }
warn()    { echo -e "${YELLOW}[WARN]${RESET}  $*"; }
die()     { echo -e "${RED}[ERROR]${RESET} $*" >&2; exit 1; }
section() { echo -e "\n${BOLD}━━━  $*  ━━━${RESET}"; }

# Kill any running spinner/journalctl on unexpected exit so the terminal stays clean.
_cleanup() {
  [[ -n "${_spin_pid:-}"  ]] && kill "$_spin_pid"  2>/dev/null; wait "$_spin_pid"  2>/dev/null || true
  [[ -n "${_jctl_pid:-}"  ]] && kill "$_jctl_pid"  2>/dev/null; wait "$_jctl_pid"  2>/dev/null || true
  printf "\r\033[K" 2>/dev/null || true
}
trap _cleanup EXIT
trap 'printf "\r\033[K"; echo -e "\n${RED}[ERROR]${RESET} Script failed at line $LINENO: $BASH_COMMAND" >&2' ERR

# ── Spinner ───────────────────────────────────────────────────────────────────
# run_spinner "label" logfile cmd [args…]
# Runs cmd in the background, animates a spinner + last log line, then clears.
# The live log tail gives real-time feedback: current npm package, apt package,
# prisma step, etc.
_SPIN_FRAMES='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
_spin_pid=
_jctl_pid=

_spin_loop() {
  local msg="$1" logfile="${2:-}" i=0 f line cols max_w status
  trap '' INT
  cols=$(tput cols 2>/dev/null || echo 80)
  max_w=$(( cols - 8 )); [[ $max_w -lt 20 ]] && max_w=60
  while true; do
    f="${_SPIN_FRAMES:$((i % ${#_SPIN_FRAMES})):1}"
    status="$msg"
    if [[ -n "$logfile" && -s "$logfile" ]]; then
      # Last non-empty, non-noise line — strip ANSI codes and pad/truncate
      line=$(grep -v \
               -e '^\s*$' \
               -e '^npm warn' -e '^npm notice' -e 'run `npm' \
               -e 'packages are looking for funding' \
               -e 'vulnerabilit' -e 'npm audit' \
               -e '^Reading database' -e '^Selecting previously' \
               "$logfile" 2>/dev/null \
             | tail -1 \
             | sed 's/\x1b\[[0-9;]*[mGKHFJl]//g' \
             | tr -d '\r\n' \
             | cut -c1-"$max_w") || true
      [[ -n "$line" ]] && status="$line"
    fi
    printf "\r  ${CYAN}%s${RESET}  %-*s" "$f" "$max_w" "$status"
    sleep 0.15
    i=$((i + 1))
  done
}

run_spinner() {
  local msg="$1" logfile="$2" rc; shift 2
  _spin_loop "$msg" "$logfile" &
  _spin_pid=$!
  # Use && / || so set -e never fires inside the function.
  # rc captures the real exit code; spinner cleanup always runs.
  "$@" >"$logfile" 2>&1 && rc=0 || rc=$?
  kill "$_spin_pid" 2>/dev/null || true; wait "$_spin_pid" 2>/dev/null || true; _spin_pid=
  printf "\r\033[K"
  if [[ $rc -ne 0 ]]; then
    echo -e "${RED}[ERROR]${RESET} Command failed (exit $rc): $*" >&2
    if [[ -s "$logfile" ]]; then
      echo "--- last 20 lines of $logfile ---" >&2
      tail -20 "$logfile" >&2
    fi
  fi
  return $rc
}

# ─── Configuration ────────────────────────────────────────────────────────────
ORBIX_ADMIN_EMAIL="${ORBIX_ADMIN_EMAIL:-}"
ORBIX_ADMIN_PASSWORD="${ORBIX_ADMIN_PASSWORD:-}"
ORBIX_SERVER_NAME="${ORBIX_SERVER_NAME:-$(hostname)}"
ORBIX_SERVER_IP="${ORBIX_SERVER_IP:-}"
ORBIX_DOMAIN="${ORBIX_DOMAIN:-}"        # optional — enables SSL + landing page

# Max upload size (MB) for the file manager / app-zip uploads. Drives nginx's
# client_max_body_size on every server block AND the backend body limit, so the
# two never disagree (a low nginx cap otherwise 413s before the request reaches
# the backend). Change it and re-run the installer to raise/lower the limit.
ORBIX_MAX_UPLOAD_MB="${ORBIX_MAX_UPLOAD_MB:-256}"

# SMTP relay — set these env vars for unattended installs, or leave blank to
# be prompted interactively when port 25 outbound is blocked.
SMTP_RELAY_HOST="${SMTP_RELAY_HOST:-}"
SMTP_RELAY_PORT="${SMTP_RELAY_PORT:-587}"
SMTP_RELAY_USER="${SMTP_RELAY_USER:-}"
SMTP_RELAY_PASS="${SMTP_RELAY_PASS:-}"

# Backend always runs on the same machine
ORBIX_API_URL="http://localhost:3000"

# Backend source
CORE_REPO_URL="${CORE_REPO_URL:-https://github.com/orbixpanel/orbixpanel-core}"
CORE_BRANCH="${CORE_BRANCH:-main}"
CORE_SRC="/opt/orbixpanel-core"

# Frontend source
FRONTEND_REPO_URL="${FRONTEND_REPO_URL:-https://github.com/orbixpanel/orbixpanel-web}"
FRONTEND_BRANCH="${FRONTEND_BRANCH:-main}"
FRONTEND_SRC="/opt/orbixpanel-web"

# Agent source
AGENT_BIN_URL="${AGENT_BIN_URL:-}"
AGENT_REPO_URL="${AGENT_REPO_URL:-https://github.com/orbixpanel/orbixpanel-agent}"
AGENT_BRANCH="${AGENT_BRANCH:-main}"
GO_VERSION="${GO_VERSION:-1.22.5}"

# Optional GitHub personal access token for private repos.
# If set, it is embedded in clone URLs so git never prompts for credentials.
# Generate at: https://github.com/settings/tokens (read-only, repo scope)
GITHUB_TOKEN="${GITHUB_TOKEN:-}"

# Fixed paths
AGENT_BIN="/usr/local/bin/orbixpanel-agent"
AGENT_SRC="/opt/orbixpanel-agent-src"
CONF_DIR="/etc/orbixpanel"
BACKEND_ENV="${CONF_DIR}/backend.env"
AGENT_ENV="${CONF_DIR}/agent.env"
BACKEND_SERVICE="/etc/systemd/system/orbixpanel-backend.service"
AGENT_SERVICE="/etc/systemd/system/orbixpanel-agent.service"
LOG_DIR="/var/log/orbixpanel"
ORBIX_USER="orbixpanel"

# Database
DB_NAME="orbixpanel"
DB_USER="orbixpanel"

# Skip flags
SKIP_NGINX="${SKIP_NGINX:-0}"
SKIP_POSTGRESQL="${SKIP_POSTGRESQL:-0}"
SKIP_REDIS="${SKIP_REDIS:-0}"
SKIP_NODE="${SKIP_NODE:-0}"
SKIP_CORE="${SKIP_CORE:-0}"
SKIP_FRONTEND="${SKIP_FRONTEND:-0}"
SKIP_GO="${SKIP_GO:-0}"

# Pre-built release download (fast install — skips npm install + build + go build)
# Requires GITHUB_TOKEN: a GitHub PAT with 'repo' read scope (repos are private).
# Generate at: https://github.com/settings/tokens  (Classic → repo)
# Run: export GITHUB_TOKEN=github_pat_... && sudo -E bash install.sh
GITHUB_TOKEN="${GITHUB_TOKEN:-}"
ORBIX_CORE_REPO="${ORBIX_CORE_REPO:-orbixpanel/orbixpanel-core}"
ORBIX_WEB_REPO="${ORBIX_WEB_REPO:-orbixpanel/orbixpanel-web}"
ORBIX_AGENT_REPO="${ORBIX_AGENT_REPO:-orbixpanel/orbixpanel-agent}"
# Set BUILD_FROM_SOURCE=1 to force building from source instead of downloading.
BUILD_FROM_SOURCE="${BUILD_FROM_SOURCE:-0}"

# Public pre-built distribution. The source repos are private, so a plain
# `curl | bash` (no GITHUB_TOKEN) downloads the build artifacts from this PUBLIC
# GitHub repo, whose releases mirror the private repos' CI output. With a token
# set, the installer uses the private source repos directly instead (dev/owner
# path). Override ORBIX_DIST_REPO/ORBIX_DIST_BASE to point at your own mirror.
ORBIX_DIST_REPO="${ORBIX_DIST_REPO:-orbixpanel/orbixpanel-dist}"
ORBIX_DIST_BASE="${ORBIX_DIST_BASE:-https://github.com/${ORBIX_DIST_REPO}/releases/download}"

# ─── Licensing (orbixpanel.com) ───────────────────────────────────────────────
# ORBIX_CLOUD_URL     — licensing service base URL.
# LICENSE_PUBLIC_KEY  — base64 Ed25519 public key the panel uses to verify license
#                       JWTs. Public by design, so it ships with the installer.
#                       While EMPTY the panel stays UNLICENSED (development mode):
#                       nothing is enforced. Set it once the signing keypair exists.
# LICENSE_KEY         — attach an existing license instead of auto-registering.
# ORBIX_REFERRAL_CODE — optional; credits whoever referred this install.
# ORBIX_INSTALL_SECRET— shared build-time secret for request signing. When set (and
#                       matching the cloud), the register call + the panel's
#                       heartbeat/telemetry are HMAC-signed so orbixpanel.com can
#                       reject calls that didn't come from an OrbixPanel install.
#                       EMPTY = unsigned (rollout default). NOTE: this ships inside
#                       Community Edition and is extractable — it raises the bar +
#                       enables rotation, it is not an unbreakable gate.
ORBIX_CLOUD_URL="${ORBIX_CLOUD_URL:-https://orbixpanel.com}"
# LICENSE_PUBLIC_KEY + ORBIX_INSTALL_SECRET: when downloaded from the cloud
# (GET /install.sh), orbixpanel.com bakes these into the placeholders below so a
# plain `curl -fsSL https://orbixpanel.com/install.sh | bash` registers + signs the
# call automatically. Run straight from git (placeholders intact) or override via
# env → the split-literal guards reset the untouched placeholder to empty.
LICENSE_PUBLIC_KEY="${LICENSE_PUBLIC_KEY:-LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQWM3cU5Jd1FYUnFSakdURy93d09FOVcvbk9TQ0xyNEpZZktGbWdzaFhpTTg9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=}"
[[ "$LICENSE_PUBLIC_KEY" == "__ORBIX_LICENSE""_PUBLIC_KEY__" ]] && LICENSE_PUBLIC_KEY=""
LICENSE_KEY="${LICENSE_KEY:-}"
ORBIX_REFERRAL_CODE="${ORBIX_REFERRAL_CODE:-}"
# Blank an unsubstituted placeholder (installer run outside the serving route).
[[ "$ORBIX_REFERRAL_CODE" == __ORBIX_* ]] && ORBIX_REFERRAL_CODE=""
ORBIX_INSTALL_SECRET="${ORBIX_INSTALL_SECRET:-36b03856d308988360a4bd1b3bfe5732ac0347f4d24a342985ca27982e90ed40}"
[[ "$ORBIX_INSTALL_SECRET" == "__ORBIX_INSTALL""_SECRET__" ]] && ORBIX_INSTALL_SECRET=""

# Single-use install token. When the installer is downloaded from the cloud
# (GET /install.sh), orbixpanel.com replaces the placeholder below with a fresh,
# expiring, one-time token that authorizes exactly one /api/register call. Run
# straight from git (placeholder left intact) → treated as empty (no token).
ORBIX_INSTALL_TOKEN="${ORBIX_INSTALL_TOKEN:-7a2ac263c209449c72fe533d6aeea19f83eba1534e276593}"
# The split literal below avoids matching the placeholder itself, so the cloud's
# substitution never rewrites this comparison — only the assignment above.
[[ "$ORBIX_INSTALL_TOKEN" == "__ORBIX_INSTALL""_TOKEN__" ]] && ORBIX_INSTALL_TOKEN=""

# ─── Install profile ──────────────────────────────────────────────────────────
# ORBIXPANEL_PROFILE — how this server will be used. Governs the public landing
# page and the panel's onboarding emphasis (NOT entitlements — the license
# edition still decides what's actually unlocked).
#   personal  → single-owner panel with a simple landing page (default).
#   reseller  → hosting business: WHM/multi-tenancy, storefront-oriented landing.
# Set it to install non-interactively; otherwise the installer prompts.
ORBIXPANEL_PROFILE="${ORBIXPANEL_PROFILE:-}"

# ORBIX_STOREFRONT_DOMAIN — reseller only. The public host for the hosting-company
# storefront/CMS. May be the SAME as ORBIX_DOMAIN (the site root becomes the
# storefront, with the static landing page kept as a fallback until it's published),
# a subdomain (e.g. hosting.$ORBIX_DOMAIN), or a separate domain. Blank = skip
# (wire it later from the panel). Set it to install non-interactively; otherwise
# the installer prompts when the profile is 'reseller' and a domain is set.
ORBIX_STOREFRONT_DOMAIN="${ORBIX_STOREFRONT_DOMAIN:-}"

# ─── Banner ───────────────────────────────────────────────────────────────────
echo -e "${BOLD}${CYAN}"
cat <<'BANNER'
  ___       _     _      ____                  _
 / _ \ _ __| |__ (_)_   |  _ \ __ _ _ __   ___| |
| | | | '__| '_ \| \ \/ / |_) / _` | '_ \ / _ \ |
| |_| | |  | |_) | |>  <|  __/ (_| | | | |  __/ |
 \___/|_|  |_.__/|_/_/\_\_|   \__,_|_| |_|\___|_|
BANNER
echo -e "${RESET}"
echo -e "${BOLD}OrbixPanel Installer${RESET}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

# ─── Pre-flight checks ────────────────────────────────────────────────────────
section "Pre-flight Checks"

[[ $EUID -eq 0 ]] || die "This script must be run as root (use sudo)."

if [[ -f /etc/os-release ]]; then
  # shellcheck source=/dev/null
  source /etc/os-release
  if [[ "$ID" != "ubuntu" && "$ID" != "debian" ]]; then
    die "Supports Ubuntu / Debian only. Detected: $ID $VERSION_ID"
  fi
  ok "OS: $PRETTY_NAME"
else
  die "/etc/os-release not found."
fi

# Prompt interactively if not provided via env vars
# Use /dev/tty so prompts work even when the script is piped (curl | bash)
if [[ -z "$ORBIX_ADMIN_EMAIL" ]]; then
  read -rp "  Admin email:    " ORBIX_ADMIN_EMAIL < /dev/tty
fi
if [[ -z "$ORBIX_ADMIN_PASSWORD" ]]; then
  # Interactive: prompt + re-type to catch typos (a mistyped admin password would
  # otherwise only surface as a failed login later). Min 8 chars = backend rule.
  while true; do
    read -rsp "  Admin password: " ORBIX_ADMIN_PASSWORD < /dev/tty; echo ""
    if [[ -z "$ORBIX_ADMIN_PASSWORD" ]]; then
      warn "Password cannot be empty."; continue
    fi
    if [[ "${#ORBIX_ADMIN_PASSWORD}" -lt 8 ]]; then
      warn "Password must be at least 8 characters."; continue
    fi
    read -rsp "  Confirm password: " _ORBIX_PW_CONFIRM < /dev/tty; echo ""
    if [[ "$ORBIX_ADMIN_PASSWORD" != "$_ORBIX_PW_CONFIRM" ]]; then
      warn "Passwords do not match — try again."; continue
    fi
    break
  done
  unset _ORBIX_PW_CONFIRM
fi

[[ -n "$ORBIX_ADMIN_EMAIL" ]]    || die "Admin email is required."
[[ -n "$ORBIX_ADMIN_PASSWORD" ]] || die "Admin password is required."

# Auto-detect primary IP
if [[ -z "$ORBIX_SERVER_IP" ]]; then
  # Public egress IP first — on NAT'd cloud VMs (AWS/GCP/Azure) the NIC only holds a
  # private address, so `ip route get`/`hostname -I` would wrongly return e.g.
  # 172.31.x. An external echo returns the real public IP used for DNS. Fall back to
  # the local interface IP when offline. Override with ORBIX_SERVER_IP=… to be explicit.
  for _ip_svc in https://checkip.amazonaws.com https://api.ipify.org https://ifconfig.me; do
    _cand=$(curl -fsS --max-time 5 "$_ip_svc" 2>/dev/null | tr -dc '0-9.')
    if [[ "$_cand" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
      ORBIX_SERVER_IP="$_cand"; break
    fi
  done
  if [[ -z "$ORBIX_SERVER_IP" ]]; then
    ORBIX_SERVER_IP=$(ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}' \
      || hostname -I 2>/dev/null | awk '{print $1}' \
      || echo "127.0.0.1")
  fi
fi
ok "Server name: $ORBIX_SERVER_NAME"
ok "Server IP:   $ORBIX_SERVER_IP"

# ─── Domain prompt ────────────────────────────────────────────────────────────
if [[ -z "$ORBIX_DOMAIN" ]]; then
  echo ""
  echo "  Optional: enter a domain name to enable SSL and a public landing page."
  echo "  Leave blank to install with server IP only (HTTP)."
  echo ""
  read -rp "  Domain name (e.g. orbixpanel.com — press Enter to skip): " ORBIX_DOMAIN < /dev/tty
  ORBIX_DOMAIN=$(echo "$ORBIX_DOMAIN" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]' | sed 's|^www\.||')
fi

# ─── Install profile prompt ───────────────────────────────────────────────────
# Normalise anything already supplied via env; only prompt when unset/invalid.
case "$(echo "$ORBIXPANEL_PROFILE" | tr '[:upper:]' '[:lower:]')" in
  personal|individual) ORBIXPANEL_PROFILE="personal" ;;
  reseller|business)   ORBIXPANEL_PROFILE="reseller" ;;
  "")
    echo ""
    echo "  How will this server be used?"
    echo "    1) Personal  — a single-owner panel (default)"
    echo "    2) Reseller  — run a hosting business: customer accounts, plans, billing"
    echo ""
    read -rp "  Choose profile [1]: " _profile_choice < /dev/tty
    case "$_profile_choice" in
      2) ORBIXPANEL_PROFILE="reseller" ;;
      *) ORBIXPANEL_PROFILE="personal" ;;
    esac
    ;;
  *)
    warn "Unknown ORBIXPANEL_PROFILE '${ORBIXPANEL_PROFILE}' — defaulting to personal."
    ORBIXPANEL_PROFILE="personal"
    ;;
esac
ok "Profile:     $ORBIXPANEL_PROFILE"

# Legal notice — the installer contacts orbixpanel.com (licensing + install
# telemetry), so surface the Terms & Privacy links before anything is sent.
echo ""
echo -e "  ${BOLD}By installing you agree to the Terms & Privacy Policy:${RESET}"
echo "    Terms:   ${ORBIX_CLOUD_URL}/terms"
echo "    Privacy: ${ORBIX_CLOUD_URL}/privacy   (install telemetry; crash reporting is on by default — opt out in Settings)"

# ─── Reseller storefront domain prompt ────────────────────────────────────────
# Reseller installs run a public storefront (hosting-company website). Ask where to
# serve it. Only when profile=reseller and a domain exists; skipped non-interactively
# unless ORBIX_STOREFRONT_DOMAIN is preset. 'same' = the site root becomes the
# storefront; otherwise a subdomain/separate host gets its own nginx server block.
if [[ "$ORBIXPANEL_PROFILE" == "reseller" && -n "$ORBIX_DOMAIN" ]]; then
  if [[ -z "$ORBIX_STOREFRONT_DOMAIN" ]]; then
    echo ""
    echo "  Where should your public storefront (hosting website) live?"
    echo "    1) Same as ${ORBIX_DOMAIN}  — your homepage becomes the storefront (default)"
    echo "    2) A subdomain or separate domain"
    echo "    3) Skip for now — configure it later in the panel"
    echo ""
    read -rp "  Choose [1]: " _sf_choice < /dev/tty
    case "$_sf_choice" in
      2)
        read -rp "  Storefront domain (e.g. hosting.${ORBIX_DOMAIN}): " ORBIX_STOREFRONT_DOMAIN < /dev/tty
        ;;
      3) ORBIX_STOREFRONT_DOMAIN="" ;;
      *) ORBIX_STOREFRONT_DOMAIN="$ORBIX_DOMAIN" ;;
    esac
  fi
  # Normalise (strip spaces/case/www.); accept the sentinel 'same'.
  ORBIX_STOREFRONT_DOMAIN=$(echo "$ORBIX_STOREFRONT_DOMAIN" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]' | sed 's|^www\.||')
  [[ "$ORBIX_STOREFRONT_DOMAIN" == "same" ]] && ORBIX_STOREFRONT_DOMAIN="$ORBIX_DOMAIN"
  if [[ -n "$ORBIX_STOREFRONT_DOMAIN" ]]; then
    if [[ "$ORBIX_STOREFRONT_DOMAIN" == "$ORBIX_DOMAIN" ]]; then
      ok "Storefront:  $ORBIX_STOREFRONT_DOMAIN (site root)"
    else
      ok "Storefront:  $ORBIX_STOREFRONT_DOMAIN"
    fi
  fi
fi

if [[ -n "$ORBIX_DOMAIN" ]]; then
  ok "Domain:      $ORBIX_DOMAIN"
  echo ""
  echo -e "  ${YELLOW}${BOLD}━━━  DNS Setup Required  ━━━${RESET}"
  echo "  Add these A records at your registrar before continuing."
  echo "  DNS changes typically propagate in 5–30 minutes."
  echo ""
  printf "  %-8s  %-36s  %s\n" "Type"   "Name"               "Value"
  printf "  %-8s  %-36s  %s\n" "──────" "──────────────────────────────────────" "───────────────"
  printf "  %-8s  %-36s  %s\n" "A"      "$ORBIX_DOMAIN"       "$ORBIX_SERVER_IP"
  printf "  %-8s  %-36s  %s\n" "A"      "www.$ORBIX_DOMAIN"   "$ORBIX_SERVER_IP"
  # A separate storefront host that isn't the apex/www needs its own A record too.
  if [[ -n "$ORBIX_STOREFRONT_DOMAIN" && "$ORBIX_STOREFRONT_DOMAIN" != "$ORBIX_DOMAIN" && "$ORBIX_STOREFRONT_DOMAIN" != "www.$ORBIX_DOMAIN" ]]; then
    printf "  %-8s  %-36s  %s\n" "A"      "$ORBIX_STOREFRONT_DOMAIN"   "$ORBIX_SERVER_IP"
  fi
  echo ""
  echo -e "  ${BOLD}Panel will be at:${RESET}   https://${ORBIX_DOMAIN}:8080"
  echo -e "  ${BOLD}Website will be at:${RESET} https://${ORBIX_DOMAIN}"
  if [[ -n "$ORBIX_STOREFRONT_DOMAIN" && "$ORBIX_STOREFRONT_DOMAIN" != "$ORBIX_DOMAIN" ]]; then
    echo -e "  ${BOLD}Storefront at:${RESET}      https://${ORBIX_STOREFRONT_DOMAIN}"
  fi
  echo ""
  read -rp "  Press Enter once the A records are saved (or already done): " _ < /dev/tty
else
  ok "No domain — IP-only install (HTTP on port 80)."
fi

# ─── Credentials (generated once, reused on re-runs) ─────────────────────────
section "Credentials"

mkdir -p "$CONF_DIR" "${CONF_DIR}/apps"
chmod 751 "$CONF_DIR"

# Read existing values on re-run so we don't rotate secrets
DB_PASS=""
JWT_SECRET=""
if [[ -f "$BACKEND_ENV" ]]; then
  DB_PASS=$(grep    "^DB_PASSWORD=" "$BACKEND_ENV" 2>/dev/null | cut -d= -f2- || true)
  JWT_SECRET=$(grep "^JWT_SECRET="  "$BACKEND_ENV" 2>/dev/null | cut -d= -f2- || true)
fi
DB_PASS="${ORBIX_DB_PASSWORD:-${DB_PASS:-$(openssl rand -hex 16)}}"
JWT_SECRET="${ORBIX_JWT_SECRET:-${JWT_SECRET:-$(openssl rand -hex 32)}}"
DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@localhost:5432/${DB_NAME}"

ok "Credentials resolved."

# ─── JSON / API helpers ───────────────────────────────────────────────────────
json_get() {
  python3 - "$1" "$2" <<'PY'
import json, sys
data = json.loads(sys.argv[1])
v = data.get(sys.argv[2], "")
# Normalize JSON booleans/None so shell string comparisons work (True -> "true").
if isinstance(v, bool):
    print("true" if v else "false")
elif v is None:
    print("")
else:
    print(v)
PY
}

api_post() {
  local path="$1" body="$2" token="${3:-}"
  local response http_code body_part

  if [[ -n "$token" ]]; then
    response=$(curl -s -w "\n%{http_code}" \
      -X POST "${ORBIX_API_URL}${path}" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer ${token}" \
      -d "$body" --connect-timeout 10 --max-time 15)
  else
    response=$(curl -s -w "\n%{http_code}" \
      -X POST "${ORBIX_API_URL}${path}" \
      -H "Content-Type: application/json" \
      -d "$body" --connect-timeout 10 --max-time 15)
  fi

  http_code=$(echo "$response" | tail -1)
  body_part=$(echo "$response" | head -n -1)

  if [[ -z "$http_code" || "$http_code" == "000" ]]; then
    die "API ${path}: no response — is the backend running? Check: journalctl -u orbixpanel-backend -n 30"
  fi
  if [[ "$http_code" -ge 400 ]]; then
    local msg
    msg=$(json_get "$body_part" "message" 2>/dev/null || echo "$body_part")
    die "API ${path} returned HTTP ${http_code}: ${msg}"
  fi
  echo "$body_part"
}

# git_clone <repo_url> <dest>  — or —  git_pull <dest> <branch>
# Injects GITHUB_TOKEN into HTTPS URLs when set so private repos work without
# interactive prompts. Disables the credential helper entirely so no system
# keyring or stored credentials interfere with anonymous public clones.
_git_url() {
  local url="$1"
  if [[ -n "$GITHUB_TOKEN" ]]; then
    # Embed token: https://TOKEN@github.com/... (strip the scheme, re-prepend with
    # the token — avoids the literal backslashes a ${//} pattern-substitution leaves,
    # which produced an unparseable "https:\/\/token@..." and broke private clones).
    echo "https://${GITHUB_TOKEN}@${url#https://}"
  else
    echo "$url"
  fi
}

_git_die_hint() {
  local url="$1"
  die "git authentication failed for ${url}
  The repository is private or your credentials are missing.
  Options:
    1. Make the repo public on GitHub (recommended for open-source).
    2. Set GITHUB_TOKEN before running the installer:
         GITHUB_TOKEN=ghp_yourtoken sudo bash install.sh
       Generate a token at: https://github.com/settings/tokens
       (classic token, 'repo' read scope is sufficient)"
}

git_clone() {
  local url="$1" dest="$2" branch="${3:-main}"
  GIT_TERMINAL_PROMPT=0 git -c credential.helper= \
    clone --quiet --depth 1 --branch "$branch" "$(_git_url "$url")" "$dest" \
    || _git_die_hint "$url"
}

git_pull() {
  local dest="$1" branch="${2:-main}"
  local url
  url=$(git -C "$dest" remote get-url origin 2>/dev/null || echo "$dest")
  GIT_TERMINAL_PROMPT=0 git -c credential.helper= -C "$dest" fetch --quiet origin \
    || _git_die_hint "$url"
  GIT_TERMINAL_PROMPT=0 git -c credential.helper= -C "$dest" checkout --quiet "$branch"
  GIT_TERMINAL_PROMPT=0 git -c credential.helper= -C "$dest" pull --quiet origin "$branch"
}

# ── Pre-built release helpers ─────────────────────────────────────────────────
# No token → download pre-built artifacts from the PUBLIC dist repo (default).
# With GITHUB_TOKEN → pull release assets from the private source repos' API.
# Set BUILD_FROM_SOURCE=1 to compile from source instead (needs a token).

_core_release_fetched=0
_web_release_fetched=0

# Download a release asset from the PUBLIC dist repo over plain HTTPS (no auth).
# Returns non-zero on failure so callers can give a clear message / fall back.
_dl_public_asset() {
  local tag="$1" name="$2" dest="$3"
  printf "  ${CYAN}↓${RESET}  Downloading %s…\n" "$name"
  if curl -fL --progress-bar -o "$dest" "${ORBIX_DIST_BASE}/${tag}/${name}"; then
    ok "Downloaded: ${name}"
    return 0
  fi
  return 1
}

# Best-effort: record which public build this box installed. The dist releases
# carry a small per-component manifest (backend/frontend/agent.version.json with
# the commit + build time) next to each asset. We save it beside the install
# config so the final summary — and operators later — can see the exact build.
# Silent on any failure (custom mirror, source build, offline): build info is a
# nicety, never a gate.
_record_build_manifest() {
  local tag="$1" name="$2"
  mkdir -p "$CONF_DIR" 2>/dev/null || true
  curl -fsL --max-time 15 -o "${CONF_DIR}/${name}" "${ORBIX_DIST_BASE}/${tag}/${name}" 2>/dev/null \
    || { rm -f "${CONF_DIR}/${name}" 2>/dev/null; return 0; }
}

# Pull one string field out of a saved version manifest (no jq dependency on the
# target). Usage: _build_field backend.version.json shortSha
_build_field() {
  local f="${CONF_DIR}/$1" key="$2"
  [[ -f "$f" ]] || return 1
  sed -n "s/.*\"${key}\":\"\([^\"]*\)\".*/\1/p" "$f" | head -1
}

_fetch_release() {
  local repo="$1" cache="$2" flag="$3" tag="${4:-latest}"
  # Public (no-token) path downloads assets directly by URL — no release JSON needed.
  [[ -z "$GITHUB_TOKEN" ]] && return 0
  local v; eval "v=\$$flag"
  [[ "$v" == "1" ]] && return 0
  # Use -sS (not -f) so curl doesn't exit non-zero on 4xx — that would cause
  # the $() subshell's || echo "000" to fire and concatenate "000" to the code.
  local http_code
  http_code=$(curl -sSL \
    -H "Authorization: Bearer ${GITHUB_TOKEN}" \
    -H "Accept: application/vnd.github+json" \
    -H "X-GitHub-Api-Version: 2022-11-28" \
    -o "$cache" -w "%{http_code}" \
    "https://api.github.com/repos/${repo}/releases/tags/${tag}" 2>/dev/null) || http_code="000"
  case "$http_code" in
    200) eval "${flag}=1" ;;
    401|403) die "GITHUB_TOKEN rejected (HTTP ${http_code}) for ${repo}. Ensure the token has 'repo' read scope." ;;
    404) if [[ -z "$GITHUB_TOKEN" ]]; then
           die "GitHub returned 404 for ${repo} — token is missing. Run: export GITHUB_TOKEN=github_pat_... && sudo -E bash install.sh"
         else
           die "GitHub returned 404 for ${repo}. Either the token lacks 'repo' scope or no 'latest' release exists. Push to main to trigger a GitHub Actions build."
         fi ;;
    000) die "Could not reach GitHub API. Check network connectivity." ;;
    *)   die "GitHub API returned HTTP ${http_code} for ${repo}." ;;
  esac
}

_dl_asset() {
  local name="$1" dest="$2" cache="$3"
  local asset_url
  asset_url=$(python3 - <<PYEOF
import json, sys
with open('${cache}') as f:
    data = json.load(f)
for a in data.get('assets', []):
    if a['name'] == '${name}':
        print(a['url'])
        sys.exit(0)
sys.exit(1)
PYEOF
) || die "Asset '${name}' not found in latest release. Re-push to main to trigger a fresh build."
  # Use API URL + Accept: application/octet-stream for private repo asset downloads.
  # --progress-bar prints a live progress bar; redirect stderr to tty so it shows
  # even when stdout is captured elsewhere.
  printf "  ${CYAN}↓${RESET}  Downloading %s…\n" "$name"
  curl -fL \
    -H "Authorization: Bearer ${GITHUB_TOKEN}" \
    -H "Accept: application/octet-stream" \
    --progress-bar \
    -o "$dest" \
    "$asset_url" \
    || die "Download failed for ${name}. Check network and GITHUB_TOKEN."
  ok "Downloaded: ${name}"
}

_fetch_core_release() { _fetch_release "$ORBIX_CORE_REPO" /tmp/orbix-core-release.json _core_release_fetched; }
_fetch_web_release()  { _fetch_release "$ORBIX_WEB_REPO"  /tmp/orbix-web-release.json  _web_release_fetched; }
_agent_release_fetched=0
_fetch_agent_release() { _fetch_release "$ORBIX_AGENT_REPO" /tmp/orbix-agent-release.json _agent_release_fetched "agent-latest"; }
_dl_backend()  {
  if [[ -n "$GITHUB_TOKEN" ]]; then _dl_asset "backend-dist.tar.gz" "$1" /tmp/orbix-core-release.json
  else _dl_public_asset latest "backend-dist.tar.gz" "$1" \
    || die "Could not download the backend from ${ORBIX_DIST_BASE}/latest. Check your network, or set GITHUB_TOKEN + BUILD_FROM_SOURCE=1 to build from source."
    _record_build_manifest latest backend.version.json; fi
}
_dl_frontend() {
  if [[ -n "$GITHUB_TOKEN" ]]; then _dl_asset "frontend-dist.tar.gz" "$1" /tmp/orbix-web-release.json
  else _dl_public_asset latest "frontend-dist.tar.gz" "$1" \
    || die "Could not download the frontend from ${ORBIX_DIST_BASE}/latest. Check your network, or set GITHUB_TOKEN + BUILD_FROM_SOURCE=1 to build from source."
    _record_build_manifest latest frontend.version.json; fi
}

# Host architecture as a Go GOARCH (amd64/arm64), or empty if unsupported.
_agent_arch() { uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/; s/arm64/arm64/' | grep -E '^(amd64|arm64)$' || true; }

# True when we should download a pre-built, key-embedded agent binary for this arch.
# Pre-built is the default (public dist repo); a token just switches the source to
# the private repo. BUILD_FROM_SOURCE=1 opts out.
_use_prebuilt_agent() {
  [[ "$BUILD_FROM_SOURCE" != "1" && -n "$(_agent_arch)" ]]
}

# Non-fatal check: is a pre-built binary for this arch actually available? Lets the
# installer fall back to a source build (e.g. before the agent-latest release exists,
# or on QEMU-virtualised ARM64) instead of failing.
_agent_prebuilt_available() {
  _use_prebuilt_agent || return 1
  local arch; arch="$(_agent_arch)"
  if [[ -z "$GITHUB_TOKEN" ]]; then
    # Public path: is the binary present in the public dist repo? (HEAD, no auth)
    curl -sSfLI -o /dev/null "${ORBIX_DIST_BASE}/agent-latest/orbixpanel-agent-linux-${arch}" 2>/dev/null
    return $?
  fi
  local code
  code=$(curl -sSL -H "Authorization: Bearer ${GITHUB_TOKEN}" -H "Accept: application/vnd.github+json" \
    -o /tmp/orbix-agent-release.json -w "%{http_code}" \
    "https://api.github.com/repos/${ORBIX_AGENT_REPO}/releases/tags/agent-latest" 2>/dev/null) || code="000"
  [[ "$code" == "200" ]] || return 1
  _agent_release_fetched=1
  python3 -c "import json,sys; d=json.load(open('/tmp/orbix-agent-release.json')); sys.exit(0 if any(a['name']=='orbixpanel-agent-linux-${arch}' for a in d.get('assets',[])) else 1)" 2>/dev/null || return 1
  return 0
}

# Download + checksum-verify the pre-built agent binary for this arch, install to AGENT_BIN.
_install_prebuilt_agent() {
  local arch; arch="$(_agent_arch)"
  local asset="orbixpanel-agent-linux-${arch}"
  if [[ -z "$GITHUB_TOKEN" ]]; then
    _dl_public_asset agent-latest "$asset" /tmp/orbix-agent.bin \
      || die "Could not download the agent from ${ORBIX_DIST_BASE}/agent-latest."
    _dl_public_asset agent-latest "SHA256SUMS" /tmp/orbix-agent.sums \
      || die "Could not download the agent checksums from ${ORBIX_DIST_BASE}/agent-latest."
    _record_build_manifest agent-latest agent.version.json
  else
    _fetch_agent_release
    _dl_asset "$asset" /tmp/orbix-agent.bin /tmp/orbix-agent-release.json
    _dl_asset "SHA256SUMS" /tmp/orbix-agent.sums /tmp/orbix-agent-release.json
  fi
  local want got
  want=$(grep " ${asset}\$" /tmp/orbix-agent.sums 2>/dev/null | awk '{print $1}')
  got=$(sha256sum /tmp/orbix-agent.bin 2>/dev/null | awk '{print $1}')
  if [[ -z "$want" || "$want" != "$got" ]]; then
    rm -f /tmp/orbix-agent.bin /tmp/orbix-agent.sums
    die "Agent binary checksum verification failed (${asset}). Aborting for safety — set BUILD_FROM_SOURCE=1 to build locally."
  fi
  install -m 0755 /tmp/orbix-agent.bin "$AGENT_BIN"
  rm -f /tmp/orbix-agent.bin /tmp/orbix-agent.sums
  ok "Pre-built agent installed + checksum-verified (${arch})."
}

_use_prebuilt() {
  # Pre-built is the default. A token switches the source to the private repos;
  # without one we download from the public dist repo. BUILD_FROM_SOURCE=1 opts out.
  [[ "$BUILD_FROM_SOURCE" != "1" ]]
}

# _env_ensure <file> <VAR> <value> — append VAR=value only when VAR isn't set yet,
# so re-runs add new keys without ever clobbering existing secrets.
_env_ensure() {
  local file="$1" var="$2" val="$3"
  grep -q "^${var}=" "$file" 2>/dev/null || echo "${var}=${val}" >> "$file"
}

# _env_get <file> <VAR> — current value of VAR, or empty.
_env_get() { sed -n "s/^$2=//p" "$1" 2>/dev/null | head -1; }

# A stable, non-reversible id for this server. Seeded from the machine-id (stable
# across reboots AND reinstalls) so re-installing reuses the same license instead
# of minting a new one. Falls back to the primary MAC, then the hostname.
_server_fingerprint() {
  local seed=""
  [[ -r /etc/machine-id ]] && seed="$(cat /etc/machine-id 2>/dev/null)"
  [[ -z "$seed" && -r /var/lib/dbus/machine-id ]] && seed="$(cat /var/lib/dbus/machine-id 2>/dev/null)"
  [[ -z "$seed" ]] && seed="$(ip link 2>/dev/null | awk '/link\/ether/{print $2; exit}')"
  [[ -z "$seed" ]] && seed="$(hostname)"
  printf '%s' "orbix:${seed}" | sha256sum | awk '{print $1}'
}

# ─── 1. System prerequisites ──────────────────────────────────────────────────
section "System Prerequisites"

# Sync system clock — apt will refuse repos whose InRelease files appear to be
# "in the future" if the server clock is behind.
if command -v timedatectl &>/dev/null; then
  timedatectl set-ntp true 2>/dev/null || true
fi
if command -v chronyc &>/dev/null; then
  chronyc makestep 2>/dev/null || true
elif command -v ntpdate &>/dev/null; then
  ntpdate -u pool.ntp.org 2>/dev/null || true
fi

# Suppress needrestart's interactive "which services to restart?" dialog. On
# Ubuntu 22.04+/24.04 it runs as an apt post-invoke hook and, in a non-interactive
# session, hangs forever holding the apt + debconf locks — deadlocking the install.
# NEEDRESTART_MODE=a auto-restarts services without prompting. Exported globally so
# every apt-get call below inherits it (alongside DEBIAN_FRONTEND).
export NEEDRESTART_MODE=a NEEDRESTART_SUSPEND=1 DEBIAN_FRONTEND=noninteractive

# Stop unattended-upgrades and wait for dpkg locks to clear.
# On fresh DigitalOcean droplets this service starts automatically and holds
# the dpkg frontend lock for several minutes, blocking any apt-get call.
systemctl stop unattended-upgrades 2>/dev/null || true
systemctl stop apt-daily.service apt-daily-upgrade.service 2>/dev/null || true
LOCK_WAIT=0
while fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock \
             /var/lib/apt/lists/lock /var/cache/apt/archives/lock \
             >/dev/null 2>&1; do
  if [[ $LOCK_WAIT -ge 120 ]]; then
    warn "apt lock held for >120 s — killing blocking processes"
    kill -9 "$(lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null)" 2>/dev/null || true
    break
  fi
  info "Waiting for apt lock… (${LOCK_WAIT}s)"
  sleep 5
  LOCK_WAIT=$((LOCK_WAIT + 5))
done

# Repair any broken dpkg state left by previous partial installs.
dpkg --configure -a --force-confold 2>/dev/null || true
DEBIAN_FRONTEND=noninteractive apt-get -f install -y -qq 2>/dev/null || true

# Update package index — tolerate single-repo failures (e.g. security.ubuntu.com
# unreachable on first boot) but still abort on hard errors.
apt-get -o Acquire::Check-Valid-Until=false \
        -o Acquire::Retries=3 \
        update -qq 2>&1 | grep -v '^W:' || true

# Pre-seed debconf so postfix installs non-interactively as "Internet Site".
echo "postfix postfix/mailname string $(hostname -f)" | debconf-set-selections
echo "postfix postfix/main_mailer_type string 'Internet Site'" | debconf-set-selections

# ── Install lead beacon (marketing) ──────────────────────────────────────────
# Record this attempt with the cloud EARLY (before the heavy install) so even a
# mid-install failure or an abandoned run is captured for follow-up. Best-effort:
# rate-limited server-side, short timeout, never blocks or fails the install.
if [[ -n "$ORBIX_CLOUD_URL" ]]; then
  _lead_fp="$(_server_fingerprint 2>/dev/null || echo '')"
  if [[ -n "$_lead_fp" ]]; then
    _lead_json='{"fingerprint":"'"$_lead_fp"'","stage":"started"'
    [[ -n "$ORBIX_ADMIN_EMAIL"   ]] && _lead_json="$_lead_json"',"email":"'"$ORBIX_ADMIN_EMAIL"'"'
    [[ -n "$ORBIX_DOMAIN"        ]] && _lead_json="$_lead_json"',"domain":"'"$ORBIX_DOMAIN"'"'
    [[ -n "$ORBIX_REFERRAL_CODE" ]] && _lead_json="$_lead_json"',"referralCode":"'"$ORBIX_REFERRAL_CODE"'"'
    _lead_json="$_lead_json}"
    curl -fsS --max-time 8 -X POST "${ORBIX_CLOUD_URL}/api/install-lead" \
      -H 'Content-Type: application/json' -d "$_lead_json" >/dev/null 2>&1 || true
  fi
fi

# Disk-space preflight — a full install (packages, DBs, node_modules, swap,
# backups) needs headroom; the common 8 GB cloud default fills up mid-install
# (ENOSPC). Warn loudly but don't block (user may have a separate data volume).
_AVAIL_GB=$(df -Pk / 2>/dev/null | awk 'NR==2{printf "%d", $4/1024/1024}')
if [[ -n "$_AVAIL_GB" && "$_AVAIL_GB" -lt 10 ]]; then
  warn "Low disk space: only ${_AVAIL_GB} GB free on / — OrbixPanel needs ~20 GB."
  warn "Grow the root disk before continuing to avoid an out-of-space failure mid-install."
fi

DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
  curl git ca-certificates gnupg lsb-release python3 openssl ufw cron \
  bind9 bind9utils build-essential zip unzip \
  postfix dovecot-core dovecot-imapd dovecot-pop3d \
  php-fpm > /dev/null
systemctl enable --now cron 2>/dev/null || true
ok "Base tools ready."

# ─── 1b. Self-repair: FileBrowser ────────────────────────────────────────────
if [[ -f /usr/local/bin/filebrowser ]]; then
  if ! timeout 5 /usr/local/bin/filebrowser --help &>/dev/null 2>&1; then
    # Binary is non-functional (wrong arch / corrupted) — wipe everything so
    # the UI reinstall downloads a clean binary.
    info "Non-functional FileBrowser binary detected — removing for clean reinstall."
    systemctl stop    filebrowser 2>/dev/null || true
    systemctl disable filebrowser 2>/dev/null || true
    rm -f  /usr/local/bin/filebrowser
    rm -f  /etc/systemd/system/filebrowser.service
    rm -rf /var/lib/filebrowser
    rm -f  /etc/nginx/conf.d/filebrowser.conf
    rm -f  /etc/nginx/.filebrowser.htpasswd
    systemctl daemon-reload 2>/dev/null || true
    systemctl reload nginx  2>/dev/null || true
    ok "FileBrowser state cleared — reinstall from the OrbixPanel UI."
  elif systemctl is-enabled filebrowser &>/dev/null 2>&1; then

    # Binary is fine but check for missing users — noauth requires at least
    # one user (ID 1) or every login attempt returns 500.
    FB_DB="/var/lib/filebrowser/filebrowser.db"
    USER_COUNT=0
    if [[ -f "$FB_DB" ]]; then
      USER_COUNT=$(/usr/local/bin/filebrowser users ls --database "$FB_DB" 2>/dev/null \
                   | tail -n +2 | wc -l) || USER_COUNT=0
      USER_COUNT="${USER_COUNT//[[:space:]]/}"
    fi
    if [[ "$USER_COUNT" -eq 0 ]]; then
      info "FileBrowser: no users in database — resetting and reconfiguring."
      systemctl stop filebrowser 2>/dev/null || true
      rm -f "$FB_DB"
      mkdir -p "$(dirname "$FB_DB")"
      /usr/local/bin/filebrowser config init --database "$FB_DB" >/dev/null 2>&1 || true
      /usr/local/bin/filebrowser config set \
        --address 127.0.0.1 --port 9090 --root /var/www --database "$FB_DB" >/dev/null 2>&1 || true
      /usr/local/bin/filebrowser config set --auth.method=noauth --database "$FB_DB" >/dev/null 2>&1 || true
      /usr/local/bin/filebrowser users add admin admin --perm.admin --database "$FB_DB" >/dev/null 2>&1 || true
      systemctl start filebrowser 2>/dev/null || true
      ok "FileBrowser database repaired — auto-login active."
    fi
  fi
fi

# ─── 1c. Self-repair: MinIO ──────────────────────────────────────────────────
# MinIO is built from source to avoid SIGSEGV on virtualised ARM64 (UTM/Apple
# Silicon). If the installed binary is non-functional, wipe all state so the
# user can cleanly reinstall from the OrbixPanel UI.
if [[ -f /usr/local/bin/minio ]]; then
  if ! timeout 10 /usr/local/bin/minio --version &>/dev/null 2>&1; then
    info "Non-functional MinIO binary detected — removing for clean reinstall."
    systemctl stop    minio 2>/dev/null || true
    systemctl disable minio 2>/dev/null || true
    rm -f  /usr/local/bin/minio
    rm -f  /etc/systemd/system/minio.service
    rm -rf /var/lib/minio
    rm -rf /etc/minio
    rm -f  /etc/nginx/conf.d/minio.conf
    systemctl daemon-reload 2>/dev/null || true
    systemctl reload nginx  2>/dev/null || true
    # Remove the DB record so the panel UI shows "not installed" correctly.
    sudo -u postgres psql -d "${DB_NAME}" \
      -c "DELETE FROM \"MinioConfig\";" 2>/dev/null || true
    ok "MinIO state cleared — reinstall from the OrbixPanel UI."
  fi
fi

# ─── 1d. PHP-FPM ─────────────────────────────────────────────────────────────
# php-fpm is required for phpMyAdmin (and any PHP website) to work under Nginx.
# The agent's phpMyAdmin installer also installs it, but it must be available
# from the start so the socket path can be detected even before phpMyAdmin is
# installed. Enable and start whichever version apt resolved.
PHP_FPM_SVC=$(systemctl list-units --type=service --all 2>/dev/null \
  | awk '/php[0-9]+-fpm\.service/{print $1; exit}') || true
if [[ -n "$PHP_FPM_SVC" ]]; then
  systemctl enable "$PHP_FPM_SVC" --quiet 2>/dev/null || true
  if systemctl is-active --quiet "$PHP_FPM_SVC" 2>/dev/null; then
    ok "PHP-FPM (${PHP_FPM_SVC}) already running."
  else
    systemctl start "$PHP_FPM_SVC" 2>/dev/null || true
    ok "PHP-FPM (${PHP_FPM_SVC}) started."
  fi
else
  warn "PHP-FPM service not found — phpMyAdmin may not work until PHP is installed."
fi

# Composer — required for git-deploying PHP / Laravel apps.
if [[ -n "$PHP_FPM_SVC" ]] && ! command -v composer >/dev/null 2>&1; then
  if command -v php >/dev/null 2>&1; then
    curl -sS https://getcomposer.org/installer 2>/dev/null \
      | php -- --install-dir=/usr/local/bin --filename=composer --quiet 2>/dev/null \
      && ok "Composer installed." || warn "Composer install failed (Laravel deploys need it)."
  else
    env DEBIAN_FRONTEND=noninteractive apt-get install -y -q php-cli >/dev/null 2>&1 || true
    command -v php >/dev/null 2>&1 && curl -sS https://getcomposer.org/installer 2>/dev/null \
      | php -- --install-dir=/usr/local/bin --filename=composer --quiet 2>/dev/null \
      && ok "Composer installed." || warn "Composer not installed (Laravel deploys need it)."
  fi
fi

# Allow root (sudo) to operate on source dirs owned by the orbixpanel user.
# Git 2.35.2+ refuses to work in directories owned by a different UID.
# This is idempotent — git ignores duplicate safe.directory entries.
git config --global --add safe.directory "$CORE_SRC"    2>/dev/null || true
git config --global --add safe.directory "$FRONTEND_SRC" 2>/dev/null || true
git config --global --add safe.directory "$AGENT_SRC"    2>/dev/null || true

# ─── 2. Nginx ─────────────────────────────────────────────────────────────────
section "Nginx"

if [[ "$SKIP_NGINX" == "1" ]]; then
  warn "Skipping nginx (SKIP_NGINX=1)."
elif command -v nginx &>/dev/null; then
  ok "nginx already installed."
else
  run_spinner "Installing nginx…" /tmp/apt-nginx.log \
    env DEBIAN_FRONTEND=noninteractive apt-get install -y -q nginx
  systemctl enable nginx --quiet
  systemctl start nginx
  ok "nginx installed and started."
fi

# Global upload-size cap — ONE place (http context) so every server block agrees
# with the backend's body limit. Without this, nginx defaults to 1 MB and returns
# 413 on any real file-manager / app-zip upload before it reaches the backend.
if [[ "$SKIP_NGINX" != "1" ]] && command -v nginx &>/dev/null; then
  cat > /etc/nginx/conf.d/orbix-upload.conf <<NGINXUP
# OrbixPanel — max request body for file-manager / app-zip uploads.
# Managed by install.sh (ORBIX_MAX_UPLOAD_MB). Re-run the installer to change.
client_max_body_size ${ORBIX_MAX_UPLOAD_MB}m;
NGINXUP
  if nginx -t >/dev/null 2>&1; then
    systemctl reload nginx 2>/dev/null || true
    ok "Upload limit set to ${ORBIX_MAX_UPLOAD_MB} MB (nginx client_max_body_size)."
  else
    warn "Upload-limit snippet written but nginx -t failed; it applies on the next successful reload."
  fi
fi

# ─── 3. PostgreSQL + database setup ───────────────────────────────────────────
section "PostgreSQL"

if [[ "$SKIP_POSTGRESQL" == "1" ]]; then
  warn "Skipping postgresql (SKIP_POSTGRESQL=1)."
else
  if ! command -v psql &>/dev/null; then
    run_spinner "Installing postgresql…" /tmp/apt-postgres.log \
      env DEBIAN_FRONTEND=noninteractive apt-get install -y -q postgresql postgresql-contrib
    systemctl enable postgresql --quiet
    systemctl start postgresql
    ok "postgresql installed and started."
  else
    ok "postgresql already installed."
  fi

  # Create DB user (or update password if it already exists).
  # Always sync the password so DATABASE_URL and the PostgreSQL user stay in
  # agreement — a leftover user from a failed uninstall would otherwise cause
  # P1000 authentication errors during migration.
  if sudo -u postgres psql -tAc \
      "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" 2>/dev/null | grep -q 1; then
    sudo -u postgres psql -c \
      "ALTER USER ${DB_USER} WITH PASSWORD '${DB_PASS}';" > /dev/null
    ok "DB user '${DB_USER}' already exists — password synced."
  else
    sudo -u postgres psql -c \
      "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';" > /dev/null
    ok "DB user '${DB_USER}' created."
  fi

  # Grant CREATEDB + CREATEROLE so the Database Manager (Phase 10) can
  # create/drop databases and users on behalf of operators.
  sudo -u postgres psql -c \
    "ALTER USER ${DB_USER} CREATEDB CREATEROLE;" > /dev/null
  ok "DB user '${DB_USER}' granted CREATEDB CREATEROLE."

  # Create database
  if sudo -u postgres psql -tAc \
      "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" 2>/dev/null | grep -q 1; then
    ok "Database '${DB_NAME}' already exists."
  else
    sudo -u postgres psql -c \
      "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};" > /dev/null
    ok "Database '${DB_NAME}' created."
  fi
fi

# ─── 4. Redis ─────────────────────────────────────────────────────────────────
section "Redis"

_redis_active() {
  systemctl is-active --quiet redis-server 2>/dev/null || \
  systemctl is-active --quiet redis 2>/dev/null
}

if [[ "$SKIP_REDIS" == "1" ]]; then
  warn "Skipping redis (SKIP_REDIS=1)."
elif _redis_active; then
  ok "redis already running."
elif command -v redis-server &>/dev/null; then
  info "redis installed but not running — starting…"
  systemctl enable redis-server --quiet 2>/dev/null || \
    systemctl enable redis --quiet 2>/dev/null || true
  systemctl start redis-server 2>/dev/null || systemctl start redis 2>/dev/null
  ok "redis started."
else
  run_spinner "Installing redis-server…" /tmp/apt-redis.log \
    env DEBIAN_FRONTEND=noninteractive apt-get install -y -q redis-server
  [[ -f /etc/redis/redis.conf ]] && \
    sed -i 's/^bind .*/bind 127.0.0.1 -::1/' /etc/redis/redis.conf
  systemctl enable redis-server --quiet
  systemctl start redis-server
  ok "redis-server installed and started."
fi

# ─── 5. BIND9 (DNS server) ────────────────────────────────────────────────────
section "BIND9 DNS Server"

# bind9 and bind9utils were installed in prerequisites.
# Create the zones directory where OrbixPanel stores per-domain zone files.
mkdir -p /etc/bind/zones

# Ensure named.conf.local exists so the agent can append zone blocks on first use.
touch /etc/bind/named.conf.local

# ── Self-repair named.conf.local ──────────────────────────────────────────────
# Older agent versions had a regex bug that left stray "};" lines in the file on
# every dns-sync, eventually breaking named-checkconf. This Python script extracts
# only valid "zone ... { ... };" blocks and rewrites the file cleanly.
python3 - /etc/bind/named.conf.local << 'BINDFIX'
import re, sys, os
path = sys.argv[1]
try:
    content = open(path).read()
    # Extract only structurally valid zone blocks (handles one level of nested braces)
    blocks = re.findall(
        r'zone\s+"[^"]+"\s*\{(?:[^{}]*|\{[^{}]*\})*\};',
        content, re.DOTALL
    )
    cleaned = '\n'.join(b.strip() for b in blocks)
    if cleaned:
        cleaned += '\n'
    if cleaned != content.rstrip('\n') + ('\n' if content else ''):
        open(path, 'w').write(cleaned)
        print(f'named.conf.local repaired ({len(blocks)} zone blocks retained)')
    else:
        print('named.conf.local OK — no repair needed')
except Exception as e:
    print(f'named.conf.local repair skipped: {e}', file=sys.stderr)
BINDFIX

# Start and enable the named service (bind9 package installs it as 'named').
# Use 'bind9' as fallback for distros that name the unit differently.
if systemctl list-unit-files named.service &>/dev/null 2>&1 | grep -q named; then
  BIND_SVC="named"
else
  BIND_SVC="bind9"
fi
systemctl enable "$BIND_SVC" --quiet 2>/dev/null || true
if systemctl is-active --quiet "$BIND_SVC" 2>/dev/null; then
  ok "BIND9 (${BIND_SVC}) already running."
else
  systemctl start "$BIND_SVC" 2>/dev/null || true
  systemctl is-active --quiet "$BIND_SVC" 2>/dev/null \
    && ok "BIND9 (${BIND_SVC}) started." \
    || warn "BIND9 may not be running — DNS management requires it. Check: systemctl status ${BIND_SVC}"
fi
ok "BIND9 zones directory ready: /etc/bind/zones"

# Derive nameserver hostnames from the panel domain.
# Hosting companies register these as glue records at their registrar so
# all hosted domains can point here: ns1.yourdomain.com → this server IP.
NS1_HOSTNAME="${NS1_HOSTNAME:-${ORBIX_DOMAIN:+ns1.${ORBIX_DOMAIN}}}"
NS2_HOSTNAME="${NS2_HOSTNAME:-${ORBIX_DOMAIN:+ns2.${ORBIX_DOMAIN}}}"

# Create the initial authoritative zone for the panel domain.
# This makes ns1/ns2 and the panel domain itself resolve immediately.
if [[ -n "$ORBIX_DOMAIN" && -n "$ORBIX_SERVER_IP" ]]; then
  _ZONE_FILE="/etc/bind/zones/db.${ORBIX_DOMAIN}"
  _SERIAL=$(date -u +%Y%m%d%H)
  if [[ ! -f "$_ZONE_FILE" ]]; then
    tee "$_ZONE_FILE" > /dev/null << ZONEOF
\$ORIGIN ${ORBIX_DOMAIN}.
\$TTL 3600
@ IN SOA ns1.${ORBIX_DOMAIN}. admin.${ORBIX_DOMAIN}. (
    ${_SERIAL} ; serial
    3600        ; refresh
    900         ; retry
    604800      ; expire
    300 )       ; minimum TTL

@       86400   IN  NS    ns1.${ORBIX_DOMAIN}.
@       86400   IN  NS    ns2.${ORBIX_DOMAIN}.
@       3600    IN  A     ${ORBIX_SERVER_IP}
ns1     3600    IN  A     ${ORBIX_SERVER_IP}
ns2     3600    IN  A     ${ORBIX_SERVER_IP}
www     3600    IN  A     ${ORBIX_SERVER_IP}
mail    3600    IN  A     ${ORBIX_SERVER_IP}
@       3600    IN  MX    10 mail.${ORBIX_DOMAIN}.
@       3600    IN  TXT   "v=spf1 ip4:${ORBIX_SERVER_IP} ~all"
ZONEOF

    if ! grep -q "zone \"${ORBIX_DOMAIN}\"" /etc/bind/named.conf.local 2>/dev/null; then
      tee -a /etc/bind/named.conf.local > /dev/null << CONFEOF
zone "${ORBIX_DOMAIN}" {
    type master;
    file "/etc/bind/zones/db.${ORBIX_DOMAIN}";
    allow-transfer { none; };
};
CONFEOF
    fi

    named-checkconf 2>/dev/null \
      && named-checkzone "$ORBIX_DOMAIN" "$_ZONE_FILE" > /dev/null 2>&1 \
      && rndc reload > /dev/null 2>&1 || true
    ok "Initial DNS zone for ${ORBIX_DOMAIN} created (ns1/ns2 → ${ORBIX_SERVER_IP})."
  else
    ok "DNS zone for ${ORBIX_DOMAIN} already exists — skipping."
  fi
fi

# ─── 5b. MariaDB (MySQL-compatible database server) ──────────────────────────
section "MariaDB"

MYSQL_ADMIN_PASS=$(openssl rand -base64 24 | tr -d '+/=' | head -c 32)

if command -v mysql &>/dev/null && systemctl is-active --quiet mariadb 2>/dev/null; then
  ok "MariaDB already installed and running."
  # Read the stored password so we can re-apply grants without rotating it
  if grep -q "MYSQL_PASSWORD" "$BACKEND_ENV" 2>/dev/null; then
    MYSQL_ADMIN_PASS=$(grep MYSQL_PASSWORD "$BACKEND_ENV" | cut -d= -f2-)
  fi
  # Always sync password — CREATE OR REPLACE USER drops and recreates the user
  # atomically, so the password always matches what we write to backend.env.
  # This handles reinstalls where the env file was deleted by uninstall.sh but
  # the MariaDB user was left behind with the old password.
  mysql -u root <<SQLEOF 2>/dev/null || true
CREATE OR REPLACE USER 'orbixpanel_admin'@'127.0.0.1' IDENTIFIED BY '${MYSQL_ADMIN_PASS}';
GRANT ALL PRIVILEGES ON *.* TO 'orbixpanel_admin'@'127.0.0.1' WITH GRANT OPTION;
CREATE OR REPLACE USER 'orbixpanel_admin'@'localhost' IDENTIFIED BY '${MYSQL_ADMIN_PASS}';
GRANT ALL PRIVILEGES ON *.* TO 'orbixpanel_admin'@'localhost' WITH GRANT OPTION;
FLUSH PRIVILEGES;
SQLEOF
  ok "MySQL admin user recreated with current password for 127.0.0.1 and localhost."
else
  run_spinner "Installing MariaDB…" /tmp/apt-mariadb.log \
    env DEBIAN_FRONTEND=noninteractive apt-get install -y -q mariadb-server
  systemctl enable mariadb --quiet 2>/dev/null || true
  systemctl start mariadb
  ok "MariaDB installed and started."

  # CREATE OR REPLACE USER always sets the correct password even if a stale
  # user exists (e.g. from a previous install that wasn't fully cleaned up).
  mysql -u root <<SQLEOF
CREATE OR REPLACE USER 'orbixpanel_admin'@'127.0.0.1' IDENTIFIED BY '${MYSQL_ADMIN_PASS}';
GRANT ALL PRIVILEGES ON *.* TO 'orbixpanel_admin'@'127.0.0.1' WITH GRANT OPTION;
CREATE OR REPLACE USER 'orbixpanel_admin'@'localhost' IDENTIFIED BY '${MYSQL_ADMIN_PASS}';
GRANT ALL PRIVILEGES ON *.* TO 'orbixpanel_admin'@'localhost' WITH GRANT OPTION;
FLUSH PRIVILEGES;
SQLEOF
  ok "MySQL admin user 'orbixpanel_admin' created for 127.0.0.1 and localhost."
fi

# ─── 5c. vsftpd (FTP server) ─────────────────────────────────────────────────
section "vsftpd FTP Server"

if command -v vsftpd &>/dev/null && systemctl is-active --quiet vsftpd 2>/dev/null; then
  ok "vsftpd already installed and running."
else
  run_spinner "Installing vsftpd…" /tmp/apt-vsftpd.log \
    env DEBIAN_FRONTEND=noninteractive apt-get install -y -q vsftpd
  ok "vsftpd installed."

  cat > /etc/vsftpd.conf <<'VSFTPD'
listen=YES
listen_ipv6=NO
anonymous_enable=NO
local_enable=YES
write_enable=YES
local_umask=022
dirmessage_enable=YES
use_localtime=YES
xferlog_enable=YES
connect_from_port_20=YES
chroot_local_user=YES
allow_writeable_chroot=YES
secure_chroot_dir=/var/run/vsftpd/empty
pam_service_name=vsftpd
rsa_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem
rsa_private_key_file=/etc/ssl/private/ssl-cert-snakeoil.key
ssl_enable=NO
pasv_enable=YES
pasv_min_port=40000
pasv_max_port=50000
VSFTPD

  mkdir -p /var/run/vsftpd/empty
  systemctl enable vsftpd --quiet 2>/dev/null || true
  systemctl restart vsftpd
  ok "vsftpd configured and started."
fi

# ─── 5d-pre0. vnstat (bandwidth monitoring) ──────────────────────────────────
section "vnstat"

if command -v vnstat &>/dev/null && systemctl is-active --quiet vnstat 2>/dev/null; then
  ok "vnstat already installed and running."
else
  run_spinner "Installing vnstat…" /tmp/apt-vnstat.log \
    env DEBIAN_FRONTEND=noninteractive apt-get install -y -q vnstat
  systemctl enable vnstat --quiet 2>/dev/null || true
  systemctl start vnstat
  ok "vnstat installed and started."
fi

# ─── 5d-pre. Fail2ban ────────────────────────────────────────────────────────
section "Fail2ban"

if command -v fail2ban-client &>/dev/null && systemctl is-active --quiet fail2ban 2>/dev/null; then
  ok "fail2ban already installed and running."
else
  run_spinner "Installing fail2ban…" /tmp/apt-fail2ban.log \
    env DEBIAN_FRONTEND=noninteractive apt-get install -y -q fail2ban
  ok "fail2ban installed."

  # Minimal local jail override — enables SSH protection
  if [ ! -f /etc/fail2ban/jail.local ]; then
    cat > /etc/fail2ban/jail.local <<'FAIL2BAN'
[DEFAULT]
bantime  = 3600
findtime = 600
maxretry = 5
backend  = systemd

[sshd]
enabled = true
FAIL2BAN
    ok "fail2ban jail.local written."
  fi

  systemctl enable fail2ban --quiet 2>/dev/null || true
  systemctl restart fail2ban
  ok "fail2ban configured and started."
fi

# ─── 5d. Postfix + Dovecot (email virtual hosting) ───────────────────────────
section "Postfix + Dovecot Email Server"

# Create the vmail system user/group (UID/GID 5000) that owns all mail storage.
if getent group vmail > /dev/null 2>&1; then
  ok "Group 'vmail' already exists."
else
  groupadd -g 5000 vmail
  ok "Created group 'vmail' (GID 5000)."
fi
if id vmail > /dev/null 2>&1; then
  ok "User 'vmail' already exists."
else
  useradd -g vmail -u 5000 -d /var/mail/vhosts -s /usr/sbin/nologin vmail
  ok "Created user 'vmail' (UID 5000)."
fi

# Mail storage directory
mkdir -p /var/mail/vhosts
chown vmail:vmail /var/mail/vhosts
chmod 770 /var/mail/vhosts
ok "Mail directory /var/mail/vhosts ready."

# Postfix virtual hosting config
postconf -e "virtual_mailbox_domains = /etc/postfix/virtual_mailbox_domains"
postconf -e "virtual_mailbox_base = /var/mail/vhosts"
postconf -e "virtual_mailbox_maps = hash:/etc/postfix/virtual_mailbox_maps"
postconf -e "virtual_alias_maps = hash:/etc/postfix/virtual_alias_maps"
postconf -e "virtual_minimum_uid = 100"
postconf -e "virtual_uid_maps = static:5000"
postconf -e "virtual_gid_maps = static:5000"

# Create empty Postfix virtual map files if they don't exist
for f in /etc/postfix/virtual_mailbox_domains /etc/postfix/virtual_mailbox_maps /etc/postfix/virtual_alias_maps; do
  [[ -f "$f" ]] || { touch "$f"; postmap "$f"; }
done
ok "Postfix virtual hosting configured."

# Enable Dovecot SASL so authenticated clients can relay via submission port
postconf -e "smtpd_sasl_auth_enable = yes"
postconf -e "smtpd_sasl_type = dovecot"
postconf -e "smtpd_sasl_path = private/auth"
postconf -e "smtpd_sasl_security_options = noanonymous"
postconf -e "broken_sasl_auth_clients = yes"
postconf -e "smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, defer_unauth_destination"

# Enable submission port 587 for mail client delivery (Roundcube → Postfix)
postconf -M "submission/inet=submission inet n - n - - smtpd" 2>/dev/null || true
postconf -P "submission/inet/syslog_name=postfix/submission" 2>/dev/null || true
postconf -P "submission/inet/smtpd_tls_security_level=may" 2>/dev/null || true
postconf -P "submission/inet/smtpd_sasl_auth_enable=yes" 2>/dev/null || true
postconf -P "submission/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject" 2>/dev/null || true
ok "Postfix SASL + submission port (587) configured."

# Set sending hostname/origin so outbound mail is stamped with the right domain.
# CRITICAL: explicitly set mydestination to localhost-only — if myhostname equals
# the virtual domain, Postfix default mydestination includes that domain and routes
# mail for it through local Unix user lookup instead of virtual_mailbox_maps,
# causing "User unknown in local recipient table" for every virtual account.
if [[ -n "$ORBIX_DOMAIN" ]]; then
  postconf -e "myhostname = ${ORBIX_DOMAIN}"
  postconf -e "myorigin = ${ORBIX_DOMAIN}"
fi
postconf -e "mydestination = localhost, localhost.localdomain"
postconf -e "inet_protocols = ipv4"
postconf -e "smtpd_banner = \$myhostname ESMTP"

# Dovecot config for passwd-file auth
# Dovecot config syntax diverges at 2.4 (Ubuntu 26.04 ships Dovecot 2.4), which
# renamed settings and the per-user variables. Write the matching drop-in so the
# config validates and `systemctl reload dovecot` succeeds (the 2.3 drop-in makes
# 2.4 fail to start → "email-sync: reload dovecot: exit status 1"). Physical
# mailbox paths are identical across both (%d/%n == %{user|domain}/%{user|username}),
# so no mail data migration is needed.
DOVECOT_VER="$(dovecot --version 2>/dev/null | grep -oE '^[0-9]+\.[0-9]+' | head -1)"
DOVECOT_MAJOR="${DOVECOT_VER%%.*}"; DOVECOT_MINOR="${DOVECOT_VER#*.}"
if [ "${DOVECOT_MAJOR:-2}" -gt 2 ] 2>/dev/null || { [ "${DOVECOT_MAJOR:-2}" -eq 2 ] && [ "${DOVECOT_MINOR:-3}" -ge 4 ]; } 2>/dev/null; then
  # ── Dovecot 2.4+ (Ubuntu 26.04) ──
  # dovecot_config_version is required but MUST live at the top of the distro's
  # main dovecot.conf (the 2.4 package ships it); drop-ins must not redeclare it.
  cat > /etc/dovecot/conf.d/99-orbixpanel.conf << 'DOVEOF'
# OrbixPanel virtual mailbox configuration (Dovecot 2.4)
auth_mechanisms = plain login
auth_allow_cleartext = yes

passdb passwd-file {
  passwd_file_path = /etc/dovecot/users
  default_password_scheme = BLF-CRYPT
}

userdb passwd-file {
  passwd_file_path = /etc/dovecot/users
  fields {
    uid = vmail
    gid = vmail
    home = /var/mail/vhosts/%{user | domain}/%{user | username}
  }
}

mail_driver = maildir
mail_path = /var/mail/vhosts/%{user | domain}/%{user | username}/Maildir
mail_privileged_group = vmail

service auth {
  unix_listener /var/spool/postfix/private/auth {
    mode = 0660
    user = postfix
    group = postfix
  }
}
DOVEOF
else
  # ── Dovecot 2.3 and earlier (Ubuntu 24.04 and below) ──
  cat > /etc/dovecot/conf.d/99-orbixpanel.conf << 'DOVEOF'
# OrbixPanel virtual mailbox configuration
auth_mechanisms = plain login
disable_plaintext_auth = no

passdb {
  driver = passwd-file
  args = scheme=BLF-CRYPT /etc/dovecot/users
}

userdb {
  driver = passwd-file
  args = /etc/dovecot/users
  default_fields = uid=vmail gid=vmail home=/var/mail/vhosts/%d/%n
}

mail_location = maildir:/var/mail/vhosts/%d/%n/Maildir
mail_privileged_group = vmail

service auth {
  unix_listener /var/spool/postfix/private/auth {
    mode = 0660
    user = postfix
    group = postfix
  }
}
DOVEOF
fi

# Empty Dovecot users file (agent maintains it)
[[ -f /etc/dovecot/users ]] || touch /etc/dovecot/users
chown root:dovecot /etc/dovecot/users
chmod 640 /etc/dovecot/users
ok "Dovecot virtual mailbox config written."

# ── DKIM signing with OpenDKIM (requires ORBIX_DOMAIN) ───────────────────────
if [[ -n "$ORBIX_DOMAIN" ]]; then
  DKIM_SELECTOR="mail"
  DKIM_KEY_DIR="/etc/opendkim/keys/${ORBIX_DOMAIN}"

  run_spinner "Installing OpenDKIM…" /tmp/apt-opendkim.log \
    env DEBIAN_FRONTEND=noninteractive apt-get install -y -q opendkim opendkim-tools

  mkdir -p /etc/opendkim "$DKIM_KEY_DIR"

  # Generate 2048-bit RSA keypair (idempotent — skip if private key exists)
  if [[ ! -f "${DKIM_KEY_DIR}/${DKIM_SELECTOR}.private" ]]; then
    opendkim-genkey -b 2048 -d "${ORBIX_DOMAIN}" -s "${DKIM_SELECTOR}" -D "${DKIM_KEY_DIR}"
  fi
  chown -R opendkim:opendkim /etc/opendkim/keys
  chmod 700 "${DKIM_KEY_DIR}"
  chmod 600 "${DKIM_KEY_DIR}/${DKIM_SELECTOR}.private"

  # Trusted hosts (skip signature verification for loopback + self)
  cat > /etc/opendkim/trusted.hosts << EOF
127.0.0.1
::1
localhost
${ORBIX_DOMAIN}
EOF

  # Main OpenDKIM config
  cat > /etc/opendkim.conf << EOF
# PidFile is REQUIRED: the systemd unit on Ubuntu 26.04 is Type=forking and waits
# for this PID file; without it opendkim never signals ready and the service times
# out in a restart loop (and mail goes unsigned). /run/opendkim is created by the
# unit's RuntimeDirectory; a tmpfiles.d entry below guarantees it on all versions.
PidFile             /run/opendkim/opendkim.pid
Mode                sv
Syslog              yes
SyslogSuccess       yes
LogWhy              yes
UserID              opendkim
Socket              local:/var/spool/postfix/opendkim/opendkim.sock
UMask               002
Canonicalization    relaxed/simple
Domain              ${ORBIX_DOMAIN}
Selector            ${DKIM_SELECTOR}
KeyFile             ${DKIM_KEY_DIR}/${DKIM_SELECTOR}.private
InternalHosts       /etc/opendkim/trusted.hosts
EOF

  # Ensure the PID/runtime dir exists now and is recreated on every boot (the
  # systemd unit's RuntimeDirectory usually does this, but tmpfiles makes it
  # version-independent so opendkim can always write its PidFile).
  install -d -o opendkim -g opendkim -m 0755 /run/opendkim 2>/dev/null || mkdir -p /run/opendkim
  echo 'd /run/opendkim 0755 opendkim opendkim -' > /etc/tmpfiles.d/opendkim.conf
  systemd-tmpfiles --create /etc/tmpfiles.d/opendkim.conf 2>/dev/null || true

  # Socket dir inside Postfix chroot
  mkdir -p /var/spool/postfix/opendkim
  chown opendkim:postfix /var/spool/postfix/opendkim

  # The socket is opendkim:opendkim mode 660 (UMask 002). Add the postfix user to
  # the opendkim group so the chrooted Postfix smtpd can reach it — otherwise:
  # "connect to Milter … Permission denied" and outgoing mail goes unsigned.
  # (Changing opendkim's own group instead breaks its startup.) Postfix is
  # restarted later so it picks up the new supplementary group.
  usermod -aG opendkim postfix 2>/dev/null || true

  # Wire OpenDKIM into Postfix as a milter
  postconf -e "milter_protocol = 6"
  postconf -e "milter_default_action = accept"
  postconf -e "smtpd_milters = local:opendkim/opendkim.sock"
  postconf -e "non_smtpd_milters = local:opendkim/opendkim.sock"

  systemctl enable opendkim --quiet 2>/dev/null || true
  systemctl restart opendkim 2>/dev/null \
    && ok "OpenDKIM configured and started (DKIM signing enabled)." \
    || warn "OpenDKIM may not have started — check: journalctl -u opendkim -n 20"
fi

# ── SMTP relay — detect port 25 block, configure relay if needed ─────────────
section "SMTP Relay"
_RELAY_WRITE=0

# Helper: test which SMTP port is reachable on a given host.
# Tries 587, 465, 2525 in order; prints the first that connects; returns it.
_detect_relay_port() {
  local host="$1"
  for p in 587 465 2525; do
    if nc -zv -w5 "$host" "$p" >/dev/null 2>&1; then
      echo "$p"
      return
    fi
  done
  echo "587"  # fallback — user will need to correct manually
}

if [[ -n "$SMTP_RELAY_HOST" ]]; then
  # Pre-configured via env vars — use as-is (unattended/CI installs)
  info "SMTP relay pre-configured via env: [${SMTP_RELAY_HOST}]:${SMTP_RELAY_PORT}"
  _RELAY_WRITE=1
elif nc -zv -w5 gmail-smtp-in.l.google.com 25 >/dev/null 2>&1; then
  ok "Outbound port 25 is open — direct mail delivery enabled, no relay needed."
else
  warn "Outbound port 25 is BLOCKED by your hosting provider."
  echo ""
  echo "  An SMTP relay service is required to send email."
  echo "  Recommended free options:"
  echo "    Brevo   (brevo.com)   — 300 emails/day free  — smtp-relay.brevo.com"
  echo "    Mailgun (mailgun.com) — 100 emails/day free  — smtp.mailgun.org"
  echo ""
  echo "  Note: also whitelist your server IP (${ORBIX_SERVER_IP}) in your relay account."
  echo ""

  # Get existing relay host (from a previous install) as default
  _EXISTING_HOST=$(postconf -h relayhost 2>/dev/null | tr -d '[]' | cut -d: -f1 || echo "")

  [[ -n "$_EXISTING_HOST" ]] \
    && read -rp "  Relay host [${_EXISTING_HOST}]: " SMTP_RELAY_HOST < /dev/tty \
    || read -rp "  Relay host (e.g. smtp-relay.brevo.com): "  SMTP_RELAY_HOST < /dev/tty
  SMTP_RELAY_HOST="${SMTP_RELAY_HOST:-$_EXISTING_HOST}"

  if [[ -n "$SMTP_RELAY_HOST" ]]; then
    # Auto-detect reachable port on the entered host
    echo "  Checking which ports are reachable on ${SMTP_RELAY_HOST}…"
    for _p in 587 465 2525; do
      if nc -zv -w5 "$SMTP_RELAY_HOST" "$_p" >/dev/null 2>&1; then
        ok "  Port ${_p} is open on ${SMTP_RELAY_HOST}."
      else
        warn "  Port ${_p} is BLOCKED."
      fi
    done
    _AUTO_PORT=$(_detect_relay_port "$SMTP_RELAY_HOST")
    echo ""

    _EXISTING_PORT=$(postconf -h relayhost 2>/dev/null | tr -d '[]' | cut -d: -f2 || echo "")
    _DEFAULT_PORT="${_EXISTING_PORT:-$_AUTO_PORT}"
    read -rp "  Relay port [${_DEFAULT_PORT}]: " _rport < /dev/tty
    SMTP_RELAY_PORT="${_rport:-$_DEFAULT_PORT}"

    read -rp "  Relay login (username/email): " SMTP_RELAY_USER < /dev/tty
    read -rsp "  Relay password/API key: "      SMTP_RELAY_PASS < /dev/tty
    echo ""
    _RELAY_WRITE=1
  else
    warn "No relay host entered — outbound email will not work."
    warn "Re-run install.sh or set SMTP_RELAY_HOST/USER/PASS env vars to configure later."
  fi
fi

if [[ "$_RELAY_WRITE" == "1" ]]; then
  # Write sasl_passwd with tight permissions
  printf '[%s]:%s %s:%s\n' \
    "$SMTP_RELAY_HOST" "$SMTP_RELAY_PORT" \
    "$SMTP_RELAY_USER" "$SMTP_RELAY_PASS" \
    > /etc/postfix/sasl_passwd
  postmap /etc/postfix/sasl_passwd
  chmod 600 /etc/postfix/sasl_passwd /etc/postfix/sasl_passwd.db
  # Wire Postfix to use the relay
  postconf -e "relayhost = [${SMTP_RELAY_HOST}]:${SMTP_RELAY_PORT}"
  postconf -e "smtp_sasl_auth_enable = yes"
  postconf -e "smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd"
  postconf -e "smtp_sasl_security_options = noanonymous"
  postconf -e "smtp_tls_security_level = encrypt"
  ok "SMTP relay configured: [${SMTP_RELAY_HOST}]:${SMTP_RELAY_PORT}"
fi

# Enable and start both services
for svc in postfix dovecot; do
  systemctl enable "$svc" --quiet 2>/dev/null || true
  if systemctl is-active --quiet "$svc" 2>/dev/null; then
    systemctl restart "$svc" 2>/dev/null || true
    ok "${svc} restarted (picks up new config)."
  else
    systemctl start "$svc" 2>/dev/null || true
    systemctl is-active --quiet "$svc" 2>/dev/null \
      && ok "${svc} started." \
      || warn "${svc} may not be running — email management requires it."
  fi
done

# ─── 6. Node.js ───────────────────────────────────────────────────────────────
section "Node.js"

if [[ "$SKIP_NODE" == "1" ]]; then
  warn "Skipping Node.js (SKIP_NODE=1)."
elif node --version 2>/dev/null | grep -qE "^v2[2-9]\."; then
  ok "Node.js $(node --version) already installed."
else
  run_spinner "Setting up NodeSource repo…" /tmp/apt-nodesource.log \
    bash -c 'curl -fsSL https://deb.nodesource.com/setup_22.x | bash -'
  run_spinner "Installing Node.js 22…" /tmp/apt-nodejs.log \
    env DEBIAN_FRONTEND=noninteractive apt-get install -y -q nodejs
  ok "Node.js $(node --version) installed."
fi

# ─── 6. System user + directories ─────────────────────────────────────────────
section "System User & Directories"

if id "$ORBIX_USER" &>/dev/null; then
  ok "User '${ORBIX_USER}' already exists."
else
  useradd --system --no-create-home --shell /usr/sbin/nologin "$ORBIX_USER"
  ok "Created system user '${ORBIX_USER}'."
fi

# systemd-journal group membership lets the backend read journalctl logs
# (GET /services/:name/logs) without root. Group is always present on Ubuntu.
if ! id -nG "$ORBIX_USER" | grep -qw systemd-journal; then
  usermod -aG systemd-journal "$ORBIX_USER"
  ok "Added '${ORBIX_USER}' to systemd-journal group."
else
  ok "'${ORBIX_USER}' already in systemd-journal group."
fi

# adm group membership grants read access to /var/log/syslog, /var/log/auth.log,
# /var/log/nginx/*.log — needed for the Log Viewer (GET /logs/read).
if ! id -nG "$ORBIX_USER" | grep -qw adm; then
  usermod -aG adm "$ORBIX_USER"
  ok "Added '${ORBIX_USER}' to adm group."
else
  ok "'${ORBIX_USER}' already in adm group."
fi

BACKUP_DIR="/var/backups/orbixpanel"
# DOCKER_CONFIG for the agent: it runs under systemd ProtectHome=true, so ~/.docker
# is unreadable and `docker compose` (a CLI plugin) can't be discovered. Point it
# at an agent-owned dir instead. Must exist + be owned by ORBIX_USER.
DOCKER_CFG_DIR="/var/lib/orbixpanel/docker"
mkdir -p "$CONF_DIR" "$LOG_DIR" "$BACKUP_DIR" "$DOCKER_CFG_DIR"
chown "${ORBIX_USER}:${ORBIX_USER}" "$LOG_DIR" "$BACKUP_DIR" "$DOCKER_CFG_DIR"
chmod 751 "$CONF_DIR"
mkdir -p "${CONF_DIR}/apps"

# Pre-create directories that are listed in the agent's ReadWritePaths but may
# not exist yet (e.g. before PHP or certbot is ever installed).
# systemd requires every ReadWritePaths entry to exist at service start time —
# missing paths cause exit 226/NAMESPACE and the service never starts.
mkdir -p /etc/letsencrypt /var/lib/letsencrypt /etc/php

ok "Directories ready."

# ─── 7. Backend (orbixpanel-core) ─────────────────────────────────────────────
section "OrbixPanel Backend"

# Prisma v7 + Node 20 compatibility shim for zeptomatch (ESM/CJS conflict)
apply_zeptomatch_shim() {
  local zm_dir
  zm_dir=$(find "${CORE_SRC}/node_modules" -maxdepth 6 -name "zeptomatch" \
    -type d 2>/dev/null | head -1)
  [[ -n "$zm_dir" ]] || { warn "zeptomatch not found — skipping shim."; return; }

  mkdir -p "${zm_dir}/dist"
  cat > "${zm_dir}/dist/index.cjs" <<'SHIM'
'use strict';
function isMatch(str, patterns) {
  if (typeof patterns === 'string') patterns = [patterns];
  if (!Array.isArray(patterns)) return false;
  return patterns.some(function(p) {
    var re = p.replace(/[.+^${}()|[\]\\]/g, '\\$&')
               .replace(/\\\*\\\*/g, 'DSTAR').replace(/\\\*/g, '[^/]*')
               .replace(/DSTAR/g, '.*').replace(/\?/g, '.');
    return new RegExp('^' + re + '$').test(str);
  });
}
module.exports = isMatch;
module.exports.default = isMatch;
SHIM

  # Overwrite package.json entirely with a minimal CJS-compatible stub.
  # Patching the existing exports is fragile — the structure varies across
  # zeptomatch patch releases and any mix of subpath + condition keys triggers
  # ERR_INVALID_PACKAGE_CONFIG on Node 20. A string-form "exports" value
  # (shorthand for { ".": { "default": "..." } }) is always valid.
  python3 - "${zm_dir}/package.json" <<'PY'
import json, sys
with open(sys.argv[1]) as f: p = json.load(f)
p['main'] = './dist/index.cjs'
p['exports'] = './dist/index.cjs'
# Remove fields that can conflict with our CJS stub
for k in ('module', 'types', 'typings'):
    p.pop(k, None)
with open(sys.argv[1], 'w') as f: json.dump(p, f, indent=2)
PY
  ok "zeptomatch CJS shim applied."
}

if [[ "$SKIP_CORE" == "1" ]]; then
  warn "Skipping backend deployment (SKIP_CORE=1)."
elif _use_prebuilt; then
  # ── Pre-built path: download compiled artifacts. With a token → private
  # source-repo release; without one → the public dist repo (default). ──────
  _fetch_core_release
  _dl_backend /tmp/backend-dist.tar.gz
  mkdir -p "$CORE_SRC"
  tar -xzf /tmp/backend-dist.tar.gz -C "$CORE_SRC"
  rm -f /tmp/backend-dist.tar.gz
  ok "Backend bundle extracted → ${CORE_SRC}"

  # Write env file (same as source path — never overwrite existing secrets)
  if [[ ! -f "$BACKEND_ENV" ]]; then
    cat > "$BACKEND_ENV" <<ENVEOF
# OrbixPanel Backend — generated by install.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
DATABASE_URL=${DATABASE_URL}
JWT_SECRET=${JWT_SECRET}
REDIS_HOST=localhost
REDIS_PORT=6379
PORT=3000
WEBSITES_ROOT=/var/www
NGINX_SITES_DIR=/etc/nginx/sites-available
DB_PASSWORD=${DB_PASS}
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=orbixpanel_admin
MYSQL_PASSWORD=${MYSQL_ADMIN_PASS}
ORBIX_DOMAIN=${ORBIX_DOMAIN}
ORBIX_STOREFRONT_DOMAIN=${ORBIX_STOREFRONT_DOMAIN}
NS1_HOSTNAME=${NS1_HOSTNAME}
NS2_HOSTNAME=${NS2_HOSTNAME}
SERVER_IP=${ORBIX_SERVER_IP}
ADMIN_EMAIL=${ORBIX_ADMIN_EMAIL}
ENVEOF
    chmod 600 "$BACKEND_ENV"
    ok "Backend env file written: $BACKEND_ENV"
  else
    ok "Backend env file already exists — skipping (secrets preserved)."
    # Ensure NS/IP/domain/email vars exist even on re-installs
    _env_ensure "$BACKEND_ENV" NS1_HOSTNAME "${NS1_HOSTNAME}"
    _env_ensure "$BACKEND_ENV" NS2_HOSTNAME "${NS2_HOSTNAME}"
    _env_ensure "$BACKEND_ENV" SERVER_IP    "${ORBIX_SERVER_IP}"
    _env_ensure "$BACKEND_ENV" ORBIX_DOMAIN "${ORBIX_DOMAIN}"
    _env_ensure "$BACKEND_ENV" ORBIX_STOREFRONT_DOMAIN "${ORBIX_STOREFRONT_DOMAIN}"
    _env_ensure "$BACKEND_ENV" ADMIN_EMAIL  "${ORBIX_ADMIN_EMAIL}"
  fi

  # Swap: npm ci can OOM on 512 MB servers
  if [[ ! -f /swapfile ]]; then
    TOTAL_RAM_MB=$(awk '/MemTotal/{printf "%d", $2/1024}' /proc/meminfo)
    if [[ "$TOTAL_RAM_MB" -lt 2048 ]]; then
      info "Low RAM detected (${TOTAL_RAM_MB} MB) — creating 1 GB swap file…"
      fallocate -l 1G /swapfile 2>/dev/null || dd if=/dev/zero of=/swapfile bs=1M count=1024 status=none
      chmod 600 /swapfile && mkswap /swapfile > /dev/null && swapon /swapfile
      echo '/swapfile none swap sw 0 0' >> /etc/fstab
      ok "Swap enabled (1 GB)."
    fi
  else
    ok "Swap already configured."
  fi

  cd "$CORE_SRC"

  run_spinner "Installing production dependencies…" /tmp/npm-ci.log \
    npm ci --omit=dev \
    || { tail -30 /tmp/npm-ci.log; die "npm ci failed."; }
  ok "Production dependencies installed."

  # Use the project-LOCAL prisma (a regular dependency, so it's in node_modules
  # after `npm ci`). A global `npm install -g prisma` pulls the latest CLI, which
  # can mismatch the pinned version and fail (e.g. "No command registered for
  # generate"). node_modules/.bin/prisma is the exact pinned version.
  PRISMA_BIN="./node_modules/.bin/prisma"
  [[ -x "$PRISMA_BIN" ]] || die "Prisma CLI missing from node_modules (expected a 'prisma' dependency)."

  # prisma.config.ts is included in the bundle (Prisma 7 requires it for
  # migrate deploy when using provider="prisma-client"). DATABASE_URL is
  # passed inline so process.env["DATABASE_URL"] resolves correctly.
  rm -rf generated/

  run_spinner "Generating Prisma client…" /tmp/prisma-gen.log \
    env DATABASE_URL="$DATABASE_URL" "$PRISMA_BIN" generate \
    || { cat /tmp/prisma-gen.log; die "prisma generate failed."; }
  ok "Prisma client generated."

  run_spinner "Applying database migrations…" /tmp/prisma-migrate.log \
    env DATABASE_URL="$DATABASE_URL" "$PRISMA_BIN" migrate deploy \
    || { cat /tmp/prisma-migrate.log; die "Migration failed. Check DATABASE_URL and PostgreSQL status."; }
  ok "Migrations applied."

  chown -R "${ORBIX_USER}:${ORBIX_USER}" "$CORE_SRC"
  ok "Backend deployed from pre-built bundle."
else
  # ── Source build path ────────────────────────────────────────────────────
  # Clone or update source
  if [[ -d "${CORE_SRC}/.git" ]]; then
    info "Updating backend source…"
    git_pull "$CORE_SRC" "$CORE_BRANCH"
    ok "Backend source updated."
  else
    info "Cloning backend source…"
    git_clone "$CORE_REPO_URL" "$CORE_SRC" "$CORE_BRANCH"
    ok "Backend source cloned."
  fi

  # Write backend env file (once — never overwritten to preserve secrets)
  if [[ ! -f "$BACKEND_ENV" ]]; then
    cat > "$BACKEND_ENV" <<ENVEOF
# OrbixPanel Backend — generated by install.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
DATABASE_URL=${DATABASE_URL}
JWT_SECRET=${JWT_SECRET}
REDIS_HOST=localhost
REDIS_PORT=6379
PORT=3000
WEBSITES_ROOT=/var/www
NGINX_SITES_DIR=/etc/nginx/sites-available
DB_PASSWORD=${DB_PASS}
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=orbixpanel_admin
MYSQL_PASSWORD=${MYSQL_ADMIN_PASS}
ORBIX_DOMAIN=${ORBIX_DOMAIN}
ORBIX_STOREFRONT_DOMAIN=${ORBIX_STOREFRONT_DOMAIN}
NS1_HOSTNAME=${NS1_HOSTNAME}
NS2_HOSTNAME=${NS2_HOSTNAME}
SERVER_IP=${ORBIX_SERVER_IP}
ADMIN_EMAIL=${ORBIX_ADMIN_EMAIL}
ENVEOF
    chmod 600 "$BACKEND_ENV"
    ok "Backend env file written: $BACKEND_ENV"
  else
    ok "Backend env file already exists — skipping (secrets preserved)."
    grep -q "^NS1_HOSTNAME="  "$BACKEND_ENV" 2>/dev/null || echo "NS1_HOSTNAME=${NS1_HOSTNAME}"       >> "$BACKEND_ENV"
    grep -q "^NS2_HOSTNAME="  "$BACKEND_ENV" 2>/dev/null || echo "NS2_HOSTNAME=${NS2_HOSTNAME}"       >> "$BACKEND_ENV"
    grep -q "^SERVER_IP="     "$BACKEND_ENV" 2>/dev/null || echo "SERVER_IP=${ORBIX_SERVER_IP}"       >> "$BACKEND_ENV"
    grep -q "^ADMIN_EMAIL="   "$BACKEND_ENV" 2>/dev/null || echo "ADMIN_EMAIL=${ORBIX_ADMIN_EMAIL}"   >> "$BACKEND_ENV"
    grep -q "^ORBIX_DOMAIN="  "$BACKEND_ENV" 2>/dev/null || echo "ORBIX_DOMAIN=${ORBIX_DOMAIN}"       >> "$BACKEND_ENV"
    grep -q "^ORBIX_STOREFRONT_DOMAIN=" "$BACKEND_ENV" 2>/dev/null || echo "ORBIX_STOREFRONT_DOMAIN=${ORBIX_STOREFRONT_DOMAIN}" >> "$BACKEND_ENV"
  fi

  # ── Swap (ensure npm/node don't get OOM-killed on low-RAM VPS) ──────────────
  # A 1 GB swap file costs nothing on SSD and prevents OOM kills during
  # npm install / prisma generate on 1–2 GB RAM droplets.
  if [[ ! -f /swapfile ]]; then
    TOTAL_RAM_MB=$(awk '/MemTotal/{printf "%d", $2/1024}' /proc/meminfo)
    if [[ "$TOTAL_RAM_MB" -lt 2048 ]]; then
      info "Low RAM detected (${TOTAL_RAM_MB} MB) — creating 1 GB swap file…"
      fallocate -l 1G /swapfile 2>/dev/null || dd if=/dev/zero of=/swapfile bs=1M count=1024 status=none
      chmod 600 /swapfile
      mkswap /swapfile > /dev/null
      swapon /swapfile
      echo '/swapfile none swap sw 0 0' >> /etc/fstab
      ok "Swap enabled (1 GB)."
    fi
  else
    ok "Swap already configured."
  fi

  cd "$CORE_SRC"

  run_spinner "Installing backend dependencies…" /tmp/npm-install.log \
    npm install \
    || { tail -30 /tmp/npm-install.log; die "npm install failed."; }
  ok "Dependencies installed."

  # Remove artefacts left by previous failed runs:
  # - prisma.config.js  — Prisma compiles prisma.config.ts here; stale copy causes parse errors
  # - generated/        — old Prisma output location (moved to src/generated/ in current schema)
  rm -f prisma.config.js
  rm -rf generated/

  # Attempt 1 — silent; if it fails apply the zeptomatch shim and retry
  if ! run_spinner "Generating Prisma client…" /tmp/prisma-gen.log \
      env DATABASE_URL="$DATABASE_URL" npx prisma generate; then
    warn "Initial prisma generate failed — applying zeptomatch CJS shim and retrying…"
    apply_zeptomatch_shim
    run_spinner "Generating Prisma client (retry)…" /tmp/prisma-gen.log \
      env DATABASE_URL="$DATABASE_URL" npx prisma generate \
      || { cat /tmp/prisma-gen.log; die "prisma generate failed (after shim)."; }
  fi

  # Verify files were actually written to the expected output directory.
  if [[ ! -d "src/generated/prisma" ]] || \
     [[ -z "$(ls -A "src/generated/prisma" 2>/dev/null)" ]]; then
    warn "src/generated/prisma/ is empty or missing. Searching for generated files…"
    find . -path ./node_modules -prune -o -name "*.d.ts" -path "*/prisma/*" -print \
      2>/dev/null | head -10 || true
    warn "Re-running prisma generate with full output:"
    DATABASE_URL="$DATABASE_URL" npx prisma generate \
      || die "prisma generate failed. Check DATABASE_URL and Prisma version."
    [[ -d "src/generated/prisma" && -n "$(ls -A "src/generated/prisma" 2>/dev/null)" ]] \
      || die "prisma generate exited 0 but produced no files in src/generated/prisma/. Manual fix: cd ${CORE_SRC} && npx prisma generate"
  fi
  ok "Prisma client generated."

  run_spinner "Applying database migrations…" /tmp/prisma-migrate.log \
    env DATABASE_URL="$DATABASE_URL" npx prisma migrate deploy \
    || { cat /tmp/prisma-migrate.log; die "Migration failed. Check DATABASE_URL and PostgreSQL status."; }
  ok "Migrations applied."

  info "Building backend…"
  # Give TypeScript enough heap. On 1 GB RAM droplets V8 auto-limits the heap
  # to ~256 MB which is not enough to compile a large NestJS project — it OOMs
  # and aborts silently. 768 MB fits within 1 GB RAM + 1 GB swap comfortably.
  export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--max-old-space-size=768"

  # 'nest build' can silently exit 0 on some ARM64 / low-RAM environments
  # without producing any output or files. If that happens we fall back to
  # running tsc directly, which is more explicit and shows real TS errors.
  run_spinner "Building backend (nest build)…" /tmp/nest-build.log \
    npm run build || true   # don't die here — check output below

  if [[ ! -f dist/main.js ]]; then
    warn "nest build did not produce dist/main.js — running tsc directly…"
    run_spinner "Building backend (tsc fallback)…" /tmp/tsc-build.log \
      ./node_modules/.bin/tsc --project tsconfig.json || true
    if [[ -f dist/main.js ]]; then
      ok "Backend built via tsc fallback → dist/main.js"
    else
      cat /tmp/tsc-build.log
      die "Backend build failed: dist/main.js still missing after tsc."
    fi
  else
    ok "Backend built → dist/main.js"
  fi

  chown -R "${ORBIX_USER}:${ORBIX_USER}" "$CORE_SRC"
fi

# ─── 7b. Licensing (orbixpanel.com) ───────────────────────────────────────────
# Wires the panel's license client and, on a fresh install, registers with
# orbixpanel.com for a 30-day trial + welcome credit.
#
# Every step here is best-effort by design: with no public key shipped, no valid
# admin email, or an unreachable cloud, the panel simply stays UNLICENSED
# (development mode — nothing is enforced). Licensing must never fail an install.
if [[ "$SKIP_CORE" != "1" && -f "$BACKEND_ENV" ]]; then
  section "Licensing"

  LICENSE_FINGERPRINT="$(_server_fingerprint)"
  _env_ensure "$BACKEND_ENV" ORBIXPANEL_PROFILE  "$ORBIXPANEL_PROFILE"
  _env_ensure "$BACKEND_ENV" ORBIX_CLOUD_URL     "$ORBIX_CLOUD_URL"
  _env_ensure "$BACKEND_ENV" LICENSE_FINGERPRINT "$LICENSE_FINGERPRINT"
  _env_ensure "$BACKEND_ENV" LICENSE_JWT_FILE    "${CONF_DIR}/license.jwt"
  # Persist the public key across re-runs: if not passed via env this run, reuse
  # whatever a previous run wrote to backend.env (so upgrades don't need it again).
  [[ -z "$LICENSE_PUBLIC_KEY" ]] && LICENSE_PUBLIC_KEY="$(_env_get "$BACKEND_ENV" LICENSE_PUBLIC_KEY)"
  [[ -n "$LICENSE_PUBLIC_KEY" ]] && _env_ensure "$BACKEND_ENV" LICENSE_PUBLIC_KEY "$LICENSE_PUBLIC_KEY"
  # Shared signing secret → lets the panel sign its heartbeat/telemetry calls.
  [[ -n "$ORBIX_INSTALL_SECRET" ]] && _env_ensure "$BACKEND_ENV" ORBIX_INSTALL_SECRET "$ORBIX_INSTALL_SECRET"

  # Upload limit — UPDATE-or-add (not add-only) so changing ORBIX_MAX_UPLOAD_MB and
  # re-running actually raises/lowers it. Must stay in sync with the nginx snippet.
  if grep -q '^ORBIX_MAX_UPLOAD_MB=' "$BACKEND_ENV" 2>/dev/null; then
    sed -i "s|^ORBIX_MAX_UPLOAD_MB=.*|ORBIX_MAX_UPLOAD_MB=${ORBIX_MAX_UPLOAD_MB}|" "$BACKEND_ENV"
  else
    echo "ORBIX_MAX_UPLOAD_MB=${ORBIX_MAX_UPLOAD_MB}" >> "$BACKEND_ENV"
  fi

  EXISTING_LICENSE_KEY="$(_env_get "$BACKEND_ENV" LICENSE_KEY)"

  # The public key is the master switch. Without it the panel cannot verify any
  # license JWT, and attaching a key anyway would silently strand it on the
  # community feature set ("license not validated"). Staying UNLICENSED instead
  # keeps the panel fully permissive, which is the correct dev-mode behaviour.
  if [[ -z "$LICENSE_PUBLIC_KEY" ]]; then
    warn "No license public key shipped yet — panel runs UNLICENSED (development mode)."
    [[ -n "$LICENSE_KEY" ]] && warn "LICENSE_KEY was provided but cannot be verified without LICENSE_PUBLIC_KEY — ignored."
  elif [[ -n "$LICENSE_KEY" ]]; then
    _env_ensure "$BACKEND_ENV" LICENSE_KEY "$LICENSE_KEY"
    ok "License key attached from environment."
  elif [[ -n "$EXISTING_LICENSE_KEY" ]]; then
    ok "License key already present — preserved across re-install."
  elif [[ -z "$ORBIX_ADMIN_EMAIL" || "$ORBIX_ADMIN_EMAIL" != *@*.* ]]; then
    warn "ORBIX_ADMIN_EMAIL is not a valid address — skipping registration (panel runs UNLICENSED)."
  else
    info "Registering this install with ${ORBIX_CLOUD_URL}…"
    REG_BODY="$(python3 - "$ORBIX_ADMIN_EMAIL" "$LICENSE_FINGERPRINT" "$ORBIX_SERVER_IP" "$ORBIX_DOMAIN" "$ORBIX_REFERRAL_CODE" "$ORBIX_INSTALL_TOKEN" <<'PY'
import json, sys
email, fp, ip, domain, ref, tok = (sys.argv[1:7] + [''] * 6)[:6]
d = {"email": email, "fingerprint": fp, "installerVersion": "install.sh"}
if ip:     d["serverIp"] = ip
if domain: d["panelDomain"] = domain
if ref:    d["referralCode"] = ref
if tok:    d["installToken"] = tok
print(json.dumps(d))
PY
)"
    # Sign the request when a shared secret is configured (must match the cloud).
    # HMAC-SHA256 over "${ts}.${REG_BODY}" — same contract as the panel's signBody
    # and the cloud's verifySignature. Signed over the EXACT body bytes we send.
    REG_SIG_HEADERS=(-H 'Content-Type: application/json')
    if [[ -n "$ORBIX_INSTALL_SECRET" ]]; then
      REG_SIG_OUT="$(python3 - "$ORBIX_INSTALL_SECRET" "$REG_BODY" <<'PY'
import sys, time, hmac, hashlib
secret, body = sys.argv[1], sys.argv[2]
ts = str(int(time.time() * 1000))
sig = hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
print(ts, sig)
PY
)"
      read -r REG_TS REG_SIG <<< "$REG_SIG_OUT"
      [[ -n "$REG_TS" && -n "$REG_SIG" ]] && REG_SIG_HEADERS+=(-H "x-orbix-timestamp: $REG_TS" -H "x-orbix-signature: $REG_SIG")
    fi

    REG_RESP="$(curl -fsS --max-time 20 -X POST "${ORBIX_CLOUD_URL}/api/register" \
      "${REG_SIG_HEADERS[@]}" -d "$REG_BODY" 2>/dev/null || true)"

    if [[ -n "$REG_RESP" ]]; then
      REG_KEY="$(printf '%s' "$REG_RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("licenseKey",""))' 2>/dev/null || true)"
      REG_JWT="$(printf '%s' "$REG_RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("licenseJwt",""))' 2>/dev/null || true)"
      REG_CLAIM="$(printf '%s' "$REG_RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("claimUrl",""))' 2>/dev/null || true)"
      REG_SLOTS="$(printf '%s' "$REG_RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("founderSlotsLeft",""))' 2>/dev/null || true)"
    fi

    if [[ -n "${REG_KEY:-}" ]]; then
      _env_ensure "$BACKEND_ENV" LICENSE_KEY "$REG_KEY"
      if [[ -n "${REG_JWT:-}" ]]; then
        # Seed the cached JWT so the panel is licensed before its first heartbeat.
        printf '%s' "$REG_JWT" > "${CONF_DIR}/license.jwt"
        chown "${ORBIX_USER}:${ORBIX_USER}" "${CONF_DIR}/license.jwt"
        chmod 640 "${CONF_DIR}/license.jwt"
      fi
      ok "Registered — 30-day trial license issued (${REG_KEY})."
      info "Claim your account to activate your trial credits:"
      info "  → ${REG_CLAIM:-${ORBIX_CLOUD_URL}/claim}  (use this email + your license key above)"
      info "  A 6-digit verification code was emailed to ${ORBIX_ADMIN_EMAIL} — enter it on the claim page"
      info "  (no email? click \"Resend code\" there). Check spam if it doesn't arrive."
      [[ -n "${REG_SLOTS:-}" && "${REG_SLOTS}" != "0" ]] && ok "\$100 founding-user bonus available — ${REG_SLOTS} of 100 slots left; claim to lock it in!"
    else
      warn "Could not register with ${ORBIX_CLOUD_URL} — panel runs UNLICENSED (development mode)."
      warn "Re-run the installer later, or set LICENSE_KEY=… to attach a license."
    fi
  fi
fi

# ─── 8. Backend systemd service ───────────────────────────────────────────────
section "Backend Service"

if [[ "$SKIP_CORE" != "1" ]]; then
  NEW_BACKEND_SERVICE="[Unit]
Description=OrbixPanel Backend
Documentation=https://github.com/orbixpanel/orbixpanel-core
After=network-online.target postgresql.service redis-server.service
Wants=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=3

[Service]
Type=simple
User=${ORBIX_USER}
WorkingDirectory=${CORE_SRC}
EnvironmentFile=${BACKEND_ENV}
ExecStart=/usr/bin/node dist/main.js
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=orbixpanel-backend

[Install]
WantedBy=multi-user.target"

  BACKEND_NEEDS_RELOAD=false
  if [[ -f "$BACKEND_SERVICE" && "$(cat "$BACKEND_SERVICE")" == "$NEW_BACKEND_SERVICE" ]]; then
    ok "Backend service file unchanged."
  else
    echo "$NEW_BACKEND_SERVICE" > "$BACKEND_SERVICE"
    ok "Backend service file written."
    BACKEND_NEEDS_RELOAD=true
  fi

  [[ "$BACKEND_NEEDS_RELOAD" == true ]] && systemctl daemon-reload
  systemctl enable orbixpanel-backend --quiet
  ok "Backend service enabled."

  if systemctl is-active --quiet orbixpanel-backend; then
    # Always restart to pick up any source updates
    systemctl restart orbixpanel-backend
    ok "Backend service restarted."
  else
    systemctl start orbixpanel-backend
    ok "Backend service started."
  fi

  # Wait for backend to accept connections (up to 60s) — show spinner while polling
  journalctl -u orbixpanel-backend -f --no-pager -n 0 > /tmp/backend-start.log 2>&1 &
  _jctl_pid=$!
  _spin_loop "Waiting for backend to start…" /tmp/backend-start.log &
  _spin_pid=$!
  WAITED=0
  until [[ "$(curl -s -o /dev/null -w '%{http_code}' \
      -X POST "${ORBIX_API_URL}/auth/login" \
      -H 'Content-Type: application/json' \
      -d '{"email":"x","password":"x"}' \
      --connect-timeout 2 --max-time 3 2>/dev/null || echo 0)" =~ ^[2-5] ]]; do
    sleep 2; WAITED=$((WAITED + 2))
    if [[ $WAITED -ge 60 ]]; then
      kill "$_spin_pid" 2>/dev/null || true; wait "$_spin_pid" 2>/dev/null || true
      kill "$_jctl_pid" 2>/dev/null || true; wait "$_jctl_pid" 2>/dev/null || true
      printf "\r\033[K"
      die "Backend not ready after 60s. Check: journalctl -u orbixpanel-backend -n 50 --no-pager"
    fi
  done
  kill "$_spin_pid" 2>/dev/null || true; wait "$_spin_pid" 2>/dev/null || true
  kill "$_jctl_pid" 2>/dev/null || true; wait "$_jctl_pid" 2>/dev/null || true
  printf "\r\033[K"
  ok "Backend is ready."

fi

# ─── 9. Frontend (orbixpanel-web) ────────────────────────────────────────────
section "OrbixPanel Frontend"

if [[ "$SKIP_FRONTEND" == "1" ]]; then
  warn "Skipping frontend (SKIP_FRONTEND=1)."
elif _use_prebuilt; then
  # ── Pre-built path ───────────────────────────────────────────────────────
  _fetch_web_release
  _dl_frontend /tmp/frontend-dist.tar.gz
  mkdir -p "$FRONTEND_SRC"
  tar -xzf /tmp/frontend-dist.tar.gz -C "$FRONTEND_SRC"
  rm -f /tmp/frontend-dist.tar.gz
  [[ -f "${FRONTEND_SRC}/dist/index.html" ]] \
    || die "Frontend bundle extracted but dist/index.html is missing. Re-trigger the GitHub Actions build."
  chmod -R 755 "${FRONTEND_SRC}/dist"
  ok "Frontend deployed from pre-built bundle → ${FRONTEND_SRC}/dist"

  # Write nginx config (same logic as source-build path — needed on fresh install)
  NGINX_SITE="/etc/nginx/sites-available/orbixpanel"
  mkdir -p /var/www/certbot
  if [[ -n "$ORBIX_DOMAIN" ]]; then
    cat > "$NGINX_SITE" <<'NGINXEOF'
server {
    listen 80;
    server_name ORBIX_DOMAIN_TOKEN www.ORBIX_DOMAIN_TOKEN _;
    location /.well-known/acme-challenge/ { root /var/www/certbot; try_files $uri =404; }
    root FRONTEND_DIST;
    index index.html;
    location /api/ {
        proxy_pass         http://127.0.0.1:3000/;
        proxy_http_version 1.1;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection 'upgrade';
        proxy_cache_bypass $http_upgrade;
    }
    location / { try_files $uri $uri/ /index.html; }
}
NGINXEOF
    sed -i "s|ORBIX_DOMAIN_TOKEN|${ORBIX_DOMAIN}|g" "$NGINX_SITE"
  else
    cat > "$NGINX_SITE" <<'NGINXEOF'
server {
    listen 80;
    server_name _;
    root FRONTEND_DIST;
    index index.html;
    location /api/ {
        proxy_pass         http://127.0.0.1:3000/;
        proxy_http_version 1.1;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection 'upgrade';
        proxy_cache_bypass $http_upgrade;
    }
    location / { try_files $uri $uri/ /index.html; }
}
NGINXEOF
  fi
  sed -i "s|FRONTEND_DIST|${FRONTEND_SRC}/dist|g" "$NGINX_SITE"
  ln -sf "$NGINX_SITE" /etc/nginx/sites-enabled/orbixpanel
  rm -f /etc/nginx/sites-enabled/default
  nginx -t 2>/dev/null || die "nginx config test failed — check $NGINX_SITE"
  systemctl reload nginx
  ok "nginx configured."
else
  # ── Source build path ─────────────────────────────────────────────────────
  # Clone or update source
  if [[ -d "${FRONTEND_SRC}/.git" ]]; then
    info "Updating frontend source…"
    git_pull "$FRONTEND_SRC" "$FRONTEND_BRANCH"
    ok "Frontend source updated."
  else
    info "Cloning frontend source…"
    git_clone "$FRONTEND_REPO_URL" "$FRONTEND_SRC" "$FRONTEND_BRANCH"
    ok "Frontend source cloned."
  fi

  run_spinner "Installing frontend dependencies…" /tmp/npm-frontend.log \
    npm install --prefix "$FRONTEND_SRC" \
    || { tail -30 /tmp/npm-frontend.log; die "npm install failed for frontend."; }
  ok "Dependencies installed."

  run_spinner "Building frontend…" /tmp/vite-build.log \
    npm run build --prefix "$FRONTEND_SRC" \
    || { tail -30 /tmp/vite-build.log; die "Frontend build failed."; }

  [[ -f "${FRONTEND_SRC}/dist/index.html" ]] \
    || die "Frontend build completed but dist/index.html is missing."
  ok "Frontend built → ${FRONTEND_SRC}/dist"

  # nginx must be able to read the dist files
  chmod -R 755 "${FRONTEND_SRC}/dist"

  # Write nginx site config.
  # If a domain is set: write an HTTP-only config that serves the ACME challenge
  # path for certbot; SSL will be added in section 9b after the cert is obtained.
  # If no domain: write the final HTTP-only panel config (no SSL needed).
  # Uses a quoted heredoc so nginx vars ($host, $uri…) are not expanded by bash.
  # FRONTEND_DIST and ORBIX_DOMAIN_TOKEN placeholders are replaced with sed.
  NGINX_SITE="/etc/nginx/sites-available/orbixpanel"
  mkdir -p /var/www/certbot

  if [[ -n "$ORBIX_DOMAIN" ]]; then
    # Temporary HTTP config — enough for certbot webroot challenge.
    # Will be replaced by the full SSL config in section 9b.
    cat > "$NGINX_SITE" <<'NGINXEOF'
server {
    listen 80;
    server_name ORBIX_DOMAIN_TOKEN www.ORBIX_DOMAIN_TOKEN _;

    # Certbot ACME challenge (used to obtain SSL certificate)
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
        try_files $uri =404;
    }

    root FRONTEND_DIST;
    index index.html;

    location /api/ {
        proxy_pass         http://127.0.0.1:3000/;
        proxy_http_version 1.1;
        proxy_set_header   Host            $host;
        proxy_set_header   X-Real-IP       $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   Upgrade         $http_upgrade;
        proxy_set_header   Connection      'upgrade';
        proxy_cache_bypass $http_upgrade;
        proxy_read_timeout 60s;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }
}
NGINXEOF
    sed -i "s|ORBIX_DOMAIN_TOKEN|${ORBIX_DOMAIN}|g" "$NGINX_SITE"
  else
    # IP-only: final config, panel on port 80.
    cat > "$NGINX_SITE" <<'NGINXEOF'
server {
    listen 80;
    server_name _;

    root FRONTEND_DIST;
    index index.html;

    # Proxy /api/* → NestJS backend (strips the /api prefix)
    location /api/ {
        proxy_pass         http://127.0.0.1:3000/;
        proxy_http_version 1.1;
        proxy_set_header   Host            $host;
        proxy_set_header   X-Real-IP       $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   Upgrade         $http_upgrade;
        proxy_set_header   Connection      'upgrade';
        proxy_cache_bypass $http_upgrade;
        proxy_read_timeout 60s;
    }

    # SPA routing — all non-file requests fall back to index.html
    location / {
        try_files $uri $uri/ /index.html;
    }
}
NGINXEOF
  fi

  sed -i "s|FRONTEND_DIST|${FRONTEND_SRC}/dist|g" "$NGINX_SITE"

  # Enable orbixpanel site, disable default placeholder
  ln -sf "$NGINX_SITE" /etc/nginx/sites-enabled/orbixpanel
  rm -f /etc/nginx/sites-enabled/default

  nginx -t 2>/dev/null || die "nginx config test failed — check $NGINX_SITE"
  systemctl reload nginx
  if [[ -n "$ORBIX_DOMAIN" ]]; then
    ok "nginx configured (HTTP, pre-SSL) — SSL will be applied in the next step."
  else
    ok "Frontend live at http://${ORBIX_SERVER_IP}/"
  fi
fi

# ─── 9b. SSL / Let's Encrypt ─────────────────────────────────────────────────
section "SSL / Let's Encrypt"

if [[ -z "$ORBIX_DOMAIN" ]]; then
  warn "No domain — skipping SSL setup."
else
  # Install certbot + nginx plugin if needed
  if ! command -v certbot &>/dev/null; then
    run_spinner "Installing certbot…" /tmp/apt-certbot.log \
      env DEBIAN_FRONTEND=noninteractive apt-get install -y -q certbot python3-certbot-nginx
    ok "certbot installed."
  else
    ok "certbot already installed."
    # Ensure the nginx plugin is present even when certbot was pre-installed
    if ! dpkg -l python3-certbot-nginx &>/dev/null 2>&1; then
      run_spinner "Installing python3-certbot-nginx…" /tmp/apt-certbot-nginx.log \
        env DEBIAN_FRONTEND=noninteractive apt-get install -y -q python3-certbot-nginx
      ok "python3-certbot-nginx installed."
    fi
  fi

  CERT_DOMAIN="$ORBIX_DOMAIN"
  CERT_DIR="/etc/letsencrypt/live/${ORBIX_DOMAIN}"

  if [[ -f "${CERT_DIR}/fullchain.pem" ]]; then
    ok "Certificate already exists for ${ORBIX_DOMAIN} — skipping certbot."
    SSL_OK=true
  else
    info "Obtaining SSL certificate for ${ORBIX_DOMAIN}…"
    # Try apex + www + mail (so Postfix/Dovecot can present a valid cert for
    # mail.<domain>); fall back to apex+www, then apex-only, as DNS allows.
    if certbot certonly \
        --webroot -w /var/www/certbot \
        -d "${ORBIX_DOMAIN}" -d "www.${ORBIX_DOMAIN}" -d "mail.${ORBIX_DOMAIN}" \
        --non-interactive --agree-tos \
        --email "${ORBIX_ADMIN_EMAIL}" 2>&1; then
      ok "Certificate obtained (apex + www + mail)."
      SSL_OK=true
    elif certbot certonly \
        --webroot -w /var/www/certbot \
        -d "${ORBIX_DOMAIN}" -d "www.${ORBIX_DOMAIN}" \
        --non-interactive --agree-tos \
        --email "${ORBIX_ADMIN_EMAIL}" 2>&1; then
      ok "Certificate obtained (apex + www — mail.${ORBIX_DOMAIN} DNS not set)."
      SSL_OK=true
    elif certbot certonly \
        --webroot -w /var/www/certbot \
        -d "${ORBIX_DOMAIN}" \
        --non-interactive --agree-tos \
        --email "${ORBIX_ADMIN_EMAIL}" 2>&1; then
      ok "Certificate obtained (apex only — www DNS not set)."
      SSL_OK=true
    else
      warn "certbot failed — check DNS propagation, then re-run install.sh."
      warn "Manual command: certbot certonly --webroot -w /var/www/certbot -d ${ORBIX_DOMAIN} --non-interactive --agree-tos --email ${ORBIX_ADMIN_EMAIL}"
      SSL_OK=false
    fi
  fi

  if [[ "$SSL_OK" == "true" ]]; then
    # ── Mail TLS: point Postfix + Dovecot at the real Let's Encrypt cert ────────
    # Default installs ship the self-signed snakeoil cert, so mail clients and any
    # app that verifies TLS fail with "self-signed certificate". Repoint both at
    # the panel domain's LE cert. Dovecot's SSL setting names changed in 2.4, so
    # write the matching drop-in and validate before reloading (a bad drop-in is
    # removed rather than left to break dovecot).
    if [[ -f "${CERT_DIR}/fullchain.pem" ]]; then
      postconf -e "smtpd_tls_cert_file=${CERT_DIR}/fullchain.pem" 2>/dev/null || true
      postconf -e "smtpd_tls_key_file=${CERT_DIR}/privkey.pem" 2>/dev/null || true

      # Postfix smtpd runs as the 'postfix' user and must READ the LE private key,
      # but LE ships it root:root 600 inside 700 dirs → STARTTLS fails with
      # "454 TLS not available due to local problem". Grant read via the standard
      # 'ssl-cert' group (Dovecot's master reads it as root, so it doesn't need
      # this, but we add it too for consistency). Re-applied on renewal by the hook.
      if getent group ssl-cert >/dev/null 2>&1; then
        usermod -aG ssl-cert postfix 2>/dev/null || true
        usermod -aG ssl-cert dovecot 2>/dev/null || true
        chgrp -R ssl-cert /etc/letsencrypt/live /etc/letsencrypt/archive 2>/dev/null || true
        chmod -R g+rX /etc/letsencrypt/live /etc/letsencrypt/archive 2>/dev/null || true
      fi

      MAIL_DOVECOT_VER="$(dovecot --version 2>/dev/null | grep -oE '^[0-9]+\.[0-9]+' | head -1)"
      MAIL_DV_MAJOR="${MAIL_DOVECOT_VER%%.*}"; MAIL_DV_MINOR="${MAIL_DOVECOT_VER#*.}"
      if [ "${MAIL_DV_MAJOR:-2}" -gt 2 ] 2>/dev/null || { [ "${MAIL_DV_MAJOR:-2}" -eq 2 ] && [ "${MAIL_DV_MINOR:-3}" -ge 4 ]; } 2>/dev/null; then
        cat > /etc/dovecot/conf.d/99-orbixpanel-ssl.conf << DOVESSL
# OrbixPanel — use the panel domain's Let's Encrypt cert (Dovecot 2.4)
ssl_server_cert_file = ${CERT_DIR}/fullchain.pem
ssl_server_key_file = ${CERT_DIR}/privkey.pem
DOVESSL
      else
        cat > /etc/dovecot/conf.d/99-orbixpanel-ssl.conf << DOVESSL
# OrbixPanel — use the panel domain's Let's Encrypt cert (Dovecot 2.3)
ssl_cert = <${CERT_DIR}/fullchain.pem
ssl_key = <${CERT_DIR}/privkey.pem
DOVESSL
      fi

      # restart (not reload) Postfix so the new ssl-cert supplementary group applies.
      if doveconf -n >/dev/null 2>&1; then
        systemctl restart dovecot 2>/dev/null || true
        systemctl restart postfix 2>/dev/null || true
        ok "Mail TLS now uses the Let's Encrypt certificate (${ORBIX_DOMAIN})."
      else
        rm -f /etc/dovecot/conf.d/99-orbixpanel-ssl.conf
        systemctl restart postfix 2>/dev/null || true
        warn "Dovecot rejected the TLS drop-in; kept the previous mail cert (run 'doveconf -n')."
      fi

      # Reload mail services automatically whenever certbot renews the cert, so
      # the renewed cert is picked up without manual intervention (~90-day cycle).
      mkdir -p /etc/letsencrypt/renewal-hooks/deploy
      cat > /etc/letsencrypt/renewal-hooks/deploy/orbix-mail-reload.sh << 'HOOKEOF'
#!/bin/bash
# OrbixPanel — after a cert renewal, re-grant key access to the mail services
# (certbot writes new privkey files as root:root 600) and reload them.
if getent group ssl-cert >/dev/null 2>&1; then
  chgrp -R ssl-cert /etc/letsencrypt/live /etc/letsencrypt/archive 2>/dev/null || true
  chmod -R g+rX /etc/letsencrypt/live /etc/letsencrypt/archive 2>/dev/null || true
fi
systemctl reload postfix 2>/dev/null || systemctl restart postfix 2>/dev/null || true
systemctl reload dovecot 2>/dev/null || systemctl restart dovecot 2>/dev/null || true
HOOKEOF
      chmod +x /etc/letsencrypt/renewal-hooks/deploy/orbix-mail-reload.sh
    fi

    # ── Landing page HTML ──────────────────────────────────────────────────────
    LANDING_DIR="/var/www/orbixpanel-landing"
    mkdir -p "$LANDING_DIR"

    # Profile shapes the public page. 'personal' → a panel splash. 'reseller' →
    # a hosting-company placeholder with a client-area link; the full storefront/
    # CMS (Phase 10) replaces this from inside the panel later.
    if [[ "$ORBIXPANEL_PROFILE" == "reseller" ]]; then
      LANDING_TITLE="${ORBIX_DOMAIN} — Web Hosting"
      LANDING_H1="${ORBIX_DOMAIN}"
      LANDING_TAGLINE="Fast, reliable web hosting.<br>Sign in to your client area to manage your sites, databases, and invoices."
      LANDING_ACTIONS="<a class=\"btn\" href=\"https://${ORBIX_DOMAIN}:8080/tenant/login\">Client Area →</a>
      <a class=\"btn btn-ghost\" href=\"https://${ORBIX_DOMAIN}:8080\">Admin Panel</a>"
      LANDING_FOOTER="Hosting powered by OrbixPanel"
    else
      LANDING_TITLE="OrbixPanel — Server Management"
      LANDING_H1="OrbixPanel"
      LANDING_TAGLINE="A powerful server management panel for your infrastructure.<br>Manage websites, databases, email, DNS, and more."
      LANDING_ACTIONS="<a class=\"btn\" href=\"https://${ORBIX_DOMAIN}:8080\">Launch Panel →</a>
      <a class=\"btn btn-ghost\" href=\"https://docs.${ORBIX_DOMAIN}\">Documentation</a>"
      LANDING_FOOTER="Powered by OrbixPanel"
    fi

    cat > "${LANDING_DIR}/index.html" << HTMLEOF
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>${LANDING_TITLE}</title>
  <style>
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
         background:linear-gradient(135deg,#0f172a 0%,#1e293b 100%);
         min-height:100vh;display:flex;flex-direction:column;align-items:center;
         justify-content:center;color:#e2e8f0;padding:2rem}
    .card{text-align:center;padding:3rem 4rem;
          background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);
          border-radius:1rem;backdrop-filter:blur(10px);max-width:520px;width:100%}
    .logo{width:64px;height:64px;border-radius:16px;object-fit:cover;
          display:block;margin:0 auto 1.5rem}
    h1{font-size:2rem;font-weight:700;margin-bottom:.5rem;
       background:linear-gradient(135deg,#f97316,#fb923c);
       -webkit-background-clip:text;-webkit-text-fill-color:transparent}
    p{color:#94a3b8;margin-bottom:2rem;line-height:1.6}
    .actions{display:flex;gap:.75rem;justify-content:center;flex-wrap:wrap}
    .btn{display:inline-block;padding:.75rem 2rem;
         background:linear-gradient(135deg,#f97316,#ea580c);
         color:#fff;text-decoration:none;border-radius:.5rem;
         font-weight:600;font-size:1rem;transition:opacity .2s}
    .btn:hover{opacity:.9}
    .btn-ghost{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);
               color:#e2e8f0}
    .btn-ghost:hover{background:rgba(255,255,255,.1);opacity:1}
    .footer{margin-top:2rem;font-size:.8rem;color:#475569}
  </style>
</head>
<body>
  <div class="card">
    <img class="logo" src="https://orbixpanel.com/logo.png" alt="OrbixPanel">
    <h1>${LANDING_H1}</h1>
    <p>${LANDING_TAGLINE}</p>
    <div class="actions">
      ${LANDING_ACTIONS}
    </div>
    <div class="footer">${LANDING_FOOTER}</div>
  </div>
</body>
</html>
HTMLEOF
    ok "Landing page created at ${LANDING_DIR}/index.html"

    # ── Apex root behaviour ─────────────────────────────────────────────────────
    # When the reseller chose the same domain for the storefront, the site root
    # proxies to the storefront renderer; it falls back to the static landing page
    # until the storefront is published (core returns 404/503 while draft), so the
    # homepage is never broken. Otherwise the root just serves the landing page.
    if [[ -n "$ORBIX_STOREFRONT_DOMAIN" && "$ORBIX_STOREFRONT_DOMAIN" == "$ORBIX_DOMAIN" ]]; then
      MAIN_ROOT_LOCATION=$(cat <<'LOCEOF'
    location / {
        proxy_intercept_errors on;
        error_page 404 502 503 504 = @landing;
        proxy_pass         http://127.0.0.1:3000/storefront-site/;
        proxy_http_version 1.1;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_read_timeout 60s;
    }
    location @landing { try_files /index.html =404; }
    # Storefront brand assets (logos/graphics) — narrow passthrough, not the whole API.
    location /api/storefront-site/assets/ {
        proxy_pass http://127.0.0.1:3000/storefront-site/assets/;
        proxy_set_header Host $host;
    }
    # Public storefront API (signup, support, captcha) — the headless JSON API the
    # storefront forms call. Core routes already start with /api/v1/storefront, so
    # forward the URI unchanged (no /api strip).
    location /api/v1/storefront/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
    }
    # Public payment API (checkout/webhooks/return) — storefront pay buttons +
    # gateway webhooks. Core routes start with /api/v1/payments, forward unchanged.
    location /api/v1/payments/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
    }
LOCEOF
)
    else
      MAIN_ROOT_LOCATION=$(cat <<'LOCEOF'
    location / {
        try_files $uri $uri/ /index.html;
    }
LOCEOF
)
    fi

    # ── Final SSL nginx config ─────────────────────────────────────────────────
    NGINX_SITE="/etc/nginx/sites-available/orbixpanel"
    cat > "$NGINX_SITE" << NGINXEOF
# Redirect HTTP → HTTPS
server {
    listen 80;
    server_name ${ORBIX_DOMAIN} www.${ORBIX_DOMAIN};
    location /.well-known/acme-challenge/ { root /var/www/certbot; try_files \$uri =404; }
    location / { return 301 https://${ORBIX_DOMAIN}\$request_uri; }
}

# Landing page — https://\${ORBIX_DOMAIN}  (port 443)
server {
    listen 443 ssl http2;
    server_name ${ORBIX_DOMAIN} www.${ORBIX_DOMAIN};

    ssl_certificate     ${CERT_DIR}/fullchain.pem;
    ssl_certificate_key ${CERT_DIR}/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 10m;

    server_tokens off;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header X-Frame-Options SAMEORIGIN always;
    add_header X-Content-Type-Options nosniff always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;

    root   ${LANDING_DIR};
    index  index.html;

${MAIN_ROOT_LOCATION}
}

# OrbixPanel — https://\${ORBIX_DOMAIN}:8080
server {
    listen 8080 ssl http2;
    server_name ${ORBIX_DOMAIN} www.${ORBIX_DOMAIN};

    ssl_certificate     ${CERT_DIR}/fullchain.pem;
    ssl_certificate_key ${CERT_DIR}/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 10m;

    root  ${FRONTEND_SRC}/dist;
    index index.html;

    # Security headers (panel UI + proxied API)
    server_tokens off;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header X-Frame-Options SAMEORIGIN always;
    add_header X-Content-Type-Options nosniff always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;

    # Payment API (checkout/methods/webhooks/return) — route already starts with
    # /api/v1/payments, so forward the URI UNCHANGED (no /api strip). Must come
    # before the generic /api/ block below, which strips the prefix.
    location /api/v1/payments/ {
        proxy_pass         http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header   Host              \$host;
        proxy_set_header   X-Real-IP         \$remote_addr;
        proxy_set_header   X-Forwarded-For   \$proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
    }

    location /api/ {
        proxy_pass         http://127.0.0.1:3000/;
        proxy_http_version 1.1;
        proxy_set_header   Host              \$host;
        proxy_set_header   X-Real-IP         \$remote_addr;
        proxy_set_header   X-Forwarded-For   \$proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_set_header   Upgrade           \$http_upgrade;
        proxy_set_header   Connection        'upgrade';
        proxy_cache_bypass \$http_upgrade;
        proxy_read_timeout 60s;
    }

    location / {
        try_files \$uri \$uri/ /index.html;
    }
}
NGINXEOF

    ln -sf "$NGINX_SITE" /etc/nginx/sites-enabled/orbixpanel
    rm -f /etc/nginx/sites-enabled/default
    nginx -t 2>/dev/null || die "SSL nginx config test failed — check $NGINX_SITE"
    systemctl reload nginx

    # Auto-renewal cron — a FALLBACK; certbot already installs a systemd timer, so
    # this is best-effort and must never abort the install (e.g. no crontab binary).
    if command -v crontab >/dev/null 2>&1 && ! crontab -l 2>/dev/null | grep -q "certbot renew"; then
      ( crontab -l 2>/dev/null; echo "0 3 * * * certbot renew --quiet --post-hook 'systemctl reload nginx'" ) | crontab - \
        || warn "Could not install renewal cron — certbot's systemd timer still handles renewal."
    fi

    # UFW: allow panel port 8080
    ufw allow 8080/tcp comment "OrbixPanel HTTPS" > /dev/null 2>&1 || true

    ok "SSL configured!"
    ok "Landing page: https://${ORBIX_DOMAIN}"
    ok "Panel:        https://${ORBIX_DOMAIN}:8080"
  fi
fi

# ─── 9c. Docs subdomain (docs.ORBIX_DOMAIN) ───────────────────────────────────
section "Docs Subdomain"

if [[ -z "$ORBIX_DOMAIN" ]]; then
  warn "No domain set — skipping docs subdomain."
elif [[ "${SSL_OK:-false}" != "true" ]]; then
  warn "SSL not configured — skipping docs subdomain."
else
  DOCS_DOMAIN="docs.${ORBIX_DOMAIN}"
  DOCS_DIR="/var/www/${DOCS_DOMAIN}"
  DOCS_NGINX_SITE="/etc/nginx/sites-available/${DOCS_DOMAIN}"
  DOCS_CERT_DIR="/etc/letsencrypt/live/${DOCS_DOMAIN}"

  info "Setting up ${DOCS_DOMAIN}…"
  mkdir -p "$DOCS_DIR"

  # ── Clone or update orbixpanel-website (contains docs/) ───────────────────
  # Use raw git commands here — git_clone/git_pull call die() on failure which
  # calls exit 1 and cannot be caught by || true. This is optional content so
  # failure must be silently skipped.
  WEBSITE_REPO_URL="${WEBSITE_REPO_URL:-https://github.com/orbixpanel/orbixpanel-website}"
  WEBSITE_SRC="/opt/orbixpanel-website"
  _WEBSITE_GIT_URL="$WEBSITE_REPO_URL"
  [[ -n "$GITHUB_TOKEN" ]] && _WEBSITE_GIT_URL="${WEBSITE_REPO_URL/https:\/\//https:\/\/${GITHUB_TOKEN}@}"
  if [[ -d "${WEBSITE_SRC}/.git" ]]; then
    GIT_TERMINAL_PROMPT=0 git -c credential.helper= -C "$WEBSITE_SRC" \
      pull --quiet origin main 2>/dev/null || true
  else
    GIT_TERMINAL_PROMPT=0 git -c credential.helper= \
      clone --quiet --depth 1 --branch main "$_WEBSITE_GIT_URL" "$WEBSITE_SRC" 2>/dev/null || true
  fi

  if [[ -d "${WEBSITE_SRC}/docs" ]]; then
    # Copy docs content from repo
    cp -r "${WEBSITE_SRC}/docs/." "$DOCS_DIR/"
    ok "Docs copied from orbixpanel-website repo."
  else
    # Fallback: generate a minimal index page
    warn "orbixpanel-website/docs not found — generating placeholder docs page."
    cat > "${DOCS_DIR}/index.html" << 'DOCEOF'
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>OrbixPanel Documentation</title>
  <style>
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:-apple-system,sans-serif;background:#0b0f1a;color:#e2e8f0;
         min-height:100vh;display:flex;align-items:center;justify-content:center}
    .card{text-align:center;padding:3rem;max-width:500px}
    .logo{width:56px;height:56px;border-radius:14px;object-fit:cover;
          display:block;margin:0 auto 1.5rem}
    h1{font-size:1.75rem;margin-bottom:.75rem;color:#f97316}
    p{color:#94a3b8;margin-bottom:1.5rem}
    a{color:#fb923c;text-decoration:none}a:hover{text-decoration:underline}
  </style>
</head>
<body>
  <div class="card">
    <img class="logo" src="https://orbixpanel.com/logo.png" alt="OrbixPanel">
    <h1>OrbixPanel Documentation</h1>
    <p>Documentation is being set up.<br>
       Visit <a href="https://github.com/orbixpanel">github.com/orbixpanel</a>
       or check back shortly.</p>
    <p><a href="/">← Back to panel</a></p>
  </div>
</body>
</html>
DOCEOF
  fi

  chmod -R 755 "$DOCS_DIR"

  # ── Add docs A record to BIND9 zone ───────────────────────────────────────
  _BIND_DNS_OK=false
  if [[ -n "$ORBIX_SERVER_IP" ]]; then
    _ZONE_FILE="/etc/bind/zones/db.${ORBIX_DOMAIN}"
    if [[ -f "$_ZONE_FILE" ]]; then
      if ! grep -qE "^docs[[:space:]]" "$_ZONE_FILE" 2>/dev/null; then
        # Use Python to append the A record and bump the serial atomically
        python3 - "$_ZONE_FILE" "$ORBIX_SERVER_IP" << 'ZONEFIX'
import sys, re
path, ip = sys.argv[1], sys.argv[2]
content = open(path).read()
# Bump 10-digit serial
def bump(m):
    return str(int(m.group()) + 1) + m.string[m.end():]
content = re.sub(r'\d{10}(?=\s*;\s*serial)', lambda m: str(int(m.group())+1), content, count=1)
# Append docs A record before end
content = content.rstrip('\n') + f'\ndocs    3600    IN  A     {ip}\n'
open(path, 'w').write(content)
print('docs A record written')
ZONEFIX
      fi
      # Validate and reload BIND9
      if named-checkconf 2>/dev/null && named-checkzone "${ORBIX_DOMAIN}" "$_ZONE_FILE" > /dev/null 2>&1; then
        rndc reload > /dev/null 2>&1 && ok "DNS A record active: ${DOCS_DOMAIN} → ${ORBIX_SERVER_IP}" \
          || warn "rndc reload failed — check: sudo systemctl status bind9"
        _BIND_DNS_OK=true
      else
        warn "named-checkconf/checkzone failed — BIND9 config has errors."
        warn "Check: sudo named-checkconf && sudo named-checkzone ${ORBIX_DOMAIN} ${_ZONE_FILE}"
      fi
    else
      warn "Zone file /etc/bind/zones/db.${ORBIX_DOMAIN} not found — add docs A record manually."
    fi
  fi

  # ── nginx config: always write HTTP-only config first ─────────────────────
  # This serves the ACME challenge AND the docs over plain HTTP as a fallback
  # when SSL is not yet available (DNS not propagated).
  _write_docs_http_nginx() {
    cat > "$DOCS_NGINX_SITE" << DOCSEOF
server {
    listen 80;
    server_name ${DOCS_DOMAIN};

    root  ${DOCS_DIR};
    index index.html;

    location /.well-known/acme-challenge/ { root /var/www/certbot; try_files \$uri =404; }

    # Serve .md files for the docs JS fetcher
    location ~* \.md\$ {
        add_header Content-Type text/plain;
        try_files \$uri =404;
    }

    location / {
        try_files \$uri \$uri/ /index.html;
    }
}
DOCSEOF
  }
  _write_docs_http_nginx
  ln -sf "$DOCS_NGINX_SITE" "/etc/nginx/sites-enabled/${DOCS_DOMAIN}"
  nginx -t 2>/dev/null && systemctl reload nginx \
    || warn "nginx reload failed — check ${DOCS_NGINX_SITE}"
  ok "Docs available over HTTP: http://${DOCS_DOMAIN}"

  # ── Verify docs domain resolves locally before attempting Certbot ──────────
  # Certbot HTTP-01 challenge needs the domain to resolve publicly.
  # If local BIND9 just loaded it, we check localhost; public resolution
  # depends on the registrar nameservers being set up.
  _DOCS_RESOLVES=false
  if dig +short "${DOCS_DOMAIN}" @127.0.0.1 2>/dev/null | grep -q "${ORBIX_SERVER_IP}"; then
    _DOCS_RESOLVES=true
    ok "${DOCS_DOMAIN} resolves locally — attempting SSL certificate."
  else
    warn "${DOCS_DOMAIN} does not resolve locally yet."
    warn "SSL will be skipped. Once DNS propagates, run the docs SSL command from the summary."
  fi

  # ── SSL certificate for docs subdomain ────────────────────────────────────
  DOCS_SSL_OK=false
  if [[ -f "${DOCS_CERT_DIR}/fullchain.pem" ]]; then
    ok "SSL certificate for ${DOCS_DOMAIN} already exists — skipping certbot."
    DOCS_SSL_OK=true
  elif [[ "$_DOCS_RESOLVES" == "true" ]]; then
    info "Obtaining SSL certificate for ${DOCS_DOMAIN}…"
    if certbot certonly \
        --webroot -w /var/www/certbot \
        -d "${DOCS_DOMAIN}" \
        --non-interactive --agree-tos \
        --email "${ORBIX_ADMIN_EMAIL}" \
        --quiet 2>/dev/null; then
      DOCS_SSL_OK=true
      ok "SSL certificate obtained for ${DOCS_DOMAIN}."
    else
      warn "certbot failed for ${DOCS_DOMAIN} — domain may not resolve publicly yet."
      warn "The docs are accessible over HTTP in the meantime."
    fi
  fi

  # ── Upgrade nginx to SSL config if certificate is available ───────────────
  if [[ "$DOCS_SSL_OK" == "true" ]]; then
    cat > "$DOCS_NGINX_SITE" << DOCSEOF
server {
    listen 80;
    server_name ${DOCS_DOMAIN};
    location /.well-known/acme-challenge/ { root /var/www/certbot; try_files \$uri =404; }
    location / { return 301 https://${DOCS_DOMAIN}\$request_uri; }
}

server {
    listen 443 ssl http2;
    server_name ${DOCS_DOMAIN};

    ssl_certificate     ${DOCS_CERT_DIR}/fullchain.pem;
    ssl_certificate_key ${DOCS_CERT_DIR}/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 10m;

    server_tokens off;
    add_header Strict-Transport-Security "max-age=63072000" always;
    add_header X-Frame-Options SAMEORIGIN always;
    add_header X-Content-Type-Options nosniff always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;

    root  ${DOCS_DIR};
    index index.html;

    location ~* \.md\$ {
        add_header Content-Type text/plain;
        try_files \$uri =404;
    }

    location / {
        try_files \$uri \$uri/ /index.html;
    }
}
DOCSEOF
    nginx -t 2>/dev/null && systemctl reload nginx \
      && ok "Docs SSL nginx config applied." \
      || warn "nginx reload failed — check ${DOCS_NGINX_SITE}"
    ok "Docs live at: https://${DOCS_DOMAIN}"
  else
    info "Docs are available over HTTP at: http://${DOCS_DOMAIN}"
    info "Run the following once DNS propagates to enable HTTPS:"
    echo "    certbot certonly --webroot -w /var/www/certbot -d ${DOCS_DOMAIN} --non-interactive --agree-tos --email ${ORBIX_ADMIN_EMAIL}"
    echo "    # Then re-run install.sh to apply the SSL nginx config."
  fi
fi

# ─── 9d. Reseller storefront host ─────────────────────────────────────────────
# When the reseller picked a SEPARATE domain/subdomain for the storefront, wire a
# dedicated nginx server block that proxies the host to the storefront renderer
# (core resolves the reseller by the Host header). Same-domain is handled inline in
# the apex 443 block above. A holding page is served until the storefront is
# published (status=live) in the panel. Mirrors the docs-subdomain flow.
if [[ "$ORBIXPANEL_PROFILE" == "reseller" && -n "$ORBIX_STOREFRONT_DOMAIN" ]]; then
  section "Reseller Storefront"

  if [[ "$ORBIX_STOREFRONT_DOMAIN" == "$ORBIX_DOMAIN" ]]; then
    ok "Storefront serves at the site root (https://${ORBIX_DOMAIN}) once published in the panel."
  elif [[ -z "$ORBIX_DOMAIN" ]]; then
    warn "No base domain — skipping storefront host."
  elif [[ "${SSL_OK:-false}" != "true" ]]; then
    warn "SSL not configured — skipping storefront host ${ORBIX_STOREFRONT_DOMAIN}."
  else
    SF_DOMAIN="$ORBIX_STOREFRONT_DOMAIN"
    SF_NGINX_SITE="/etc/nginx/sites-available/${SF_DOMAIN}"
    SF_CERT_DIR="/etc/letsencrypt/live/${SF_DOMAIN}"
    SF_HOLD_DIR="/var/www/${SF_DOMAIN}"
    info "Setting up storefront host ${SF_DOMAIN}…"
    mkdir -p "$SF_HOLD_DIR"

    # Holding page — shown until the storefront is published in the panel.
    cat > "${SF_HOLD_DIR}/index.html" << SFHOLD
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${SF_DOMAIN}</title>
<style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;display:flex;align-items:center;justify-content:center;margin:0}.c{text-align:center;padding:2rem}h1{color:#f97316;margin:0 0 .5rem}p{color:#94a3b8}</style>
</head><body><div class="c"><h1>${SF_DOMAIN}</h1><p>Our website is coming soon.</p></div></body></html>
SFHOLD

    # DNS A record when the storefront host is a subdomain of the base domain.
    if [[ "$SF_DOMAIN" == *."$ORBIX_DOMAIN" && -n "$ORBIX_SERVER_IP" ]]; then
      _SF_LABEL="${SF_DOMAIN%.$ORBIX_DOMAIN}"
      _ZONE_FILE="/etc/bind/zones/db.${ORBIX_DOMAIN}"
      if [[ -f "$_ZONE_FILE" ]] && ! grep -qE "^${_SF_LABEL}[[:space:]]" "$_ZONE_FILE" 2>/dev/null; then
        python3 - "$_ZONE_FILE" "$ORBIX_SERVER_IP" "$_SF_LABEL" << 'ZONEFIX'
import sys, re
path, ip, label = sys.argv[1], sys.argv[2], sys.argv[3]
content = open(path).read()
content = re.sub(r'\d{10}(?=\s*;\s*serial)', lambda m: str(int(m.group())+1), content, count=1)
content = content.rstrip('\n') + f'\n{label}    3600    IN  A     {ip}\n'
open(path, 'w').write(content)
print(f'{label} A record written')
ZONEFIX
        if named-checkconf 2>/dev/null && named-checkzone "${ORBIX_DOMAIN}" "$_ZONE_FILE" > /dev/null 2>&1; then
          rndc reload > /dev/null 2>&1 && ok "DNS A record active: ${SF_DOMAIN} → ${ORBIX_SERVER_IP}" \
            || warn "rndc reload failed — check: sudo systemctl status bind9"
        else
          warn "named-checkzone failed after adding ${_SF_LABEL} — check BIND9 config."
        fi
      fi
    fi

    # HTTP-first nginx: ACME challenge + storefront proxy with holding-page fallback.
    cat > "$SF_NGINX_SITE" << SFEOF
server {
    listen 80;
    server_name ${SF_DOMAIN};

    root  ${SF_HOLD_DIR};
    index index.html;

    location /.well-known/acme-challenge/ { root /var/www/certbot; try_files \$uri =404; }

    location /api/storefront-site/assets/ {
        proxy_pass http://127.0.0.1:3000/storefront-site/assets/;
        proxy_set_header Host \$host;
    }

    location /api/v1/storefront/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
    }

    location /api/v1/payments/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
    }

    location / {
        proxy_intercept_errors on;
        error_page 404 502 503 504 = @hold;
        proxy_pass         http://127.0.0.1:3000/storefront-site/;
        proxy_http_version 1.1;
        proxy_set_header   Host              \$host;
        proxy_set_header   X-Real-IP         \$remote_addr;
        proxy_set_header   X-Forwarded-For   \$proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto \$scheme;
        proxy_read_timeout 60s;
    }
    location @hold { try_files /index.html =404; }
}
SFEOF
    ln -sf "$SF_NGINX_SITE" "/etc/nginx/sites-enabled/${SF_DOMAIN}"
    nginx -t 2>/dev/null && systemctl reload nginx \
      || warn "nginx reload failed — check ${SF_NGINX_SITE}"

    # Resolve check (local BIND first, then public), then certbot — mirrors docs.
    _SF_RESOLVES=false
    if dig +short "${SF_DOMAIN}" @127.0.0.1 2>/dev/null | grep -q "${ORBIX_SERVER_IP}" \
       || dig +short "${SF_DOMAIN}" 2>/dev/null | grep -q "${ORBIX_SERVER_IP}"; then
      _SF_RESOLVES=true
    fi

    SF_SSL_OK=false
    if [[ -f "${SF_CERT_DIR}/fullchain.pem" ]]; then
      ok "SSL certificate for ${SF_DOMAIN} already exists — skipping certbot."
      SF_SSL_OK=true
    elif [[ "$_SF_RESOLVES" == "true" ]]; then
      info "Obtaining SSL certificate for ${SF_DOMAIN}…"
      if certbot certonly --webroot -w /var/www/certbot -d "${SF_DOMAIN}" \
          --non-interactive --agree-tos --email "${ORBIX_ADMIN_EMAIL}" --quiet 2>/dev/null; then
        SF_SSL_OK=true; ok "SSL certificate obtained for ${SF_DOMAIN}."
      else
        warn "certbot failed for ${SF_DOMAIN} — it may not resolve publicly yet."
      fi
    else
      warn "${SF_DOMAIN} does not resolve to ${ORBIX_SERVER_IP} yet — serving over HTTP for now."
    fi

    if [[ "$SF_SSL_OK" == "true" ]]; then
      cat > "$SF_NGINX_SITE" << SFEOF
server {
    listen 80;
    server_name ${SF_DOMAIN};
    location /.well-known/acme-challenge/ { root /var/www/certbot; try_files \$uri =404; }
    location / { return 301 https://${SF_DOMAIN}\$request_uri; }
}

server {
    listen 443 ssl http2;
    server_name ${SF_DOMAIN};

    ssl_certificate     ${SF_CERT_DIR}/fullchain.pem;
    ssl_certificate_key ${SF_CERT_DIR}/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 10m;

    server_tokens off;
    add_header Strict-Transport-Security "max-age=63072000" always;
    add_header X-Frame-Options SAMEORIGIN always;
    add_header X-Content-Type-Options nosniff always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;

    root  ${SF_HOLD_DIR};
    index index.html;

    location /api/storefront-site/assets/ {
        proxy_pass http://127.0.0.1:3000/storefront-site/assets/;
        proxy_set_header Host \$host;
    }

    location /api/v1/storefront/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
    }

    location /api/v1/payments/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
    }

    location / {
        proxy_intercept_errors on;
        error_page 404 502 503 504 = @hold;
        proxy_pass         http://127.0.0.1:3000/storefront-site/;
        proxy_http_version 1.1;
        proxy_set_header   Host              \$host;
        proxy_set_header   X-Real-IP         \$remote_addr;
        proxy_set_header   X-Forwarded-For   \$proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        proxy_read_timeout 60s;
    }
    location @hold { try_files /index.html =404; }
}
SFEOF
      nginx -t 2>/dev/null && systemctl reload nginx \
        && ok "Storefront live at: https://${SF_DOMAIN}" \
        || warn "nginx reload failed — check ${SF_NGINX_SITE}"
    else
      info "Storefront reachable over HTTP at: http://${SF_DOMAIN} (holding page until published)."
      info "Enable HTTPS once DNS propagates:"
      echo "    certbot certonly --webroot -w /var/www/certbot -d ${SF_DOMAIN} --non-interactive --agree-tos --email ${ORBIX_ADMIN_EMAIL}"
      echo "    # Then re-run install.sh to apply the SSL nginx config."
    fi

    ok "Publish it in the panel: Storefront → add a Home page → set status to Live."
  fi
fi

# ─── 10. Go toolchain ─────────────────────────────────────────────────────────
section "Go Toolchain"

# Decide agent delivery BEFORE the Go section — only install Go for source builds.
AGENT_PREBUILT=0
if [[ -z "$AGENT_BIN_URL" && "$SKIP_GO" != "1" ]] && _agent_prebuilt_available; then
  AGENT_PREBUILT=1
fi

if [[ "$SKIP_GO" == "1" || -n "$AGENT_BIN_URL" || "$AGENT_PREBUILT" == "1" ]]; then
  if [[ "$SKIP_GO" == "1" ]]; then warn "Skipping Go (SKIP_GO=1)."
  elif [[ -n "$AGENT_BIN_URL" ]]; then info "AGENT_BIN_URL set — skipping Go installation."
  else info "Using pre-built agent binary — skipping Go installation."; fi
else
  GO_MIN_MAJOR=1; GO_MIN_MINOR=21; go_ok=false

  if [[ -x /usr/local/go/bin/go ]]; then
    CURRENT_GO=$(/usr/local/go/bin/go version | awk '{print $3}' | sed 's/go//')
    CUR_MAJOR=$(echo "$CURRENT_GO" | cut -d. -f1)
    CUR_MINOR=$(echo "$CURRENT_GO" | cut -d. -f2)
    if [[ "$CUR_MAJOR" -gt "$GO_MIN_MAJOR" ]] || \
       [[ "$CUR_MAJOR" -eq "$GO_MIN_MAJOR" && "$CUR_MINOR" -ge "$GO_MIN_MINOR" ]]; then
      go_ok=true; ok "Go $CURRENT_GO already installed."
    else
      warn "Go $CURRENT_GO below minimum — upgrading…"
    fi
  fi

  if [[ "$go_ok" == false ]]; then
    GOARCH=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')
    GO_TARBALL="go${GO_VERSION}.linux-${GOARCH}.tar.gz"
    info "Downloading Go ${GO_VERSION} (${GOARCH})…"
    curl -fsSL "https://go.dev/dl/${GO_TARBALL}" -o "/tmp/${GO_TARBALL}"
    rm -rf /usr/local/go
    tar -C /usr/local -xzf "/tmp/${GO_TARBALL}"
    rm "/tmp/${GO_TARBALL}"
    ok "Go ${GO_VERSION} installed to /usr/local/go."
  fi

  export PATH="$PATH:/usr/local/go/bin"
fi

# ─── 11. Agent binary ─────────────────────────────────────────────────────────
section "OrbixPanel Agent Binary"

if [[ -n "$AGENT_BIN_URL" ]]; then
  if [[ -f "$AGENT_BIN" ]]; then
    ok "Agent binary already present — skipping download."
  else
    info "Downloading agent binary…"
    curl -fsSL "$AGENT_BIN_URL" -o "$AGENT_BIN"
    chmod +x "$AGENT_BIN"
    ok "Agent binary downloaded."
  fi
elif [[ "$AGENT_PREBUILT" == "1" ]]; then
  # Default: install the pre-built, key-embedded binary for this arch from our
  # release (source never lands on the customer's server). Checksum-verified.
  _install_prebuilt_agent
else
  # Clone or update source, tracking whether commits changed so we know
  # whether a rebuild is needed even when the binary already exists.
  AGENT_HASH_BEFORE=""
  if [[ -d "${AGENT_SRC}/.git" ]]; then
    AGENT_HASH_BEFORE=$(git -C "$AGENT_SRC" rev-parse HEAD 2>/dev/null || true)
    info "Updating agent source…"
    git_pull "$AGENT_SRC" "$AGENT_BRANCH"
    ok "Agent source updated."
  else
    info "Cloning agent source…"
    git_clone "$AGENT_REPO_URL" "$AGENT_SRC" "$AGENT_BRANCH"
    ok "Agent source cloned."
  fi
  AGENT_HASH_AFTER=$(git -C "$AGENT_SRC" rev-parse HEAD 2>/dev/null || true)

  # ── Ensure MinIO is pinned to the source-compiled release tag ────────────
  # On amd64 the agent downloads a pre-built binary; on arm64 it builds from
  # source (pre-built arm64 binaries crash on QEMU-virtualised ARM64).
  # If the repo has a different version constant, patch it to the pinned tag.
  MINIO_PINNED_VER="RELEASE.2024-01-18T22-51-28Z"
  MINIO_GO="${AGENT_SRC}/executor/minio.go"
  if [[ -f "$MINIO_GO" ]] && ! grep -qF "${MINIO_PINNED_VER}" "$MINIO_GO"; then
    info "Pinning MinIO source tag to ${MINIO_PINNED_VER} in agent source…"
    sed -i "s|minioVersion = \"[^\"]*\"|minioVersion = \"${MINIO_PINNED_VER}\"|g" "$MINIO_GO"
    rm -f "$AGENT_BIN"
    ok "MinIO tag pinned — agent will be rebuilt."
  fi

  # ── Patch dns.go: fix updateNamedConf regex to handle nested braces ─────────
  # named.conf.local blocks contain "allow-transfer { none; };" which has a
  # nested brace. The old regex [^}]* stops at the inner } and leaves a stray
  # }; on every sync, causing named-checkconf to fail. Replace with a pattern
  # that handles one level of nesting: (?:[^{}]*|\{[^{}]*\})*.
  _DNS_GO="${AGENT_SRC}/executor/dns.go"
  if [[ -f "$_DNS_GO" ]] && grep -qF '\{[^}]*\};' "$_DNS_GO"; then
    info "Patching dns.go updateNamedConf regex…"
    python3 - "$_DNS_GO" << 'PYEOF'
import sys
path = sys.argv[1]
with open(path) as f:
    c = f.read()
old = r'`"\s*\{[^}]*\};\s*\n?`'
new = r'`"\s*\{(?:[^{}]*|\{[^{}]*\})*\};\s*\n?`'
if old in c:
    c = c.replace(old, new)
    with open(path, 'w') as f:
        f.write(c)
    print('patched')
else:
    print('already patched or pattern not found — skipping')
PYEOF
    rm -f "$AGENT_BIN"
    ok "dns.go regex patched — agent will be rebuilt."
  fi

  # Rebuild if: binary missing OR source has new commits since last build.
  if [[ ! -f "$AGENT_BIN" ]] || [[ "$AGENT_HASH_BEFORE" != "$AGENT_HASH_AFTER" ]]; then
    BUILD_GOPATH="/tmp/go-build-orbix-$$"
    mkdir -p "$BUILD_GOPATH"
    run_spinner "Building agent binary…" /tmp/go-build.log \
      bash -c "cd '$AGENT_SRC' && GOPATH='$BUILD_GOPATH' CGO_ENABLED=0 GOOS=linux GOTOOLCHAIN=local \
        /usr/local/go/bin/go build -ldflags='-s -w' -o '$AGENT_BIN' ." \
      || { cat /tmp/go-build.log; die "Agent build failed."; }
    rm -rf "$BUILD_GOPATH"
    chmod +x "$AGENT_BIN"
    ok "Agent built: $AGENT_BIN"
  else
    ok "Agent binary up to date — skipping build."
  fi
fi

# ─── 12. Sudoers rule (agent needs systemctl) ─────────────────────────────────
section "Sudoers Rule"

# Shared group for tenant Linux users. The tenant file manager runs file ops as
# the tenant's own user (sudo -u); restricting the sudo runas to this group means
# those ops can NEVER run as root — a hard OS-level jail even if a path check slips.
groupadd -f orbixtenants

SUDOERS_FILE="/etc/sudoers.d/orbixpanel-agent"
# Always write so new rules (pg_dump etc.) are applied on re-runs.
cat > "$SUDOERS_FILE" <<SUDOEOF
# Agent privileged commands. Modern sudo (Ubuntu 24.04/26.04, sudo >=1.9.10) rejects
# wildcards in command arguments, so scoping by path is not expressible here. We grant
# bare commands (any args) — valid on every sudo version — and rely on the real controls:
#   - the agent only runs a fixed set of TYPED tasks (whitelisted in the Go agent),
#   - the backend path-jails tenant file ops to /home/orbix-<user>, and
#   - tenant ops run under the non-root %orbixtenants runas (see that block below).
# Proper future hardening: a single path-validating wrapper the agent calls.
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/bin/systemctl
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/sbin/nginx
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/bin/tee
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/bin/chmod
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/sbin/ufw
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/bin/kill
${ORBIX_USER} ALL=(root) NOPASSWD: /bin/cat
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/sbin/rndc
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/bin/rndc
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/sbin/named-checkconf
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/bin/named-checkconf
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/sbin/named-checkzone
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/bin/named-checkzone
${ORBIX_USER} ALL=(root) NOPASSWD: /bin/rm
${ORBIX_USER} ALL=(root) NOPASSWD: /bin/ln
${ORBIX_USER} ALL=(root) NOPASSWD: /bin/mkdir
${ORBIX_USER} ALL=(root) NOPASSWD: /bin/mv
${ORBIX_USER} ALL=(root) NOPASSWD: /bin/cp
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/bin/cp
${ORBIX_USER} ALL=(root) NOPASSWD: /bin/chmod
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/sbin/useradd
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/sbin/userdel
${ORBIX_USER} ALL=(root) NOPASSWD: /bin/chown
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/sbin/usermod
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/bin/truncate
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/bin/certbot
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/local/bin/filebrowser
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/local/bin/n
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/sbin/postmap
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/bin/apt-get
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/sbin/chpasswd
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/bin/fail2ban-client
${ORBIX_USER} ALL=(root) NOPASSWD: /bin/tar
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/bin/tar
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/bin/unzip
${ORBIX_USER} ALL=(root) NOPASSWD: /usr/local/bin/orbixpanel
# Tenant file manager: runas pinned to the non-root %orbixtenants group;
# the backend path-jails every op to /home/orbix-<user>.
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /usr/bin/find
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /bin/cat
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /usr/bin/stat
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /usr/bin/tee
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /bin/mkdir
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /bin/rm
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /bin/mv
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /usr/bin/du
# Tenant file manager (parity with reseller): copy, archive, permissions, new file.
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /bin/cp
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /usr/bin/cp
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /bin/tar
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /usr/bin/tar
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /usr/bin/zip
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /usr/bin/unzip
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /usr/bin/touch
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /bin/touch
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /bin/chmod
${ORBIX_USER} ALL=(%orbixtenants) NOPASSWD: /usr/bin/chmod
# Postgres backups run as the postgres role.
${ORBIX_USER} ALL=(postgres) NOPASSWD: /usr/bin/pg_dump
${ORBIX_USER} ALL=(postgres) NOPASSWD: /usr/bin/pg_restore
SUDOEOF
chmod 440 "$SUDOERS_FILE"
# Validate sudoers syntax before leaving it in place. On failure, surface the real
# visudo error + the offending line, and keep a copy for inspection.
if ! _visudo_out="$(visudo -c -f "$SUDOERS_FILE" 2>&1)"; then
  warn "sudoers validation failed — visudo said:"
  printf '%s\n' "$_visudo_out" >&2
  cp -f "$SUDOERS_FILE" "${SUDOERS_FILE}.invalid" 2>/dev/null || true
  rm -f "$SUDOERS_FILE"
  die "Generated sudoers file failed visudo check (copy saved at ${SUDOERS_FILE}.invalid)."
fi
ok "Sudoers rule written."

# Ensure /root/.ssh exists with correct permissions so the SSH key sync task
# can write authorized_keys without needing to create the directory itself.
mkdir -p /root/.ssh
chmod 700 /root/.ssh
ok "/root/.ssh directory ensured."

# ─── 13. UFW pre-configuration ───────────────────────────────────────────────
section "UFW Firewall Pre-configuration"

# Pre-configure safe defaults so the operator can safely enable UFW from the
# panel without being locked out. UFW is NOT auto-enabled here.
if command -v ufw &>/dev/null; then
  ufw --force reset > /dev/null 2>&1 || true
  ufw default deny incoming  > /dev/null
  ufw default allow outgoing > /dev/null
  # Essential: SSH must be allowed before UFW is ever enabled
  ufw allow 22/tcp  > /dev/null
  # Panel UI and API
  ufw allow 80/tcp  > /dev/null
  ufw allow 443/tcp > /dev/null
  ufw allow 21/tcp  > /dev/null
  ufw allow 40000:50000/tcp > /dev/null  # vsftpd passive mode
  ufw allow 9000/tcp > /dev/null  # MinIO S3 API
  ufw allow 8184/tcp > /dev/null  # MinIO Console UI
  ok "UFW defaults configured (SSH + HTTP/HTTPS + FTP + MinIO allowed). Enable from the panel when ready."
  warn "Do NOT enable UFW until you have verified that port 22 (SSH) is in the rules list."
else
  warn "ufw not found — skipping firewall pre-configuration."
fi

# ─── 14. Admin registration + server registration ────────────────────────────
section "Admin & Server Registration"

SAFE_PASS=$(printf '%s' "$ORBIX_ADMIN_PASSWORD" | \
  python3 -c "import json,sys; print(json.dumps(sys.stdin.read())[1:-1])")

# Register admin user — succeeds on fresh install (201), ignored on re-run (409)
REG_CODE=$(curl -s -o /dev/null -w '%{http_code}' \
  -X POST "${ORBIX_API_URL}/auth/register" \
  -H "Content-Type: application/json" \
  -d "{\"email\":\"${ORBIX_ADMIN_EMAIL}\",\"password\":\"${SAFE_PASS}\"}" \
  --connect-timeout 10 --max-time 15 2>/dev/null || echo "000")
if   [[ "$REG_CODE" == "201" ]]; then ok "Admin user registered."
elif [[ "$REG_CODE" =~ ^(400|409)$ ]]; then ok "Admin user already exists."
else warn "Registration returned HTTP ${REG_CODE} — proceeding to login."
fi

# Login
info "Authenticating…"
LOGIN_RESP=$(api_post "/auth/login" \
  "{\"email\":\"${ORBIX_ADMIN_EMAIL}\",\"password\":\"${SAFE_PASS}\"}")
JWT=$(json_get "$LOGIN_RESP" "access_token")
if [[ -z "$JWT" ]]; then
  REQUIRES_2FA=$(json_get "$LOGIN_RESP" "requiresTwoFactor")
  if [[ "$REQUIRES_2FA" == "true" ]]; then
    # Admin has 2FA enabled. Complete it interactively (TOTP or a backup code) by
    # exchanging the preAuthToken for a real access_token via /auth/2fa/verify.
    PREAUTH_TOKEN=$(json_get "$LOGIN_RESP" "preAuthToken")
    if [[ -r /dev/tty && -n "$PREAUTH_TOKEN" ]]; then
      info "This admin account has two-factor authentication enabled."
      for _attempt in 1 2 3; do
        read -rp "  Enter the 6-digit 2FA code (or a backup code): " TFA_CODE < /dev/tty
        TFA_CODE=$(printf '%s' "$TFA_CODE" | tr -d '[:space:]')
        [[ -z "$TFA_CODE" ]] && continue
        # Raw curl (not api_post) so a bad code doesn't abort the installer — the loop retries.
        VERIFY_RESP=$(curl -s -X POST "${ORBIX_API_URL}/auth/2fa/verify" \
          -H "Content-Type: application/json" \
          -d "{\"preAuthToken\":\"${PREAUTH_TOKEN}\",\"code\":\"${TFA_CODE}\"}" \
          --connect-timeout 10 --max-time 15 2>/dev/null || true)
        JWT=$(json_get "$VERIFY_RESP" "access_token" 2>/dev/null || true)
        if [[ -n "$JWT" ]]; then ok "Two-factor verified."; break; fi
        warn "Invalid code — $((3 - _attempt)) attempt(s) left."
      done
    fi
    if [[ -z "$JWT" ]]; then
      if [[ -f "$AGENT_ENV" ]] && grep -q "^SERVER_ID=" "$AGENT_ENV" 2>/dev/null; then
        warn "2FA not completed. Skipping server registration (already registered); remaining steps continue."
        JWT=""
      else
        die "Admin account has 2FA enabled and no valid code was entered.\nRe-run install.sh and enter the 2FA code when prompted, or temporarily disable 2FA in OrbixPanel Settings."
      fi
    fi
  else
    die "Login failed — no access_token in response: ${LOGIN_RESP}"
  fi
else
  ok "Authenticated as ${ORBIX_ADMIN_EMAIL}."
fi

# Register server (idempotent — skip if already registered)
if [[ -f "$AGENT_ENV" ]] && grep -q "^SERVER_ID=" "$AGENT_ENV" 2>/dev/null; then
  EXISTING_ID=$(grep "^SERVER_ID=" "$AGENT_ENV" | cut -d= -f2-)
  ok "Server already registered (SERVER_ID=${EXISTING_ID})."
else
  info "Registering server '${ORBIX_SERVER_NAME}' (${ORBIX_SERVER_IP})…"
  SERVER_RESP=$(api_post "/servers" \
    "{\"name\":\"${ORBIX_SERVER_NAME}\",\"ip\":\"${ORBIX_SERVER_IP}\"}" "$JWT")
  SERVER_ID=$(json_get "$SERVER_RESP" "id")
  [[ -n "$SERVER_ID" ]] || die "Server created but id missing from response."
  ok "Server registered: ID=${SERVER_ID}"

  cat > "$AGENT_ENV" <<ENVEOF
# OrbixPanel Agent — generated by install.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
API_URL=${ORBIX_API_URL}
SERVER_ID=${SERVER_ID}
POLL_INTERVAL=5
HEARTBEAT_INTERVAL=30
ENVEOF
  chmod 600 "$AGENT_ENV"
  ok "Agent env file written."
fi

# ─── 14b. Seed default website + email ───────────────────────────────────────
# Server is now registered — SERVER_ID (fresh) or EXISTING_ID (re-run) is set.
_SEED_SERVER_ID="${SERVER_ID:-${EXISTING_ID:-}}"

if [[ -n "$_SEED_SERVER_ID" && -n "$ORBIX_DOMAIN" ]]; then

  # ── Panel website record ────────────────────────────────────────────────────
  _SITE_SSL_STATUS="none"
  [[ "${SSL_OK:-false}" == "true" ]] && _SITE_SSL_STATUS="issued"
  psql "$DATABASE_URL" -c "
    INSERT INTO \"Website\" (id, \"serverId\", domain, \"sslStatus\", \"nginxEnabled\", \"isPanel\", \"createdAt\")
    VALUES (gen_random_uuid(), '${_SEED_SERVER_ID}', '${ORBIX_DOMAIN}', '${_SITE_SSL_STATUS}', true, true, NOW())
    ON CONFLICT (domain) DO UPDATE SET \"sslStatus\" = '${_SITE_SSL_STATUS}', \"isPanel\" = true;
  " >/dev/null
  ok "Panel website seeded: ${ORBIX_DOMAIN} (SSL: ${_SITE_SSL_STATUS})"

  # ── Default email account (admin@ORBIX_DOMAIN) ─────────────────────────────
  if [[ -n "$ORBIX_ADMIN_PASSWORD" ]]; then
    _DPASS=$(doveadm pw -s BLF-CRYPT -p "${ORBIX_ADMIN_PASSWORD}" 2>/dev/null || echo "")
    if [[ -n "$_DPASS" ]]; then
      _DPASS_SQL="${_DPASS//\'/\'\'}"

      psql "$DATABASE_URL" -c "
        INSERT INTO \"EmailDomain\" (id, \"serverId\", domain, \"createdAt\")
        VALUES (gen_random_uuid(), '${_SEED_SERVER_ID}', '${ORBIX_DOMAIN}', NOW())
        ON CONFLICT (\"serverId\", domain) DO NOTHING;
      " >/dev/null

      _SEED_DOMAIN_ID=$(psql "$DATABASE_URL" -tAc "
        SELECT id FROM \"EmailDomain\" WHERE \"serverId\" = '${_SEED_SERVER_ID}' AND domain = '${ORBIX_DOMAIN}' LIMIT 1;
      " 2>/dev/null | tr -d '[:space:]')

      if [[ -n "$_SEED_DOMAIN_ID" ]]; then
        psql "$DATABASE_URL" -c "
          INSERT INTO \"EmailAccount\" (id, \"domainId\", username, \"passwordHash\", \"quotaMb\", \"isDefault\", \"createdAt\")
          VALUES (gen_random_uuid(), '${_SEED_DOMAIN_ID}', 'admin', '${_DPASS_SQL}', NULL, true, NOW())
          ON CONFLICT (\"domainId\", username) DO NOTHING;
        " >/dev/null
        ok "Default email account seeded: admin@${ORBIX_DOMAIN}"
      else
        warn "Could not create email domain in DB — skipping email seed."
      fi

      # Write to Postfix/Dovecot so mail works immediately
      _EMAIL_ADDR="admin@${ORBIX_DOMAIN}"
      if ! grep -qxF "${ORBIX_DOMAIN}" /etc/postfix/virtual_mailbox_domains 2>/dev/null; then
        echo "${ORBIX_DOMAIN}" >> /etc/postfix/virtual_mailbox_domains
        postmap /etc/postfix/virtual_mailbox_domains
      fi
      if ! grep -qF "${_EMAIL_ADDR}" /etc/postfix/virtual_mailbox_maps 2>/dev/null; then
        echo "${_EMAIL_ADDR}   ${ORBIX_DOMAIN}/admin/" >> /etc/postfix/virtual_mailbox_maps
        postmap /etc/postfix/virtual_mailbox_maps
      fi
      if ! grep -qF "admin@${ORBIX_DOMAIN}" /etc/dovecot/users 2>/dev/null; then
        echo "admin@${ORBIX_DOMAIN}:${_DPASS}::::" >> /etc/dovecot/users
      fi
      mkdir -p "/var/mail/vhosts/${ORBIX_DOMAIN}/admin"
      chown -R vmail:vmail "/var/mail/vhosts/${ORBIX_DOMAIN}"
      systemctl reload postfix dovecot 2>/dev/null || true
    else
      warn "doveadm not available — skipping email seed."
    fi
  fi
  # ── DNS zone + records seed ─────────────────────────────────────────────────
  # Seed the DnsZone and default DnsRecord rows so the database matches the
  # zone file on disk. Without this, any future dns-sync dispatched by the
  # backend would overwrite the zone file with only the DB records (losing
  # the NS, A, MX, SPF records that were written by the installer).
  if [[ -n "$ORBIX_DOMAIN" && -n "$ORBIX_SERVER_IP" ]]; then
    # Extract only UUID-shaped lines; || true prevents set -e firing when grep
    # finds no match (empty SELECT result on first install).
    _uuid_from_psql() { grep -m1 -E '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' || true; }

    _ZONE_ID=$(psql "$DATABASE_URL" -tAc "
      SELECT id FROM \"DnsZone\"
      WHERE \"serverId\" = '${_SEED_SERVER_ID}' AND domain = '${ORBIX_DOMAIN}'
      LIMIT 1;
    " 2>/dev/null | _uuid_from_psql) || true

    if [[ -z "$_ZONE_ID" ]]; then
      _ZONE_ID=$(psql "$DATABASE_URL" -tAc "
        INSERT INTO \"DnsZone\" (id, \"serverId\", domain, \"createdAt\")
        VALUES (gen_random_uuid(), '${_SEED_SERVER_ID}', '${ORBIX_DOMAIN}', NOW())
        RETURNING id;
      " 2>/dev/null | _uuid_from_psql) || true
    fi

    if [[ -n "$_ZONE_ID" ]]; then
      _DNS_REC_COUNT=$(psql "$DATABASE_URL" -tAc "
        SELECT COUNT(*) FROM \"DnsRecord\" WHERE \"zoneId\" = '${_ZONE_ID}';
      " 2>/dev/null | tr -d '[:space:]')

      if [[ "${_DNS_REC_COUNT:-0}" -eq 0 ]]; then
        # Build NS + glue A records when nameservers are configured.
        # BIND9 requires A records for NS servers that are subdomains of this zone
        # (glue records). Without them, named-checkzone fails and the zone won't load.
        _NS_ROWS=""
        if [[ -n "$NS1_HOSTNAME" ]]; then
          _NS_ROWS="  (gen_random_uuid(), '${_ZONE_ID}', 'NS', '@',    '${NS1_HOSTNAME}', 86400, NULL),"$'\n'
          _NS_ROWS+="  (gen_random_uuid(), '${_ZONE_ID}', 'NS', '@',    '${NS2_HOSTNAME}', 86400, NULL),"$'\n'
          # Glue A records: add only if ns1/ns2 are subdomains of this zone
          _NS1_LABEL="${NS1_HOSTNAME%.${ORBIX_DOMAIN}}"
          _NS2_LABEL="${NS2_HOSTNAME%.${ORBIX_DOMAIN}}"
          if [[ "$_NS1_LABEL" != "$NS1_HOSTNAME" && -n "$_NS1_LABEL" ]]; then
            _NS_ROWS+="  (gen_random_uuid(), '${_ZONE_ID}', 'A',  '${_NS1_LABEL}', '${ORBIX_SERVER_IP}', 86400, NULL),"$'\n'
            _NS_ROWS+="  (gen_random_uuid(), '${_ZONE_ID}', 'A',  '${_NS2_LABEL}', '${ORBIX_SERVER_IP}', 86400, NULL),"
          fi
        fi

        psql "$DATABASE_URL" << DNSEOF
INSERT INTO "DnsRecord" (id, "zoneId", type, name, value, ttl, priority) VALUES
${_NS_ROWS}
  (gen_random_uuid(), '${_ZONE_ID}', 'A',   '@',    '${ORBIX_SERVER_IP}',                  3600,  NULL),
  (gen_random_uuid(), '${_ZONE_ID}', 'A',   'www',  '${ORBIX_SERVER_IP}',                  3600,  NULL),
  (gen_random_uuid(), '${_ZONE_ID}', 'A',   'mail', '${ORBIX_SERVER_IP}',                  3600,  NULL),
  (gen_random_uuid(), '${_ZONE_ID}', 'MX',  '@',    'mail.${ORBIX_DOMAIN}',                3600,  10),
  (gen_random_uuid(), '${_ZONE_ID}', 'TXT', '@',    'v=spf1 ip4:${ORBIX_SERVER_IP} ~all',  3600,  NULL);
DNSEOF
        ok "DNS zone seeded for ${ORBIX_DOMAIN} (ns1/ns2 glue + A/MX/TXT records)."
      else
        ok "DNS records for ${ORBIX_DOMAIN} already exist — skipping seed."
      fi
    else
      warn "Could not create DnsZone in DB — DNS records not seeded."
    fi
  fi
fi

# ─── 15. Agent systemd service ────────────────────────────────────────────────
section "Agent Service"

NEW_AGENT_SERVICE="[Unit]
Description=OrbixPanel Agent
Documentation=https://github.com/orbixpanel/orbixpanel-agent
After=network-online.target orbixpanel-backend.service
Wants=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=3

[Service]
Type=simple
User=${ORBIX_USER}
Environment=DOCKER_CONFIG=/var/lib/orbixpanel/docker
EnvironmentFile=${AGENT_ENV}
ExecStart=${AGENT_BIN}
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=orbixpanel-agent
# ProtectHome must be off: the agent manages tenant homes under /home (Phase 6)
# and writes /root/.ssh/authorized_keys. Any ProtectHome protection makes /home
# read-only inside the sandbox even as root (ReadWritePaths cannot override it),
# which broke tenant provisioning. false is the only value that lets it write.
ProtectHome=false
PrivateTmp=true

[Install]
WantedBy=multi-user.target"

AGENT_NEEDS_RELOAD=false
if [[ -f "$AGENT_SERVICE" && "$(cat "$AGENT_SERVICE")" == "$NEW_AGENT_SERVICE" ]]; then
  ok "Agent service file unchanged."
else
  echo "$NEW_AGENT_SERVICE" > "$AGENT_SERVICE"
  ok "Agent service file written."
  AGENT_NEEDS_RELOAD=true
fi

[[ "$AGENT_NEEDS_RELOAD" == true ]] && systemctl daemon-reload
systemctl enable orbixpanel-agent --quiet
ok "Agent service enabled."

if systemctl is-active --quiet orbixpanel-agent; then
  [[ "$AGENT_NEEDS_RELOAD" == true ]] \
    && { systemctl restart orbixpanel-agent; ok "Agent restarted."; } \
    || ok "Agent already running."
else
  systemctl start orbixpanel-agent
  ok "Agent service started."
fi

# ─── 16. Verification ─────────────────────────────────────────────────────────
section "Verification"

sleep 5

# systemctl is-active exits non-zero for any state other than "active"
# (e.g. "activating", "failed", "inactive"). Using || echo "unknown" in a $()
# would capture both the real state AND "unknown" if the exit code is non-zero.
# Use '; true' to always exit 0 so only the real state word is captured.
BACKEND_STATUS=$(systemctl is-active orbixpanel-backend 2>/dev/null; true)
AGENT_STATUS=$(systemctl   is-active orbixpanel-agent   2>/dev/null; true)
[[ -z "$BACKEND_STATUS" ]] && BACKEND_STATUS="unknown"
[[ -z "$AGENT_STATUS"   ]] && AGENT_STATUS="unknown"

[[ "$BACKEND_STATUS" == "active" ]] \
  && ok   "orbixpanel-backend: ${BACKEND_STATUS}" \
  || warn "orbixpanel-backend: ${BACKEND_STATUS} — check: journalctl -u orbixpanel-backend -n 50"
[[ "$AGENT_STATUS" == "active" ]] \
  && ok   "orbixpanel-agent: ${AGENT_STATUS}" \
  || warn "orbixpanel-agent: ${AGENT_STATUS} — check: journalctl -u orbixpanel-agent -n 50"

# ─── DNS & reachability self-check ────────────────────────────────────────────
# The #1 "I can't browse the site" cause is DNS: the domain doesn't resolve to THIS
# server's public IP — a missing A record, a subdomain whose parent zone lives on a
# different server, or a changed public IP. The installer can't edit a parent zone
# it doesn't control, so it detects the mismatch and prints exactly what to add.
if [[ -n "$ORBIX_DOMAIN" ]]; then
  section "DNS & Reachability"
  # Query PUBLIC resolvers (what the world sees), not the box's own BIND, so a
  # locally-authoritative zone can't produce a false "resolves OK".
  _resolve_ip() {
    local d="$1" ip=""
    if command -v dig >/dev/null 2>&1; then
      ip="$(dig +short A "$d" @1.1.1.1 2>/dev/null | grep -E '^[0-9.]+$' | tail -1)"
      [[ -z "$ip" ]] && ip="$(dig +short A "$d" @8.8.8.8 2>/dev/null | grep -E '^[0-9.]+$' | tail -1)"
    fi
    [[ -z "$ip" ]] && ip="$(getent hosts "$d" 2>/dev/null | awk '{print $1; exit}')"
    echo "$ip"
  }
  _dns_ok=true
  _dns_report=""
  _dns_one() {
    local d="$1" ip; ip="$(_resolve_ip "$d")"
    if [[ -z "$ip" ]]; then
      warn "DNS: ${d} does not resolve yet"; _dns_ok=false
      _dns_report+="    A   ${d}   →   ${ORBIX_SERVER_IP}"$'\n'
    elif [[ "$ip" != "$ORBIX_SERVER_IP" ]]; then
      warn "DNS: ${d} → ${ip}  (should be ${ORBIX_SERVER_IP})"; _dns_ok=false
      _dns_report+="    A   ${d}   →   ${ORBIX_SERVER_IP}   (currently ${ip})"$'\n'
    else
      ok "DNS: ${d} → ${ORBIX_SERVER_IP}"
    fi
  }
  _dns_one "$ORBIX_DOMAIN"
  _dns_one "docs.${ORBIX_DOMAIN}"
  if [[ -n "$ORBIX_STOREFRONT_DOMAIN" && "$ORBIX_STOREFRONT_DOMAIN" != "$ORBIX_DOMAIN" ]]; then _dns_one "$ORBIX_STOREFRONT_DOMAIN"; fi

  if [[ "$_dns_ok" != true ]]; then
    echo ""
    echo -e "  ${YELLOW}${BOLD}━━━ ACTION NEEDED: add DNS record(s) ━━━${RESET}"
    echo "  Point these at this server's public IP (${ORBIX_SERVER_IP}):"
    printf "%s" "$_dns_report"
    # 3+ labels ⇒ a subdomain whose A record belongs in the PARENT zone.
    if [[ "$(printf '%s' "$ORBIX_DOMAIN" | tr -cd '.' | wc -c | tr -d ' ')" -ge 2 ]]; then
      _parent="${ORBIX_DOMAIN#*.}"
      echo ""
      echo "  NOTE: ${ORBIX_DOMAIN} is a subdomain — add the A record in the PARENT zone"
      echo "        (${_parent}), NOT at a registrar. If ${_parent} is run by another"
      echo "        OrbixPanel/BIND, add it in that panel's DNS."
    fi
    echo ""
    echo "  Also open inbound 80, 443 and 8080 (firewall / cloud security group)."
    echo "  DNS may take a few minutes to propagate; re-check with:"
    echo "    dig +short ${ORBIX_DOMAIN}"
    echo ""
  else
    ok "All domains resolve to this server (${ORBIX_SERVER_IP})."
  fi
fi

# ─── Summary ──────────────────────────────────────────────────────────────────
SERVER_ID_FINAL=$(grep "^SERVER_ID=" "$AGENT_ENV" 2>/dev/null | cut -d= -f2- || echo "—")

echo ""
echo -e "${GREEN}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo -e "${GREEN}${BOLD}  Installation complete!${RESET}"
echo -e "${GREEN}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo ""
if [[ -n "$ORBIX_DOMAIN" && "${SSL_OK:-false}" == "true" ]]; then
  printf "  %-20s %s\n" "Website:"        "https://${ORBIX_DOMAIN}"
  printf "  %-20s %s\n" "Panel (HTTPS):"  "https://${ORBIX_DOMAIN}:8080"
  printf "  %-20s %s\n" "Docs:"           "https://docs.${ORBIX_DOMAIN}"
  printf "  %-20s %s\n" "Panel (IP):"     "http://${ORBIX_SERVER_IP}:80  (redirect to HTTPS)"
else
  printf "  %-20s %s\n" "Dashboard:"      "http://${ORBIX_SERVER_IP}/"
fi
printf "  %-20s %s\n" "Admin email:"    "$ORBIX_ADMIN_EMAIL"
printf "  %-20s %s\n" "Server name:"    "$ORBIX_SERVER_NAME"
printf "  %-20s %s\n" "Server IP:"      "$ORBIX_SERVER_IP"
printf "  %-20s %s\n" "Server ID:"      "$SERVER_ID_FINAL"
printf "  %-20s %s\n" "Backend API:"    "$ORBIX_API_URL"
# Installed build (public prebuilt installs record a manifest; source/token
# installs simply have none, so this is skipped).
_BSHA=$(_build_field backend.version.json shortSha 2>/dev/null || true)
_FSHA=$(_build_field frontend.version.json shortSha 2>/dev/null || true)
_ASHA=$(_build_field agent.version.json shortSha 2>/dev/null || true)
_BAT=$(_build_field backend.version.json builtAt 2>/dev/null || true)
if [[ -n "${_BSHA}${_FSHA}${_ASHA}" ]]; then
  _BUILD_STR=""
  [[ -n "$_BSHA" ]] && _BUILD_STR="core ${_BSHA}"
  [[ -n "$_FSHA" ]] && _BUILD_STR="${_BUILD_STR:+${_BUILD_STR}, }web ${_FSHA}"
  [[ -n "$_ASHA" ]] && _BUILD_STR="${_BUILD_STR:+${_BUILD_STR}, }agent ${_ASHA}"
  printf "  %-20s %s\n" "Build:"         "$_BUILD_STR"
  [[ -n "$_BAT" ]] && printf "  %-20s %s\n" "Built (UTC):"    "$_BAT"
fi
_RELAY_HOST_FINAL=$(postconf -h relayhost 2>/dev/null || echo "")
if [[ -n "$_RELAY_HOST_FINAL" && "$_RELAY_HOST_FINAL" != "[]" && "$_RELAY_HOST_FINAL" != "" ]]; then
  printf "  %-20s %s\n" "SMTP relay:"     "$_RELAY_HOST_FINAL"
else
  printf "  %-20s %s\n" "SMTP relay:"     "none (direct delivery via port 25)"
fi
printf "  %-20s %s\n" "Config dir:"     "$CONF_DIR"
printf "  %-20s %s\n" "Terms:"          "${ORBIX_CLOUD_URL}/terms"
printf "  %-20s %s\n" "Privacy:"        "${ORBIX_CLOUD_URL}/privacy"
echo ""
echo -e "  ${BOLD}Useful commands:${RESET}"
echo "    journalctl -u orbixpanel-backend -f   # backend logs"
echo "    journalctl -u orbixpanel-agent -f     # agent logs"
echo "    systemctl status orbixpanel-backend   # backend status"
echo "    systemctl status orbixpanel-agent     # agent status"
if [[ -n "$ORBIX_DOMAIN" ]]; then
  echo "    certbot renew --dry-run               # test SSL renewal"
fi
if [[ -n "$ORBIX_DOMAIN" && "${DOCS_SSL_OK:-false}" != "true" ]]; then
  echo ""
  echo -e "  ${BOLD}Enable docs HTTPS after DNS propagates:${RESET}"
  echo "    certbot certonly --webroot -w /var/www/certbot \\"
  echo "      -d docs.${ORBIX_DOMAIN} --non-interactive --agree-tos --email ${ORBIX_ADMIN_EMAIL}"
  echo "    # Then re-run install.sh to apply the SSL nginx config for docs."
fi
echo ""

# ── Nameserver registration instructions ─────────────────────────────────────
if [[ -n "$ORBIX_DOMAIN" && -n "$NS1_HOSTNAME" ]]; then
  echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
  echo -e "${BOLD}  Nameserver setup (one-time, at your registrar)${RESET}"
  echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
  echo ""
  echo -e "  ${BOLD}Step 1 — Register nameserver hosts (glue records)${RESET}"
  echo    "  At your domain registrar for ${ORBIX_DOMAIN},"
  echo    "  find 'Register Nameservers' or 'Host Records' and add:"
  echo ""
  printf  "    %-30s %s\n" "${NS1_HOSTNAME}." "→  ${ORBIX_SERVER_IP}"
  printf  "    %-30s %s\n" "${NS2_HOSTNAME}." "→  ${ORBIX_SERVER_IP}"
  echo ""
  echo -e "  ${BOLD}Step 2 — Point ${ORBIX_DOMAIN} to your nameservers${RESET}"
  echo    "  In the Nameservers tab for ${ORBIX_DOMAIN}, set custom nameservers:"
  echo ""
  printf  "    %s\n" "${NS1_HOSTNAME}"
  printf  "    %s\n" "${NS2_HOSTNAME}"
  echo ""
  echo -e "  ${BOLD}Step 3 — Point client domains here${RESET}"
  echo    "  For any domain hosted on this server, the client sets"
  echo    "  their nameservers (at their own registrar) to:"
  echo ""
  printf  "    %s\n" "${NS1_HOSTNAME}"
  printf  "    %s\n" "${NS2_HOSTNAME}"
  echo ""
  echo -e "  ${YELLOW}Glue records take 1–24 hours to propagate.${RESET}"
  echo -e "  Verify with: ${BOLD}dig ${NS1_HOSTNAME}${RESET}"
  echo ""
fi

# ── Required DNS records for mail deliverability ──────────────────────────────
if [[ -n "$ORBIX_DOMAIN" ]]; then
  DKIM_PUB=""
  DKIM_TXT_FILE="/etc/opendkim/keys/${ORBIX_DOMAIN}/mail.txt"
  if [[ -f "$DKIM_TXT_FILE" ]]; then
    # The record spans multiple quoted strings — concatenate them into the full
    # v=DKIM1; k=rsa; p=<key> value (do NOT truncate at v=DKIM1, that drops the key).
    DKIM_PUB=$(grep -o '"[^"]*"' "$DKIM_TXT_FILE" | tr -d '"\n' )
    [[ -z "$DKIM_PUB" ]] && DKIM_PUB="(see ${DKIM_TXT_FILE})"
  fi

  echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
  echo -e "${BOLD}  Required DNS records for email delivery${RESET}"
  echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
  echo ""
  printf "  %-6s %-34s %s\n" "Type" "Name" "Value"
  printf "  %-6s %-34s %s\n" "----" "----" "-----"
  printf "  %-6s %-34s %s\n" "A"    "mail.${ORBIX_DOMAIN}."     "${ORBIX_SERVER_IP}"
  printf "  %-6s %-34s %s\n" "MX"   "${ORBIX_DOMAIN}."          "10 mail.${ORBIX_DOMAIN}."
  printf "  %-6s %-34s %s\n" "TXT"  "${ORBIX_DOMAIN}."          "\"v=spf1 a mx ip4:${ORBIX_SERVER_IP} ~all\""
  printf "  %-6s %-34s %s\n" "TXT"  "_dmarc.${ORBIX_DOMAIN}."   "\"v=DMARC1; p=none; rua=mailto:postmaster@${ORBIX_DOMAIN}\""
  if [[ -n "$DKIM_PUB" ]]; then
    printf "  %-6s %-34s\n"  "TXT"  "mail._domainkey.${ORBIX_DOMAIN}."
    echo   "         Value: ${DKIM_PUB}"
    echo   "         (also in ${DKIM_TXT_FILE})"
  fi
  echo ""
  echo -e "  ${BOLD}Why the A record?${RESET} mail.${ORBIX_DOMAIN} is the host your mail apps"
  echo    "    (IMAP 993 / POP3 995 / SMTP 587) connect to, and the TLS certificate"
  echo    "    includes it — without this A record, clients can't resolve the server."
  echo ""
  echo -e "  ${BOLD}PTR / reverse-DNS:${RESET} Set your droplet hostname to ${ORBIX_DOMAIN}"
  echo    "    in the DigitalOcean control panel → the PTR record updates automatically."
  echo ""
  echo -e "  ${YELLOW}${BOLD}IMPORTANT — DigitalOcean blocks outbound port 25 by default.${RESET}"
  echo    "  Mail will queue but never reach external inboxes until you request"
  echo    "  unblocking at: https://www.digitalocean.com/community/questions/how-to-get-smtp-access-on-digital-ocean"
  echo    "  (Create a support ticket from your DO account.)"
  echo ""
fi

# ─── Management CLI + component registry ──────────────────────────────────────
# Installs /usr/local/bin/orbixpanel and writes /etc/orbixpanel/components.json.
# Non-fatal: a failure here must never fail the whole install.
section "Management CLI" 2>/dev/null || true
install_orbixpanel_cli() {
  local dest="/usr/local/bin/orbixpanel"
  local src_dir="" src=""
  # CLI source, in order: a local sibling (checkout install) → the PUBLIC dist repo
  # (tokenless — so public installs get the CLI and it updates with each release) →
  # the private GitHub API (needs a token). The dist path means the CLI is no longer
  # gated behind a token.
  if [[ -n "${BASH_SOURCE[0]:-}" && -f "$(dirname "${BASH_SOURCE[0]}")/orbixpanel.sh" ]]; then
    src="$(dirname "${BASH_SOURCE[0]}")/orbixpanel.sh"
    cp "$src" "$dest"
  elif curl -fsSL "${ORBIX_DIST_BASE}/latest/orbixpanel.sh" -o "$dest" && [[ -s "$dest" ]]; then
    : # downloaded the CLI from the public dist, no token needed
  else
    local repo="${ORBIX_INSTALL_REPO:-orbixpanel/orbixpanel-install}"
    curl -fsSL \
      -H "Authorization: Bearer ${GITHUB_TOKEN:-}" \
      -H "Accept: application/vnd.github.raw" \
      "https://api.github.com/repos/${repo}/contents/orbixpanel.sh" \
      -o "$dest" || return 1
  fi
  chmod +x "$dest"
  # Write the initial component registry, then verify it landed (the panel needs
  # /etc/orbixpanel/components.json to show the Components page).
  "$dest" components >/dev/null 2>&1 || true
  if [[ ! -f /etc/orbixpanel/components.json ]]; then
    warn "Component registry not generated — the panel will regenerate it, or run 'orbixpanel components'"
  fi
  return 0
}
if install_orbixpanel_cli; then
  ok "Management CLI installed → run 'orbixpanel help'"
else
  warn "Could not install the orbixpanel CLI (non-fatal). You can add it later."
fi
