#!/usr/bin/env bash
# ============================================================================
#  hellomirrors.sh  -  hellomirrors 软件源切换工具
#
#  支持将系统软件源切换至腾讯云 / 华为云 / 阿里云 / 中科大镜像。当前支持
#  CentOS 7/8、CentOS Stream 8/9/10、Ubuntu 24.04 及 Debian 12/13 amd64。
#
#  用法:
#      sudo bash hellomirrors.sh                   # 交互式 (数字选源, 回车默认腾讯)
#      sudo bash hellomirrors.sh -s aliyun         # 直接指定阿里云 (非交互)
#      sudo bash hellomirrors.sh -s huawei -e      # 指定华为云, 跳过 EPEL
#      sudo bash hellomirrors.sh -h                # 查看帮助
#
#  选项:
#      -s, --source <name>    镜像源: tencent | huawei | aliyun | ustc
#      -i, --intranet         强制内网地址 (默认自动探测本机云厂商)
#      -p, --protocol <p>     协议: http | https  (默认 https)
#      -e, --no-epel          不安装 / 不切换 EPEL 源
#          --dry-run          只检测并显示计划, 不修改系统
#          --restore          从首次备份恢复 repo 配置
#      -v, --version          显示版本
#      -h, --help             显示帮助
# ============================================================================

set -euo pipefail

# ----------------------------------------------------------------------------
#  全局配置
# ----------------------------------------------------------------------------

## 镜像源地址表: key|中文名|公网域名|内网域名
## 内网地址仅在该云厂商 ECS 内可达, 走内网免公网流量。
readonly SCRIPT_VERSION="3.0.0-dev"
readonly MIRROR_SOURCES=(
    "tencent|腾讯云|mirrors.tencent.com|mirrors.tencentyun.com"
    "huawei|华为云|mirrors.huaweicloud.com|mirrors.myhuaweicloud.com"
    "aliyun|阿里云|mirrors.aliyun.com|mirrors.cloud.aliyuncs.com"
    "ustc|中科大|mirrors.ustc.edu.cn|"
)

# 镜像站可能限制旧版 curl 的默认标识, 使用明确的工具标识避免误判为限流。
readonly HTTP_USER_AGENT="hellomirrors/${SCRIPT_VERSION}"

## 云厂商元数据探测地址 (link-local IP, 不依赖 DNS, 各云固定不变)
## 每家多个候选, 任一可达即判定为本机为该云 ECS。
readonly -A CLOUD_META=(
    ["tencent"]="http://169.254.0.23/latest/meta-data/instance-id http://metadata.tencentyun.com/latest/meta-data/instance-id"
    ["huawei"]="http://169.254.169.254/latest/meta-data/instance-id"
    ["aliyun"]="http://100.100.100.200/latest/meta-data/instance-id"
)

## 命令行可覆盖的运行参数
VENDOR=""            # tencent | huawei | aliyun | ustc  (空则进入交互菜单)
PROTOCOL="https"     # http | https
USE_INTRANET="auto"  # auto | yes | no  (auto=自动探测本机云厂商)
ENABLE_EPEL="yes"    # yes | no
DRY_RUN="no"
RESTORE_ONLY="no"

## 路径在系统识别后按发行版配置。环境变量用于测试夹具和受控部署。
readonly REPO_DIR_OVERRIDE="${HELLOMIRRORS_REPO_DIR:-}"
readonly REPO_BACKUP_OVERRIDE="${HELLOMIRRORS_REPO_BACKUP:-}"
readonly LOCK_FILE_OVERRIDE="${HELLOMIRRORS_LOCK_FILE:-}"
readonly OS_RELEASE_FILE="${HELLOMIRRORS_OS_RELEASE:-/etc/os-release}"
readonly ARCH_OVERRIDE="${HELLOMIRRORS_ARCH:-}"
REPO_DIR="${REPO_DIR_OVERRIDE}"
REPO_BACKUP="${REPO_BACKUP_OVERRIDE}"
LOCK_FILE="${LOCK_FILE_OVERRIDE}"

## 事务状态: 每次运行保留临时快照, 成功后删除, 失败时自动恢复
TRANSACTION_DIR=""
TRANSACTION_ACTIVE="no"
RECOVERY_ACTIVE="no"
RECOVERY_PERFORMED="no"
CHANGES_COMMITTED="no"
SIGNAL_NAME=""
ORIGINAL_ARGS=()

## 运行时状态 (由 detect_system / resolve_mirror / detect_cloud 填充)
SYS_NAME=""          # CentOS | CentOS Stream
SYS_VERSION=""       # 完整版本号, 如 7
SYS_MAJOR=""         # 主版本号, 如 7
ARCH=""              # 架构, 如 x86_64
DISTRO_FAMILY=""     # rpm | apt
APT_CODENAME=""      # APT suite, 如 noble / bookworm
DEB_ARCH=""          # APT 架构, 如 amd64
APT_SOURCE_TYPES="deb"
APT_ARCHIVE_ROOT=""  # 主仓库根路径, 如 ubuntu / debian
APT_SECURITY_ROOT="" # 安全仓库根路径, 如 ubuntu / debian-security
APT_COMPONENTS=""
APT_KEYRING=""
APT_MANAGED_FILES=()
APT_INCLUDE_BACKUP_FILES="no"
VENDOR_NAME=""       # 厂商中文名, 如 腾讯云
MIRROR_HOST=""       # 镜像域名, 如 mirrors.tencent.com
CLOUD_VENDOR=""      # 探测到的本机云厂商 key, 探测不到为空

# ----------------------------------------------------------------------------
#  输出工具 -- 自有的步骤化提示风格
# ----------------------------------------------------------------------------

# 颜色: 仅在交互终端启用, 避免管道/日志里混入颜色码
if [[ -t 1 ]]; then
    C_STEP='\033[1;37m'
    C_OK='\033[32m'        C_DO='\033[36m'
    C_WARN='\033[33m'      C_ERR='\033[31m'
    C_DIM='\033[2m'        C_RST='\033[0m'
else
    C_STEP='' C_OK='' C_DO='' C_WARN='' C_ERR='' C_DIM='' C_RST=''
fi

say()  { printf "%b\n" "$*"; }
ok()   { say "  ${C_OK}✓${C_RST} $*"; }
do_()  { say "  ${C_DO}▶${C_RST} $*"; }
warn() { say "  ${C_WARN}⚠${C_RST} $*"; }
err()  { say "  ${C_ERR}✗${C_RST} $*" >&2; }
dim()  { say "${C_DIM}$*${C_RST}"; }
die()  { err "$*"; exit 1; }

clear_screen() {
    if [[ -t 1 && -n "${TERM:-}" && "${TERM}" != "dumb" ]]; then
        printf '\033[2J\033[H'
    fi
}

# 步骤标题: step 1/2 请选择镜像源
step() { local n="$1" total="$2"; shift 2; say; say "${C_STEP}[$n/$total] $*${C_RST}"; }

# 分隔线 / 顶底框线
hr()  { say "${C_DIM}────────────────────────────────────────────────${C_RST}"; }
top() { say "${C_DIM}╔════════════════════════════════════════════════╗${C_RST}"; }
mid() { say "${C_DIM}────────────────────────────────────────────────${C_RST}"; }
bot() { say "${C_DIM}╚════════════════════════════════════════════════╝${C_RST}"; }

# 标题横幅 (上下双线包夹, 标题居中, 不画右框避免中英文宽度错位)
banner() {
    top
    say ""
    say "                  ${C_STEP}hellomirrors${C_RST}"
    say ""
    say "             你好镜像，你好世界。"
    say "       源更近，更新更快，切换更安心。"
    say ""
    bot
}

# 温馨提示 (单行, 不显眼)
tip() {
    dim "     $*"
}

print_restore_hint() {
    local script_path
    printf -v script_path '%q' "$1"
    tip "温馨提示：如需恢复，可执行命令: bash ${script_path} --restore"
}

build_invocation() {
    local script_path arg quoted command
    printf -v script_path '%q' "$0"
    command="bash ${script_path}"
    for arg in "${ORIGINAL_ARGS[@]}"; do
        printf -v quoted '%q' "$arg"
        command+=" ${quoted}"
    done
    printf '%s' "$command"
}

print_retry_hint() {
    tip "可重新执行: $(build_invocation)"
}

build_source_invocation() {
    local source="$1" script_path quoted command
    printf -v script_path '%q' "$0"
    printf -v quoted '%q' "$source"
    command="bash ${script_path} --source ${quoted} --protocol ${PROTOCOL}"
    [[ "$ENABLE_EPEL" == "no" ]] && command+=" --no-epel"
    [[ "$DRY_RUN" == "yes" ]] && command+=" --dry-run"
    printf '%s' "$command"
}

die_retry() {
    err "$*"
    print_retry_hint
    exit 1
}

# ----------------------------------------------------------------------------
#  用法
# ----------------------------------------------------------------------------
usage() {
    cat <<EOF
hellomirrors - 软件源切换工具 (支持 CentOS / CentOS Stream / Ubuntu 24.04 / Debian 12/13)

用法:
  sudo bash $0 [选项]

选项:
  -s, --source <name>    镜像源: tencent | huawei | aliyun | ustc
  -i, --intranet         强制使用内网地址 (默认自动探测本机云厂商)
  -p, --protocol <p>     协议: http | https  (默认 https)
  -e, --no-epel          不安装 / 不切换 EPEL 源 (仅 CentOS)
      --dry-run          只检测并显示计划, 不修改系统
      --restore          从首次备份恢复 repo 配置
  -v, --version          显示版本
  -h, --help             显示本帮助

无参数运行时进入交互菜单 (自动探测本机云厂商并排首位, 回车默认内网)。
EOF
    exit 0
}

# ----------------------------------------------------------------------------
#  参数解析
# ----------------------------------------------------------------------------
parse_args() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
        -s|--source)
            [[ $# -ge 2 ]] || die "$1 需要指定镜像源"
            [[ -n "$2" ]] || die "$1 需要指定镜像源"
            VENDOR="${2,,}"
            shift 2
            ;;
        -i|--intranet) USE_INTRANET="yes"; shift ;;
        -p|--protocol)
            [[ $# -ge 2 ]] || die "$1 需要指定协议"
            [[ -n "$2" ]] || die "$1 需要指定协议"
            PROTOCOL="${2,,}"
            shift 2
            ;;
        -e|--no-epel)  ENABLE_EPEL="no"; shift ;;
        --dry-run)      DRY_RUN="yes"; shift ;;
        --restore)      RESTORE_ONLY="yes"; shift ;;
        -v|--version)   say "hellomirrors ${SCRIPT_VERSION}"; exit 0 ;;
        -h|--help)     usage ;;
        *) die "未知参数: $1 (用 -h 查看帮助)" ;;
        esac
    done
    if [[ "$RESTORE_ONLY" == "yes" && "$DRY_RUN" == "yes" ]]; then
        die "--restore 不能与 --dry-run 同时使用"
    fi
}

