#!/usr/bin/env bash
#
# ip-test.sh — VPS / 落地节点 IP 检测工具（Docker 版，宿主机零残留）
#
# 菜单结构：
#   1) 本机质量检测（NodeQuality 一体化体检：硬件 / IP / 网络 / 回程路由）
#        1) 完整检测模式        四项全跑，跑完给出可分享报告链接
#        2) 硬件质量体检        HardwareQuality
#        3) IP 质量体检         IPQuality
#        4) 网络质量体检        NetQuality
#        5) 回程路由检测        Backroute Trace
#   2) 落地 IP 检测（经代理，默认执行 IP 纯净度检测）
#        1) 粘贴连接字符串（自动识别格式）
#        2) 手动输入 IP / 端口 / 账号 / 密码
#   3) 彻底清除脚本（删除镜像与依赖，把宿主机恢复到未安装状态）
#   4) 退出
#
# 为什么落地检测里没有回程路由：SOCKS5/HTTP 代理只承载 TCP/UDP，不承载 ICMP。
# 路由追踪经代理时要么失败、要么走宿主机真实线路（结果无效且暴露宿主机 IP）。
#
# 本机质量检测在一次性容器里跑 NodeQuality（宿主机零污染）。注意：容器内测硬盘 I/O
# 会受 overlay 文件系统影响，仅供参考；要精确硬盘数据请直接在宿主机/VM 里跑。
#
# 镜像与依赖默认【保留】，只有走菜单第 3 项或 --purge 才会全部删除。
#
# 连接字符串支持的格式（自动识别，方括号内可省略）：
#   socks5://user:pass@host:port      socks5h:// / http:// / https:// 同理
#   socks5://host:port                免认证
#   user:pass@host:port               无 scheme，默认按 socks5 处理
#   host:port@user:pass               反向 @ 写法
#   host:port:user:pass               机场/代理商常见的四段式
#   user:pass:host:port               四段式的另一种顺序
#   host:port                         免认证
#   还能自动剥掉 curl -x / --proxy / ALL_PROXY= / export 这类前缀和包裹的引号。
#   URL 形式里的 %XX 会解码；四段式里的 % 视为密码原文（不解码）。
#   密码里含 : 或 @ 也能正确切分（按最后一个 @ 和位置特征判断）。
#   ss:// vmess:// vless:// trojan:// 等不是 HTTP/SOCKS 代理，会明确拒绝。
#
# 凭据保护要点（"确保信息不泄露"）：
#   - 密码 / 连接字符串用 read -rs 读入，终端不回显，也不进 shell history
#   - 上游账密【绝不】出现在任何命令行参数里 —— 而是写进 gost 配置文件挂载进容器。
#     原因：容器进程在宿主机 `ps aux` 里能看到完整 cmdline，`docker inspect` 和
#     /var/lib/docker/containers/*/config.v2.json 里也会明文常驻。
#   - 配置文件放在 mktemp -d 建的 700 目录里，退出时 shred 覆写删除
#   - 传给检测容器的代理变量指向本地无认证桥接，本身不含任何密码
#   - 解析结果回显时密码只显示位数，不显示内容
#   - 检测到 set -x / bash -x 会直接中止（否则密码会被 xtrace 打到终端）
#   - 关闭 core dump，避免密码随核心转储落盘
#
# 用法：
#   ./ip-test.sh                  交互式菜单
#   ./ip-test.sh --purge          非交互：彻底清除（镜像 + 依赖）后退出；
#                                 默认不动 Docker，加 -y 才连 Docker 一并卸载
#   ./ip-test.sh --purge -y       非交互彻底清除，并自动确认卸载 Docker
#   ./ip-test.sh --no-install     缺依赖时只报错、不自动安装
#
# 上游脚本地址/参数如有变动，用环境变量覆盖即可，无需改脚本：
#   URL_IPQUALITY / NQ_URL                             下载地址（落地 IPQuality / NodeQuality）
#   IPQ_ARGS                                           传给落地 IPQuality 的额外参数
#   GOST_IMAGE / RUNNER_IMAGE                          桥接镜像 / 跑检测的基础镜像
# 例：IPQ_ARGS='-l en' ./ip-test.sh
#     NQ_URL=https://run.nodequality.com ./ip-test.sh
#
set -euo pipefail

# ── 硬性前置：xtrace 下会泄露密码，直接拒绝运行 ─────────────────────────
case $- in
  *x*) echo "检测到 set -x / bash -x：密码会被 xtrace 打印出来，已中止。" >&2; exit 1 ;;
esac

umask 077
ulimit -c 0 2>/dev/null || true   # 禁止 core dump，防止密码落盘

# ── 可覆盖的镜像 ───────────────────────────────────────────────────────
GOST_IMAGE=${GOST_IMAGE:-gogost/gost:latest}
RUNNER_IMAGE=${RUNNER_IMAGE:-debian:stable-slim}

# ── 检测脚本地址（上游改路径时改这里或用环境变量覆盖）──────────────────
URL_IPQUALITY=${URL_IPQUALITY:-https://raw.githubusercontent.com/xykt/IPQuality/main/ip.sh}
# NodeQuality 一体化体检脚本（官方一键入口，等价于 bash <(curl -sL 该地址)）
NQ_URL=${NQ_URL:-https://run.nodequality.com}

RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'
BLUE=$'\033[0;34m'; CYAN=$'\033[0;36m'; DIM=$'\033[2m'; NC=$'\033[0m'

NAME_PREFIX='iptest-bridge-'
BRIDGE_NAME=''
WORKDIR=''
LOCAL_PORT=''
AUTO_INSTALL=1
PURGE_ONLY=0
ASSUME_YES=0
INSTALLED_PKGS=()

# 一键导出的报告文件路径（本轮会话里生成的都记在这，脚本彻底退出时自动删除，
# 不给宿主机留任何残留；会话过程中随时可以打开/复制，退出后才清掉）。
EXPORTED_REPORTS=()

# 记录“本脚本亲手装的东西”，供「彻底清除」时精确回滚（只删我们装的，不碰你原有的）。
# 位置可用 IPTEST_STATE 覆盖（默认 /var/lib/ip-test）。
STATE_DIR=${IPTEST_STATE:-/var/lib/ip-test}
MANIFEST="$STATE_DIR/installed.list"
record_line() {
  mkdir -p "$STATE_DIR" 2>/dev/null || return 0
  if [[ -f $MANIFEST ]] && grep -qxF -- "$1" "$MANIFEST" 2>/dev/null; then
    return 0
  fi
  printf '%s\n' "$1" >> "$MANIFEST" 2>/dev/null || true
}

# 解析结果（全局，供 gost 配置生成使用）
PROXY_PROTO=''
PROXY_HOST=''
PROXY_PORT=''
PROXY_USER=''
PROXY_PASS=''

for arg in "$@"; do
  case $arg in
    --purge)      PURGE_ONLY=1 ;;
    -y|--yes)     ASSUME_YES=1 ;;
    --no-install) AUTO_INSTALL=0 ;;
    -h|--help) awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; exit 0 ;;
    *) echo "未知参数：$arg（试试 --help）" >&2; exit 1 ;;
  esac
done

die()  { echo "${RED}[错误] $*${NC}" >&2; exit 1; }
# soft：只中止当前这一项，回到菜单，不杀掉整个脚本
soft() { echo "${RED}[错误] $*${NC}" >&2; return 1; }

# ── 每轮落地检测后的临时资源清理（凭据 + 桥接容器），镜像不动 ────────────
teardown_bridge() {
  stty echo 2>/dev/null || true
  if [[ -n $BRIDGE_NAME ]]; then
    docker rm -f "$BRIDGE_NAME" >/dev/null 2>&1 || true
    BRIDGE_NAME=''
  fi
  if [[ -n $WORKDIR && -d $WORKDIR ]]; then
    command -v shred >/dev/null 2>&1 && \
      find "$WORKDIR" -type f -exec shred -uzn1 {} + 2>/dev/null || true
    rm -rf -- "$WORKDIR" 2>/dev/null || true
    WORKDIR=''
  fi
  PROXY_PROTO=''; PROXY_HOST=''; PROXY_PORT=''; PROXY_USER=''; PROXY_PASS=''
}

CLEANED=''
cleanup() {
  local code=$?
  [[ -n $CLEANED ]] && return "$code"
  CLEANED=1
  teardown_bridge
  if (( ${#EXPORTED_REPORTS[@]} )); then
    local f deleted=0
    for f in "${EXPORTED_REPORTS[@]}"; do
      [[ -f $f ]] && rm -f -- "$f" 2>/dev/null && deleted=$((deleted+1))
    done
    (( deleted )) && echo "${DIM}已清理本次会话导出的 ${deleted} 份报告文件，宿主机不留残留。${NC}" >&2
  fi
  return "$code"
}
trap cleanup EXIT
# Ctrl-C / TERM 真正中止：只发 exit，清理交给 EXIT trap 跑一次
trap 'echo; exit 130' INT TERM

# ══════════════════════════════════════════════════════════════════════
#  依赖检测与自动安装
# ══════════════════════════════════════════════════════════════════════
PKG_MGR=''
detect_pkg_mgr() {
  local m
  for m in apt-get dnf yum apk pacman zypper; do
    command -v "$m" >/dev/null 2>&1 && { PKG_MGR=$m; return 0; }
  done
  return 1
}

pkg_install() {
  local generic=$1 pkg
  case $PKG_MGR:$generic in
    *:curl)     pkg=curl ;;
    apt-get:iproute2|zypper:iproute2) pkg=iproute2 ;;
    *:iproute2) pkg=iproute ;;
    *:docker)   pkg=docker ;;
  esac
  echo "${BLUE}  安装 ${generic} (包名 ${pkg}) …${NC}"
  case $PKG_MGR in
    apt-get) DEBIAN_FRONTEND=noninteractive apt-get install -y "$pkg" >/dev/null ;;
    dnf)     dnf install -y "$pkg" >/dev/null ;;
    yum)     yum install -y "$pkg" >/dev/null ;;
    apk)     apk add --no-cache "$pkg" >/dev/null ;;
    pacman)  pacman -Sy --noconfirm "$pkg" >/dev/null ;;
    zypper)  zypper --non-interactive install "$pkg" >/dev/null ;;
  esac || die "自动安装 ${pkg} 失败，请手动安装后重试。"
  INSTALLED_PKGS+=("$pkg")
  record_line "pkg|$PKG_MGR|$pkg"
}

