#!/usr/bin/env bash
# ==============================================================================
# bt_ols_stack_setup.sh - 全新 Linux 服务器（Debian / Ubuntu）一键部署
#
#   宝塔面板 -> OpenLiteSpeed -> PHP 8.3 (lsphp + 面板 PHP)
#   -> PHP 扩展 (redis / opcache / imagick / mbstring / msgpack)
#   -> 独立 Redis -> MySQL 5.7
#
# 特点：
#   * 单文件、交互式：输入 IP / 端口 / 用户 / SSH 密码即可自动跑完
#   * 长时编译安装带“失败快停”监视，绝不在已失败的安装上空等
#   * 每步幂等：已装组件自动跳过，可安全重复执行
#   * SSH 密码只存在于本次进程环境，不写入任务日志
#
# 用法：
#   bash bt_ols_stack_setup.sh
#   bash bt_ols_stack_setup.sh --host 1.2.3.4 --yes --skip-mysql
#   bash bt_ols_stack_setup.sh --help
# ==============================================================================
set -uo pipefail

SCRIPT_VERSION="1.0.0"

if [ -t 1 ]; then
  R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; C=$'\033[36m'; B=$'\033[1m'; N=$'\033[0m'
else
  R=; G=; Y=; C=; B=; N=
fi
say()  { printf '%s\n' "$*"; }
step() { printf '\n%s===== %s =====%s\n' "$B" "$*" "$N"; }
ok()   { printf '%s[ OK ]%s %s\n' "$G" "$N" "$*"; }
warn() { printf '%s[warn]%s %s\n' "$Y" "$N" "$*"; }
err()  { printf '%s[FAIL]%s %s\n' "$R" "$N" "$*" >&2; }
info() { printf '%s[info]%s %s\n' "$C" "$N" "$*"; }

usage() {
  cat <<'USAGE'
全新服务器一键部署：宝塔面板 + OpenLiteSpeed + PHP 8.3 + 扩展 + Redis + MySQL 5.7
（仅支持 Debian / Ubuntu，需要 root 或免密 sudo）

交互式：
  bash bt_ols_stack_setup.sh

无人值守：
  bash bt_ols_stack_setup.sh --host 1.2.3.4 --ssh-user root --yes

参数：
  --host HOST           服务器 IP / 域名
  --local               在对端本机直接执行（脚本已上传到目标机时用，不需要 SSH）
  --ssh-port PORT       SSH 端口（默认 22）
  --ssh-user USER       SSH 用户（默认 root）
  --ssh-pass PASS       不推荐：命令行明文密码
  --ssh-pass-file FILE  从文件首行读取 SSH 密码
  --panel-port PORT     宝塔面板端口（默认宝塔随机）
  --panel-user USER     宝塔面板账号（默认宝塔随机）
  --panel-pass PASS     宝塔面板密码（默认宝塔随机生成）
  --no-ssl              面板使用 HTTP（默认 HTTPS 自签证书）
  --with-mysql          安装 MySQL 5.7（源码编译，30-90 分钟）
  --skip-mysql          跳过 MySQL 5.7
  --skip-extras         跳过 PHP 扩展安装
  --timeout-scale N     所有超时按 N 倍放大（慢机器用）
  --dry-run             只检测并输出计划，不做任何改动
  --yes                 跳过最终确认
  --log FILE            本地日志文件
  -h, --help            显示帮助

退出码：0 成功 | 1 前置/校验失败 | 2 安装失败或超时
USAGE
}

# ------------------------------ 参数与交互 ------------------------------------
HOST=""; SSH_PORT="22"; SSH_USER="root"; SSH_PASS="${SSHPASS:-}"; SSH_PASS_FILE=""
PANEL_PORT=""; PANEL_USER=""; PANEL_PASS=""; PANEL_SSL="1"
WANT_MYSQL="ask"; WANT_EXTRAS="1"; ASSUME_YES="0"; DRY_RUN="0"; TMO_SCALE="1"; LOG_FILE=""
LOCAL_MODE="0"
REMOTE_RUN="bash -s"; REMOTE_HOSTNAME=""; CTL_DIR=""

while [ "$#" -gt 0 ]; do
  case "$1" in
    --host) HOST="${2:-}"; shift 2 ;;
    --local) LOCAL_MODE="1"; shift ;;
    --ssh-port) SSH_PORT="${2:-}"; shift 2 ;;
    --ssh-user) SSH_USER="${2:-}"; shift 2 ;;
    --ssh-pass) SSH_PASS="${2:-}"; shift 2 ;;
    --ssh-pass-file) SSH_PASS_FILE="${2:-}"; shift 2 ;;
    --panel-port) PANEL_PORT="${2:-}"; shift 2 ;;
    --panel-user) PANEL_USER="${2:-}"; shift 2 ;;
    --panel-pass) PANEL_PASS="${2:-}"; shift 2 ;;
    --no-ssl) PANEL_SSL="0"; shift ;;
    --with-mysql) WANT_MYSQL="1"; shift ;;
    --skip-mysql) WANT_MYSQL="0"; shift ;;
    --skip-extras) WANT_EXTRAS="0"; shift ;;
    --timeout-scale) TMO_SCALE="${2:-1}"; shift 2 ;;
    --dry-run) DRY_RUN="1"; shift ;;
    --yes|-y) ASSUME_YES="1"; shift ;;
    --log) LOG_FILE="${2:-}"; shift 2 ;;
    -h|--help) usage; exit 0 ;;
    *) err "未知参数: $1"; usage; exit 1 ;;
  esac
done

case "$TMO_SCALE" in *[!0-9]*|"") err "--timeout-scale 必须是正整数"; exit 1 ;; esac
case "$SSH_PORT" in *[!0-9]*|"") err "--ssh-port 必须是数字"; exit 1 ;; esac
[ -z "$PANEL_PORT" ] || case "$PANEL_PORT" in *[!0-9]*) err "--panel-port 必须是数字"; exit 1 ;; esac

if [ -n "$SSH_PASS_FILE" ]; then
  [ -r "$SSH_PASS_FILE" ] || { err "读不到 --ssh-pass-file: $SSH_PASS_FILE"; exit 1; }
  SSH_PASS="$(head -n1 "$SSH_PASS_FILE")"
fi

is_tty() { [ -t 0 ]; }

if [ "$LOCAL_MODE" = "1" ]; then
  [ -z "${HOST:-}" ] || { err "--local 不能与 --host 同时使用"; exit 1; }
  HOST="$(hostname 2>/dev/null || echo localhost)"
else
  if [ -z "$HOST" ] && is_tty; then
    printf '%s服务器 IP / 域名:%s ' "$B" "$N"; read -r HOST
  fi
  [ -n "$HOST" ] || { err "必须提供服务器 IP（--host 或交互输入）"; exit 1; }

  if is_tty; then
    printf 'SSH 端口 [%s]: ' "$SSH_PORT"; read -r _v; [ -n "${_v:-}" ] && SSH_PORT="$_v"
    printf 'SSH 用户 [%s]: ' "$SSH_USER"; read -r _v; [ -n "${_v:-}" ] && SSH_USER="$_v"
  fi

  if [ -z "$SSH_PASS" ] && is_tty; then
    printf 'SSH 密码: '; read -rs SSH_PASS; echo
  fi
  [ -n "$SSH_PASS" ] || { err "必须提供 SSH 密码（交互输入 / --ssh-pass / --ssh-pass-file）"; exit 1; }
