#!/usr/bin/env bash
# MyOsicam Panel installer — served by https://install.myosicam.us/panel
# Rendered at request time; https://install.myosicam.us is replaced server-side.
set -euo pipefail

INSTALL_SERVER_URL="https://install.myosicam.us"
LOG="/var/log/myosicam-install.log"
TOTAL_PHASES=18
START_TS="$(date +%Y%m%d-%H%M%S)"

# ---------- Redact-on-write tee -------------------------------------------
# We capture secrets into these vars and redact before writing to LOG.
REDACT_LIST=()  # filled by phases as secrets are generated

redact() {
  local in="$1"
  local v
  for v in "${REDACT_LIST[@]}"; do
    [[ -z "$v" ]] && continue
    in="${in//$v/****}"
  done
  printf '%s' "$in"
}

# fd 9 is a duplicate of the original stderr, kept open across _start_logging
# so die()/on_error() messages always reach the user even if the redact-tee
# subshell is racing to exit. (We've seen redact-tee swallow death messages
# when the script exits between phases — see schema_migrations bug.)
exec 9>&2

# Everything written after _start_logging() is redacted and teed to LOG.
_start_logging() {
  mkdir -p "$(dirname "$LOG")"
  : > "$LOG"
  exec > >(while IFS= read -r line; do
            printf '%s\n' "$(redact "$line")" | tee -a "$LOG"
          done) 2>&1
}

# ---------- Pretty printing -----------------------------------------------
RED='\033[0;31m'; GRN='\033[0;32m'; YEL='\033[0;33m'; BLU='\033[0;34m'; NC='\033[0m'
phase() { printf "\n${BLU}[phase %d/%d]${NC} %s\n" "$1" "$TOTAL_PHASES" "$2"; }
ok()    { printf "  ${GRN}✓${NC} %s\n" "$1"; }
warn()  { printf "  ${YEL}!${NC} %s\n" "$1"; }
# die/on_error write to BOTH the redact-tee log (so they appear inline) AND
# fd 9 (the original stderr) so they survive if the subshell is dying.
die() {
  local msg="FAILED at phase ${CUR_PHASE:-?}: $1"
  printf "\n${RED}%s${NC}\n  See %s for details.\n" "$msg" "$LOG" >&2
  printf "\n%s\n  See %s for details.\n" "$msg" "$LOG" >&9
  exit 1
}
CUR_PHASE=0

trap 'on_error $LINENO "$BASH_COMMAND"' ERR
on_error() {
  local msg="Unexpected failure at line $1 (phase ${CUR_PHASE:-?}). Last command: $2"
  printf "\n${RED}%s${NC}\n  See %s\n" "$msg" "$LOG" >&2
  printf "\n%s\n  See %s\n" "$msg" "$LOG" >&9
  exit 1
}

# ---------- Arg / env parsing ---------------------------------------------
LICENSE="${LICENSE_KEY:-}"
DOMAIN=""
EMAIL=""
CHANNEL="stable"
USE_SSL="yes"
REINSTALL="no"
VERSION_PIN=""

# OSCam-Checker / VPN integration (spec 046). Defaults below are fine for the
# public install flow; the bootstrap key is per-operator and has no default.
WG_API_URL_DEFAULT="https://admin.kanasavpn.com/api/wg"
OSCAM_CHECKER_BINARY_URL_DEFAULT="https://github.com/rachidb13/oscam-checker/releases/latest/download/oscam-checker"
WG_API_URL="${WG_API_URL:-}"
WG_BOOTSTRAP_KEY="${WG_BOOTSTRAP_KEY:-}"
OSCAM_CHECKER_BINARY_URL="${OSCAM_CHECKER_BINARY_URL:-}"

while [[ $# -gt 0 ]]; do
  case "$1" in
    --license)              LICENSE="$2"; shift 2;;
    --domain)               DOMAIN="$2";  shift 2;;
    --email)                EMAIL="$2";   shift 2;;
    --channel)              CHANNEL="$2"; shift 2;;
    --no-ssl)               USE_SSL="no"; shift;;
    --reinstall)            REINSTALL="yes"; shift;;
    --version)              VERSION_PIN="$2"; shift 2;;
    --wg-api-url)           WG_API_URL="$2"; shift 2;;
    --wg-bootstrap-key)     WG_BOOTSTRAP_KEY="$2"; shift 2;;
    --oscam-checker-url)    OSCAM_CHECKER_BINARY_URL="$2"; shift 2;;
    -h|--help)              echo "Usage: curl -sSL $INSTALL_SERVER_URL/panel | bash -s -- [--license KEY] [--domain FQDN] [--email ADDR] [--channel stable|beta] [--no-ssl] [--reinstall] [--wg-bootstrap-key KEY] [--wg-api-url URL] [--oscam-checker-url URL]"; exit 0;;
    *)                      die "unknown flag: $1";;
  esac
done

[[ -z "$WG_API_URL"               ]] && WG_API_URL="$WG_API_URL_DEFAULT"
[[ -z "$OSCAM_CHECKER_BINARY_URL" ]] && OSCAM_CHECKER_BINARY_URL="$OSCAM_CHECKER_BINARY_URL_DEFAULT"
[[ -n "$WG_BOOTSTRAP_KEY" ]] && REDACT_LIST+=("$WG_BOOTSTRAP_KEY")