apt_updated=0
ensure_pkg() { # ensure_pkg <命令名> <通用依赖名>
  command -v "$1" >/dev/null 2>&1 && return 0
  (( AUTO_INSTALL )) || die "缺少依赖 $1，且已指定 --no-install。"
  [[ $EUID -eq 0 ]] || die "缺少依赖 $1，需要 root 才能自动安装（请用 sudo 运行，或手动安装）。"
  detect_pkg_mgr || die "缺少依赖 $1，且未识别到包管理器，请手动安装。"
  if [[ $PKG_MGR == apt-get && $apt_updated -eq 0 ]]; then
    echo "${DIM}  apt-get update …${NC}"; apt-get update -qq >/dev/null 2>&1 || true; apt_updated=1
  fi
  pkg_install "$2"
  command -v "$1" >/dev/null 2>&1 || die "安装完成但仍找不到 $1，请手动检查。"
}

install_docker() {
  echo "${YELLOW}未检测到 Docker，准备自动安装。${NC}"
  if [[ -d /etc/pve ]] || command -v pveversion >/dev/null 2>&1; then
    echo "${RED}  ⚠ 检测到这是 Proxmox VE 宿主机。官方不建议在 PVE 宿主机直接装 Docker：${NC}"
    echo "${RED}    Docker 会改写 iptables/nftables FORWARD 策略，可能干扰 PVE 桥接与 VM 网络。${NC}"
    echo "${DIM}    更稳妥的做法是在一台 LXC/VM 里跑本脚本。继续安装请在 5 秒内 Ctrl-C 取消…${NC}"
    sleep 5
  fi
  (( AUTO_INSTALL )) || die "未安装 Docker，且已指定 --no-install。"
  [[ $EUID -eq 0 ]] || die "自动安装 Docker 需要 root，请用 sudo 运行，或手动安装 Docker。"
  detect_pkg_mgr || die "未识别到包管理器，无法自动安装 Docker，请手动安装。"

  if command -v curl >/dev/null 2>&1 && [[ $PKG_MGR != apk ]]; then
    echo "${BLUE}  通过 Docker 官方脚本 get.docker.com 安装…${NC}"
    if curl -fsSL https://get.docker.com | sh >/dev/null 2>&1; then
      INSTALLED_PKGS+=("docker(get.docker.com)")
      record_line "docker|$PKG_MGR|get.docker.com"
    else
      echo "${DIM}  官方脚本失败，回退到发行版仓库…${NC}"; pkg_install docker
    fi
  else
    case $PKG_MGR in
      apk) apk add --no-cache docker docker-cli >/dev/null || die "apk 安装 docker 失败。"
           INSTALLED_PKGS+=("docker"); record_line "pkg|apk|docker"; record_line "pkg|apk|docker-cli" ;;
      *)   pkg_install docker ;;
    esac
  fi

  if command -v systemctl >/dev/null 2>&1; then
    systemctl enable --now docker >/dev/null 2>&1 || true
  elif command -v rc-service >/dev/null 2>&1; then
    rc-update add docker boot >/dev/null 2>&1 || true; rc-service docker start >/dev/null 2>&1 || true
  elif command -v service >/dev/null 2>&1; then
    service docker start >/dev/null 2>&1 || true
  fi

  local i
  for (( i=0; i<20; i++ )); do docker info >/dev/null 2>&1 && break; sleep 1; done
  docker info >/dev/null 2>&1 || die "Docker 已安装但守护进程未就绪，请手动启动后重试。"
  echo "${GREEN}  Docker 安装完成并已运行。${NC}"
}

ensure_docker() {
  if ! command -v docker >/dev/null 2>&1; then
    install_docker
  elif ! docker info >/dev/null 2>&1; then
    echo "${YELLOW}Docker 已安装但守护进程未运行，尝试启动…${NC}"
    if command -v systemctl >/dev/null 2>&1; then systemctl start docker >/dev/null 2>&1 || true
    elif command -v service >/dev/null 2>&1; then service docker start >/dev/null 2>&1 || true; fi
    local i; for (( i=0; i<15; i++ )); do docker info >/dev/null 2>&1 && break; sleep 1; done
    docker info >/dev/null 2>&1 || die "无法连接 Docker 守护进程（需要 root 或加入 docker 组）。"
  fi
}

ensure_deps() {
  ensure_pkg curl curl
  ensure_pkg ss   iproute2
  ensure_docker
}