# ----------------------------------------------------------------------------
#  云厂商探测 -- 串行测三家元数据接口, 判断本机是否为某云 ECS
#  每家用 link-local IP 直连 (不依赖 DNS), 兼顾多候选地址
# ----------------------------------------------------------------------------
detect_cloud() {
    CLOUD_VENDOR=""

    # 串行测三家元数据, 每家用 timeout 1 秒兜底, 命中即停 (最多 3 秒)
    # 命中且内网镜像域名可达 -> 标记本机云厂商 (走内网)
    # 否则静默走公网 (不告知用户, 避免困惑)
    local m
    for m in "${MIRROR_SOURCES[@]}"; do
        local key name _ _
        IFS='|' read -r key name _ _ <<<"$m"
        [[ -n "${CLOUD_META[$key]:-}" ]] || continue
        local url hit="no"
        for url in ${CLOUD_META[$key]}; do
            if timeout 1 curl --noproxy '*' -fsS --max-time 1 "$url" >/dev/null 2>&1; then
                hit="yes"; break
            fi
        done
        if [[ "$hit" == "yes" ]]; then
            # 命中后追加验证内网镜像域名可达性 (DNS 可能被改导致内网域名解析失败)
            local intranet_host=""
            local mm
            for mm in "${MIRROR_SOURCES[@]}"; do
                local k _ _ intranet
                IFS='|' read -r k _ _ intranet <<<"$mm"
                [[ "$k" == "$key" ]] && intranet_host="$intranet" && break
            done
            if timeout 2 curl --noproxy '*' -A "$HTTP_USER_AGENT" -LfsS --max-time 2 \
                "${PROTOCOL}://${intranet_host}/" >/dev/null 2>&1; then
                CLOUD_VENDOR="$key"
                ok "检测到本机为 ${name} ECS, 将优先使用其内网源"
            fi
            return 0
        fi
    done
    return 0
}

# ----------------------------------------------------------------------------
#  交互菜单 -- 数字选源, 本机厂商排首位默认内网, 回车即开始
# ----------------------------------------------------------------------------
interactive_menu() {
    banner
    dim "  运行环境: ${SYS_NAME} ${SYS_VERSION}  ${ARCH}"

    detect_cloud

    # ---- 按探测结果排序: 本机厂商排第一 ----
    local ordered=()
    local m
    # 先放本机厂商
    for m in "${MIRROR_SOURCES[@]}"; do
        local key
        IFS='|' read -r key _ _ _ <<<"$m"
        [[ -n "$CLOUD_VENDOR" && "$key" == "$CLOUD_VENDOR" ]] && ordered+=("$m")
    done
    # 再放其余
    for m in "${MIRROR_SOURCES[@]}"; do
        local key
        IFS='|' read -r key _ _ _ <<<"$m"
        [[ -z "$CLOUD_VENDOR" || "$key" != "$CLOUD_VENDOR" ]] && ordered+=("$m")
    done

    # ---- 选择镜像源 ----
    local step_total=2
    [[ "$DISTRO_FAMILY" == "apt" ]] && step_total=1
    step 1 "$step_total" "请选择镜像源"
    local i=1
    local keys=() labels=() intranet_flags=()
    for m in "${ordered[@]}"; do
        local key name pub _
        IFS='|' read -r key name pub _ <<<"$m"
        local tag=""
        if [[ $i -eq 1 ]]; then
            tag="  (默认"
            [[ -n "$CLOUD_VENDOR" && "$key" == "$CLOUD_VENDOR" ]] && tag="${tag}, 本机内网"
            tag="${tag})"
        elif [[ -n "$CLOUD_VENDOR" && "$key" == "$CLOUD_VENDOR" ]]; then
            tag="  (本机内网)"
        fi
        say "  ${C_STEP}$i)${C_RST} ${name}${tag}"
        labels+=("$name"); keys+=("$key")
        # 选第一/本机厂商默认内网, 选其他默认公网
        [[ -n "$CLOUD_VENDOR" && "$key" == "$CLOUD_VENDOR" ]] && intranet_flags+=("yes") || intranet_flags+=("no")
        i=$((i+1))
    done
    local choice max_choice="${#keys[@]}"
    while true; do
        read -rp $'\n  请输入序号 [1-'"${max_choice}"$'], 回车=1: ' choice || choice=""
        choice="${choice// /}"
        [[ -z "$choice" ]] && choice=1
        if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= max_choice )); then break; fi
        warn "无效输入, 请输入 1-${max_choice}"
    done
    VENDOR="${keys[$((choice-1))]}"
    VENDOR_NAME="${labels[$((choice-1))]}"
    # 选本机厂商=内网, 选其他=公网 (除非命令行已强制 -i)
    [[ "$USE_INTRANET" != "yes" ]] && USE_INTRANET="${intranet_flags[$((choice-1))]}"
    ok "已选择: ${VENDOR_NAME}"

    [[ "$DISTRO_FAMILY" == "apt" ]] && return 0

    # ---- EPEL ----
    step 2 2 "EPEL 扩展源"
    dim "  安装 EPEL 后可安装 htop / nginx / redis 等扩展软件包。"
    local ep
    read -rp $'\n  是否安装 EPEL? [y/n], 回车=y: ' ep || ep=""
    ep="${ep,,}"
    if [[ "$ep" == "n" || "$ep" == "no" ]]; then
        ENABLE_EPEL="no"; dim "  跳过 EPEL"
    else
        ENABLE_EPEL="yes"; ok "将安装 EPEL"
    fi
    return 0
}

# ----------------------------------------------------------------------------
#  根据 VENDOR 解析出显示名与地址
# ----------------------------------------------------------------------------
resolve_mirror() {
    local m
    for m in "${MIRROR_SOURCES[@]}"; do
        local key name pub intranet
        IFS='|' read -r key name pub intranet <<<"$m"
        if [[ "$key" == "$VENDOR" ]]; then
            VENDOR_NAME="$name"
            if [[ "$USE_INTRANET" == "yes" && -z "$intranet" ]]; then
                die "${name}不提供专用内网地址, 请移除 --intranet"
            fi
            # auto 模式: 本机是该厂商 ECS 才用内网, 否则公网
            if [[ "$USE_INTRANET" == "yes" ]]; then
                MIRROR_HOST="$intranet"
            elif [[ "$USE_INTRANET" == "auto" && "$CLOUD_VENDOR" == "$VENDOR" ]]; then
                MIRROR_HOST="$intranet"
                USE_INTRANET="yes"
            else
                MIRROR_HOST="$pub"
                USE_INTRANET="no"
            fi
            return 0
        fi
    done
    die "不支持的镜像源: $VENDOR (可选: tencent / huawei / aliyun / ustc)"
}

# ----------------------------------------------------------------------------
#  生成主仓库元数据探测路径, 避免站点首页可达但实际仓库不存在
# ----------------------------------------------------------------------------
mirror_probe_path() {
    if [[ "$DISTRO_FAMILY" == "apt" ]]; then
        printf '%s/dists/%s/InRelease' "$APT_ARCHIVE_ROOT" "$APT_CODENAME"
        return 0
    fi

    local branch lockver
    if [[ "$SYS_NAME" == "CentOS Stream" ]]; then
        if [[ "$SYS_MAJOR" == "8" ]]; then
            printf 'centos-vault/8-stream/BaseOS/%s/os/repodata/repomd.xml' "$ARCH"
        else
            printf 'centos-stream/%s-stream/BaseOS/%s/os/repodata/repomd.xml' "$SYS_MAJOR" "$ARCH"
        fi
        return 0
    fi

    if [[ "$SYS_MAJOR" == "7" ]]; then
        if [[ "$ARCH" == "x86_64" ]]; then branch="centos-vault"; else branch="centos-vault/altarch"; fi
        lockver="7.9.2009"
        printf '%s/%s/os/%s/repodata/repomd.xml' "$branch" "$lockver" "$ARCH"
    else
        branch="centos-vault"
        lockver="8.5.2111"
        printf '%s/%s/BaseOS/%s/os/repodata/repomd.xml' "$branch" "$lockver" "$ARCH"
    fi
}

main_probe_paths() {
    local branch
    case "${SYS_NAME}:${SYS_MAJOR}" in
    "CentOS:7")
        if [[ "$ARCH" == "x86_64" ]]; then branch="centos-vault"; else branch="centos-vault/altarch"; fi
        printf 'BaseOS|%s/7.9.2009/os/%s/repodata/repomd.xml\n' "$branch" "$ARCH"
        printf 'Updates|%s/7.9.2009/updates/%s/repodata/repomd.xml\n' "$branch" "$ARCH"
        printf 'Extras|%s/7.9.2009/extras/%s/repodata/repomd.xml\n' "$branch" "$ARCH"
        ;;
    "CentOS:8")
        printf 'BaseOS|centos-vault/8.5.2111/BaseOS/%s/os/repodata/repomd.xml\n' "$ARCH"
        printf 'AppStream|centos-vault/8.5.2111/AppStream/%s/os/repodata/repomd.xml\n' "$ARCH"
        printf 'Extras|centos-vault/8.5.2111/extras/%s/os/repodata/repomd.xml\n' "$ARCH"
        if [[ "$ENABLE_EPEL" == "yes" ]]; then
            printf 'PowerTools|centos-vault/8.5.2111/PowerTools/%s/os/repodata/repomd.xml\n' "$ARCH"
        fi
        ;;
    "CentOS Stream:8")
        printf 'BaseOS|centos-vault/8-stream/BaseOS/%s/os/repodata/repomd.xml\n' "$ARCH"
        printf 'AppStream|centos-vault/8-stream/AppStream/%s/os/repodata/repomd.xml\n' "$ARCH"
        printf 'Extras|centos-vault/8-stream/extras/%s/os/repodata/repomd.xml\n' "$ARCH"
        if [[ "$ENABLE_EPEL" == "yes" ]]; then
            printf 'PowerTools|centos-vault/8-stream/PowerTools/%s/os/repodata/repomd.xml\n' "$ARCH"
        fi
        ;;
    "CentOS Stream:9"|"CentOS Stream:10")
        printf 'BaseOS|centos-stream/%s-stream/BaseOS/%s/os/repodata/repomd.xml\n' "$SYS_MAJOR" "$ARCH"
        printf 'AppStream|centos-stream/%s-stream/AppStream/%s/os/repodata/repomd.xml\n' "$SYS_MAJOR" "$ARCH"
        printf 'Extras|centos-stream/SIGs/%s-stream/extras/%s/extras-common/repodata/repomd.xml\n' \
            "$SYS_MAJOR" "$ARCH"
        if [[ "$ENABLE_EPEL" == "yes" ]]; then
            printf 'CRB|centos-stream/%s-stream/CRB/%s/os/repodata/repomd.xml\n' "$SYS_MAJOR" "$ARCH"
        fi
        ;;
    *)
        die "无法生成仓库预检路径: ${SYS_NAME} ${SYS_MAJOR}"
        ;;
    esac
}

