#!/usr/bin/env bash
#
# uclash - Ubuntu 下 mihomo(Clash) 代理与节点管理工具
#
# 用法:
#   uclash on                     启动服务并设置系统代理
#   uclash off                    关闭系统代理并停止服务
#   uclash node list [组|编号]    列出代理组节点
#   uclash node groups            列出代理组及编号
#   uclash node use <节点|编号> [组|编号]  切换节点
#   uclash status                 查看状态
#   uclash version                查看 uclash 本体版本
#   uclash doctor                 校验 mihomo 核心与版本兼容性
#   uclash update                 检查并更新 uclash 本体
#   uclash core install [版本]    下载并安装受支持版本的 mihomo 核心
#   uclash core list              列出下载主机可安装的 mihomo 版本
#   uclash core version           查看当前 mihomo 核心版本
#   uclash sub add <URL>          新增订阅 provider (mihomo proxy-providers)
#   uclash sub list               列出已配置的订阅 provider
#   uclash sub update [名称]      触发订阅重新拉取
#
# 允许从非 Bash shell (如 zsh bin/uclash) 调用: 统一交给 Bash 解释执行。
if [ -z "${BASH_VERSION:-}" ]; then
  if command -v bash >/dev/null 2>&1; then
    exec bash "$0" "$@"
  fi
  printf 'uclash: 需要 bash 运行\n' >&2
  exit 1
fi

set -euo pipefail

CLASH_SERVICE="${CLASH_SERVICE:-mihomo}"
CLASH_CONFIG="${CLASH_CONFIG:-/etc/mihomo/config.yaml}"
CLASH_DEFAULT_GROUP="${CLASH_DEFAULT_GROUP:-}"
CLASH_PROXY_HOST="${CLASH_PROXY_HOST:-127.0.0.1}"
CLASH_API_TIMEOUT="${CLASH_API_TIMEOUT:-5}"
CLASH_WAIT_SECONDS="${CLASH_WAIT_SECONDS:-15}"
CLASH_MIN_VERSION="${CLASH_MIN_VERSION:-1.18.0}"
CLASH_MAX_VERSION="${CLASH_MAX_VERSION:-1.19.31}"
CLASH_STRICT="${CLASH_STRICT:-0}"
CLASH_CORE_USER_DIR="${CLASH_CORE_USER_DIR:-${HOME:-$PWD}/.local/share/uclash/bin}"
CLASH_SUB_GROUP="${CLASH_SUB_GROUP:-PROXY}"
CLASH_SUB_INTERVAL="${CLASH_SUB_INTERVAL:-3600}"
CLASH_SUB_HEALTH_URL="${CLASH_SUB_HEALTH_URL:-https://www.gstatic.com/generate_204}"
CLASH_SUB_HEALTH_INTERVAL="${CLASH_SUB_HEALTH_INTERVAL:-300}"
CLASH_SUB_RELOAD="${CLASH_SUB_RELOAD:-1}"

UCLASH_STATE_DIR="${UCLASH_STATE_DIR:-${XDG_DATA_HOME:-${HOME:-$PWD}/.local/share}/uclash}"
UCLASH_CONFIG_FILE="${UCLASH_CONFIG_FILE:-${XDG_CONFIG_HOME:-${HOME:-$PWD}/.config}/uclash/config}"
UCLASH_VERSION="${UCLASH_VERSION:-v4.1.1}"
UCLASH_BASE_URL_EMBEDDED="https://files.tymler.work/clash"
UCLASH_RELEASE_PATH="ubuntu-clash/unix"
UPDATE_INSECURE=0
case "${UCLASH_INSECURE:-}" in
  1|true|TRUE|yes|YES|on|ON) UPDATE_INSECURE=1 ;;
esac
UPDATE_DOWNLOADER=""
UPDATE_WORK=""
UPDATE_LAST_ERR=""

log() { printf 'uclash: %s\n' "$*" >&2; }
die() { log "$*"; exit 1; }
require() { command -v "$1" >/dev/null 2>&1 || die "缺少依赖命令: $1"; }

usage() {
  cat <<EOF
uclash - mihomo(Clash) 代理管理工具

用法:
  uclash on                     启动 mihomo 服务并设置系统代理
  uclash off                    解除系统代理并停止 mihomo 服务
  uclash node list [组|编号]    列出代理组的节点 (默认组: 自动识别; 编号见 node groups)
  uclash node groups            列出代理组及其编号
  uclash node use <节点|编号> [组|编号]  将代理组切换至指定节点
  uclash status                 查看服务、代理与节点状态
  uclash version                查看 uclash 本体版本
  uclash doctor                 校验 mihomo 核心与版本兼容性
  uclash update                 检查并更新 uclash 本体
  uclash core install [版本]    下载并安装受支持版本的 mihomo 核心
  uclash core list              列出下载主机可安装的 mihomo 版本
  uclash core version           查看当前 mihomo 核心版本
  uclash sub add <URL>          新增订阅 provider
  uclash sub list               列出已配置的订阅 provider
  uclash sub update [名称]      触发订阅重新拉取
  uclash help                   显示本帮助

环境变量:
  CLASH_SERVICE=$CLASH_SERVICE
  CLASH_CONFIG=$CLASH_CONFIG
  CLASH_DEFAULT_GROUP=${CLASH_DEFAULT_GROUP:-}   # 留空则自动识别 (优先配置文件)
  CLASH_PROXY_HOST=$CLASH_PROXY_HOST
  CLASH_MIN_VERSION=$CLASH_MIN_VERSION
  CLASH_MAX_VERSION=$CLASH_MAX_VERSION
  CLASH_STRICT=$CLASH_STRICT
  UCLASH_BASE_URL=${UCLASH_BASE_URL:-}
  UCLASH_INSECURE=${UCLASH_INSECURE:-}
  UCLASH_STATE_DIR=$UCLASH_STATE_DIR
  UCLASH_CONFIG_FILE=$UCLASH_CONFIG_FILE
  CLASH_SUB_GROUP=$CLASH_SUB_GROUP
  CLASH_SUB_INTERVAL=$CLASH_SUB_INTERVAL
  CLASH_SUB_RELOAD=$CLASH_SUB_RELOAD

提示: 运行安装脚本可自动配置 Bash/Zsh 环境变量包装与 Tab 补全:
  ./install-dev.sh                             # 本地安装 (默认 ~/.local)
  curl -fsSL <base>/ubuntu-clash/unix/install.sh | bash   # 在线安装
  ./uninstall.sh                               # 卸载
EOF
}

config_value() {
  local key="$1"
  [ -r "$CLASH_CONFIG" ] || return 1
  grep -E "^[[:space:]]*${key}[[:space:]]*:" "$CLASH_CONFIG" 2>/dev/null \
    | head -n1 \
    | sed -E "s/^[[:space:]]*${key}[[:space:]]*:[[:space:]]*//; s/[[:space:]]+#.*$//; s/^[[:space:]]*//; s/[[:space:]]*$//; s/^[\"']//; s/[\"']$//"
}

# uclash_config_value: 从 uclash 用户配置文件中读取白名单键。
# 仅做文本提取, 不 source 整个文件, 避免执行任意代码。
uclash_config_value() {
  local key="$1"
  case "$key" in
    CLASH_DEFAULT_GROUP|update_base_url) ;;
    *) return 1 ;;
  esac
  [ -r "$UCLASH_CONFIG_FILE" ] || return 1
  sed -nE "s/^[[:space:]]*${key}[[:space:]]*=(.*)$/\1/p" "$UCLASH_CONFIG_FILE" \
    | head -n1 \
    | sed -E 's/[[:space:]]+#.*$//; s/^[[:space:]]*//; s/[[:space:]]*$//; s/^["'\'']//; s/["'\'']$//'
}

# default_group: 默认代理组解析 (不含命令行显式参数)。
# 优先级: CLASH_DEFAULT_GROUP 环境变量 > 配置文件; 均为空时由调用方自动探测。
default_group() {
  if [ -n "${CLASH_DEFAULT_GROUP:-}" ]; then
    printf '%s' "$CLASH_DEFAULT_GROUP"
    return 0
  fi
  uclash_config_value CLASH_DEFAULT_GROUP || true
}

proxy_port() {
  local p
  p="$(config_value mixed-port || true)"
  [ -n "$p" ] || p="$(config_value port || true)"
  [ -n "$p" ] || die "无法从 $CLASH_CONFIG 解析代理端口 (mixed-port/port)"
  printf '%s' "$p"
}

api_base() {
  local ctrl
  ctrl="$(config_value external-controller || true)"
  ctrl="${ctrl:-127.0.0.1:9090}"
  ctrl="${ctrl#http://}"
  ctrl="${ctrl#https://}"
  ctrl="${ctrl/0.0.0.0/127.0.0.1}"
  printf 'http://%s' "$ctrl"
}

api_secret() { config_value secret || true; }

api_request() {
  local method="$1" path="$2" data="${3:-}"
  local -a args=(-sS --max-time "$CLASH_API_TIMEOUT" -X "$method" "$(api_base)$path")
  local secret
  secret="$(api_secret)"
  [ -n "$secret" ] && args+=(-H "Authorization: Bearer $secret")
  [ -n "$data" ] && args+=(-H "Content-Type: application/json" --data "$data")
  curl "${args[@]}"
}

# api_request_code: 同 api_request, 但只返回 HTTP 状态码 (用于判断重载/刷新结果)。
api_request_code() {
  local method="$1" path="$2" data="${3:-}"
  local -a args=(-sS -o /dev/null -w '%{http_code}' --max-time "$CLASH_API_TIMEOUT" -X "$method" "$(api_base)$path")
  local secret
  secret="$(api_secret)"
  [ -n "$secret" ] && args+=(-H "Authorization: Bearer $secret")
  [ -n "$data" ] && args+=(-H "Content-Type: application/json" --data "$data")
  curl "${args[@]}"
}

port_open() {
  (exec 3<>"/dev/tcp/${CLASH_PROXY_HOST}/$1") 2>/dev/null
}

wait_for_port() {
  local port="$1" deadline=$(( SECONDS + CLASH_WAIT_SECONDS ))
  while (( SECONDS < deadline )); do
    if port_open "$port"; then return 0; fi
    sleep 0.5
  done
  return 1
}

api_version_info() {
  local json
  json="$(api_request GET /version)" || return 1
  python3 - "$json" <<'PY'
import json, sys

try:
    data = json.loads(sys.argv[1])
except Exception:
    sys.exit(1)
print("true" if data.get("meta") is True else "false", data.get("version", ""))
PY
}