case "$CHANNEL" in stable|beta) ;; *) die "--channel must be stable or beta";; esac

prompt_if_tty() {
  local varname="$1" prompt="$2" default="${3:-}"
  if [[ -n "${!varname}" ]]; then return; fi
  if [[ ! -t 0 ]]; then
    die "$varname is required but stdin is not a TTY. Pass it via --${varname,,} or the corresponding env var."
  fi
  local input
  if [[ -n "$default" ]]; then
    read -rp "$prompt [$default]: " input
    input="${input:-$default}"
  else
    read -rp "$prompt: " input
  fi
  printf -v "$varname" '%s' "$input"
}

# ----------------------------------------------------------------------------
# Pre-phase root + OS gate — runs BEFORE _start_logging so its die() goes
# straight to stderr without traversing the redact-tee subshell. Otherwise
# minimal shells (busybox, alpine) can race and drop the death message.
# ----------------------------------------------------------------------------
if [[ $EUID -ne 0 ]]; then
  printf "\n\033[0;31mFAILED:\033[0m must run as root (use: curl ... | sudo bash)\n" >&2
  exit 1
fi
if [[ ! -r /etc/os-release ]]; then
  printf "\n\033[0;31mFAILED:\033[0m cannot read /etc/os-release\n" >&2
  exit 1
fi
. /etc/os-release
case "${ID:-}:${VERSION_ID:-}" in
  ubuntu:22.04|ubuntu:24.04|debian:12) ;;
  *)
    printf "\n\033[0;31mFAILED:\033[0m unsupported OS: %s (need Ubuntu 22.04/24.04 or Debian 12). Ubuntu 20.04 is EOL and the upstream PHP 8.3 PPA no longer ships binaries for focal.\n" "${PRETTY_NAME:-$ID $VERSION_ID}" >&2
    exit 1
    ;;
esac

_start_logging

echo "MyOsicam Panel installer — $START_TS"
echo "Install server: $INSTALL_SERVER_URL"
echo "Log file:       $LOG"
# ============================================================================
# Phase 1 — Preflight
# ============================================================================
CUR_PHASE=1
phase 1 "Preflight checks"

ok "running as root"
ok "OS: ${PRETTY_NAME}"

MEM_KB=$(awk '/MemTotal/ {print $2}' /proc/meminfo)
MEM_MB=$(( MEM_KB / 1024 ))
if (( MEM_MB < 1800 )); then die "insufficient RAM: ${MEM_MB}MB (need ≥ 2GB)"; fi
ok "RAM: ${MEM_MB}MB"

FREE_MB=$(df -Pm / | awk 'NR==2 {print $4}')
if (( FREE_MB < 10240 )); then die "insufficient disk: ${FREE_MB}MB free on / (need ≥ 10GB)"; fi
ok "Disk: ${FREE_MB}MB free on /"

# Map commands to the packages that provide them (ss is in iproute2).
_ensure_tool() {
  local cmd="$1" pkg="$2"
  if ! command -v "$cmd" >/dev/null 2>&1; then
    apt-get update -qq
    apt-get install -y -qq "$pkg"
  fi
}
_ensure_tool curl    curl
_ensure_tool ss      iproute2
_ensure_tool openssl openssl
ok "required tools present"
# ============================================================================
# Phase 2 — Collect inputs
# ============================================================================
CUR_PHASE=2
phase 2 "Collecting inputs"

prompt_if_tty LICENSE "License key"
[[ -z "$LICENSE" ]] && die "license key is required"
REDACT_LIST+=("$LICENSE")

prompt_if_tty DOMAIN "Panel domain (FQDN, e.g. panel.example.com)"
[[ -z "$DOMAIN" ]] && die "domain is required"
if ! [[ "$DOMAIN" =~ ^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$ ]]; then
  die "invalid FQDN: $DOMAIN"
fi

prompt_if_tty EMAIL "Admin email"
if ! [[ "$EMAIL" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]]; then
  die "invalid email: $EMAIL"
fi

if [[ "$USE_SSL" != "no" ]] && [[ -t 0 ]]; then
  read -rp "Use Let's Encrypt SSL for $DOMAIN? [Y/n]: " ans
  case "${ans,,}" in n|no) USE_SSL="no";; *) USE_SSL="yes";; esac
fi
ok "SSL: $USE_SSL"
ok "Channel: $CHANNEL"
# ============================================================================
# Phase 3 — DNS sanity (only if SSL=yes)
# ============================================================================
CUR_PHASE=3
phase 3 "DNS sanity"