epel_probe_path() {
    if [[ "$SYS_MAJOR" == "7" ]]; then
        printf 'epel-archive/7/%s/repodata/repomd.xml' "$ARCH"
    else
        printf 'epel/%s/Everything/%s/repodata/repomd.xml' "$SYS_MAJOR" "$ARCH"
    fi
}

epel_probe_paths() {
    printf 'EPEL|%s\n' "$(epel_probe_path)"
    if [[ "$SYS_MAJOR" == "9" ]]; then
        printf 'EPEL Next|epel/next/9/Everything/%s/repodata/repomd.xml\n' "$ARCH"
    fi
}

epel_release_probe_paths() {
    if [[ "$SYS_MAJOR" == "7" ]]; then
        printf 'epel-release|epel-archive/7/x86_64/Packages/e/epel-release-7-14.noarch.rpm\n'
    else
        printf 'epel-release|epel/epel-release-latest-%s.noarch.rpm\n' "$SYS_MAJOR"
    fi
    if [[ "$SYS_MAJOR" == "9" ]]; then
        printf 'epel-next-release|epel/epel-next-release-latest-9.noarch.rpm\n'
    fi
}

apt_probe_targets() {
    printf '%s|%s\n' "$APT_ARCHIVE_ROOT" "$APT_CODENAME" \
        "$APT_ARCHIVE_ROOT" "${APT_CODENAME}-updates" \
        "$APT_ARCHIVE_ROOT" "${APT_CODENAME}-backports" \
        "$APT_SECURITY_ROOT" "${APT_CODENAME}-security"
}

# ----------------------------------------------------------------------------
#  HTTP 探测 -- 记录具体失败原因, 供仓库完整性检查输出
# ----------------------------------------------------------------------------
PROBE_ERROR=""
PROBE_REPO=""
PROBE_PATH=""

set_curl_probe_error() {
    local rc="$1" status="$2"
    case "$rc" in
    6)  PROBE_ERROR="DNS 解析失败" ;;
    7)  PROBE_ERROR="连接被拒绝或网络不可达" ;;
    22)
        if [[ "$status" == "429" ]]; then
            PROBE_ERROR="HTTP 429 (请求受限)"
        elif [[ -n "$status" && "$status" != "000" ]]; then
            PROBE_ERROR="HTTP ${status}"
        else
            PROBE_ERROR="镜像站拒绝请求"
        fi
        ;;
    28) PROBE_ERROR="连接超时" ;;
    35) PROBE_ERROR="TLS 握手失败" ;;
    47) PROBE_ERROR="重定向次数过多" ;;
    60) PROBE_ERROR="TLS 证书校验失败" ;;
    *)  PROBE_ERROR="请求失败 (curl 错误 ${rc})" ;;
    esac
}

download_url() {
    local url="$1" output="$2" status="" rc=0
    PROBE_ERROR=""
    if status="$(curl -A "$HTTP_USER_AGENT" -LfsS --retry 1 --retry-delay 0 \
        --max-redirs 3 --connect-timeout 3 --max-time 8 \
        -o "$output" -w '%{http_code}' "$url" 2>/dev/null)"; then
        return 0
    else
        rc=$?
    fi
    set_curl_probe_error "$rc" "$status"
    return 1
}

probe_metadata_object() {
    local url="$1" status="" rc=0
    PROBE_ERROR=""

    if status="$(curl -A "$HTTP_USER_AGENT" -LfsSI --retry 1 --retry-delay 0 \
        --max-redirs 3 --connect-timeout 3 --max-time 5 \
        -o /dev/null -w '%{http_code}' "$url" 2>/dev/null)"; then
        return 0
    else
        rc=$?
    fi

    # 少数镜像/CDN 不支持 HEAD；仅在方法被拒绝时回退到单字节 GET。
    if [[ "$status" == "403" || "$status" == "405" || "$status" == "501" ]]; then
        if status="$(curl -A "$HTTP_USER_AGENT" -LfsS --range 0-0 --retry 1 \
            --retry-delay 0 --max-redirs 3 --connect-timeout 3 --max-time 5 \
            -o /dev/null -w '%{http_code}' "$url" 2>/dev/null)"; then
            return 0
        else
            rc=$?
        fi
    fi

    set_curl_probe_error "$rc" "$status"
    return 1
}