fi

if [ "$WANT_MYSQL" = "ask" ]; then
  if is_tty && [ "$ASSUME_YES" != "1" ]; then
    printf '是否安装 MySQL 5.7（源码编译，可能 30-90 分钟）? [Y/n]: '; read -r _v
    case "${_v:-Y}" in n|N|no|NO) WANT_MYSQL="0" ;; *) WANT_MYSQL="1" ;; esac
  else
    WANT_MYSQL="1"
  fi
fi

if [ "$PANEL_SSL" = "1" ] && is_tty && [ "$ASSUME_YES" != "1" ]; then
  printf '宝塔面板使用 HTTPS 自签证书? [Y/n]: '; read -r _v
  case "${_v:-Y}" in n|N|no|NO) PANEL_SSL="0" ;; esac
fi

TS="$(date +%Y%m%d-%H%M%S)"
[ -n "$LOG_FILE" ] || LOG_FILE="./bt-ols-setup-${HOST//[^A-Za-z0-9._-]/_}-${TS}.log"

# ------------------------------ 连接与日志 ------------------------------------
cleanup() {
  if [ -n "$CTL_DIR" ] && [ -S "$CTL_DIR/ctl" ]; then
    ssh -O exit -o ControlPath="$CTL_DIR/ctl" "$SSH_USER@$HOST" >/dev/null 2>&1 || true
  fi
  unset SSHPASS
}
trap 'cleanup' EXIT INT TERM

ssh_do() {  # 从 stdin 读脚本，在远端执行
  if [ -n "${SSHPASS:-}" ]; then
    sshpass -e ssh "${SSH_OPTS[@]}" "$SSH_USER@$HOST" "$REMOTE_RUN"
  else
    ssh "${SSH_OPTS[@]}" "$SSH_USER@$HOST" "$REMOTE_RUN"
  fi
}
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >>"$LOG_FILE"; }
remote_has() { [ "$(ssh_do <<<"test -e '$1' && echo yes || echo no" 2>/dev/null)" = "yes" ]; }

need_local_tools() {
  command -v ssh >/dev/null 2>&1 || { err "本机缺少 ssh 客户端"; exit 1; }
  if ! command -v sshpass >/dev/null 2>&1; then
    warn "本机缺少 sshpass，尝试自动安装 ..."
    command -v apt-get >/dev/null 2>&1 && { apt-get install -y sshpass >/dev/null 2>&1 || sudo -n apt-get install -y sshpass >/dev/null 2>&1 || true; }
    command -v yum >/dev/null 2>&1 && yum install -y sshpass >/dev/null 2>&1
    command -v sshpass >/dev/null 2>&1 || { err "请先安装 sshpass（例如 apt-get install -y sshpass）"; exit 1; }
  fi
}

connect() {
  CTL_DIR="$(mktemp -d "${TMPDIR:-/tmp}/btols.XXXXXX")"; chmod 700 "$CTL_DIR"
  SSH_OPTS=(
    -p "$SSH_PORT"
    -o ConnectTimeout=15 -o ServerAliveInterval=15 -o ServerAliveCountMax=4
    -o StrictHostKeyChecking=no -o UserKnownHostsFile=/tmp/codex_bt_ols_php_redis_known_hosts
    -o ControlMaster=auto -o ControlPath="$CTL_DIR/ctl" -o ControlPersist=600 -o LogLevel=ERROR
  )
  export SSHPASS="$SSH_PASS"
  local out
  out="$(ssh_do <<<'echo __OK__; hostname; id -u')" || { err "SSH 连接失败：$SSH_USER@$HOST:$SSH_PORT"; exit 1; }
  printf '%s\n' "$out" | grep -q __OK__ || { err "SSH 认证失败（检查用户/密码/端口）"; exit 1; }
  REMOTE_HOSTNAME="$(printf '%s\n' "$out" | sed -n 2p)"
  local uid; uid="$(printf '%s\n' "$out" | sed -n 3p)"
  if [ "$uid" != "0" ]; then
    if ssh_do <<<'sudo -n true' >/dev/null 2>&1; then REMOTE_RUN="sudo -n bash -s"
    else err "远端用户 $SSH_USER 不是 root 且无免密 sudo，无法安装"; exit 1; fi
  fi
}

