#!/usr/bin/env bash
# =============================================================================
# quick_start.sh — Pritunl 管控平台（pritunl_control_platform）一键安装脚本
#
# 目标：一条命令完成安装，交付形态对标 1Panel：
#   bash -c "$(curl -sSL https://<下载服务器>/pritunl-control/quick_start.sh)"
#
# 三种使用场景：
#   场景 A（标准）：
#     bash -c "$(curl -sSL https://dl.example.com/pritunl-control/quick_start.sh)"
#   场景 B（带参数）：
#     curl -sSL https://dl.example.com/pritunl-control/quick_start.sh | bash -s -- --port 8081 --dir /opt/pcc
#   场景 C（离线包，内网无下载服务器）：
#     bash quick_start.sh --package /tmp/pritunl-control-1.0.3.tar.gz --dir /opt/pcc
#
# 子命令：install（默认）| upgrade | uninstall | status | version | help
#
# 配置优先级：命令行参数 > 环境变量 > 交互式输入 > 默认值
#
# 设计要点：
#   1. 幂等：重复执行不破坏数据（保留 .env、保留数据库）
#   2. 安全：仅从参数/环境变量/随机生成获取口令，脚本内不含任何硬编码凭据
#   3. 隔离：支持 --dir / --port / --service-name，便于在单机上做隔离测试
#   4. 兼容：目标机可能无 tar 命令，自动降级为 Python tarfile 解包
#
# 关联文档：docs/one-click-install.md（使用说明）、docs/deployment-rocky9.md（手工分步排障）
# =============================================================================
# 注意：必须带 -E（errtrace）。ERR trap 默认不被 shell 函数继承，而本脚本的业务逻辑
# 全部写在函数里；缺 -E 时 trap on_error ERR 一次都不会执行，失败路径将丢失
# 「阶段 / 退出码 / 日志路径 / 排障手册」诊断信息。
set -Eeuo pipefail

# -----------------------------------------------------------------------------
# 常量与默认值
# -----------------------------------------------------------------------------
readonly SCRIPT_NAME="quick_start.sh"
readonly SCRIPT_VERSION="1.0.5"

readonly DEFAULT_DIR="/opt/pritunl-control"
readonly DEFAULT_PORT="8080"
readonly DEFAULT_SERVICE="pritunl-control"
readonly DEFAULT_ADMIN_USER="admin"
readonly DEFAULT_VERSION="latest"
readonly DEFAULT_MONGO_URI="mongodb://127.0.0.1:27017"

readonly PRITUNL_PY="/usr/lib/pritunl/usr/bin/python3"
readonly ENV_TEMPLATE_REL="deploy/.env.production.example"
readonly SERVICE_TEMPLATE_REL="deploy/pritunl-control.service"
readonly LOG_FILE="/var/log/pritunl-control-install.log"
readonly DOWNLOAD_RETRY=3
readonly DOWNLOAD_RETRY_SLEEP=3
readonly HEALTH_TIMEOUT=30

# -----------------------------------------------------------------------------
# 运行时变量（全部预初始化，避免 set -u 报错）
# -----------------------------------------------------------------------------
SUBCOMMAND="install"
BASE_URL="${PCC_BASE_URL:-}"
VERSION="${PCC_VERSION:-$DEFAULT_VERSION}"
INSTALL_DIR="${PCC_DIR:-$DEFAULT_DIR}"
PORT="${PCC_PORT:-$DEFAULT_PORT}"
SERVICE_NAME="${PCC_SERVICE_NAME:-$DEFAULT_SERVICE}"
ADMIN_USER="${PCC_ADMIN_USER:-$DEFAULT_ADMIN_USER}"
ADMIN_PASSWORD="${PCC_ADMIN_PASSWORD:-}"
JWT_SECRET="${PCC_JWT_SECRET:-}"
MONGO_URI="${PCC_MONGO_URI:-$DEFAULT_MONGO_URI}"
PRITUNL_MONGO_URI="${PCC_PRITUNL_MONGO_URI:-}"
PACKAGE_FILE="${PCC_PACKAGE:-}"
DIST_DIR="${PCC_DIST_DIR:-}"
ASSUME_YES=0
if [[ "${PCC_ASSUME_YES:-0}" == "1" || "${PCC_ASSUME_YES:-}" == "true" ]]; then
    ASSUME_YES=1
fi

# 卸载时的彻底清除开关（默认 0=保留，安全默认值）
# 说明 1：--yes 不会触发清除，必须显式给出 --purge / --purge-db
# 说明 2：PCC_PURGE / PCC_PURGE_DB 仅在子命令为 uninstall 时生效（见 parse_args），
#         避免环境变量残留导致任意一次 uninstall 静默带上删除语义
PURGE=0
PURGE_DB=0
CONFIRM_DROP_DB=""
if [[ "${PCC_PURGE:-0}" == "1" || "${PCC_PURGE:-}" == "true" ]]; then
    PURGE=1
fi
if [[ "${PCC_PURGE_DB:-0}" == "1" || "${PCC_PURGE_DB:-}" == "true" ]]; then
    PURGE_DB=1
fi
if [[ -n "${PCC_CONFIRM_DROP_DB:-}" ]]; then
    CONFIRM_DROP_DB="$PCC_CONFIRM_DROP_DB"
fi

# 是否允许「无 .sha256 校验文件」仍继续安装（默认 0=严格，缺失即中止）
# 仅在你完全信任下载通道且确实无法提供校验文件时显式开启
ALLOW_UNSIGNED=0
# 「跳过完整性校验」是否已播报过：acquire_package 与 verify_package 各有一处 WARN 通路，
# 加标志保证同一件事只说一次（N1），不影响任何判定结果。
UNSIGNED_WARNED=0
if [[ "${PCC_ALLOW_UNSIGNED:-0}" == "1" || "${PCC_ALLOW_UNSIGNED:-}" == "true" ]]; then
    ALLOW_UNSIGNED=1
fi

# 「用户是否显式指定」标记：升级重跑时避免用默认值覆盖用户自定义配置
#（ADMIN_USERNAME / MONGO_URI / PRITUNL_MONGO_URI / PRITUNL_SITE_PACKAGES）
ADMIN_USER_EXPLICIT=0
MONGO_URI_EXPLICIT=0
PRITUNL_MONGO_URI_EXPLICIT=0
[[ -n "${PCC_ADMIN_USER:-}" ]] && ADMIN_USER_EXPLICIT=1
[[ -n "${PCC_MONGO_URI:-}" ]] && MONGO_URI_EXPLICIT=1
[[ -n "${PCC_PRITUNL_MONGO_URI:-}" ]] && PRITUNL_MONGO_URI_EXPLICIT=1

LOG_OK=0
DETECTED_SP=0
TMP_DIR=""
# 本次安装自动创建的父目录（用于 uninstall --purge 时一并清理空壳，见 do_uninstall）
AUTO_PARENT_CREATED=""
RESOLVED_VERSION=""
PKG_TARBALL=""
PKG_SHA_FILE=""
INSTALL_STAGE="初始化"
SITE_PACKAGES=""
GENERATED_PASSWORD=0

# -----------------------------------------------------------------------------
# 日志与终端输出
# -----------------------------------------------------------------------------
C_RESET=""; C_INFO=""; C_WARN=""; C_ERROR=""; C_OK=""; C_BOLD=""
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
    C_RESET="\033[0m"; C_INFO="\033[0;36m"; C_WARN="\033[0;33m"
    C_ERROR="\033[0;31m"; C_OK="\033[0;32m"; C_BOLD="\033[1m"
fi

# 初始化日志文件（不可写时静默降级为仅 stdout）
if ( touch "$LOG_FILE" ) >/dev/null 2>&1; then
    LOG_OK=1
fi

# _emit <级别> <颜色> <消息...>：彩色输出 + 写入日志文件（纯文本）
_emit() {
    local level="$1" color="$2"; shift 2
    local ts plain
    ts="$(date '+%F %T')"
    plain="[$level $ts] $*"
    printf '%b%s%b\n' "$color" "$plain" "$C_RESET"
    if [[ "$LOG_OK" == "1" ]]; then
        printf '%s\n' "$plain" >> "$LOG_FILE" 2>/dev/null || true
    fi
}

log_info()    { _emit "INFO" "$C_INFO" "$@"; }
log_warn()    { _emit "WARN" "$C_WARN" "$@"; }
log_error()   { _emit "ERROR" "$C_ERROR" "$@"; }
log_success() { _emit "OK" "$C_OK" "$@"; }
log_step()    { _emit "STEP" "$C_BOLD" "$@"; }

# 分隔线（仅 stdout，不进日志，避免噪声）
log_line() { printf '%b%s%b\n' "$C_BOLD" "--------------------------------------------------------------" "$C_RESET"; }

# -----------------------------------------------------------------------------
# 错误处理与清理
# -----------------------------------------------------------------------------
on_error() {
    local rc=$?
    log_error "执行失败：阶段【${INSTALL_STAGE}】退出码 ${rc}"
    log_error "安装未完成，请查看日志：$LOG_FILE"
    log_error "排障参考：docs/deployment-rocky9.md §11 故障排查手册"
    exit "$rc"
}
trap on_error ERR

