#!/usr/bin/env bash
#
# Proxmox VE 8 -> 9 分阶段升级助手
#
# 依据官方文档：https://pve.proxmox.com/wiki/Upgrade_from_8_to_9
#
# 用法：./pve8to9.sh <子命令> [选项]
# 详见 ./pve8to9.sh help

set -Eeuo pipefail

VERSION="1.0.0"
STATE_DIR="/var/lib/pve8to9-helper"
BACKUP_DIR="/var/backups/pve8to9-helper"
LOG_FILE=""
GUEST_STATE="${STATE_DIR}/running-guests.txt"

ASSUME_YES=0
DRY_RUN=0

C_RED=$'\033[0;31m'
C_GREEN=$'\033[0;32m'
C_YELLOW=$'\033[0;33m'
C_BLUE=$'\033[0;34m'
C_BOLD=$'\033[1m'
C_OFF=$'\033[0m'

if [[ ! -t 1 ]]; then
    C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE=""; C_BOLD=""; C_OFF=""
fi

log()  { printf '%s\n' "$*" | tee -a "${LOG_FILE:-/dev/null}" >&2; }
info() { log "${C_BLUE}[INFO]${C_OFF} $*"; }
ok()   { log "${C_GREEN}[ OK ]${C_OFF} $*"; }
warn() { log "${C_YELLOW}[WARN]${C_OFF} $*"; }
err()  { log "${C_RED}[FAIL]${C_OFF} $*"; }
head1() { log ""; log "${C_BOLD}== $* ==${C_OFF}"; }

die() { err "$*"; exit 1; }

on_error() {
    local rc=$? line=$1
    err "脚本在第 ${line} 行中断（退出码 ${rc}）。日志：${LOG_FILE:-<未启用>}"
    exit "$rc"
}
trap 'on_error $LINENO' ERR

# 执行一条会改动系统的命令；--dry-run 时只打印
run() {
    if (( DRY_RUN )); then
        log "${C_YELLOW}[DRY ]${C_OFF} $*"
        return 0
    fi
    log "${C_BLUE}[RUN ]${C_OFF} $*"
    local rc
    set +e
    "$@" 2>&1 | tee -a "${LOG_FILE:-/dev/null}"
    rc="${PIPESTATUS[0]}"
    set -e
    return "$rc"
}

# 需要保留交互（apt 配置文件冲突提问）的命令，不能走管道
run_tty() {
    if (( DRY_RUN )); then
        log "${C_YELLOW}[DRY ]${C_OFF} $*"
        return 0
    fi
    log "${C_BLUE}[RUN ]${C_OFF} $*"
    "$@"
}

confirm() {
    local prompt="$1"
    if (( ASSUME_YES )); then
        info "${prompt} -> 已通过 --yes 自动确认"
        return 0
    fi
    if [[ ! -t 0 ]]; then
        die "需要确认「${prompt}」但当前非交互终端，请加 --yes 或在真实终端运行"
    fi
    local reply
    read -r -p "$(printf '%s%s%s [y/N] ' "$C_BOLD" "$prompt" "$C_OFF")" reply
    [[ "$reply" =~ ^[Yy]$ ]]
}

require_root() {
    [[ ${EUID} -eq 0 ]] || die "请以 root 运行本脚本"
}

require_pve() {
    command -v pveversion >/dev/null 2>&1 || die "未检测到 pveversion，本脚本只能在 Proxmox VE 节点上运行"
}

setup_logging() {
    mkdir -p "$STATE_DIR" "$BACKUP_DIR"
    LOG_FILE="${STATE_DIR}/pve8to9-$(date +%Y%m%d-%H%M%S).log"
    : > "$LOG_FILE"
    info "日志：${LOG_FILE}"
}

# ---------- 环境探测 ----------

pve_manager_version() {
    dpkg-query -W -f='${Version}' pve-manager 2>/dev/null || echo "0"
}

is_pve9() {
    dpkg --compare-versions "$(pve_manager_version)" ge "9.0.0"
}

is_uefi() {
    [[ -d /sys/firmware/efi ]]
}

root_fs_type() {
    findmnt -no FSTYPE / 2>/dev/null || echo "unknown"
}

has_pkg() {
    local st
    st="$(dpkg-query -W -f='${Status}' "$1" 2>/dev/null || true)"
    [[ "$st" == *"ok installed"* ]]
}

cpu_vendor() {
    if grep -qi 'GenuineIntel' /proc/cpuinfo; then
        echo intel
    elif grep -qi 'AuthenticAMD' /proc/cpuinfo; then
        echo amd
    else
        echo unknown
    fi
}

microcode_pkg() {
    case "$(cpu_vendor)" in
        intel) echo "intel-microcode" ;;
        amd)   echo "amd64-microcode" ;;
        *)     echo "" ;;
    esac
}