# ------------------------------ 通用安装监视器 --------------------------------
# run_watch LABEL LAUNCH_FILE LOG EXPECT TIMEOUT STALL SCAN
# LAUNCH_FILE 是本地文件，内容为远端启动脚本，最后必须输出 PID=<pid>
run_watch() {
  local label="$1" launch_file="$2" log_remote="$3" expect="$4" tmo="$5" stall="$6" scan="${7:-1}"
  local tmpl_status out pid
  tmpl_status="$(mktemp)"
  cat >"$tmpl_status" <<'TPL'
set -u
if kill -0 __PID__ 2>/dev/null; then echo "PROC=alive"; else echo "PROC=gone"; fi
echo "SIZE=$(stat -c %s '__LOG__' 2>/dev/null || echo 0)"
if [ -z "__EXPECT__" ]; then echo "ART=skip";
elif [ -e "__EXPECT__" ]; then echo "ART=yes"; else echo "ART=no"; fi
echo "SUCCESS=$(grep -c 'Successify' '__LOG__' 2>/dev/null || true)"
if [ "__SCAN__" = "1" ]; then
  echo "ERR=$(grep -Ec 'make: \*\*\*|cannot find -l|collect2: error|configure: error|ERROR: .*install failed|fatal error:|No such file or directory \(.*\.c' '__LOG__' 2>/dev/null || true)"
  grep -E 'make: \*\*\*|cannot find -l|collect2: error|configure: error|ERROR: .*install failed|fatal error:' '__LOG__' 2>/dev/null | tail -1 | cut -c1-220 | sed 's/^/LASTERR=/'
else
  echo "ERR=0"
fi
tail -1 '__LOG__' 2>/dev/null | cut -c1-140 | sed 's/^/LAST=/'
TPL
  sed -i -e "s#__LOG__#${log_remote}#g" -e "s#__EXPECT__#${expect}#g" -e "s#__SCAN__#${scan}#g" "$tmpl_status"

  out="$(ssh_do <"$launch_file")" || { err "${label}：无法在远端启动（SSH 断开）"; rm -f "$tmpl_status"; return 2; }
  pid="$(printf '%s\n' "$out" | sed -n 's/^PID=//p' | tail -1)"
  if [ -z "$pid" ]; then err "${label}：启动失败：$out"; rm -f "$tmpl_status"; return 2; fi
  info "远端 PID=${pid}，日志 ${log_remote}"
  sed -i "s#__PID__#${pid}#g" "$tmpl_status"

  local start now last_size=-1 last_change gone_since="" poll=0
  local status_out proc size art succ errm lasterr last base delta
  start="$(date +%s)"; last_change="$start"
  while :; do
    now="$(date +%s)"
    if [ "$((now - start))" -gt "$tmo" ]; then
      err "${label}：超时（${tmo}s）"; ssh_do <<<"tail -25 '$log_remote'" 2>/dev/null | tee -a "$LOG_FILE" || true
      rm -f "$tmpl_status"; return 1
    fi
    poll=$((poll + 1))
    if ! status_out="$(ssh_do <"$tmpl_status")"; then warn "SSH 轮询失败，20s 后重试"; sleep 20; continue; fi
    proc="$(sed -n 's/^PROC=//p' <<<"$status_out" | tail -1)"
    size="$(sed -n 's/^SIZE=//p' <<<"$status_out" | tail -1)"
    art="$(sed -n 's/^ART=//p' <<<"$status_out" | tail -1)"
    succ="$(sed -n 's/^SUCCESS=//p' <<<"$status_out" | tail -1)"
    errm="$(sed -n 's/^ERR=//p' <<<"$status_out" | tail -1)"
    lasterr="$(sed -n 's/^LASTERR=//p' <<<"$status_out" | tail -1)"
    last="$(sed -n 's/^LAST=//p' <<<"$status_out" | tail -1)"

    base="$last_size"; [ "$base" -lt 0 ] && base=0
    delta="$(( ${size:-0} - base ))"
    printf '  [%02d] %5ss  proc=%-5s 产物=%-4s 日志=%sB(+%s) 报错=%s\n' \
      "$poll" "$((now - start))" "${proc:-?}" "${art:-?}" "${size:-0}" "$delta" "${errm:-0}"
    [ -n "${last:-}" ] && printf '         last: %s\n' "$last"

    if [ "${errm:-0}" -gt 0 ] 2>/dev/null; then
      if [ "$art" = "yes" ]; then err "${label}：产物已存在但日志报错，需人工确认"; else err "${label}：安装失败"; fi
      printf '         error: %s\n' "${lasterr:-未知}"
      ssh_do <<<"tail -25 '$log_remote'" 2>/dev/null | tee -a "$LOG_FILE" || true
      rm -f "$tmpl_status"; return 1
    fi
    if [ "$art" = "yes" ] && { [ "$proc" = "gone" ] || [ "${succ:-0}" -gt 0 ]; }; then
      ok "${label}：完成（产物 ${expect}）"; log "ok: ${label}"
      rm -f "$tmpl_status"; return 0
    fi
    [ "${size:-0}" != "$last_size" ] && last_change="$now"
    last_size="${size:-0}"

    if [ "$proc" = "gone" ]; then
      if [ -z "$gone_since" ]; then gone_since="$now"; printf '         进程已退出，等 20s 确认产物 ...\n'
      elif [ "$((now - gone_since))" -ge 20 ]; then
        err "${label}：进程退出但未生成产物（${expect}）"
        ssh_do <<<"tail -25 '$log_remote'" 2>/dev/null | tee -a "$LOG_FILE" || true
        rm -f "$tmpl_status"; return 1
      fi
    else
      gone_since=""
    fi

    if [ "$((now - last_change))" -ge "$stall" ]; then
      err "${label}：日志 ${stall}s 无增长，判定卡死"
      ssh_do <<<"tail -25 '$log_remote'" 2>/dev/null | tee -a "$LOG_FILE" || true
      rm -f "$tmpl_status"; return 1
    fi
    sleep 20
  done
}

# run_install LABEL TYPE NAME VERSION EXPECT TIMEOUT STALL SCAN
run_install() {
  local label="$1" type="$2" name="$3" ver="$4" expect="$5" tmo="$6" stall="$7" scan="${8:-1}"
  local log_remote="/tmp/bt_install_${name}_${ver}.log"
  local f; f="$(mktemp)"
  cat >"$f" <<'TPL'
set -u
cd /www/server/panel/install || { echo "LAUNCH_ERR=no_install_dir"; exit 0; }
: > "__LOG__"
setsid nohup bash install_soft.sh __TYPE__ install __NAME__ __VERSION__ >> "__LOG__" 2>&1 < /dev/null &
echo "PID=$!"
TPL
  sed -i -e "s#__TYPE__#${type}#g" -e "s#__NAME__#${name}#g" \
         -e "s#__VERSION__#${ver}#g" -e "s#__LOG__#${log_remote}#g" "$f"
  log "install: ${name} ${ver} (type=${type}) expect=${expect}"
  run_watch "$label" "$f" "$log_remote" "$expect" "$tmo" "$stall" "$scan"
  local rc=$?
  rm -f "$f"
  return $rc
}

# ==============================================================================
# 第二部分：远端探测 / 面板 / 预置修复 / 组件安装 / 校验 / 主流程
# ==============================================================================

scaled() { echo $(( $1 * TMO_SCALE )); }
pval()   { printf '%s\n' "$1" | sed -n "s/^$2=//p" | tail -1; }
shq()    { local s="$1"; printf "'%s'" "${s//\'/\'\\\'\'}"; }
strip_ansi() { sed -e 's/\x1b\[[0-9;]*[A-Za-z]//g' -e 's/\x1b\[[0-9]*;//g'; }

# 把一段远端脚本用 base64 下发，后台 setsid 运行，日志落盘，再用 run_watch 监视。
# 脚本自身必须在成功时 touch 一个标记文件（作为 EXPECT），失败则不 touch。
# run_remote_script LABEL SCRIPT_TEXT EXPECT TMO STALL [SCAN]
run_remote_script() {
  local label="$1" text="$2" expect="$3" tmo="$4" stall="$5" scan="${6:-1}"
  local slug rs log_remote b64 f
  slug="$(printf '%s' "$label" | tr -c 'A-Za-z0-9' '_' | sed -e 's/^_*//' -e 's/_*$//' | cut -c1-32)"
  [ -n "$slug" ] || slug="step$$"
  rs="/tmp/bt_step_${slug}.sh"
  log_remote="/tmp/bt_step_${slug}.log"
  b64="$(printf '%s\n' "$text" | base64 | tr -d '\n')"
  f="$(mktemp)"
  {
    echo 'set -u'
    printf 'echo %s | base64 -d > %s\n' "$(shq "$b64")" "$(shq "$rs")"
    [ -z "$expect" ] || printf 'rm -f %s\n' "$(shq "$expect")"
    printf ': > %s\n' "$(shq "$log_remote")"
    printf 'setsid nohup bash %s >> %s 2>&1 < /dev/null &\n' "$(shq "$rs")" "$(shq "$log_remote")"
    echo 'echo "PID=$!"'
  } > "$f"
  run_watch "$label" "$f" "$log_remote" "$expect" "$tmo" "$stall" "$scan"
  local rc=$?
  rm -f "$f"
  return $rc
}