cleanup() {
    # $? 必须是函数第一条语句——任何命令跑过就取不到真实退出码（N5）。
    local rc=$?
    if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then
        rm -rf "$TMP_DIR" 2>/dev/null || true
    fi
    # N5：安装中途失败时，预检阶段自动创建的父目录会残留空壳。这里只在「非零退出」
    # 且该父目录确实为空时 rmdir；成功路径（rc=0）绝不动手。
    # 只用 rmdir（非空目录天然保留），白名单与 do_uninstall 里的清理逻辑保持一致。
    if (( rc != 0 )) && [[ -n "${AUTO_PARENT_CREATED:-}" && -d "${AUTO_PARENT_CREATED:-}" ]]; then
        case "$AUTO_PARENT_CREATED" in
            /|/opt|/usr|/var|/tmp|/home|/root|/srv|/data) : ;;   # 保护常见根级目录
            *)
                if rmdir "$AUTO_PARENT_CREATED" 2>/dev/null; then
                    log_info "已清理安装时自动创建的空父目录: $AUTO_PARENT_CREATED" || true
                fi
                ;;
        esac
    fi
    # 末尾必须 return 0：EXIT trap 的返回值会污染脚本退出码，否则失败的 rmdir
    # 可能把「校验失败」伪装成「安装成功」。
    return 0
}
trap cleanup EXIT

# -----------------------------------------------------------------------------
# 通用工具函数
# -----------------------------------------------------------------------------

# 是否有可用的交互终端（curl | bash 时 stdin 已被脚本占用，必须走 /dev/tty）
# 说明：不能用 [[ -r /dev/tty ]] 判断——access(2) 不真正打开设备，cron/CI/docker exec/
# paramiko 等非 tty 环境下 /dev/tty 可能存在但打不开，会误判为「有终端」并刷出
# “/dev/tty: 没有那个设备或地址” 噪声。这里用子 shell 真正试开一次。
have_tty() {
    if [[ -t 0 ]]; then
        return 0
    fi
    ( : < /dev/tty ) 2>/dev/null
}

# prompt_value <提示语> <默认值> → 回显用户选择的值；非交互时直接返回默认值
prompt_value() {
    local prompt="$1" default="$2" ans=""
    if [[ "$ASSUME_YES" == "1" ]] || ! have_tty; then
        printf '%s' "$default"
        return 0
    fi
    printf '%b%s%b' "$C_BOLD" "$prompt" "$C_RESET" > /dev/tty
    if [[ -t 0 ]]; then
        read -r ans || true
    else
        read -r ans < /dev/tty || true
    fi
    if [[ -z "$ans" ]]; then ans="$default"; fi
    printf '%s' "$ans"
}

# prompt_yes_no <提示语> <默认 y/n> → 返回 0(是)/1(否)
prompt_yes_no() {
    local prompt="$1" default="${2:-y}" ans=""
    if [[ "$ASSUME_YES" == "1" ]] || ! have_tty; then
        [[ "$default" == "y" ]] && return 0 || return 1
    fi
    printf '%b%s%b' "$C_BOLD" "$prompt" "$C_RESET" > /dev/tty
    if [[ -t 0 ]]; then
        read -r ans || true
    else
        read -r ans < /dev/tty || true
    fi
    ans="$(printf '%s' "$ans" | tr '[:upper:]' '[:lower:]')"
    [[ -z "$ans" ]] && ans="$default"
    [[ "$ans" == "y" || "$ans" == "yes" ]]
}

# 随机串生成：优先 openssl，缺失时降级 Python secrets
rand_str() {
    local n="${1:-24}"
    if command -v openssl >/dev/null 2>&1; then
        openssl rand -base64 "$n" | tr -d '\n'
        return 0
    fi
    local py
    py="$(command -v python3 || true)"
    [[ -n "$py" ]] || py="$PRITUNL_PY"
    "$py" -c "import secrets,base64;print(base64.b64encode(secrets.token_bytes($n)).decode())"
}

# 端口是否被占用（ss 优先，降级 bash /dev/tcp）
port_in_use() {
    local p="$1"
    if command -v ss >/dev/null 2>&1; then
        if ss -tln 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]$p$"; then
            return 0
        fi
        return 1
    fi
    if ( exec 3<>"/dev/tcp/127.0.0.1/$p" ) >/dev/null 2>&1; then
        exec 3>&- 2>/dev/null || true
        return 0
    fi
    return 1
}

# HTTP 状态码探测（curl 优先，降级 wget；不可达返回 000）
http_code() {
    local url="$1" code=""
    if command -v curl >/dev/null 2>&1; then
        code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null || echo 000)"
    elif command -v wget >/dev/null 2>&1; then
        code="$(wget --spider -S -T 5 "$url" 2>&1 | awk '/HTTP\// {c=$2} END{print c}')"
    fi
    [[ -z "$code" ]] && code="000"
    printf '%s' "$code"
}

# 计算文件 sha256（sha256sum → openssl → python）
sha256_of() {
    local f="$1" sum=""
    if command -v sha256sum >/dev/null 2>&1; then
        sum="$(sha256sum "$f" 2>/dev/null | awk '{print $1}')"
    elif command -v openssl >/dev/null 2>&1; then
        sum="$(openssl dgst -sha256 "$f" 2>/dev/null | awk '{print $NF}')"
    else
        local py
        py="$(command -v python3 || true)"
        [[ -n "$py" ]] || py="$PRITUNL_PY"
        sum="$("$py" -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$f")"
    fi
    printf '%s' "$sum"
}

# 选择系统 Python（Pritunl 自带 Python 兜底）
sys_python() {
    local py
    py="$(command -v python3 || true)"
    if [[ -z "$py" ]]; then
        py="$(command -v python || true)"
    fi
    if [[ -z "$py" && -x "$PRITUNL_PY" ]]; then
        py="$PRITUNL_PY"
    fi
    printf '%s' "$py"
}

# 下载文件（失败重试 3 次，间隔 3 秒）
download_file() {
    local url="$1" dest="$2" i=0
    for ((i = 1; i <= DOWNLOAD_RETRY; i++)); do
        log_info "下载 $url （第 $i/$DOWNLOAD_RETRY 次尝试）"
        if command -v curl >/dev/null 2>&1; then
            if curl -fsSL --connect-timeout 10 --max-time 600 "$url" -o "$dest" 2>/dev/null; then
                [[ -s "$dest" ]] && return 0
            fi
        elif command -v wget >/dev/null 2>&1; then
            if wget -q -T 600 -O "$dest" "$url" 2>/dev/null; then
                [[ -s "$dest" ]] && return 0
            fi
        else
            log_error "系统既无 curl 也无 wget，无法下载。请先安装：dnf install -y curl"
            return 1
        fi
        rm -f "$dest"
        if [[ $i -lt $DOWNLOAD_RETRY ]]; then
            log_warn "下载失败，${DOWNLOAD_RETRY_SLEEP}s 后重试 ..."
            sleep "$DOWNLOAD_RETRY_SLEEP"
        fi
    done
    log_error "下载失败（已重试 ${DOWNLOAD_RETRY} 次）: $url"
    return 1
}

# 解压 tar.gz：优先 tar，缺失时用 Python tarfile 兜底（精简镜像常见无 tar）
extract_tarball() {
    local pkg="$1" dest="$2"
    mkdir -p "$dest"
    if command -v tar >/dev/null 2>&1; then
        tar xzf "$pkg" -C "$dest"
        return 0
    fi
    log_warn "系统无 tar 命令，改用 Python tarfile 解包（或可执行 dnf install -y tar）"
    local py
    py="$(sys_python)"
    [[ -n "$py" ]] || { log_error "无 tar 且无可用 Python，无法解包"; return 1; }
    "$py" - "$pkg" "$dest" <<'PYEOF'
import sys
import tarfile
import warnings

# 屏蔽新版 Python tarfile 的提取策略告警（仅提示性质，不影响解包结果）
warnings.filterwarnings("ignore")

pkg, dest = sys.argv[1], sys.argv[2]
with tarfile.open(pkg, "r:gz") as tf:
    tf.extractall(dest)
print("python tarfile 解包完成:", dest)
PYEOF
}

# .env 键写入/更新（保持 600 权限；键不存在则追加）
env_upsert() {
    local file="$1" key="$2" val="$3" tmp="${1}.tmp.$$" seen=0
    if grep -Eq "^${key}=" "$file" 2>/dev/null; then
        seen=1
    fi
    if [[ "$seen" == "1" ]]; then
        awk -v k="$key" -v v="$val" 'BEGIN{FS="="} { if ($1==k) { print k"="v } else { print } }' "$file" > "$tmp"
    else
        cp -p "$file" "$tmp" 2>/dev/null || true
        printf '%s=%s\n' "$key" "$val" >> "$tmp"
    fi
    mv "$tmp" "$file"
    chmod 600 "$file"
}