# APT 只读 sources.list 以及 sources.list.d 下的 *.list / *.sources，
# .bak、.save、.distUpgrade 等一律忽略。所有检查都必须走这个列表，
# 否则备份文件里的旧内容会造成误报。
apt_source_files() {
    local f
    for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
        [[ -e "$f" ]] && printf '%s\n' "$f"
    done
}

# 在生效中的源文件里搜索；用法：apt_sources_grep <grep选项...> <正则>
apt_sources_grep() {
    apt_source_files | tr '\n' '\0' | xargs -r -0 grep "$@" 2>/dev/null
}

running_vmids() {
    if command -v qm >/dev/null 2>&1; then
        qm list 2>/dev/null | awk 'NR>1 && $3=="running" {print "qm " $1}'
    fi
    if command -v pct >/dev/null 2>&1; then
        pct list 2>/dev/null | awk 'NR>1 && $2=="running" {print "pct " $1}'
    fi
}

free_root_gb() {
    local v
    v="$(df -BG --output=avail / 2>/dev/null | awk 'NR==2 {gsub("G","",$1); print $1+0}')"
    printf '%s\n' "${v:-0}"
}

# 模拟 apt 操作，确认不会误删 proxmox-ve / pve-manager
apt_guard() {
    local sim
    sim="$(apt-get -s "$@" 2>&1 || true)"
    if grep -qE '^Remv (proxmox-ve|pve-manager|pve-cluster|qemu-server) ' <<<"$sim"; then
        err "该操作会移除 Proxmox 核心包，已中止。模拟输出："
        grep -E '^Remv ' <<<"$sim" | tee -a "${LOG_FILE:-/dev/null}" >&2
        return 1
    fi
    return 0
}

# ---------- preflight ----------

cmd_preflight() {
    local fail=0 warncnt=0

    head1 "基础环境"
    local ver
    ver="$(pve_manager_version)"
    info "pve-manager 版本：${ver}"
    info "运行内核：$(uname -r)"

    if is_pve9; then
        ok "已经是 PVE 9，无需再执行 8->9 升级"
        return 0
    fi

    if dpkg --compare-versions "$ver" ge "8.4.1"; then
        ok "版本满足 8->9 前置要求（>= 8.4.1）"
    else
        err "版本低于 8.4.1，请先执行：apt update && apt dist-upgrade"
        fail=$((fail+1))
    fi

    local avail
    avail="$(free_root_gb)"
    if (( avail >= 10 )); then
        ok "根分区可用空间 ${avail}G"
    elif (( avail >= 5 )); then
        warn "根分区可用空间仅 ${avail}G（建议 >= 10G）"
        warncnt=$((warncnt+1))
    else
        err "根分区可用空间仅 ${avail}G，低于官方要求的 5G"
        fail=$((fail+1))
    fi

    head1 "阻塞项检查"

    if has_pkg systemd-boot; then
        err "已安装 systemd-boot 元包，升级时会破坏引导相关包 -> 运行 '$0 fix' 移除"
        fail=$((fail+1))
    else
        ok "未安装 systemd-boot 元包"
    fi

    if command -v ceph >/dev/null 2>&1 && has_pkg ceph-mon; then
        local cephver
        cephver="$(ceph --version 2>/dev/null | awk '{print $3}' || echo unknown)"
        if [[ "$cephver" == 19.* ]]; then
            ok "Ceph ${cephver}（Squid），满足要求"
        else
            err "检测到超融合 Ceph ${cephver}，必须先升级到 Ceph 19.2 Squid 再升 PVE"
            fail=$((fail+1))
        fi
    else
        ok "未检测到超融合 Ceph"
    fi

    if has_pkg proxmox-backup-server; then
        err "本机同时装有 Proxmox Backup Server，需先按 PBS 3->4 指南升级"
        fail=$((fail+1))
    else
        ok "未与 PBS 同机部署"
    fi

    if has_pkg linux-image-amd64; then
        err "安装了 linux-image-amd64，会与 PVE 9 冲突 -> apt remove linux-image-amd64"
        fail=$((fail+1))
    else
        ok "未安装冲突的 linux-image-amd64"
    fi

    local bp
    bp="$(apt_sources_grep -ilE '^[^#]*backports' || true)"
    if [[ -n "$bp" ]]; then
        err "存在 backports 源，官方未测试该组合，请先注释：${bp//$'\n'/ }"
        fail=$((fail+1))
    else
        ok "未启用 backports 源"
    fi

    head1 "建议处理项"

    local mc
    mc="$(microcode_pkg)"
    if [[ -n "$mc" ]] && ! has_pkg "$mc"; then
        warn "未安装 CPU 微码包 ${mc}（需要 non-free-firmware 组件）-> '$0 fix' 可自动处理"
        warncnt=$((warncnt+1))
    elif [[ -n "$mc" ]]; then
        ok "已安装微码包 ${mc}"
    fi

    if is_uefi && [[ "$(root_fs_type)" != "zfs" ]] && ! has_pkg grub-efi-amd64; then
        warn "UEFI 启动且根非 ZFS，但未安装 grub-efi-amd64，升级后可能无法引导 -> '$0 fix' 可安装"
        warncnt=$((warncnt+1))
    fi

    if [[ -x /usr/share/pve-manager/migrations/pve-lvm-disable-autoactivation ]]; then
        info "存在 LVM autoactivation 迁移脚本，'$0 fix' 会询问是否执行"
    fi

    local guests
    guests="$(running_vmids || true)"
    if [[ -n "$guests" ]]; then
        warn "检测到 $(wc -l <<<"$guests") 个运行中的虚拟机/容器，升级前建议全部关机 -> '$0 stop-guests'"
        warncnt=$((warncnt+1))
    else
        ok "没有运行中的虚拟机/容器"
    fi

    # tmux 设 $TMUX，screen 设 $STY；$TERM 只是兜底，可能被覆盖
    if [[ -n "${TMUX:-}" ]]; then
        ok "运行在 tmux 会话中"
    elif [[ -n "${STY:-}" ]]; then
        ok "运行在 screen 会话中（${STY}）"
    elif [[ "${TERM:-}" == screen* || "${TERM:-}" == tmux* ]]; then
        ok "检测到终端复用器（TERM=${TERM}）"
    elif [[ -n "${SSH_CONNECTION:-}" ]]; then
        warn "当前是 SSH 会话且未使用 tmux/screen，断线会中断升级，请先执行 tmux 或 screen"
        warncnt=$((warncnt+1))
    fi

    head1 "官方检查脚本 pve8to9 --full"
    if command -v pve8to9 >/dev/null 2>&1; then
        pve8to9 --full 2>&1 | tee -a "${LOG_FILE:-/dev/null}" || true
    else
        warn "未找到 pve8to9 命令（应随 8.4 包提供）"
    fi

    head1 "小结"
    log "阻塞项：${fail}    建议处理：${warncnt}"
    if (( fail > 0 )); then
        err "请先清掉全部阻塞项，再执行 '$0 repos'"
        return 1
    fi
    ok "未发现阻塞项。下一步：$0 fix -> $0 stop-guests -> $0 repos -> $0 upgrade"
    return 0
}