if [[ "$USE_SSL" == "yes" ]]; then
  SERVER_IP=$(curl -fsS --max-time 5 https://api.ipify.org || echo "")
  DOMAIN_IP=$(getent hosts "$DOMAIN" | awk '{print $1}' | head -1 || echo "")
  if [[ -z "$SERVER_IP" ]]; then warn "could not determine public IP; skipping DNS check"
  elif [[ -z "$DOMAIN_IP" ]]; then
    warn "$DOMAIN does not resolve"
    if [[ -t 0 ]]; then
      read -rp "Continue anyway? [y/N]: " ans
      [[ "${ans,,}" != "y" && "${ans,,}" != "yes" ]] && die "aborted by user (DNS not ready)"
    else
      die "$DOMAIN has no A record and stdin is not a TTY"
    fi
  elif [[ "$DOMAIN_IP" != "$SERVER_IP" ]]; then
    warn "$DOMAIN resolves to $DOMAIN_IP but this server is $SERVER_IP"
    if [[ -t 0 ]]; then
      read -rp "Continue anyway? [y/N]: " ans
      [[ "${ans,,}" != "y" && "${ans,,}" != "yes" ]] && die "aborted by user (DNS mismatch)"
    else
      die "DNS mismatch and stdin is not a TTY"
    fi
  else
    ok "$DOMAIN → $SERVER_IP"
  fi
else
  ok "skipped (SSL disabled)"
fi
# ============================================================================
# Phase 4 — Port conflict check
# ============================================================================
CUR_PHASE=4
phase 4 "Port conflict check"

_port_busy() { ss -ltn "sport = :$1" 2>/dev/null | awk 'NR>1' | grep -q .; }
_port_holder() { ss -lptn "sport = :$1" 2>/dev/null | awk 'NR>1 {for(i=1;i<=NF;i++) if($i ~ /users:/) print $i}' | head -1; }

for p in 80 443; do
  if _port_busy "$p"; then
    holder="$(_port_holder "$p" || true)"
    # nginx holding 80/443 is ok — we'll reconfigure it.
    if [[ "$holder" == *'"nginx"'* ]]; then
      ok "port $p held by nginx (will reconfigure)"
    else
      die "port $p already in use by: ${holder:-unknown}. Stop the holder and retry."
    fi
  else
    ok "port $p free"
  fi
done

# 3306 only matters if we'd be installing MariaDB fresh; if mysql/mariadb is
# already present we'll just use it. No conflict check needed.
# ============================================================================
# Phase 5 — Validate license against install server
# ============================================================================
CUR_PHASE=5
phase 5 "Validating license with $INSTALL_SERVER_URL"

VAL_BODY=$(printf '{"license_key":"%s","fqdn":"%s","channel":"%s"}' \
  "$LICENSE" "$DOMAIN" "$CHANNEL")

VAL_RESP=$(mktemp); trap 'rm -f "$VAL_RESP"' EXIT
HTTP_CODE=$(curl -sS -o "$VAL_RESP" -w '%{http_code}' \
  -X POST "$INSTALL_SERVER_URL/panel/api/license/validate" \
  -H "Content-Type: application/json" \
  --data "$VAL_BODY" || echo "000")

case "$HTTP_CODE" in
  200) ;;
  404) die "license key not recognized";;
  403)
    ERR=$(php -r '$r=json_decode(file_get_contents("'$VAL_RESP'"),true); echo $r["error"]??"forbidden";' 2>/dev/null || echo "forbidden")
    case "$ERR" in
      license_revoked)         die "license has been revoked";;
      max_installs_exceeded)   die "license install quota exhausted (contact support)";;
      *)                       die "license forbidden: $ERR";;
    esac ;;
  *) die "install server error (HTTP $HTTP_CODE)";;
esac

# Parse JSON without assuming PHP is installed — use a tiny python/awk fallback.
_json_get() {
  # $1 = key; reads JSON from $VAL_RESP
  python3 -c "import json,sys; print(json.load(open('$VAL_RESP'))['$1'])" 2>/dev/null \
    || grep -oE "\"$1\":\"[^\"]+\"" "$VAL_RESP" | head -1 | sed -E "s/\"$1\":\"([^\"]+)\"/\1/"
}
VERSION=$(_json_get version)
TARBALL_URL=$(_json_get tarball_url)
TARBALL_SHA=$(_json_get tarball_sha256)
PANEL_ID=$(_json_get panel_id || true)
PANEL_API_KEY=$(_json_get panel_api_key || true)

[[ "$PANEL_ID" == "null" ]] && PANEL_ID=""
[[ "$PANEL_API_KEY" == "null" ]] && PANEL_API_KEY=""
[[ -n "$PANEL_API_KEY" ]] && REDACT_LIST+=("$PANEL_API_KEY")

[[ -z "$VERSION" || -z "$TARBALL_URL" || -z "$TARBALL_SHA" ]] && die "unexpected validation response shape"
ok "license OK — version $VERSION on channel $CHANNEL"
[[ -n "$PANEL_ID" ]] && ok "central admin panel id: $PANEL_ID"

# ----- Auto-fetch WG bootstrap key ----------------------------------------
# When --wg-bootstrap-key wasn't supplied, ask admin.kanasavpn.com to issue
# one for this license + FQDN. Without this key the agent install (SSH
# bootstrap) cannot allocate a WireGuard slot and will fail at step 5/7.
if [[ -z "$WG_BOOTSTRAP_KEY" ]]; then
  WG_ISSUE_URL="${WG_API_URL_DEFAULT}/issue-key.php"
  WG_RESP="$(mktemp)"
  WG_BODY=$(printf '{"license_key":"%s","panel_fqdn":"%s"}' "$LICENSE" "$DOMAIN")
  WG_HTTP=$(curl -sS -o "$WG_RESP" -w '%{http_code}' \
              -X POST "$WG_ISSUE_URL" \
              -H 'Content-Type: application/json' \
              --data "$WG_BODY" || echo "000")
  if [[ "$WG_HTTP" == "200" ]]; then
    WG_BOOTSTRAP_KEY=$(python3 -c "import json; print(json.load(open('$WG_RESP'))['bootstrap_key'])" 2>/dev/null \
                       || grep -oE '"bootstrap_key":"[^"]+"' "$WG_RESP" | head -1 | sed -E 's/"bootstrap_key":"([^"]+)"/\1/')
    if [[ -n "$WG_BOOTSTRAP_KEY" ]]; then
      REDACT_LIST+=("$WG_BOOTSTRAP_KEY")
      ok "WG bootstrap key issued by admin.kanasavpn.com"
    else
      warn "WG endpoint returned 200 but no bootstrap_key in body; agent install will require manual key"
    fi
  else
    warn "could not fetch WG_BOOTSTRAP_KEY (HTTP $WG_HTTP from $WG_ISSUE_URL); pass --wg-bootstrap-key manually"
  fi
  rm -f "$WG_RESP"