# -----------------------------------------------------------------------------
# 帮助与版本
# -----------------------------------------------------------------------------
print_help() {
    cat <<'EOF'
Pritunl 管控平台 — 一键安装脚本

用法：
  bash -c "$(curl -sSL https://<下载服务器>/pritunl-control/quick_start.sh)"
  curl -sSL https://<下载服务器>/pritunl-control/quick_start.sh | bash -s -- [选项]
  bash quick_start.sh [--package <本地包路径>] [选项]

子命令：
  install（默认）   安装（目录已存在时自动按升级处理，保留 .env 与数据库）
  upgrade          升级（强制保留 .env，不重新生成 JWT/管理员）
  uninstall        卸载（停止并禁用服务、删除 unit；目录与数据默认保留）
  status           查看服务状态、端口、版本与健康状况
  version          打印脚本版本
  help             显示本帮助

选项：
  --base-url <url>        下载源根地址（如 https://dl.example.com/pritunl-control）
  --version <ver>         版本号，默认 latest（读取远端 VERSION 文件）
  --dir <path>            安装目录，默认 /opt/pritunl-control
  --port <port>           服务端口，默认 8080
  --service-name <name>   systemd 服务名，默认 pritunl-control（隔离测试请改）
  --admin-user <name>     管理员用户名，默认 admin
  --admin-password <pwd>  管理员口令，默认随机生成并打印
  --jwt-secret <secret>   JWT 密钥，默认随机生成（openssl rand -base64 48）
  --mongo-uri <uri>       MongoDB 连接串，默认 mongodb://127.0.0.1:27017
  --pritunl-mongo-uri <uri>  Pritunl 库连接串，默认由 --mongo-uri 推导
  --package <file>        离线安装：使用本地程序包，不走网络下载
  --dist <dir>            额外指定前端产物目录（拷入 <dir>/backend/static）
  --yes                   非交互模式，全部使用默认值/自动确认
  --purge                 仅用于 uninstall：同时删除安装目录（默认保留）
  --purge-db              仅用于 uninstall：同时删除 pritunl_control 数据库（不可恢复）
                          需同时传 --confirm-drop-db=pritunl_control，且仅限默认实例
  --confirm-drop-db <name>  --purge-db 的二次确认令牌，值必须等于 pritunl_control
  --allow-unsigned        允许在缺少 .sha256 校验文件时继续安装（在线/离线均生效；默认中止，不建议）

环境变量（与选项一一对应，优先级低于命令行参数）：
  PCC_BASE_URL  PCC_VERSION  PCC_DIR  PCC_PORT  PCC_SERVICE_NAME
  PCC_ADMIN_USER  PCC_ADMIN_PASSWORD  PCC_JWT_SECRET  PCC_MONGO_URI
  PCC_PRITUNL_MONGO_URI  PCC_DIST_DIR  PCC_ASSUME_YES=1
  PCC_PACKAGE  PCC_PURGE=1  PCC_PURGE_DB=1  PCC_ALLOW_UNSIGNED=1
  PCC_CONFIRM_DROP_DB=pritunl_control
  （PCC_PURGE / PCC_PURGE_DB / PCC_CONFIRM_DROP_DB 仅 uninstall 子命令下生效）

示例：
  # 交互式安装（会询问下载源地址）
  bash -c "$(curl -sSL https://dl.example.com/pritunl-control/quick_start.sh)"

  # 非交互安装到 8081（适合自动化/灰度）
  curl -sSL https://dl.example.com/pritunl-control/quick_start.sh | bash -s -- \
      --port 8081 --dir /opt/pcc --service-name pcc --yes

  # 内网离线安装
  bash quick_start.sh --package /tmp/pritunl-control-1.0.3.tar.gz --dir /opt/pcc --yes

  # 卸载并删除目录（数据库保留）
  curl -sSL https://dl.example.com/pritunl-control/quick_start.sh | bash -s -- \
      uninstall --dir /opt/pcc --service-name pcc --yes --purge

  # 删库（仅限默认生产实例，且必须带二次确认令牌；不可恢复）
  quick_start.sh uninstall --purge-db --confirm-drop-db=pritunl_control

文档：docs/one-click-install.md
EOF
}

print_version() {
    printf '%s %s\n' "$SCRIPT_NAME" "$SCRIPT_VERSION"
}