version_lt() {
  [ "$1" != "$2" ] || return 1
  [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" = "$1" ]
}

version_gt() {
  [ "$1" != "$2" ] || return 1
  [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ]
}

check_mihomo_version() {
  local info meta version
  if ! info="$(api_version_info)"; then
    log "警告: 无法访问 mihomo API ($(api_base)), 跳过版本兼容性校验"
    return 2
  fi
  meta="${info%% *}"
  version="${info#* }"
  version="${version#v}"
  if [ "$meta" != "true" ]; then
    log "错误: 当前控制器不是 mihomo 核心 (meta=$meta), 不兼容"
    return 1
  fi
  if [ -z "$version" ]; then
    log "错误: 无法获取 mihomo 版本, 不兼容"
    return 1
  fi
  if version_lt "$version" "$CLASH_MIN_VERSION"; then
    log "错误: mihomo 版本 $version 低于最低支持 $CLASH_MIN_VERSION"
    return 1
  fi
  if version_gt "$version" "$CLASH_MAX_VERSION"; then
    log "警告: mihomo 版本 $version 高于已验证的 $CLASH_MAX_VERSION, 可能不兼容, 请升级 uclash"
  fi
  return 0
}

probe_proxies() {
  local json
  json="$(api_request GET /proxies)" || return 1
  python3 - "$json" <<'PY'
import json, sys

try:
    data = json.loads(sys.argv[1])
except Exception as exc:
    print(f"解析 /proxies 响应失败: {exc}", file=sys.stderr)
    sys.exit(1)

proxies = data.get("proxies")
if not isinstance(proxies, dict):
    print("响应缺少 proxies 对象, API 契约可能已变更", file=sys.stderr)
    sys.exit(1)

selectors = 0
for name, proxy in proxies.items():
    if not isinstance(proxy, dict):
        continue
    if proxy.get("type") == "Selector":
        if not isinstance(proxy.get("all"), list) or "now" not in proxy:
            print(f"Selector 组 {name} 缺少 all/now, API 契约可能已变更", file=sys.stderr)
            sys.exit(1)
        selectors += 1
print(selectors)
PY
}

cmd_doctor() {
  require curl
  require python3

  printf 'mihomo API:    %s\n' "$(api_base)"

  local info meta version
  if ! info="$(api_version_info)"; then
    die "无法访问 mihomo API ($(api_base)), 请确认服务已启动"
  fi
  meta="${info%% *}"
  version="${info#* }"
  version="${version#v}"

  if [ "$meta" != "true" ]; then
    printf '核心版本:      %s (meta=%s)\n' "${version:-unknown}" "$meta"
    printf '结果:          不兼容 - 当前控制器不是 mihomo 核心\n'
    return 1
  fi
  printf '核心版本:      %s (meta)\n' "${version:-unknown}"
  printf '兼容范围:      %s - %s\n' "$CLASH_MIN_VERSION" "$CLASH_MAX_VERSION"

  if [ -z "$version" ]; then
    printf '结果:          不兼容 - 无法获取 mihomo 版本\n'
    return 1
  fi

  local selectors
  if ! selectors="$(probe_proxies)"; then
    printf '结果:          不兼容 - /proxies 能力探测失败\n'
    return 1
  fi
  printf 'API 能力:      /proxies 正常 (Selector 组: %s)\n' "$selectors"

  if version_lt "$version" "$CLASH_MIN_VERSION"; then
    printf '结果:          不兼容 - 核心 %s 低于最低支持 %s, 请升级 mihomo 或 uclash\n' "$version" "$CLASH_MIN_VERSION"
    return 1
  fi
  if version_gt "$version" "$CLASH_MAX_VERSION"; then
    if [ "$CLASH_STRICT" = "1" ]; then
      printf '结果:          不兼容 (strict) - 核心 %s 高于已验证 %s\n' "$version" "$CLASH_MAX_VERSION"
      return 1
    fi
    printf '结果:          未验证 - 核心 %s 高于已验证 %s, 可能不兼容, 请升级 uclash\n' "$version" "$CLASH_MAX_VERSION"
    return 0
  fi
  printf '结果:          兼容\n'
  return 0
}

cmd_on() {
  require systemctl
  local port
  port="$(proxy_port)"
  log "启动服务 $CLASH_SERVICE ..."
  systemctl start "$CLASH_SERVICE" || die "systemctl 启动 $CLASH_SERVICE 失败"
  log "等待代理端口 ${CLASH_PROXY_HOST}:${port} 就绪 ..."
  wait_for_port "$port" || die "代理端口 ${CLASH_PROXY_HOST}:${port} 在 ${CLASH_WAIT_SECONDS}s 内未就绪"
  local compat=0
  check_mihomo_version || compat=$?
  if [ "$compat" -eq 1 ]; then
    log "因版本不兼容, 停止服务 $CLASH_SERVICE ..."
    systemctl stop "$CLASH_SERVICE" || log "警告: 停止服务 $CLASH_SERVICE 失败"
    die "mihomo 版本不兼容, 已取消开启 (运行 uclash doctor 查看详情)"
  fi
  log "服务已启动, 已设置系统代理"
  cat <<EOF
export http_proxy=http://${CLASH_PROXY_HOST}:${port}
export https_proxy=http://${CLASH_PROXY_HOST}:${port}
export HTTP_PROXY=http://${CLASH_PROXY_HOST}:${port}
export HTTPS_PROXY=http://${CLASH_PROXY_HOST}:${port}
EOF
}

cmd_off() {
  require systemctl
  cat <<'EOF'
unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY
EOF
  log "停止服务 $CLASH_SERVICE ..."
  systemctl stop "$CLASH_SERVICE" || die "systemctl 停止 $CLASH_SERVICE 失败"
  log "服务已停止, 系统代理已解除"
}

node_list() {
  local requested="${1:-$(default_group)}" json
  require curl
  require python3
  json="$(api_request GET /proxies)" || die "无法访问 mihomo API ($(api_base))"
  python3 - "$requested" "$json" <<'PY'
import json, sys

requested, raw = sys.argv[1], sys.argv[2]
try:
    proxies = json.loads(raw).get("proxies", {})
except Exception as exc:
    print(f"解析 API 响应失败: {exc}", file=sys.stderr)
    sys.exit(1)

groups = [n for n, p in proxies.items() if isinstance(p, dict) and "all" in p]


def describe_groups():
    for i, name in enumerate(groups, 1):
        p = proxies[name]
        t = p.get("type", "?")
        tag = "可切换" if t == "Selector" else "自动选择"
        now = p.get("now")
        extra = f", 当前: {now}" if now else ""
        print(f"  {i:>2}. {name} ({t}, {tag}{extra})", file=sys.stderr)


def resolve(proxies, requested):
    if requested:
        if requested in proxies:
            return requested
        ci = [n for n in proxies if n.lower() == requested.lower()]
        if len(ci) == 1:
            return ci[0]
        if requested.isdigit():
            i = int(requested)
            if 1 <= i <= len(groups):
                return groups[i - 1]
        return None
    selectors = [n for n, p in proxies.items() if p.get("type") == "Selector"]
    pref = [n for n in selectors if n.lower() == "proxy"]
    if pref:
        return pref[0]
    if selectors:
        return selectors[0]
    return None


group = resolve(proxies, requested)
if group is None:
    if requested:
        print(f"未找到代理组: {requested}", file=sys.stderr)
        if requested.isdigit():
            print('提示: 运行 "uclash node groups" 查看可用组编号', file=sys.stderr)
    else:
        print("未找到可自动识别的代理组 (无 Selector 组)", file=sys.stderr)
    if groups:
        print("可用代理组:", file=sys.stderr)
        describe_groups()
    sys.exit(1)

proxy = proxies[group]
if "all" not in proxy:
    print(f"{group} 不是代理组 (type={proxy.get('type')})", file=sys.stderr)
    sys.exit(1)

t = proxy.get("type")
now = proxy.get("now")
extra = "" if t == "Selector" else f" [{t}, 自动选择, 不可手动切换]"
print(f"代理组: {group} (当前: {now}){extra}")
for i, name in enumerate(proxy.get("all", []), 1):
    mark = "*" if name == now else " "
    print(f"  {mark} {i:>2}. {name}")
PY
}

cmd_node_groups() {
  [ "$#" -eq 0 ] || die "node groups 不接受参数: $*"
  require curl
  require python3
  local json
  json="$(api_request GET /proxies)" || die "无法访问 mihomo API ($(api_base))"
  python3 - "$json" <<'PY'
import json, sys

try:
    proxies = json.loads(sys.argv[1]).get("proxies", {})
except Exception as exc:
    print(f"解析 API 响应失败: {exc}", file=sys.stderr)
    sys.exit(1)

groups = [n for n, p in proxies.items() if isinstance(p, dict) and "all" in p]
if not groups:
    print("未找到任何代理组")
    sys.exit(0)
print("代理组:")
for i, name in enumerate(groups, 1):
    p = proxies[name]
    t = p.get("type", "?")
    tag = "可切换" if t == "Selector" else "自动选择"
    now = p.get("now")
    extra = f", 当前: {now}" if now else ""
    print(f"  {i:>2}. {name} ({t}, {tag}{extra})")
PY
}

filter_prefix() {
  local prefix="$1" line
  while IFS= read -r line; do
    [ -n "$line" ] || continue
    case "$line" in
      "$prefix"*) printf '%s\n' "$line" ;;
    esac
  done
}

complete_groups() {
  require curl
  require python3
  local json
  json="$(api_request GET /proxies 2>/dev/null)" || return 0
  python3 - "$json" <<'PY' 2>/dev/null || true
import json, sys

try:
    proxies = json.loads(sys.argv[1]).get("proxies", {})
except Exception:
    sys.exit(0)
for name, proxy in proxies.items():
    if "all" in proxy:
        print(name)
PY
}

complete_nodes() {
  local requested="${1:-$(default_group)}"
  require curl
  require python3
  local json
  json="$(api_request GET /proxies 2>/dev/null)" || return 0
  python3 - "$requested" "$json" <<'PY' 2>/dev/null || true
import json, sys

requested, raw = sys.argv[1], sys.argv[2]
try:
    proxies = json.loads(raw).get("proxies", {})
except Exception:
    sys.exit(0)


def resolve(proxies, requested):
    if requested:
        if requested in proxies:
            return requested
        ci = [n for n in proxies if n.lower() == requested.lower()]
        return ci[0] if len(ci) == 1 else None
    selectors = [n for n, p in proxies.items() if p.get("type") == "Selector"]
    pref = [n for n in selectors if n.lower() == "proxy"]
    if pref:
        return pref[0]
    if selectors:
        return selectors[0]
    return None


group = resolve(proxies, requested)
if group and "all" in proxies[group]:
    for name in proxies[group].get("all", []):
        print(name)
PY
}