# ---------- fix ----------

fix_systemd_boot() {
    has_pkg systemd-boot || { ok "systemd-boot 元包未安装，跳过"; return 0; }
    warn "systemd-boot 是元包，PVE 通常用 proxmox-boot-tool 管理引导，官方建议移除"
    warn "唯一需要保留的情况：你手动配置了 systemd-boot 作为引导器"
    confirm "移除 systemd-boot 元包？" || { info "跳过"; return 0; }
    apt_guard remove systemd-boot || die "移除 systemd-boot 的模拟结果不安全，已中止"
    run apt-get -y remove systemd-boot
    ok "已移除 systemd-boot 元包"
}

# 只动 Debian 基础源所在的文件，且只匹配官方/镜像站的 Debian 仓库行。
# 早期版本用 'debian/' 做匹配，会误伤 URL 里含 /debian/ 的第三方源（如 packagecloud）。
DEBIAN_BASE_RE='^deb[[:space:]]+(\[[^]]*\][[:space:]]+)?https?://(([a-z0-9._-]+\.)?debian\.org|mirrors?\.[a-z0-9._-]+)/[^[:space:]]*[[:space:]]+[a-z]+(-[a-z]+)?[[:space:]]+.*\bmain\b'

ensure_non_free_firmware() {
    local f=/etc/apt/sources.list
    [[ -e "$f" ]] || { warn "未找到 ${f}，跳过 non-free-firmware 处理"; return 0; }
    grep -q 'non-free-firmware' "$f" && return 0
    grep -qE "$DEBIAN_BASE_RE" "$f" || {
        warn "${f} 中未找到 Debian 基础源，请手动确认 non-free-firmware 组件已启用"
        return 0
    }

    info "为 ${f} 补上 non-free-firmware 组件"
    if (( ! DRY_RUN )); then
        mkdir -p "$BACKUP_DIR"
        cp -a "$f" "${BACKUP_DIR}/sources.list.$(date +%Y%m%d-%H%M%S).bak"
        # 地址用 # 作分隔符：正则里含 / 和 |，不能用默认的 /
        sed -i -E "\\#${DEBIAN_BASE_RE}# s/[[:space:]]*\$/ non-free-firmware/" "$f"
    fi
    run apt-get update
}