# -----------------------------------------------------------------------------
# 参数解析
# -----------------------------------------------------------------------------
parse_args() {
    local args=()
    # 第一个非选项参数视为子命令
    if [[ $# -gt 0 && "$1" != -* ]]; then
        SUBCOMMAND="$1"
        shift
    fi
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --base-url)       BASE_URL="${2:-}"; shift 2 ;;
            --base-url=*)     BASE_URL="${1#*=}"; shift ;;
            --version)        VERSION="${2:-}"; shift 2 ;;
            --version=*)      VERSION="${1#*=}"; shift ;;
            --dir)            INSTALL_DIR="${2:-}"; shift 2 ;;
            --dir=*)          INSTALL_DIR="${1#*=}"; shift ;;
            --port)           PORT="${2:-}"; shift 2 ;;
            --port=*)         PORT="${1#*=}"; shift ;;
            --service-name)   SERVICE_NAME="${2:-}"; shift 2 ;;
            --service-name=*) SERVICE_NAME="${1#*=}"; shift ;;
            --admin-user)     ADMIN_USER="${2:-}"; ADMIN_USER_EXPLICIT=1; shift 2 ;;
            --admin-user=*)   ADMIN_USER="${1#*=}"; ADMIN_USER_EXPLICIT=1; shift ;;
            --admin-password) ADMIN_PASSWORD="${2:-}"; shift 2 ;;
            --admin-password=*) ADMIN_PASSWORD="${1#*=}"; shift ;;
            --jwt-secret)     JWT_SECRET="${2:-}"; shift 2 ;;
            --jwt-secret=*)   JWT_SECRET="${1#*=}"; shift ;;
            --mongo-uri)      MONGO_URI="${2:-}"; MONGO_URI_EXPLICIT=1; shift 2 ;;
            --mongo-uri=*)    MONGO_URI="${1#*=}"; MONGO_URI_EXPLICIT=1; shift ;;
            --pritunl-mongo-uri)  PRITUNL_MONGO_URI="${2:-}"; PRITUNL_MONGO_URI_EXPLICIT=1; shift 2 ;;
            --pritunl-mongo-uri=*) PRITUNL_MONGO_URI="${1#*=}"; PRITUNL_MONGO_URI_EXPLICIT=1; shift ;;
            --package)        PACKAGE_FILE="${2:-}"; shift 2 ;;
            --package=*)      PACKAGE_FILE="${1#*=}"; shift ;;
            --dist)           DIST_DIR="${2:-}"; shift 2 ;;
            --dist=*)         DIST_DIR="${1#*=}"; shift ;;
            --purge)          PURGE=1; shift ;;
            --purge-db)       PURGE_DB=1; shift ;;
            --confirm-drop-db)     CONFIRM_DROP_DB="${2:-}"; shift 2 ;;
            --confirm-drop-db=*)   CONFIRM_DROP_DB="${1#*=}"; shift ;;
            --allow-unsigned) ALLOW_UNSIGNED=1; shift ;;
            --yes|-y)         ASSUME_YES=1; shift ;;
            --help|-h)        print_help; exit 0 ;;
            *) log_error "未知参数: $1（用 --help 查看用法）"; exit 1 ;;
        esac
    done

    # 子命令归一化
    case "$SUBCOMMAND" in
        install|upgrade|uninstall|status|version|help) : ;;
        *) log_error "未知子命令: $SUBCOMMAND（用 --help 查看用法）"; exit 1 ;;
    esac

    # 清除类开关仅对 uninstall 生效：环境变量（PCC_PURGE / PCC_PURGE_DB）是启动时全局读取的，
    # 一旦残留在 shell profile / CI 里，任何一次 uninstall 都会被静默带上删除语义，故此处强制归零。
    if [[ "$SUBCOMMAND" != "uninstall" ]]; then
        if [[ "$PURGE" == "1" || "$PURGE_DB" == "1" ]]; then
            log_warn "检测到清除类开关（--purge/--purge-db 或 PCC_PURGE/PCC_PURGE_DB），"
            log_warn "  当前子命令为 $SUBCOMMAND —— 已忽略（仅 uninstall 生效）"
        fi
        PURGE=0
        PURGE_DB=0
        CONFIRM_DROP_DB=""
    fi

    # 端口合法性：只接受十进制写法。
    # 不用 ^[0-9]+$ + ((PORT<1))：bash 会把 08 视为非法八进制、007 视为八进制 7，
    # 导致两个算术比较都失败、条件为假而「放行」非法端口。
    if [[ ! "$PORT" =~ ^[1-9][0-9]{0,4}$ ]]; then
        log_error "端口不合法: $PORT（须为 1-65535 的十进制数字，不支持 08 / 007 这类写法）"
        exit 1
    fi
    if (( 10#$PORT < 1 || 10#$PORT > 65535 )); then
        log_error "端口超出范围: $PORT（有效范围 1-65535）"
        exit 1
    fi

    # 安装目录去尾斜杠，避免拼接出 // 影响 unit 生成
    INSTALL_DIR="${INSTALL_DIR%/}"
    [[ -n "$INSTALL_DIR" ]] || { log_error "安装目录不能为空"; exit 1; }
    # 目录白名单：必须绝对路径，且仅含 [A-Za-z0-9._/-]。
    # 排除空格与 $ ` " ' \ ; # 等特殊字符——它们会破坏 sed 替换（脚本用 # 作分隔符）
    # 与 systemd unit 的未加引号赋值，属于「早失败好过静默出错」。
    if [[ ! "$INSTALL_DIR" =~ ^/[A-Za-z0-9._/-]+$ ]]; then
        log_error "安装目录不合法: $INSTALL_DIR"
        log_error "  要求：绝对路径，且仅含字母、数字、. _ - /（不含空格与其他特殊字符）"
        exit 1
    fi
    # 规范化路径（消除 ..、//、符号链接），保证 unit、日志与提示中的路径一致
    if command -v readlink >/dev/null 2>&1; then
        local normalized
        normalized="$(readlink -m "$INSTALL_DIR" 2>/dev/null || true)"
        if [[ -n "$normalized" ]]; then
            INSTALL_DIR="$normalized"
        fi
    fi

    # 服务名白名单：systemd unit 名，仅允许 [A-Za-z0-9._-]，避免写入异常文件名/路径
    if [[ ! "$SERVICE_NAME" =~ ^[A-Za-z0-9._-]+$ ]] || (( ${#SERVICE_NAME} > 64 )); then
        log_error "服务名不合法: $SERVICE_NAME（仅允许字母、数字、. _ -，长度 ≤ 64）"
        exit 1
    fi

    # Pritunl 库连接串：用户未显式指定时由 MONGO_URI 推导
    if [[ -z "$PRITUNL_MONGO_URI" ]]; then
        PRITUNL_MONGO_URI="${MONGO_URI%/}/pritunl"
    fi
}

# -----------------------------------------------------------------------------
# 预检（precheck）
# -----------------------------------------------------------------------------
precheck() {
    INSTALL_STAGE="预检"
    log_step "[1/9] 预检环境 ..."

    # 1) root 权限：需写 /etc/systemd、导入 pritunl 包、调用 OpenVPN 启停
    if [[ "$(id -u)" -ne 0 ]]; then
        log_error "必须以 root 运行（需要写 systemd unit 并以 root 启动服务）"
        exit 1
    fi
    log_success "root 权限 OK"

    # 2) OS 检测：Rocky/CentOS/RHEL/Alma 9 通过；其他发行版警告但允许继续
    local os_id="" os_ver=""
    if [[ -r /etc/os-release ]]; then
        # shellcheck disable=SC1091
        os_id="$(. /etc/os-release && printf '%s' "${ID:-}")"
        os_ver="$(. /etc/os-release && printf '%s' "${VERSION_ID:-}")"
    fi
    os_ver="${os_ver%%.*}"
    local os_ok=0
    case "$os_id" in
        rocky|centos|rhel|almalinux|alma) [[ "$os_ver" == "9" ]] && os_ok=1 ;;
    esac
    if [[ "$os_ok" == "1" ]]; then
        log_success "操作系统 OK: ${os_id} ${os_ver}"
    else
        log_warn "未验证的操作系统: ${os_id:-未知} ${os_ver:-未知}（本平台在 Rocky Linux 9.x 上验证）"
        if ! prompt_yes_no "  是否仍然继续安装？[y/N] " "n"; then
            log_error "用户取消安装"
            exit 1
        fi
        log_warn "继续安装（非验证系统，出现问题请自行排障）"
    fi

    # 3) 架构
    local arch
    arch="$(uname -m)"
    if [[ "$arch" != "x86_64" ]]; then
        log_warn "架构为 $arch（推荐 x86_64），继续安装但风险自负"
    else
        log_success "架构 OK: $arch"
    fi

    # 4) systemd
    if ! command -v systemctl >/dev/null 2>&1; then
        log_error "未检测到 systemd，本脚本仅支持 systemd 托管（Rocky Linux 9）"
        exit 1
    fi
    log_success "systemd OK"

    # 5) 下载工具（离线包模式不强求）
    if [[ -n "$PACKAGE_FILE" ]]; then
        log_info "离线包模式（--package），跳过下载工具检查"
    elif command -v curl >/dev/null 2>&1; then
        log_success "下载工具 OK: curl"
    elif command -v wget >/dev/null 2>&1; then
        log_success "下载工具 OK: wget"
    else
        log_error "未找到 curl 或 wget，无法下载程序包。请先安装：dnf install -y curl"
        exit 1
    fi

    # 6) 解包能力：无 tar 时用 Python tarfile 兜底
    if command -v tar >/dev/null 2>&1; then
        log_success "解包工具 OK: tar"
    else
        local py
        py="$(sys_python)"
        if [[ -n "$py" ]]; then
            log_warn "未找到 tar，将使用 Python tarfile 兜底解包（$py）；建议 dnf install -y tar"
        else
            log_error "既无 tar 也无可用 Python，无法解包程序包"
            exit 1
        fi
    fi

    # 7) Pritunl 检测：未安装/未运行时管控功能不可用（列表/详情仍可用）
    if command -v pritunl >/dev/null 2>&1; then
        local pv
        pv="$(pritunl version 2>/dev/null | head -1 || echo 未知)"
        log_info "Pritunl 版本: ${pv:-未知}"
        if systemctl is-active --quiet pritunl 2>/dev/null; then
            log_success "Pritunl 服务运行中"
        else
            log_warn "Pritunl 服务未运行（Server 启停将不可用，列表/详情不受影响）"
            if ! prompt_yes_no "  是否仍继续安装？[Y/n] " "y"; then
                log_error "用户取消安装"
                exit 1
            fi
        fi
    else
        log_warn "未检测到 pritunl 命令：Server 管控（启停/Route）不可用，仅数据类功能可用"
        if ! prompt_yes_no "  是否仍继续安装？[y/N] " "n"; then
            log_error "用户取消安装（请先安装 Pritunl 后重试）"
            exit 1
        fi
    fi

    # 8) MongoDB 检测
    if systemctl is-active --quiet mongod 2>/dev/null; then
        log_success "MongoDB 服务运行中"
    else
        log_warn "mongod 服务未运行：平台无法读写自建库 pritunl_control"
        log_warn "  若 MongoDB 在远端，请确保已通过 --mongo-uri / PCC_MONGO_URI 指定可连通地址"
        if ! prompt_yes_no "  是否仍继续安装？[y/N] " "n"; then
            log_error "用户取消安装"
            exit 1
        fi
    fi

    # 9) Pritunl 自带 Python：venv 的基础解释器，缺失则后续无法创建 venv
    if [[ ! -x "$PRITUNL_PY" ]]; then
        log_error "Pritunl 自带 Python 不存在: $PRITUNL_PY"
        log_error "平台必须基于 Pritunl 自带 Python 创建 venv（--system-site-packages）才能 import pritunl"
        exit 1
    fi
    local pyver
    pyver="$("$PRITUNL_PY" --version 2>&1 | head -1)"
    log_success "Pritunl 自带 Python OK: $pyver"

    # 10) pritunl 包路径（写入 .env 的 PRITUNL_SITE_PACKAGES）
    SITE_PACKAGES="$(ls -d /usr/lib/pritunl/usr/lib/python3.*/site-packages 2>/dev/null | head -1 || true)"
    if [[ -n "$SITE_PACKAGES" ]]; then
        DETECTED_SP=1
        log_success "pritunl 包路径: $SITE_PACKAGES"
    else
        DETECTED_SP=0
        SITE_PACKAGES="/usr/lib/pritunl/usr/lib/python3.12/site-packages"
        log_warn "未探测到 pritunl 包路径，回退默认值: $SITE_PACKAGES（Pritunl 升级后需按 §9.3 复核）"
    fi

    # 11) 端口占用：若被本服务自身占用视为升级重启，否则报错
    if port_in_use "$PORT"; then
        if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
            log_warn "端口 $PORT 已被本服务（$SERVICE_NAME）占用，将按升级处理并重启"
        else
            log_error "端口 $PORT 已被占用，请先停止占用进程或换端口（--port）后重试"
            log_error "  排查命令：ss -tlnp | grep :$PORT"
            exit 1
        fi
    else
        log_success "端口 $PORT 可用"
    fi

    # 12) 目录已存在 → 升级语义（保留 .env 与数据库）
    if [[ -d "$INSTALL_DIR" ]]; then
        log_warn "安装目录已存在: $INSTALL_DIR —— 将执行升级（保留 .env 与数据库）"
    else
        log_info "安装目录: $INSTALL_DIR（新建）"
    fi

    # 13) 磁盘空间（<1G 时警告）
    # 双重保险：
    #   a) 先尝试创建父目录——既能让 df 取到真实可用空间，也能在不可写时给出明确 WARN；
    #   b) df 赋值末尾 || true——父目录不存在时 df 返回非 0，pipefail 会让整条赋值非 0
    #      从而触发 set -e 原地「静默退出」（零错误信息），必须兜底。
    local parent_dir avail_mb
    parent_dir="$(dirname "$INSTALL_DIR")"
    if [[ ! -d "$parent_dir" ]]; then
        if mkdir -p "$parent_dir" 2>/dev/null; then
            log_info "已创建父目录: $parent_dir"
            # 记录「父目录由本次安装创建」，供 uninstall --purge 清理空壳（QA-11）
            AUTO_PARENT_CREATED="$parent_dir"
        else
            log_warn "父目录不可用: $parent_dir（后续步骤若需创建会报错并给出诊断）"
        fi
    fi
    avail_mb="$(df -Pm "$parent_dir" 2>/dev/null | awk 'NR==2{print $4}' || true)"
    if [[ -n "${avail_mb:-}" && "$avail_mb" =~ ^[0-9]+$ ]]; then
        if (( avail_mb < 1024 )); then
            log_warn "目标分区可用空间仅 ${avail_mb}MB，建议 ≥ 5GB"
        fi
    else
        log_warn "无法获取磁盘可用空间（df 不可用或父目录不可访问），跳过磁盘检查"
    fi

    log_success "预检通过"
}

# -----------------------------------------------------------------------------
# 解析版本 + 下载程序包
# -----------------------------------------------------------------------------
prepare_package() {
    INSTALL_STAGE="获取程序包"
    log_step "[2/9] 获取程序包 ..."
    TMP_DIR="$(mktemp -d /tmp/pcc-install.XXXXXX)"

    if [[ -n "$PACKAGE_FILE" ]]; then
        # 离线模式：使用本地包
        [[ -f "$PACKAGE_FILE" ]] || { log_error "离线包不存在: $PACKAGE_FILE"; exit 1; }
        PKG_TARBALL="$PACKAGE_FILE"
        RESOLVED_VERSION="$(basename "$PACKAGE_FILE" .tar.gz)"
        RESOLVED_VERSION="${RESOLVED_VERSION#pritunl-control-}"
        log_success "使用本地离线包: $PKG_TARBALL（版本 ${RESOLVED_VERSION}）"

        # 离线包与在线下载采用**同一套严格策略**：缺少校验文件一律中止。
        # 理由：build_release.sh 本来就自动生成 .sha256，正常拷包（整个 dist/release/ 目录）
        # 零成本就能带上；统一策略比「本地包不用校验」更好解释，也不会让用户形成错误肌肉记忆。
        if [[ -f "${PACKAGE_FILE}.sha256" ]]; then
            PKG_SHA_FILE="${PACKAGE_FILE}.sha256"
            log_success "发现校验文件: ${PACKAGE_FILE}.sha256"
        elif [[ "$ALLOW_UNSIGNED" == "1" ]]; then
            log_warn "未发现 ${PACKAGE_FILE}.sha256，已按 --allow-unsigned 跳过完整性校验"
            UNSIGNED_WARNED=1
        else
            log_error "离线包缺少校验文件: ${PACKAGE_FILE}.sha256"
            log_error "  请随包提供（拷包时连同 .sha256 一起拷贝）："
            log_error "    sha256sum $(basename "$PACKAGE_FILE") > $(basename "$PACKAGE_FILE").sha256"
            log_error "  确认来源可信且必须跳过时，显式加 --allow-unsigned"
            exit 1
        fi
        return 0
    fi

    # 在线模式：base-url 必填
    if [[ -z "$BASE_URL" ]]; then
        if [[ "$ASSUME_YES" == "1" ]]; then
            log_error "非交互模式下必须指定下载源：--base-url <url> 或 PCC_BASE_URL"
            exit 1
        fi
        log_info "未指定下载源地址，请输入程序包的下载根地址"
        log_info "  示例：https://dl.example.com/pritunl-control"
        BASE_URL="$(prompt_value "  下载源根地址> " "")"
        if [[ -z "$BASE_URL" ]]; then
            log_error "下载源根地址不能为空（或用 --package 走离线安装）"
            exit 1
        fi
    fi
    # 去掉结尾斜杠，统一拼接
    BASE_URL="${BASE_URL%/}"
    log_info "下载源: $BASE_URL"

    # latest → 读取远端 VERSION
    if [[ "$VERSION" == "latest" || -z "$VERSION" ]]; then
        local ver_file="$TMP_DIR/VERSION"
        download_file "$BASE_URL/VERSION" "$ver_file"
        RESOLVED_VERSION="$(head -1 "$ver_file" 2>/dev/null | tr -d '\r\n ')"
        [[ -n "$RESOLVED_VERSION" ]] || { log_error "远端 VERSION 文件为空或格式异常"; exit 1; }
        log_success "远端最新版本: $RESOLVED_VERSION"
    else
        RESOLVED_VERSION="$VERSION"
        log_info "指定版本: $RESOLVED_VERSION"
    fi

    PKG_TARBALL="$TMP_DIR/pritunl-control-${RESOLVED_VERSION}.tar.gz"
    PKG_SHA_FILE="$TMP_DIR/pritunl-control-${RESOLVED_VERSION}.tar.gz.sha256"

    download_file "$BASE_URL/pritunl-control-${RESOLVED_VERSION}.tar.gz" "$PKG_TARBALL"
    log_success "程序包下载完成: $(du -h "$PKG_TARBALL" | awk '{print $1}')"

    # 校验和文件缺失：默认中止（严格模式）。控制下载通道者只要不部署 .sha256 就能绕过
    # 校验，因此放行必须显式 opt-out（--allow-unsigned / PCC_ALLOW_UNSIGNED=1）。
    if ! download_file "$BASE_URL/pritunl-control-${RESOLVED_VERSION}.tar.gz.sha256" "$PKG_SHA_FILE"; then
        if [[ "$ALLOW_UNSIGNED" == "1" ]]; then
            log_warn "未下载到 .sha256 校验文件，已按 --allow-unsigned 跳过完整性校验（不建议生产使用）"
            PKG_SHA_FILE=""
            UNSIGNED_WARNED=1
        else
            log_error "未下载到校验文件: $BASE_URL/pritunl-control-${RESOLVED_VERSION}.tar.gz.sha256"
            log_error "程序包缺少 .sha256，已终止安装（不改动现有系统）"
            log_error "  排查：下载服务器是否漏传该文件？确认来源可信时可用 --allow-unsigned 跳过"
            exit 1
        fi
    fi
}

# -----------------------------------------------------------------------------
# 完整性校验（失败必须终止安装）
# -----------------------------------------------------------------------------
verify_package() {
    INSTALL_STAGE="校验程序包"
    if [[ -z "${PKG_SHA_FILE:-}" || ! -f "${PKG_SHA_FILE:-}" ]]; then
        # 兜底：唯一合法来源是显式 --allow-unsigned（在线与离线两条入口都已在中止前判断过）。
        # 这里不再无条件放行，避免成为绕过校验的后门。
        # N1：acquire_package 已播报过 WARN 时直接静默放行，避免同一件事连打两条。
        if [[ "${UNSIGNED_WARNED:-0}" == "1" ]]; then
            return 0
        fi
        if [[ "$ALLOW_UNSIGNED" == "1" ]]; then
            log_warn "无校验文件，已按 --allow-unsigned 跳过 SHA256 校验"
            return 0
        fi
        log_error "无校验文件且未指定 --allow-unsigned，拒绝继续安装（完整性校验不可跳过）"
        exit 1
    fi
    log_step "[3/9] 校验程序包完整性 ..."
    local expected actual
    expected="$(awk '{print $1}' "$PKG_SHA_FILE" | head -1 | tr -d '\r\n ')"
    actual="$(sha256_of "$PKG_TARBALL")"
    if [[ -z "$expected" ]]; then
        log_error "校验文件内容为空: $PKG_SHA_FILE"
        exit 1
    fi
    if [[ "$expected" == "$actual" ]]; then
        log_success "SHA256 校验通过: $actual"
    else
        log_error "SHA256 校验失败！"
        log_error "  期望: $expected"
        log_error "  实际: $actual"
        log_error "程序包可能损坏或被篡改，已终止安装（不会改动现有系统）"
        exit 1
    fi
}

# -----------------------------------------------------------------------------
# 备份（目录已存在时）
# -----------------------------------------------------------------------------
backup_existing() {
    INSTALL_STAGE="备份"
    if [[ ! -d "$INSTALL_DIR" ]]; then
        log_info "[4/9] 目录不存在，跳过备份"
        return 0
    fi
    log_step "[4/9] 备份现有安装 ..."
    local ts backup_dir
    ts="$(date +%Y%m%d-%H%M%S)"
    backup_dir="$INSTALL_DIR/backups/preinstall-$ts"
    mkdir -p "$backup_dir"

    # .env 一定备份（生产密钥，权限 600）
    if [[ -f "$INSTALL_DIR/.env" ]]; then
        cp -p "$INSTALL_DIR/.env" "$backup_dir/.env"
        chmod 600 "$backup_dir/.env"
        log_success ".env 已备份: $backup_dir/.env"
    else
        log_warn "未发现 .env，跳过备份"
    fi

    # 仅在标准安装目录（/opt/pritunl-control）下调用包内 backup.sh：
    # backup.sh 内部 APP_ROOT 硬编码为 /opt/pritunl-control，对非标准目录会备份错误路径的 .env，
    # 故隔离目录场景只做 .env 备份，避免把生产密钥复制到测试目录。
    if [[ "$INSTALL_DIR" == "$DEFAULT_DIR" && -f "$INSTALL_DIR/deploy/backup.sh" ]] && command -v mongodump >/dev/null 2>&1; then
        log_info "调用包内 deploy/backup.sh 做完整备份（含数据库导出）..."
        if bash "$INSTALL_DIR/deploy/backup.sh" >/dev/null 2>&1; then
            log_success "完整备份完成（见 $INSTALL_DIR/backups/）"
        else
            log_warn "deploy/backup.sh 执行失败（非致命），已保留 .env 备份"
        fi
    else
        log_warn "非标准目录或无 mongodump：未做数据库导出。"
        log_warn "  如需备份数据库请手工执行：mongodump --db pritunl_control --out $backup_dir/mongo"
    fi
}

# -----------------------------------------------------------------------------
# 解压安装
# -----------------------------------------------------------------------------
install_files() {
    INSTALL_STAGE="部署代码"
    log_step "[5/9] 解压并安装程序文件到 $INSTALL_DIR ..."
    mkdir -p "$INSTALL_DIR"
    local extract_dir="$TMP_DIR/extract"
    extract_tarball "$PKG_TARBALL" "$extract_dir"

    # 顶层若带 pritunl-control-<ver>/ 前缀目录则下沉一层（兼容两种打包风格）
    if [[ -d "$extract_dir/backend" ]]; then
        :
    else
        local top
        top="$(find "$extract_dir" -maxdepth 1 -mindepth 1 -type d | head -1 || true)"
        if [[ -n "$top" && -d "$top/backend" ]]; then
            extract_dir="$top"
        fi
    fi

    [[ -f "$extract_dir/backend/requirements.txt" ]] || {
        log_error "程序包结构异常：缺少 backend/requirements.txt"
        exit 1
    }

    # 保留既有 .env：先挪出，解包后再放回（升级时不覆盖用户配置）
    local env_backup="$TMP_DIR/keep.env" have_env=0
    if [[ -f "$INSTALL_DIR/.env" ]]; then
        cp -p "$INSTALL_DIR/.env" "$env_backup"
        have_env=1
    fi

    cp -a "$extract_dir/." "$INSTALL_DIR/"
    mkdir -p "$INSTALL_DIR/backend/static"

    # 记录「父目录由本脚本创建」，供 uninstall --purge 清理空壳父目录（QA-11）。
    # 只记录自动创建的那一级，原本就存在的父目录不会被误删。
    if [[ -n "${AUTO_PARENT_CREATED:-}" ]]; then
        printf '%s\n' "$AUTO_PARENT_CREATED" > "$INSTALL_DIR/.pcc_created_parent"
    fi

    if [[ "$have_env" == "1" ]]; then
        cp -p "$env_backup" "$INSTALL_DIR/.env"
        chmod 600 "$INSTALL_DIR/.env"
        log_success "已保留既有 .env（不覆盖用户配置）"
    fi

    if [[ -f "$extract_dir/VERSION" ]]; then
        log_success "程序版本: $(head -1 "$extract_dir/VERSION" | tr -d '\r\n')"
    fi
    log_success "程序文件部署完成: $INSTALL_DIR"
}

# -----------------------------------------------------------------------------
# 创建/更新 venv
# -----------------------------------------------------------------------------
setup_venv() {
    INSTALL_STAGE="创建虚拟环境"
    log_step "[6/9] 创建/更新 Python 虚拟环境（可能需要数分钟，视网络而定）..."
    local venv_script="$INSTALL_DIR/deploy/setup_venv.sh"
    [[ -f "$venv_script" ]] || { log_error "缺少 deploy/setup_venv.sh"; exit 1; }

    # setup_venv.sh 内 VENV_DIR/REQ_FILE 硬编码为 /opt/pritunl-control；
    # 非标准目录时生成一份路径改写副本（不修改原脚本），保证隔离安装可用。
    if [[ "$INSTALL_DIR" != "$DEFAULT_DIR" ]]; then
        local derived="$INSTALL_DIR/deploy/.setup_venv.local.sh"
        sed -e "s#^VENV_DIR=.*#VENV_DIR=\"$INSTALL_DIR/venv\"#" \
            -e "s#^REQ_FILE=.*#REQ_FILE=\"$INSTALL_DIR/backend/requirements.txt\"#" \
            "$venv_script" > "$derived"
        chmod +x "$derived"
        venv_script="$derived"
        log_info "非标准安装目录，已生成本地化 venv 脚本: $derived"
    fi

    bash "$venv_script"
    log_success "venv 就绪: $INSTALL_DIR/venv"
}

# -----------------------------------------------------------------------------
# 生成 .env
# -----------------------------------------------------------------------------
generate_env() {
    INSTALL_STAGE="生成配置"
    log_step "[7/9] 生成 .env 配置 ..."
    local env_file="$INSTALL_DIR/.env"
    local tpl="$INSTALL_DIR/$ENV_TEMPLATE_REL"
    local is_new=0

    if [[ -f "$env_file" ]]; then
        log_info "发现既有 .env，保留原值（升级语义）"
    else
        is_new=1
        if [[ -f "$tpl" ]]; then
            cp "$tpl" "$env_file"
            log_info "已从模板创建 .env: $ENV_TEMPLATE_REL"
        else
            : > "$env_file"
            log_warn "未找到模板 $tpl，将写入最小化 .env"
        fi
    fi

    # 凭据：全新安装时随机生成（脚本内无硬编码口令）
    if [[ "$is_new" == "1" ]]; then
        if [[ -z "$ADMIN_PASSWORD" ]]; then
            ADMIN_PASSWORD="$(rand_str 12)"
            GENERATED_PASSWORD=1
        fi
        if [[ -z "$JWT_SECRET" ]]; then
            JWT_SECRET="$(rand_str 48)"
        fi
        env_upsert "$env_file" ADMIN_USERNAME "$ADMIN_USER"
        env_upsert "$env_file" ADMIN_PASSWORD "$ADMIN_PASSWORD"
        env_upsert "$env_file" JWT_SECRET "$JWT_SECRET"
    else
        # 升级：不重新生成 JWT/管理员；若用户显式传入则按显式覆盖（尊重命令行最高优先级）
        if [[ -n "$ADMIN_PASSWORD" ]]; then
            env_upsert "$env_file" ADMIN_PASSWORD "$ADMIN_PASSWORD"
            log_warn "已按命令行参数覆盖 ADMIN_PASSWORD"
        fi
        if [[ -n "$JWT_SECRET" ]]; then
            env_upsert "$env_file" JWT_SECRET "$JWT_SECRET"
            log_warn "已按命令行参数覆盖 JWT_SECRET（所有已登录会话将失效）"
        fi
        # 用户名仅在用户显式指定时覆盖，避免把自定义管理员名改回默认 admin
        if [[ "$ADMIN_USER_EXPLICIT" == "1" ]]; then
            env_upsert "$env_file" ADMIN_USERNAME "$ADMIN_USER"
        fi
    fi

    # 运行参数：端口每次同步（必须与 systemd unit 一致）
    env_upsert "$env_file" APP_HOST "0.0.0.0"
    env_upsert "$env_file" APP_PORT "$PORT"
    # 数据库与桥接路径：全新安装或用户显式指定时写入；升级时保留用户自定义值
    if [[ "$is_new" == "1" || "$MONGO_URI_EXPLICIT" == "1" ]]; then
        env_upsert "$env_file" MONGO_URI "$MONGO_URI"
    fi
    if [[ "$is_new" == "1" || "$PRITUNL_MONGO_URI_EXPLICIT" == "1" ]]; then
        env_upsert "$env_file" PRITUNL_MONGO_URI "$PRITUNL_MONGO_URI"
    fi
    if [[ "$is_new" == "1" || "$DETECTED_SP" == "1" ]]; then
        env_upsert "$env_file" PRITUNL_SITE_PACKAGES "$SITE_PACKAGES"
    fi

    # 其余项补齐（模板已有则保持原值）
    grep -q '^JWT_EXPIRE_HOURS=' "$env_file" || env_upsert "$env_file" JWT_EXPIRE_HOURS "12"
    grep -q '^SERVER_OP_TIMEOUT=' "$env_file" || env_upsert "$env_file" SERVER_OP_TIMEOUT "30"
    grep -q '^SERVER_OP_POLL_INTERVAL=' "$env_file" || env_upsert "$env_file" SERVER_OP_POLL_INTERVAL "1.0"
    grep -q '^RESTART_SETTLE_SECONDS=' "$env_file" || env_upsert "$env_file" RESTART_SETTLE_SECONDS "2"
    grep -q '^AUDIT_RETENTION_DAYS=' "$env_file" || env_upsert "$env_file" AUDIT_RETENTION_DAYS "90"
    grep -q '^AUDIT_LOG_READS=' "$env_file" || env_upsert "$env_file" AUDIT_LOG_READS "false"
    grep -q '^CORS_ORIGINS=' "$env_file" || env_upsert "$env_file" CORS_ORIGINS ""

    chmod 600 "$env_file"
    chown root:root "$env_file" 2>/dev/null || true
    log_success ".env 就绪（权限 $(stat -c '%a' "$env_file" 2>/dev/null || echo 600)）: $env_file"
}

# -----------------------------------------------------------------------------
# 部署前端
# -----------------------------------------------------------------------------
deploy_frontend() {
    INSTALL_STAGE="部署前端"
    log_step "[8/9] 部署前端静态资源 ..."
    local static_dir="$INSTALL_DIR/backend/static"
    mkdir -p "$static_dir"
    local src=""

    if [[ -n "$DIST_DIR" && -d "$DIST_DIR" ]]; then
        src="$DIST_DIR"
    elif [[ -d "$INSTALL_DIR/frontend/dist" ]]; then
        src="$INSTALL_DIR/frontend/dist"
    elif [[ -d "$TMP_DIR/extract/frontend/dist" ]]; then
        src="$TMP_DIR/extract/frontend/dist"
    fi

    if [[ -n "$src" ]]; then
        cp -a "$src/." "$static_dir/"
        log_success "前端已部署: $src → $static_dir"
    elif [[ -f "$static_dir/index.html" ]]; then
        log_info "包内无前端产物，保留既有 $static_dir/index.html"
    else
        log_warn "未发现前端产物（frontend/dist），根路径将返回占位页"
        log_warn "  稍后可用 --dist <目录> 重跑本脚本补装，或按 docs/deployment-rocky9.md §4.5 手工部署"
    fi
}

# -----------------------------------------------------------------------------
# 安装 systemd unit
# -----------------------------------------------------------------------------
install_service() {
    INSTALL_STAGE="安装 systemd 服务"
    log_line
    log_info "安装 systemd unit ..."
    local unit="/etc/systemd/system/${SERVICE_NAME}.service"
    local tpl="$INSTALL_DIR/$SERVICE_TEMPLATE_REL"

    if [[ ! -f "$tpl" ]]; then
        log_warn "未找到 unit 模板 $tpl，改用内置模板生成"
        tpl="$TMP_DIR/pritunl-control.service"
        cat > "$tpl" <<'UNIT'
[Unit]
Description=Pritunl Control Platform
After=network.target mongod.service pritunl.service
Wants=mongod.service

[Service]
Type=simple
User=root
WorkingDirectory=/opt/pritunl-control/backend
EnvironmentFile=/opt/pritunl-control/.env
ExecStart=/opt/pritunl-control/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8080 --workers 1
Restart=on-failure
RestartSec=5
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target
UNIT
    fi

    # 按用户指定目录/端口改写三处关键项；--workers 1 为硬约束，不可更改
    sed -e "s#^WorkingDirectory=.*#WorkingDirectory=$INSTALL_DIR/backend#" \
        -e "s#^EnvironmentFile=.*#EnvironmentFile=$INSTALL_DIR/.env#" \
        -e "s#^ExecStart=.*#ExecStart=$INSTALL_DIR/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port $PORT --workers 1#" \
        "$tpl" > "$unit"
    chmod 644 "$unit"
    log_success "unit 已写入: $unit"
    log_info "  WorkingDirectory=$INSTALL_DIR/backend"
    log_info "  EnvironmentFile=$INSTALL_DIR/.env"
    log_info "  ExecStart=... --port $PORT --workers 1"

    systemctl daemon-reload
    log_success "systemd daemon-reload 完成"
}

# -----------------------------------------------------------------------------
# 启动服务
# -----------------------------------------------------------------------------
start_service() {
    INSTALL_STAGE="启动服务"
    log_info "启动服务 $SERVICE_NAME ..."
    systemctl enable "$SERVICE_NAME" >/dev/null 2>&1 || true
    systemctl restart "$SERVICE_NAME"
    sleep 2
    log_success "服务已启动并设置开机自启: $SERVICE_NAME"
}

# -----------------------------------------------------------------------------
# 健康检查（最多 30 秒）
# -----------------------------------------------------------------------------
health_check() {
    INSTALL_STAGE="健康检查"
    log_step "[9/9] 健康检查（最长 ${HEALTH_TIMEOUT}s）..."
    local code="000" waited=0 ok=0 probe=""

    while (( waited < HEALTH_TIMEOUT )); do
        code="$(http_code "http://127.0.0.1:${PORT}/")"
        if [[ "$code" == "200" ]]; then
            ok=1
            break
        fi
        sleep 2
        waited=$((waited + 2))
    done

    if [[ "$ok" == "1" ]]; then
        log_success "GET / -> 200（用时约 ${waited}s）"
    else
        log_error "GET / -> $code（${HEALTH_TIMEOUT}s 内未就绪）"
        log_error "  排查：journalctl -u $SERVICE_NAME --no-pager -n 50"
        return 1
    fi

    # API 探活：错误口令应返回 1006 信封，证明认证链路正常
    if command -v curl >/dev/null 2>&1; then
        probe="$(curl -s --max-time 8 -X POST "http://127.0.0.1:${PORT}/api/v1/auth/login" \
            -H 'Content-Type: application/json' \
            -d '{"username":"__probe__","password":"__probe__"}' 2>/dev/null || true)"
    fi
    if printf '%s' "$probe" | grep -q '"code":1006'; then
        log_success "API 探活正常（错误口令返回 code=1006）"
    else
        log_warn "API 探活未返回 1006 信封，响应: ${probe:0:160}"
        log_warn "  请手工确认：curl -s -X POST http://127.0.0.1:$PORT/api/v1/auth/login -H 'Content-Type: application/json' -d '{}'"
    fi

    # 单 worker 校验（多 worker 会导致并发管控状态错乱）
    # 说明：用 wc -l 而非 grep -c，避免 0 命中时 grep 退出码非 0 影响取值
    local workers
    workers="$(ps -ef | grep '[u]vicorn app.main:app' | grep "port $PORT" | wc -l | tr -d ' ')"
    if [[ "${workers:-0}" == "1" ]]; then
        log_success "单 worker 校验通过（进程数=1）"
    else
        log_warn "检测到 uvicorn 进程数=${workers:-0}（期望 1），请检查是否有残留进程"
    fi

    # 服务状态
    log_info "systemctl is-active $SERVICE_NAME -> $(systemctl is-active "$SERVICE_NAME" 2>/dev/null || true)"
    return 0
}

# -----------------------------------------------------------------------------
# 安装摘要
# -----------------------------------------------------------------------------
print_summary() {
    local ip
    ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
    [[ -n "$ip" ]] || ip="<服务器IP>"

    log_line
    log_success "===== Pritunl 管控平台安装完成 ====="
    log_line
    printf '%b访问地址%b    : http://%s:%s\n' "$C_BOLD" "$C_RESET" "$ip" "$PORT"
    printf '%b管理员账号%b  : %s\n' "$C_BOLD" "$C_RESET" "$ADMIN_USER"
    if [[ "$GENERATED_PASSWORD" == "1" ]]; then
        printf '%b管理员口令%b  : %s%b（随机生成，请立即保存并登录后修改）%b\n' \
            "$C_BOLD" "$C_RESET" "$C_WARN" "$ADMIN_PASSWORD" "$C_RESET"
    else
        printf '%b管理员口令%b  : %s（来自参数/环境变量，未打印来源）\n' "$C_BOLD" "$C_RESET" "******"
    fi
    printf '%b安装目录%b    : %s\n' "$C_BOLD" "$C_RESET" "$INSTALL_DIR"
    printf '%b服务端口%b    : %s\n' "$C_BOLD" "$C_RESET" "$PORT"
    printf '%b服务名称%b    : %s\n' "$C_BOLD" "$C_RESET" "$SERVICE_NAME"
    printf '%b程序版本%b    : %s\n' "$C_BOLD" "$C_RESET" "${RESOLVED_VERSION:-未知}"
    printf '%b配置文件%b    : %s/.env（权限 600）\n' "$C_BOLD" "$C_RESET" "$INSTALL_DIR"
    printf '%b日志文件%b    : journalctl -u %s -f\n' "$C_BOLD" "$C_RESET" "$SERVICE_NAME"
    printf '%b安装日志%b    : %s\n' "$C_BOLD" "$C_RESET" "$LOG_FILE"
    log_line
    cat <<EOF
常用运维命令：
  systemctl status $SERVICE_NAME           # 查看状态
  systemctl restart $SERVICE_NAME          # 改 .env 后必须重启
  journalctl -u $SERVICE_NAME -f           # 实时日志
  bash $INSTALL_DIR/deploy/backup.sh       # 备份（代码 + .env + 数据库）

下一步建议：
  1) 立即用上述口令登录 http://$ip:$PORT 并在「修改密码」中改掉初始口令
  2) 开放防火墙（仅放行内网段）：
     firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.19.0.0/16" port protocol="tcp" port="$PORT" accept'
     firewall-cmd --reload
  3) 跑一次冒烟测试：
     cd $INSTALL_DIR/backend && ../venv/bin/python ../tests/smoke_test.py --base-url http://127.0.0.1:$PORT