# ------------------------------ 远端环境探测 ----------------------------------
rmt_env_probe() {
  ssh_do <<'EOF'
. /etc/os-release 2>/dev/null || true
echo "ID=${ID:-unknown}"
echo "CODENAME=${VERSION_CODENAME:-}"
echo "PRETTY=${PRETTY_NAME:-unknown}"
echo "ARCH=$(uname -m)"
echo "MEM_MB=$(awk '/^MemTotal:/{print int($2/1024)}' /proc/meminfo)"
echo "SWAP_MB=$(awk '/^SwapTotal:/{print int($2/1024)}' /proc/meminfo)"
echo "AVAIL_MB=$(awk '/^MemAvailable:/{print int($2/1024)}' /proc/meminfo)"
echo "DISK_MB=$(df -Pm / | awk 'NR==2{print $4}')"
echo "PANEL=$(test -f /www/server/panel/BT-Panel && echo yes || echo no)"
echo "OLS=$(test -x /usr/local/lsws/bin/lswsctrl && echo yes || echo no)"
echo "LSPHP=$(test -x /usr/local/lsws/lsphp83/bin/php && echo yes || echo no)"
echo "PANELPHP=$(test -x /www/server/php/83/bin/php && echo yes || echo no)"
echo "REDIS=$(test -x /www/server/redis/src/redis-server && echo yes || echo no)"
echo "MYSQL=$(test -x /www/server/mysql/bin/mysql && echo yes || echo no)"
echo "NFTC=$(command -v nft >/dev/null 2>&1 && echo yes || echo no)"
echo "VHOSTS=$(ls -1 /www/server/panel/vhost/openlitespeed 2>/dev/null | wc -l)"
EOF
}

# ------------------------------ 宝塔面板 --------------------------------------
ensure_panel() {
  if remote_has /www/server/panel/BT-Panel; then ok "宝塔面板已存在，跳过安装"; return 0; fi
  step "安装宝塔面板"
  if [ "$DRY_RUN" = "1" ]; then info "[dry-run] 将下载 install_panel.sh 并以 -y 安装面板"; return 0; fi

  local out
  out="$(ssh_do <<'EOF'
set -u
url="https://download.bt.cn/install/install_panel.sh"
ok=""
for i in 1 2 3 4 5; do
  if wget -q --no-check-certificate -T 40 -O /tmp/bt_install_panel.sh "$url" \
     && [ "$(stat -c %s /tmp/bt_install_panel.sh 2>/dev/null || echo 0)" -gt 2000 ]; then ok="yes"; break; fi
  echo "download retry $i ..."
  sleep 5
done
[ -n "$ok" ] || { echo "DOWNLOAD=fail"; exit 0; }
grep -q 'BT-Panel\|install_6.0\|bt_install\|python' /tmp/bt_install_panel.sh || { echo "DOWNLOAD=bad"; exit 0; }
echo "DOWNLOAD=ok size=$(stat -c %s /tmp/bt_install_panel.sh)"
EOF
)" || { err "下载宝塔安装脚本失败（SSH 断开）"; return 1; }
  printf '%s\n' "$out" | grep -q 'DOWNLOAD=ok' || { err "下载宝塔安装脚本失败：$(printf '%s' "$out" | tail -3)"; return 1; }
  ok "安装脚本已就绪"

  local args="" script marker='/tmp/bt_panel_ready.ok'
  [ -z "$PANEL_USER" ] || args="$args -u $(shq "$PANEL_USER")"
  [ -z "$PANEL_PASS" ] || args="$args -p $(shq "$PANEL_PASS")"
  [ -z "$PANEL_PORT" ] || args="$args -P $(shq "$PANEL_PORT")"
  [ "$PANEL_SSL" = "0" ] && args="$args --ssl-disable"
  script="$(cat <<EOS
set -u
bash /tmp/bt_install_panel.sh -y$args
rc=\$?
if [ -f /www/server/panel/BT-Panel ]; then
  echo "PANEL_INSTALL=ok rc=\$rc"
  touch /tmp/bt_panel_ready.ok
else
  echo "PANEL_INSTALL=fail rc=\$rc"; exit 1
fi
EOS
)"
  local rc=0
  run_remote_script "宝塔面板安装" "$script" "$marker" "$(scaled 2400)" "$(scaled 900)" 1 || rc=$?
  if [ "$rc" != "0" ]; then
    if remote_has /www/server/panel/BT-Panel; then ok "面板实际已安装成功"; rc=0; fi
  fi
  return $rc
}

# ------------------------------ 系统预置 --------------------------------------
ensure_apt_base() {
  step "安装编译基础依赖"
  if [ "$DRY_RUN" = "1" ]; then info "[dry-run] 将 apt update 并安装编译依赖"; return 0; fi
  local script marker='/tmp/bt_apt_base.ok'
  script="$(cat <<'EOS'
set -u
export DEBIAN_FRONTEND=noninteractive
printf 'Acquire::http::Timeout "30";\nAcquire::https::Timeout "30";\nAcquire::Retries "3";\n' > /etc/apt/apt.conf.d/99-btols-timeout
echo "apt update ..."
if ! apt-get update -qq > /tmp/bt_apt_update.log 2>&1; then
  echo "APT_UPDATE=fail, retry with aliyun mirror"
  for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
    [ -f "$f" ] || continue
    sed -i -e 's|http://deb.debian.org/debian|https://mirrors.aliyun.com/debian|g' \
           -e 's|http://security.debian.org/debian-security|https://mirrors.aliyun.com/debian-security|g' \
           -e 's|http://archive.ubuntu.com/ubuntu|https://mirrors.aliyun.com/ubuntu|g' \
           -e 's|http://security.ubuntu.com/ubuntu|https://mirrors.aliyun.com/ubuntu|g' \
           -e 's|http://ports.ubuntu.com/ubuntu-ports|https://mirrors.aliyun.com/ubuntu-ports|g' "$f"
  done
  apt-get update -qq > /tmp/bt_apt_update.log 2>&1 || { echo "APT_UPDATE=fail"; tail -6 /tmp/bt_apt_update.log; exit 1; }
fi
echo "APT_UPDATE=ok"
pkgs="wget curl zip unzip tar gcc g++ make cmake pkg-config autoconf automake libtool bison re2c patch \
libssl-dev libcurl4-openssl-dev libzip-dev libonig-dev libsqlite3-dev libxml2-dev libpng-dev libjpeg-dev \
libfreetype6-dev libwebp-dev libxslt1-dev libbz2-dev libreadline-dev libldap2-dev libsodium-dev libtidy-dev \
libkrb5-dev libicu-dev libargon2-dev libpcre2-dev libgmp-dev libncurses-dev"
missing=""
for p in $pkgs; do dpkg -s "$p" >/dev/null 2>&1 || missing="$missing $p"; done
if [ -n "$missing" ]; then
  echo "installing:$missing"
  apt-get install -y $missing > /tmp/bt_apt_base.log 2>&1 || { echo "APT_BASE=fail"; tail -8 /tmp/bt_apt_base.log; exit 1; }
fi
echo "APT_BASE=ok"
touch /tmp/bt_apt_base.ok
EOS
)"
  run_remote_script "编译依赖" "$script" "$marker" "$(scaled 1800)" "$(scaled 600)" 0
}