fix_microcode() {
    local mc
    mc="$(microcode_pkg)"
    [[ -n "$mc" ]] || { warn "无法识别 CPU 厂商，跳过微码"; return 0; }
    has_pkg "$mc" && { ok "微码包 ${mc} 已安装"; return 0; }
    confirm "安装 CPU 微码包 ${mc}（会自动补 non-free-firmware 组件）？" || { info "跳过"; return 0; }
    ensure_non_free_firmware
    run apt-get -y install "$mc" || warn "安装 ${mc} 失败，可稍后手动处理，不阻塞升级"
}

fix_grub_efi() {
    is_uefi || { ok "非 UEFI 启动，跳过 grub-efi-amd64"; return 0; }
    [[ "$(root_fs_type)" == "zfs" ]] && { ok "根为 ZFS，不受该 grub 问题影响"; return 0; }
    has_pkg grub-efi-amd64 && { ok "grub-efi-amd64 已安装"; return 0; }
    warn "UEFI + LVM 根分区存在 grub 无法引导的已知问题，需要安装 grub-efi-amd64"
    confirm "安装 grub-efi-amd64？" || { info "跳过"; return 0; }
    apt_guard install grub-efi-amd64 || die "安装 grub-efi-amd64 的模拟结果不安全，已中止"
    run apt-get -y install grub-efi-amd64
}

fix_lvm_autoactivation() {
    local script=/usr/share/pve-manager/migrations/pve-lvm-disable-autoactivation
    [[ -x "$script" ]] || { ok "无需处理 LVM autoactivation"; return 0; }
    info "PVE 9 起新建 LVM 卷默认关闭 autoactivation；共享存储上强烈建议迁移，纯本地存储为可选"
    confirm "现在运行 LVM autoactivation 迁移脚本？" || { info "跳过"; return 0; }
    run "$script"
}

fix_audit_socket() {
    systemctl is-enabled systemd-journald-audit.socket >/dev/null 2>&1 || {
        ok "systemd-journald-audit.socket 已禁用"; return 0; }
    info "升级过程中该 socket 会刷大量内核审计日志，官方建议先停用"
    confirm "停用 systemd-journald-audit.socket？" || { info "跳过"; return 0; }
    run systemctl disable --now systemd-journald-audit.socket
}

cmd_fix() {
    is_pve9 && { ok "已是 PVE 9，无需执行升级前修复"; return 0; }
    head1 "修复阻塞项与建议项"
    fix_systemd_boot
    fix_grub_efi
    fix_lvm_autoactivation
    fix_microcode
    fix_audit_socket
    head1 "完成"
    info "建议重新运行：$0 preflight"
}

# ---------- guests ----------

cmd_stop_guests() {
    head1 "停止运行中的虚拟机与容器"
    local guests
    guests="$(running_vmids || true)"
    if [[ -z "$guests" ]]; then
        ok "当前没有运行中的客户机"
        return 0
    fi
    log "$guests"
    confirm "关闭以上全部客户机？（清单会保存，升级后可用 start-guests 恢复）" || { info "已取消"; return 0; }

    if (( ! DRY_RUN )); then
        mkdir -p "$STATE_DIR"
        printf '%s\n' "$guests" > "$GUEST_STATE"
        ok "客户机清单已保存到 ${GUEST_STATE}"
    fi

    local tool vmid
    while read -r tool vmid; do
        [[ -n "${vmid:-}" ]] || continue
        info "正在关闭 ${tool} ${vmid} ..."
        if ! run "$tool" shutdown "$vmid" --timeout 180; then
            warn "${tool} ${vmid} 优雅关机失败，改为强制停止"
            run "$tool" stop "$vmid" || warn "${tool} ${vmid} 强制停止也失败，请手动处理"
        fi
    done <<<"$guests"

    sleep 3
    local still
    still="$(running_vmids || true)"
    if [[ -n "$still" ]]; then
        warn "仍有客户机在运行："
        log "$still"
        return 1
    fi
    ok "全部客户机已停止"
}

cmd_start_guests() {
    head1 "恢复启动客户机"
    [[ -f "$GUEST_STATE" ]] || die "未找到清单 ${GUEST_STATE}，请手动启动"
    log "$(cat "$GUEST_STATE")"
    confirm "按清单启动以上客户机？" || { info "已取消"; return 0; }
    local tool vmid
    while read -r tool vmid; do
        [[ -n "${vmid:-}" ]] || continue
        info "启动 ${tool} ${vmid} ..."
        run "$tool" start "$vmid" || warn "${tool} ${vmid} 启动失败，请手动检查"
    done < "$GUEST_STATE"
    ok "启动流程结束，请到 Web UI 逐个确认状态"
}

# ---------- repos ----------

backup_apt() {
    local ts tarball
    ts="$(date +%Y%m%d-%H%M%S)"
    tarball="${BACKUP_DIR}/apt-${ts}.tar.gz"
    if (( DRY_RUN )); then
        log "${C_YELLOW}[DRY ]${C_OFF} 备份 /etc/apt 到 ${tarball}"
        return 0
    fi
    mkdir -p "$BACKUP_DIR"
    tar -czf "$tarball" -C /etc apt
    echo "$tarball" > "${STATE_DIR}/last-apt-backup"
    ok "已备份 /etc/apt -> ${tarball}"
}