EOF
    log_line
}

# -----------------------------------------------------------------------------
# 安装主流程
# -----------------------------------------------------------------------------
do_install() {
    local mode="$1"   # install | upgrade
    log_line
    log_info "Pritunl 管控平台一键安装脚本 v$SCRIPT_VERSION（模式: $mode）"
    log_info "安装目录: $INSTALL_DIR | 端口: $PORT | 服务名: $SERVICE_NAME"
    log_info "日志文件: $LOG_FILE"

    precheck
    prepare_package
    verify_package
    backup_existing
    install_files
    setup_venv
    generate_env
    deploy_frontend
    install_service
    start_service

    if health_check; then
        print_summary
        return 0
    fi
    log_error "健康检查未通过，请按上方提示排查后重跑本脚本（幂等，可安全重跑）"
    return 1
}

# -----------------------------------------------------------------------------
# 卸载
# -----------------------------------------------------------------------------
do_uninstall() {
    INSTALL_STAGE="卸载"
    log_line

    # QA-08 防护（在任何破坏性动作之前校验）：
    #   (1) 非默认实例一律拒绝删库——库名 pritunl_control 硬编码且多实例共享，
    #       对隔离实例删库会波及生产实例；
    #   (2) 删库必须同时给出二次确认令牌 --confirm-drop-db=pritunl_control，--yes 不豁免。
    if [[ "$PURGE_DB" == "1" ]]; then
        if [[ "$INSTALL_DIR" != "$DEFAULT_DIR" || "$SERVICE_NAME" != "$DEFAULT_SERVICE" ]]; then
            log_error "--purge-db 仅允许在默认生产实例（$DEFAULT_DIR / $DEFAULT_SERVICE）上使用"
            log_error "  当前实例：目录=$INSTALL_DIR 服务名=$SERVICE_NAME"
            log_error "  原因：数据库 pritunl_control 为多实例共享且无法隔离，删除将波及所有实例"
            log_error "  其他场景请手工执行：mongosh --eval 'db.getSiblingDB(\"pritunl_control\").dropDatabase()'"
            exit 1
        fi
        if [[ "$CONFIRM_DROP_DB" != "pritunl_control" ]]; then
            log_error "--purge-db 需要二次确认令牌（--yes 不豁免）："
            log_error "  quick_start.sh uninstall --purge-db --confirm-drop-db=pritunl_control"
            log_error "  该操作不可恢复，会清空 admins / api_tokens / audit_logs"
            exit 1
        fi
        log_warn "已通过删库二次确认（--confirm-drop-db=pritunl_control），该操作不可恢复"
    fi

    log_warn "即将卸载服务：$SERVICE_NAME（安装目录：$INSTALL_DIR）"
    # --yes 视为已确认卸载（用户显式执行了 uninstall），但数据删除仍默认保留
    if [[ "$ASSUME_YES" != "1" ]] && ! prompt_yes_no "确认卸载？[y/N] " "n"; then
        log_info "已取消卸载"
        return 0
    fi

    if systemctl list-unit-files "${SERVICE_NAME}.service" >/dev/null 2>&1; then
        systemctl stop "$SERVICE_NAME" 2>/dev/null || true
        systemctl disable "$SERVICE_NAME" 2>/dev/null || true
        log_success "服务已停止并取消自启: $SERVICE_NAME"
    else
        log_warn "未发现服务 $SERVICE_NAME，跳过停止"
    fi

    local unit="/etc/systemd/system/${SERVICE_NAME}.service"
    if [[ -f "$unit" ]]; then
        rm -f "$unit"
        log_success "unit 已删除: $unit"
    fi
    systemctl daemon-reload
    log_success "systemd daemon-reload 完成"

    # 默认保留数据与程序（安全默认值）；--purge 才删除安装目录
    local remove_dir=0
    if [[ "$PURGE" == "1" ]]; then
        remove_dir=1
    elif prompt_yes_no "是否删除安装目录 $INSTALL_DIR？（默认保留）[y/N] " "n"; then
        remove_dir=1
    fi
    if [[ "$remove_dir" == "1" ]]; then
        # QA-11：若父目录是安装时自动创建的，且删完安装目录后为空，则一并清理空壳。
        # 用 rmdir（只能删空目录）+ 白名单保护，避免误删既有目录。
        local auto_parent=""
        if [[ -f "$INSTALL_DIR/.pcc_created_parent" ]]; then
            auto_parent="$(head -1 "$INSTALL_DIR/.pcc_created_parent" 2>/dev/null | tr -d '\r\n')"
        fi
        rm -rf "$INSTALL_DIR"
        log_success "安装目录已删除: $INSTALL_DIR"
        if [[ -n "$auto_parent" && -d "$auto_parent" ]]; then
            case "$auto_parent" in
                /|/opt|/usr|/var|/tmp|/home|/root|/srv|/data) : ;;   # 保护常见根级目录
                *)
                    if rmdir "$auto_parent" 2>/dev/null; then
                        log_success "已清理安装时自动创建的空父目录: $auto_parent"
                    else
                        log_info "父目录非空，保留: $auto_parent"
                    fi
                    ;;
            esac
        fi
    else
        log_info "已保留安装目录: $INSTALL_DIR"
    fi

    # 数据库删除：--purge-db 或交互确认（默认保留）
    if command -v mongosh >/dev/null 2>&1 || command -v mongo >/dev/null 2>&1; then
        local remove_db=0
        if [[ "$PURGE_DB" == "1" ]]; then
            remove_db=1
            log_warn "已指定 --purge-db，将删除数据库 pritunl_control（不可恢复）"
        elif prompt_yes_no "是否删除数据库 pritunl_control？（默认保留）[y/N] " "n"; then
            remove_db=1
        fi
        if [[ "$remove_db" == "1" ]]; then
            local sh
            sh="$(command -v mongosh || command -v mongo)"
            "$sh" --quiet --eval 'db.getSiblingDB("pritunl_control").dropDatabase()' >/dev/null 2>&1 \
                && log_success "数据库 pritunl_control 已删除" \
                || log_warn "数据库删除失败（可手工执行 mongosh --eval 'db.getSiblingDB(\"pritunl_control\").dropDatabase()'）"
        else
            log_info "已保留数据库 pritunl_control"
        fi
    else
        log_info "未检测到 mongosh/mongo，跳过数据库处理（数据保留）"
    fi

    log_success "卸载完成"
}