fi
# ============================================================================
# Phase 6 — Existing install check
# ============================================================================
CUR_PHASE=6
phase 6 "Checking for existing install"

PANEL_DIR="/var/www/myosicam"

if [[ -f "$PANEL_DIR/.env" ]]; then
  if [[ "$REINSTALL" != "yes" ]]; then
    die "existing install detected at $PANEL_DIR. Re-run with --reinstall to back up and replace."
  fi
  BK_DIR="${PANEL_DIR}.backup-${START_TS}"
  echo "Backing up $PANEL_DIR → $BK_DIR"
  cp -a "$PANEL_DIR" "$BK_DIR"
  ok "panel directory backed up"

  # best-effort DB dump using creds from the old .env
  OLD_DB=$(grep -E '^DB_DATABASE=' "$PANEL_DIR/.env" | cut -d= -f2- | tr -d '"'"'")
  OLD_USER=$(grep -E '^DB_USERNAME=' "$PANEL_DIR/.env" | cut -d= -f2- | tr -d '"'"'")
  OLD_PASS=$(grep -E '^DB_PASSWORD=' "$PANEL_DIR/.env" | cut -d= -f2- | tr -d '"'"'")
  if [[ -n "$OLD_DB" && -n "$OLD_USER" ]]; then
    BK_SQL="/root/myosicam-db-backup-${START_TS}.sql.gz"
    if mysqldump --single-transaction -u "$OLD_USER" -p"$OLD_PASS" "$OLD_DB" 2>/dev/null | gzip > "$BK_SQL"; then
      chmod 600 "$BK_SQL"
      ok "DB dump saved to $BK_SQL"
    else
      warn "could not dump old DB (continuing)"
    fi
  fi
  rm -rf "$PANEL_DIR"
else
  ok "no existing install at $PANEL_DIR"
fi
# ============================================================================
# Phase 7 — APT dependencies
# ============================================================================
CUR_PHASE=7
phase 7 "Installing system packages"

export DEBIAN_FRONTEND=noninteractive

# Fresh VPS images often run unattended-upgrades on first boot, which holds
# /var/lib/apt/lists/lock and /var/lib/dpkg/lock-frontend. Retry any apt
# operation for up to 5 minutes when (and only when) the failure is a lock —
# far more user-friendly than a hard exit on a transient race. Real errors
# still die immediately.
_apt_retry() {
  local waited=0 limit=300 errfile
  errfile=$(mktemp)
  while (( waited < limit )); do
    if "$@" 2>"$errfile"; then
      rm -f "$errfile"
      [[ $waited -gt 0 ]] && echo "  apt lock released after ${waited}s"
      return 0
    fi
    if grep -q -e 'Could not get lock' -e 'Unable to acquire' -e 'is held by process' "$errfile"; then
      [[ $waited -eq 0 ]] && echo "  apt is busy (unattended-upgrades on first boot?); waiting..."
      sleep 5
      waited=$((waited + 5))
      continue
    fi
    cat "$errfile" >&9
    rm -f "$errfile"
    die "$1 failed (non-lock error; see above)"
  done
  cat "$errfile" >&9
  rm -f "$errfile"
  die "apt lock still held after ${limit}s. Run 'ps -ef | grep -E apt\\|dpkg' to investigate."
}
_apt_retry apt-get update -qq

# Default repos ship PHP 8.3 only on Ubuntu 24.04 (Noble).
# Ubuntu 22.04 needs ondrej/php; Debian 12 needs sury.
case "${ID}:${VERSION_ID}" in
  ubuntu:22.04)
    _apt_retry apt-get install -y -qq ca-certificates curl gnupg lsb-release software-properties-common
    LC_ALL=C.UTF-8 add-apt-repository -y ppa:ondrej/php
    _apt_retry apt-get update -qq
    ;;
  debian:12)
    _apt_retry apt-get install -y -qq ca-certificates curl lsb-release gnupg
    curl -fsSL https://packages.sury.org/php/apt.gpg \
      | gpg --dearmor -o /usr/share/keyrings/sury-php.gpg
    echo "deb [signed-by=/usr/share/keyrings/sury-php.gpg] https://packages.sury.org/php/ $(lsb_release -sc) main" \
      > /etc/apt/sources.list.d/sury-php.list
    _apt_retry apt-get update -qq
    ;;
esac

PHP_PKGS="php8.3-cli php8.3-fpm php8.3-mysql php8.3-mbstring php8.3-xml php8.3-curl php8.3-bcmath php8.3-opcache php8.3-readline php8.3-zip php8.3-intl"
BASE_PKGS="nginx composer git certbot python3-certbot-nginx ufw"