keyring_path() {
    if [[ -f /usr/share/keyrings/proxmox-archive-keyring.gpg ]]; then
        echo /usr/share/keyrings/proxmox-archive-keyring.gpg
    else
        echo ""
    fi
}

# 第三方源（非 debian.org / proxmox.com）通常没有 trixie 套件，
# 换源后会 404，且其中的包未必兼容 Debian 13。官方建议升级期间先停用。
check_third_party_repos() {
    local hits
    hits="$(apt_sources_grep -lE '^[^#]*https?://' || true)"
    [[ -n "$hits" ]] || return 0

    local f third=()
    while read -r f; do
        [[ -n "$f" ]] || continue
        if grep -E '^[^#]*https?://' "$f" \
           | grep -qvE 'debian\.org|proxmox\.com|mirrors\.(tuna\.tsinghua|ustc|nju|aliyun)\.edu\.cn|mirrors\.aliyun\.com'; then
            third+=("$f")
        fi
    done <<<"$hits"

    [[ ${#third[@]} -gt 0 ]] || { ok "未发现第三方 APT 源"; return 0; }

    warn "检测到第三方 APT 源，它们多半没有 trixie 套件，换源后 apt update 会报 404："
    for f in "${third[@]}"; do
        log "  --- ${f}"
        grep -nE '^[^#]*https?://' "$f" | sed 's/^/      /' | tee -a "${LOG_FILE:-/dev/null}" >&2 || true
    done
    log "  官方建议：升级期间先停用，升级完成后再确认其是否已支持 Debian 13。"

    confirm "现在注释掉这些第三方源？（升级后可自行恢复）" || { warn "保留第三方源，若 apt update 报错请回来处理"; return 0; }

    for f in "${third[@]}"; do
        info "停用 ${f}"
        if (( DRY_RUN )); then
            log "${C_YELLOW}[DRY ]${C_OFF} 注释 ${f} 中的启用条目"
        else
            mkdir -p "$BACKUP_DIR"
            cp -a "$f" "${BACKUP_DIR}/$(basename "$f").$(date +%Y%m%d-%H%M%S).bak"
            sed -i -E 's@^([[:space:]]*(deb(-src)?|Types:|URIs:|Suites:|Components:|Signed-By:|Enabled:))@# \1@' "$f"
        fi
    done
    ok "第三方源已停用，备份在 ${BACKUP_DIR}"
}

cmd_repos() {
    is_pve9 && { ok "已是 PVE 9，跳过换源"; return 0; }

    local ver
    ver="$(pve_manager_version)"
    dpkg --compare-versions "$ver" ge "8.4.1" \
        || die "当前 pve-manager ${ver} < 8.4.1，请先 apt dist-upgrade 到最新 8.4"

    if has_pkg systemd-boot; then
        die "systemd-boot 元包仍在，请先运行 '$0 fix'"
    fi

    head1 "把 APT 源从 bookworm 切到 trixie"
    info "当前源文件："
    apt_source_files | while read -r f; do
        log "  --- ${f}"
        grep -vE '^\s*(#|$)' "$f" | sed 's/^/      /' | tee -a "${LOG_FILE:-/dev/null}" >&2 || true
    done

    confirm "确认改写以上源文件（会先整体备份 /etc/apt）？" || { info "已取消"; return 0; }
    backup_apt
    tidy_stray_backups

    local f
    while read -r f; do
        if grep -q 'bookworm' "$f"; then
            info "改写 ${f}"
            run sed -i 's/bookworm/trixie/g' "$f"
        fi
    done < <(apt_source_files)

    check_third_party_repos

    # 企业源没有订阅时会 401，这里检测并允许关掉
    if apt_sources_grep -qsE '^[^#]*enterprise\.proxmox\.com'; then
        warn "检测到 Proxmox 企业源，需要有效订阅才能使用"
        if confirm "没有订阅的话建议禁用企业源并改用 no-subscription，现在切换？"; then
            local ef
            for ef in /etc/apt/sources.list.d/pve-enterprise.list /etc/apt/sources.list.d/pve-enterprise.sources; do
                [[ -e "$ef" ]] || continue
                info "禁用 ${ef}"
                if (( DRY_RUN )); then
                    log "${C_YELLOW}[DRY ]${C_OFF} 注释 ${ef} 全部条目"
                else
                    sed -i 's/^\([^#]\)/# \1/' "$ef"
                fi
            done
            write_no_subscription
        fi
    else
        if ! apt_sources_grep -qsE '^[^#]*proxmox\.com/debian/pve'; then
            warn "未找到 PVE 9 软件源"
            confirm "写入官方 no-subscription 源？" && write_no_subscription
        fi
    fi

    tidy_stray_backups

    head1 "刷新索引"
    if ! run apt-get update; then
        apt_update_hint
        return 1
    fi
    ok "换源完成。下一步：$0 upgrade"
}

# apt 会对 sources.list.d 下扩展名不认识的文件报 Notice，
# 把散落的备份挪到统一目录，避免干扰后续检查的输出。
tidy_stray_backups() {
    local f found=0
    for f in /etc/apt/sources.list.d/*.bak /etc/apt/sources.list.d/*.bak.* /etc/apt/sources.list.d/*.save; do
        [[ -e "$f" ]] || continue
        found=1
        if (( DRY_RUN )); then
            log "${C_YELLOW}[DRY ]${C_OFF} 移动 ${f} -> ${BACKUP_DIR}/"
        else
            mkdir -p "$BACKUP_DIR"
            mv "$f" "${BACKUP_DIR}/"
        fi
    done
    (( found )) && ok "散落的备份文件已移到 ${BACKUP_DIR}"
    return 0
}

apt_update_hint() {
    err "apt update 失败，暂不能继续"
    log "  1) 若报 'Conflicting values set for option Signed-By'："
    log "     同一个源被 .list 和 .sources 重复定义了。查看："
    log "       grep -rn 'proxmox.com/debian/pve' /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources"
    log "     保留带 Signed-By 的 .sources，把 .list 里的同源行注释掉。"
    log "  2) 若报 404：多半是第三方源没有 trixie 套件，先注释掉该源。"
    log "  3) 若报连接超时：检查 IPv6 可达性，可加 Acquire::ForceIPv4"
    log "  4) 想整体退回升级前状态：$0 rollback-repos"
}

# 同一个 URI+套件被 .list 和 .sources 各定义一次时，若两处的 Signed-By 不同，
# apt 会直接拒绝读取全部源（Conflicting values set for option Signed-By）。
# 所以写 deb822 之前必须先停用 .list 里的同源条目。
disable_legacy_pve_entries() {
    local f hits=()
    while read -r f; do
        [[ "$f" == *.list || "$f" == /etc/apt/sources.list ]] || continue
        grep -qE '^[^#]*proxmox\.com/debian/pve' "$f" && hits+=("$f")
    done < <(apt_source_files)

    [[ ${#hits[@]} -gt 0 ]] || return 0

    warn "以下传统 .list 文件也定义了 PVE 源，会与 deb822 配置冲突，需停用："
    for f in "${hits[@]}"; do
        log "  --- ${f}"
        grep -nE '^[^#]*proxmox\.com/debian/pve' "$f" | sed 's/^/      /' \
            | tee -a "${LOG_FILE:-/dev/null}" >&2 || true
    done

    for f in "${hits[@]}"; do
        if (( DRY_RUN )); then
            log "${C_YELLOW}[DRY ]${C_OFF} 注释 ${f} 中的 PVE 源条目"
        else
            mkdir -p "$BACKUP_DIR"
            cp -a "$f" "${BACKUP_DIR}/$(basename "$f").$(date +%Y%m%d-%H%M%S).bak"
            sed -i -E '/proxmox\.com\/debian\/pve/ s@^([[:space:]]*deb(-src)?)@# \1@' "$f"
        fi
    done
    ok "已停用传统 .list 中的 PVE 源条目"
}

write_no_subscription() {
    local kr
    kr="$(keyring_path)"
    if [[ -z "$kr" ]]; then
        warn "未找到 proxmox-archive-keyring.gpg，请先在 8.4 下 apt install proxmox-archive-keyring"
        return 1
    fi

    disable_legacy_pve_entries

    info "写入 /etc/apt/sources.list.d/proxmox.sources（trixie / pve-no-subscription）"
    if (( DRY_RUN )); then
        log "${C_YELLOW}[DRY ]${C_OFF} 生成 proxmox.sources"
        return 0
    fi
    cat > /etc/apt/sources.list.d/proxmox.sources <<EOF
Types: deb
URIs: http://download.proxmox.com/debian/pve
Suites: trixie
Components: pve-no-subscription
Signed-By: ${kr}
EOF
    ok "已写入 proxmox.sources"
}

cmd_rollback_repos() {
    head1 "回滚 APT 源"
    local marker tarball
    marker="${STATE_DIR}/last-apt-backup"
    [[ -f "$marker" ]] || die "未找到备份记录 ${marker}"
    tarball="$(cat "$marker")"
    [[ -f "$tarball" ]] || die "备份文件不存在：${tarball}"
    warn "将用 ${tarball} 覆盖当前 /etc/apt"
    confirm "确认回滚？" || { info "已取消"; return 0; }
    run tar -xzf "$tarball" -C /etc
    run apt-get update
    ok "已回滚 APT 源"
}

# ---------- upgrade ----------

cmd_upgrade() {
    is_pve9 && { ok "已经是 PVE 9"; return 0; }

    apt_sources_grep -qsE '^[^#]*trixie' \
        || die "APT 源里没有 trixie，请先运行 '$0 repos'"

    if apt_sources_grep -qsE '^[^#]*bookworm'; then
        warn "生效中的源文件仍有 bookworm 条目："
        apt_sources_grep -nHsE '^[^#]*bookworm' | tee -a "${LOG_FILE:-/dev/null}" >&2 || true
        confirm "带着这些残留继续升级？（不建议）" || die "请先清理 bookworm 条目"
    fi

    local guests
    guests="$(running_vmids || true)"
    if [[ -n "$guests" ]]; then
        warn "仍有客户机在运行，升级中可能造成数据损坏"
        confirm "无视并继续？（建议先跑 '$0 stop-guests'）" || die "已中止"
    fi

    head1 "重要提示"
    log "  1. 升级期间会询问配置文件冲突，推荐答法："
    log "     /etc/issue            -> No（保留现有）"
    log "     /etc/lvm/lvm.conf     -> Yes（用维护者版本，除非你手改过）"
    log "     /etc/ssh/sshd_config  -> 未手改过则 Yes；手改过要逐条看 diff"
    log "     /etc/default/grub     -> No（保留现有），除非你确认 diff 无关紧要"
    log "     /etc/chrony/chrony.conf -> 未手改过则 Yes"
    log "  2. 看到 apt-listchanges 分页时按 q 退出即可"
    log "  3. 全程不要断开终端；网卡名可能变化，务必保证有 IPMI/物理控制台"

    confirm "开始 dist-upgrade 到 PVE 9？" || { info "已取消"; return 0; }

    if ! run apt-get update; then
        apt_update_hint
        exit 1
    fi
    apt_guard dist-upgrade || die "dist-upgrade 模拟显示会移除 Proxmox 核心包，已中止；请检查源配置"

    # 保留交互：配置文件冲突需要人工判断
    if ! run_tty apt-get dist-upgrade; then
        err "dist-upgrade 未正常结束"
        log "  常见处理：apt -f install 后重试；仍失败时用 '$0 rollback-repos' 回滚源"
        log "  参考：https://pve.proxmox.com/wiki/Upgrade_from_8_to_9#Troubleshooting"
        exit 1
    fi

    head1 "升级命令已结束"
    ok "接下来请运行：$0 postcheck，确认无误后 reboot"
    warn "即使之前已用过 6.14 内核，也必须重启一次"
}

# ---------- postcheck ----------

# 升级后 chrony 常因 AppArmor 挡住 systemd 就绪通知而起不来，
# pve8to9 只会笼统报 "No (active) time synchronisation daemon"，这里给出具体线索。
check_time_sync() {
    local unit found=""
    for unit in chrony chronyd systemd-timesyncd ntpsec ntp openntpd; do
        if systemctl is-active --quiet "${unit}.service" 2>/dev/null; then
            found="$unit"
            break
        fi
    done

    if [[ -n "$found" ]]; then
        ok "时间同步服务 ${found}.service 运行中"
        return 0
    fi

    err "没有运行中的时间同步服务"

    if systemctl is-failed --quiet chrony.service 2>/dev/null; then
        warn "chrony.service 处于 failed 状态，最近日志："
        journalctl -u chrony -n 15 --no-pager 2>&1 \
            | tee -a "${LOG_FILE:-/dev/null}" >&2 || true
        if journalctl -u chrony -n 50 --no-pager 2>/dev/null \
             | grep -q 'Could not send notification to \$NOTIFY_SOCKET'; then
            warn "命中已知问题：chronyd 无法向 /run/systemd/notify 发送就绪通知"
            warn "日志里紧邻的 'Loaded seccomp filter' 是巧合，调 -F 级别无效"
            if dmesg 2>/dev/null | grep -q 'apparmor="DENIED".*chronyd'; then
                err "已确认是 AppArmor 拒绝："
                dmesg | grep 'apparmor="DENIED".*chronyd' | tail -3 \
                    | tee -a "${LOG_FILE:-/dev/null}" >&2 || true
            else
                log "  用 dmesg | grep -i 'apparmor.*chronyd' 确认是否为 AppArmor 拒绝"
            fi
            log "  修复（profile 保持 enforce）："
            log "    mkdir -p /etc/apparmor.d/local"
            log "    echo '  /run/systemd/notify w,' >> /etc/apparmor.d/local/usr.sbin.chronyd"
            log "    apparmor_parser -r /etc/apparmor.d/usr.sbin.chronyd"
            log "    systemctl restart chrony"
            log "  详见 docs/runbook.md 的「chrony 升级后启动失败」一节"
        fi
    else
        log "  排查：systemctl status chrony --no-pager -l"
    fi
    return 0
}

cmd_postcheck() {
    head1 "升级后检查"
    info "pveversion：$(pveversion 2>/dev/null || echo 'N/A')"

    if is_pve9; then
        ok "pve-manager 已是 9.x"
    else
        warn "pve-manager 仍是 $(pve_manager_version)，升级可能未完成；可尝试 apt -f install"
    fi

    local svc
    for svc in pveproxy pvedaemon pvestatd pvescheduler; do
        if systemctl is-active --quiet "${svc}.service" 2>/dev/null; then
            ok "${svc}.service 运行中"
        else
            err "${svc}.service 未运行"
        fi
    done

    head1 "时间同步"
    check_time_sync

    head1 "网卡名核对"
    info "新内核可能改变网卡命名，请对照 /etc/network/interfaces"
    if command -v ip >/dev/null 2>&1; then
        ip -brief link show 2>&1 | tee -a "${LOG_FILE:-/dev/null}" >&2 || true
    else
        warn "未找到 ip 命令，请手动核对网卡名"
    fi

    if command -v pve8to9 >/dev/null 2>&1; then
        head1 "再跑一次 pve8to9"
        pve8to9 --full 2>&1 | tee -a "${LOG_FILE:-/dev/null}" || true
    fi

    head1 "后续建议"
    log "  - 浏览器强制刷新 Web UI（Ctrl+Shift+R）"
    log "  - 恢复客户机：$0 start-guests"
    log "  - 可选：apt modernize-sources 把源转成 deb822 格式"
    log "  - 安全收口：见 docs/hardening.md"
}

# ---------- help ----------

cmd_help() {
    cat <<EOF
Proxmox VE 8 -> 9 升级助手 v${VERSION}

用法：
  $0 <子命令> [选项]

子命令（建议按顺序）：
  preflight       只读检查：版本、磁盘、Ceph/PBS、阻塞包，并调用官方 pve8to9 --full
  fix             处理常见阻塞/建议项（systemd-boot、grub-efi、LVM、微码、审计 socket）
  stop-guests     关闭全部运行中的 VM/CT，并保存清单
  repos           备份 /etc/apt 后，把源从 bookworm 切换到 trixie
  upgrade         执行 apt dist-upgrade 升级到 PVE 9（保留交互式提问）
  postcheck       升级后检查版本、服务、网卡命名
  start-guests    按清单恢复启动客户机
  rollback-repos  用备份还原 /etc/apt

选项：
  -y, --yes       所有确认自动通过（慎用）
  -n, --dry-run   只打印将执行的操作，不改动系统
  -h, --help      显示本帮助

典型流程：
  tmux
  ./$(basename "$0") preflight
  ./$(basename "$0") fix
  ./$(basename "$0") preflight        # 确认 FAILURES 归零
  ./$(basename "$0") stop-guests
  ./$(basename "$0") repos
  ./$(basename "$0") upgrade
  ./$(basename "$0") postcheck
  reboot
  ./$(basename "$0") postcheck
  ./$(basename "$0") start-guests

前置要求（脚本不会替你做）：
  - 全部 VM/CT 已有经过验证的备份
  - 具备 IPMI / IKVM / 物理控制台，不要只依赖 SSH
  - 通过 tmux 或 screen 运行，避免断线中断升级

官方文档：https://pve.proxmox.com/wiki/Upgrade_from_8_to_9
EOF
}

# ---------- main ----------

main() {
    local cmd=""
    local args=()
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -y|--yes)     ASSUME_YES=1 ;;
            -n|--dry-run) DRY_RUN=1 ;;
            -h|--help)    cmd_help; exit 0 ;;
            -*)           die "未知选项：$1（用 -h 查看帮助）" ;;
            *)            args+=("$1") ;;
        esac
        shift
    done

    cmd="${args[0]:-help}"

    case "$cmd" in
        help) cmd_help; exit 0 ;;
    esac

    require_root
    require_pve
    setup_logging

    (( DRY_RUN )) && warn "DRY-RUN 模式：不会对系统做任何改动"

    # 用 "|| rc=$?" 承接返回码：子命令报告「发现问题」是正常结果，不该触发 ERR trap
    local rc=0
    case "$cmd" in
        preflight)      cmd_preflight      || rc=$? ;;
        fix)            cmd_fix            || rc=$? ;;
        stop-guests)    cmd_stop_guests    || rc=$? ;;
        start-guests)   cmd_start_guests   || rc=$? ;;
        repos)          cmd_repos          || rc=$? ;;
        upgrade)        cmd_upgrade        || rc=$? ;;
        postcheck)      cmd_postcheck      || rc=$? ;;
        rollback-repos) cmd_rollback_repos || rc=$? ;;
        *)              err "未知子命令：${cmd}"; cmd_help; exit 1 ;;
    esac
    exit "$rc"
}

main "$@"