probe_repository_metadata() {
    local host="$1" path="$2" repomd url base ref detail
    local -a references=()
    url="${PROTOCOL}://${host}/${path}"
    repomd="$(mktemp "${TMPDIR:-/tmp}/hellomirrors-repomd.XXXXXX")" || {
        PROBE_ERROR="无法创建元数据检查临时文件"
        return 1
    }

    if ! download_url "$url" "$repomd"; then
        detail="$PROBE_ERROR"
        rm -f -- "$repomd"
        PROBE_ERROR="仓库入口不可用: ${detail}"
        return 1
    fi

    mapfile -t references < <(sed -n 's/.*<location[^>]*href="\([^"]*\)".*/\1/p' "$repomd")
    rm -f -- "$repomd"
    if [[ ${#references[@]} -eq 0 ]]; then
        PROBE_ERROR="repomd.xml 无有效元数据引用"
        return 1
    fi

    base="${url%repodata/repomd.xml}"
    for ref in "${references[@]}"; do
        if [[ "$ref" != repodata/* || "$ref" == *".."* ]]; then
            PROBE_ERROR="repomd.xml 包含异常引用: ${ref}"
            return 1
        fi
        if ! probe_metadata_object "${base}${ref}"; then
            detail="$PROBE_ERROR"
            PROBE_ERROR="元数据引用不可用: ${ref} (${detail})"
            return 1
        fi
    done
    return 0
}

probe_apt_repository_metadata() {
    local host="$1" root="$2" suite="$3" inrelease url component path detail
    local -a components=()
    url="${PROTOCOL}://${host}/${root}/dists/${suite}/InRelease"
    inrelease="$(mktemp "${TMPDIR:-/tmp}/hellomirrors-inrelease.XXXXXX")" || {
        PROBE_ERROR="无法创建元数据检查临时文件"
        return 1
    }

    if ! download_url "$url" "$inrelease"; then
        detail="$PROBE_ERROR"
        rm -f -- "$inrelease"
        PROBE_ERROR="InRelease 不可用: ${detail}"
        return 1
    fi

    read -r -a components <<<"$APT_COMPONENTS"
    for component in "${components[@]}"; do
        path="${component}/binary-${DEB_ARCH}/Packages.xz"
        # grep 必须读完整个区段；-q 提前退出会在 pipefail 下让 sed 的 SIGPIPE 造成假失败。
        if ! sed -n '/^SHA256:/,/^SHA512:/p' "$inrelease" | \
            command grep -F " ${path}" >/dev/null; then
            rm -f -- "$inrelease"
            PROBE_ERROR="InRelease 缺少索引引用: ${path}"
            return 1
        fi
        if ! probe_metadata_object "${PROTOCOL}://${host}/${root}/dists/${suite}/${path}"; then
            detail="$PROBE_ERROR"
            rm -f -- "$inrelease"
            PROBE_ERROR="软件包索引不可用: ${path} (${detail})"
            return 1
        fi
    done
    rm -f -- "$inrelease"
    return 0
}

probe_failed_repository() {
    local host="$1" failed_path="$2" root suite
    if [[ "$DISTRO_FAMILY" == "apt" ]]; then
        IFS='|' read -r root suite <<<"$failed_path"
        probe_apt_repository_metadata "$host" "$root" "$suite"
    else
        probe_repository_metadata "$host" "$failed_path"
    fi
}

# ----------------------------------------------------------------------------
#  连通性预检 -- 验证主仓库及 EPEL 元数据, 内网不通自动回退公网
#  避免选错内网导致 yum 卡 30 秒重试
# ----------------------------------------------------------------------------
probe_repository_set() {
    local host="$1" repo path root suite
    PROBE_REPO=""
    PROBE_PATH=""

    if [[ "$DISTRO_FAMILY" == "apt" ]]; then
        while IFS='|' read -r root suite; do
            if ! probe_apt_repository_metadata "$host" "$root" "$suite"; then
                PROBE_REPO="$suite"
                PROBE_PATH="${root}|${suite}"
                PROBE_ERROR="${suite}: ${PROBE_ERROR}"
                return 1
            fi
        done < <(apt_probe_targets)
        return 0
    fi

    while IFS='|' read -r repo path; do
        if ! probe_repository_metadata "$host" "$path"; then
            PROBE_REPO="$repo"
            PROBE_PATH="$path"
            PROBE_ERROR="${repo}: ${PROBE_ERROR}"
            return 1
        fi
    done < <(main_probe_paths)

    if [[ "$ENABLE_EPEL" == "yes" ]]; then
        while IFS='|' read -r repo path; do
            if ! probe_repository_metadata "$host" "$path"; then
                PROBE_REPO="$repo"
                PROBE_PATH="$path"
                PROBE_ERROR="${repo}: ${PROBE_ERROR}"
                return 1
            fi
        done < <(epel_probe_paths)

        while IFS='|' read -r repo path; do
            if ! probe_metadata_object "${PROTOCOL}://${host}/${path}"; then
                PROBE_REPO="$repo"
                PROBE_PATH="$path"
                PROBE_ERROR="${repo}: 安装包不可用 (${PROBE_ERROR})"
                return 1
            fi
        done < <(epel_release_probe_paths)
    fi
    return 0
}

show_alternative_sources() {
    local failed_repo="$1" failed_path="$2" selected_error="$3"
    local m key name public _ result_error available="no"

    [[ -n "$failed_repo" && -n "$failed_path" ]] || return 0
    dim "  公网镜像 ${failed_repo} 实时检查:"
    for m in "${MIRROR_SOURCES[@]}"; do
        IFS='|' read -r key name public _ <<<"$m"
        if [[ "$public" == "$MIRROR_HOST" ]]; then
            dim "    ${name}: 不可用 (${selected_error})"
        elif probe_failed_repository "$public" "$failed_path"; then
            ok "${name}: 可用"
            tip "重试: $(build_source_invocation "$key")"
            available="yes"
        else
            result_error="$PROBE_ERROR"
            dim "    ${name}: 不可用 (${result_error})"
        fi
    done
    [[ "$available" == "yes" ]] || warn "未找到可用的备用公网镜像"
}

check_connectivity() {
    do_ "验证仓库元数据完整性 ${MIRROR_HOST} ..."

    if probe_repository_set "$MIRROR_HOST"; then
        ok "仓库元数据完整"
        return 0
    fi

    # 不通: 若当前是内网地址, 尝试回退公网
    if [[ "$USE_INTRANET" == "yes" ]]; then
        local intranet_error="$PROBE_ERROR"
        # 找公网地址
        local pub_addr=""
        local m
        for m in "${MIRROR_SOURCES[@]}"; do
            local key _ pub _
            IFS='|' read -r key _ pub _ <<<"$m"
            [[ "$key" == "$VENDOR" ]] && pub_addr="$pub" && break
        done
        if [[ -n "$pub_addr" ]] && probe_repository_set "$pub_addr"; then
            dim "  内网 ${MIRROR_HOST} 不可用 (${intranet_error}), 已改用公网 ${pub_addr}"
            MIRROR_HOST="$pub_addr"
            USE_INTRANET="no"
            ok "仓库元数据完整 (公网回退)"
            return 0
        fi
        if [[ -n "$pub_addr" ]]; then
            local public_error="$PROBE_ERROR" failed_repo="$PROBE_REPO" failed_path="$PROBE_PATH"
            err "内网 ${MIRROR_HOST} 不可用 (${intranet_error})"
            err "公网 ${pub_addr} 也不可用 (${public_error})"
            show_alternative_sources "$failed_repo" "$failed_path" "$public_error"
            return 1
        fi
    fi

    local selected_error="$PROBE_ERROR" failed_repo="$PROBE_REPO" failed_path="$PROBE_PATH"
    err "${VENDOR_NAME} ${SYS_NAME} ${SYS_MAJOR} ${failed_repo} 元数据不完整"
    err "${selected_error}"
    dim "  本次切换尚未修改 repo"
    show_alternative_sources "$failed_repo" "$failed_path" "$selected_error"
    return 1
}

# ----------------------------------------------------------------------------
#  权限与系统检测
# ----------------------------------------------------------------------------
preflight_common() {
    if [[ ( "$DRY_RUN" != "yes" || "$RESTORE_ONLY" == "yes" ) && $EUID -ne 0 ]]; then
        die "请使用 root 运行本脚本"
    fi
    [[ "$PROTOCOL" != "http" && "$PROTOCOL" != "https" ]] && die "协议只能是 http 或 https"
    local cmd
    for cmd in curl timeout grep sed awk uname cp rm mv cat mktemp; do
        command -v "$cmd" >/dev/null 2>&1 || die "缺少依赖命令: $cmd"
    done
    return 0
}

configure_repository_paths() {
    if [[ -z "$REPO_DIR" ]]; then
        case "$DISTRO_FAMILY" in
        rpm) REPO_DIR="/etc/yum.repos.d" ;;
        apt) REPO_DIR="/etc/apt" ;;
        *) die "无法为未知发行版配置软件源目录" ;;
        esac
    fi
    if [[ -z "$REPO_BACKUP" ]]; then
        if [[ "$DISTRO_FAMILY" == "apt" ]]; then
            REPO_BACKUP="/etc/apt.hellomirrors.bak"
        else
            REPO_BACKUP="${REPO_DIR}.bak"
        fi
    fi
    [[ -n "$LOCK_FILE" ]] || LOCK_FILE="/var/lock/hellomirrors.lock"
}

preflight_system() {
    if [[ "$DRY_RUN" != "yes" || "$RESTORE_ONLY" == "yes" ]]; then
        command -v flock >/dev/null 2>&1 || die "缺少依赖命令: flock (util-linux)"
    fi
    case "$DISTRO_FAMILY" in
    rpm)
        command -v rpm >/dev/null 2>&1 || die "缺少依赖命令: rpm"
        if ! command -v dnf >/dev/null 2>&1 && ! command -v yum >/dev/null 2>&1; then
            die "缺少软件包管理器: yum 或 dnf"
        fi
        ;;
    apt)
        command -v apt-get >/dev/null 2>&1 || die "缺少软件包管理器: apt-get"
        command -v dpkg >/dev/null 2>&1 || die "缺少依赖命令: dpkg"
        [[ "$(dpkg --print-architecture 2>/dev/null)" == "$DEB_ARCH" ]] || \
            die "dpkg 架构与系统不匹配, 需要 ${DEB_ARCH}"
        [[ -f "$APT_KEYRING" ]] || die "缺少 ${SYS_NAME} 归档签名密钥: ${APT_KEYRING}"
        ;;
    esac
    [[ -d "$REPO_DIR" ]] || die "软件源目录不存在: ${REPO_DIR}"
    return 0
}

detect_system() {
    local os_id="" os_name="" os_version="" os_codename="" os_fields=""
    if [[ -f "$OS_RELEASE_FILE" ]]; then
        # 测试可注入 os-release 路径；只在子 shell 中读取，避免污染脚本全局变量。
        # shellcheck disable=SC1090
        os_fields="$(. "$OS_RELEASE_FILE" && printf '%s|%s|%s|%s' \
            "${ID:-}" "${NAME:-}" "${VERSION_ID:-}" "${VERSION_CODENAME:-}")"
        IFS='|' read -r os_id os_name os_version os_codename <<<"$os_fields"
    fi
    ARCH="${ARCH_OVERRIDE:-$(uname -m)}"

    case "$os_id" in
    centos)
        DISTRO_FAMILY="rpm"
        if [[ "$os_name" == *"Stream"* ]]; then
            SYS_NAME="CentOS Stream"
        else
            SYS_NAME="CentOS"
        fi

        SYS_VERSION="$(rpm -E '%{rhel}' 2>/dev/null || true)"
        [[ -n "$SYS_VERSION" ]] || SYS_VERSION="$os_version"
        [[ -n "$SYS_VERSION" ]] || die "无法识别系统版本"
        SYS_MAJOR="${SYS_VERSION%%.*}"

        case "$SYS_NAME" in
        CentOS)
            [[ "$SYS_MAJOR" == "7" || "$SYS_MAJOR" == "8" ]] || \
                die "CentOS 仅支持 7 / 8, 当前主版本 ${SYS_MAJOR}"
            ;;
        "CentOS Stream")
            [[ "$SYS_MAJOR" == "8" || "$SYS_MAJOR" == "9" || "$SYS_MAJOR" == "10" ]] || \
                die "CentOS Stream 仅支持 8 / 9 / 10, 当前主版本 ${SYS_MAJOR}"
            ;;
        esac
        ;;
    ubuntu)
        DISTRO_FAMILY="apt"
        SYS_NAME="Ubuntu"
        SYS_VERSION="$os_version"
        SYS_MAJOR="${SYS_VERSION%%.*}"
        APT_CODENAME="$os_codename"
        [[ "$SYS_VERSION" == "24.04" && "$APT_CODENAME" == "noble" ]] || \
            die "Ubuntu 目前仅支持 24.04 LTS (noble), 当前版本 ${SYS_VERSION:-未知}"
        [[ "$ARCH" == "x86_64" ]] || die "Ubuntu 24.04 目前仅支持 x86_64, 当前架构 ${ARCH}"
        DEB_ARCH="amd64"
        APT_ARCHIVE_ROOT="ubuntu"
        APT_SECURITY_ROOT="ubuntu"
        APT_COMPONENTS="main restricted universe multiverse"
        APT_KEYRING="/usr/share/keyrings/ubuntu-archive-keyring.gpg"
        ENABLE_EPEL="no"
        ;;
    debian)
        DISTRO_FAMILY="apt"
        SYS_NAME="Debian"
        SYS_VERSION="$os_version"
        SYS_MAJOR="${SYS_VERSION%%.*}"
        APT_CODENAME="$os_codename"
        case "${SYS_VERSION}:${APT_CODENAME}" in
        "12:bookworm"|"13:trixie") ;;
        *)
            die "Debian 目前仅支持 12 (bookworm) / 13 (trixie), 当前版本 ${SYS_VERSION:-未知} (${APT_CODENAME:-未知})"
            ;;
        esac
        [[ "$ARCH" == "x86_64" ]] || \
            die "Debian ${SYS_VERSION} 目前仅支持 x86_64, 当前架构 ${ARCH}"
        DEB_ARCH="amd64"
        APT_ARCHIVE_ROOT="debian"
        APT_SECURITY_ROOT="debian-security"
        APT_COMPONENTS="main contrib non-free non-free-firmware"
        APT_KEYRING="/usr/share/keyrings/debian-archive-keyring.gpg"
        ENABLE_EPEL="no"
        ;;
    *)
        die "当前系统暂不支持 (ID=$os_id NAME=$os_name)"
        ;;
    esac
    return 0
}

# ----------------------------------------------------------------------------
#  修改事务 -- 进程锁 + 逐次快照 + 失败自动恢复
# ----------------------------------------------------------------------------
acquire_lock() {
    exec 9>"$LOCK_FILE" || die "无法创建运行锁: ${LOCK_FILE}"
    flock -n 9 || die "另一个 hellomirrors 正在运行, 请稍后重试"
    return 0
}

install_signal_handlers() {
    trap 'handle_signal "Ctrl+C" 130' INT
    trap 'handle_signal "TERM" 143' TERM
    trap 'handle_signal "SSH 断开(HUP)" 129' HUP
}

clear_repo_dir() {
    (
        shopt -s dotglob nullglob
        local entries=("${REPO_DIR}"/*)
        [[ ${#entries[@]} -eq 0 ]] || rm -rf -- "${entries[@]}"
    )
}

apt_official_uri_pattern() {
    case "$SYS_NAME" in
    Ubuntu)
        printf '%s' 'https?://(([[:alnum:]-]+[.])*archive[.]ubuntu[.]com|security[.]ubuntu[.]com|ports[.]ubuntu[.]com|mirrors[.]tencent[.]com|mirrors[.]tencentyun[.]com|mirrors[.]huaweicloud[.]com|mirrors[.]myhuaweicloud[.]com|mirrors[.]aliyun[.]com|mirrors[.]cloud[.]aliyuncs[.]com|mirrors[.]ustc[.]edu[.]cn)/(ubuntu|ubuntu-ports)(/|[[:space:]]|$)'
        ;;
    Debian)
        printf '%s' 'https?://(deb[.]debian[.]org|security[.]debian[.]org|ftp[.]debian[.]org|ftp[.][[:alnum:]-]+[.]debian[.]org|mirrors[.]tencent[.]com|mirrors[.]tencentyun[.]com|mirrors[.]huaweicloud[.]com|mirrors[.]myhuaweicloud[.]com|mirrors[.]aliyun[.]com|mirrors[.]cloud[.]aliyuncs[.]com|mirrors[.]ustc[.]edu[.]cn)/(debian|debian-security)(/|[[:space:]]|$)'
        ;;
    *) die "无法识别 APT 官方源: ${SYS_NAME}" ;;
    esac
}

apt_add_managed_file() {
    local candidate="$1" existing
    for existing in "${APT_MANAGED_FILES[@]}"; do
        [[ "$existing" == "$candidate" ]] && return 0
    done
    APT_MANAGED_FILES+=("$candidate")
}

collect_apt_managed_sources() {
    local candidate state relative pattern
    pattern="$(apt_official_uri_pattern)"
    APT_MANAGED_FILES=()

    # hellomirrors.sources is always controlled, including its initially absent state.
    apt_add_managed_file "${REPO_DIR}/sources.list.d/hellomirrors.sources"
    [[ -f "${REPO_DIR}/sources.list" ]] && \
        grep -Eiq -- "$pattern" "${REPO_DIR}/sources.list" && \
        apt_add_managed_file "${REPO_DIR}/sources.list"

    shopt -s nullglob
    for candidate in "${REPO_DIR}/sources.list.d/"*.list \
        "${REPO_DIR}/sources.list.d/"*.sources; do
        [[ "$candidate" == "${REPO_DIR}/sources.list.d/hellomirrors.sources" ]] && continue
        grep -Eiq -- "$pattern" "$candidate" && apt_add_managed_file "$candidate"
    done
    shopt -u nullglob

    # A restore can recreate files from the first backup. Include those paths in
    # the current transaction so a failed restore can still roll back cleanly.
    if [[ "$APT_INCLUDE_BACKUP_FILES" == "yes" && -f "${REPO_BACKUP}/manifest" ]]; then
        while IFS=$'\t' read -r state relative; do
            [[ "$state" == "present" || "$state" == "missing" ]] || continue
            [[ -n "$relative" && "$relative" != /* && "$relative" != *"../"* ]] || continue
            apt_add_managed_file "${REPO_DIR}/${relative}"
        done <"${REPO_BACKUP}/manifest"
    fi
}

capture_apt_snapshot() {
    local destination="$1" source relative
    collect_apt_managed_sources
    mkdir -p "${destination}/files"
    printf '%s\n' 'hellomirrors apt snapshot v1' >"${destination}/format"
    : >"${destination}/manifest"
    for source in "${APT_MANAGED_FILES[@]}"; do
        relative="${source#"${REPO_DIR}/"}"
        if [[ -e "$source" || -L "$source" ]]; then
            printf 'present\t%s\n' "$relative" >>"${destination}/manifest"
            mkdir -p "${destination}/files/$(dirname "$relative")"
            cp -a -- "$source" "${destination}/files/${relative}"
        else
            printf 'missing\t%s\n' "$relative" >>"${destination}/manifest"
        fi
    done
}

apply_apt_snapshot() {
    local snapshot="$1" state relative target
    [[ -f "${snapshot}/format" && -f "${snapshot}/manifest" ]] || return 1
    [[ "$(<"${snapshot}/format")" == "hellomirrors apt snapshot v1" ]] || return 1
    while IFS=$'\t' read -r state relative; do
        case "$relative" in
        ""|/*|..|../*|*/../*|*/..) return 1 ;;
        esac
        target="${REPO_DIR}/${relative}"
        case "$state" in
        present)
            [[ -e "${snapshot}/files/${relative}" || -L "${snapshot}/files/${relative}" ]] || return 1
            mkdir -p "$(dirname "$target")"
            rm -f -- "$target"
            cp -a -- "${snapshot}/files/${relative}" "$target" || return 1
            ;;
        missing)
            rm -f -- "$target" || return 1
            ;;
        *) return 1 ;;
        esac
    done <"${snapshot}/manifest"
}

recover_stale_transaction() {
    local snapshots=() disposable=()
    shopt -s nullglob
    snapshots=("${REPO_DIR}.hellomirrors-run."*)
    disposable=("${REPO_DIR}.hellomirrors-preparing."* "${REPO_DIR}.hellomirrors-committed."* \
        "${REPO_DIR}.hellomirrors-restored."*)
    shopt -u nullglob

    if [[ ${#disposable[@]} -gt 0 ]]; then
        rm -rf -- "${disposable[@]}"
        dim "  已清理 ${#disposable[@]} 个已失效的事务临时目录"
    fi

    [[ ${#snapshots[@]} -gt 0 ]] || return 0
    if [[ ${#snapshots[@]} -gt 1 ]]; then
        die "发现 ${#snapshots[@]} 个未完成的运行快照, 为避免恢复错误已停止: ${REPO_DIR}.hellomirrors-run.*"
    fi

    local snapshot="${snapshots[0]}"
    warn "发现上次异常中断留下的快照, 正在恢复..."
    RECOVERY_ACTIVE="yes"
    if { [[ "$DISTRO_FAMILY" == "apt" ]] && apply_apt_snapshot "$snapshot"; } || \
        { [[ "$DISTRO_FAMILY" != "apt" ]] && clear_repo_dir && cp -a "${snapshot}/." "${REPO_DIR}/"; }; then
        local restored_dir="${snapshot/.hellomirrors-run./.hellomirrors-restored.}"
        if ! mv "$snapshot" "$restored_dir"; then
            RECOVERY_ACTIVE="no"
            die "无法确认中断快照已恢复, 快照保留在: ${snapshot}"
        fi
        RECOVERY_ACTIVE="no"
        rm -rf -- "$restored_dir"
        RECOVERY_PERFORMED="yes"
        ok "已恢复到上次操作开始前的 repo 配置"
        return 0
    fi

    RECOVERY_ACTIVE="no"
    die "中断快照自动恢复失败, 快照保留在: ${snapshot}"
}

begin_transaction() {
    local preparing_dir run_dir
    preparing_dir="$(mktemp -d "${REPO_DIR}.hellomirrors-preparing.XXXXXX")" || \
        die "无法创建运行快照"
    if [[ "$DISTRO_FAMILY" == "apt" ]]; then
        if ! capture_apt_snapshot "$preparing_dir"; then
            rm -rf -- "$preparing_dir"
            die "无法创建 APT 受控源快照"
        fi
    elif ! cp -a "${REPO_DIR}/." "${preparing_dir}/"; then
        rm -rf -- "$preparing_dir"
        die "无法创建运行快照"
    fi
    run_dir="${preparing_dir/.hellomirrors-preparing./.hellomirrors-run.}"
    if ! mv "$preparing_dir" "$run_dir"; then
        rm -rf -- "$preparing_dir"
        die "无法提交运行快照"
    fi
    TRANSACTION_DIR="$run_dir"
    TRANSACTION_ACTIVE="yes"
    trap 'handle_exit' EXIT
    install_signal_handlers
    return 0
}

rollback_transaction() {
    [[ "$TRANSACTION_ACTIVE" == "yes" ]] || return 0

    warn "操作未完成, 正在恢复本次运行前的 repo 配置..."
    if { [[ "$DISTRO_FAMILY" == "apt" ]] && apply_apt_snapshot "$TRANSACTION_DIR"; } || \
        { [[ "$DISTRO_FAMILY" != "apt" ]] && clear_repo_dir && cp -a "${TRANSACTION_DIR}/." "${REPO_DIR}/"; }; then
        local restored_dir="${TRANSACTION_DIR/.hellomirrors-run./.hellomirrors-restored.}"
        if ! mv "$TRANSACTION_DIR" "$restored_dir"; then
            err "无法确认回滚快照状态, 快照保留在: ${TRANSACTION_DIR}"
            return 1
        fi
        TRANSACTION_DIR=""
        TRANSACTION_ACTIVE="no"
        rm -rf -- "$restored_dir"
        ok "已自动恢复原 repo 配置"
        return 0
    fi

    err "自动恢复失败, 运行快照保留在: ${TRANSACTION_DIR}"
    return 1
}

commit_transaction() {
    [[ "$TRANSACTION_ACTIVE" == "yes" ]] || return 0
    local committed_dir="${TRANSACTION_DIR/.hellomirrors-run./.hellomirrors-committed.}"
    mv "$TRANSACTION_DIR" "$committed_dir"
    TRANSACTION_DIR=""
    TRANSACTION_ACTIVE="no"
    CHANGES_COMMITTED="yes"
    trap - EXIT
    rm -rf -- "$committed_dir"
    return 0
}

handle_signal() {
    local signal_name="$1" exit_code="$2"
    SIGNAL_NAME="$signal_name"

    if [[ "$RECOVERY_ACTIVE" == "yes" ]]; then
        trap - INT TERM HUP
        warn "收到 ${signal_name}, 快照恢复被中断; 原快照仍保留, 请重新运行脚本"
        print_retry_hint
    elif [[ "$TRANSACTION_ACTIVE" != "yes" ]]; then
        trap - INT TERM HUP
        if [[ "$CHANGES_COMMITTED" == "yes" ]]; then
            warn "收到 ${signal_name}, 但软件源配置已经提交完成"
        elif [[ "$RECOVERY_PERFORMED" == "yes" ]]; then
            warn "收到 ${signal_name}, 上次中断状态已恢复, 本次换源尚未开始"
            print_retry_hint
        else
            warn "收到 ${signal_name}, 操作已取消, 系统配置尚未修改"
            print_retry_hint
        fi
    fi
    exit "$exit_code"
}

handle_exit() {
    local rc=$?
    trap - EXIT INT TERM HUP
    set +e
    if [[ -n "$SIGNAL_NAME" ]]; then
        warn "收到 ${SIGNAL_NAME}, 正在安全结束当前操作..."
    fi
    if rollback_transaction; then
        print_retry_hint
    else
        rc=1
    fi
    exit "$rc"
}

# 首次备份长期保留; 临时目录写完后再原子改名, 避免留下半份 .bak
backup_repos() {
    if [[ -d "$REPO_BACKUP" ]]; then
        dim "  已有备份 ${REPO_BACKUP}, 跳过覆盖 (保留首次原始备份)"
        return 0
    fi

    local backup_tmp
    backup_tmp="$(mktemp -d "${REPO_BACKUP}.tmp.XXXXXX")" || die "无法创建备份目录"
    if [[ "$DISTRO_FAMILY" == "apt" ]]; then
        if ! capture_apt_snapshot "$backup_tmp"; then
            rm -rf -- "$backup_tmp"
            die "原 APT 受控源备份失败"
        fi
    elif ! cp -a "${REPO_DIR}/." "${backup_tmp}/"; then
        rm -rf -- "$backup_tmp"
        die "原 repo 备份失败"
    fi
    if ! mv "$backup_tmp" "$REPO_BACKUP"; then
        rm -rf -- "$backup_tmp"
        die "无法保存原 repo 备份"
    fi
    ok "已备份原 repo 至 ${REPO_BACKUP}"
    return 0
}

restore_original() {
    [[ -d "$REPO_BACKUP" ]] || die "未找到首次备份: ${REPO_BACKUP}"
    if [[ "$DISTRO_FAMILY" == "apt" ]]; then
        APT_INCLUDE_BACKUP_FILES="yes"
        begin_transaction
        APT_INCLUDE_BACKUP_FILES="no"
    else
        begin_transaction
    fi
    if [[ "$DISTRO_FAMILY" == "apt" ]]; then
        apply_apt_snapshot "$REPO_BACKUP" || die "APT 受控源备份无效"
    else
        clear_repo_dir
        cp -a "${REPO_BACKUP}/." "${REPO_DIR}/"
    fi
    rebuild_cache
    commit_transaction
    ok "已从 ${REPO_BACKUP} 恢复原 repo 配置"
    return 0
}

# ============================================================================
#  repo 模板生成
#  说明: 这些模板是 CentOS 官方 repo 的标准结构, 用于在切源前重建干净的
#        配置文件, 避免残留旧配置干扰。生成后再用 sed 替换为镜像地址。
# ============================================================================

# CentOS 7 / 8 (已 EOL, 配合 vault 仓库)
write_centos_repo() {
    case "$1" in
    8)
        cat >"${REPO_DIR}/CentOS-Linux-AppStream.repo" <<'EOF'
[appstream]
name=CentOS Linux $releasever - AppStream
mirrorlist=http://mirrorlist.centos.org/?release=$releasever&arch=$basearch&repo=AppStream&infra=$infra
#baseurl=http://mirror.centos.org/$contentdir/$releasever/AppStream/$basearch/os/
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial
EOF
        cat >"${REPO_DIR}/CentOS-Linux-BaseOS.repo" <<'EOF'
[baseos]
name=CentOS Linux $releasever - BaseOS
mirrorlist=http://mirrorlist.centos.org/?release=$releasever&arch=$basearch&repo=BaseOS&infra=$infra
#baseurl=http://mirror.centos.org/$contentdir/$releasever/BaseOS/$basearch/os/
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial
EOF
        cat >"${REPO_DIR}/CentOS-Linux-Extras.repo" <<'EOF'
[extras]
name=CentOS Linux $releasever - Extras
mirrorlist=http://mirrorlist.centos.org/?release=$releasever&arch=$basearch&repo=extras&infra=$infra
#baseurl=http://mirror.centos.org/$contentdir/$releasever/extras/$basearch/os/
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial
EOF
        cat >"${REPO_DIR}/CentOS-Linux-Plus.repo" <<'EOF'
[plus]
name=CentOS Linux $releasever - Plus
mirrorlist=http://mirrorlist.centos.org/?release=$releasever&arch=$basearch&repo=centosplus&infra=$infra
#baseurl=http://mirror.centos.org/$contentdir/$releasever/centosplus/$basearch/os/
gpgcheck=1
enabled=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial
EOF
        cat >"${REPO_DIR}/CentOS-Linux-PowerTools.repo" <<'EOF'
[powertools]
name=CentOS Linux $releasever - PowerTools
mirrorlist=http://mirrorlist.centos.org/?release=$releasever&arch=$basearch&repo=PowerTools&infra=$infra
#baseurl=http://mirror.centos.org/$contentdir/$releasever/PowerTools/$basearch/os/
gpgcheck=1
enabled=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial
EOF
        cat >"${REPO_DIR}/CentOS-Linux-Sources.repo" <<'EOF'
[baseos-source]
name=CentOS Linux $releasever - BaseOS - Source
baseurl=http://vault.centos.org/$contentdir/$releasever/BaseOS/Source/
gpgcheck=1
enabled=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial

[appstream-source]
name=CentOS Linux $releasever - AppStream - Source
baseurl=http://vault.centos.org/$contentdir/$releasever/AppStream/Source/
gpgcheck=1
enabled=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial
EOF
        ;;
    7)
        cat >"${REPO_DIR}/CentOS-Base.repo" <<'EOF'
[base]
name=CentOS-$releasever - Base
mirrorlist=http://mirrorlist.centos.org/?release=$releasever&arch=$basearch&repo=os&infra=$infra
#baseurl=http://mirror.centos.org/centos/$releasever/os/$basearch/
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-7

[updates]
name=CentOS-$releasever - Updates
mirrorlist=http://mirrorlist.centos.org/?release=$releasever&arch=$basearch&repo=updates&infra=$infra
#baseurl=http://mirror.centos.org/centos/$releasever/updates/$basearch/
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-7

[extras]
name=CentOS-$releasever - Extras
mirrorlist=http://mirrorlist.centos.org/?release=$releasever&arch=$basearch&repo=extras&infra=$infra
#baseurl=http://mirror.centos.org/centos/$releasever/extras/$basearch/
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-7

[centosplus]
name=CentOS-$releasever - Plus
mirrorlist=http://mirrorlist.centos.org/?release=$releasever&arch=$basearch&repo=centosplus&infra=$infra
#baseurl=http://mirror.centos.org/centos/$releasever/centosplus/$basearch/
gpgcheck=1
enabled=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-7
EOF
        cat >"${REPO_DIR}/CentOS-Sources.repo" <<'EOF'
[base-source]
name=CentOS-$releasever - Base Sources
baseurl=http://vault.centos.org/centos/$releasever/os/Source/
gpgcheck=1
enabled=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-7

[updates-source]
name=CentOS-$releasever - Updates Sources
baseurl=http://vault.centos.org/centos/$releasever/updates/Source/
gpgcheck=1
enabled=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-7

[extras-source]
name=CentOS-$releasever - Extras Sources
baseurl=http://vault.centos.org/centos/$releasever/extras/Source/
gpgcheck=1
enabled=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-7
EOF
        ;;
    *) die "CentOS 仅支持 7 / 8, 当前主版本 $1" ;;
    esac
}

# CentOS Stream 8 已归档, 仓库布局仍使用 8-stream, 不能混用 CentOS Linux 8.5
write_stream8_repo() {
    cat >"${REPO_DIR}/CentOS-Stream-BaseOS.repo" <<'EOF'
[baseos]
name=CentOS Stream 8 - BaseOS
mirrorlist=http://mirrorlist.centos.org/?release=$stream&arch=$basearch&repo=BaseOS&infra=$infra
#baseurl=http://mirror.centos.org/$contentdir/$stream/BaseOS/$basearch/os/
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial
EOF
    cat >"${REPO_DIR}/CentOS-Stream-AppStream.repo" <<'EOF'
[appstream]
name=CentOS Stream 8 - AppStream
mirrorlist=http://mirrorlist.centos.org/?release=$stream&arch=$basearch&repo=AppStream&infra=$infra
#baseurl=http://mirror.centos.org/$contentdir/$stream/AppStream/$basearch/os/
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial
EOF
    cat >"${REPO_DIR}/CentOS-Stream-Extras.repo" <<'EOF'
[extras]
name=CentOS Stream 8 - Extras
mirrorlist=http://mirrorlist.centos.org/?release=$stream&arch=$basearch&repo=extras&infra=$infra
#baseurl=http://mirror.centos.org/$contentdir/$stream/extras/$basearch/os/
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial
EOF
    cat >"${REPO_DIR}/CentOS-Stream-PowerTools.repo" <<'EOF'
[powertools]
name=CentOS Stream 8 - PowerTools
mirrorlist=http://mirrorlist.centos.org/?release=$stream&arch=$basearch&repo=PowerTools&infra=$infra
#baseurl=http://mirror.centos.org/$contentdir/$stream/PowerTools/$basearch/os/
gpgcheck=1
enabled=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial
EOF
}

# CentOS Stream 9 / 10 (模板结构相同, 仅 gpgkey 不同)
write_stream_repo() {
    local gpgkey
    case "$1" in
    10) gpgkey="file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial-SHA256" ;;
    9)  gpgkey="file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial" ;;
    *)  die "CentOS Stream 仅支持 9 / 10, 当前主版本 $1" ;;
    esac

    cat >"${REPO_DIR}/centos.repo" <<EOF
[baseos]
name=CentOS Stream \$releasever - BaseOS
#baseurl=https://mirror.stream.centos.org/\$releasever-stream/BaseOS/\$basearch/os/
metalink=https://mirrors.centos.org/metalink?repo=centos-baseos-\$stream&arch=\$basearch&protocol=https,http
gpgkey=${gpgkey}
gpgcheck=1
repo_gpgcheck=0
metadata_expire=6h
countme=1
enabled=1

[appstream]
name=CentOS Stream \$releasever - AppStream
#baseurl=https://mirror.stream.centos.org/\$releasever-stream/AppStream/\$basearch/os/
metalink=https://mirrors.centos.org/metalink?repo=centos-appstream-\$stream&arch=\$basearch&protocol=https,http
gpgkey=${gpgkey}
gpgcheck=1
repo_gpgcheck=0
metadata_expire=6h
countme=1
enabled=1

[crb]
name=CentOS Stream \$releasever - CRB
#baseurl=https://mirror.stream.centos.org/\$releasever-stream/CRB/\$basearch/os/
metalink=https://mirrors.centos.org/metalink?repo=centos-crb-\$stream&arch=\$basearch&protocol=https,http
gpgkey=${gpgkey}
gpgcheck=1
repo_gpgcheck=0
metadata_expire=6h
countme=1
enabled=0
EOF
    cat >"${REPO_DIR}/centos-addons.repo" <<EOF
[highavailability]
name=CentOS Stream \$releasever - HighAvailability
#baseurl=https://mirror.stream.centos.org/\$releasever-stream/HighAvailability/\$basearch/os/
metalink=https://mirrors.centos.org/metalink?repo=centos-highavailability-\$stream&arch=\$basearch&protocol=https,http
gpgkey=${gpgkey}
gpgcheck=1
repo_gpgcheck=0
metadata_expire=6h
countme=1
enabled=0

[extras-common]
name=CentOS Stream \$releasever - Extras packages
#baseurl=https://mirror.stream.centos.org/SIGs/\$releasever-stream/extras/\$basearch/extras-common/
metalink=https://mirrors.centos.org/metalink?repo=centos-extras-sig-extras-common-\$stream&arch=\$basearch&protocol=https,http
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-SIG-Extras-SHA512
gpgcheck=1
repo_gpgcheck=0
metadata_expire=6h
countme=1
enabled=1
EOF
}

# EPEL 7 / 8 / 9 / 10
write_epel_repo() {
    case "$1" in
    10)
        cat >"${REPO_DIR}/epel.repo" <<'EOF'
[epel]
name=Extra Packages for Enterprise Linux $releasever - $basearch
#baseurl=https://download.example/pub/epel/$releasever/Everything/$basearch/
metalink=https://mirrors.fedoraproject.org/metalink?repo=epel-$releasever&arch=$basearch
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-$releasever_major
gpgcheck=1
repo_gpgcheck=0
metadata_expire=24h
countme=1
enabled=1

[epel-debuginfo]
name=Extra Packages for Enterprise Linux $releasever - $basearch - Debug
#baseurl=https://download.example/pub/epel/$releasever/Everything/$basearch/debug/
metalink=https://mirrors.fedoraproject.org/metalink?repo=epel-debug-$releasever&arch=$basearch
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-$releasever_major
gpgcheck=1
repo_gpgcheck=0
metadata_expire=24h
enabled=0

[epel-source]
name=Extra Packages for Enterprise Linux $releasever - $basearch - Source
#baseurl=https://download.example/pub/epel/$releasever/Everything/source/tree/
metalink=https://mirrors.fedoraproject.org/metalink?repo=epel-source-$releasever&arch=source
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-$releasever_major
gpgcheck=1
repo_gpgcheck=0
metadata_expire=24h
enabled=0
EOF
        ;;
    9)
        cat >"${REPO_DIR}/epel.repo" <<'EOF'
[epel]
name=Extra Packages for Enterprise Linux $releasever - $basearch
#baseurl=https://download.example/pub/epel/$releasever/Everything/$basearch/
metalink=https://mirrors.fedoraproject.org/metalink?repo=epel-$releasever&arch=$basearch&infra=$infra&content=$contentdir
enabled=1
gpgcheck=1
countme=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-$releasever

[epel-debuginfo]
name=Extra Packages for Enterprise Linux $releasever - $basearch - Debug
#baseurl=https://download.example/pub/epel/$releasever/Everything/$basearch/debug/
metalink=https://mirrors.fedoraproject.org/metalink?repo=epel-debug-$releasever&arch=$basearch&infra=$infra&content=$contentdir
enabled=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-$releasever
gpgcheck=1

[epel-source]
name=Extra Packages for Enterprise Linux $releasever - $basearch - Source
#baseurl=https://download.example/pub/epel/$releasever/Everything/source/tree/
metalink=https://mirrors.fedoraproject.org/metalink?repo=epel-source-$releasever&arch=$basearch&infra=$infra&content=$contentdir
enabled=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-$releasever
gpgcheck=1
EOF
        ;;
    8)
        cat >"${REPO_DIR}/epel.repo" <<'EOF'
[epel]
name=Extra Packages for Enterprise Linux 8 - $basearch
#baseurl=https://download.example/pub/epel/8/Everything/$basearch
metalink=https://mirrors.fedoraproject.org/metalink?repo=epel-8&arch=$basearch&infra=$infra&content=$contentdir
enabled=1
gpgcheck=1
countme=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-8

[epel-debuginfo]
name=Extra Packages for Enterprise Linux 8 - $basearch - Debug
#baseurl=https://download.example/pub/epel/8/Everything/$basearch/debug
metalink=https://mirrors.fedoraproject.org/metalink?repo=epel-debug-8&arch=$basearch&infra=$infra&content=$contentdir
enabled=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-8
gpgcheck=1

[epel-source]
name=Extra Packages for Enterprise Linux 8 - $basearch - Source
#baseurl=https://download.example/pub/epel/8/Everything/source/tree/
metalink=https://mirrors.fedoraproject.org/metalink?repo=epel-source-8&arch=$basearch&infra=$infra&content=$contentdir
enabled=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-8
gpgcheck=1
EOF
        ;;
    7)
        cat >"${REPO_DIR}/epel.repo" <<'EOF'
[epel]
name=Extra Packages for Enterprise Linux 7 - $basearch
#baseurl=http://download.example/pub/epel/7/$basearch
metalink=https://mirrors.fedoraproject.org/metalink?repo=epel-7&arch=$basearch&infra=$infra&content=$contentdir
failovermethod=priority
enabled=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-7

[epel-debuginfo]
name=Extra Packages for Enterprise Linux 7 - $basearch - Debug
#baseurl=http://download.example/pub/epel/7/$basearch/debug
metalink=https://mirrors.fedoraproject.org/metalink?repo=epel-debug-7&arch=$basearch&infra=$infra&content=$contentdir
failovermethod=priority
enabled=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-7
gpgcheck=1

[epel-source]
name=Extra Packages for Enterprise Linux 7 - $basearch - Source
#baseurl=http://download.example/pub/epel/7/source/tree/
metalink=https://mirrors.fedoraproject.org/metalink?repo=epel-source-7&arch=$basearch&infra=$infra&content=$contentdir
failovermethod=priority
enabled=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-7
gpgcheck=1
EOF
        ;;
    *) die "EPEL 仅支持 7 / 8 / 9 / 10, 当前主版本 $1" ;;
    esac
}