DB_PKG=""
if ! command -v mysql >/dev/null 2>&1; then
  # Prefer MariaDB on Debian/Ubuntu
  DB_PKG="mariadb-server"
fi

_apt_retry apt-get install -y -qq $PHP_PKGS $BASE_PKGS $DB_PKG
ok "packages installed"

# Service start — prefer systemctl when systemd is PID 1, fall back to
# `service` for minimal/container environments where systemd isn't running.
svc_enable_now() {
  local svc="$1"
  if [[ -d /run/systemd/system ]]; then
    systemctl enable --now "$svc"
  else
    service "$svc" start 2>/dev/null || /etc/init.d/"$svc" start 2>/dev/null || \
      warn "could not start $svc (no systemd, no init script)"
  fi
}

svc_enable_now php8.3-fpm
svc_enable_now nginx
[[ -n "$DB_PKG" ]] && svc_enable_now mariadb
ok "services enabled"

# ── Panel self-update sudoers (054-panel-self-update) ─────────────────
# Allow the panel's web user to reload php8.3-fpm as part of in-panel updates.
# Scoped to EXACTLY ONE command; no wildcards.
SUDOERS_FILE="/etc/sudoers.d/panel-update"
SUDOERS_LINE="www-data ALL=(root) NOPASSWD: /bin/systemctl reload php8.3-fpm"
if [[ ! -f "$SUDOERS_FILE" ]] || ! grep -qxF "$SUDOERS_LINE" "$SUDOERS_FILE"; then
  printf '%s\n' "$SUDOERS_LINE" > "$SUDOERS_FILE"
  chown root:root "$SUDOERS_FILE"
  chmod 0440 "$SUDOERS_FILE"
  # Validate syntax — if invalid, REMOVE the file rather than leaving sudo broken.
  if ! visudo -cf "$SUDOERS_FILE" >/dev/null; then
    echo "ERROR: sudoers entry for panel-update is invalid; removing it" >&2
    rm -f "$SUDOERS_FILE"
    exit 1
  fi
  ok "panel-update sudoers entry installed"
else
  ok "panel-update sudoers entry already present"
fi

# ── Cron jobs (054-panel-self-update + 012-panel-job-runner) ──────────
# Hourly: poll install.myosicam.us for newer releases.
# Every minute: drive the in-panel job runner (oscam-health, expiry-monitor, etc.).
# Both run as www-data so they share filesystem perms with php-fpm.
CRON_FILE="/etc/cron.d/myosicam-panel"
cat > "$CRON_FILE" <<EOF
# MyOsicam Panel — auto-installed by bootstrap. Do not edit by hand;
# this file is overwritten on every panel install/reinstall.
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# Drive registered panel jobs (oscam-health, expiry, etc.) — every minute.
* * * * * www-data /usr/bin/php $PANEL_DIR/cron/panel-job-runner.php >> /var/log/myosicam-panel-jobs.log 2>&1

# Check install.myosicam.us for newer releases — hourly at minute 7.
7 * * * * www-data /usr/bin/php $PANEL_DIR/cron/panel-update-check.php >> /var/log/myosicam-panel-update.log 2>&1
EOF
chown root:root "$CRON_FILE"
chmod 0644 "$CRON_FILE"
# Pre-create the log files with www-data ownership so the first run doesn't fail on root-owned logs.
for LOGFILE in /var/log/myosicam-panel-jobs.log /var/log/myosicam-panel-update.log; do
  touch "$LOGFILE"
  chown www-data:www-data "$LOGFILE"
  chmod 0640 "$LOGFILE"
done
ok "panel cron jobs installed at $CRON_FILE"

# ============================================================================
# Phase 8 — Database setup
# ============================================================================
CUR_PHASE=8
phase 8 "Setting up database"

DB_NAME="myosicam_panel"
DB_USER="myosicam"
DB_PASS="$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-32)"
REDACT_LIST+=("$DB_PASS")

# Determine admin connection string
MYSQL_ADMIN=()
if mysql -u root -e 'SELECT 1' >/dev/null 2>&1; then
  MYSQL_ADMIN=(mysql -u root)
  ok "MySQL root authenticated via unix socket"
else
  if [[ ! -t 0 ]]; then
    die "MySQL socket auth failed and stdin is not a TTY — cannot prompt for root password"
  fi
  echo "MySQL socket auth failed — please enter the MySQL root password:"
  read -rsp "  MySQL root password: " MYSQL_ROOT_PW
  echo
  REDACT_LIST+=("$MYSQL_ROOT_PW")
  if ! mysql -u root -p"$MYSQL_ROOT_PW" -e 'SELECT 1' >/dev/null 2>&1; then
    die "MySQL root password rejected"
  fi
  MYSQL_ADMIN=(mysql -u root -p"$MYSQL_ROOT_PW")
fi

# Check for existing DB
if "${MYSQL_ADMIN[@]}" -e "USE $DB_NAME" >/dev/null 2>&1; then
  if [[ "$REINSTALL" != "yes" ]]; then
    die "database '$DB_NAME' already exists. Re-run with --reinstall to drop and recreate (DB was backed up in phase 6)."
  fi
  "${MYSQL_ADMIN[@]}" -e "DROP DATABASE $DB_NAME;"
  ok "dropped existing $DB_NAME (backup is in /root/myosicam-db-backup-*)"
fi