cmd_complete() {
  local -a words=("$@")
  local count=${#words[@]}
  local prefix=""
  if [ "$count" -gt 0 ]; then
    prefix="${words[$((count - 1))]}"
  fi

  if [ "$count" -le 1 ]; then
    printf '%s\n' on off node status doctor core update sub version help | filter_prefix "$prefix"
    return 0
  fi

  case "${words[0]}" in
    node)
      case "$count" in
        2)
          printf '%s\n' list groups use | filter_prefix "$prefix"
          ;;
        3)
          case "${words[1]}" in
            list) complete_groups | filter_prefix "$prefix" ;;
            use)  complete_nodes | filter_prefix "$prefix" ;;
          esac
          ;;
        4)
          if [ "${words[1]}" = "use" ]; then
            complete_groups | filter_prefix "$prefix"
          fi
          ;;
      esac
      ;;
    core)
      case "$count" in
        2)
          printf '%s\n' install list version | filter_prefix "$prefix"
          ;;
        *)
          case "${words[1]}" in
            install) printf '%s\n' --user --dry-run --insecure -h --help | filter_prefix "$prefix" ;;
            list|ls) printf '%s\n' --insecure -h --help | filter_prefix "$prefix" ;;
          esac
          ;;
      esac
      ;;
    sub)
      case "$count" in
        2)
          printf '%s\n' add set remove list nodes status update | filter_prefix "$prefix"
          ;;
        3)
          case "${words[1]}" in
            set|remove)         complete_subs | filter_prefix "$prefix" ;;
            nodes|update|status) complete_providers | filter_prefix "$prefix" ;;
          esac
          ;;
      esac
      ;;
    update)
      case "$count" in
        2)
          printf '%s\n' -y --yes --check --force --dry-run --base-url --insecure --prefix --shell | filter_prefix "$prefix"
          ;;
      esac
      ;;
  esac
  return 0
}

cmd_node_use() {
  local node="${1:-}" requested="${2:-$(default_group)}" json out
  [ -n "$node" ] || die "用法: uclash node use <节点|编号> [组|编号]"
  require curl
  require python3

  json="$(api_request GET /proxies)" || die "无法访问 mihomo API ($(api_base))"
  out="$(python3 - "$requested" "$node" "$json" <<'PY'
import json, sys

requested, node, raw = sys.argv[1], sys.argv[2], sys.argv[3]
try:
    proxies = json.loads(raw).get("proxies", {})
except Exception as exc:
    print(f"解析 API 响应失败: {exc}", file=sys.stderr)
    sys.exit(1)

groups = [n for n, p in proxies.items() if isinstance(p, dict) and "all" in p]


def describe_groups():
    for i, name in enumerate(groups, 1):
        p = proxies[name]
        t = p.get("type", "?")
        tag = "可切换" if t == "Selector" else "自动选择"
        now = p.get("now")
        extra = f", 当前: {now}" if now else ""
        print(f"  {i:>2}. {name} ({t}, {tag}{extra})", file=sys.stderr)


def resolve(proxies, requested):
    if requested:
        if requested in proxies:
            return requested
        ci = [n for n in proxies if n.lower() == requested.lower()]
        if len(ci) == 1:
            return ci[0]
        if requested.isdigit():
            i = int(requested)
            if 1 <= i <= len(groups):
                return groups[i - 1]
        return None
    selectors = [n for n, p in proxies.items() if p.get("type") == "Selector"]
    pref = [n for n in selectors if n.lower() == "proxy"]
    if pref:
        return pref[0]
    if selectors:
        return selectors[0]
    return None


group = resolve(proxies, requested)
if group is None:
    if requested:
        print(f"未找到代理组: {requested}", file=sys.stderr)
        if requested.isdigit():
            print('提示: 运行 "uclash node groups" 查看可用组编号', file=sys.stderr)
    else:
        print("未找到可自动识别的代理组 (无 Selector 组)", file=sys.stderr)
    if groups:
        print("可用代理组:", file=sys.stderr)
        describe_groups()
    sys.exit(1)

proxy = proxies[group]
if proxy.get("type") != "Selector":
    print(f"{group} 不是可切换的 Selector 组 (type={proxy.get('type')}, 自动选择, 不支持手动切换)", file=sys.stderr)
    sys.exit(1)

allnodes = proxy.get("all", [])
if node in allnodes:
    resolved = node
elif node.isdigit():
    i = int(node)
    if 1 <= i <= len(allnodes):
        resolved = allnodes[i - 1]
    else:
        print(f"节点编号超出范围: {node} (该组共 {len(allnodes)} 个节点)", file=sys.stderr)
        sys.exit(1)
else:
    print(f"节点不存在: {node}", file=sys.stderr)
    sys.exit(1)
print(group)
print(resolved)
PY
)" || die "节点校验失败"

  local group_name resolved_node
  group_name="$(printf '%s\n' "$out" | sed -n '1p')"
  resolved_node="$(printf '%s\n' "$out" | sed -n '2p')"
  [ -n "$group_name" ] && [ -n "$resolved_node" ] || die "节点校验失败"

  local enc body code secret
  enc="$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1], safe=""))' "$group_name")"
  body="$(python3 -c 'import json,sys;print(json.dumps({"name": sys.argv[1]}))' "$resolved_node")"
  secret="$(api_secret)"
  local -a args=(-sS -o /dev/null -w '%{http_code}' --max-time "$CLASH_API_TIMEOUT" -X PUT \
    -H 'Content-Type: application/json' --data "$body" "$(api_base)/proxies/$enc")
  [ -n "$secret" ] && args+=(-H "Authorization: Bearer $secret")
  code="$(curl "${args[@]}")" || die "切换节点请求失败"

  case "$code" in
    200|204) log "已将 [$group_name] 切换至: $resolved_node" ;;
    *) die "切换失败 (HTTP $code)" ;;
  esac
}

cmd_status() {
  local active
  active="$(systemctl is-active "$CLASH_SERVICE" 2>/dev/null || true)"
  printf '服务 (%s): %s\n' "$CLASH_SERVICE" "${active:-unknown}"

  if [ -n "${http_proxy:-}${HTTP_PROXY:-}" ]; then
    printf 'http_proxy:  %s\n' "${http_proxy:-${HTTP_PROXY:-}}"
    printf 'https_proxy: %s\n' "${https_proxy:-${HTTPS_PROXY:-}}"
  else
    printf '系统代理: 未设置\n'
  fi

  if [ "$active" = "active" ] && command -v curl >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then
    local info
    if info="$(api_version_info 2>/dev/null)"; then
      printf 'mihomo 核心: %s\n' "${info#* }"
    fi
    local json
    if json="$(api_request GET /proxies 2>/dev/null)"; then
      python3 - "$(default_group)" "$json" <<'PY' 2>/dev/null || true
import json, sys

requested, raw = sys.argv[1], sys.argv[2]
try:
    proxies = json.loads(raw).get("proxies", {})
except Exception:
    sys.exit(0)


def resolve(proxies, requested):
    if requested:
        if requested in proxies:
            return requested
        ci = [n for n in proxies if n.lower() == requested.lower()]
        return ci[0] if len(ci) == 1 else None
    selectors = [n for n, p in proxies.items() if p.get("type") == "Selector"]
    pref = [n for n in selectors if n.lower() == "proxy"]
    if pref:
        return pref[0]
    if selectors:
        return selectors[0]
    return None


group = resolve(proxies, requested)
proxy = proxies.get(group) if group else None
if proxy and proxy.get("now"):
    print(f"节点 ({group}): {proxy.get('now')}")
PY
    fi
  fi
}

usage_sub() {
  cat <<EOF
uclash sub - 管理 mihomo 订阅 (proxy-providers)

用法:
  uclash sub add <URL> [选项]         新增订阅 provider (名称缺省自动生成)
  uclash sub set <名称> <URL> [选项]  更新/新增指定名称的 provider
  uclash sub remove <名称> [选项]     删除 provider 及其组内引用
  uclash sub list                     列出已配置的 provider
  uclash sub nodes <名称>             列出 provider 的节点 (来自内核)
  uclash sub status [名称]            查看 provider 节点数与更新时间
  uclash sub update [名称]            触发 provider 重新拉取

选项 (add/set/remove):
  --name <名称>       provider 名称 (仅 add; 缺省由 URL 主机名生成)
  --group <组名>      承载订阅的 Selector 组 (默认 $CLASH_SUB_GROUP)
  --interval <秒>     provider 更新间隔 (默认 $CLASH_SUB_INTERVAL)
  --no-reload         仅改配置, 不触发内核重载
  --dry-run           仅显示将执行的变更, 不写盘
  -h, --help          显示本帮助

说明:
  订阅清单存于用户配置目录, 主配置由基础配置 (mihomo.base.yaml) 生成,
  并在末尾追加以标记块包裹的 proxy-providers / proxy-groups (无需额外依赖)。
  写主配置需相应权限 (系统级配置通常用 sudo 运行)。
  订阅由 mihomo 内核下载/解析并按 interval 自动刷新, 支持 Clash YAML 与 base64 分享链接。
EOF
}

# 解析配置属主用户 home: root 经 sudo 运行时取 SUDO_USER 的 home, 避免写入 root 目录。
sub_user_home() {
  local u="${UCLASH_USER:-${SUDO_USER:-}}" h
  if [ -n "$u" ] && [ "$u" != "root" ]; then
    h="$(getent passwd "$u" 2>/dev/null | cut -d: -f6 || true)"
    [ -n "$h" ] && { printf '%s' "$h"; return 0; }
  fi
  printf '%s' "${HOME:-$PWD}"
}

sub_dir() {
  if [ -n "${UCLASH_CONFIG_DIR:-}" ]; then
    printf '%s' "$UCLASH_CONFIG_DIR"
    return 0
  fi
  printf '%s/uclash' "${XDG_CONFIG_HOME:-$(sub_user_home)/.config}"
}

sub_file() {
  printf '%s' "${UCLASH_SUB_FILE:-$(sub_dir)/subscriptions}"
}

sub_base_file() {
  printf '%s' "${UCLASH_BASE_CONFIG:-$(sub_dir)/mihomo.base.yaml}"
}

mihomo_bin() {
  local b
  b="$(service_exec_path || true)"
  if [ -n "$b" ] && [ -x "$b" ]; then printf '%s' "$b"; return 0; fi
  b="$(command -v mihomo 2>/dev/null || true)"
  if [ -n "$b" ] && [ -x "$b" ]; then printf '%s' "$b"; return 0; fi
  return 1
}

sub_validate_url() {
  case "$1" in
    http://*|https://*) ;;
    *) die "订阅 URL 必须以 http:// 或 https:// 开头: $1" ;;
  esac
}

sub_validate_interval() {
  case "$1" in
    ''|*[!0-9]*) die "interval 必须为正整数: $1" ;;
  esac
  [ "$1" -gt 0 ] || die "interval 必须为正整数: $1"
}

sub_validate_name() {
  case "$1" in
    ''|*[!A-Za-z0-9._-]*) die "provider 名称仅允许字母/数字/._- : $1" ;;
  esac
}