ensure_swap() { # $1 = 需要的 MB
  local need="${1:-2048}"
  step "检查交换分区（swap ≥ ${need} MB）"
  if [ "$DRY_RUN" = "1" ]; then info "[dry-run] 将确保 swap ≥ ${need} MB"; return 0; fi
  local script marker='/tmp/bt_swap.ok'
  script="$(cat <<'EOS'
set -u
NEED=__NEED__
cur() { awk '/^SwapTotal:/{print int($2/1024)}' /proc/meminfo; }
if [ "$(cur)" -ge "$NEED" ]; then echo "SWAP=ok ($(cur) MB)"; touch /tmp/bt_swap.ok; exit 0; fi
mkdir -p /www/server
SWF=/www/server/swap
if [ ! -s "$SWF" ]; then
  echo "creating swap file ${NEED}M ..."
  fallocate -l "${NEED}M" "$SWF" 2>/dev/null || dd if=/dev/zero of="$SWF" bs=1M count="$NEED" status=none
  chmod 600 "$SWF"
fi
mkswap "$SWF" > /dev/null 2>&1 || { echo "SWAP=mkswap_fail"; exit 1; }
swapon "$SWF" 2>/dev/null || true
[ "$(cur)" -ge "$NEED" ] || { echo "SWAP=activate_fail"; exit 1; }
grep -q "^$SWF " /etc/fstab 2>/dev/null || echo "$SWF none swap sw 0 0" >> /etc/fstab
echo "SWAP=added (now $(cur) MB)"
touch /tmp/bt_swap.ok
EOS
)"
  script="${script//__NEED__/$need}"
  run_remote_script "swap 配置" "$script" "$marker" "$(scaled 900)" "$(scaled 300)" 0
}

ensure_libiconv() {
  step "Debian 预置：libiconv 链接修复"
  if [ "$DRY_RUN" = "1" ]; then info "[dry-run] 将确保 -liconv 可链接"; return 0; fi
  local gate
  gate="$(ssh_do <<'EOF'
set -u
printf 'int main(void){return 0;}\n' > /tmp/bt_iconv_t.c
if gcc /tmp/bt_iconv_t.c -liconv -o /tmp/bt_iconv_t > /dev/null 2>&1; then echo "LICONV=ok"; else echo "LICONV=build"; fi
EOF
)" || { err "无法检查 libiconv 状态"; return 1; }
  if printf '%s\n' "$gate" | grep -q 'LICONV=ok'; then ok "-liconv 已可链接，跳过"; return 0; fi

  local script marker='/tmp/bt_libiconv.ok'
  script="$(cat <<'EOS'
set -u
cd /root || exit 1
echo "downloading libiconv-1.14 ..."
wget -q --no-check-certificate -T 60 -O libiconv-1.14.tar.gz https://download.bt.cn/src/libiconv-1.14.tar.gz || { echo "ICONV=download_fail"; exit 1; }
tar zxf libiconv-1.14.tar.gz || { echo "ICONV=untar_fail"; exit 1; }
cd libiconv-1.14 || exit 1
echo "configure ..."
./configure --prefix=/usr/local/libiconv --enable-static || { echo "ICONV=configure_fail"; exit 1; }
echo "make ..."
make -j1 || { echo "ICONV=make_fail"; exit 1; }
make install || { echo "ICONV=install_fail"; exit 1; }
echo "ICONV=built"
touch /tmp/bt_libiconv.ok
EOS
)"
  run_remote_script "libiconv 编译" "$script" "$marker" "$(scaled 2400)" "$(scaled 600)" 0 || return 1

  ssh_do <<'EOF'
set -u
SO=/usr/local/libiconv/lib/libiconv.so.2.5.1
[ -f "$SO" ] || SO="$(ls -1 /usr/local/libiconv/lib/libiconv.so.2* 2>/dev/null | head -1)"
[ -n "$SO" ] && [ -f "$SO" ] || { echo "LINK=no_lib"; exit 1; }
ln -sf "$SO" /usr/lib/x86_64-linux-gnu/libiconv.so
ln -sf "$SO" /usr/lib/x86_64-linux-gnu/libiconv.so.2
ldconfig
printf 'int main(void){return 0;}\n' > /tmp/bt_iconv_t.c
if gcc /tmp/bt_iconv_t.c -liconv -o /tmp/bt_iconv_t > /dev/null 2>&1; then echo "LICONV_LINK_OK"; else echo "LICONV_LINK_FAIL"; exit 1; fi
EOF
  local rc=$?
  if [ "$rc" = "0" ]; then ok "libiconv 修复完成（-liconv 已可链接）"; else err "libiconv 修复失败，PHP 编译可能报 cannot find -liconv"; fi
  return $rc
}

# ------------------------------ 面板 PHP8.3 修复 -----------------------------
# 宝塔面板 13 在 OLS 下打开「软件商店 -> PHP 8.3 -> 设置」会报
#   KeyError: 'phpinfo'  (class/ajax.py, GetPHPConfig)
# 根因：php_info() 用 os.listdir('/usr/local/lsws/') 判断版本目录，而 OLS 的目录叫
#   lsphp83 而不是 83，于是提前 returnMsg(False, 'PHP版本不存在!')，调用点再取
#   ['phpinfo']['modules'] 就 KeyError。同时 OLS 的 php.ini 不在 etc/php.ini。
fix_panel_php83() {
  step "修复面板 PHP 8.3 设置页（OLS 下的 KeyError: phpinfo）"
  if [ "$DRY_RUN" = "1" ]; then info "[dry-run] 将检查并按需修复面板 ajax.py"; return 0; fi
  local script marker='/tmp/bt_panel_php83.ok'
  script="$(cat <<'EOS'
set -u
AJAX=/www/server/panel/class/ajax.py
[ -f "$AJAX" ] || { echo "PANEL_PATCH=no_ajax_file"; exit 1; }
if grep -q '_ols_php_info' "$AJAX" 2>/dev/null; then
  echo "PANEL_PATCH=already"
  touch /tmp/bt_panel_php83.ok
  exit 0
fi
PY=/www/server/panel/pyenv/bin/python3
[ -x "$PY" ] || PY="$(command -v python3 || true)"
[ -n "$PY" ] || { echo "PANEL_PATCH=no_python"; exit 1; }
"$PY" - "$AJAX" <<'PYEOF'
import io, os, shutil, sys, time
path = sys.argv[1]
src = io.open(path, encoding='utf-8').read()
if '_ols_php_info' in src:
    print('PANEL_PATCH=already')
    sys.exit(0)
old1 = ("                    phpini_ols = self.php_info(\n"
        "                        get)['phpinfo']['modules'].lower()\n"
        "                    phpini_ols = phpini_ols.split()")
new1 = ("                    _ols_php_info = self.php_info(get)\n"
        "                    if not isinstance(_ols_php_info, dict):\n"
        "                        _ols_php_info = {}\n"
        "                    _ols_phpinfo = _ols_php_info.get('phpinfo', {})\n"
        "                    if not isinstance(_ols_phpinfo, dict):\n"
        "                        _ols_phpinfo = {}\n"
        "                    phpini_ols = str(_ols_phpinfo.get('modules', '')).lower()\n"
        "                    phpini_ols = phpini_ols.split()")
old2 = ("        infos=os.listdir(php_path)\n"
        "        if php_version not in infos:\n"
        "            return public.returnMsg(False, 'PHP版本不存在!')")
new2 = ("        infos=os.listdir(php_path)\n"
        "        if php_version not in infos and name not in infos:\n"
        "            return public.returnMsg(False, 'PHP版本不存在!')")
old3 = ("        php_bin = php_path + name + '/bin/php'\n"
        "        php_ini = php_path + name + '/etc/php.ini'\n")
new3 = (old3 +
        "        if not os.path.exists(php_ini) and len(php_version) >= 2:\n"
        "            _php_ini_ols = '{}{}/etc/php/{}.{}/litespeed/php.ini'.format(\n"
        "                php_path, name, php_version[0], php_version[1])\n"
        "            if os.path.exists(_php_ini_ols):\n"
        "                php_ini = _php_ini_ols\n")
for label, old in (('caller', old1), ('listdir', old2), ('ini_path', old3)):
    if src.count(old) != 1:
        print('PANEL_PATCH=pattern_mismatch:%s:%d' % (label, src.count(old)))
        sys.exit(2)
src = src.replace(old1, new1).replace(old2, new2).replace(old3, new3)
st = os.stat(path)
shutil.copy2(path, path + '.bak-' + time.strftime('%Y%m%d%H%M%S'))
with io.open(path, 'w', encoding='utf-8') as fh:
    fh.write(src)
os.chmod(path, st.st_mode & 0o7777)
print('PANEL_PATCH=patched')
PYEOF
[ "$?" = "0" ] || { echo "PANEL_PATCH=python_failed"; exit 1; }
rm -f /www/server/panel/class/__pycache__/ajax*.pyc 2>/dev/null
[ -x /etc/init.d/bt ] && { /etc/init.d/bt reload >/dev/null 2>&1 || true; }
echo "PANEL_PATCH=ok"
touch /tmp/bt_panel_php83.ok
EOS
)"
  if run_remote_script "面板 PHP8.3 修复" "$script" "$marker" "$(scaled 900)" "$(scaled 300)" 0; then
    ok "面板 PHP 8.3 设置页已可用（已修复或本来就正常）"
    return 0
  fi
  warn "面板补丁未应用成功：新版面板代码结构可能不同，请人工确认 class/ajax.py 的 php_info()"
  return 1
}