rewrite_epel_repo_files() {
    local epel_branch="$1" repo_file
    local -a repo_files=("${REPO_DIR}/epel.repo")
    for repo_file in "${REPO_DIR}/epel-testing.repo" \
        "${REPO_DIR}/epel-next.repo" "${REPO_DIR}/epel-next-testing.repo"; do
        [[ -f "$repo_file" ]] && repo_files+=("$repo_file")
    done

    # epel-cisco-openh264 等附属仓库不位于 EPEL 镜像树，必须保留其原始 metalink。
    sed -e "s|^#baseurl=http\(s\)\?|baseurl=${PROTOCOL}|g" \
        -e "s|^metalink=|#metalink=|g" \
        -e "s|download.example/pub/epel|${MIRROR_HOST}/${epel_branch}|g" \
        -i "${repo_files[@]}"
}

disable_apt_official_sources() {
    local file pattern sed_pattern temp_file
    mkdir -p "${REPO_DIR}/sources.list.d"
    pattern="$(apt_official_uri_pattern)"
    sed_pattern="${pattern//\//\\/}"

    # Only files captured by this transaction may be changed. Deb822 files are
    # rewritten stanza-by-stanza so third-party stanzas in the same file remain active.
    for file in "${APT_MANAGED_FILES[@]}"; do
        [[ -f "$file" ]] || continue
        case "$file" in
        *.sources)
            temp_file="$(mktemp "${file}.tmp.XXXXXX")" || die "无法创建 APT 源临时文件"
            if ! awk -v official_pattern="$pattern" '
                BEGIN { RS=""; ORS="\n\n" }
                function has_official_uri(text, count, lines, i, line, in_uris) {
                    count=split(text, lines, "\n")
                    in_uris=0
                    for (i=1; i<=count; i++) {
                        line=tolower(lines[i])
                        if (line ~ /^[^[:space:]#][^:]*:/)
                            in_uris=(line ~ /^uris[[:space:]]*:/)
                        if (in_uris && line !~ /^[[:space:]]*#/ && line ~ official_pattern)
                            return 1
                    }
                    return 0
                }
                {
                    stanza=$0
                    if (has_official_uri(stanza)) {
                        count=split(stanza, lines, "\n")
                        found=0
                        stanza=""
                        for (i=1; i<=count; i++) {
                            if (tolower(lines[i]) ~ /^[[:space:]]*enabled[[:space:]]*:/) {
                                lines[i]="Enabled: no"
                                found=1
                            }
                            stanza=stanza (i == 1 ? "" : "\n") lines[i]
                        }
                        if (!found) stanza=stanza "\nEnabled: no"
                    }
                    print stanza
                }
            ' "$file" >"$temp_file"; then
                rm -f -- "$temp_file"
                die "无法停用 ${SYS_NAME} Deb822 官方源: ${file}"
            fi
            chmod --reference="$file" "$temp_file" 2>/dev/null || true
            chown --reference="$file" "$temp_file" 2>/dev/null || true
            mv -f -- "$temp_file" "$file"
            ;;
        *.list)
            sed -E -i \
                "/^[[:space:]]*deb(-src)?[[:space:]]+(\[[^]]*\][[:space:]]+)?${sed_pattern}/ s/^/# hellomirrors disabled: /" \
                "$file"
            ;;
        esac
    done
}

detect_apt_source_types() {
    local file pattern
    pattern="$(apt_official_uri_pattern)"
    APT_SOURCE_TYPES="deb"
    for file in "${APT_MANAGED_FILES[@]}"; do
        [[ -f "$file" ]] || continue
        case "$file" in
        *.sources)
            if awk -v official_pattern="$pattern" '
                BEGIN { RS="" }
                function inspect_stanza(text, count, lines, i, line, in_types, in_uris,
                                        official, source, disabled) {
                    count=split(text, lines, "\n")
                    in_types=0
                    in_uris=0
                    official=0
                    source=0
                    disabled=0
                    for (i=1; i<=count; i++) {
                        line=tolower(lines[i])
                        if (line ~ /^[^[:space:]#][^:]*:/) {
                            in_types=(line ~ /^types[[:space:]]*:/)
                            in_uris=(line ~ /^uris[[:space:]]*:/)
                        }
                        if (in_uris && line !~ /^[[:space:]]*#/ && line ~ official_pattern)
                            official=1
                        if (in_types && line ~ /(^|[[:space:]])deb-src([[:space:]]|$)/)
                            source=1
                        if (line ~ /^[[:space:]]*enabled[[:space:]]*:[[:space:]]*no([[:space:]]|$)/)
                            disabled=1
                    }
                    return official && source && !disabled
                }
                {
                    if (inspect_stanza($0)) found=1
                }
                END { exit(found ? 0 : 1) }
            ' "$file"; then
                APT_SOURCE_TYPES="deb deb-src"
                return 0
            fi
            ;;
        *.list)
            if grep -Eiq \
                "^[[:space:]]*deb-src[[:space:]]+(\[[^]]*\][[:space:]]+)?${pattern}" "$file"; then
                APT_SOURCE_TYPES="deb deb-src"
                return 0
            fi
            ;;
        esac
    done
}

validate_apt_output_target() {
    local target="${REPO_DIR}/sources.list.d/hellomirrors.sources"
    if [[ -e "$target" ]] && ! grep -Fxq '# Managed by hellomirrors' "$target"; then
        die "拒绝覆盖非 hellomirrors 管理的文件: ${target}"
    fi
}

write_apt_sources() {
    local target="${REPO_DIR}/sources.list.d/hellomirrors.sources"
    mkdir -p "${REPO_DIR}/sources.list.d"
    validate_apt_output_target
    if [[ "$APT_ARCHIVE_ROOT" == "$APT_SECURITY_ROOT" ]]; then
        cat >"$target" <<EOF
# Managed by hellomirrors
Types: ${APT_SOURCE_TYPES}
URIs: ${PROTOCOL}://${MIRROR_HOST}/${APT_ARCHIVE_ROOT}
Suites: ${APT_CODENAME} ${APT_CODENAME}-updates ${APT_CODENAME}-backports ${APT_CODENAME}-security
Components: ${APT_COMPONENTS}
Architectures: ${DEB_ARCH}
Signed-By: ${APT_KEYRING}
EOF
    else
        cat >"$target" <<EOF
# Managed by hellomirrors
Types: ${APT_SOURCE_TYPES}
URIs: ${PROTOCOL}://${MIRROR_HOST}/${APT_ARCHIVE_ROOT}
Suites: ${APT_CODENAME} ${APT_CODENAME}-updates ${APT_CODENAME}-backports
Components: ${APT_COMPONENTS}
Architectures: ${DEB_ARCH}
Signed-By: ${APT_KEYRING}

Types: ${APT_SOURCE_TYPES}
URIs: ${PROTOCOL}://${MIRROR_HOST}/${APT_SECURITY_ROOT}
Suites: ${APT_CODENAME}-security
Components: ${APT_COMPONENTS}
Architectures: ${DEB_ARCH}
Signed-By: ${APT_KEYRING}
EOF
    fi
}

# ----------------------------------------------------------------------------
#  切换主源 -- 重建模板, 再用 sed 替换为镜像地址
# ----------------------------------------------------------------------------
switch_main_repo() {
    local branch="" vault_branch="centos-vault"
    cd "$REPO_DIR" || die "无法进入 ${REPO_DIR}"

    case "$SYS_NAME" in
    CentOS)
        # CentOS 7 非 x86 位于 vault/altarch; CentOS 8 的各架构均位于 vault
        if [[ "$SYS_MAJOR" == "7" && "$ARCH" != "x86_64" ]]; then
            branch="centos-vault/altarch"
        else
            branch="centos-vault"
        fi
        rm -f CentOS-*.repo
        write_centos_repo "$SYS_MAJOR"

        local lockver
        case "$SYS_MAJOR" in
        8)
            lockver="8.5.2111"
            sed -e "s|^#baseurl=http|baseurl=${PROTOCOL}|g" \
                -e "s|^mirrorlist=|#mirrorlist=|g" \
                -e "s|mirror.centos.org/\$contentdir|mirror.centos.org/${branch}|g" \
                -e "s|\$releasever|${lockver}|g" -i CentOS-*
            sed -e "s|vault.centos.org/\$contentdir|vault.centos.org/${vault_branch}|g" \
                -i CentOS-Linux-Sources.repo
            ;;
        7)
            lockver="7.9.2009"
            sed -e "s|^#baseurl=http|baseurl=${PROTOCOL}|g" \
                -e "s|^mirrorlist=|#mirrorlist=|g" \
                -e "s|mirror.centos.org/centos|mirror.centos.org/${branch}|g" \
                -e "s|\$releasever|${lockver}|g" -i CentOS-*
            sed -e "s|vault.centos.org/centos|vault.centos.org/${vault_branch}|g" \
                -i CentOS-Sources.repo
            ;;
        esac
        # 官方域名 -> 镜像站
        sed -e "s|mirror.centos.org|${MIRROR_HOST}|g" \
            -e "s|vault.centos.org|${MIRROR_HOST}|g" -i CentOS-*
        ok "主源: ${VENDOR_NAME} ${branch} (${SYS_NAME} ${SYS_MAJOR} 归档版本 ${lockver})"
        ;;

    "CentOS Stream")
        case "$SYS_MAJOR" in
        9|10)
            branch="centos-stream"
            rm -f centos.repo centos-addons.repo CentOS-Stream-*.repo
            write_stream_repo "$SYS_MAJOR"
            sed -e "s|^#baseurl=https|baseurl=${PROTOCOL}|g" \
                -e "s|^metalink=|#metalink=|g" \
                -e "s|mirror.stream.centos.org|${MIRROR_HOST}/${branch}|g" \
                -i centos.repo centos-addons.repo
            ok "主源: ${VENDOR_NAME} centos-stream (${SYS_NAME} ${SYS_MAJOR})"
            ;;
        8)
            # Stream 8 已 EOL, 但必须保留 Stream 的 8-stream 仓库布局
            branch="centos-vault"
            dim "  CentOS Stream 8 已 EOL, 使用归档仓库"
            rm -f CentOS-Stream-*.repo
            write_stream8_repo
            sed -e "s|^#baseurl=http|baseurl=${PROTOCOL}|g" \
                -e "s|^mirrorlist=|#mirrorlist=|g" \
                -e "s|mirror.centos.org/\$contentdir|${MIRROR_HOST}/${branch}|g" \
                -e 's|\$stream|8-stream|g' -i CentOS-Stream-*
            ok "主源: ${VENDOR_NAME} ${branch} (Stream 8 归档分支 8-stream)"
            ;;
        *) die "CentOS Stream ${SYS_MAJOR} 暂不支持" ;;
        esac
        ;;
    Ubuntu|Debian)
        collect_apt_managed_sources
        validate_apt_output_target
        detect_apt_source_types
        disable_apt_official_sources
        write_apt_sources
        ok "主源: ${VENDOR_NAME} ${APT_ARCHIVE_ROOT} (${SYS_NAME} ${SYS_VERSION} ${DEB_ARCH})"
        ;;
    esac
    return 0
}