sub_default_name() {
  python3 - "$1" <<'PY'
import re, sys
from urllib.parse import urlsplit

host = urlsplit(sys.argv[1]).hostname or ""
name = re.sub(r"[^A-Za-z0-9._-]+", "-", host).strip("-")
print(name or "sub")
PY
}

sub_registry_ensure() {
  local f
  f="$(sub_file)"
  mkdir -p "$(dirname "$f")" || die "无法创建目录: $(dirname "$f")"
  [ -e "$f" ] || : > "$f"
  chmod 0600 "$f" 2>/dev/null || true
}

sub_registry_upsert() {
  local name="$1" url="$2" interval="$3" group="$4" f tmp
  sub_registry_ensure
  f="$(sub_file)"
  tmp="${f}.tmp.$$"
  awk -F'\t' -v n="$name" '$1 != n' "$f" > "$tmp" || { rm -f "$tmp"; die "更新订阅清单失败"; }
  printf '%s\t%s\t%s\t%s\n' "$name" "$url" "$interval" "$group" >> "$tmp"
  mv -f "$tmp" "$f" || die "保存订阅清单失败: $f"
  chmod 0600 "$f" 2>/dev/null || true
}

sub_registry_remove() {
  local name="$1" f tmp
  f="$(sub_file)"
  [ -f "$f" ] || return 1
  tmp="${f}.tmp.$$"
  awk -F'\t' -v n="$name" '$1 != n' "$f" > "$tmp" || { rm -f "$tmp"; die "更新订阅清单失败"; }
  mv -f "$tmp" "$f" || die "保存订阅清单失败: $f"
}

sub_registry_has() {
  local name="$1" f
  f="$(sub_file)"
  [ -f "$f" ] || return 1
  awk -F'\t' -v n="$name" '$1 == n { found = 1 } END { exit(found ? 0 : 1) }' "$f"
}

sub_unique_name() {
  local base="$1" candidate="$1" i=2
  while sub_registry_has "$candidate"; do
    candidate="${base}-${i}"
    i=$((i + 1))
  done
  printf '%s' "$candidate"
}

sub_mask_url() {
  python3 -c '
import hashlib, sys
from urllib.parse import urlsplit

for line in sys.stdin:
    fields = line.rstrip("\n").split("\t")
    if len(fields) >= 2 and fields[1]:
        u = urlsplit(fields[1])
        host = u.hostname or ""
        if u.port:
            host = f"{host}:{u.port}"
        digest = hashlib.sha256(fields[1].encode()).hexdigest()[:8]
        fields[1] = f"{u.scheme}://{host}/\u2026 #{digest}"
    print("\t".join(fields))
'
}

sub_mask_one() {
  printf 'x\t%s\n' "$1" | sub_mask_url | sed -n '1s/^[^\t]*\t//p'
}

sub_config_writable() {
  local d
  d="$(dirname "$CLASH_CONFIG")"
  [ -d "$d" ] || die "配置目录不存在: $d"
  [ -w "$d" ] || die "配置目录不可写: $d (系统级配置请使用 sudo 运行)"
}

sub_config_backup() {
  [ -e "$CLASH_CONFIG" ] || return 0
  local backup
  rm -f "${CLASH_CONFIG}".bak.* 2>/dev/null || true
  backup="${CLASH_CONFIG}.bak.$(date +%Y%m%d%H%M%S)"
  cp -p "$CLASH_CONFIG" "$backup" || die "备份配置失败: $CLASH_CONFIG"
  printf '%s' "$backup"
}

sub_config_restore() {
  local backup="$1"
  [ -n "$backup" ] && [ -f "$backup" ] || return 0
  cp -p "$backup" "$CLASH_CONFIG" || log "警告: 回滚配置失败: $CLASH_CONFIG"
}

sub_config_validate() {
  local file="$1" bin out rc=0
  bin="$(mihomo_bin || true)"
  [ -n "$bin" ] || return 0
  out="$("$bin" -t -f "$file" -d "$(dirname "$CLASH_CONFIG")" 2>&1)" || rc=$?
  if [ "$rc" -ne 0 ]; then
    printf '%s\n' "$out" >&2
    return 1
  fi
}

SUB_MARK_BEGIN="# >>> uclash managed >>>"
SUB_MARK_END="# <<< uclash managed <<<"

# sub_render_region: 由订阅清单生成 proxy-providers / proxy-groups YAML 片段。
sub_render_region() {
  python3 -c '
import json, sys

path, group_default, health_url, health_interval = sys.argv[1:5]
entries = []
try:
    with open(path, "r", encoding="utf-8") as fh:
        for line in fh:
            line = line.rstrip("\n")
            if not line or line.startswith("#"):
                continue
            parts = line.split("\t")
            if len(parts) < 4:
                continue
            name, url, interval, group = parts[0], parts[1], parts[2], parts[3]
            if not name or not url:
                continue
            try:
                interval = int(interval)
            except ValueError:
                interval = 3600
            entries.append((name, url, interval, group or group_default))
except FileNotFoundError:
    pass

providers = [(name, url, interval) for name, url, interval, _ in entries]
groups = {}
order = []
for name, url, interval, group in entries:
    if group not in groups:
        groups[group] = []
        order.append(group)
    if name not in groups[group]:
        groups[group].append(name)

out = []
if not providers:
    out.append("proxy-providers: {}")
    out.append("proxy-groups: []")
else:
    out.append("proxy-providers:")
    for name, url, interval in providers:
        out.append(f"  {json.dumps(name, ensure_ascii=False)}:")
        out.append("    type: http")
        out.append(f"    url: {json.dumps(url, ensure_ascii=False)}")
        provider_path = "./providers/" + name + ".yaml"
        out.append(f"    interval: {interval}")
        out.append(f"    path: {json.dumps(provider_path, ensure_ascii=False)}")
        out.append("    health-check:")
        out.append("      enable: true")
        out.append(f"      url: {json.dumps(health_url, ensure_ascii=False)}")
        out.append(f"      interval: {health_interval}")
    out.append("proxy-groups:")
    for group in order:
        out.append(f"  - name: {json.dumps(group, ensure_ascii=False)}")
        out.append("    type: select")
        out.append("    proxies:")
        out.append("      - DIRECT")
        out.append("    use:")
        for name in groups[group]:
            out.append(f"      - {json.dumps(name, ensure_ascii=False)}")
print("\n".join(out))
' "$1" "$2" "$3" "$4"
}

# sub_render_base: 去掉基础配置中已有的 uclash 受管标记块。
sub_render_base() {
  awk -v b="$SUB_MARK_BEGIN" -v e="$SUB_MARK_END" '
    $0 == b { skip = 1; next }
    $0 == e { skip = 0; next }
    !skip { print }
  ' "$1"
}

sub_generate_final() {
  local base="$1" stripped region
  stripped="$(sub_render_base "$base")" || return 1
  if printf '%s\n' "$stripped" | grep -Eq '^proxy-providers[[:space:]]*:'; then
    log "错误: 基础配置含 top-level proxy-providers, 请删除 (改由 uclash 生成): $base"
    return 1
  fi
  if printf '%s\n' "$stripped" | grep -Eq '^proxy-groups[[:space:]]*:'; then
    log "错误: 基础配置含 top-level proxy-groups, 请删除 (改由 uclash 生成): $base"
    return 1
  fi
  region="$(sub_render_region "$(sub_file)" "$CLASH_SUB_GROUP" "$CLASH_SUB_HEALTH_URL" "$CLASH_SUB_HEALTH_INTERVAL")" || return 1
  printf '%s\n' "$stripped"
  printf '%s\n' "$SUB_MARK_BEGIN"
  printf '%s\n' "$region"
  printf '%s\n' "$SUB_MARK_END"
}

sub_ensure_base() {
  local base
  base="$(sub_base_file)"
  [ -f "$base" ] && return 0
  mkdir -p "$(dirname "$base")" || die "无法创建目录: $(dirname "$base")"
  if [ -f "$CLASH_CONFIG" ]; then
    cp -p "$CLASH_CONFIG" "$base" || die "初始化基础配置失败: $base"
    log "已从 $CLASH_CONFIG 初始化基础配置: $base"
    log "若其中含 proxy-providers/proxy-groups, 请删除后重试 (这些由 uclash 生成)"
  else
    cat > "$base" <<'EOF'
# uclash 基础配置 (mihomo 主配置的非订阅部分; 请在此维护端口/DNS/规则等)
# uclash sub 会在文件末尾追加以标记块包裹的 proxy-providers 与 proxy-groups。
mixed-port: 7890
external-controller: 127.0.0.1:9090
# secret: "your-secret"
mode: rule
log-level: info
dns:
  enable: false
proxies: []
rules:
  - MATCH,DIRECT
EOF
    log "已生成基础配置模板: $base"
  fi
  chmod 0644 "$base" 2>/dev/null || true
}

sub_apply_config() {
  local base final backup tmp
  base="$(sub_base_file)"
  [ -f "$base" ] || die "基础配置不存在: $base"
  sub_config_writable
  final="$(sub_generate_final "$base")" || die "生成配置失败 (基础配置: $base)"
  backup="$(sub_config_backup)"
  tmp="${CLASH_CONFIG}.tmp.$$"
  printf '%s\n' "$final" > "$tmp" || { rm -f "$tmp"; sub_config_restore "$backup"; die "写入临时配置失败"; }
  if ! sub_config_validate "$tmp"; then
    rm -f "$tmp"
    sub_config_restore "$backup"
    die "配置校验失败, 已回滚 (基础配置: $base)"
  fi
  mv -f "$tmp" "$CLASH_CONFIG" || { rm -f "$tmp"; sub_config_restore "$backup"; die "替换配置失败"; }
  log "已生成 mihomo 配置: $CLASH_CONFIG (基础配置: $base)"
}

sub_reload() {
  require curl
  local body code
  body="$(python3 -c 'import json,sys;print(json.dumps({"path": sys.argv[1]}))' "$CLASH_CONFIG")"
  if ! code="$(api_request_code PUT "/configs?force=true" "$body")"; then
    log "警告: 无法访问 mihomo API ($(api_base)); 配置已写入但未重载"
    log "如需生效: sudo systemctl restart $CLASH_SERVICE"
    return 0
  fi
  case "$code" in
    2*) log "已重载 mihomo 配置" ;;
    *)  log "警告: 重载失败 (HTTP $code); 配置已写入, 可执行: sudo systemctl restart $CLASH_SERVICE" ;;
  esac
}

sub_print_plan() {
  local action="$1" name="$2" url="$3" group="$4" interval="$5"
  printf '订阅变更计划:\n'
  printf '  动作: %s\n' "$action"
  printf '  名称: %s\n' "$name"
  printf '  URL:  %s\n' "$(sub_mask_one "$url")"
  printf '  组:   %s\n' "$group"
  printf '  间隔: %s 秒\n' "$interval"
  printf '  配置: %s\n' "$CLASH_CONFIG"
  log "dry-run: 未执行任何写入"
}