"${MYSQL_ADMIN[@]}" <<SQL
CREATE DATABASE $DB_NAME CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASS';
ALTER USER '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASS';
GRANT ALL PRIVILEGES ON $DB_NAME.* TO '$DB_USER'@'localhost';
FLUSH PRIVILEGES;
SQL
ok "created database $DB_NAME and user $DB_USER"
# ============================================================================
# Phase 9 — Download and verify tarball
# ============================================================================
CUR_PHASE=9
phase 9 "Downloading panel tarball"

TARBALL="/tmp/panel-${VERSION}.tar.gz"
echo "  fetching: $TARBALL_URL"
if ! curl -fsSL --max-time 120 --connect-timeout 10 "$TARBALL_URL" -o "$TARBALL"; then
  die "curl failed to download $TARBALL_URL (exit $?)"
fi
if [[ ! -s "$TARBALL" ]]; then
  die "downloaded tarball is empty or missing: $TARBALL"
fi
echo "  downloaded $(stat -c%s "$TARBALL") bytes"
ACTUAL_SHA=$(sha256sum "$TARBALL" | awk '{print $1}')
if [[ "$ACTUAL_SHA" != "$TARBALL_SHA" ]]; then
  die "tarball checksum mismatch (expected $TARBALL_SHA, got $ACTUAL_SHA) — possible tampering"
fi
ok "tarball verified (sha256 $TARBALL_SHA)"

mkdir -p "$PANEL_DIR"
tar -xzf "$TARBALL" -C "$PANEL_DIR" --strip-components=1
ok "panel extracted to $PANEL_DIR"
rm -f "$TARBALL"
# ============================================================================
# Phase 10 — Composer install (PHP dependencies)
# ============================================================================
# The release tarball is *supposed* to ship a pre-built vendor/ tree, but we
# never want a missing or partial vendor/ to silently disable runtime features
# such as the phpseclib3-based agent installer (which would otherwise hang the
# UI at "connecting via SSH..." with a class-not-found in a backgrounded
# proc_open). Running `composer install` here is idempotent — when vendor/
# already matches composer.lock it is a near-no-op — and it guarantees a
# usable autoloader on every fresh install.
CUR_PHASE=10
phase 10 "Installing PHP dependencies (composer install)"

command -v composer >/dev/null 2>&1 || die "composer binary not found in PATH (apt install composer should have run in phase 1)"
if [[ ! -f "$PANEL_DIR/composer.json" ]]; then
  die "tarball is missing composer.json — cannot install PHP dependencies"
fi
if [[ ! -f "$PANEL_DIR/composer.lock" ]]; then
  warn "composer.lock not present in tarball — falling back to unlocked install"
fi

# All other phases here run as root (apt, mysql admin, nginx, certbot). Run
# composer as root too, with COMPOSER_ALLOW_SUPERUSER=1 to suppress the
# interactive warning that would otherwise stall a non-TTY install. Phase 11
# below chowns the entire $PANEL_DIR (including vendor/) to www-data.
(
  cd "$PANEL_DIR"
  COMPOSER_ALLOW_SUPERUSER=1 composer install \
    --no-dev --optimize-autoloader --no-interaction --no-progress
)

if [[ ! -f "$PANEL_DIR/vendor/autoload.php" ]]; then
  die "composer install completed but vendor/autoload.php is still missing"
fi
ok "composer dependencies installed"
# ============================================================================
# Phase 11 — File permissions
# ============================================================================
CUR_PHASE=11
phase 11 "Setting permissions"

chown -R www-data:www-data "$PANEL_DIR"
find "$PANEL_DIR" -type d -exec chmod 755 {} +
find "$PANEL_DIR" -type f -exec chmod 644 {} +
mkdir -p "$PANEL_DIR/storage/logs" "$PANEL_DIR/storage/templates"
chmod 775 "$PANEL_DIR/storage/logs" "$PANEL_DIR/storage/templates"
chown -R www-data:www-data "$PANEL_DIR/storage"
ok "permissions set"
# ============================================================================
# Phase 12 — Generate .env
# ============================================================================
CUR_PHASE=12
phase 12 "Writing .env"

PANEL_ENC_KEY="$(openssl rand -hex 32)"
REDACT_LIST+=("$PANEL_ENC_KEY")

APP_URL="https://$DOMAIN"
[[ "$USE_SSL" == "no" ]] && APP_URL="http://$DOMAIN"

cat > "$PANEL_DIR/.env" <<EOF
# MyOsicam Panel — generated by installer on $START_TS
APP_ENV=production
APP_DEBUG=false
APP_VERSION=$VERSION
APP_URL=$APP_URL

DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=$DB_NAME
DB_USERNAME=$DB_USER
DB_PASSWORD='$DB_PASS'

PANEL_USERS_DIR=

LICENSE_SERVER_URL=https://license.myosicam.com/api/v1/validate
LICENSE_GRACE_HOURS=72
UPDATE_SERVER_URL=https://update.myosicam.com/api/v1/latest

SESSION_NAME=myosicam_session
SESSION_LIFETIME=7200

OSCAM_ALLOWED_PATHS=/var/www/myosicam,/var/www/myosicam/panel_users
PANEL_URL=$APP_URL

# Central admin panel credentials issued during license validation.
ADMIN_BASE_URL=https://admin.myosicam.us
PANEL_ID=$PANEL_ID
PANEL_API_KEY='$PANEL_API_KEY'