# ----------------------------------------------------------------------------
#  安装 / 切换 EPEL 源
# ----------------------------------------------------------------------------
switch_epel_repo() {
    [[ "$DISTRO_FAMILY" != "rpm" || "$ENABLE_EPEL" != "yes" ]] && return 0

    local epel_branch="epel"
    if [[ "$SYS_MAJOR" == "7" ]]; then
        epel_branch="epel-archive"
    fi

    # 只安装缺少的 release 包; Stream 9 还需要 epel-next-release
    local urls=()
    if ! rpm -q epel-release >/dev/null 2>&1; then
        case "$SYS_MAJOR" in
        7)
            # noarch release 包统一取 x86_64 目录中的最新版本, 配置仍使用实际 $basearch
            urls+=("${PROTOCOL}://${MIRROR_HOST}/epel-archive/7/x86_64/Packages/e/epel-release-7-14.noarch.rpm")
            ;;
        *)
            urls+=("${PROTOCOL}://${MIRROR_HOST}/epel/epel-release-latest-${SYS_MAJOR}.noarch.rpm")
            ;;
        esac
    fi
    if [[ "$SYS_MAJOR" == "9" ]] && ! rpm -q epel-next-release >/dev/null 2>&1; then
        urls+=("${PROTOCOL}://${MIRROR_HOST}/epel/epel-next-release-latest-9.noarch.rpm")
    fi

    if [[ ${#urls[@]} -gt 0 ]]; then
        do_ "安装 epel-release..."
        local pkgmgr="yum"
        [[ "$SYS_MAJOR" == 8 || "$SYS_MAJOR" == 9 || "$SYS_MAJOR" == 10 ]] && pkgmgr="dnf"

        if ! "$pkgmgr" install -y "${urls[@]}"; then
            die "epel-release 安装失败 (${MIRROR_HOST})"
        fi
    else
        dim "  已安装 epel-release, 仅切换源地址"
    fi

    # 覆盖主配置并保留 epel-next 等附加配置, 备份目录永不修改
    write_epel_repo "$SYS_MAJOR"

    # 只改写镜像站实际承载的 EPEL/EPEL Next 文件，不触碰 Cisco OpenH264 等附属仓库。
    rewrite_epel_repo_files "$epel_branch"

    # 启用 PowerTools(8) / CRB(9,10), 不依赖可能缺失的 config-manager 插件
    case "$SYS_MAJOR" in
    9|10)
        sed -i '/^\[crb\]/,/^\[/{s/^enabled=0/enabled=1/}' "${REPO_DIR}/centos.repo"
        ;;
    8)
        local power_repo
        for power_repo in "${REPO_DIR}/CentOS-Linux-PowerTools.repo" \
            "${REPO_DIR}/CentOS-Stream-PowerTools.repo"; do
            [[ -f "$power_repo" ]] && sed -i 's/^enabled=0/enabled=1/' "$power_repo"
        done
        ;;
    esac

    ok "EPEL: ${VENDOR_NAME} ${epel_branch}"
    return 0
}