cmd_sub_add() {
  local url="" name="" group="$CLASH_SUB_GROUP" interval="$CLASH_SUB_INTERVAL"
  local reload=1 dryrun=0
  local -a pos=()
  while [ "$#" -gt 0 ]; do
    case "$1" in
      --name)       shift; name="${1:-}"; [ -n "$name" ] || die "--name 需要参数" ;;
      --name=*)     name="${1#*=}" ;;
      --group)      shift; group="${1:-}"; [ -n "$group" ] || die "--group 需要参数" ;;
      --group=*)    group="${1#*=}" ;;
      --interval)   shift; interval="${1:-}"; [ -n "$interval" ] || die "--interval 需要参数" ;;
      --interval=*) interval="${1#*=}" ;;
      --no-reload)  reload=0 ;;
      --dry-run)    dryrun=1 ;;
      -h|--help)    usage_sub; return 0 ;;
      --)           shift; while [ "$#" -gt 0 ]; do pos+=("$1"); shift; done; break ;;
      -*)           die "未知选项: $1" ;;
      *)            pos+=("$1") ;;
    esac
    shift
  done
  [ "${#pos[@]}" -eq 1 ] || die "用法: uclash sub add <URL> [--name N] [--group G] [--interval S] [--no-reload] [--dry-run]"
  url="${pos[0]}"
  sub_validate_url "$url"
  sub_validate_interval "$interval"

  if [ -z "$name" ]; then
    name="$(sub_default_name "$url")"
    name="$(sub_unique_name "$name")"
  fi
  sub_validate_name "$name"

  if [ "$dryrun" -eq 1 ]; then
    sub_print_plan add "$name" "$url" "$group" "$interval"
    return 0
  fi

  sub_ensure_base
  sub_registry_upsert "$name" "$url" "$interval" "$group"
  sub_apply_config
  if [ "$reload" -eq 1 ]; then
    sub_reload
  else
    log "已跳过重载 (--no-reload); 生效请执行: sudo systemctl restart $CLASH_SERVICE"
  fi
}

cmd_sub_set() {
  local group="$CLASH_SUB_GROUP" interval="$CLASH_SUB_INTERVAL" reload=1 dryrun=0
  local -a pos=()
  while [ "$#" -gt 0 ]; do
    case "$1" in
      --group)      shift; group="${1:-}"; [ -n "$group" ] || die "--group 需要参数" ;;
      --group=*)    group="${1#*=}" ;;
      --interval)   shift; interval="${1:-}"; [ -n "$interval" ] || die "--interval 需要参数" ;;
      --interval=*) interval="${1#*=}" ;;
      --no-reload)  reload=0 ;;
      --dry-run)    dryrun=1 ;;
      -h|--help)    usage_sub; return 0 ;;
      --)           shift; while [ "$#" -gt 0 ]; do pos+=("$1"); shift; done; break ;;
      -*)           die "未知选项: $1" ;;
      *)            pos+=("$1") ;;
    esac
    shift
  done
  [ "${#pos[@]}" -eq 2 ] || die "用法: uclash sub set <名称> <URL> [--group G] [--interval S] [--no-reload] [--dry-run]"
  local name="${pos[0]}" url="${pos[1]}"
  sub_validate_url "$url"
  sub_validate_interval "$interval"
  sub_validate_name "$name"

  if [ "$dryrun" -eq 1 ]; then
    sub_print_plan set "$name" "$url" "$group" "$interval"
    return 0
  fi

  sub_ensure_base
  sub_registry_upsert "$name" "$url" "$interval" "$group"
  sub_apply_config
  if [ "$reload" -eq 1 ]; then
    sub_reload
  else
    log "已跳过重载 (--no-reload); 生效请执行: sudo systemctl restart $CLASH_SERVICE"
  fi
}

cmd_sub_remove() {
  local reload=1 dryrun=0
  local -a pos=()
  while [ "$#" -gt 0 ]; do
    case "$1" in
      --no-reload) reload=0 ;;
      --dry-run)   dryrun=1 ;;
      -h|--help)   usage_sub; return 0 ;;
      --)          shift; while [ "$#" -gt 0 ]; do pos+=("$1"); shift; done; break ;;
      -*)          die "未知选项: $1" ;;
      *)           pos+=("$1") ;;
    esac
    shift
  done
  [ "${#pos[@]}" -eq 1 ] || die "用法: uclash sub remove <名称> [--no-reload] [--dry-run]"
  local name="${pos[0]}"
  sub_validate_name "$name"

  if [ "$dryrun" -eq 1 ]; then
    printf '订阅变更计划:\n  动作: remove\n  名称: %s\n  配置: %s\n' "$name" "$CLASH_CONFIG"
    log "dry-run: 未执行任何写入"
    return 0
  fi

  sub_registry_has "$name" || die "未找到订阅: $name"
  sub_ensure_base
  sub_registry_remove "$name" || die "删除订阅失败: $name"
  sub_apply_config
  if [ "$reload" -eq 1 ]; then
    sub_reload
  else
    log "已跳过重载 (--no-reload); 生效请执行: sudo systemctl restart $CLASH_SERVICE"
  fi
}

cmd_sub_list() {
  [ "$#" -eq 0 ] || die "sub list 不接受参数: $*"
  local f out
  f="$(sub_file)"
  if [ ! -s "$f" ]; then
    log "未配置任何订阅 provider"
    return 0
  fi
  out="$(awk -F'\t' 'NF >= 4 && $1 !~ /^#/ { print $1"\t"$2"\t"$3"\t"$4 }' "$f")"
  if [ -z "$out" ]; then
    log "未配置任何订阅 provider"
    return 0
  fi
  printf '%-18s %-44s %-10s %s\n' "名称" "URL(脱敏)" "interval" "组"
  printf '%s\n' "$out" | sub_mask_url | while IFS="$(printf '\t')" read -r name url interval group; do
    printf '%-18s %-44s %-10s %s\n' "$name" "$url" "$interval" "${group:-无}"
  done
}

cmd_sub_nodes() {
  [ "$#" -le 1 ] || die "用法: uclash sub nodes <名称>"
  local name="${1:-}"
  [ -n "$name" ] || die "用法: uclash sub nodes <名称>"
  require curl
  require python3
  local enc json
  enc="$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1], safe=""))' "$name")"
  json="$(api_request GET "/providers/proxies/$enc")" || die "无法访问 mihomo API ($(api_base))"
  python3 - "$name" "$json" <<'PY'
import json, sys

name, raw = sys.argv[1], sys.argv[2]
try:
    data = json.loads(raw)
except Exception as exc:
    print(f"解析 API 响应失败: {exc}", file=sys.stderr)
    sys.exit(1)

proxies = data.get("proxies") if isinstance(data, dict) else None
if not isinstance(proxies, list):
    print(f"未找到 provider: {name}", file=sys.stderr)
    sys.exit(1)
if not proxies:
    print(f"{name}: 暂无节点 (provider 可能尚未拉取成功, 可执行: uclash sub update {name})")
    sys.exit(0)

print(f"{name} 共 {len(proxies)} 个节点:")
for i, p in enumerate(proxies, 1):
    if not isinstance(p, dict):
        continue
    pname = p.get("name", "?")
    hist = p.get("history") or []
    delay = hist[-1].get("delay") if hist and isinstance(hist[-1], dict) else None
    suffix = f"  ({delay}ms)" if isinstance(delay, int) and delay > 0 else ""
    print(f"  {i:>3}. {pname}{suffix}")
PY
}

sub_refresh() {
  local name="$1" enc code
  enc="$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1], safe=""))' "$name")"
  if ! code="$(api_request_code PUT "/providers/proxies/$enc")"; then
    die "刷新请求失败: $name (无法访问 mihomo API)"
  fi
  case "$code" in
    2*) log "已触发刷新: $name" ;;
    *)  die "刷新失败: $name (HTTP $code)" ;;
  esac
}

cmd_sub_status() {
  [ "$#" -le 1 ] || die "用法: uclash sub status [名称]"
  require curl
  require python3
  local name="${1:-}" path="/providers/proxies" json
  if [ -n "$name" ]; then
    path="/providers/proxies/$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1], safe=""))' "$name")"
  fi
  json="$(api_request GET "$path")" || die "无法访问 mihomo API ($(api_base))"
  python3 - "$name" "$json" <<'PY'
import json, sys

name, raw = sys.argv[1], sys.argv[2]
try:
    data = json.loads(raw)
except Exception as exc:
    print(f"解析 API 响应失败: {exc}", file=sys.stderr)
    sys.exit(1)

if name:
    providers = {name: data} if isinstance(data, dict) and data else {}
else:
    providers = data.get("providers", {}) if isinstance(data, dict) else {}

if not providers:
    print("未找到任何 provider")
    sys.exit(0)

for pname, p in providers.items():
    if not isinstance(p, dict):
        continue
    typ = p.get("vehicleType") or p.get("type") or "?"
    proxies = p.get("proxies")
    count = len(proxies) if isinstance(proxies, list) else "?"
    updated = p.get("updatedAt") or p.get("updated") or "未知"
    print(f"{pname}: 类型={typ} 节点数={count} 更新={updated}")
PY
}

cmd_sub_update() {
  [ "$#" -le 1 ] || die "用法: uclash sub update [名称]"
  require curl
  require python3
  local name="${1:-}"
  if [ -n "$name" ]; then
    sub_refresh "$name"
    return 0
  fi

  local json names
  json="$(api_request GET /providers/proxies)" || die "无法访问 mihomo API ($(api_base))"
  names="$(printf '%s' "$json" | python3 -c '
import json, sys
try:
    data = json.load(sys.stdin)
except Exception as exc:
    print(f"解析 API 响应失败: {exc}", file=sys.stderr)
    sys.exit(1)
for key in (data.get("providers") or {}):
    print(key)
')"
  [ -n "$names" ] || die "未找到任何 provider"
  local n
  while IFS= read -r n; do
    [ -n "$n" ] && sub_refresh "$n"
  done <<EOF
$names
EOF
}

cmd_sub() {
  local sub="${1:-help}"
  shift || true
  case "$sub" in
    add)            cmd_sub_add "$@" ;;
    set)            cmd_sub_set "$@" ;;
    remove|rm)      cmd_sub_remove "$@" ;;
    list|ls)        cmd_sub_list "$@" ;;
    nodes)          cmd_sub_nodes "$@" ;;
    status)         cmd_sub_status "$@" ;;
    update)         cmd_sub_update "$@" ;;
    help|-h|--help) usage_sub ;;
    *)              die "未知 sub 子命令: $sub (可用: add, set, remove, list, nodes, status, update)" ;;
  esac
}