# Self-update identity (feature 054). Re-validated against install.myosicam.us
# each time the operator applies a panel update; required by PanelUpdateService.
PANEL_LICENSE_KEY=$LICENSE
PANEL_FQDN=$DOMAIN
PANEL_UPDATE_CHANNEL=$CHANNEL
# Canonical install root. Must be the parent that owns current/, releases/,
# shared/ — NEVER a release dir. BASE_PATH resolves through the symlink chain
# to the release dir after a swap, which would cause apply() to recurse and
# build releases/vX/releases/vY nested layouts. This pins the right path.
PANEL_UPDATE_BASE_PATH=$PANEL_DIR

DEPLOY_TOKEN_TTL_MINUTES=30
TEMPLATE_STORAGE_PATH=

PANEL_ENCRYPTION_KEY=$PANEL_ENC_KEY

ADMIN_EMAIL=$EMAIL

# OSCam-Checker / WireGuard VPN integration (feature 046)
WG_API_URL=$WG_API_URL
WG_BOOTSTRAP_KEY=$WG_BOOTSTRAP_KEY
OSCAM_CHECKER_BINARY_URL=$OSCAM_CHECKER_BINARY_URL
EOF

chown www-data:www-data "$PANEL_DIR/.env"
chmod 640 "$PANEL_DIR/.env"
ok ".env written"
# ============================================================================
# Phase 13 — Import schema
# ============================================================================
CUR_PHASE=13
phase 13 "Importing database schema"

if [[ ! -f "$PANEL_DIR/database/install.sql" ]]; then
  die "tarball is missing database/install.sql"
fi
mysql -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" < "$PANEL_DIR/database/install.sql"
ok "install.sql imported"