# ══════════════════════════════════════════════════════════════════════
#  工具函数
# ══════════════════════════════════════════════════════════════════════
urlencode() {
  local LC_ALL=C
  local s=$1 i c h out=''
  for (( i=0; i<${#s}; i++ )); do
    c=${s:i:1}
    case $c in
      [a-zA-Z0-9.~_-]) out+=$c ;;
      *) printf -v h '%%%02X' "'$c"; out+=$h ;;
    esac
  done
  printf '%s' "$out"
}

# 只解码合法的 %XX，其余字符（包括孤立的 %）原样保留
urldecode() {
  local LC_ALL=C
  local s=$1 out='' i=0 c hex
  while (( i < ${#s} )); do
    c=${s:i:1}
    if [[ $c == '%' && ${s:i+1:2} =~ ^[0-9A-Fa-f]{2}$ ]]; then
      hex=${s:i+1:2}
      out+=$(printf '%b' "\\x${hex}")
      i=$(( i + 3 ))
    else
      out+=$c
      i=$(( i + 1 ))
    fi
  done
  printf '%s' "$out"
}

yaml_squote() { local s=${1//\'/\'\'}; printf "'%s'" "$s"; }

is_port() { [[ $1 =~ ^[0-9]+$ ]] && (( $1 >= 1 && $1 <= 65535 )); }

looks_like_host() {
  local h=$1
  [[ -z $h ]] && return 1
  [[ $h == localhost ]] && return 0
  [[ $h =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] && return 0          # IPv4
  [[ $h =~ ^\[[0-9A-Fa-f:.]+\]$ ]] && return 0                    # [IPv6]
  [[ $h =~ ^[A-Za-z0-9]([A-Za-z0-9_-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9_-]*[A-Za-z0-9])?)+$ ]] && return 0
  return 1
}

port_in_use() {
  local p=$1
  if command -v ss >/dev/null 2>&1; then
    ss -ltnH 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${p}\$"
    return $?
  fi
  (exec 3<>/dev/tcp/127.0.0.1/"$p") 2>/dev/null && { exec 3>&- 2>/dev/null || true; return 0; }
  return 1
}

pick_port() {
  local p n
  for (( n=0; n<80; n++ )); do
    p=$(( (RANDOM % 15000) + 20000 ))
    port_in_use "$p" || { printf '%s' "$p"; return 0; }
  done
  return 1
}

ensure_image() {
  local img=$1
  docker image inspect "$img" >/dev/null 2>&1 && return 0
  echo "${BLUE}  拉取镜像 ${img} …${NC}"
  docker pull -q "$img" >/dev/null 2>&1 || { soft "镜像拉取失败：${img}（检查网络或镜像名）"; return 1; }
}

bridge_running() {
  [[ "$(docker inspect -f '{{.State.Running}}' "$BRIDGE_NAME" 2>/dev/null)" == true ]]
}

# ── 广告横幅过滤 ─────────────────────────────────────────────────────────
# NodeQuality / IPQuality 这类上游脚本内部会跑第三方检测组件（回程路由、IP
# 质量等），这些组件常年挂着"广告轮播位"：赞助商会变，但版式一直是同一套——
# 连续好几行只由某个词高重复率堆出来的纯大写刷屏行，紧跟一行图文混排的推广
# 语（品牌名 + URL + 中文文案），块与块之间用空行分隔。
# 与其按具体广告商的字母表写死过滤规则（换个广告商就要重新加词），这里改成
# 识别这套通用版式：只要连续出现 2 行「高重复率大写刷屏行」，就判定进入广告
# 块，一路吞掉直到下一个空行为止（连推广语那行也一起吞，不用单独认它写的
# 是什么品牌）。真实检测数据（IP、AS 号、中文地名等）不会长这样，基本不会
# 被误伤；已知的几个广告关键词继续留作兜底。
#
# 关键点：必须按【字符】读，不能按【行】读。上游的下载/测速进度条是靠只发
# 「\r」不发「\n」来原地刷新的（比如 "# 10%\r## 50%\r######## 100%\n"），
# 之前用 `read -r line`（只认 \n）会把这一整串 \r 更新当成"还没读完的一
# 行"死死攒住，直到最后真正的 \n 出现才一次性吐出来——进度条看起来就是卡住
# 不动、最后突然跳 100%，攒起来的内容里混着好几个 \r 一次性打印还会把终端
# 搞出乱码方块。这里改成逐字符处理：遇到 \r 立刻原样吐出去（不参与广告判
# 定，反正真实的广告块都是完整整行、不会用 \r），只有遇到真正的 \n 才把攒
# 到的这一整行拿去跑广告识别逻辑，这样进度条能实时刷新，广告块也照样能拦。
#
# 这个过滤器还顺手干两件事（都因为"能实时透传 \r"之后才显出来）：
#   1) 摘掉 NodeQuality 那四个交互问句（见下 strip_nq_prompts）——它们黏成一坨混进输出；
#   2) 处理 \r 进度条与其后整行内容的衔接：用「回车 + 清到行尾」(\r\033[K) 覆盖上一条
#      进度行，而不是凭空补一个 \n。补 \n 会把进度条最后一帧（如 BenchOs 99.6%→100%）
#      拆成两行；覆盖式则始终在同一行刷新，且能清掉旧帧比新内容长时残留的尾巴（那正是
#      "测试完成…"被截断、报告链接显示不全的根因）。
# 广告从「结构」上识别，不再维护广告商名字/关键词（脚本更新换广告商也不会漏）。见 filter_ads。

# ── NodeQuality 交互问句摘除 ─────────────────────────────────────────────
# NodeQuality 官方入口用 read -p 逐个问「是否跑 硬件 / IP / 网络 / 回程」四个问题，
# 答案由我们从管道喂进去。因为 stdin 不是 tty，终端不会回显换行，四个问句会
# 无换行地紧紧黏成"一整行"混进正常输出里，例如：
#   运行 HardwareQuality 测试？（回车默认 'y'…）[y/f/v/n]：运行 IPQuality 测试？…[y/n]：…
# 不能整行丢弃（问句后面可能黏着"安装前清理"这类真实状态行），要摘掉"问句本身"、
# 保留黏在末尾的真实内容。识别特征极稳定：以「运行」开头、以「[y…/n]：」收尾，中间不含
# 全角「：」——真实检测数据（IP/AS/地名）不会长这样，不会误伤。
# 用 # 作 sed 分隔符：问句里含字面 /（如 [y/f/v/n]），再用默认的 / 作分隔符会冲突。
NQ_PROMPT_SED='s#运行[^：]*\[y[^]]*\][：]##g'
strip_nq_prompts() {   # 供 EOF 收尾与单测直接调用；只有命中特征才起 sed，普通行原样返回
  case $1 in
    *运行*'[y'*) printf '%s' "$1" | sed -E "$NQ_PROMPT_SED" ;;
    *)           printf '%s' "$1" ;;
  esac
}

# ── OSC 8 超链接展开 ─────────────────────────────────────────────────────
# NodeQuality / IPQuality 的"测试完成，点击查看你的结果"是一段 OSC 8 终端超链接：
#   ESC]8;;URL ESC\  可见文字  ESC]8;; ESC\      （收尾也可能是 BEL \a）
# URL 藏在不可见的转义序列里。它常和 curl 上传进度条挤在同一行，被进度条的 \r 刷新
# 覆盖破坏 → 终端里只剩截断的可见文字、链接彻底看不到，grep 也抓不到那串 URL。
# 这里把超链接就地展开成可见的「文字 (URL)」，URL 变成普通文本：既能显示/复制，也不会
# 再被当成"看不见的转义"而随进度条一起被冲掉；两种收尾（ESC\ 和 BEL）都处理。
OSC8_SED=$'s/\x1b\\]8;[^;]*;([^\x1b\x07]*)(\x1b\\\\|\x07)([^\x1b\x07]*)\x1b\\]8;;(\x1b\\\\|\x07)/\\3 (\\1)/g'
render_osc8() {
  case $1 in
    *$'\033]8;'*) printf '%s' "$1" | sed -E "$OSC8_SED" ;;
    *)            printf '%s' "$1" ;;
  esac
}

filter_ads() {
  local buf='' ch plain nospace line last_was_cr=0 deferred=''
  local -a para=(); local emitted_any=0 pending_blank=0
  # ECR = 回车 + ANSI「清到行尾」(\033[K)。凡是"要覆盖上一条 \r 进度行"的场景都用它：
  # 先把光标退回行首、清掉整行残留字符，再写新内容。这样既不会像补 \n 那样凭空多出一行
  # （BenchOs 99.6%→100% 就是被多补的 \n 拆成两行的），也不会因为新内容比进度条短而留下
  # 旧字符的尾巴（"测试完成…"被截断、报告链接显示不全，都是残留尾巴造成的）。
  local ECR=$'\r\033[K'
  # 打印一条"真正的整行内容"：若紧接在 \r 进度行之后，就覆盖那一行（不另起新行）。
  emit_line() {
    if (( last_was_cr )); then printf '%s%s\n' "$ECR" "$1"; last_was_cr=0
    else printf '%s\n' "$1"; fi
  }
  # 冲刷"攒起来的完成语/报告链接"：把它作为独立一行打印在最后一帧进度条的【下面】。
  # 关键：这里不能用 emit_line（那会 ECR 覆盖掉最后一帧真实进度 "100 4403…10609"），
  # 而是若光标还停在进度行上就先补一个 \n 把它挪到下一行，再打印这条完整的完成语。
  flush_deferred() {
    [[ -z $deferred ]] && return 0
    (( last_was_cr )) && { printf '\n'; last_was_cr=0; }
    # 去掉可能黏在尾部的进度残留空白（不依赖 extglob）
    deferred="${deferred%"${deferred##*[![:space:]]}"}"
    printf '%s\n' "$deferred"
    deferred=''
  }
  # ── 广告过滤（按“段落”从结构上判定，不依赖具体广告商名字/关键词）───────────
  # 第三方推广只有两种形态：① 反复堆砌品牌名的彩色 ASCII 横幅；② 几行中文文案 + 一个官网
  # http(s) 链接。真实检测报告的数据行既不是纯字母刷屏、也不含第三方外链。于是按空行分段，
  # 整段命中任一条即判为广告、【整段丢弃】（连带其前后空行一起吸收，顺便消除多余空白）：
  para_is_ad() {
    local l p ns uniq has_url=0 has_art=0
    for l in "${para[@]}"; do
      p=$(printf '%s' "$l" | sed -E 's/\x1b\[[0-9;]*[a-zA-Z]//g')   # 去掉 ANSI 颜色
      # 本工具自己的结果链接要保留，不算广告外链
      case $p in
        *测试完成*|*点击查看*|*报告链接*|*nodequality.com/r/*|*[Rr]eport?[Cc]heck?[Pp]lace*) continue ;;
      esac
      case $p in *http://*|*https://*) has_url=1 ;; esac
      ns=${p//[[:space:]]/}
      # 横幅行：纯拉丁字母、够长、字符种类少（真实数据行含数字/中文/标点，天然豁免）；
      # 稀疏艺术行与实心横幅行同段，会被一起带走。
      if [[ ${#ns} -ge 24 && $ns =~ ^[A-Za-z]+$ ]]; then
        uniq=$(printf '%s' "$ns" | fold -w1 | sort -u | wc -l)
        (( uniq <= 12 )) && has_art=1
      fi
    done
    (( has_art )) && return 0
    # 含第三方链接且段落短（广告文案都很短；限 ≤8 行以免误伤恰好含链接的大表格）
    (( has_url )) && (( ${#para[@]} <= 8 )) && return 0
    return 1
  }
  flush_para() {
    (( ${#para[@]} == 0 )) && return 0
    if para_is_ad; then para=(); return 0; fi
    (( emitted_any )) && (( pending_blank )) && emit_line ""   # 段间保留一个空行分隔
    pending_blank=0
    local l
    for l in "${para[@]}"; do emit_line "$l"; done
    emitted_any=1
    para=()
  }
  while IFS= read -r -n1 -d '' ch || [[ -n $ch ]]; do
    if [[ $ch == $'\n' ]]; then
      # 先摘掉 NodeQuality 的交互问句（只在命中特征时起 sed，普通行零开销）
      if [[ $buf == *运行*'[y'* ]]; then
        line=$(printf '%s' "$buf" | sed -E "$NQ_PROMPT_SED")
      else
        line=$buf
      fi
      # 整行都是问句、摘完啥也不剩：直接吞掉，不留空行（真正的空行 buf 为空、会照常输出）
      if [[ -z $line && -n $buf ]]; then buf=''; continue; fi
      # 把 OSC 8 超链接就地展开成「文字 (URL)」，让报告链接以纯文本可见、可复制、可被 grep 抓到
      line=$(render_osc8 "$line")
      # 进度块已结束（收到一条真正的 \n 整行）：把之前攒起来的完成语/链接先作为独立一行
      # 打印在最后一帧进度条下面（红框位置），再输出本行（如"安装后清理"）。
      flush_deferred
      plain=$(printf '%s' "$line" | sed -E 's/\x1b\[[0-9;]*[a-zA-Z]//g')
      nospace=${plain//[[:space:]]/}
      # 按空行分段：空行 → 结算当前段落（广告整段丢、正文整段出）；正文行先攒进 para。
      if [[ -z $nospace ]]; then
        flush_para
        pending_blank=1
      else
        para+=("$line")
      fi
      buf=''
    elif [[ $ch == $'\r' ]]; then
      # \r：进度条一类的实时刷新，不参与广告判定，立刻吐出去（保证实时）。
      # 先把攒着的正文段落吐出来（进度条一起，说明上一段文字已结束），保持"文字在进度之前"的顺序。
      flush_para
      line=$(render_osc8 "$buf"); buf=''
      # 关键（本轮修）：完成语/报告链接常被 curl 上传进度条的 \r 切成碎片、且黏着进度数字，
      # 若就地打印会黏进进度行、还被后一帧 100% 覆盖冲掉（截断+链接丢失）。改为攒进 deferred：
      # 命中"测试完成"就从它起头（丢掉前面的进度数字残渣），后续碎片直接续上拼成完整一句；
      # 等进度块结束（下一条 \n 整行 或 EOF）再由 flush_deferred 作为独立一行打印在最后一帧
      # 进度条【下面】（用户要求的红框位置）。期间不动 last_was_cr，进度帧照常在原地实时刷新。
      case $line in
        *http://*|*https://*|*测试完成*|*点击查看*|*报告链接*)
          if [[ $line == *测试完成* ]]; then deferred="测试完成${line#*测试完成}"
          else deferred+=$line; fi ;;
        *)
          # 普通进度帧：ECR（回车+清行）覆盖上一帧，同一行实时刷新，绝不多生成行。
          printf '%s%s' "$ECR" "$line"
          last_was_cr=1 ;;
      esac
    else
      buf+="$ch"
    fi
  done
  # ── EOF 收尾 ──────────────────────────────────────────────────────────
  if [[ -n $buf ]]; then
    line=$(strip_nq_prompts "$buf")
    line=$(render_osc8 "$line")
    buf=''
    # 收尾半行：完成语/链接碎片进 deferred 独立成行；其余正文进 para 走段落广告判定
    case $line in
      *测试完成*|*点击查看*|*报告链接*)
        if [[ $line == *测试完成* ]]; then deferred="测试完成${line#*测试完成}"
        else deferred+=$line; fi ;;
      *) nospace=${line//[[:space:]]/}; [[ -n $nospace ]] && para+=("$line") ;;
    esac
  fi
  flush_para          # 结算最后一个段落（广告丢 / 正文出）
  # 流结束：把最后攒着的完成语/报告链接作为独立一行打印在最后一帧进度条下面。
  flush_deferred
  # 若整个流最后停在 \r 进度帧上（光标在进度行末尾），补一个换行收尾，
  # 避免脚本随后 echo 的报告链接直接接在进度条尾巴上。
  (( last_was_cr )) && printf '\n'
  return 0   # 上面的 [[ ]] &&/(( )) 在"没什么要冲刷"时退出码为 1，
             # 在 set -e + pipefail 下会被当成整个函数失败，进而炸穿全脚本；
             # 显式 return 0 兜底，这里从头到尾都不算真正的错误。
}

# ══════════════════════════════════════════════════════════════════════
#  连接字符串解析
#  成功时填充 PROXY_PROTO / PROXY_HOST / PROXY_PORT / PROXY_USER / PROXY_PASS
# ══════════════════════════════════════════════════════════════════════

# 拆 host:port，支持 [IPv6]:port
split_hostport() { # $1=串  -> 设置 _SP_HOST / _SP_PORT
  local s=$1
  _SP_HOST=''; _SP_PORT=''
  if [[ $s =~ ^(\[[0-9A-Fa-f:.]+\]):([0-9]+)$ ]]; then
    _SP_HOST=${BASH_REMATCH[1]}; _SP_PORT=${BASH_REMATCH[2]}; return 0
  fi
  [[ $s == *:* ]] || return 1
  _SP_HOST=${s%:*}; _SP_PORT=${s##*:}
  [[ -n $_SP_HOST && -n $_SP_PORT ]] || return 1
  is_port "$_SP_PORT" || return 1
  return 0
}

# 判断一个串是否形如 host:port
is_hostport() {
  local h p
  split_hostport "$1" || return 1
  h=$_SP_HOST; p=$_SP_PORT
  looks_like_host "$h" && is_port "$p"
}

parse_conn_string() {
  local raw=$1 s scheme rest userinfo hostport prev
  PROXY_PROTO=''; PROXY_HOST=''; PROXY_PORT=''; PROXY_USER=''; PROXY_PASS=''

  # 1) 反复剥掉粘贴时容易带上的外壳：空白、引号/尖括号、export、VAR=、curl -x 等。
  #    循环到不再变化为止，这样 `export ALL_PROXY="socks5://..."` 这种嵌套也能剥干净。
  #    用正则而不是 extglob：避免改动调用者的 shell 选项。
  s=$raw
  while :; do
    prev=$s
    s=${s#"${s%%[![:space:]]*}"}; s=${s%"${s##*[![:space:]]}"}
    if [[ ( ${s:0:1} == '"' && ${s: -1} == '"' && ${#s} -ge 2 ) \
       || ( ${s:0:1} == "'" && ${s: -1} == "'" && ${#s} -ge 2 ) \
       || ( ${s:0:1} == '<' && ${s: -1} == '>' && ${#s} -ge 2 ) ]]; then
      s=${s:1:${#s}-2}
    fi
    [[ $s =~ ^[Ee][Xx][Pp][Oo][Rr][Tt][[:space:]]+(.*)$ ]] && s=${BASH_REMATCH[1]}
    [[ $s =~ ^[Cc][Uu][Rr][Ll][[:space:]]+(.*)$ ]] && s=${BASH_REMATCH[1]}
    [[ $s =~ ^(-x|--proxy|--socks5|--socks5-hostname|--preproxy)[[:space:]]+(.*)$ ]] && s=${BASH_REMATCH[2]}
    [[ $s =~ ^(ALL_PROXY|all_proxy|HTTP_PROXY|http_proxy|HTTPS_PROXY|https_proxy|SOCKS_PROXY|socks_proxy)=(.*)$ ]] && s=${BASH_REMATCH[2]}
    [[ $s == "$prev" ]] && break
  done

  [[ -n $s ]] || { soft "连接字符串为空。"; return 1; }
  [[ $s == *[$'\n\r']* ]] && { soft "连接字符串含换行符，请粘贴单行内容。"; return 1; }
  [[ $s == *[[:space:]]* ]] && { soft "连接字符串中间含空格，可能是粘贴时带了多余内容。"; return 1; }

  # 2) scheme
  local decode=0
  if [[ $s =~ ^([A-Za-z][A-Za-z0-9+.-]*)://(.*)$ ]]; then
    scheme=$(printf '%s' "${BASH_REMATCH[1]}" | tr 'A-Z' 'a-z')
    rest=${BASH_REMATCH[2]}
    decode=1                     # URL 形式：userinfo 里的 %XX 要解码
    case $scheme in
      socks5|socks5h|socks|socks5t) PROXY_PROTO=socks5 ;;
      http)  PROXY_PROTO=http ;;
      https) PROXY_PROTO=https ;;
      socks4|socks4a)
        soft "socks4 上游本脚本未支持（gost 配置只写了 socks5/http/https），请换 socks5 节点。"; return 1 ;;
      ss|ssr|vmess|vless|trojan|trojan-go|hysteria|hysteria2|hy2|tuic|snell|naive|wg|wireguard|juicity|anytls)
        soft "这是 ${scheme} 节点链接，不是 HTTP/SOCKS 代理，本脚本无法直接连。请先用对应客户端把它转成本地 socks5 端口，再把那个端口填进来。"
        return 1 ;;
      *) soft "不认识的协议 ${scheme}://，支持的是 socks5 / socks5h / http / https。"; return 1 ;;
    esac
    rest=${rest%%/*}             # 去掉路径部分（URL 里密码含 / 必须写成 %2F）
    [[ -n $rest ]] || { soft "连接字符串里没有主机信息。"; return 1; }
  else
    rest=$s
  fi

  # 3) @ 形式：user:pass@host:port 或 host:port@user:pass
  if [[ $rest == *@* ]]; then
    userinfo=${rest%@*}          # 按最后一个 @ 切，允许密码里含 @
    hostport=${rest##*@}
    if is_hostport "$hostport"; then
      :                          # 正常方向
    elif is_hostport "$userinfo"; then
      local tmp=$userinfo; userinfo=$hostport; hostport=$tmp   # 反向写法
    else
      # 密码里含 @ 且主机段被切坏时，退回按第一个 @ 切一次试试
      userinfo=${rest%%@*}; hostport=${rest#*@}
      is_hostport "$hostport" || { soft "无法识别 @ 两侧哪边是主机、哪边是账密。请改用 socks5://user:pass@host:port。"; return 1; }
    fi
    split_hostport "$hostport" || { soft "主机端口部分无法解析。"; return 1; }
    PROXY_HOST=$_SP_HOST; PROXY_PORT=$_SP_PORT
    if [[ -n $userinfo ]]; then
      PROXY_USER=${userinfo%%:*}
      if [[ $userinfo == *:* ]]; then PROXY_PASS=${userinfo#*:}; else PROXY_PASS=''; fi
    fi
    (( decode )) && { PROXY_USER=$(urldecode "$PROXY_USER"); PROXY_PASS=$(urldecode "$PROXY_PASS"); }

  # 4) 免认证的 host:port（含 [IPv6]:port，必须在冒号分段之前判断）
  elif is_hostport "$rest"; then
    split_hostport "$rest" || { soft "主机端口部分无法解析。"; return 1; }
    PROXY_HOST=$_SP_HOST; PROXY_PORT=$_SP_PORT

  # 5) 纯冒号分隔的多段式
  else
    if [[ $rest == *'['* || $rest == *']'* || $rest == *'::'* ]]; then
      soft "IPv6 节点请用 URL 形式，把地址放进方括号：socks5://user:pass@[2001:db8::1]:1080"; return 1
    fi
    local -a f=()
    IFS=':' read -r -a f <<< "$rest"
    local n=${#f[@]}
    case $n in
      1) soft "只给了一段（${f[0]}），缺少端口。至少要 host:port。"; return 1 ;;
      2) soft "两段式必须是 host:port，且端口是 1-65535 的数字（当前：${f[0]} / ${f[1]}）。"; return 1 ;;
      3) soft "三段式无法判断含义。请用 host:port:user:pass 或 socks5://user:pass@host:port。"; return 1 ;;
      *)
        local shape_a=0 shape_b=0            # a: host:port:user:pass   b: user:pass:host:port
        looks_like_host "${f[0]}"   && is_port "${f[1]}"   && shape_a=1
        looks_like_host "${f[n-2]}" && is_port "${f[n-1]}" && shape_b=1
        if (( shape_a && shape_b )); then
          echo "${YELLOW}      这个字符串两种解释都成立，请确认顺序：${NC}" >&2
          echo "        ${GREEN}1${NC}) host:port:user:pass  → 主机 ${f[0]}:${f[1]}" >&2
          echo "        ${GREEN}2${NC}) user:pass:host:port  → 主机 ${f[n-2]}:${f[n-1]}" >&2
          local pick=''
          read -r -p "${YELLOW}      输入 1/2 [默认 1]: ${NC}" pick || pick=''
          case ${pick:-1} in
            1) shape_b=0 ;;
            2) shape_a=0 ;;
            *) soft "无效选择：$pick"; return 1 ;;
          esac
        fi
        local j
        if (( shape_a )); then
          PROXY_HOST=${f[0]}; PROXY_PORT=${f[1]}; PROXY_USER=${f[2]}; PROXY_PASS=${f[3]}
          for (( j=4; j<n; j++ )); do PROXY_PASS+=":${f[j]}"; done   # 密码里可含 :
        elif (( shape_b )); then
          PROXY_HOST=${f[n-2]}; PROXY_PORT=${f[n-1]}; PROXY_USER=${f[0]}; PROXY_PASS=${f[1]}
          for (( j=2; j<n-2; j++ )); do PROXY_PASS+=":${f[j]}"; done
        else
          soft "四段式里既找不到 host:port 开头、也找不到 host:port 结尾，无法识别。"; return 1
        fi ;;
    esac
  fi

  # 5) 收尾校验
  [[ -z $PROXY_PROTO ]] && PROXY_PROTO=socks5          # 无 scheme 时按 socks5
  [[ -n $PROXY_HOST ]] || { soft "没解析出主机地址。"; return 1; }
  is_port "$PROXY_PORT" || { soft "端口 ${PROXY_PORT:-<空>} 不是 1-65535 的数字。"; return 1; }
  looks_like_host "$PROXY_HOST" || echo "${YELLOW}      ⚠ 主机 ${PROXY_HOST} 看起来不像 IP 或域名，仍会尝试连接。${NC}"
  if [[ -n $PROXY_USER && -z $PROXY_PASS ]]; then
    soft "解析出账号但没有密码。若该节点免认证，请只给 host:port。"; return 1
  elif [[ -z $PROXY_USER && -n $PROXY_PASS ]]; then
    soft "解析出密码但没有账号。"; return 1
  fi
  return 0
}

show_parsed() {
  local n=${#PROXY_PASS} mask='' i
  for (( i=0; i<n && i<24; i++ )); do mask+='*'; done
  echo "${GREEN}      已识别 →${NC} 协议 ${CYAN}${PROXY_PROTO}${NC}  主机 ${CYAN}${PROXY_HOST}${NC}  端口 ${CYAN}${PROXY_PORT}${NC}"
  if [[ -n $PROXY_USER ]]; then
    echo "${GREEN}                ${NC}账号 ${CYAN}${PROXY_USER}${NC}  密码 ${DIM}${mask}（${n} 位，不回显）${NC}"
  else
    echo "${GREEN}                ${NC}${DIM}免认证（无账号密码）${NC}"
  fi
}

# ── gost 配置生成（v3 YAML / v2 JSON），支持 socks5 / http / https 上游 ──
#   socks5 → connector socks5 + dialer tcp
#   http   → connector http   + dialer tcp
#   https  → connector http   + dialer tls
write_gost_v3() {
  local f=$1 conn dialer
  case $PROXY_PROTO in
    socks5) conn=socks5; dialer=tcp ;;
    http)   conn=http;   dialer=tcp ;;
    https)  conn=http;   dialer=tls ;;
  esac
  {
    printf 'services:\n'
    printf '  - name: bridge\n'
    printf '    addr: ":1080"\n'
    printf '    handler:\n      type: socks5\n      chain: up\n'
    printf '    listener:\n      type: tcp\n'
    printf 'chains:\n'
    printf '  - name: up\n    hops:\n      - name: hop-0\n        nodes:\n'
    printf '          - name: node-0\n'
    printf '            addr: %s\n' "$(yaml_squote "${PROXY_HOST}:${PROXY_PORT}")"
    printf '            connector:\n              type: %s\n' "$conn"
    if [[ -n $PROXY_USER ]]; then
      printf '              auth:\n'
      printf '                username: %s\n' "$(yaml_squote "$PROXY_USER")"
      printf '                password: %s\n' "$(yaml_squote "$PROXY_PASS")"
    fi
    printf '            dialer:\n              type: %s\n' "$dialer"
  } > "$f"
}

write_gost_v2() {
  local f=$1 auth='' scheme
  case $PROXY_PROTO in
    socks5) scheme=socks5 ;; http) scheme=http ;; https) scheme=https ;;
  esac
  [[ -n $PROXY_USER ]] && auth="$(urlencode "$PROXY_USER"):$(urlencode "$PROXY_PASS")@"
  cat > "$f" <<JSON
{
  "ServeNodes": ["socks5://:1080"],
  "ChainNodes": ["${scheme}://${auth}${PROXY_HOST}:${PROXY_PORT}"]
}
JSON
}

start_bridge() {
  local fmt=$1 ext i
  case $fmt in v3) ext=yaml ;; v2) ext=json ;; esac
  local cfg="$WORKDIR/gost.$ext"
  "write_gost_$fmt" "$cfg"
  chmod 644 "$cfg"   # 目录已 700，其他用户进不来；文件可读是为兼容非 root 镜像

  docker rm -f "$BRIDGE_NAME" >/dev/null 2>&1 || true
  docker run -d --name "$BRIDGE_NAME" \
    -p "127.0.0.1:${LOCAL_PORT}:1080" \
    -v "${cfg}:/gost.${ext}:ro" \
    "$GOST_IMAGE" -C "/gost.${ext}" >/dev/null 2>"$WORKDIR/run.err" || {
      echo "${DIM}$(tail -3 "$WORKDIR/run.err" 2>/dev/null)${NC}" >&2; return 1; }

  for (( i=0; i<25; i++ )); do
    bridge_running || return 1
    port_in_use "$LOCAL_PORT" && return 0
    sleep 0.4
  done
  return 1
}

IP_ENDPOINTS=(https://api.ipify.org https://icanhazip.com https://ipinfo.io/ip)
probe_exit_ip() {
  local socks=${1:-} ep out
  for ep in "${IP_ENDPOINTS[@]}"; do
    if [[ -n $socks ]]; then
      out=$(curl -fsS --max-time 10 --socks5-hostname "$socks" "$ep" 2>/dev/null) || continue
    else
      out=$(curl -fsS --max-time 10 "$ep" 2>/dev/null) || continue
    fi
    out=${out//[$'\r\n\t ']/}
    [[ -n $out ]] && { printf '%s' "$out"; return 0; }
  done
  return 1
}

# ══════════════════════════════════════════════════════════════════════
#  检测工具执行器
# ══════════════════════════════════════════════════════════════════════
DOCKER_TTY=(-i)
[[ -t 0 && -t 1 ]] && DOCKER_TTY=(-i -t)

# 落地检测在容器里跑 IPQuality 的入口。
#   $1 = 工具标识（目前仅 ipquality）
#   $2 = 可选的本地代理地址（给则只有"检测命令"这一步走代理）
#
# 关键设计：代理地址用非标准变量名 IPT_PROXY 传进去，容器里先用【直连】
# 装依赖、下脚本，最后才 export 标准代理变量执行检测。两个原因：
#   1. apt 只认 socks5h:// 且需要 Acquire::*::Proxy 单独配置，让它走代理纯属自找麻烦；
#   2. 装依赖和拉 GitHub 脚本本来就不该占用落地节点带宽，也不影响检测结果。
run_tool() {
  local tool=$1 proxy=${2:-}
  # 落地检测无需人工交互：容器用 -i（不带 -t），并把 `yes y` 喂进 stdin 自动确认
  # xykt IPQuality 的「Lacking necessary dependencies … Continue? (y/n): 」这类提问。
  # 不带 -t 还能让上游脚本判定为非交互、少刷一堆 tty-gated 的彩色赞助商横幅。
  local -a docker_args=(--rm -i --network host)
  local -a env_args=()
  local pkgs='curl ca-certificates' fetch='' exec_cmd='' inner

  case $tool in
    ipquality)
      pkgs='curl ca-certificates python3 jq bc'            # IPQuality 用 python3 排版
      fetch="curl -fsSL '$URL_IPQUALITY' -o /tmp/ip.sh"
      exec_cmd="bash /tmp/ip.sh ${IPQ_ARGS:-}"
      ;;
  esac

  # 代理 URL 本身不含任何凭据（指向本地无认证桥接），放命令行是安全的
  [[ -n $proxy ]] && env_args+=(-e IPT_PROXY="$proxy")

  # 注意：这段 heredoc 里【不能】用反斜杠续行。unquoted heredoc 会把
  # "\\ + 换行" 变成"裸反斜杠 + 换行被删"，结果是给命令多传一个字面空格参数。
  inner=$(cat <<EOS
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >/dev/null 2>&1 || true
if ! apt-get install -y --no-install-recommends ${pkgs} >/dev/null 2>&1; then
  echo '容器内安装依赖失败（换个 RUNNER_IMAGE 或检查网络）' >&2; exit 1
fi
if ! ${fetch}; then
  echo '下载检测脚本失败（地址可能变了，见 --help 里的覆盖变量）' >&2; exit 1
fi
if [ -n "\${IPT_PROXY:-}" ]; then
  export ALL_PROXY="\$IPT_PROXY"   all_proxy="\$IPT_PROXY"
  export HTTP_PROXY="\$IPT_PROXY"  http_proxy="\$IPT_PROXY"
  export HTTPS_PROXY="\$IPT_PROXY" https_proxy="\$IPT_PROXY"
  export NO_PROXY= no_proxy=
fi
${exec_cmd}
EOS
)

  ensure_image "$RUNNER_IMAGE" || return 1
  echo
  echo "${DIM}（在一次性容器内安装依赖并执行，跑完即销毁）${NC}"
  echo
  local -a run_rc
  set +e
  # `< <(yes y)`：把无限个 "y" 喂给容器 stdin，自动确认上游依赖安装提问；
  # docker 退出后 yes 收到 SIGPIPE 自然结束。PIPESTATUS[0] 仍是 docker 的退出码。
  docker run "${docker_args[@]}" "${env_args[@]}" -e "TERM=${TERM:-xterm}" "$RUNNER_IMAGE" bash -c "$inner" < <(yes y) 2>&1 | filter_ads
  run_rc=("${PIPESTATUS[@]}")
  set -e
  if (( run_rc[0] != 0 )); then
    echo "${YELLOW}检测脚本执行失败或被中断。${NC}"
    echo "${DIM}若上游脚本地址/参数有变，可用环境变量覆盖，例如：${NC}"
    echo "${DIM}  URL_IPQUALITY=<新地址> IPQ_ARGS='-l en' ./ip-test.sh${NC}"
    return 1
  fi
}

# ══════════════════════════════════════════════════════════════════════
#  菜单动作
# ══════════════════════════════════════════════════════════════════════
# ── 本机质量检测：一次性容器里跑 NodeQuality 一体化体检 ────────────────────
#   $1 = 模式 full|hardware|ip|net|route
#
# NodeQuality 官方入口是交互式脚本，会依次问「是否跑 硬件 / IP / 网络 / 回程」四个 y/n。
# 我们不走它默认的 y/f/v/n 快捷键，而是按主菜单选好的模式，用管道把答案喂给它 stdin：
#   完整   y y y y      硬件   y n n n      IP     n y n n
#   网络   n n y n      回程   n n n y
# 末尾再补 `yes y`，把「安装前清理 / 上传报告生成分享链接」这类后续提问一律答 y，
# 这样跑完能拿到 https://nodequality.com/r/... 的可分享报告链接。
#
# 两个受限环境下无法核实、但已收敛到这一处、便于随时调整的假设（上游若改行为就改这里）：
#   1. NodeQuality 从 stdin 读答案（而非强制 /dev/tty）；
#   2. 四个提问顺序为 硬件→IP→网络→回程（依据官方交互截图）。
run_nodequality() {
  local mode=$1
  local a1 a2 a3 a4 title
  case $mode in
    full)     a1=y a2=y a3=y a4=y title='完整检测模式（硬件 / IP / 网络 / 回程路由）' ;;
    hardware) a1=y a2=n a3=n a4=n title='硬件质量体检（HardwareQuality）' ;;
    ip)       a1=n a2=y a3=n a4=n title='IP 质量体检（IPQuality）' ;;
    net)      a1=n a2=n a3=y a4=n title='网络质量体检（NetQuality）' ;;
    route)    a1=n a2=n a3=n a4=y title='回程路由检测（Backroute Trace）' ;;
    *) soft "未知检测模式：$mode"; return 1 ;;
  esac

  echo
  echo "${BLUE}=== 本机质量检测 · ${title} ===${NC}"
  local ip; ip=$(probe_exit_ip) || { soft "本机无法访问外网，请先检查网络。"; return 1; }
  echo "${GREEN}本机出口 IP: ${CYAN}${ip}${NC}"
  echo "${DIM}提示：容器内测硬盘 I/O 会受 overlay 文件系统影响，仅供参考；${NC}"
  echo "${DIM}      要精确硬盘数据请直接在宿主机/VM 里跑。${NC}"

  # NodeQuality 的硬件体检（BenchOs）要 mount /proc /sys /dev 这类伪文件系统才能跑，
  # 光给 NET_RAW/NET_ADMIN 不够（会报 mount: permission denied，进而连累 /dev/fd
  # 解析失败、整体非零退出）。这里改用 --privileged：容器仍是 --rm 跑完即删，
  # 不挂任何宿主机目录，退出即销毁，不会在宿主机留下痕迹，只是放开了容器自身的权限。
  # -i 让内层管道拿到 stdin。
  local -a docker_args=(--rm -i --privileged --network host)

  # 注意：这段 heredoc 里【不能】用反斜杠续行（见文件顶部踩坑说明）。
  # printf 里的 \n 是留给 printf 自己解释的换行，heredoc 不会吃掉它（只有 \+换行才会）。
  local inner
  inner=$(cat <<EOS
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >/dev/null 2>&1 || true
if ! apt-get install -y --no-install-recommends curl ca-certificates wget >/dev/null 2>&1; then
  echo '容器内安装依赖失败（换个 RUNNER_IMAGE 或检查网络）' >&2; exit 1
fi
if ! curl -fsSL '${NQ_URL}' -o /tmp/nq.sh; then
  echo '下载 NodeQuality 脚本失败（地址可能变了，可用 NQ_URL 覆盖）' >&2; exit 1
fi
{ printf '%s\n' '${a1}' '${a2}' '${a3}' '${a4}'; yes y; } | bash /tmp/nq.sh
EOS
)

  ensure_image "$RUNNER_IMAGE" || return 1
  echo
  echo "${DIM}（在一次性容器内安装依赖并执行 NodeQuality，跑完即销毁）${NC}"
  echo

  local rawlog rc link
  rawlog=$(mktemp "${TMPDIR:-/tmp}/nq-raw.XXXXXX") || { soft "无法创建临时日志文件。"; return 1; }

  # -e TERM：容器没分配 tty，upstream 脚本里用到 tput 一类东西时会报
  # "TERM environment variable not set" 并且它自带的进度显示会失效，
  # 这里显式传一个，让它自己的进度提示能正常渲染。
  # 装依赖 / 下载脚本这一步全程静默、没有任何输出，先提前说明一下，免得
  # 看着像卡住了；一旦 NodeQuality 自己开始跑，filter_ads 会实时透传它的
  # 输出（包括 \r 刷新的下载进度条），不会再有延迟。
  echo "${DIM}准备阶段（装依赖、下载脚本）可能需要十几秒到几十秒，期间没有输出属于正常现象…${NC}"
  echo
  set +e
  docker run "${docker_args[@]}" -e "TERM=${TERM:-xterm}" "$RUNNER_IMAGE" bash -c "$inner" 2>&1 | filter_ads | tee "$rawlog"
  rc=${PIPESTATUS[0]}
  set -e

  # NodeQuality 自身在最后「安装后清理」阶段（卸载 BenchOs 的 proc/sys/dev、
  # 删临时目录）偶尔会返回非零退出码，但这不代表测试本身失败——只要输出里
  # 已经出现报告链接，就说明各项检测和上传都已经跑完了。所以判断顺序是：
  # 先看有没有抓到链接，链接在就按成功处理，退出码只在没链接时才当作失败依据。
  link=$(grep -oE 'https://nodequality\.com/r/[A-Za-z0-9_-]+' "$rawlog" | tail -n1 || true)

  if [[ -z $link ]] && (( rc != 0 )); then
    echo "${YELLOW}NodeQuality 执行失败或被中断。${NC}"
    echo "${DIM}若官方入口地址有变，可用环境变量覆盖，例如：${NC}"
    echo "${DIM}  NQ_URL=https://run.nodequality.com ./ip-test.sh${NC}"
    rm -f "$rawlog" 2>/dev/null || true
    return 1
  fi

  if (( rc != 0 )); then
    echo "${DIM}（提示：NodeQuality 清理阶段返回了非零状态，但报告已正常生成，可忽略这条。）${NC}"
  fi

  echo
  export_nodequality_report "$rawlog" "$mode"
  rm -f "$rawlog" 2>/dev/null || true
}

# ── 检测完成后一键导出报告：从输出里抓 https://nodequality.com/r/... 链接，
#    单独存成一份小文件放在当前目录，方便直接复制分享，不用再翻一屏输出去找。
#   $1 = 本轮原始输出日志路径   $2 = 检测模式（用于文件名）
export_nodequality_report() {
  local rawlog=$1 mode=$2 link report_file
  link=$(grep -oE 'https://nodequality\.com/r/[A-Za-z0-9_-]+' "$rawlog" | tail -n1 || true)

  if [[ -z $link ]]; then
    echo "${YELLOW}检测完成，但没有在输出里找到可分享的报告链接。${NC}"
    echo "${DIM}（NodeQuality 生成分享链接前一般需要联网上传结果；若刚才网络不稳，可重跑一次。）${NC}"
    return 0
  fi

  report_file="./nodequality-report-${mode}-$(date +%Y%m%d-%H%M%S).txt"
  if printf '%s\n' "$link" > "$report_file" 2>/dev/null; then
    local abs_path; abs_path="$(pwd)/${report_file#./}"
    EXPORTED_REPORTS+=("$abs_path")
    echo "${GREEN}检测完成，报告链接已导出：${NC}"
    echo "  ${CYAN}${link}${NC}"
    echo "${DIM}  已保存到：${abs_path}（一行纯链接，方便直接复制/分享）${NC}"
    echo "${DIM}  注：这个文件只在本次会话期间保留，脚本完全退出后会自动删除。${NC}"
  else
    echo "${GREEN}检测完成，报告链接：${NC} ${CYAN}${link}${NC}"
    echo "${YELLOW}  （当前目录不可写，保存文件失败，但链接本身有效，复制上面这行即可）${NC}"
  fi
}

action_local() {
  while true; do
    echo
    echo "${CYAN}────────── 本机质量检测（NodeQuality）──────────${NC}"
    echo "  ${GREEN}1${NC}) 完整检测模式   ${DIM}硬件 / IP / 网络 / 回程路由 全跑${NC}"
    echo "  ${GREEN}2${NC}) 硬件质量体检   ${DIM}HardwareQuality${NC}"
    echo "  ${GREEN}3${NC}) IP 质量体检    ${DIM}IPQuality${NC}"
    echo "  ${GREEN}4${NC}) 网络质量体检   ${DIM}NetQuality${NC}"
    echo "  ${GREEN}5${NC}) 回程路由检测   ${DIM}Backroute Trace${NC}"
    echo "  ${GREEN}6${NC}) 返回主菜单"
    echo "${CYAN}────────────────────────────────────────────────${NC}"
    local c; read -r -p "${YELLOW}请选择 [1-6]: ${NC}" c || { echo; return 0; }
    case $c in
      1) run_nodequality full ;;
      2) run_nodequality hardware ;;
      3) run_nodequality ip ;;
      4) run_nodequality net ;;
      5) run_nodequality route ;;
      6) return 0 ;;
      '') : ;;
      *) echo "${RED}无效选项：$c${NC}" ;;
    esac
  done
}

# 收集落地节点信息：连接字符串 或 手动输入
collect_proxy_info() {
  echo
  echo "${YELLOW}落地节点信息录入方式：${NC}"
  echo "  ${GREEN}1${NC}) 粘贴连接字符串   ${DIM}自动识别格式${NC}"
  echo "  ${GREEN}2${NC}) 手动输入         ${DIM}IP / 端口 / 账号 / 密码${NC}"
  local m; read -r -p "${YELLOW}输入 1/2 [默认 1]: ${NC}" m || return 1

  case ${m:-1} in
    1)
      echo "${DIM}  支持 socks5://user:pass@host:port、host:port:user:pass、user:pass@host:port 等，${NC}"
      echo "${DIM}  也能直接粘 ALL_PROXY=... 或 curl -x ... 。为防泄露，输入不回显。${NC}"
      local cs
      read -r -s -p "${YELLOW}  连接字符串（不回显）: ${NC}" cs || return 1
      echo
      parse_conn_string "$cs" || { cs=''; return 1; }
      cs=''
      show_parsed
      ;;
    2)
      echo "${YELLOW}请选择上游代理协议：${NC}"
      echo "  ${GREEN}1${NC}) socks5   ${GREEN}2${NC}) http   ${GREEN}3${NC}) https"
      local psel; read -r -p "${YELLOW}输入 1/2/3 [默认 1]: ${NC}" psel || return 1
      case ${psel:-1} in
        1) PROXY_PROTO=socks5 ;;
        2) PROXY_PROTO=http ;;
        3) PROXY_PROTO=https ;;
        *) soft "无效协议选项：$psel"; return 1 ;;
      esac
      echo
      echo "${YELLOW}请依次输入落地节点信息（账号密码留空表示免认证）：${NC}"
      read -r -p "${YELLOW}  ① IP 地址或域名 : ${NC}" PROXY_HOST || return 1
      [[ -n $PROXY_HOST ]] || { soft "地址不能为空。"; return 1; }
      read -r -p "${YELLOW}  ② 端口          : ${NC}" PROXY_PORT || return 1
      is_port "$PROXY_PORT" || { soft "端口必须是 1-65535 的数字。"; return 1; }
      read -r -p "${YELLOW}  ③ 账号（可留空）: ${NC}" PROXY_USER || return 1
      read -r -s -p "${YELLOW}  ④ 密码（可留空，不回显）: ${NC}" PROXY_PASS || return 1
      echo
      if [[ -n $PROXY_USER && -z $PROXY_PASS ]]; then
        soft "填了账号但没填密码。若该节点免认证，请把账号也留空。"; return 1
      elif [[ -z $PROXY_USER && -n $PROXY_PASS ]]; then
        soft "填了密码但没填账号。若该节点免认证，请把密码也留空。"; return 1
      fi
      ;;
    *) soft "无效选项：$m"; return 1 ;;
  esac

  if [[ $PROXY_USER == *[$'\n\r']* || $PROXY_PASS == *[$'\n\r']* ]]; then
    soft "账号或密码含换行符，无法处理。"; return 1
  fi
  return 0
}

# ── 落地 IP 检测：起桥接 → 默认执行 IP 纯净度检测 ──────────────────────
action_proxy() {
  collect_proxy_info || return 1

  local tmpbase=${TMPDIR:-/tmp}
  [[ -d $tmpbase && -w $tmpbase ]] || tmpbase=/tmp
  WORKDIR=$(mktemp -d "${tmpbase%/}/iptest.XXXXXXXX") || { soft "无法创建临时目录。"; return 1; }
  chmod 700 "$WORKDIR"
  BRIDGE_NAME="${NAME_PREFIX}$$"
  LOCAL_PORT=$(pick_port) || { teardown_bridge; soft "找不到可用的本地端口。"; return 1; }

  echo
  echo "${BLUE}[1/3] 启动本地无认证桥接 127.0.0.1:${LOCAL_PORT}（上游 ${PROXY_PROTO}）…${NC}"
  echo "${DIM}      账密写入 700 权限配置文件挂载进容器，不经命令行，ps/inspect 均不可见。${NC}"
  ensure_image "$GOST_IMAGE" || { teardown_bridge; return 1; }

  local ok=0 fmt
  for fmt in v3 v2; do
    if start_bridge "$fmt"; then
      ok=1; echo "${GREEN}      桥接就绪（gost 配置格式：${fmt}）${NC}"; break
    fi
    echo "${DIM}      ${fmt} 格式未能启动，换另一种再试…${NC}"
    docker logs "$BRIDGE_NAME" 2>&1 | tail -5 | sed "s/^/${DIM}        /;s/\$/${NC}/" || true
  done
  (( ok )) || {
    teardown_bridge
    soft "gost 桥接起不来（见上方日志，多为镜像版本与配置格式不匹配，可换 GOST_IMAGE）。"
    return 1
  }

  echo
  echo "${BLUE}[2/3] 验证连通性与落地 IP…${NC}"
  local out_ip my_ip
  if ! out_ip=$(probe_exit_ip "127.0.0.1:${LOCAL_PORT}"); then
    echo "${RED}      连不通。桥接端口是通的，问题在上游节点本身：${NC}" >&2
    echo "${RED}      地址/端口/协议写错、账号密码不对，或该节点不允许你的 IP 接入。${NC}" >&2
    echo "${DIM}      gost 日志：${NC}" >&2
    docker logs "$BRIDGE_NAME" 2>&1 | tail -15 | sed 's/^/        /' >&2 || true
    teardown_bridge; return 1
  fi
  echo "${GREEN}      连通正常，落地公网 IP: ${CYAN}${out_ip}${NC}"
  my_ip=$(probe_exit_ip || true)
  if [[ -n ${my_ip:-} && $my_ip == "$out_ip" ]]; then
    echo "${YELLOW}      ⚠ 落地 IP 与本机出口 IP 相同，代理可能未真正生效。${NC}"
  fi

  # socks5h = 由代理端做 DNS 解析（域名交给落地节点解析），避免 DNS 泄露、
  # 也让基于域名的判定更贴近节点真实环境。该 URL 指向本地无认证桥接，不含凭据。
  local proxy_url="socks5h://127.0.0.1:${LOCAL_PORT}"

  echo
  echo "${BLUE}[3/3] 对落地节点执行 IP 纯净度检测…${NC}"
  run_tool ipquality "$proxy_url" || true

  echo
  echo "${DIM}核对：上面检测报告里的 IP 应为 ${out_ip}；若显示成你自己的 IP，${NC}"
  echo "${DIM}说明该工具没读代理变量，本轮结果无效。${NC}"
  teardown_bridge
}

pkg_remove() { # pkg_remove <mgr> <pkg>
  local mgr=$1 pkg=$2
  case $mgr in
    apt-get) DEBIAN_FRONTEND=noninteractive apt-get purge -y "$pkg" >/dev/null 2>&1 ;;
    dnf)     dnf remove -y "$pkg" >/dev/null 2>&1 ;;
    yum)     yum remove -y "$pkg" >/dev/null 2>&1 ;;
    apk)     apk del "$pkg" >/dev/null 2>&1 ;;
    pacman)  pacman -Rns --noconfirm "$pkg" >/dev/null 2>&1 ;;
    zypper)  zypper --non-interactive remove "$pkg" >/dev/null 2>&1 ;;
    *)       return 1 ;;
  esac
}

# 卸载 Docker（影响面大，调用方须先确认）。how = get.docker.com | 其它（走发行版包）
remove_docker() { # remove_docker <mgr> <how>
  local mgr=$1 how=$2
  if command -v systemctl >/dev/null 2>&1; then
    systemctl disable --now docker docker.socket >/dev/null 2>&1 || true
  elif command -v rc-service >/dev/null 2>&1; then
    rc-service docker stop >/dev/null 2>&1 || true; rc-update del docker >/dev/null 2>&1 || true
  elif command -v service >/dev/null 2>&1; then
    service docker stop >/dev/null 2>&1 || true
  fi
  if [[ $how == get.docker.com ]]; then
    case $mgr in
      apt-get)
        DEBIAN_FRONTEND=noninteractive apt-get purge -y \
          docker-ce docker-ce-cli containerd.io docker-buildx-plugin \
          docker-compose-plugin docker-ce-rootless-extras >/dev/null 2>&1 || true
        rm -f /etc/apt/sources.list.d/docker.list \
              /etc/apt/keyrings/docker.asc /etc/apt/keyrings/docker.gpg >/dev/null 2>&1 || true ;;
      dnf) dnf remove -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null 2>&1 || true ;;
      yum) yum remove -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null 2>&1 || true ;;
      *)   pkg_remove "$mgr" docker || true ;;
    esac
  else
    pkg_remove "$mgr" docker || true
    [[ $mgr == apk ]] && pkg_remove apk docker-cli || true
  fi
  if [[ $mgr == apt-get ]]; then
    DEBIAN_FRONTEND=noninteractive apt-get autoremove -y >/dev/null 2>&1 || true
  fi
}

# 按安装清单卸载依赖：普通包直接删；Docker 单独二次确认（默认不删）
purge_deps() {
  if [[ ! -s $MANIFEST ]]; then
    echo "${DIM}  没有记录到由本脚本安装的依赖，跳过依赖卸载。${NC}"
    return 0
  fi
  local kind a b
  local -a docker_lines=()
  while IFS='|' read -r kind a b; do
    if [[ -z ${kind:-} ]]; then continue; fi
    case $kind in
      docker) docker_lines+=("$kind|$a|$b") ;;
      pkg)
        if [[ $b == docker || $b == docker-cli ]]; then
          docker_lines+=("$kind|$a|$b")
        else
          echo "${DIM}  卸载依赖 $b（经 $a）…${NC}"
          if pkg_remove "$a" "$b"; then
            echo "${DIM}    已卸载 $b${NC}"
          else
            echo "${YELLOW}    $b 卸载失败，请手动检查${NC}"
          fi
        fi ;;
    esac
  done < "$MANIFEST"

  if (( ${#docker_lines[@]} == 0 )); then
    : > "$MANIFEST"
    return 0
  fi

  echo
  echo "${YELLOW}  本脚本当初自动安装过 Docker。卸载它会影响本机【所有】Docker 用途，不只是本工具。${NC}"
  if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
    local nimg ncon
    nimg=$(docker images -q 2>/dev/null | wc -l | tr -d ' ')
    ncon=$(docker ps -aq 2>/dev/null | wc -l | tr -d ' ')
    echo "${DIM}    当前本机有 ${nimg} 个镜像、${ncon} 个容器，卸载 Docker 后都将不可用。${NC}"
  fi
  local ans=''
  if (( ASSUME_YES )); then
    ans=y
  elif [[ -t 0 ]]; then
    read -r -p "${YELLOW}  确认一并卸载 Docker？[y/N]: ${NC}" ans || ans=''
  else
    ans=n
  fi
  if [[ $ans =~ ^[Yy]$ ]]; then
    local line k m h
    for line in "${docker_lines[@]}"; do
      IFS='|' read -r k m h <<< "$line"
      echo "${DIM}  卸载 Docker（经 $m）…${NC}"
      remove_docker "$m" "$h"
    done
    echo "${DIM}  注：/var/lib/docker（镜像与卷）保留，如需彻底清空请自行删除。${NC}"
    : > "$MANIFEST"
  else
    echo "${DIM}  已跳过 Docker 卸载。需要时手动卸载，或加 -y 再跑一次「彻底清除」。${NC}"
    printf '%s\n' "${docker_lines[@]}" > "$MANIFEST"
  fi
}

action_purge() {
  echo
  echo "${YELLOW}[彻底清除] 移除本工具产生的容器、镜像、依赖与临时凭据…${NC}"
  teardown_bridge
  if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
    local stale
    stale=$(docker ps -aq --filter "name=^${NAME_PREFIX}" 2>/dev/null || true)
    if [[ -n $stale ]]; then
      # shellcheck disable=SC2086
      docker rm -f $stale >/dev/null 2>&1 || true
      echo "${DIM}  已删除遗留桥接容器${NC}"
    fi
    local img
    for img in "$GOST_IMAGE" "$RUNNER_IMAGE"; do
      if docker image inspect "$img" >/dev/null 2>&1; then
        docker rmi -f "$img" >/dev/null 2>&1 && echo "${DIM}  已删除镜像 ${img}${NC}" || \
          echo "${YELLOW}  镜像 ${img} 删除失败（可能被其他容器占用）${NC}"
      fi
    done
  else
    echo "${DIM}  Docker 不可用，跳过容器 / 镜像清理。${NC}"
  fi
  find "${TMPDIR:-/tmp}" /tmp -maxdepth 1 -type d -name 'iptest.*' -exec rm -rf {} + 2>/dev/null || true
  purge_deps
  if [[ ! -s $MANIFEST ]]; then
    rm -rf "$STATE_DIR" 2>/dev/null || true
  fi
  echo "${GREEN}[完成] 已彻底清除：镜像、依赖与残留。${NC}"
}

# ══════════════════════════════════════════════════════════════════════
#  主流程
# ══════════════════════════════════════════════════════════════════════
echo "${CYAN}════════════════════════════════════════════════════${NC}"
echo "${GREEN}         VPS / 落地节点 IP 检测工具  (ip-test)       ${NC}"
echo "${CYAN}════════════════════════════════════════════════════${NC}"

if (( PURGE_ONLY )); then
  action_purge; exit 0
fi

echo "${DIM}检查依赖（缺失将自动安装，可用 --no-install 关闭）…${NC}"
ensure_deps
if (( ${#INSTALLED_PKGS[@]} )); then
  echo "${GREEN}依赖就绪（本次安装：${INSTALLED_PKGS[*]}）${NC}"
else
  echo "${GREEN}依赖就绪。${NC}"
fi

stale=$(docker ps -aq --filter "name=^${NAME_PREFIX}" 2>/dev/null || true)
if [[ -n $stale ]]; then
  echo "${DIM}清扫上次残留的桥接容器…${NC}"
  # shellcheck disable=SC2086
  docker rm -f $stale >/dev/null 2>&1 || true
fi

while true; do
  echo
  echo "${CYAN}──────────────── 主菜单 ────────────────${NC}"
  echo "  ${GREEN}1${NC}) 本机质量检测   ${DIM}NodeQuality 综合体检${NC}"
  echo "  ${GREEN}2${NC}) 落地 IP 检测   ${DIM}经代理，默认跑纯净度${NC}"
  echo "  ${GREEN}3${NC}) 彻底清除脚本   ${DIM}删镜像 + 依赖${NC}"
  echo "  ${GREEN}4${NC}) 退出"
  echo "${CYAN}────────────────────────────────────────${NC}"
  read -r -p "${YELLOW}请选择 [1-4]: ${NC}" CHOICE || { echo; break; }
  case $CHOICE in
    1) action_local ;;
    2) action_proxy || echo "${DIM}（本轮落地检测未完成，可重试）${NC}" ;;
    3) action_purge; echo "${GREEN}已彻底清除，脚本退出。${NC}"; break ;;
    4) echo "${GREEN}再见。${NC}"; break ;;
    '') : ;;
    *) echo "${RED}无效选项：$CHOICE${NC}" ;;
  esac
done