complete_subs() {
  local f
  f="$(sub_file)"
  [ -r "$f" ] || return 0
  awk -F'\t' 'NF >= 1 && $1 != "" && $1 !~ /^#/ { print $1 }' "$f" 2>/dev/null || true
}

# complete_providers: 优先列出内核中全部 provider (含非 uclash 托管), API 不可用时回退订阅清单。
complete_providers() {
  local out=""
  if command -v curl >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then
    out="$(api_request GET /providers/proxies 2>/dev/null | python3 -c '
import json, sys
try:
    data = json.load(sys.stdin)
except Exception:
    sys.exit(0)
for key in (data.get("providers") or {}):
    print(key)
' 2>/dev/null || true)"
  fi
  if [ -n "$out" ]; then
    printf '%s\n' "$out"
    return 0
  fi
  complete_subs
}

usage_core() {
  cat <<EOF
uclash core - 管理 mihomo 核心二进制

用法:
  uclash core install [版本] [选项]   下载并安装指定/默认受支持版本的 mihomo
  uclash core list [选项]            列出下载主机上当前架构可安装的版本
  uclash core version                查看当前 mihomo 核心版本

install 选项:
  --user        安装到 $CLASH_CORE_USER_DIR
  --dry-run     仅显示安装计划, 不写盘
  --insecure    跳过 TLS 证书校验 (自签/私有 CA; 有风险)
  -h, --help    显示本帮助

list 选项:
  --insecure    跳过 TLS 证书校验
  -h, --help    显示本帮助

默认版本为已验证最高版本 $CLASH_MAX_VERSION; 默认安装目标优先取 systemd 服务 $CLASH_SERVICE 的实际启动路径。
下载来源为 UCLASH_BASE_URL 指定下载主机的目录列表, 不做 sha256 校验。
环境变量:
  UCLASH_INSECURE=${UCLASH_INSECURE:-}   置 1/true/yes/on 等价 --insecure
EOF
}

core_asset_suffix() {
  case "$(uname -m)" in
    x86_64|amd64)  printf '%s' "linux-amd64" ;;
    aarch64|arm64) printf '%s' "linux-arm64" ;;
    armv7l|armv7)  printf '%s' "linux-armv7" ;;
    armv6l|armv6)  printf '%s' "linux-armv6" ;;
    i386|i686|x86) printf '%s' "linux-386" ;;
    riscv64)       printf '%s' "linux-riscv64" ;;
    *)             return 1 ;;
  esac
}

service_exec_path() {
  command -v systemctl >/dev/null 2>&1 || return 1
  local out
  out="$(systemctl show -p ExecStart --value "$CLASH_SERVICE" 2>/dev/null)" || return 1
  [ -n "$out" ] || return 1
  printf '%s\n' "$out" | sed -n 's/.*path=\([^ ;]*\).*/\1/p'
}

core_dir_url() {
  local base
  base="$(resolve_base_url)" || return 1
  printf '%s/ubuntu-clash/core' "$base"
}

core_fetch() {
  local url="$1" dest="$2" what="$3" timeout="${4:-300}" err rc=0
  local -a tls=()
  [ "$UPDATE_INSECURE" -eq 1 ] && tls=(-k)
  err="$(curl "${tls[@]}" -fSL --retry 3 --max-time "$timeout" -o "$dest" "$url" 2>&1)" || rc=$?
  if [ "$rc" -ne 0 ]; then
    if [ "$UPDATE_INSECURE" -eq 0 ] && is_cert_error "$err"; then
      die "$what 失败: $url
提示: TLS 证书校验失败; 若服务器使用自签/私有 CA 证书, 可重试加 --insecure (或 UCLASH_INSECURE=1)"
    fi
    die "$what 失败: $url"
  fi
}

core_index_names() {
  local file="$1"
  python3 - "$file" <<'PY'
import sys
from html.parser import HTMLParser
from urllib.parse import unquote, urlsplit


class Links(HTMLParser):
    def __init__(self):
        super().__init__()
        self.hrefs = []

    def handle_starttag(self, tag, attrs):
        if tag != "a":
            return
        for key, value in attrs:
            if key == "href" and value:
                self.hrefs.append(value)


parser = Links()
with open(sys.argv[1], "r", encoding="utf-8", errors="replace") as fh:
    parser.feed(fh.read())

for href in parser.hrefs:
    name = unquote(urlsplit(href).path).rstrip("/").rsplit("/", 1)[-1]
    if name:
        print(name)
PY
}

core_index_resolve() {
  local file="$1" asset="$2"
  core_index_names "$file" | grep -Fxq -- "$asset"
}

confirm() {
  local prompt="$1" reply
  [ -t 0 ] || return 1
  printf '%s [y/N] ' "$prompt" >&2
  read -r reply || return 1
  case "$reply" in
    y|Y|yes|YES) return 0 ;;
    *) return 1 ;;
  esac
}

core_resolve_version() {
  local v="${1#v}"
  [ -n "$v" ] || return 1
  printf '%s' "$v"
}

cmd_core_version() {
  local info
  if info="$(api_version_info 2>/dev/null)"; then
    local av="${info#* }"
    printf '%s\n' "${av#v}"
    return 0
  fi
  local bin line ver
  bin="$(command -v mihomo 2>/dev/null || true)"
  if [ -n "$bin" ] && [ -x "$bin" ]; then
    line="$("$bin" -v 2>/dev/null || true)"
    line="${line%%$'\n'*}"
    ver="$(printf '%s\n' "$line" | sed -n 's/^Mihomo Meta \([^ ]*\).*/\1/p')"
    if [ -n "$ver" ]; then
      printf '%s\n' "${ver#v}"
    else
      printf '%s\n' "$line"
    fi
    return 0
  fi
  die "无法获取 mihomo 版本: API 不可达且未找到 mihomo 命令"
}

cmd_core_list() {
  while [ "$#" -gt 0 ]; do
    case "$1" in
      -insecure|--insecure) UPDATE_INSECURE=1 ;;
      -h|--help)            usage_core; return 0 ;;
      --)                   shift; break ;;
      -*)                   die "未知选项: $1" ;;
      *)                    die "多余的参数: $1" ;;
    esac
    shift
  done
  [ "$#" -eq 0 ] || die "多余的参数: $*"

  require curl
  require uname
  require python3

  local suffix dir
  suffix="$(core_asset_suffix)" || die "不支持的架构: $(uname -m)"
  dir="$(core_dir_url)" || die "未配置下载地址 (UCLASH_BASE_URL)"

  CORE_WORK="$(mktemp -d)"
  trap 'rm -rf "$CORE_WORK"' EXIT

  local index="$CORE_WORK/index.html"
  log "获取列表 $dir/ ..."
  core_fetch "$dir/" "$index" "获取列表" "$CLASH_API_TIMEOUT"

  local versions
  versions="$(core_index_names "$index" \
    | sed -n "s|^mihomo-${suffix}-v\([0-9][0-9.]*\)\.deb\$|\1|p" \
    | sort -Vu)"
  if [ -z "$versions" ]; then
    log "无可用版本 (架构: $suffix)"
    return 0
  fi
  printf '%s\n' "$versions"
}

cmd_core_install() {
  local ver="" user=0 dryrun=0
  while [ "$#" -gt 0 ]; do
    case "$1" in
      --user)               user=1 ;;
      --dry-run)            dryrun=1 ;;
      -insecure|--insecure) UPDATE_INSECURE=1 ;;
      -h|--help)            usage_core; return 0 ;;
      --)          shift; break ;;
      -*)          die "未知选项: $1" ;;
      *)
        [ -z "$ver" ] || die "版本重复指定: $1"
        ver="$1"
        ;;
    esac
    shift
  done
  [ "$#" -eq 0 ] || die "多余的参数: $*"

  require curl
  require dpkg-deb
  require uname
  require python3

  ver="$(core_resolve_version "${ver:-$CLASH_MAX_VERSION}")" || die "无效版本"
  local suffix asset dir
  suffix="$(core_asset_suffix)" || die "不支持的架构: $(uname -m)"
  asset="mihomo-${suffix}-v${ver}.deb"
  dir="$(core_dir_url)" || die "未配置下载地址 (UCLASH_BASE_URL)"
  local url="$dir/$asset"

  local target
  if [ "$user" -eq 1 ]; then
    target="${CLASH_CORE_USER_DIR}/mihomo"
  else
    target="$(service_exec_path || true)"
    [ -n "$target" ] || target="/usr/local/bin/mihomo"
  fi

  local cover
  if [ -e "$target" ]; then
    cover="是 (原文件备份为 ${target}.bak.<时间戳>)"
  else
    cover="否 (新建)"
  fi
  printf 'mihomo 核心安装计划:\n'
  printf '  版本:   %s\n' "$ver"
  printf '  架构:   %s\n' "$suffix"
  printf '  列表:   %s/\n' "$dir"
  printf '  来源:   %s\n' "$url"
  printf '  目标:   %s\n' "$target"
  printf '  校验:   无 (按目录列表下载)\n'
  printf '  覆盖:   %s\n' "$cover"

  if version_gt "$ver" "$CLASH_MAX_VERSION"; then
    log "警告: 版本 $ver 高于已验证的 $CLASH_MAX_VERSION, 可能不兼容"
  fi

  if [ "$dryrun" -eq 1 ]; then
    log "dry-run: 未执行任何写入"
    return 0
  fi

  if [ "$user" -eq 0 ]; then
    local d
    d="$(dirname "$target")"
    [ -w "$d" ] || die "目标目录不可写: $d (系统级安装请使用 sudo)"
  fi

  CORE_WORK="$(mktemp -d)"
  trap 'rm -rf "$CORE_WORK"' EXIT

  local index="$CORE_WORK/index.html"
  log "获取列表 $dir/ ..."
  core_fetch "$dir/" "$index" "获取列表" "$CLASH_API_TIMEOUT"
  core_index_resolve "$index" "$asset" >/dev/null || die "列表中未找到资产: $asset"

  if ! confirm "确认下载并安装到 $target ?"; then
    die "已取消"
  fi

  local deb="$CORE_WORK/$asset" xdir="$CORE_WORK/extract" bin
  log "下载 $url ..."
  core_fetch "$url" "$deb" "下载"

  mkdir -p "$xdir"
  dpkg-deb -x "$deb" "$xdir" || die "解包失败: $asset"
  bin="$xdir/usr/bin/mihomo"
  [ -f "$bin" ] || die "包内未找到二进制: usr/bin/mihomo"
  chmod 0755 "$bin"

  local binline binver
  binline="$("$bin" -v 2>/dev/null || true)"
  binline="${binline%%$'\n'*}"
  binver="$(printf '%s\n' "$binline" | sed -n 's/^Mihomo Meta \([^ ]*\).*/\1/p')"
  if [ -n "$binver" ] && [ "${binver#v}" != "$ver" ]; then
    die "二进制版本不匹配: 期望 $ver, 实际 $binver"
  fi

  local tmp="${target}.tmp.$$"
  install -D -m 0755 "$bin" "$tmp" || die "写入临时文件失败: $tmp"
  if [ -e "$target" ]; then
    local backup="${target}.bak.$(date +%Y%m%d%H%M%S)"
    mv -f "$target" "$backup" || die "备份原文件失败: $target"
    log "已备份原文件到 $backup"
  fi
  mv -f "$tmp" "$target" || die "安装失败: $target"

  log "已安装 mihomo $ver -> $target"

  local execpath
  execpath="$(service_exec_path || true)"
  if [ "$user" -eq 1 ]; then
    log "注意: --user 安装不会影响系统服务 $CLASH_SERVICE"
  elif [ -n "$execpath" ] && [ "$execpath" != "$target" ]; then
    log "注意: 服务 $CLASH_SERVICE 实际启动路径为 $execpath, 与安装目标 $target 不一致, 可能不会生效"
  fi
  log "如需生效: sudo systemctl restart $CLASH_SERVICE"
}