# Back-fill schema_migrations with every shipped migration filename so future
# incremental upgrades see them as applied. The schema's PK is `version`
# (the migration filename); see 001_initial_schema.sql.
if [[ -d "$PANEL_DIR/database/migrations" ]]; then
  for f in "$PANEL_DIR"/database/migrations/*.sql; do
    name=$(basename "$f")
    mysql -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" \
      -e "INSERT IGNORE INTO schema_migrations (version, applied_at) VALUES ('$name', NOW());"
  done
  ok "schema_migrations back-filled"
fi
# ============================================================================
# Phase 14 — Seed panel_installation, licenses, admin_users
# ============================================================================
CUR_PHASE=14
phase 14 "Seeding admin account"

ADMIN_USER="admin"
ADMIN_PASS="$(openssl rand -base64 18 | tr -d '/+=' | cut -c1-20)"
REDACT_LIST+=("$ADMIN_PASS")

VPS_HOSTNAME="$(hostname -f 2>/dev/null || hostname)"
PANEL_VERSION="$VERSION"

php -r '
$db = $argv[1]; $u = $argv[2]; $p = $argv[3];
$user = $argv[4]; $pw = $argv[5];
$licenseKey = $argv[6]; $version = $argv[7]; $vpsHostname = $argv[8];

$pdo = new PDO("mysql:host=127.0.0.1;dbname=$db;charset=utf8mb4", $u, $p,
  [PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]);

// Generate UUID v4 for install_id (single-row table; insert if not present).
$existing = $pdo->query("SELECT install_id FROM panel_installation LIMIT 1")->fetchColumn();
if ($existing === false || $existing === null) {
    $b = random_bytes(16);
    $b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
    $b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
    $hex = bin2hex($b);
    $iid = sprintf("%s-%s-%s-%s-%s",
        substr($hex,0,8), substr($hex,8,4), substr($hex,12,4),
        substr($hex,16,4), substr($hex,20,12));
    $pdo->prepare("INSERT INTO panel_installation (install_id, license_key, installed_at, version, vps_hostname)
                   VALUES (?, ?, NOW(), ?, ?)")
        ->execute([$iid, $licenseKey, $version, $vpsHostname]);
} else {
    $iid = $existing;
}

$pdo->prepare("INSERT INTO licenses (install_id, license_key, status, validated_at, created_at)
               VALUES (?, ?, \"valid\", NOW(), NOW())
               ON DUPLICATE KEY UPDATE status=\"valid\", validated_at=NOW()")
    ->execute([$iid, $licenseKey]);

$hash = password_hash($pw, PASSWORD_BCRYPT);
// must_change_password=1 forces the operator to replace the auto-generated
// install password (printed in the success banner) on first login. Cleared
// atomically by AccountController::updatePassword() once a real password is
// set. See specs/047-force-password-change/spec.md.
$pdo->prepare("INSERT INTO admin_users (install_id, username, password_hash, role, created_at, must_change_password)
               VALUES (?, ?, ?, \"superadmin\", NOW(), 1)")
    ->execute([$iid, $user, $hash]);

echo "seeded\n";
' "$DB_NAME" "$DB_USER" "$DB_PASS" "$ADMIN_USER" "$ADMIN_PASS" "$LICENSE" "$PANEL_VERSION" "$VPS_HOSTNAME" >/dev/null
ok "admin user '$ADMIN_USER' created"
# ============================================================================
# Phase 15 — Nginx vhost
# ============================================================================
CUR_PHASE=15
phase 15 "Configuring Nginx"

VHOST="/etc/nginx/sites-available/myosicam.conf"
cat > "$VHOST" <<NGINX
server {
    listen 80;
    server_name $DOMAIN;
    root $PANEL_DIR/public;
    index index.php;

    server_tokens off;
    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;

    client_max_body_size 16M;

    location / {
        try_files \$uri \$uri/ /index.php?\$query_string;
    }

    location ~ \.php\$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }

    location ~ /\. { deny all; }
    location ~ ^/storage/ { deny all; }
}
NGINX

ln -sf "$VHOST" /etc/nginx/sites-enabled/myosicam.conf
rm -f /etc/nginx/sites-enabled/default
nginx -t
if [[ -d /run/systemd/system ]]; then
  systemctl reload nginx
else
  service nginx reload 2>/dev/null || nginx -s reload 2>/dev/null || warn "could not reload nginx"
fi
ok "nginx configured and reloaded"
# ============================================================================
# Phase 16 — Let's Encrypt
# ============================================================================
CUR_PHASE=16
phase 16 "Obtaining SSL certificate"

if [[ "$USE_SSL" == "yes" ]]; then
  if certbot --nginx -d "$DOMAIN" -m "$EMAIL" --agree-tos --non-interactive --redirect; then
    ok "SSL issued; HTTP→HTTPS redirect active"
  else
    warn "certbot failed — panel remains reachable on HTTP. Re-run later with:"
    warn "  sudo certbot --nginx -d $DOMAIN -m $EMAIL --agree-tos"
    APP_URL="http://$DOMAIN"  # keep summary accurate
    sed -i "s|^APP_URL=.*|APP_URL=$APP_URL|" "$PANEL_DIR/.env"
    sed -i "s|^PANEL_URL=.*|PANEL_URL=$APP_URL|" "$PANEL_DIR/.env"
  fi
else
  ok "skipped (--no-ssl)"
fi
# ============================================================================
# Phase 17 — Smoke test
# ============================================================================
CUR_PHASE=17
phase 17 "Smoke testing panel"

# Loopback probe with Host header so the smoke test works even if DNS for
# $DOMAIN isn't reachable from the box. -L follows the HTTP→HTTPS redirect
# certbot wired up; -k tolerates the cert mismatch when curl resolves
# 127.0.0.1 but presents the redirect target's hostname.
SMOKE_URL="http://127.0.0.1/login"
SMOKE_CODE=$(curl -sS -L -k -o /dev/null -w '%{http_code}' -H "Host: $DOMAIN" \
  --resolve "$DOMAIN:443:127.0.0.1" --resolve "$DOMAIN:80:127.0.0.1" \
  "http://$DOMAIN/login" || echo "000")
case "$SMOKE_CODE" in
  2*|3*)
    ok "panel responds HTTP $SMOKE_CODE at $APP_URL/login"
    ;;
  *)
    warn "panel returned HTTP $SMOKE_CODE at $APP_URL/login (loopback probe)"
    echo "---- nginx error log (tail 50) ----"
    tail -n 50 /var/log/nginx/error.log 2>/dev/null || true
    echo "---- php-fpm log (tail 50) ----"
    tail -n 50 /var/log/php8.3-fpm.log 2>/dev/null || true
    ;;
esac
# ============================================================================
# Phase 18 — Summary
# ============================================================================
CUR_PHASE=18
phase 18 "Installation complete"

MASKED_LICENSE="***${LICENSE: -4}"
SUMMARY_FILE="/root/myosicam-install-summary.txt"

# Full summary (unmasked) → root-only file
cat > "$SUMMARY_FILE" <<EOF
MyOsicam Panel — installation summary ($START_TS)

Panel URL:          $APP_URL
Admin username:     $ADMIN_USER
Admin password:     $ADMIN_PASS
Admin email:        $EMAIL

Database:
  Name:      $DB_NAME
  User:      $DB_USER
  Password:  $DB_PASS

License key:        $LICENSE
Version installed:  $VERSION
Channel:            $CHANNEL

Install log:        $LOG
EOF
chmod 600 "$SUMMARY_FILE"

# Print the success banner directly to fd 9 (the unredacted, race-proof
# duplicate of stderr opened before _start_logging). This bypasses both
# the redact filter (so passwords aren't masked on screen) and the
# redact-tee subshell (so the banner can't be eaten on script exit).
cat >&9 <<EOF

────────────────────────────────────────────────────────────────
  ✓  MyOsicam Panel installed successfully
────────────────────────────────────────────────────────────────
  Panel URL:       $APP_URL/login
  Admin user:      $ADMIN_USER
  Admin password:  $ADMIN_PASS

  ⚠  CHANGE THIS PASSWORD on first login.

  Database:
    Name:      $DB_NAME
    User:      $DB_USER
    Password:  $DB_PASS

  License:         $MASKED_LICENSE  (full value in $SUMMARY_FILE)
  Version:         $VERSION  ($CHANNEL)

  Full details saved to:  $SUMMARY_FILE  (chmod 600, root only)
  Install log:            $LOG
────────────────────────────────────────────────────────────────
EOF

if [[ -z "$WG_BOOTSTRAP_KEY" ]]; then
  cat >&9 <<EOF

  ⚠  WG_BOOTSTRAP_KEY is NOT set. Agent install (SSH bootstrap) will fail
     until you populate it. Either re-run the installer with
       --wg-bootstrap-key=<key-from-admin.kanasavpn.com>
     or edit $PANEL_DIR/.env and set WG_BOOTSTRAP_KEY=<key>, then
       systemctl reload php8.3-fpm

EOF
fi