# ------------------------------ 面板状态刷新 ----------------------------------
refresh_panel_state() {
  step "刷新面板状态"
  if [ "$DRY_RUN" = "1" ]; then info "[dry-run] 将清理 pycache 并重启面板"; return 0; fi
  ssh_do <<'EOF' > /dev/null 2>&1 || true
rm -f /www/server/panel/class/__pycache__/ajax*.pyc 2>/dev/null
[ -x /etc/init.d/bt ] && /etc/init.d/bt restart > /dev/null 2>&1
exit 0
EOF
  ok "面板状态已刷新"
}

# ------------------------------ PHP 扩展 --------------------------------------
extras_probe() { # 输出 EXT|<module>|<yes|no>
  ssh_do <<'EOF'
L=/usr/local/lsws/lsphp83
if [ ! -x "$L/bin/php" ]; then echo "EXT|php|missing"; exit 0; fi
# 注意：opcache 在 php -m 里显示为 "Zend OPcache"，必须做小写整词匹配
MODS=" $($L/bin/php -m 2>/dev/null | tr 'A-Z' 'a-z' | tr '\n' ' ') "
for m in redis opcache imagick mbstring msgpack igbinary; do
  case "$MODS" in *" $m "*) echo "EXT|$m|yes" ;; *) echo "EXT|$m|no" ;; esac
done
EOF
}

install_php_extras() {
  step "安装 PHP 8.3 扩展（redis / imagick / msgpack / igbinary）"
  if [ "$DRY_RUN" = "1" ]; then info "[dry-run] 将从 LiteSpeed 源安装 lsphp83-* 扩展"; return 0; fi
  if ! remote_has /usr/local/lsws/lsphp83/bin/php; then err "lsphp83 不存在，无法安装扩展（请先完成 php-ols 安装）"; return 1; fi

  local probe missing
  probe="$(extras_probe)" || probe=""
  missing="$(printf '%s\n' "$probe" | sed -n 's/^EXT|\(.*\)|no$/\1/p' | tr '\n' ' ')"
  if [ -z "$missing" ]; then ok "PHP 扩展已全部就绪，跳过"; return 0; fi
  info "缺少扩展：$missing"

  local script marker='/tmp/bt_extras.ok'
  script="$(cat <<'EOS'
set -u
export DEBIAN_FRONTEND=noninteractive
# LiteSpeed apt 源（宝塔装 openlitespeed 时通常已写入，缺失则补上）
if [ ! -f /etc/apt/sources.list.d/lst_debian_repo.list ]; then
  . /etc/os-release 2>/dev/null || true
  CN="${VERSION_CODENAME:-bookworm}"
  echo "deb http://rpms.litespeedtech.com/debian/ $CN main" > /etc/apt/sources.list.d/lst_debian_repo.list
  wget -q --no-check-certificate -T 40 -O /etc/apt/trusted.gpg.d/lst_debian_repo.gpg https://rpms.litespeedtech.com/debian/lst_debian_repo.gpg || true
fi
apt-get update -qq > /dev/null 2>&1 || true
want=""
for p in lsphp83-redis lsphp83-imagick lsphp83-msgpack lsphp83-igbinary; do
  dpkg -s "$p" > /dev/null 2>&1 || want="$want $p"
done
[ -z "$want" ] && { echo "EXTRAS=already"; touch /tmp/bt_extras.ok; exit 0; }
echo "apt install:$want"
if apt-get install -y $want > /tmp/bt_extras_apt.log 2>&1; then
  echo "EXTRAS=apt_ok"
else
  echo "EXTRAS=apt_fail"; tail -10 /tmp/bt_extras_apt.log
  touch /tmp/bt_extras.fail
fi
L=/usr/local/lsws/lsphp83
for m in redis imagick msgpack igbinary; do
  $L/bin/php -m 2>/dev/null | grep -qix "$m" && echo "  + $m ok" || echo "  - $m missing"
done
touch /tmp/bt_extras.ok
EOS
)"
  if ! run_remote_script "PHP 扩展" "$script" "$marker" "$(scaled 1800)" "$(scaled 600)" 0; then
    warn "apt 安装扩展未确认成功，尝试宝塔 *-ols 扩展脚本 ..."
  fi

  probe="$(extras_probe)" || probe=""
  missing="$(printf '%s\n' "$probe" | sed -n 's/^EXT|\(.*\)|no$/\1/p' | tr '\n' ' ')"
  local m rc=0
  for m in $missing; do
    case "$m" in
      redis)
        run_install "扩展 redis (redis-ols)" 3 redis-ols 83 /usr/local/lsws/lsphp83/lib/php/20230831/redis.so "$(scaled 2400)" "$(scaled 600)" 1 || rc=1 ;;
      imagick)
        run_install "扩展 imagick (imagemagick-ols)" 3 imagemagick-ols 83 /usr/local/lsws/lsphp83/lib/php/20230831/imagick.so "$(scaled 2400)" "$(scaled 600)" 1 || rc=1 ;;
      *)
        warn "扩展 $m 无对应宝塔脚本，需人工处理" ;;
    esac
  done

  probe="$(extras_probe)" || probe=""
  printf '%s\n' "$probe" | while IFS='|' read -r _ m s; do
    [ "$s" = "yes" ] && ok "PHP 扩展 $m" || warn "PHP 扩展 $m 缺失"
  done
  printf '%s\n' "$probe" | grep -q '|no$' && rc=1
  return $rc
}