usage_update() {
  cat <<EOF
uclash update - 检查并更新 uclash 本体 (含 Bash/Zsh 补全)

用法:
  uclash update [选项]

选项 (单横线写法同样接受):
  -y, --yes        跳过确认, 直接执行更新 (非交互环境必须)
  --check          仅检查可用版本, 不下载或安装
  --force          允许降级, 或对同版本强制重装
  --dry-run        仅显示更新计划, 不写盘
  --base-url URL   覆盖下载基地址 (默认: 配置文件 update_base_url 或构建期内嵌值)
  --insecure       跳过 TLS 证书校验 (自签/私有 CA; 有风险)
  --prefix DIR     安装前缀 (默认由当前脚本路径推导)
  --shell <名称>   透传给安装脚本: bash | zsh | both | auto
  -h, --help       显示本帮助

更新清单: <基地址>/$UCLASH_RELEASE_PATH/update-manifest.json
更新说明: 取清单 notes 字段 (来源 docs/CHANGELOG.md 的 ### notes 段落)
环境变量:
  UCLASH_BASE_URL=${UCLASH_BASE_URL:-}   下载基地址
  UCLASH_INSECURE=${UCLASH_INSECURE:-}   置 1/true/yes/on 等价 --insecure
  UCLASH_STATE_DIR=$UCLASH_STATE_DIR   状态目录 (版本、地址与安装清单)
  UCLASH_CONFIG_FILE=$UCLASH_CONFIG_FILE  用户配置文件 (update_base_url)
EOF
}

current_version() {
  local v=""
  [ -s "$UCLASH_STATE_DIR/version" ] && v="$(tr -d '[:space:]' < "$UCLASH_STATE_DIR/version")"
  [ -n "$v" ] || v="$UCLASH_VERSION"
  v="${v#v}"
  case "$v" in
    ""|*"@UCLASH_VERSION"*) return 1 ;;
  esac
  printf '%s' "$v"
}

uclash_source_version() {
  local self="${BASH_SOURCE[0]:-}" dir v
  [ -n "$self" ] || return 1
  if ! dir="$(cd "$(dirname "$self")" >/dev/null 2>&1 && pwd)"; then
    self="$(command -v -- uclash 2>/dev/null || true)"
    [ -n "$self" ] || return 1
    dir="$(cd "$(dirname "$self")" >/dev/null 2>&1 && pwd)" || return 1
  fi
  [ -f "$dir/../version" ] || return 1
  v="$(tr -d '[:space:]' < "$dir/../version")"
  v="${v#v}"
  [ -n "$v" ] || return 1
  printf '%s' "$v"
}

cmd_version() {
  [ "$#" -eq 0 ] || die "version 命令不接受参数: $*"
  local v=""
  v="$(current_version || true)"
  [ -n "$v" ] || v="$(uclash_source_version || true)"
  [ -n "$v" ] || die "无法确定 uclash 版本 (状态文件、内嵌版本与源码 version 均不可用)"
  printf 'uclash v%s\n' "$v"
}

current_prefix() {
  local self="${BASH_SOURCE[0]:-}"
  case "$self" in
    /*) ;;
    *) self="$(command -v -- uclash 2>/dev/null || true)" ;;
  esac
  [ -n "$self" ] || return 1
  local dir
  dir="$(cd "$(dirname "$self")" >/dev/null 2>&1 && pwd)" || return 1
  [ "$(basename "$dir")" = "bin" ] || return 1
  dirname "$dir"
}

resolve_base_url() {
  local explicit="${1:-}" line cfg
  if [ -n "$explicit" ]; then printf '%s' "$explicit"; return 0; fi
  if [ -n "${UCLASH_BASE_URL:-}" ]; then printf '%s' "$UCLASH_BASE_URL"; return 0; fi
  cfg="$(uclash_config_value update_base_url || true)"
  if [ -n "$cfg" ]; then printf '%s' "$cfg"; return 0; fi
  case "$UCLASH_BASE_URL_EMBEDDED" in
    ""|*"@UCLASH_BASE_URL"*) ;;
    *) printf '%s' "$UCLASH_BASE_URL_EMBEDDED"; return 0 ;;
  esac
  local f="$UCLASH_STATE_DIR/base_url"
  if [ -s "$f" ]; then
    while IFS= read -r line || [ -n "$line" ]; do
      case "$line" in
        ''|\#*) continue ;;
      esac
      printf '%s' "$line"
      return 0
    done < "$f"
  fi
  return 1
}

persist_base_url() {
  install -d "$UCLASH_STATE_DIR"
  printf '%s\n' "$1" > "$UCLASH_STATE_DIR/base_url"
  chmod 0644 "$UCLASH_STATE_DIR/base_url"
}

detect_downloader() {
  if command -v curl >/dev/null 2>&1; then
    printf '%s' curl
  elif command -v wget >/dev/null 2>&1; then
    printf '%s' wget
  else
    return 1
  fi
}

update_download() {
  local url="$1" dest="$2" err rc=0
  UPDATE_LAST_ERR=""
  if [ "$UPDATE_DOWNLOADER" = "curl" ]; then
    if [ "$UPDATE_INSECURE" -eq 1 ]; then
      err="$(curl -kfsSL --retry 3 --max-time 300 -o "$dest" "$url" 2>&1)" || rc=$?
    else
      err="$(curl -fsSL --retry 3 --max-time 300 -o "$dest" "$url" 2>&1)" || rc=$?
    fi
  else
    if [ "$UPDATE_INSECURE" -eq 1 ]; then
      err="$(wget -q --no-check-certificate -O "$dest" "$url" 2>&1)" || rc=$?
    else
      err="$(wget -q -O "$dest" "$url" 2>&1)" || rc=$?
    fi
  fi
  UPDATE_LAST_ERR="$err"
  [ "$rc" -eq 0 ]
}

is_cert_error() {
  local msg
  msg="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
  case "$msg" in
    *ssl*|*x509*|*certificate*|*self-signed*|*"self signed"*) return 0 ;;
  esac
  return 1
}

# update_die_download: 下载失败时输出错误; 未启用 insecure 且疑似证书问题时追加提示。
update_die_download() {
  local msg="$1"
  if [ "$UPDATE_INSECURE" -eq 0 ] && is_cert_error "$UPDATE_LAST_ERR"; then
    die "$msg
提示: TLS 证书校验失败; 若服务器使用自签/私有 CA 证书, 可重试加 --insecure (或 UCLASH_INSECURE=1)"
  fi
  die "$msg"
}

update_sha256() {
  local file="$1"
  if command -v sha256sum >/dev/null 2>&1; then
    sha256sum "$file" | sed -n 's/ .*//p'
  elif command -v shasum >/dev/null 2>&1; then
    shasum -a 256 "$file" | sed -n 's/ .*//p'
  else
    return 1
  fi
}

# update_read_manifest: 校验更新清单, 并把字段拆分到 <work>/manifest.* 供 shell 读取。
update_read_manifest() {
  local json="$1" work="$2"
  python3 - "$json" "$work" <<'PY'
import json
import os
import re
import sys

path, work = sys.argv[1], sys.argv[2]


def fail(msg):
    sys.stderr.write(msg + "\n")
    raise SystemExit(1)


try:
    with open(path, "r", encoding="utf-8") as fh:
        data = json.load(fh)
except (OSError, ValueError) as exc:
    fail("无法解析更新清单: %s" % exc)

if not isinstance(data, dict):
    fail("更新清单顶层应为对象")

version = data.get("version")
update_date = data.get("update_date")
file_name = data.get("file")
digest = data.get("sha256")
notes = data.get("notes")
files = data.get("files")

if not isinstance(version, str) or not version.strip():
    fail("更新清单缺少 version")
if not isinstance(update_date, str) or not update_date.strip():
    fail("更新清单缺少 update_date")
update_date = update_date.strip()
if not re.fullmatch(r"[0-9]{12}", update_date):
    fail("更新清单 update_date 非法: %s (expect YYYYMMDDHHMM)" % update_date)
if not isinstance(file_name, str) or not file_name.strip():
    fail("更新清单缺少 file")
file_name = file_name.strip()
if "/" in file_name or "\\" in file_name or file_name in (".", ".."):
    fail("更新清单 file 非法: %s" % file_name)
if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", digest):
    fail("更新清单 sha256 非法")
if not isinstance(notes, str) or not notes.strip():
    fail("更新清单缺少 notes; 请按更新记录规范补 docs/CHANGELOG.md 的 ### notes")
if not isinstance(files, list) or not files:
    fail("更新清单缺少 files")

clean = []
for item in files:
    if not isinstance(item, str) or not item.strip():
        fail("更新清单 files 含非法条目")
    item = item.strip()
    if item.startswith("/") or ".." in item.split("/"):
        fail("更新清单 files 含非法路径: %s" % item)
    clean.append(item)

payload = {
    "manifest.version": version.strip() + "\n",
    "manifest.update_date": update_date + "\n",
    "manifest.file": file_name + "\n",
    "manifest.sha256": digest.lower() + "\n",
    "manifest.notes": notes.strip() + "\n",
    "manifest.files": "\n".join(clean) + "\n",
}
for name, content in payload.items():
    with open(os.path.join(work, name), "w", encoding="utf-8") as fh:
        fh.write(content)
PY
}

# update_rel_name <abs-path>: 把安装绝对路径映射为归档内相对名; 无法映射时输出空行。
update_rel_name() {
  case "$1" in
    */bin/uclash)                         printf 'bin/uclash\n' ;;
    */bash-completion/completions/uclash)  printf 'completions/uclash\n' ;;
    */.zfunc/_uclash)                     printf 'completions/_uclash\n' ;;
    */site-functions/_uclash)             printf 'completions/_uclash\n' ;;
    *)                                    printf '\n' ;;
  esac
}