# ----------------------------------------------------------------------------
#  重建缓存
# ----------------------------------------------------------------------------
rebuild_cache() {
    do_ "重建软件源缓存..."
    if [[ "$DISTRO_FAMILY" == "apt" ]]; then
        apt-get clean >/dev/null 2>&1 || true
        apt-get -o APT::Update::Error-Mode=any update || \
            die "软件源缓存重建失败, 请检查上方仓库错误"
    elif command -v dnf >/dev/null 2>&1; then
        dnf clean all >/dev/null 2>&1 || true
        dnf makecache || die "软件源缓存重建失败, 请检查上方仓库错误"
    else
        yum clean all >/dev/null 2>&1 || true
        yum makecache || die "软件源缓存重建失败, 请检查上方仓库错误"
    fi
    ok "缓存重建完成"
    return 0
}

# ----------------------------------------------------------------------------
#  主流程
# ----------------------------------------------------------------------------
main() {
    ORIGINAL_ARGS=("$@")
    install_signal_handlers
    parse_args "$@"
    clear_screen
    preflight_common
    detect_system
    configure_repository_paths
    preflight_system

    if [[ "$RESTORE_ONLY" == "yes" ]]; then
        banner
        dim "  运行环境: ${SYS_NAME} ${SYS_VERSION}  ${ARCH}"
        mid
        acquire_lock
        recover_stale_transaction
        restore_original
        mid
        ok "恢复完成"
        return 0
    fi

    # 无 -s 参数 -> 交互菜单; 镜像不可用时允许原地重新选择。
    local interactive_mode="no" lock_acquired="no"
    if [[ -z "$VENDOR" ]]; then
        interactive_mode="yes"
    else
        # 命令行模式: 打印 banner, 探测云厂商决定内网/公网
        banner
        dim "  运行环境: ${SYS_NAME} ${SYS_VERSION}  ${ARCH}"
        if [[ "$USE_INTRANET" == "auto" ]]; then
            detect_cloud
        fi
    fi

    while true; do
        if [[ "$interactive_mode" == "yes" ]]; then
            interactive_menu
        fi
        resolve_mirror
        if [[ "$DRY_RUN" != "yes" && "$lock_acquired" == "no" ]]; then
            acquire_lock
            recover_stale_transaction
            lock_acquired="yes"
        fi
        if check_connectivity; then
            break
        fi
        if [[ "$interactive_mode" != "yes" ]]; then
            print_retry_hint
            return 1
        fi
        warn "所选镜像当前不可用, 请重新选择"
        VENDOR=""
        VENDOR_NAME=""
        MIRROR_HOST=""
        USE_INTRANET="auto"
    done

    if [[ "$DRY_RUN" == "yes" ]]; then
        mid
        dim "  计划: ${SYS_NAME} ${SYS_MAJOR} -> ${VENDOR_NAME} (${PROTOCOL}://${MIRROR_HOST})"
        if [[ "$DISTRO_FAMILY" == "apt" ]]; then
            dim "  APT: ${APT_COMPONENTS}"
        else
            dim "  EPEL: $([[ "$ENABLE_EPEL" == "yes" ]] && printf '启用' || printf '跳过')"
        fi
        ok "预检完成, 未修改系统"
        return 0
    fi

    # 执行区: 上分隔线 -> 切换动作 -> 下分隔线 (yum 输出自然夹在中间)
    mid
    do_ "开始切换软件源 -> ${VENDOR_NAME} (${PROTOCOL}://${MIRROR_HOST})"

    backup_repos
    begin_transaction
    switch_main_repo
    switch_epel_repo
    rebuild_cache
    commit_transaction

    print_restore_hint "$0"
    mid
    ok "切换完成! 当前源: ${VENDOR_NAME}"
}

if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
    main "$@"
fi