# ------------------------------ Redis -----------------------------------------
harden_redis() {
  step "Redis 安全加固（仅本机监听）"
  if [ "$DRY_RUN" = "1" ]; then info "[dry-run] 将确保 bind 127.0.0.1 / protected-mode yes"; return 0; fi
  ssh_do <<'EOF'
set -u
CONF=/www/server/redis/redis.conf
[ -f "$CONF" ] || { echo "REDIS_CONF=missing"; exit 0; }
CHANGED=0
if ! grep -qE '^bind[[:space:]]+127\.0\.0\.1' "$CONF"; then
  if grep -qE '^bind[[:space:]]' "$CONF"; then
    sed -i -E 's|^bind[[:space:]].*|bind 127.0.0.1|' "$CONF"
  else
    printf '\nbind 127.0.0.1\n' >> "$CONF"
  fi
  CHANGED=1
fi
grep -qE '^protected-mode[[:space:]]+yes' "$CONF" || { sed -i -E 's|^protected-mode.*|protected-mode yes|' "$CONF"; CHANGED=1; }
if [ "$CHANGED" = "1" ]; then
  echo "REDIS_CONF=changed, restarting"
  if [ -x /etc/init.d/redis ]; then /etc/init.d/redis restart > /dev/null 2>&1 || /etc/init.d/redis start > /dev/null 2>&1 || true; fi
else
  echo "REDIS_CONF=unchanged"
fi
sleep 2
CLI=/www/server/redis/src/redis-cli
[ -x "$CLI" ] && echo "REDIS_PING=$($CLI ping 2>/dev/null)" || echo "REDIS_PING=no_cli"
EOF
  local st
  st="$(ssh_do <<'EOF'
PL=$(ss -lntp 2>/dev/null | awk '$4 ~ /:6379$/ {print $4}' | head -1)
echo "REDIS_LISTEN=${PL:-none}"
EOF
)"
  if printf '%s\n' "$st" | grep -q '127.0.0.1:6379'; then ok "Redis 仅本机监听（127.0.0.1:6379）"
  else warn "Redis 监听状态：$(printf '%s' "$st" | tail -1)"; fi
}

# ------------------------------ MySQL 5.7 -------------------------------------
install_mysql_57() {
  step "安装 MySQL 5.7（源码编译，30-90 分钟）"
  if [ "$DRY_RUN" = "1" ]; then info "[dry-run] 将安装 MySQL 5.7"; return 0; fi
  if remote_has /www/server/mysql/bin/mysql; then
    local v; v="$(ssh_do <<<'/www/server/mysql/bin/mysql --version 2>/dev/null | head -1')"
    if printf '%s' "$v" | grep -q '5\.7'; then ok "MySQL 5.7 已存在，跳过"; return 0; fi
    warn "已有 MySQL 但版本不是 5.7：$v"
  fi

  ensure_swap 2048 || warn "swap 配置未确认，继续尝试"
  local script marker='/tmp/bt_mysql_prep.ok'
  script="$(cat <<'EOS'
set -u
AVAIL=$(awk '/^MemAvailable:/{print int($2/1024)}' /proc/meminfo)
SWAP=$(awk '/^SwapTotal:/{print int($2/1024)}' /proc/meminfo)
echo "mem available=${AVAIL}MB swap=${SWAP}MB"
if [ $((AVAIL + SWAP)) -lt 1560 ]; then
  echo "MYSQL_PREP=insufficient_memory (need >=1560MB combined)"
  exit 1
fi
mkdir -p /www/server/panel/install
touch /www/server/panel/install/i_mysql.pl
echo "MYSQL_PREP=ok"
touch /tmp/bt_mysql_prep.ok
EOS
)"
  run_remote_script "MySQL 前置检查" "$script" "$marker" "$(scaled 600)" "$(scaled 300)" 0 || { err "MySQL 前置检查失败（内存不足）"; return 1; }

  run_install "MySQL 5.7" 3 mysql 5.7 /www/server/mysql/bin/mysql "$(scaled 10800)" "$(scaled 1800)" 1 || return 1
  local v; v="$(ssh_do <<<'/www/server/mysql/bin/mysql --version 2>/dev/null | head -1')"
  if printf '%s' "$v" | grep -q '5\.7'; then ok "MySQL 版本：$v"; else err "MySQL 版本异常（期望 5.7）：$v"; return 1; fi
}

# ------------------------------ 最终校验 --------------------------------------
final_verify() {
  step "远端安装结果校验"
  ssh_do <<'EOF'
echo "--- OpenLiteSpeed ---"
if [ -x /usr/local/lsws/bin/lswsctrl ]; then
  echo "VER|/usr/local/lsws|$(cat /usr/local/lsws/VERSION 2>/dev/null)"
  /usr/local/lsws/bin/lswsctrl status 2>&1 | head -3 | sed 's/^/STATUS|/'
else echo "VER|/usr/local/lsws|MISSING"; fi
echo "--- PHP ---"
for d in /usr/local/lsws/lsphp83 /www/server/php/83; do
  if [ -x "$d/bin/php" ]; then
    echo "PHP|$d|$($d/bin/php -r 'echo PHP_VERSION;' 2>/dev/null)"
    mods="$($d/bin/php -m 2>/dev/null | tr 'A-Z' 'a-z' | tr '\n' ' ')"
    for m in redis opcache imagick mbstring msgpack igbinary; do
      case " $mods " in *" $m "*) echo "EXT|$d|$m|yes" ;; *) echo "EXT|$d|$m|no" ;; esac
    done
  else
    echo "PHP|$d|MISSING"
  fi
done
echo "--- Redis ---"
if [ -x /www/server/redis/src/redis-cli ]; then echo "REDIS|ping=$(/www/server/redis/src/redis-cli ping 2>/dev/null || echo no)"; else echo "REDIS|MISSING"; fi
echo "--- MySQL ---"
if [ -x /www/server/mysql/bin/mysql ]; then echo "MYSQL|$(/www/server/mysql/bin/mysql --version 2>/dev/null | head -1)"; else echo "MYSQL|MISSING"; fi
echo "--- Listeners ---"
ss -lntp 2>/dev/null | awk 'NR==1 || /:(80|443|7080|6379|3306)[[:space:]]/ {print "LIST|" $0}'
echo "--- Panel ---"
[ -f /www/server/panel/BT-Panel ] && echo "PANEL|yes" || echo "PANEL|no"
EOF
}