# update_prune_obsolete: 删除旧安装清单中已不在新版 files 清单里的文件。
# 本地上一版安装清单记录的是绝对路径, 先映射为归档内相对名再与 manifest.files
# 比对; 无法判定归属的路径保留并提示人工确认 (调用前已快照旧清单)。
update_prune_obsolete() {
  local -a old=("$@") new=()
  local f n rel keep
  if [ -f "$UPDATE_WORK/manifest.files" ]; then
    while IFS= read -r f; do
      [ -n "$f" ] && new+=("$f")
    done < "$UPDATE_WORK/manifest.files"
  fi
  for f in ${old[@]+"${old[@]}"}; do
    rel="$(update_rel_name "$f")"
    # 存疑条目已在更新前提示并保留, 不在此处删除。
    [ -n "$rel" ] || continue
    keep=0
    for n in ${new[@]+"${new[@]}"}; do
      if [ "$n" = "$rel" ]; then keep=1; break; fi
    done
    [ "$keep" -eq 1 ] && continue
    if [ -e "$f" ] || [ -L "$f" ]; then
      rm -f -- "$f" || die "清理旧版本文件失败: $f"
      log "已清理旧版本文件: $f"
    fi
  done
}

cmd_update() {
  local base_arg="" force=0 check=0 dryrun=0 prefix="" shell_opt="" yes=0
  while [ "$#" -gt 0 ]; do
    case "$1" in
      -y|-yes|--yes)            yes=1 ;;
      -check|--check)           check=1 ;;
      -force|--force)           force=1 ;;
      -dry-run|--dry-run)       dryrun=1 ;;
      -base-url|--base-url)     shift; base_arg="${1:-}" ;;
      -base-url=*|--base-url=*) base_arg="${1#*=}" ;;
      -insecure|--insecure)     UPDATE_INSECURE=1 ;;
      -prefix|--prefix)         shift; prefix="${1:-}" ;;
      -prefix=*|--prefix=*)     prefix="${1#*=}" ;;
      -shell|--shell)           shift; shell_opt="${1:-}" ;;
      -shell=*|--shell=*)       shell_opt="${1#*=}" ;;
      -h|--help|help)           usage_update; return 0 ;;
      --)                       shift; break ;;
      -*)                       die "未知选项: $1" ;;
      *)                        die "多余的参数: $1" ;;
    esac
    shift
  done
  [ "$#" -eq 0 ] || die "多余的参数: $*"

  case "$shell_opt" in
    ""|auto|bash|zsh|both) ;;
    *) die "无效的 --shell 值: $shell_opt (可选: bash, zsh, both, auto)" ;;
  esac

  require mktemp
  require tar
  require python3
  UPDATE_DOWNLOADER="$(detect_downloader)" || die "需要 curl 或 wget 以进行下载"

  local base_url local_ver
  base_url="$(resolve_base_url "$base_arg")" || die "未配置下载地址, 请用 --base-url、UCLASH_BASE_URL 或在用户配置中设置 update_base_url"
  base_url="${base_url%/}"
  local_ver="$(current_version || true)"

  UPDATE_WORK="$(mktemp -d "${TMPDIR:-/tmp}/uclash-update.XXXXXX")"
  trap 'rm -rf "$UPDATE_WORK"' EXIT

  local root="$base_url/$UCLASH_RELEASE_PATH"
  log "获取更新清单: $root/update-manifest.json"
  update_download "$root/update-manifest.json" "$UPDATE_WORK/update-manifest.json" \
    || update_die_download "无法获取更新清单: $root/update-manifest.json"
  update_read_manifest "$UPDATE_WORK/update-manifest.json" "$UPDATE_WORK" \
    || die "更新清单校验失败: $root/update-manifest.json"

  local remote_ver archive expected_sha notes update_date
  remote_ver="$(tr -d '[:space:]' < "$UPDATE_WORK/manifest.version")"
  remote_ver="${remote_ver#v}"
  archive="$(tr -d '[:space:]' < "$UPDATE_WORK/manifest.file")"
  expected_sha="$(tr -d '[:space:]' < "$UPDATE_WORK/manifest.sha256")"
  notes="$(cat "$UPDATE_WORK/manifest.notes")"
  update_date="$(tr -d '[:space:]' < "$UPDATE_WORK/manifest.update_date")"
  [ -n "$remote_ver" ] || die "更新清单版本为空 (污染或不完整的发布?)"
  [ -n "$local_ver" ] || die "无法确定本地 uclash 版本 (状态文件与内嵌版本均不可用)"

  printf '当前版本: %s\n' "$local_ver"
  printf '远端版本: %s\n' "$remote_ver"
  printf '更新日期: %s\n' "$(printf '%s' "$update_date" | sed -E 's/^(.{4})(..)(..)(..)(..)$/\1-\2-\3 \4:\5/')"

  local action
  if version_gt "$remote_ver" "$local_ver"; then
    action="update"
    printf '状态:     可更新\n'
  elif [ "$remote_ver" = "$local_ver" ]; then
    action="current"
    printf '状态:     已是最新\n'
  else
    action="downgrade"
    printf '状态:     远端低于本地 (降级)\n'
  fi

  if [ "$action" = "update" ] || [ "$force" -eq 1 ]; then
    printf '更新说明:\n%s\n' "$notes"
  fi

  if [ "$check" -eq 1 ]; then
    [ "$action" = "update" ] && return 0
    return 1
  fi

  if [ "$action" = "current" ] && [ "$force" -eq 0 ]; then
    log "已是最新版本 $local_ver, 无需更新 (使用 --force 强制重装)"
    return 0
  fi
  if [ "$action" = "downgrade" ] && [ "$force" -eq 0 ]; then
    die "远端版本 $remote_ver 低于本地 $local_ver, 如需降级请加 --force"
  fi

  if [ -z "$prefix" ]; then
    prefix="$(current_prefix || true)"
  fi
  [ -n "$prefix" ] || prefix="$HOME/.local"
  prefix="${prefix%/}"

  local url="$root/$archive"
  printf '下载地址: %s\n' "$url"
  printf '安装前缀: %s\n' "$prefix"
  if [ "$force" -eq 1 ]; then printf '模式:     强制 (%s)\n' "$action"; fi

  if [ "$dryrun" -eq 1 ]; then
    log "dry-run: 未执行任何下载与写入"
    return 0
  fi

  if [ "$yes" -eq 0 ]; then
    if [ -t 0 ]; then
      confirm "确认更新 uclash $local_ver -> $remote_ver ?" || { log "已取消"; return 0; }
    else
      die "非交互环境执行更新需使用 -y/--yes 确认 (或 --check/--dry-run 仅查看)"
    fi
  fi

  log "下载 $url ..."
  update_download "$url" "$UPDATE_WORK/$archive" || update_die_download "下载失败: $url"

  local actual
  actual="$(update_sha256 "$UPDATE_WORK/$archive")" || die "需要 sha256sum 或 shasum 以校验下载内容"
  [ "$actual" = "$expected_sha" ] || die "sha256 校验失败: 期望 $expected_sha, 实际 $actual"
  log "sha256 校验通过"

  tar -xzf "$UPDATE_WORK/$archive" -C "$UPDATE_WORK" || die "解压失败: $archive"
  local inner="$UPDATE_WORK/uclash-unix/install.sh"
  [ -f "$inner" ] || die "归档中未找到 install.sh"

  local f
  while IFS= read -r f; do
    [ -n "$f" ] || continue
    [ -e "$UPDATE_WORK/uclash-unix/$f" ] || die "归档缺少更新清单中的文件: $f"
  done < "$UPDATE_WORK/manifest.files"

  local -a old_files=()
  if [ -f "$UCLASH_STATE_DIR/files.list" ]; then
    while IFS= read -r f; do
      [ -n "$f" ] && old_files+=("$f")
    done < "$UCLASH_STATE_DIR/files.list"
  fi

  # 安装脚本会按旧 files.list 全量删除后再重建; 先把无法映射为归档相对名的
  # 存疑条目从清单移出, 避免被擅自删除。这些文件保留在磁盘并提示人工确认。
  if [ "${#old_files[@]}" -gt 0 ]; then
    local -a kept=()
    local rel
    for f in "${old_files[@]}"; do
      rel="$(update_rel_name "$f")"
      if [ -z "$rel" ]; then
        log "无法判定旧文件归属, 保留待人工确认: $f"
      else
        kept+=("$f")
      fi
    done
    if [ "${#kept[@]}" -gt 0 ]; then
      printf '%s\n' "${kept[@]}" > "$UCLASH_STATE_DIR/files.list"
    else
      : > "$UCLASH_STATE_DIR/files.list"
    fi
  fi

  local -a passthru=(--prefix "$prefix")
  [ -n "$shell_opt" ] && passthru+=(--shell "$shell_opt")
  [ "$force" -eq 1 ] && passthru+=(--force)

  log "执行安装脚本, 更新至 $remote_ver ..."
  bash "$inner" ${passthru[@]+"${passthru[@]}"} < /dev/null

  update_prune_obsolete ${old_files[@]+"${old_files[@]}"}

  persist_base_url "$base_url"
  log "已更新 uclash: $local_ver -> $remote_ver"
  log "请执行 \"source ~/.bashrc\" 或 \"source ~/.zshrc\", 或重开终端以生效"
}

cmd_core() {
  local sub="${1:-help}"
  shift || true
  case "$sub" in
    install)        cmd_core_install "$@" ;;
    list|ls)        cmd_core_list "$@" ;;
    version|ver)    cmd_core_version "$@" ;;
    help|-h|--help) usage_core ;;
    *)              die "未知 core 子命令: $sub (可用: install, list, version)" ;;
  esac
}

main() {
  local cmd="${1:-help}"
  shift || true
  case "$cmd" in
    -V|--version) cmd="version" ;;
  esac
  case "$cmd" in
    on|start)        cmd_on "$@" ;;
    off|stop)        cmd_off "$@" ;;
    node)
      local sub="${1:-list}"
      shift || true
      case "$sub" in
        list|ls) node_list "$@" ;;
        groups|g) cmd_node_groups "$@" ;;
        use)     cmd_node_use "$@" ;;
        *)       die "未知 node 子命令: $sub (可用: list, groups, use)" ;;
      esac
      ;;
    status)          cmd_status "$@" ;;
    version)         cmd_version "$@" ;;
    doctor)          cmd_doctor "$@" ;;
    update)          cmd_update "$@" ;;
    core)            cmd_core "$@" ;;
    sub)             cmd_sub "$@" ;;
    help|-h|--help)  usage ;;
    __complete)      cmd_complete "$@" ;;
    *)               usage >&2; exit 1 ;;
  esac
}

main "$@"