# -----------------------------------------------------------------------------
# 状态
# -----------------------------------------------------------------------------
do_status() {
    INSTALL_STAGE="查看状态"
    log_line
    local ver="未知"
    if [[ -f "$INSTALL_DIR/VERSION" ]]; then
        ver="$(head -1 "$INSTALL_DIR/VERSION" | tr -d '\r\n')"
    fi

    printf '%b安装目录%b  : %s\n' "$C_BOLD" "$C_RESET" "$INSTALL_DIR"
    printf '%b服务名称%b  : %s\n' "$C_BOLD" "$C_RESET" "$SERVICE_NAME"
    printf '%b服务端口%b  : %s\n' "$C_BOLD" "$C_RESET" "$PORT"
    printf '%b程序版本%b  : %s\n' "$C_BOLD" "$C_RESET" "$ver"

    local active enabled
    active="$(systemctl is-active "$SERVICE_NAME" 2>/dev/null || true)"
    enabled="$(systemctl is-enabled "$SERVICE_NAME" 2>/dev/null || true)"
    [[ -n "$active" ]] || active="inactive"
    [[ -n "$enabled" ]] || enabled="disabled"
    printf '%b服务状态%b  : %s（开机自启: %s）\n' "$C_BOLD" "$C_RESET" "$active" "$enabled"
    printf '%b.env 权限%b  : %s\n' "$C_BOLD" "$C_RESET" "$(stat -c '%a' "$INSTALL_DIR/.env" 2>/dev/null || echo '不存在')"
    printf '%b进程数%b    : %s（期望 1）\n' "$C_BOLD" "$C_RESET" "$(ps -ef | grep '[u]vicorn app.main:app' | grep "port $PORT" | wc -l | tr -d ' ')"

    local code
    code="$(http_code "http://127.0.0.1:${PORT}/")"
    if [[ "$code" == "200" ]]; then
        log_success "健康检查: GET / -> 200（正常）"
    else
        log_warn "健康检查: GET / -> $code（未就绪）"
    fi
    log_line
}

# -----------------------------------------------------------------------------
# 入口
# -----------------------------------------------------------------------------
main() {
    parse_args "$@"
    # upgrade 语义守卫：目录不存在时很可能是 --dir 手误，若按 install 处理会在错误路径
    # 建一套全新实例并打印新随机口令，返回「成功」——必须提前报错。
    if [[ "$SUBCOMMAND" == "upgrade" && ! -d "$INSTALL_DIR" ]]; then
        log_error "upgrade 要求安装目录已存在: $INSTALL_DIR"
        log_error "  该目录不存在——请确认 --dir 是否手误；全新安装请用 install"
        exit 1
    fi
    case "$SUBCOMMAND" in
        help)    print_help ;;
        version) print_version ;;
        status)  do_status ;;
        uninstall) do_uninstall ;;
        upgrade) do_install "upgrade" ;;
        install) do_install "install" ;;
    esac
}

main "$@"