# ------------------------------ 主流程 ----------------------------------------
main() {
  if [ "$LOCAL_MODE" = "1" ]; then
    ssh_do() { bash -s; }
    step "0/9 本地模式检查"
    [ "$(id -u)" = "0" ] || { err "本地模式需要 root 权限（当前 uid=$(id -u)）"; exit 1; }
    REMOTE_HOSTNAME="$(hostname 2>/dev/null || echo localhost)"
    ok "在本机直接执行：$REMOTE_HOSTNAME"
  else
    step "0/9 本机前置检查"
    need_local_tools
    ok "本机工具就绪"

    step "1/9 连接远端服务器"
    connect
    ok "已连接 $SSH_USER@$HOST:$SSH_PORT（主机名：$REMOTE_HOSTNAME）"
  fi

  step "2/9 远端系统探测"
  local env_out
  env_out="$(rmt_env_probe)" || { err "远端探测失败"; exit 1; }
  REMOTE_ID="$(pval "$env_out" ID)"; REMOTE_CODENAME="$(pval "$env_out" CODENAME)"
  local pretty arch mem swap disk avail nftc
  pretty="$(pval "$env_out" PRETTY)"; arch="$(pval "$env_out" ARCH)"
  mem="$(pval "$env_out" MEM_MB)"; swap="$(pval "$env_out" SWAP_MB)"
  disk="$(pval "$env_out" DISK_MB)"; avail="$(pval "$env_out" AVAIL_MB)"
  nftc="$(pval "$env_out" NFTC)"
  info "系统：$pretty（$arch）  ID=$REMOTE_ID"
  info "内存：${mem}MB  可用：${avail}MB  Swap：${swap}MB  根分区可用：${disk}MB"
  case "$REMOTE_ID" in
    debian|ubuntu) ;;
    *) err "本脚本仅支持 Debian / Ubuntu，当前为：$REMOTE_ID（$pretty）"; exit 1 ;;
  esac
  [ "$arch" = "x86_64" ] || warn "当前架构 $arch 非 x86_64，宝塔/LiteSpeed 包可能不可用"
  [ "${disk:-0}" -ge 4096 ] 2>/dev/null || warn "根分区可用空间偏小（${disk}MB），源码编译可能失败"
  [ "$nftc" = "yes" ] && warn "检测到 nftables，宝塔防火墙插件可能不可用（不影响本次安装）"

  ok "探测完成：面板=$(pval "$env_out" PANEL) OLS=$(pval "$env_out" OLS) lsphp83=$(pval "$env_out" LSPHP) 面板PHP=$(pval "$env_out" PANELPHP) Redis=$(pval "$env_out" REDIS) MySQL=$(pval "$env_out" MYSQL)"

  if [ "$DRY_RUN" = "1" ]; then
    step "dry-run 计划"
    say "  1. 安装宝塔面板（如缺失）"
    say "  2. apt 基础依赖 + swap"$([ "$WANT_MYSQL" = "1" ] && echo " + libiconv 修复")
    say "  3. OpenLiteSpeed 1.9"
    say "  4. php-ols 83  ->  /usr/local/lsws/lsphp83"
    say "  5. php 8.3     ->  /www/server/php/83（面板软件列表可见）"
    [ "$WANT_EXTRAS" = "1" ] && say "  6. PHP 扩展：redis/opcache/imagick/mbstring/msgpack/igbinary"
    say "  7. Redis 7.4（仅本机监听）"
    [ "$WANT_MYSQL" = "1" ] && say "  8. MySQL 5.7"
    say "  9. 远端校验 + 汇总"
    ok "dry-run 结束，未做任何改动"
    exit 0
  fi

  if [ "$ASSUME_YES" != "1" ] && is_tty; then
    printf '\n%s将开始安装，预计 40-120 分钟。继续? [y/N]: %s' "$Y" "$N"
    local a; read -r a
    case "${a:-n}" in y|Y|yes|YES) ;; *) warn "已取消"; exit 0 ;; esac
  fi

  local failed="" rc=0

  step "3/9 宝塔面板"
  ensure_panel || { err "宝塔面板安装失败，中止"; exit 2; }

  step "4/9 系统预置"
  ensure_apt_base || { err "编译依赖安装失败，中止"; exit 2; }
  if [ "$WANT_MYSQL" = "1" ] || [ "${mem:-0}" -lt 3072 ] 2>/dev/null; then
    ensure_swap 2048 || warn "swap 未确认成功"
  fi
  ensure_libiconv || { err "libiconv 修复失败，PHP 编译会中断，中止"; exit 2; }

  step "5/9 OpenLiteSpeed 1.9"
  if remote_has /usr/local/lsws/bin/lswsctrl; then ok "OpenLiteSpeed 已安装，跳过"
  else run_install "OpenLiteSpeed 1.9" 3 openlitespeed 1.9 /usr/local/lsws/bin/lswsctrl "$(scaled 5400)" "$(scaled 1200)" 1 \
       || { err "OpenLiteSpeed 安装失败，中止"; exit 2; }; fi

  step "6/9 PHP 8.3（lsphp / OLS 运行版本）"
  if remote_has /usr/local/lsws/lsphp83/bin/php; then ok "lsphp83 已安装，跳过"
  else run_install "PHP 8.3 (php-ols)" 3 php-ols 83 /usr/local/lsws/lsphp83/bin/php "$(scaled 5400)" "$(scaled 1200)" 1 \
       || { err "php-ols 83 安装失败，中止"; exit 2; }; fi

  step "7/9 PHP 8.3（面板软件列表可见版本）"
  if remote_has /www/server/php/83/bin/php; then ok "/www/server/php/83 已安装，跳过"
  else run_install "PHP 8.3 (php)" 3 php 8.3 /www/server/php/83/bin/php "$(scaled 7200)" "$(scaled 1500)" 1 \
       || { err "php 8.3 安装失败，中止"; exit 2; }; fi

  if [ "$WANT_EXTRAS" = "1" ]; then
    step "8/9 PHP 扩展与 Redis"
    install_php_extras || { failed="$failed
 - PHP 扩展"; rc=1; }
    if remote_has /www/server/redis/src/redis-server; then ok "Redis 已安装，跳过"
    else run_install "Redis 7.4" 3 redis 7.4 /www/server/redis/src/redis-server "$(scaled 3600)" "$(scaled 900)" 1 \
         || { failed="$failed
 - Redis"; rc=1; }; fi
    harden_redis || true
  else
    step "8/9 跳过 PHP 扩展（--skip-extras）"
    if remote_has /www/server/redis/src/redis-server; then ok "Redis 已安装"
    else run_install "Redis 7.4" 3 redis 7.4 /www/server/redis/src/redis-server "$(scaled 3600)" "$(scaled 900)" 1 || rc=1; fi
    harden_redis || true
  fi

  step "9/9 MySQL 与校验"
  if [ "$WANT_MYSQL" = "1" ]; then
    install_mysql_57 || { failed="$failed
 - MySQL 5.7"; rc=1; }
  else
    info "已跳过 MySQL 5.7（可用 --with-mysql 安装）"
  fi

  fix_panel_php83 || { failed="$failed
 - 面板 PHP8.3 设置页补丁"; rc=1; }

  refresh_panel_state
  final_verify | tee -a "$LOG_FILE"

  step "完成"
  local panel_info
  panel_info="$(ssh_do <<'EOF'
if [ -x /etc/init.d/bt ]; then /etc/init.d/bt default 2>&1 | sed -n '1,14p'; fi
EOF
)" || panel_info=""
  [ -n "$panel_info" ] && printf '%s\n' "$panel_info" | strip_ansi

  say ""
  if [ -z "$failed" ]; then
    ok "全部完成，未发现失败项"
  else
    err "以下步骤失败/存疑，请复查：$failed"
    rc=1
  fi
  info "本机日志：$LOG_FILE"
  info "复检：可直接重跑本脚本（幂等，已装的会跳过、只补缺失项），或加 --dry-run 只看现状"
  return $rc
}

main "$@"
exit $?
