#!/bin/sh
# openwrt-isp-split - 按目标 IP 归属运营商做 mwan3 分流
#
# 依赖：mwan3-nft (>=3.6，原生 nftables 版)、nft、awk、sort
#
# 子命令：
#   fetch      下载并重建运营商 IP 段（不改变线上流量）
#   apply      把已构建的集合原子刷入 nftables（不断流、不重启 mwan3）
#   wire       在 mwan3 中创建 nft 集合与分流规则，并插到正确位置
#   sync       以上三步 + 重载 mwan3（首次安装用这个）
#   status     查看各集合条目数、命中包数/字节数、数据源健康度
#   diff       重新计算主源与校验源的分歧（超阈值返回 3）
#   probe      分线实测各运营商出口速度，用来决定策略映射
#   rollback   回滚到上一份数据并重新 apply
#   install    注册开机自启与每日更新
#   uninstall  卸载所有改动
set -u
ISP_VERSION="0.4.1"

# 配置表里的注释行（# 开头，允许前导空白）必须在按空白展开之前整行删掉
# （B-1）。否则 `for row in $ISP_TABLE` 会把 "#" 与后面的文字拆成两个
# token，注释片段被当成数据行。只改内存，不写回配置文件。
_strip_comments() {
	printf '%s\n' "$1" | sed 's/^[[:space:]]*#.*$//' | sed '/^[[:space:]]*$/d'
}

CONF=${ISP_SPLIT_CONF:-/etc/isp-split.conf}
BASE=${ISP_SPLIT_BASE:-/etc/isp-split}
# 归一化尾部斜杠（含多个）：`/etc//` 与 `/etc` 必须落到同一条拒绝分支；
# 根路径保持 `/`，否则会变成空串绕过下面的空值检查。
if [ "$BASE" != "/" ]; then
	BASE=$(printf '%s' "$BASE" | sed 's#/*$##')
	[ -n "$BASE" ] || BASE=/
fi
# BASE 会被 cp/写文件，也会在 uninstall 里被 `rm -rf`。它可以从环境变量
# 覆盖（离线自检用），所以先挡住危险值：空、根、相对路径、含 ".."。
case "$BASE" in
"" | "/" | "." | ".." | *..*)
	echo "ISP_SPLIT_BASE 非法: '$BASE'" >&2
	exit 1
	;;
/*) ;;
*)
	echo "ISP_SPLIT_BASE 必须是绝对路径: '$BASE'" >&2
	exit 1
	;;
esac
# A-10：BASE 的下一站是 `rm -rf "$BASE"`（uninstall）。只挡空/根/相对/..
# 还不够——/etc、/usr、/var 都是合法绝对路径，却能直接抹掉系统目录。
# 这里与 teardown.sh 的 validate_base 同一口径，先在最外层拒绝；uninstall
# 不会再有机会往 /etc/work 里写临时文件。
case "$BASE" in
/ | /etc | /usr | /var | /bin | /sbin | /lib | /lib64 | /boot | /dev | /proc | /sys | /www | /root | /home | /tmp | /mnt | /opt)
	echo "ISP_SPLIT_BASE 指向系统目录，拒绝执行: '$BASE'" >&2
	exit 1
	;;
esac
DATA=$BASE/data
WORK=$BASE/work
LIB=${ISP_SPLIT_LIB:-/usr/lib/isp-split}
CFG_MWAN3=${ISP_SPLIT_MWAN3:-/etc/config/mwan3}
# crontab 文件与 dnsmasq 服务脚本：允许覆盖仅用于离线自检。
CRONTAB=${ISP_SPLIT_CRONTAB:-/etc/crontabs/root}
DNSMASQ_SERVICE=${DNSMASQ_SERVICE:-/etc/init.d/dnsmasq}
# mwan3 服务脚本：允许覆盖仅用于自检（stub reload 验证失败路径）。
MWAN3_SERVICE=${MWAN3_SERVICE:-/etc/init.d/mwan3}

[ -f "$CONF" ] || { echo "缺少 $CONF" >&2; exit 1; }
. "$CONF"

# 配置文件常被 Windows 编辑器/网页粘贴保存成 CRLF。sh 的 source 不在乎，
# 但 ISP_TABLE 的最后一列会变成 "1\r"，`[ "$4" = "1" ]` 永远不成立：
# plan 只剩一张空表、status 只剩兜底行、apply 报"没有启用的运营商"，
# 而真正的原因（行尾多了一个 \r）完全看不出来。这里在入口统一去掉。
# 注释行也在同一处删掉（B-1）：删除必须发生在任何按空白展开之前。
ISP_TABLE=$(_strip_comments "$(printf '%s' "${ISP_TABLE:-}" | tr -d '\r')")
DNS_TABLE=$(_strip_comments "$(printf '%s' "${DNS_TABLE:-}" | tr -d '\r')")
DOMAIN_SPLIT=$(_strip_comments "$(printf '%s' "${DOMAIN_SPLIT:-}" | tr -d '\r')")

# ---- 多数据源（0.2.1 默认双源）----
# ISP_SOURCES 为空 = 单源兼容模式：仍按 ISP_MIRROR/<源名>.txt 拉取，
# 与 0.1.x 的行为逐字节一致（老配置升级后不需要改任何东西）。
ISP_SOURCES=$(printf '%s' "${ISP_SOURCES:-}" | tr -d '\r')
ISP_SOURCE_FILES=$(printf '%s' "${ISP_SOURCE_FILES:-}" | tr -d '\r')
# 0.2.1 起默认启用校验源；用户配置里显式写了 ISP_VERIFY_ENABLE=0 时仍以用户值为准。
ISP_VERIFY_ENABLE=${ISP_VERIFY_ENABLE:-1}
ISP_DIFF_MAX_JACCARD_DROP=${ISP_DIFF_MAX_JACCARD_DROP:-90}
ISP_DIFF_MAX_DELTA_PCT=${ISP_DIFF_MAX_DELTA_PCT:-3}
ISP_SOURCE_MAX_FAIL_DAYS=${ISP_SOURCE_MAX_FAIL_DAYS:-3}
RAW_ARCHIVE=$BASE/raw
STATE_DIR=$BASE/state
SOURCE_STATE=$STATE_DIR/sources.tsv

ISP_ANCHOR=${ISP_ANCHOR:-'dest_port|src_port|default_rule|https|internet'}
ISP_CRON=${ISP_CRON:-"17 4 * * *"}
PROBE_BYTES=${PROBE_BYTES:-33554432}
# 单个源文件的体积上限（默认 4 MiB）。正常数据 < 300 KB；
# 镜像被劫持或换源换错时，一个超大响应会直接写满 overlay（/etc 就在
# overlay 上），路由器连重启都可能起不来。
MAX_RAW_BYTES=${ISP_SPLIT_MAX_RAW_BYTES:-4194304}
# 下载暂存目录（A-01）：先落 tmpfs，逐文件校验通过后再 mv 回 overlay 的
# $WORK/raw.new。允许覆盖仅用于离线自检；生产固定 /tmp。
FETCH_STAGE_ROOT=${ISP_SPLIT_FETCH_TMP:-/tmp}
PROBE_TIME=${PROBE_TIME:-25}

# mwan3 reload 是异步的：命令返回时 nft 链可能还在重建。
# 轮询间隔与次数可被配置/测试覆盖；默认最多等 15 秒。
MWAN3_READY_TRIES=${MWAN3_READY_TRIES:-15}
MWAN3_READY_DELAY=${MWAN3_READY_DELAY:-1}
DNS_RETRY_TRIES=${DNS_RETRY_TRIES:-15}
DNS_RETRY_DELAY=${DNS_RETRY_DELAY:-1}

log() {
	logger -t isp-split -- "$*" 2>/dev/null
	echo "[isp-split] $*"
}

die() {
	_op_lock_cleanup
	log "错误: $*"
	exit 1
}

# ---- 全局操作锁（F-28）----
# fetch/wire/apply/sync 共用固定的 $WORK/raw.new、out.new、compose.new、
# data.new 与 mwan3 候选配置；cron refresh 与手动 sync 并发时彼此 rm -rf、
# 半套数据互踩（实测 4 个并发 fetch 全部"构建产物为空"，DATA 都没生成）。
#
# 选型：单把操作锁，而不是"每次调用唯一临时名 + 原子 mv"。理由：
#   1) 冲突点不只在临时文件——DATA 目录整体 mv、历史快照、.stamp/.sets
#      事务、mwan3 落盘 + reload 都必须串行，唯一临时名解决不了后三者；
#   2) 锁语义与既有的体检锁实现一致（mkdir 原子建锁 + pid/started +
#      陈旧回收），运维只需要理解一种锁。
# 嵌套调用（sync -> fetch -> wire -> apply）按同一 pid 重入，深度归零才释放。
OP_LOCK=$STATE_DIR/op.lock
OP_LOCK_OWNER=0
OP_LOCK_DEPTH=0
OP_LOCK_PID=""
OP_LOCK_AGE=""

# 锁年龄。pid/started 写入有窗口期，缺失时退回目录 mtime，绝不把"还没写"
# 当成 epoch 0（否则刚建的锁会被立刻误判成陈旧锁）。
_op_lock_age() {
	_ol_t=$(cat "$OP_LOCK/started" 2>/dev/null)
	case "$_ol_t" in '' | *[!0-9]*) _ol_t="" ;; esac
	if [ -z "$_ol_t" ]; then
		_ol_t=$(stat -c %Y "$OP_LOCK" 2>/dev/null)
		case "$_ol_t" in '' | *[!0-9]*) _ol_t="" ;; esac
	fi
	if [ -z "$_ol_t" ]; then
		printf '0'
		return 0
	fi
	_ol_a=$(( $(date +%s) - _ol_t ))
	[ "$_ol_a" -ge 0 ] 2>/dev/null || _ol_a=0
	printf '%s' "$_ol_a"
}

# 0=拿到锁 1=已有操作在跑（OP_LOCK_PID/OP_LOCK_AGE） 2=建不了锁目录
#
# 与体检锁的差异（有意）：持锁进程活着就按"在跑"处理，不设 5 分钟上限
# （fetch 在慢速 MIPS 路由器上可能跑很久）。只有超过 6 小时才回收，防 pid
# 复用永久锁死；进程不存在时按 5 秒宽限回收。
_op_lock_try() {
	OP_LOCK_PID=""
	OP_LOCK_AGE=""
	mkdir -p "$STATE_DIR" 2>/dev/null || return 2
	if mkdir "$OP_LOCK" 2>/dev/null; then
		date +%s >"$OP_LOCK/started" 2>/dev/null || true
		printf '%s\n' "$$" >"$OP_LOCK/pid" 2>/dev/null || true
		OP_LOCK_OWNER=1
		return 0
	fi
	_ol_p=$(cat "$OP_LOCK/pid" 2>/dev/null)
	case "$_ol_p" in '' | *[!0-9]*) _ol_p="" ;; esac
	_ol_s=$(cat "$OP_LOCK/started" 2>/dev/null)
	case "$_ol_s" in '' | *[!0-9]*) _ol_s="" ;; esac
	_ol_age=$(_op_lock_age)
	case "$_ol_age" in '' | *[!0-9]*) _ol_age=0 ;; esac
	# 元数据还没写全：锁刚 mkdir，持锁者正在写；宽限期内按在跑处理。
	if [ -z "$_ol_p" ] && [ -z "$_ol_s" ]; then
		OP_LOCK_AGE=$_ol_age
		return 1
	fi
	# 活着的 pid 算持锁；只有超过 6 小时才允许回收（防 pid 复用永久锁死）。
	if [ -n "$_ol_p" ] && kill -0 "$_ol_p" 2>/dev/null && [ "$_ol_age" -lt 21600 ]; then
		OP_LOCK_PID=$_ol_p
		OP_LOCK_AGE=$_ol_age
		return 1
	fi
	# 刚创建/刚交接：宽限期内不回收，避免踩到 mkdir -> 写文件窗口。
	if [ "$_ol_age" -lt 5 ]; then
		[ -n "$_ol_p" ] && OP_LOCK_PID=$_ol_p
		OP_LOCK_AGE=$_ol_age
		return 1
	fi
	# 陈旧锁：rename 原子搬走再重建，避免 A 刚 rm、B 又 rm 掉 A 的新锁。
	_stale=$OP_LOCK.stale.$$
	rm -rf "$_stale" 2>/dev/null
	if mv "$OP_LOCK" "$_stale" 2>/dev/null; then
		rm -rf "$_stale" 2>/dev/null
		if mkdir "$OP_LOCK" 2>/dev/null; then
			date +%s >"$OP_LOCK/started" 2>/dev/null || true
			printf '%s\n' "$$" >"$OP_LOCK/pid" 2>/dev/null || true
			OP_LOCK_OWNER=1
			return 0
		fi
	fi
	OP_LOCK_AGE=$_ol_age
	return 1
}

# 获取锁；已被本进程持有时只增加深度。等待上限 ISP_SPLIT_LOCK_WAIT 秒
# （默认 30，0=不等待直接失败）。返回非 0 时调用方必须放弃并保持现状。
_op_lock_acquire() {
	if [ "${OP_LOCK_OWNER:-0}" = "1" ]; then
		OP_LOCK_DEPTH=$((OP_LOCK_DEPTH + 1))
		return 0
	fi
	_limit=${ISP_SPLIT_LOCK_WAIT:-30}
	case "$_limit" in '' | *[!0-9]*) _limit=30 ;; esac
	_waited=0
	while :; do
		_op_lock_try
		_rc=$?
		if [ "$_rc" -eq 0 ]; then
			OP_LOCK_DEPTH=1
			[ "$_waited" -gt 0 ] &&
				log "已获取操作锁（等待 ${_waited}s）"
			return 0
		fi
		if [ "$_rc" -eq 2 ]; then
			log "错误：无法创建锁目录 $STATE_DIR（BASE 不可写？）"
			return 1
		fi
		[ "$_waited" -eq 0 ] &&
			log "另一个 isp-split 操作正在运行（pid ${OP_LOCK_PID:-?}），最多等待 ${_limit}s"
		[ "$_waited" -ge "$_limit" ] && return 1
		sleep 1
		_waited=$((_waited + 1))
	done
}

# 释放一层；深度归零且 pid 仍是自己时才删目录。
_op_lock_release() {
	[ "${OP_LOCK_OWNER:-0}" = "1" ] || return 0
	OP_LOCK_DEPTH=$((OP_LOCK_DEPTH - 1))
	[ "$OP_LOCK_DEPTH" -gt 0 ] && return 0
	if [ "$(cat "$OP_LOCK/pid" 2>/dev/null)" = "$$" ]; then
		rm -rf "$OP_LOCK" 2>/dev/null
	fi
	OP_LOCK_OWNER=0
	OP_LOCK_DEPTH=0
	return 0
}

# 进程退出/致命错误时把锁整体放掉（不受嵌套深度影响）。
_op_lock_cleanup() {
	[ "${OP_LOCK_OWNER:-0}" = "1" ] || return 0
	OP_LOCK_DEPTH=1
	_op_lock_release
}

# 正常路径也由 EXIT 兜底；cmd_wire 的 trap 会覆盖它，故 wire 内部显式恢复。
trap '_op_lock_cleanup' EXIT
trap '_op_lock_cleanup; exit 130' INT TERM

need_nft() {
	command -v nft >/dev/null 2>&1 || die "未找到 nft 命令"
	nft list table inet mwan3 >/dev/null 2>&1 ||
		die "table inet mwan3 不存在，请确认 mwan3-nft 已启动"
}

# ---- OpenClash fake-ip 模式检测（BUG-17）----
# fake-ip 模式给域名返回 198.18.0.0/15 的假地址，而本工具是按"目标 IP
# 归属运营商"匹配集合的：假 IP 不在任何运营商网段里，规则永远不会命中，
# 所有流量都落到兜底；同时 fake-ip 的 DNS 劫持会盖掉本工具写的分线 DNS
# 片段。结果是"装好了但静默不生效"，用户只能看到兜底计数一直涨。
# 因此 plan/verify/status 以及 wire/apply/sync 启动时都主动告警。
# 只告警、不阻断：用户可能就是要先装好，再切 redir-host 模式。
_openclash_fakeip() {
	command -v uci >/dev/null 2>&1 || return 1
	_ocf_en=$(uci -q get openclash.config.enable 2>/dev/null) || return 1
	[ "$_ocf_en" = "1" ] || return 1
	_ocf_mode=$(uci -q get openclash.config.en_mode 2>/dev/null) || return 1
	# 兼容 fake-ip / fakeip / fake_ip / Fake-IP 等写法
	_ocf_norm=$(printf '%s' "$_ocf_mode" | tr 'A-Z' 'a-z' | tr -d ' _-')
	case "$_ocf_norm" in
	fakeip*) return 0 ;;
	esac
	return 1
}

_openclash_fakeip_warn() {
	_openclash_fakeip || return 0
	# sync 会依次调用 wire/apply，同一次执行只提示一次，避免刷屏
	[ "${_OCF_WARNED:-0}" = "1" ] && return 0
	_OCF_WARNED=1
	echo "[isp-split] ⚠ 检测到 OpenClash fake-ip 模式：假 IP 无法匹配运营商集合，"
	echo "            IP 分流与多线路 DNS 均不会生效。处理：OpenClash 切 redir-host 模式"
	echo "            （LuCI → OpenClash → 模式设置），或接受分流不生效。"
	return 0
}

# work 目录（临时批处理/备份文件的家）是否真的可写。
# 用 mktemp 探路而不是固定名字 .write-test：固定名字在可写目录里
# （例如把 BASE 指到 /tmp 下的测试/容器场景）能被低权用户预先做成
# 符号链接，root 的 `: > 文件` 会顺着链接把目标截断。
_work_writable() {
	mkdir -p "$WORK" || return 1
	_wt=$(mktemp "$WORK/.write-test.XXXXXX" 2>/dev/null) || return 1
	rm -f "$_wt"
	return 0
}

# 统计一个集合里的元素个数
#
# 坑：带 flags interval 的集合，nft list set 输出的是区间格式
#     （1.48.0.0-1.50.255.255），不是 CIDR（1.48.0.0/15）。
#     用 /前缀 正则去数会永远得到 0。这里按元素的逗号分隔来数。
count_set_elements() {
	nft list set inet "$1" "$2" 2>/dev/null | awk '
		{
			line = $0
			# 用 index() 而不是正则匹配花括号：
			# busybox awk 的 ERE 会拒绝 \{，报 "Invalid contents of {}" 并整段退出。
			if (!inb && index(line, "elements = {")) {
				inb = 1
				line = substr(line, index(line, "elements = {") + 12)
			}
			if (inb) {
				q = index(line, "}")
				if (q) {
					end = 1
					line = substr(line, 1, q - 1)
				}
				if (line ~ /[0-9]/)
					seen = 1
				c += gsub(/,/, ",", line)
				if (end)
					inb = 0
			}
		}
		END { printf "%d\n", (seen ? c + 1 : 0) }'
}

# 按 ISP_TABLE 顺序输出运营商源名
priority_list() {
	for row in $ISP_TABLE; do
		case "$row" in \#*) continue ;; esac
		_row_check_row "$row"
		printf '%s ' "${row%%:*}"
	done
}

# 已启用集合的源名列表（空格分隔）。用来说明"运营商表有没有变化"：
# 启用/停用某个运营商会合法地改变地址总量，增幅熔断要跳过这种情况。
_enabled_src_list() {
	_el=""
	for row in $ISP_TABLE; do
		_old_ifs=$IFS
		IFS=':'
		set -- $row
		IFS=$_old_ifs
		[ $# -ge 4 ] || continue
		[ "$4" = "1" ] || continue
		_el="${_el:+$_el }$1"
	done
	printf '%s\n' "$_el"
}

# 遍历启用的运营商，逐行回调：cb <源名> <集合名> <策略>
foreach_enabled() {
	_cb=$1
	_old_ifs=$IFS
	for row in $ISP_TABLE; do
		IFS=':'
		set -- $row
		IFS=$_old_ifs
		case "$row" in \#*) continue ;; esac
		[ $# -ge 4 ] ||
			die "ISP_TABLE 行格式错误（应为 源:集合:策略:0|1）: $row"
		_row_check "$1" "$2" "$3" "$4"
		[ "$4" = "1" ] || continue
		$_cb "$1" "$2" "$3"
	done
}

# A-01：流式下载，最多只让 MAX_RAW_BYTES+1 字节落盘。
# 不能用 `cmd > file` 再 wc：那样大响应会先写满 overlay/tmpfs。
# 子 shell 里把下载器自己的 rc 写进 sidecar，再让 head 提前关管道；
# 体积超限优先按“超限”处理（此时下载器通常因 SIGPIPE 非 0，属预期）。
_fetch_stream() {
	_fs_out=$1
	shift
	_fs_rcf=$_fs_out.rc.$$
	rm -f "$_fs_rcf"
	{ "$@"; echo $? >"$_fs_rcf"; } |
		head -c $((MAX_RAW_BYTES + 1)) >"$_fs_out"
	_fs_rc=$(cat "$_fs_rcf" 2>/dev/null)
	rm -f "$_fs_rcf"
	case "${_fs_rc:-}" in '' | *[!0-9]*) _fs_rc=1 ;; esac
	_fs_sz=$(wc -c <"$_fs_out" 2>/dev/null | tr -d ' \t')
	case "${_fs_sz:-}" in '' | *[!0-9]*) _fs_sz=0 ;; esac
	if [ "$_fs_sz" -gt "$MAX_RAW_BYTES" ]; then
		log "源下载超过上限 $MAX_RAW_BYTES 字节（已流式截断到 $((MAX_RAW_BYTES + 1)) 字节）"
		return 1
	fi
	[ "$_fs_rc" -eq 0 ] || return 1
	return 0
}

fetch_one() {
	_out=$2
	case "$MAX_RAW_BYTES" in
	'' | *[!0-9]* | 0)
		log "错误：MAX_RAW_BYTES 必须是正整数（当前: '$MAX_RAW_BYTES'）"
		return 1
		;;
	esac
	if command -v uclient-fetch >/dev/null 2>&1; then
		# uclient-fetch 没有 --max-filesize，统一走 head 截断。
		_fetch_stream "$_out" uclient-fetch -q -T 30 -O - "$1"
	elif command -v curl >/dev/null 2>&1; then
		# --max-filesize 让有 Content-Length 的响应在下载前就被拒；
		# 无长度/分块响应仍由 _fetch_stream 的 head 兜底。
		_fetch_stream "$_out" curl -fsS -m 30 --max-filesize "$MAX_RAW_BYTES" -o - "$1"
	elif command -v wget >/dev/null 2>&1; then
		_fetch_stream "$_out" wget -q -T 30 -O - "$1"
	else
		die "没有可用的下载工具（uclient-fetch / curl / wget）"
	fi
}

# ---------------------------------------------------------------- 多数据源（0.2.1）
#
# 0.2.1 默认双源：yfgao 主源 + clang 校验源（模板见 files/etc/isp-split.conf）。
# 老配置里显式声明的 ISP_SOURCES 完全按用户声明执行，不会被覆盖。
# ISP_SOURCES 为空时完全退回 0.1.x 的单源行为（ISP_MIRROR/<源名>.txt）。

# 源声明规范化：每行一条 <名字>:<角色>:<基址>
_source_rows() {
	if printf '%s' "${ISP_SOURCES:-}" | grep -q '[^[:space:]]'; then
		printf '%s\n' "$ISP_SOURCES" | tr -s ' \t' '\n' | sed '/^[[:space:]]*$/d'
	else
		printf '%s\n' "ispip:primary:${ISP_MIRROR:-}"
	fi
}

# 拆一行源声明到 _s_name / _s_role / _s_base
_source_split() {
	_s_name=${1%%:*}
	_s_rest=${1#*:}
	_s_role=${_s_rest%%:*}
	_s_base=${_s_rest#*:}
}

_source_role_of() {
	_source_rows | awk -F: -v n="$1" '$1 == n { print $2; exit }'
}

_source_base_of() {
	_source_rows | awk -v n="$1" '
		{
			i = index($0, ":")
			name = substr($0, 1, i - 1)
			if (name != n) next
			rest = substr($0, i + 1)
			j = index(rest, ":")
			print substr(rest, j + 1)
			exit
		}'
}

_primary_source_name() {
	_source_rows | awk -F: '$2 == "primary" { print $1; exit }'
}

# 单个源声明的白名单校验（写入侧与读取侧共用）
_source_check() {
	_sc_row=$1
	_source_split "$_sc_row"
	case "$_s_name" in
	"" | *[!A-Za-z0-9._-]*)
		die "ISP_SOURCES 的源名 '$_s_name' 非法（只允许 [A-Za-z0-9._-]）"
		;;
	esac
	case "$_s_role" in
	primary | verify | audit) ;;
	*) die "ISP_SOURCES 源 '$_s_name' 的角色 '$_s_role' 非法（只能是 primary / verify / audit）" ;;
	esac
	# ISP_MIRROR 没配（老配置残缺）时 status/plan 仍要能看，只是 fetch 会拒绝
	[ -n "$_s_base" ] || return 0
	case "$_s_base" in
	https://*) _sc_host=${_s_base#https://} ;;
	http://*)
		[ "${ISP_SPLIT_ALLOW_HTTP:-0}" = "1" ] ||
			die "ISP_SOURCES 源 '$_s_name' 使用明文 HTTP（$_s_base）：数据可被中间人替换。改用 https://，或确认风险后设 ISP_SPLIT_ALLOW_HTTP=1"
		_sc_host=${_s_base#http://}
		log "警告：源 '$_s_name' 使用明文 HTTP，数据完整性无保证"
		;;
	*)
		die "ISP_SOURCES 源 '$_s_name' 的基址必须形如 https://host（当前: $_s_base）"
		;;
	esac
	# 基址只允许主机/端口/路径：不带查询串、不带空白、不带 shell 元字符。
	case "$_sc_host" in
	"" | *[!A-Za-z0-9._:/~-]*)
		die "ISP_SOURCES 源 '$_s_name' 的基址不合法（只允许 host[:port][/path]）: $_s_base"
		;;
	esac
}

_source_check_all() {
	_sa_primary=0
	_sa_seen=" "
	for _sa_row in $(_source_rows); do
		_source_check "$_sa_row"
		case "$_sa_seen" in
		*" $_s_name "*) die "ISP_SOURCES 里源名 '$_s_name' 重复" ;;
		esac
		_sa_seen="$_sa_seen$_s_name "
		if [ "$_s_role" = "primary" ]; then
			_sa_primary=$((_sa_primary + 1))
		fi
	done
	[ "$_sa_primary" -eq 1 ] ||
		die "ISP_SOURCES 必须且只能有一个 primary 源（当前 $_sa_primary 个）"
}

# ISP_SOURCE_FILES 规范化成 "<源名>:<ISP_TABLE 源名>=<远端文件名>" 每行一条。
# 书写格式（与设计一致）：yfgao:chinatelecom=chinanet,unicom_cnc=unicom
_source_map_lines() {
	printf '%s\n' "${ISP_SOURCE_FILES:-}" | tr ',' '\n' | awk '
		{
			gsub(/[[:space:]]+/, "", $0)
			if ($0 == "") next
			i = index($0, ":")
			if (i > 0) {
				cur = substr($0, 1, i - 1)
				$0 = substr($0, i + 1)
			}
			if (cur != "") print cur ":" $0
		}'
}

# <源名> <ISP_TABLE 源名> -> 该源上的远端文件名（空 = 未显式映射）
# 映射值写"不带 .txt 的名字"（与设计文档一致）：chinatelecom=chinanet。
# 值里自带扩展名（如 foo.dat）时原样使用，不再补后缀。
_source_file_of() {
	_sf=$(_source_map_lines |
		awk -v n="$1" -v s="$2" -F'[:=]' '$1 == n && $2 == s { print $3; exit }')
	case "$_sf" in
	"") ;;
	*.*) ;;
	*) _sf="$_sf.txt" ;;
	esac
	printf '%s' "$_sf"
}

# 该源是否声明过任意一条文件名映射。
# 声明过 = 默认只处理映射里列出的运营商；主源上"已启用但未映射"的运营商
# 仍会退回 <源名>.txt 兼容路径（见 _fetch_source）。完全没声明 = 老配置。
_source_has_map() {
	_source_map_lines | grep -q "^$1:"
}

# ---- 源健康状态：$BASE/state/sources.tsv ----
# 列：name  role  last_ok  fail_days  jaccard  note
_state_get() {
	[ -f "$SOURCE_STATE" ] || return 0
	awk -F "$(printf '\t')" -v n="$1" -v f="$2" '
		$1 == n {
			if (f == "role") print $2
			else if (f == "last_ok") print $3
			else if (f == "fail") print $4
			else if (f == "jaccard") print $5
			else if (f == "note") print $6
			exit
		}' "$SOURCE_STATE"
}

# 连续失败计数 +1；返回新的失败天数
_state_fail_plus() {
	_sf_prev=$(_state_get "$1" fail)
	case "${_sf_prev:-}" in '' | *[!0-9]*) _sf_prev=0 ;; esac
	_sf_new=$((_sf_prev + 1))
	_state_put "$1" "$2" "$(_state_get "$1" last_ok)" "$_sf_new" \
		"$(_state_get "$1" jaccard)" "$3"
	printf '%s' "$_sf_new"
}

_state_put() {
	mkdir -p "$STATE_DIR" || return 1
	_sp_tmp=$(mktemp "$STATE_DIR/.sources.XXXXXX" 2>/dev/null) || return 1
	if [ -f "$SOURCE_STATE" ]; then
		awk -F "$(printf '\t')" -v OFS="$(printf '\t')" \
			-v n="$1" -v r="$2" -v o="$3" -v f="$4" -v j="$5" -v s="$6" '
			$1 == n { if (!done) { print n, r, o, f, j, s; done = 1 } next }
			{ print }
			END { if (!done) print n, r, o, f, j, s }
		' "$SOURCE_STATE" > "$_sp_tmp" || { rm -f "$_sp_tmp"; return 1; }
	else
		printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$5" "$6" > "$_sp_tmp" ||
			{ rm -f "$_sp_tmp"; return 1; }
	fi
	mv -f "$_sp_tmp" "$SOURCE_STATE" 2>/dev/null || { rm -f "$_sp_tmp"; return 1; }
	# A-14：state 里是数据源健康度/下载时间，不含口令但也没必要让本机低权
	# 用户读；统一 700/600（升级前已存在的旧权限也在这里被纠正）。
	chmod 700 "$STATE_DIR" 2>/dev/null || true
	chmod 600 "$SOURCE_STATE" 2>/dev/null || true
	return 0
}

# 清理 state/sources.tsv 里已经不在 ISP_SOURCES 中的残留行。
#
# 典型场景：0.1.x 单源时代留下 `ispip primary`，用户后来改成双源，
# 旧行会一直躺在文件里（status/health 虽然只遍历声明过的源，但数据本身是脏的，
# 排查时也很容易被误读）。这里在 fetch/status/health 入口做一次幂等清理：
#   · 只保留当前 ISP_SOURCES（空则为兼容模式的 ispip）声明过的源；
#   · 顺带丢掉字段数不足 6 的坏行；
#   · 没有任何变化时不动文件（status 每 10 秒轮询，不必反复写 flash）。
_state_prune() {
	[ -f "$SOURCE_STATE" ] || return 0
	_sp_decl=" "
	for _sp_row in $(_source_rows); do
		_sp_name=${_sp_row%%:*}
		[ -n "$_sp_name" ] && _sp_decl="$_sp_decl$_sp_name "
	done
	[ "$_sp_decl" != "  " ] || return 0

	mkdir -p "$STATE_DIR" || return 1
	_sp_tmp=$(mktemp "$STATE_DIR/.sources.prune.XXXXXX" 2>/dev/null) || return 1
	_sp_rem=$(mktemp "$STATE_DIR/.sources.removed.XXXXXX" 2>/dev/null) ||
		{ rm -f "$_sp_tmp"; return 1; }
	awk -F "$(printf '\t')" -v keep="$_sp_decl" -v rem="$_sp_rem" '
		NF >= 6 {
			if (index(keep, " " $1 " ") > 0) print
			else print $1 > rem
		}' "$SOURCE_STATE" > "$_sp_tmp" || {
		rm -f "$_sp_tmp" "$_sp_rem"
		return 1
	}

	if cmp -s "$_sp_tmp" "$SOURCE_STATE"; then
		rm -f "$_sp_tmp" "$_sp_rem"
		return 0
	fi

	_sp_removed=$(tr '\n' ' ' < "$_sp_rem" 2>/dev/null)
	mv -f "$_sp_tmp" "$SOURCE_STATE" 2>/dev/null || {
		rm -f "$_sp_tmp" "$_sp_rem"
		return 1
	}
	rm -f "$_sp_rem"
	[ -n "${_sp_removed:-}" ] &&
		log "已清理 state/sources.tsv 中不在 ISP_SOURCES 的残留源: ${_sp_removed% }" >&2
	return 0
}

_ts_text() {
	case "${1:-}" in
	"" | 0 | *[!0-9]*) printf '%s' "-"; return 0 ;;
	esac
	date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null ||
		date -r "$1" '+%Y-%m-%d %H:%M' 2>/dev/null || printf '%s' "-"
}

# 下载一个源的全部文件到 <目录>（文件名保持远端原名）。
# 只做"单源单文件"的三道闸门（体积/行数/badcidr）；总量与增幅闸门在合成后做。
# 返回 0 成功；1 失败（调用方决定冻结还是降级）。
_fetch_source() {
	_fs_name=$1
	_fs_base=$(printf '%s' "$2" | sed 's:/*$::')
	_fs_dir=$3
	_fs_role=$4

	mkdir -p "$_fs_dir" || { log "源 $_fs_name: 无法创建 $_fs_dir"; return 1; }

	for _fs_row in $ISP_TABLE; do
		case "$_fs_row" in \#*) continue ;; esac
		_row_check_row "$_fs_row"
		_fs_src=${_fs_row%%:*}
		_fs_file=$(_source_file_of "$_fs_name" "$_fs_src")
		if [ -z "$_fs_file" ]; then
			# 该源声明过映射时，映射里没列出的运营商默认不下载：
			# yfgao 没有 hk/mo/tw 这些分类，按 <源名>.txt 去猜只会每天 404。
			# 但为了不破坏 0.2.0 老配置（clang 主源 + 用户自己启用 hk 等），
			# 主源上"已启用但未映射"的运营商仍退回历史命名试一次；
			# 文件不存在时由下载失败明确报错，而不是静默少一个集合。
			if _source_has_map "$_fs_name"; then
				case "$_fs_row" in
				*:1) ;;
				*) continue ;;
				esac
				[ "$_fs_role" = "primary" ] || continue
				_fs_file="$_fs_src.txt"
			else
				# 完全没声明映射的老配置，沿用 0.1.x 的命名 <源名>.txt；
				# 校验源只在显式映射里出现——绝不去猜别人的文件名。
				[ "$_fs_role" = "primary" ] || continue
				_fs_file="$_fs_src.txt"
			fi
		fi
		case "$_fs_file" in
		"" | *[!A-Za-z0-9._-]*)
			log "源 $_fs_name: 映射出的文件名 '$_fs_file' 非法"
			return 1
			;;
		esac

		log "源 $_fs_name: 下载 $_fs_src ($_fs_file)"
		fetch_one "$_fs_base/$_fs_file" "$_fs_dir/$_fs_file" || {
			log "源 $_fs_name: $_fs_src 下载失败"
			return 1
		}
		_fs_sz=$(wc -c < "$_fs_dir/$_fs_file" 2>/dev/null | tr -d ' \t')
		if [ "${_fs_sz:-0}" -gt "$MAX_RAW_BYTES" ]; then
			log "源 $_fs_name: $_fs_src 体积 ${_fs_sz} 字节，超过上限 $MAX_RAW_BYTES"
			return 1
		fi
		_fs_n=$(grep -c '[0-9]' "$_fs_dir/$_fs_file" 2>/dev/null)
		_fs_n=${_fs_n:-0}
		[ "$_fs_n" -gt 5 ] || {
			log "源 $_fs_name: $_fs_src 内容异常（仅 $_fs_n 行）"
			return 1
		}
		_fs_bad=$(awk -f "$LIB/badcidr.awk" "$_fs_dir/$_fs_file" 2>/dev/null | head -5)
		if [ -n "$_fs_bad" ]; then
			printf '%s\n' "$_fs_bad" >&2
			log "源 $_fs_name: $_fs_src 含非法 CIDR，拒绝（现有数据未改动）"
			return 1
		fi
	done
	return 0
}

# 把主源文件按 ISP_SOURCE_FILES 映射拷成 <isp>.raw，交给 build.sh
_compose_raw() {
	_cm_name=$1
	_cm_src=$2
	_cm_out=$3
	rm -rf "$_cm_out"
	mkdir -p "$_cm_out" || return 1
	for _cm_row in $ISP_TABLE; do
		case "$_cm_row" in \#*) continue ;; esac
		_row_check_row "$_cm_row"
		_cm_isp=${_cm_row%%:*}
		_cm_file=$(_source_file_of "$_cm_name" "$_cm_isp")
		if [ -z "$_cm_file" ]; then
			if _source_has_map "$_cm_name"; then
				case "$_cm_row" in
				*:1) _cm_file="$_cm_isp.txt" ;;
				*) continue ;;
				esac
			else
				_cm_file="$_cm_isp.txt"
			fi
		fi
		[ -f "$_cm_src/$_cm_file" ] || {
			log "合成: 缺少 $_cm_isp 的源文件（$_cm_file）"
			return 1
		}
		cp -f "$_cm_src/$_cm_file" "$_cm_out/$_cm_isp.raw" || return 1
	done
	return 0
}

# 源数据存档：$BASE/raw/<源名>/<远端文件名>。差异/日变化都基于存档重算，
# 所以即使当天拒绝更新，也能事后 `isp-split diff` 复查。
_archive_source() {
	_ar_new=$RAW_ARCHIVE/.$1.new
	mkdir -p "$RAW_ARCHIVE" || return 1
	rm -rf "$_ar_new"
	mkdir -p "$_ar_new" || return 1
	cp -a "$2/." "$_ar_new/" 2>/dev/null || { rm -rf "$_ar_new"; return 1; }
	date +%s > "$_ar_new/.ok" || { rm -rf "$_ar_new"; return 1; }
	rm -rf "$RAW_ARCHIVE/.$1.old"
	if [ -d "$RAW_ARCHIVE/$1" ]; then
		mv "$RAW_ARCHIVE/$1" "$RAW_ARCHIVE/.$1.old" 2>/dev/null || return 1
	fi
	if ! mv "$_ar_new" "$RAW_ARCHIVE/$1" 2>/dev/null; then
		[ -d "$RAW_ARCHIVE/.$1.old" ] && mv "$RAW_ARCHIVE/.$1.old" "$RAW_ARCHIVE/$1" 2>/dev/null
		return 1
	fi
	rm -rf "$RAW_ARCHIVE/.$1.old"
	return 0
}

# 单个运营商两个源的三段差异 + Jaccard
# 输出到 stdout：<P地址数> <V地址数> <共同> <仅主> <仅校验> <Jaccard%>
_diff_ranges() {
	_dr_p=$1
	_dr_v=$2
	_dr_pout=$3
	_dr_vout=$4
	awk -v vf="$_dr_v" -v outp="$_dr_pout" -v outv="$_dr_vout" \
		-f "$LIB/ispdiff.awk" "$_dr_p"
}

_quarantine_write() { # <校验源名> <运营商源名> <仅主区间> <仅校验区间>
	_qw_dir=$BASE/quarantine/$(date +%Y%m%d)
	mkdir -p "$_qw_dir" || return 1
	awk -f "$LIB/range2cidr.awk" "$3" > "$_qw_dir/$1.$2.only-primary.txt" || return 1
	awk -f "$LIB/range2cidr.awk" "$4" > "$_qw_dir/$1.$2.only-verify.txt" || return 1
	return 0
}

# 计算并打印 主源 vs 校验源 的分歧；超阈值返回 3（拒绝更新）
# <主源目录> <校验源目录> <主源名> <校验源名> <save: always|reject|never>
_run_diff() {
	_rd_pdir=$1
	_rd_vdir=$2
	_rd_pname=$3
	_rd_vname=$4
	_rd_save=$5
	_rd_rc=0
	_rd_rows=0
	_rd_min=""
	_rd_bad=""
	DIFF_MIN_JACCARD=""
	DIFF_REJECTED=""
	DIFF_ROWS=0

	mkdir -p "$WORK" || return 1
	_rd_tmp=$WORK/.diff.$$
	rm -rf "$_rd_tmp"
	mkdir -p "$_rd_tmp" || return 1

	echo "== 数据源差异（主源 $_rd_pname vs 校验源 $_rd_vname，阈值 ${ISP_DIFF_MAX_JACCARD_DROP}%）"
	printf '%-14s %10s %10s %10s %9s %9s %8s\n' \
		"运营商" "主源地址" "校验地址" "共同" "仅主" "仅校验" "Jaccard"

	for _rd_row in $ISP_TABLE; do
		case "$_rd_row" in \#*) continue ;; esac
		_row_check_row "$_rd_row"
		_rd_isp=${_rd_row%%:*}
		_rd_pf=$(_source_file_of "$_rd_pname" "$_rd_isp")
		if [ -z "$_rd_pf" ]; then
			if _source_has_map "$_rd_pname"; then
				case "$_rd_row" in
				*:1) _rd_pf="$_rd_isp.txt" ;;
				*) continue ;;
				esac
			else
				_rd_pf="$_rd_isp.txt"
			fi
		fi
		_rd_vf=$(_source_file_of "$_rd_vname" "$_rd_isp")
		[ -n "$_rd_vf" ] || continue
		[ -f "$_rd_pdir/$_rd_pf" ] && [ -f "$_rd_vdir/$_rd_vf" ] || continue

		awk -f "$LIB/cidr2range.awk" "$_rd_pdir/$_rd_pf" | sort |
			awk -f "$LIB/merge.awk" > "$_rd_tmp/p.ranges"
		awk -f "$LIB/cidr2range.awk" "$_rd_vdir/$_rd_vf" | sort |
			awk -f "$LIB/merge.awk" > "$_rd_tmp/v.ranges"
		# 先建空文件：awk 只在有内容时才创建输出文件，
		# "没有差异"时下游 range2cidr 会打不开不存在的路径。
		: > "$_rd_tmp/only-p.ranges"
		: > "$_rd_tmp/only-v.ranges"
		_rd_res=$(_diff_ranges "$_rd_tmp/p.ranges" "$_rd_tmp/v.ranges" \
			"$_rd_tmp/only-p.ranges" "$_rd_tmp/only-v.ranges")
		# shellcheck disable=SC2086
		set -- $_rd_res
		[ $# -ge 6 ] || continue
		_rd_rows=$((_rd_rows + 1))
		_rd_j=$6
		printf '%-14s %10s %10s %10s %9s %9s %7s%%\n' \
			"$_rd_isp" "$1" "$2" "$3" "$4" "$5" "$_rd_j"

		if awk -v j="$_rd_j" -v m="$_rd_min" 'BEGIN { exit !(m == "" || j + 0 < m + 0) }'; then
			_rd_min=$_rd_j
		fi
		if awk -v j="$_rd_j" -v t="$ISP_DIFF_MAX_JACCARD_DROP" \
			'BEGIN { exit !(j + 0 < t + 0) }'; then
			_rd_rc=3
			_rd_bad="$_rd_bad $_rd_isp($_rd_j%)"
			if [ "$_rd_save" != "never" ]; then
				_quarantine_write "$_rd_vname" "$_rd_isp" \
					"$_rd_tmp/only-p.ranges" "$_rd_tmp/only-v.ranges" || true
				{
					printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
						"$_rd_isp" "$1" "$2" "$3" "$4" "$5" "$_rd_j" "reject"
				} >> "$BASE/quarantine/$(date +%Y%m%d)/summary.tsv" 2>/dev/null || true
			fi
		fi
	done

	rm -rf "$_rd_tmp"
	[ "$_rd_rows" -gt 0 ] ||
		echo "（两源没有共同运营商文件，无法比较——检查 ISP_SOURCE_FILES 映射）"
	[ -n "$_rd_min" ] || _rd_min=""
	DIFF_MIN_JACCARD=$_rd_min
	DIFF_REJECTED=$_rd_bad
	DIFF_ROWS=$_rd_rows
	return $_rd_rc
}

# 单运营商地址数日变化（只比主源，同口径：原始文件 vs 上次存档）
_delta_check() { # <源名> <新目录>
	_dc_name=$1
	_dc_new=$2
	_dc_bad=""
	[ -d "$RAW_ARCHIVE/$_dc_name" ] || return 0
	echo "== 主源日变化（阈值 ${ISP_DIFF_MAX_DELTA_PCT}%）"
	printf '%-14s %10s %10s %8s\n' "运营商" "上次" "本次" "变化"
	for _dc_row in $ISP_TABLE; do
		case "$_dc_row" in \#*) continue ;; esac
		_row_check_row "$_dc_row"
		_dc_isp=${_dc_row%%:*}
		_dc_f=$(_source_file_of "$_dc_name" "$_dc_isp")
		if [ -z "$_dc_f" ]; then
			if _source_has_map "$_dc_name"; then
				case "$_dc_row" in
				*:1) _dc_f="$_dc_isp.txt" ;;
				*) continue ;;
				esac
			else
				_dc_f="$_dc_isp.txt"
			fi
		fi
		[ -f "$_dc_new/$_dc_f" ] && [ -f "$RAW_ARCHIVE/$_dc_name/$_dc_f" ] || continue
		_dc_old_n=$(awk -f "$LIB/cidr2range.awk" "$RAW_ARCHIVE/$_dc_name/$_dc_f" |
			awk '{ t += $2 - $1 + 1 } END { printf "%.0f", t }')
		_dc_new_n=$(awk -f "$LIB/cidr2range.awk" "$_dc_new/$_dc_f" |
			awk '{ t += $2 - $1 + 1 } END { printf "%.0f", t }')
		[ "${_dc_old_n:-0}" -gt 0 ] || continue
		_dc_pct=$(awk -v a="$_dc_old_n" -v b="$_dc_new_n" \
			'BEGIN { d = b - a; if (d < 0) d = -d; printf "%.1f", d * 100 / a }')
		printf '%-14s %10s %10s %7s%%\n' "$_dc_isp" "$_dc_old_n" "$_dc_new_n" "$_dc_pct"
		if awk -v p="$_dc_pct" -v t="$ISP_DIFF_MAX_DELTA_PCT" \
			'BEGIN { exit !(p + 0 > t + 0) }'; then
			_dc_bad="$_dc_bad $_dc_isp($_dc_pct%)"
		fi
	done
	if [ -n "$_dc_bad" ]; then
		echo "拒绝：单运营商日变化超过 ${ISP_DIFF_MAX_DELTA_PCT}%$_dc_bad"
		return 3
	fi
	return 0
}

# 源健康度区块（status / plan 共用）
_sources_report() {
	_source_check_all
	_state_prune
	echo "数据源健康度:"
	printf '  %-8s %-8s %-16s %6s %9s  %s\n' "源" "角色" "最后成功" "失败" "Jaccard" "状态"
	for _sr_row in $(_source_rows); do
		_source_split "$_sr_row"
		_sr_last=$(_state_get "$_s_name" last_ok)
		_sr_fail=$(_state_get "$_s_name" fail)
		_sr_jac=$(_state_get "$_s_name" jaccard)
		[ -n "${_sr_fail:-}" ] || _sr_fail=0
		_sr_state="正常"
		if [ "$_s_role" = "primary" ]; then
			[ "${_sr_fail:-0}" -gt 0 ] &&
				_sr_state="主源失败（已冻结，连续 $_sr_fail 天）"
		elif [ "$ISP_VERIFY_ENABLE" != "1" ]; then
			_sr_state="未启用校验（ISP_VERIFY_ENABLE=0）"
		elif [ "${_sr_fail:-0}" -ge "${ISP_SOURCE_MAX_FAIL_DAYS:-3}" ]; then
			_sr_state="拒绝更新（连续失败 $_sr_fail/$ISP_SOURCE_MAX_FAIL_DAYS 天）"
		elif [ "${_sr_fail:-0}" -gt 0 ]; then
			_sr_state="降级（连续失败 $_sr_fail/$ISP_SOURCE_MAX_FAIL_DAYS 天）"
		fi
		[ -n "${_sr_last:-}" ] || _sr_last=""
		if [ -n "${_sr_jac:-}" ]; then
			_sr_jac_s="$_sr_jac%"
		else
			_sr_jac_s="-"
		fi
		printf '  %-8s %-8s %-16s %6s %9s  %s\n' \
			"$_s_name" "$_s_role" "$(_ts_text "$_sr_last")" "$_sr_fail" \
			"$_sr_jac_s" "$_sr_state"
	done
	if [ -f "$DATA/.stamp" ]; then
		echo "  数据更新时间: $(_ts_text "$(cat "$DATA/.stamp" 2>/dev/null)")"
	fi
}

# 下次 cron 触发的绝对时间（空 = 表达式不合法/一年内不触发）
# 纯 awk 实现：路由器上没有 python，逐分钟调 date 又太慢（10 秒轮询受不了）。
_cron_next_epoch() {
	_cne_off=$(date +%z 2>/dev/null)
	[ -n "${_cne_off:-}" ] || _cne_off="+0000"
	awk -v expr="$1" -v now="$(date +%s)" -v off="$_cne_off" '
	function expand(f, lo, hi, arr,   n, p, k, r, h, a, b, st, i) {
		if (f == "") return 0
		n = split(f, p, ",")
		for (k = 1; k <= n; k++) {
			st = 1
			r = p[k]
			if (index(r, "/") > 0) { split(r, h, "/"); st = h[2] + 0; r = h[1] }
			if (r == "*") { a = lo; b = hi }
			else if (index(r, "-") > 0) { split(r, h, "-"); a = h[1] + 0; b = h[2] + 0 }
			else { a = r + 0; b = a }
			if (st < 1) st = 1
			if (a < lo) a = lo
			if (b > hi) b = hi
			for (i = a; i <= b; i += st) arr[i] = 1
		}
		return 1
	}
	function civil(z,   era, doe, yoe, y, doy, mp) {
		z += 719468
		era = int((z >= 0 ? z : z - 146096) / 146097)
		doe = z - era * 146097
		yoe = int((doe - int(doe / 1460) + int(doe / 36524) - int(doe / 146096)) / 365)
		y = yoe + era * 400
		doy = doe - (365 * yoe + int(yoe / 4) - int(yoe / 100))
		mp = int((5 * doy + 2) / 153)
		CD = doy - int((153 * mp + 2) / 5) + 1
		CM = mp + (mp < 10 ? 3 : -9)
		CY = y + (CM <= 2)
	}
	BEGIN {
		if (split(expr, f, " ") != 5) exit 1
		if (!expand(f[1], 0, 59, MIN)) exit 1
		if (!expand(f[2], 0, 23, HOUR)) exit 1
		if (!expand(f[3], 1, 31, DOM)) exit 1
		if (!expand(f[4], 1, 12, MON)) exit 1
		if (!expand(f[5], 0, 7, DOW)) exit 1
		if (DOW[7]) DOW[0] = 1
		dom_star = (f[3] ~ /^\*/)
		dow_star = (f[5] ~ /^\*/)
		sign = (substr(off, 1, 1) == "-") ? -1 : 1
		offsec = sign * (substr(off, 2, 2) * 3600 + substr(off, 4, 2) * 60)
		start = now + offsec
		z0 = int(start / 86400)
		for (d = 0; d <= 366; d++) {
			z = z0 + d
			civil(z)
			dow = (z + 4) % 7
			dok = DOM[CD]
			wok = DOW[dow]
			if (dom_star || dow_star) { if (!(dok && wok)) continue }
			else if (!(dok || wok)) continue
			if (!MON[CM]) continue
			for (h = 0; h <= 23; h++) {
				if (!HOUR[h]) continue
				for (m = 0; m <= 59; m++) {
					if (!MIN[m]) continue
					t = z * 86400 + h * 3600 + m * 60
					if (t <= start) continue
					print t - offsec
					exit
				}
			}
		}
		exit 1
	}'
}

# mwan3 运行时状态 -> TSV 行（L=接口 T=track P=策略成员）
_mwan3_lines() {
	command -v mwan3 >/dev/null 2>&1 || return 0
	mwan3 status 2>/dev/null | awk '
		$1 == "interface" { cur = $2; printf "L\t%s\t%s\t%s\n", $2, $4, $8; next }
		$1 == "track" {
			ip = $2; sub(/:$/, "", ip)
			printf "T\t%s\t%s\t%s\n", cur, ip, $3
			next
		}
		/^Current policies:/ { sec = 1; next }
		sec && /^[^[:space:]]/ {
			if ($0 ~ /^[A-Za-z0-9_.-]+:$/) { pol = $0; sub(/:$/, "", pol); next }
			sec = 0
		}
		sec && /^[[:space:]]+[A-Za-z0-9_.-]+ \([0-9]+%\)/ {
			iface = $1
			pct = $0; sub(/.*\(/, "", pct); sub(/%.*/, "", pct)
			printf "P\t%s\t%s\t%s\n", pol, iface, pct
			next
		}
	'
}

# A-01：下载前空间预检。overlay（$BASE）与 tmpfs 暂存各留 2×MAX_RAW_BYTES，
# 避免"下载到一半才发现写不下"。df 解析失败时只告警不阻断（老 busybox
# 的 df 输出列可能不同），真正的体积上限仍由 _fetch_stream 保证。
_fetch_avail_kb() {
	_fak=$(df -k -P "$1" 2>/dev/null | awk 'NR == 2 { print $4; exit }')
	case "${_fak:-}" in '' | *[!0-9]*) _fak="" ;; esac
	printf '%s' "$_fak"
}

_fetch_space_precheck() {
	_fsp_need=$(( (MAX_RAW_BYTES * 2 + 1023) / 1024 ))
	for _fsp_pair in "overlay:$BASE" "tmp:$FETCH_STAGE_ROOT"; do
		_fsp_label=${_fsp_pair%%:*}
		_fsp_dir=${_fsp_pair#*:}
		mkdir -p "$_fsp_dir" 2>/dev/null || {
			log "错误：无法创建 $_fsp_label 目录 $_fsp_dir"
			return 1
		}
		_fsp_kb=$(_fetch_avail_kb "$_fsp_dir")
		if [ -z "$_fsp_kb" ]; then
			log "警告：无法读取 $_fsp_label（$_fsp_dir）剩余空间，跳过空间预检"
			continue
		fi
		if [ "$_fsp_kb" -lt "$_fsp_need" ]; then
			log "错误：$_fsp_label（$_fsp_dir）剩余 ${_fsp_kb}KB，小于 2×源上限 ${_fsp_need}KB，拒绝下载"
			return 1
		fi
	done
	return 0
}

_cmd_fetch_body() {
	mkdir -p "$WORK" || die "无法创建 $WORK"

	# 源声明先校验，再联网：非法 URL/角色不该等到下载一半才发现。
	_source_check_all
	_state_prune
	case "${ISP_SOURCE_MAX_FAIL_DAYS:-}" in
	"" | 0 | *[!0-9]*)
		die "ISP_SOURCE_MAX_FAIL_DAYS 必须是正整数（当前: '${ISP_SOURCE_MAX_FAIL_DAYS:-}'）"
		;;
	esac

	_primary=$(_primary_source_name)
	_primary_base=$(_source_base_of "$_primary")
	[ -n "$_primary" ] || die "ISP_SOURCES 里没有 primary 源"
	[ -n "$_primary_base" ] ||
		die "主源缺少镜像基址：ISP_SOURCES 为空时必须配置 ISP_MIRROR"

	_force=${ISP_SPLIT_FORCE_FETCH:-0}

	case "$MAX_RAW_BYTES" in
	'' | *[!0-9]* | 0)
		die "ISP_SPLIT_MAX_RAW_BYTES 必须是正整数（当前: '$MAX_RAW_BYTES'）"
		;;
	esac
	# A-01：先查空间，再动任何暂存目录。
	_fetch_space_precheck ||
		die "磁盘剩余空间不足（overlay/tmpfs 需各 ≥ 2×$MAX_RAW_BYTES 字节），本次未下载"

	rm -rf "$WORK/raw.new" "$WORK/out.new" "$WORK/compose.new"
	mkdir -p "$WORK/raw.new"

	# A-01：下载先落 tmpfs。每个源的全部文件通过体积/行数/badcidr 校验后，
	# 才把该源目录 mv 进 $WORK/raw.new，overlay 不会在闸门前被灌满。
	FETCH_STAGE=$(mktemp -d "$FETCH_STAGE_ROOT/isp-split-fetch.XXXXXX" 2>/dev/null) ||
		die "无法创建下载暂存目录（$FETCH_STAGE_ROOT）"

	# ---- 1) 主源：失败即冻结（保留旧数据），绝不自动换源 ----
	log "主源: $_primary（$_primary_base）"
	if ! _fetch_source "$_primary" "$_primary_base" "$FETCH_STAGE/$_primary" primary; then
		_state_fail_plus "$_primary" primary "主源下载失败" >/dev/null
		die "主源 $_primary 下载失败，保留现有数据不动（冻结，cron 会在下次再试）"
	fi
	mv "$FETCH_STAGE/$_primary" "$WORK/raw.new/$_primary" ||
		die "无法把主源暂存目录移入 $WORK/raw.new，现有数据未改动"
	# 主源下载成功就先记一笔：后面的 diff/熔断即使拒绝更新，也不该让
	# status 把主源显示成"从未成功"。
	_state_put "$_primary" primary "$(date +%s)" 0 \
		"$(_state_get "$_primary" jaccard)" "主源下载正常（待校验）"

	# ---- 2) 校验源/审计源：默认不启用 ----
	_verify_ok=""
	_verify_fail=""
	for _row in $(_source_rows); do
		_source_split "$_row"
		case "$_s_role" in
		verify | audit) ;;
		*) continue ;;
		esac
		if [ "$ISP_VERIFY_ENABLE" != "1" ]; then
			log "跳过校验源 $_s_name（配置里显式 ISP_VERIFY_ENABLE=0）"
			continue
		fi

		log "校验源: $_s_name（$_s_base）"
		if _fetch_source "$_s_name" "$_s_base" "$FETCH_STAGE/$_s_name" "$_s_role"; then
			mv "$FETCH_STAGE/$_s_name" "$WORK/raw.new/$_s_name" ||
				die "无法把校验源暂存目录移入 $WORK/raw.new，现有数据未改动"
			_verify_ok="$_verify_ok $_s_name"
			_state_put "$_s_name" "$_s_role" "$(date +%s)" 0 \
				"$(_state_get "$_s_name" jaccard)" "校验源下载正常（待 diff）"
		else
			# 设计 §2.5 的分级降级：校验源挂了继续用主源，但连续失败
			# 达到 ISP_SOURCE_MAX_FAIL_DAYS 天就升级为"拒绝更新"，防止
			# 校验长期失效而无人察觉。
			_fail=$(_state_fail_plus "$_s_name" "$_s_role" "校验源不可用（已降级）")
			_verify_fail="$_verify_fail $_s_name"
			log "警告: 校验源 $_s_name 不可用（连续 $_fail/$ISP_SOURCE_MAX_FAIL_DAYS 天），本次降级为只用主源"
			if [ "$_fail" -ge "$ISP_SOURCE_MAX_FAIL_DAYS" ] && [ "$_force" != "1" ]; then
				log "错误: 校验源 $_s_name 已连续 $_fail 天不可用（>= $ISP_SOURCE_MAX_FAIL_DAYS），拒绝在无校验的情况下更新"
				echo "现有数据未改动。确认可接受无校验更新，可设 ISP_SPLIT_FORCE_FETCH=1 再试。"
				exit 3
			fi
		fi
	done

	# ---- 3) 闸门：日变化 + 与校验源的差异（都在写任何东西之前）----
	# 日变化闸门只跟"校验源已启用且拉到了"一起生效：单源兼容模式
	# （ISP_SOURCES 为空）不能凭空多出一道会拒更新的闸门，0.1.x 行为保持不变。
	if [ -n "$_verify_ok" ] && [ "$_force" != "1" ]; then
		if ! _delta_check "$_primary" "$WORK/raw.new/$_primary"; then
			echo "已拒绝本次更新（现有数据未改动）。确认数据无误可设 ISP_SPLIT_FORCE_FETCH=1 再试。"
			exit 3
		fi
	fi

	_diff_min_all=""
	for _vname in $_verify_ok; do
		_rd_ok=1
		_run_diff "$WORK/raw.new/$_primary" "$WORK/raw.new/$_vname" \
			"$_primary" "$_vname" reject || _rd_ok=0

		# 先把本次结果写进源健康度，再决定是否拒绝：
		# 被拒绝的那次也要能在 status 里看到"最近 Jaccard 掉到多少"。
		if [ "${DIFF_ROWS:-0}" -eq 0 ]; then
			_state_put "$_vname" "$(_source_role_of "$_vname")" "$(date +%s)" 0 \
				"$(_state_get "$_vname" jaccard)" "无可比对运营商（检查映射）"
		elif [ -n "${DIFF_MIN_JACCARD:-}" ]; then
			if [ "$_rd_ok" = "1" ]; then
				_jnote="校验源正常"
			else
				_jnote="分歧超阈值（ ${DIFF_REJECTED# }）"
			fi
			_state_put "$_vname" "$(_source_role_of "$_vname")" "$(date +%s)" 0 \
				"$DIFF_MIN_JACCARD" "$_jnote"
			if awk -v a="$_diff_min_all" -v b="$DIFF_MIN_JACCARD" \
				'BEGIN { exit !(a == "" || b + 0 < a + 0) }'; then
				_diff_min_all=$DIFF_MIN_JACCARD
			fi
		fi

		if [ "${DIFF_ROWS:-0}" -eq 0 ]; then
			if [ "$_force" = "1" ]; then
				echo
				log "警告: 校验源 $_vname 没有可比对的运营商，已按 ISP_SPLIT_FORCE_FETCH=1 放行"
			else
				echo
				echo "已拒绝本次更新：校验源 $_vname 没有可比对的运营商（检查 ISP_SOURCE_FILES 的文件名映射）。"
				echo "没有可比对项时无法证明主源没有漂移。确认可接受无校验更新，可设 ISP_SPLIT_FORCE_FETCH=1 再试。"
				exit 3
			fi
		elif [ "$_rd_ok" = "0" ]; then
			if [ "$_force" = "1" ]; then
				echo
				log "警告: 与校验源 $_vname 的分歧超过 ${ISP_DIFF_MAX_JACCARD_DROP}%（${DIFF_REJECTED}），已按 ISP_SPLIT_FORCE_FETCH=1 放行"
			else
				echo
				echo "已拒绝本次更新：与校验源的分歧超过 ${ISP_DIFF_MAX_JACCARD_DROP}%（$_vname:${DIFF_REJECTED}）。"
				echo "差异段已留档: $BASE/quarantine/$(date +%Y%m%d)/"
				echo "人工确认数据无误后，可用 ISP_SPLIT_FORCE_FETCH=1 isp-split fetch 放行。"
				exit 3
			fi
		fi
	done
	DIFF_MIN_JACCARD=$_diff_min_all

	# ---- 4) 合成（只有 primary 参与合成；校验源只跑 diff）----
	log "构建集合（互斥化）"
	_compose_raw "$_primary" "$WORK/raw.new/$_primary" "$WORK/compose.new" ||
		die "按 ISP_SOURCE_FILES 合成失败，保留现有数据不动"

	# 第三参数=全部行（保持 build.sh 的原始优先级序号与每个运营商的数据
	# 文件），第四参数=只有 enabled=1 的行参与 claimed 差集占位（F-27）。
	# 禁用行仍会生成自己的 .txt（重新启用后无需立刻重抓），但不会把地址
	# 从后面启用的运营商里扣走。
	ISP_SPLIT_LIB=$LIB sh "$LIB/build.sh" "$WORK/compose.new" "$WORK/out.new" \
		"$(priority_list)" "$(_enabled_src_list)" ||
		die "构建失败，保留现有数据不动"

	# 至少要有启用集合的非空产物，否则视为构建异常
	_ok=0
	_chk() { [ -s "$WORK/out.new/$1.txt" ] && _ok=1; }
	foreach_enabled _chk
	[ "$_ok" -eq 1 ] || die "构建产物为空，拒绝覆盖"

	# 熔断：单个集合地址数超过 IPv4 总量 50% 时拒绝本次更新。
	# 正常运营商数据远低于该比例；出现这种结果通常意味着坏 CIDR 或源被污染。
	_fetch_guard() {
		_gf=$WORK/out.new/$1.txt
		[ -s "$_gf" ] || return 0
		if ! awk '
			{
				split($0, p, "/")
				if (p[2] !~ /^[0-9]+$/) next
				t += 2 ^ (32 - (p[2] + 0))
			}
			END { exit (t > 2147483648 ? 1 : 0) }
		' "$_gf"; then
			die "集合 $2 地址数超过 IPv4 总量的 50%，拒绝本次更新"
		fi
	}
	foreach_enabled _fetch_guard

	# 熔断（续）：单集合 50% 只挡得住"一步到位"的投毒，两种方式能绕过：
	#   1) 把 60% 的地址平分给 3 个集合，每个都不到 50%；
	#   2) 先给正常数据，再每天放大一点，永远不触发 50%。
	# 这里补两条上限：总量绝对值（25% IPv4）与相对上一份数据的增幅（2 倍）。
	# 运营商表变化时跳过增幅比较——启用/停用运营商会合法地改变总量。
	# 两条都能用 ISP_SPLIT_FORCE_FETCH=1 显式放行（源站大改版时的逃生口）。
	_addr_total=0
	_addr_add() {
		[ -s "$1" ] || return 0
		_at_v=$(awk '
			{
				split($0, p, "/")
				if (p[2] !~ /^[0-9]+$/) next
				t += 2 ^ (32 - (p[2] + 0))
			}
			END { printf "%.0f", t }
		' "$1" 2>/dev/null)
		_addr_total=$((_addr_total + ${_at_v:-0}))
	}
	_addr_new() { _addr_add "$WORK/out.new/$1.txt"; }
	_addr_old() { _addr_add "$DATA/$1.txt"; }

	_addr_total=0
	foreach_enabled _addr_new
	_new_total=$_addr_total
	_addr_total=0
	foreach_enabled _addr_old
	_old_total=$_addr_total

	if [ "$_new_total" -gt 1073741824 ] && [ "$_force" != "1" ]; then
		die "所有集合合计 $_new_total 个地址，超过 IPv4 总量的 25%，拒绝本次更新（疑似源数据被污染；确认数据无误可设 ISP_SPLIT_FORCE_FETCH=1 再试）"
	fi
	_sets_prev=$(cat "$DATA/.sets" 2>/dev/null || true)
	if [ "$_old_total" -gt 0 ] && [ -n "$_sets_prev" ] &&
		[ "$_sets_prev" = "$(_enabled_src_list)" ] &&
		[ "$_new_total" -gt $((_old_total * 2)) ] && [ "$_force" != "1" ]; then
		die "本次数据地址数比上一份翻倍以上（$_old_total -> $_new_total），拒绝本次更新（疑似分步投毒；确认数据无误可设 ISP_SPLIT_FORCE_FETCH=1 再试）"
	fi

	mkdir -p "$BASE/.history"
	if [ -d "$DATA" ]; then
		_ts=$(date +%Y%m%d-%H%M%S)
		# 快照失败（磁盘满）时必须中止：否则新数据写坏了也没有东西可回滚。
		cp -a "$DATA" "$BASE/.history/$_ts" ||
			die "无法创建历史快照（磁盘空间不足？），现有数据未改动"
		# 只保留最近 3 份历史快照
		_cnt=$(ls -1d "$BASE/.history"/*/ 2>/dev/null | wc -l | tr -d ' ')
		if [ "$_cnt" -gt 3 ]; then
			ls -1d "$BASE/.history"/*/ 2>/dev/null | sort |
				head -n $((_cnt - 3)) |
				while read -r _d; do rm -rf "$_d"; done
		fi
	fi

	# 先在暂存目录里准备好整套数据并校验，最后整体替换目录。
	# 旧写法是 `rm -f $DATA/*.txt` + `cp`：磁盘满时 cp 写一半失败没人看，
	# 现场留下"半套数据 + 旧 .stamp"，fetch 照样打印"数据已更新"并返回 0，
	# 下一次 apply 就会把没拷全的集合静默清空。
	_new=$WORK/data.new
	rm -rf "$_new"
	mkdir -p "$_new" || die "无法创建暂存目录 $_new"
	cp "$WORK/out.new"/*.txt "$_new/" ||
		{ rm -rf "$_new"; die "写入新数据失败（磁盘空间不足？），现有数据未改动"; }
	date +%s > "$_new/.stamp" ||
		{ rm -rf "$_new"; die "无法写入数据时间戳，现有数据未改动"; }
	# 记录本次启用的运营商，下次 fetch 用它判断"表变了没有"
	_enabled_src_list > "$_new/.sets" ||
		{ rm -rf "$_new"; die "无法写入运营商列表，现有数据未改动"; }

	_chk_new() {
		[ -s "$_new/$1.txt" ] || {
			rm -rf "$_new"
			die "构建产物 $1.txt 为空或缺失，拒绝覆盖现有数据"
		}
	}
	foreach_enabled _chk_new

	_old=$BASE/data.old
	rm -rf "$_old"
	if [ -d "$DATA" ]; then
		mv "$DATA" "$_old" || { rm -rf "$_new"; die "无法替换数据目录，现有数据未改动"; }
	fi
	if ! mv "$_new" "$DATA"; then
		[ -d "$_old" ] && mv "$_old" "$DATA"
		die "无法替换数据目录，已恢复原数据"
	fi
	rm -rf "$_old"

	# 主源存档留到最后：更新成功了才把本次数据当作"上一份"，
	# 失败/拒绝时存档保持不动，日变化与 diff 才有正确的比较基准。
	_archive_source "$_primary" "$WORK/raw.new/$_primary" ||
		log "警告: 主源存档失败（不影响本次更新，但下次的日变化比较会退化为无基准）"
	for _vname in $_verify_ok; do
		_archive_source "$_vname" "$WORK/raw.new/$_vname" ||
			log "警告: 校验源 $_vname 存档失败（不影响本次更新）"
	done

	# 源健康度落盘（主源成功）
	_state_put "$_primary" primary "$(date +%s)" 0 "${DIFF_MIN_JACCARD:-}" "正常"

	if [ -n "${DIFF_MIN_JACCARD:-}" ]; then
		mkdir -p "$STATE_DIR" 2>/dev/null || true
		printf '%s\t%s\n' "$DIFF_MIN_JACCARD" "$(date +%s)" \
			> "$STATE_DIR/last-diff" 2>/dev/null || true
	fi

	log "数据已更新：$(wc -l "$DATA"/*.txt | tail -1)"
}

# A-01：暂存目录由 EXIT/INT/TERM 兜底清理（die 也会走到 EXIT trap）。
FETCH_STAGE=""
_fetch_stage_cleanup() {
	[ -n "${FETCH_STAGE:-}" ] || return 0
	rm -rf "$FETCH_STAGE" 2>/dev/null || true
	FETCH_STAGE=""
}

# F-28：fetch 的固定 work 路径（raw.new/out.new/compose.new/data.new）
# 必须串行使用。锁在整段 fetch 期间持有；失败路径由 die/EXIT trap 释放。
cmd_fetch() {
	_op_lock_acquire || die "另一个 isp-split 操作正在运行（锁: $OP_LOCK），本次未做任何改动；稍后重试"
	trap '_fetch_stage_cleanup; _op_lock_cleanup' EXIT
	trap '_fetch_stage_cleanup; _op_lock_cleanup; exit 130' INT TERM
	_cmd_fetch_body
	_rc=$?
	_fetch_stage_cleanup
	_op_lock_release
	trap '_op_lock_cleanup' EXIT
	trap '_op_lock_cleanup; exit 130' INT TERM
	return $_rc
}

# ---------------------------------------------------------------- diff

# 重新计算"主源 vs 校验源"的分歧（基于 $BASE/raw 存档，不联网）。
# 分歧超过 ISP_DIFF_MAX_JACCARD_DROP 时返回 3；差异段写 quarantine/<日期>/。
cmd_diff() {
	_save=always
	case "${1:-}" in
	--no-save) _save=never ;;
	"") ;;
	*) die "用法: isp-split diff [--no-save]" ;;
	esac
	_source_check_all
	_state_prune
	_p=$(_primary_source_name)
	[ -d "$RAW_ARCHIVE/$_p" ] ||
		die "还没有主源存档（$RAW_ARCHIVE/$_p）。先运行 isp-split fetch 拉一份数据"

	_rc=0
	_any=0
	for _row in $(_source_rows); do
		_source_split "$_row"
		[ "$_s_role" = "primary" ] && continue
		_any=1
		if [ ! -d "$RAW_ARCHIVE/$_s_name" ]; then
			echo "== 校验源 $_s_name: 无存档（从未成功拉取）；启用后先跑一次 fetch"
			continue
		fi
		_run_diff "$RAW_ARCHIVE/$_p" "$RAW_ARCHIVE/$_s_name" \
			"$_p" "$_s_name" "$_save" || _rc=3
	done
	[ "$_any" = "1" ] ||
		echo "ISP_SOURCES 里没有校验源（只有 primary），没有可比较的对象。"

	if [ "$_rc" -eq 3 ]; then
		echo
		echo "分歧超过阈值 ${ISP_DIFF_MAX_JACCARD_DROP}%：按设计 fetch 会拒绝本次更新（退出码 3）。"
		[ "$_save" = "always" ] &&
			echo "差异段已留档: $BASE/quarantine/$(date +%Y%m%d)/"
	fi
	return $_rc
}

# ---------------------------------------------------------------- dnstest

# 单条线路的 DNS 可达性探测（LuCI 配置页的"测线"按钮）
cmd_dnstest() {
	_pol=${1:-}
	_json=0
	[ "${2:-}" = "--json" ] && _json=1
	[ -n "$_pol" ] || die "用法: isp-split dnstest <策略名> [--json]"
	_valid_policy "$_pol" || die "策略名 '$_pol' 非法"
	_TARGET=$_pol
	_dns=$(dns_of_policy "$_pol")
	[ -n "$_dns" ] || die "DNS_TABLE 里没有策略 '$_pol' 的 DNS 服务器"
	_valid_ipv4 "$_dns" || die "DNS_TABLE 里策略 '$_pol' 的 DNS '$_dns' 不是合法 IPv4"
	_dev=$(policy_device "$_pol")

	_ok=false
	_ans=""
	if command -v nslookup >/dev/null 2>&1; then
		if command -v timeout >/dev/null 2>&1; then
			_out=$(timeout 6 nslookup www.example.com "$_dns" 2>/dev/null)
		else
			_out=$(nslookup www.example.com "$_dns" 2>/dev/null)
		fi
		_ans=$(printf '%s\n' "$_out" | awk '/^Address: / { print $2 }' | grep -v ':' | head -1)
		[ -n "$_ans" ] && _ok=true
	fi

	if [ "$_json" = "1" ]; then
		printf '{"policy":"%s","dns":"%s","device":"%s","ok":%s,"answer":"%s"}\n' \
			"$_pol" "$_dns" "${_dev:-}" "$_ok" "${_ans:-}"
	else
		printf '策略 %s -> 线路 %s，DNS %s\n' "$_pol" "${_dev:-未知}" "$_dns"
		if [ "$_ok" = "true" ]; then
			printf '  结果: 可用（www.example.com -> %s）\n' "$_ans"
		else
			printf '  结果: 不可用（DNS 无响应或 nslookup 不可用）\n'
		fi
	fi
	[ "$_ok" = "true" ] || return 1
	return 0
}

# ---------------------------------------------------------------- apply

# 把一个大集合分块写进 nft 批处理文件，避免单行过长
_emit_elements() {
	_set=$1
	_file=$2
	awk -v set="$_set" -v tbl="$TABLE" '
		{
			buf = (buf == "") ? $1 : buf ", " $1
			n++
			if (n % 400 == 0) {
				printf "add element inet %s %s { %s }\n", tbl, set, buf
				buf = ""
			}
		}
		END {
			if (buf != "")
				printf "add element inet %s %s { %s }\n", tbl, set, buf
		}
	' "$_file"
}

_apply_one() {
	_src=$1
	_set=$2
	_file=$DATA/$_src.txt

	if [ "$TABLE" = "mwan3" ] && nft list set inet mwan3 "$_set" >/dev/null 2>&1; then
		# 即使数据文件为空也必须 flush：否则上游当天把该运营商完全
		# 覆盖（或源返回空）时，旧网段会永远留在 nft 里继续分流。
		echo "flush set inet $TABLE $_set" >> "$BATCH"
	else
		[ "$TABLE" = "mwan3" ] && log "集合 $_set 不存在，创建"
		# 按 mwan3 的声明方式创建，保证标志位一致，
		# 否则下次 mwan3 reload 会判定标志不同而删掉重建。
		echo "add set inet $TABLE $_set { type ipv4_addr; flags interval; auto-merge; counter; }" \
			>> "$BATCH"
	fi

	if [ -s "$_file" ]; then
		_emit_elements "$_set" "$_file" >> "$BATCH"
	else
		log "集合 $_set 无数据，已清空"
	fi
}

# 生成 nft 批处理语句
#   $1 输出文件；$2 表名（mwan3 或自检用的独立表）；$3=1 时先建表
_gen_batch() {
	_gen_out=$1
	_gen_tbl=${2:-mwan3}
	_gen_new=${3:-0}
	: > "$_gen_out"
	TABLE=$_gen_tbl
	BATCH=$_gen_out
	[ "$_gen_new" = "1" ] && echo "add table inet $_gen_tbl" >> "$_gen_out"
	_apply_one_all() { _apply_one "$@"; }
	foreach_enabled _apply_one_all
}

# 兜底流量计数器：插在本工具规则块之后、既有端口/兜底规则之前。
# 命中集合的流量在前面的 jump 里已离开本链，能走到这条规则的
# 必然是未命中任何启用集合的流量，从而可以量化"覆盖率"。
#
# 是否把 DNS（udp/tcp 任一侧 53）排除在兜底统计之外。默认 1=排除：
# DNS 查询/响应本身不是业务流量（网关本地应答或运营商解析开销），计进
# 兜底只会稀释"未命中运营商集合的真实业务流量"这个口径。写 0 保持旧行为。
_fallback_skip_dns() {
	[ "${COUNT_FALLBACK_SKIP_DNS:-1}" != "0" ]
}

# 判断链上的兜底计数规则是否已经是当前配置期望的形状。
# 传入的必须是已经过滤过 counter name "isp_fallback" 的行。
# nft list chain 会渲染 counter packets N bytes N / # handle N，比较前先去掉。
_fallback_rule_ok() {
	_fbo_norm=$(printf '%s\n' "$1" |
		sed 's/ # handle [0-9][0-9]*$//; s/ packets [0-9][0-9]* bytes [0-9][0-9]*//g')
	_fbo_n=$(printf '%s\n' "$_fbo_norm" |
		grep -c 'counter name "isp_fallback"' || true)
	_fbo_n=${_fbo_n:-0}
	if _fallback_skip_dns; then
		# 为什么是两条：nft 不接受一条规则里同时出现 udp dport 和 tcp dport
		# （conflicting transport layer protocols）。拆成
		#   A) 非 tcp/udp：ICMP 等其他协议照常计数
		#   B) tcp/udp 且 dport != 53 且 sport != 53
		# B 必须同时排除两侧 53：DNS 查询 dport=53，DNS 响应 sport=53
		# （WAN→LAN）——只排除 dport 会漏掉响应包（BUG-21 残留半修）。
		# nft 1.1.6 接受一条规则里写两个 th 不等式，故仍是两条规则。
		# 两条规则共用同一个命名计数器，计数自动汇总。
		# 两条都必须带 ct state new：否则未打标的连接会按包重复计数。
		[ "$_fbo_n" -eq 2 ] || return 1
		_fbo_other=$(printf '%s\n' "$_fbo_norm" |
			grep -F 'meta l4proto != tcp meta l4proto != udp counter name "isp_fallback"' |
			grep -F 'ct state new' | head -1)
		[ -n "$_fbo_other" ] || return 1
		printf '%s\n' "$_fbo_other" |
			grep -qE 'meta nfproto ipv4 .*meta mark (&|and) 0x0*3f00 == 0x0+' ||
			return 1
		# 不依赖 dport/sport 的渲染顺序：同一行里两个条件都出现即可。
		_fbo_dns=$(printf '%s\n' "$_fbo_norm" |
			grep -F 'th dport != 53' |
			grep -F 'th sport != 53' | head -1)
		[ -n "$_fbo_dns" ] || return 1
		case "$_fbo_dns" in
		*'ct state new'*) ;;
		*) return 1 ;;
		esac
		# nft 通常按书写顺序渲染集合；两种顺序都认，避免老版本
		# 归一化顺序后每次 apply 都误判成"形状不对"而重建。
		case "$_fbo_dns" in
		*'meta l4proto { tcp, udp }'* | *'meta l4proto { udp, tcp }'*) ;;
		*) return 1 ;;
		esac
		printf '%s\n' "$_fbo_dns" |
			grep -qE 'meta nfproto ipv4 .*meta mark (&|and) 0x0*3f00 == 0x0+' ||
			return 1
	else
		# COUNT_FALLBACK_SKIP_DNS=0：单条规则，不排除 DNS，但同样只计首包。
		[ "$_fbo_n" -eq 1 ] || return 1
		printf '%s\n' "$_fbo_norm" |
			grep -qE 'meta nfproto ipv4 .*meta mark (&|and) 0x0*3f00 == 0x0+ .*ct state new .*counter name "isp_fallback"' ||
			return 1
		printf '%s\n' "$_fbo_norm" | grep -q 'meta l4proto' && return 1
	fi
	return 0
}

# 删除链上所有 isp_fallback 计数规则。删除循环加上限，避免 nft 删除
# 返回成功但规则实际仍在时无限循环。
_fallback_drop_rules() {
	_fbd_i=0
	while [ "$_fbd_i" -lt 10 ]; do
		_fbd_h=$(nft -a list chain inet mwan3 mwan3_rules 2>/dev/null |
			grep 'counter name "isp_fallback"' |
			grep -oE '# handle [0-9]+' | grep -oE '[0-9]+' | head -1)
		[ -n "$_fbd_h" ] || return 0
		nft delete rule inet mwan3 mwan3_rules handle "$_fbd_h" 2>/dev/null || return 1
		_fbd_i=$((_fbd_i + 1))
	done
	! nft -a list chain inet mwan3 mwan3_rules 2>/dev/null |
		grep -q 'counter name "isp_fallback"'
}

_fallback_counter() {
	[ "$COUNT_FALLBACK" = "1" ] || return 0
	nft list chain inet mwan3 mwan3_rules >/dev/null 2>&1 || return 0

	if ! nft list counter inet mwan3 isp_fallback >/dev/null 2>&1; then
		nft add counter inet mwan3 isp_fallback 2>/dev/null || return 0
	fi

	# 幂等：规则已存在且形状正确（mark==0、ct state new、IPv4、命名计数器、
	# DNS 条件）就不动它。旧版单条规则（无 mark 条件或未排除 DNS）、只排
	# dport=53 的半修形状（漏 DNS 响应）、以及缺少 ct state new 的按包计数
	# 形状，都需要重建。
	_fb_lines=$(nft -a list chain inet mwan3 mwan3_rules 2>/dev/null |
		grep 'counter name "isp_fallback"')
	if _fallback_rule_ok "$_fb_lines"; then
		return 0
	fi

	# 只在缺失/重复/形状不对时重建。
	_fallback_drop_rules || return 1

	# 插入点：最后一条 daddr @isp_* 规则的下一条
	_h=$(nft -a list chain inet mwan3 mwan3_rules 2>/dev/null |
		sed -n '/chain mwan3_rules/,/^	}/p' |
		awk '/daddr @isp_/ { seen = 1; next }
		     seen && /# handle/    { print $NF; exit }')

	# ct state new 是兜底计数的口径锚点（BUG-21 收尾，FIX23）：
	# 这个计数器的语义是"未命中任何集合的新连接数"，status 也是这么写的。
	# 但 `meta mark & 0x3f00 == 0` 只说明"这个包进来时还没打标"，不保证它
	# 接下来一定会被打标：如果整条连接最终没拿到 conntrack 标记（例如
	# mwan3 的 default_rule_v4 被 enabled=0 关闭，或策略链什么都没命中），
	# 同一连接的每个包都会重新走一遍本链，兜底计数就从"按连接"退化成
	# "按包"，数字被放大任意倍（VM 实测：一条未打标的 ICMP 流 ping 5 次，
	# 旧形状计 +5，加 ct state new 后计 +1）。
	# 加 ct state new 后，不论后续是否打标，同一连接只计首包一次。
	_FB_BASE='meta nfproto ipv4 meta mark & 0x00003f00 == 0x00000000 ct state new'
	if _fallback_skip_dns; then
		_COND_OTHER="$_FB_BASE meta l4proto != tcp meta l4proto != udp counter name \"isp_fallback\""
		_COND_DNS="$_FB_BASE meta l4proto { tcp, udp } th dport != 53 th sport != 53 counter name \"isp_fallback\""
		if [ -n "$_h" ]; then
			# 连续两次插到同一个锚点前：先 OTHER 后 DNS，两条保持相邻。
			if nft insert rule inet mwan3 mwan3_rules position "$_h" \
				"$_COND_OTHER" 2>/dev/null &&
				nft insert rule inet mwan3 mwan3_rules position "$_h" \
					"$_COND_DNS" 2>/dev/null; then
				return 0
			fi
			_fallback_drop_rules
			return 1
		fi
		# 没找到位置（本工具规则在链尾或版本差异）就追加到链尾
		if nft add rule inet mwan3 mwan3_rules "$_COND_OTHER" 2>/dev/null &&
			nft add rule inet mwan3 mwan3_rules "$_COND_DNS" 2>/dev/null; then
			return 0
		fi
		_fallback_drop_rules
		return 1
	fi

	# COUNT_FALLBACK_SKIP_DNS=0：保持 0.1.x/0.2.x 的单条旧形状。
	_COND="$_FB_BASE counter name \"isp_fallback\""
	if [ -n "$_h" ]; then
		nft insert rule inet mwan3 mwan3_rules position "$_h" \
			"$_COND" 2>/dev/null && return 0
	fi
	# 没找到位置（本工具规则在链尾或版本差异）就追加到链尾
	nft add rule inet mwan3 mwan3_rules "$_COND" 2>/dev/null || true
}

_cmd_apply_body() {
	_openclash_fakeip_warn
	need_nft
	[ -f "$DATA/.stamp" ] || die "还没有数据，先执行: isp-split fetch"

	# mwan3 reload 返回后 nft 链可能仍在重建：此时 flush/add element 与
	# DNS 旁路都会落到旧链上，随后被 mwan3 的 batch flush 掉。
	# 先等 mwan3_output 真正就绪，再动集合。
	_ap_waited=0
	if ! _mwan3_output_ready; then
		_ap_waited=1
		log "等待 mwan3 重建完成…"
		_mwan3_wait_ready ||
			die "mwan3 在 ${MWAN3_READY_TRIES}s 内未就绪（mwan3_output 缺少 hook output 或 jump mwan3_rules），请检查 mwan3 日志后重试 apply"
		log "mwan3 已就绪，继续 apply"
	fi

	# 集合由 wire 写入的 config ipset 在 mwan3 reload 时建立。
	# apply 的 batch 只 flush/add element，不含 add set——集合缺失时
	# nft 的报错是模糊的"批量加载失败"，这里给出明确指引。
	# 取第一个 enabled=1 的行，而不是无条件取第一行：用户禁用第一个
	# 运营商后，wire 不会为它建集合，旧写法会让每日 refresh 永久失败。
	_fs=""
	for _row in $(_conf_table ISP_TABLE 2>/dev/null); do
		_old_ifs=$IFS
		IFS=':'
		set -- $_row
		IFS=$_old_ifs
		case "$_row" in \#*) continue ;; esac
		[ $# -ge 4 ] || die "ISP_TABLE 行格式错误（应为 源:集合:策略:0|1）: $_row"
		_row_check "$1" "$2" "$3" "$4"
		[ "$4" = "1" ] || continue
		_fs=$2
		break
	done
	[ -n "$_fs" ] || die "ISP_TABLE 中没有启用的运营商（第 4 列为 1），请先修改 $CONF"
	if ! nft list set inet mwan3 "$_fs" >/dev/null 2>&1; then
		die "nft 中不存在集合 $_fs —— 集合由 wire 创建。先执行: isp-split wire（或 isp-split sync）"
	fi
	_work_writable || die "work 目录不可写（$WORK），未做任何改动"
	_gen_batch "$WORK/batch.nft" mwan3
	[ -s "$WORK/batch.nft" ] || die "没有可应用的集合"

	# nft -f 对整份文件是一个事务：要么全成功，要么全不变。
	nft -f "$WORK/batch.nft" || die "nft 批量加载失败，线上规则未改动"
	# 兜底计数规则失败以前是静默的：status 里"兜底"行直接消失，用户只会
	# 看到数字不再增长，很容易误判成"没有兜底流量"。至少留一条可查的日志。
	_fallback_counter ||
		log "警告：兜底计数规则未安装成功（status 的兜底行会缺失或为旧值），可稍后重跑 isp-split apply"
	# 任何一次 mwan3 reload 都会 flush mwan3_output，DNS 旁路规则会丢；
	# apply/refresh 是日常最常跑的入口，这里也重放一次。
	# 失败不能再只打警告：apply 必须让调用方看到非 0，否则 cron/LuCI
	# 会以为 DNS 旁路已恢复。
	_dns_bypass 1 "$_ap_waited" ||
		die "DNS 旁路规则恢复失败（mwan3 未就绪或 DNS_TABLE 配置错误，见上方日志）"
	log "已生效"
}

# F-28：apply 与 fetch/wire 共用 WORK（batch.nft）和 nft 事务，
# 与 fetch 的 DATA 替换、wire 的 reload 必须互斥。
cmd_apply() {
	_op_lock_acquire || die "另一个 isp-split 操作正在运行（锁: $OP_LOCK），本次未做任何改动；稍后重试"
	_cmd_apply_body
	_rc=$?
	_op_lock_release
	return $_rc
}

# 干跑校验：让 nft 完整解析并检查，但不提交任何改动。
# 用来提前发现"某条元素非法导致整批被拒"这类失败模式。
cmd_check() {
	need_nft
	[ -f "$DATA/.stamp" ] || die "还没有数据，先执行: isp-split fetch"
	_work_writable || die "work 目录不可写（$WORK），未做任何改动"
	_gen_batch "$WORK/batch.nft" mwan3
	[ -s "$WORK/batch.nft" ] || die "没有可应用的集合"

	if nft -c -f "$WORK/batch.nft" 2>"$WORK/check.err"; then
		log "干跑校验通过：$(wc -l < "$WORK/batch.nft") 条语句，未提交任何改动"
	else
		log "干跑校验失败，未提交任何改动。"
		echo "---- nft 报错（原始）----" >&2
		head -6 "$WORK/check.err" >&2

		# nft 对非法条目只会说 "Could not resolve hostname"，对用户毫无指向性。
		# 这里再扫一遍数据文件，把具体哪一行有问题指出来。
		echo "---- 数据文件自检 ----" >&2
		_badfound=0
		_bc() {
			_src=$1
			_set=$2
			_f="$DATA/$_src.txt"
			[ -s "$_f" ] || return 0
			_o=$(awk -f "$LIB/badcidr.awk" "$_f" 2>/dev/null | head -5)
			if [ -n "$_o" ]; then
				echo "  $_f:" >&2
				printf '%s\n' "$_o" | sed 's/^/    /' >&2
				_badfound=1
			fi
		}
		foreach_enabled _bc
		if [ "$_badfound" = "0" ]; then
			echo "  数据文件本身格式都合法，问题可能出在集合名或 nft 版本差异上。" >&2
		fi
		return 1
	fi
}

# 实弹自检：在一张独立的临时表里真跑一遍，测完立刻删除。
# inet mwan3 全程不受影响，也验证了真实加载耗时与元素数。
cmd_selftest() {
	need_nft
	[ -f "$DATA/.stamp" ] || die "还没有数据，先执行: isp-split fetch"
	_work_writable || die "work 目录不可写（$WORK），未做任何改动"
	log "在独立表 inet isptest 中实测加载（inet mwan3 不受影响）"

	nft delete table inet isptest 2>/dev/null
	_gen_batch "$WORK/selftest.nft" isptest 1
	_t0=$(date +%s)
	if ! nft -f "$WORK/selftest.nft"; then
		nft delete table inet isptest 2>/dev/null
		die "自检加载失败，临时表已清理"
	fi
	_t1=$(date +%s)

	_total=0
	_cidr=0
	_st() {
		_n=$(count_set_elements isptest "$2")
		_c=$(wc -l < "$DATA/$1.txt" 2>/dev/null | tr -d ' ')
		printf '  %-14s 输入 %5s 条 CIDR -> 内核 %5s 个元素\n' "$2" "${_c:-0}" "$_n"
		_total=$((_total + _n))
		_cidr=$((_cidr + ${_c:-0}))
	}
	foreach_enabled _st
	echo "  ----"
	echo "  合计：输入 $_cidr 条 CIDR，内核合并为 $_total 个元素，加载耗时 $((_t1 - _t0)) 秒"
	echo "  （元素数少于 CIDR 数是正常的：auto-merge 会把相邻网段合并成区间）"

	nft delete table inet isptest 2>/dev/null
	log "自检完成，临时表已删除"
}


# ---------------------------------------------------------------- wire

# 段名冲突保护（SEC S8，与 init 的 policy/member 保护同一口径）：
# 同名段已存在但段内没有 isp_split=1，说明是用户/其它工具的配置，
# 绝不静默改写、合并或打上我们的标记（否则 uninstall 会把它删掉）。
_wire_ipset_check() {
	_wi_set=$1
	_wi_sec="ipset_$_wi_set"
	_wi_type=$(uci -q get "mwan3.$_wi_sec" 2>/dev/null)
	[ -n "$_wi_type" ] || return 0
	if [ "$_wi_type" != "ipset" ]; then
		die "mwan3 中已存在同名段 '$_wi_sec'（类型 $_wi_type，不是 ipset）；为保护现有配置，wire 不做改动（冲突段名: mwan3.$_wi_sec；请先在 mwan3 中删除/改名，或改用其它集合名）"
	fi
	[ "$(uci -q get "mwan3.$_wi_sec.isp_split" 2>/dev/null)" = "1" ] ||
		die "mwan3 中已存在同名 ipset 段 '$_wi_sec' 且没有 isp_split 标记；为保护现有配置，wire 不做改动（冲突段名: mwan3.$_wi_sec；请先在 mwan3 中删除/改名，或改用其它集合名）"
	return 0
}

# 生成一个 ipset 段的配置文本。候选文件由文本级重写生成（不经过
# uci commit），这样 splice/解析校验全部通过之前不会落盘任何东西。
_wire_ipset_block() {
	_wb_src=$1
	_wb_set=$2
	_wb_file=$DATA/$_wb_src.txt
	case "$_wb_file" in
	*"'"*) die "数据文件路径含单引号，无法安全写入 mwan3 配置: $_wb_file" ;;
	esac
	printf "config ipset 'ipset_%s'\n" "$_wb_set"
	printf "\toption name '%s'\n" "$_wb_set"
	printf "\toption family 'ipv4'\n"
	printf "\toption counters '1'\n"
	printf "\toption loadfile '%s'\n" "$_wb_file"
	printf "\toption enabled '1'\n"
	# 精确标记：rules-splice/uninstall 只按它识别本工具的段，
	# 不再按 isp_ 前缀误伤用户自己的 rule。
	printf "\toption isp_split '1'\n"
	printf "\n"
}

# 在候选配置里重写某个集合的 ipset 段：删掉同名旧块（只可能是带标记的
# 本工具段，冲突已在 _wire_ipset_check 里 die），再追加一份完整的新块。
_wire_ipset_text() {
	_wt_src=$1
	_wt_set=$2
	_wt_sec="ipset_$_wt_set"
	_wt_file=$WORK/mwan3.base
	_wt_tmp=$WORK/mwan3.base.tmp
	awk -v want="$_wt_sec" '
		BEGIN { sq = "\047"; dq = "\"" }
		function ours(   nm) {
			if ($1 != "config" || $2 != "ipset") return 0
			nm = $3
			return (nm == want || nm == sq want sq || nm == dq want dq)
		}
		ours() { skip = 1; next }
		/^[[:space:]]*config[[:space:]]/ { skip = 0 }
		skip { next }
		{ print }
	' "$_wt_file" > "$_wt_tmp" || return 1
	mv -f "$_wt_tmp" "$_wt_file" || return 1
	# 文件末尾没有换行时先补一个，避免新块粘在上一行末尾。
	if [ -s "$_wt_file" ] && [ -n "$(tail -c 1 "$_wt_file" 2>/dev/null)" ]; then
		printf '\n' >> "$_wt_file" || return 1
	fi
	_wire_ipset_block "$_wt_src" "$_wt_set" >> "$_wt_file" || return 1
	return 0
}

_wire_ipset() {
	_src=$1
	_set=$2
	_sec="ipset_$_set"
	_wire_ipset_check "$_set"
	if ! uci -q get "mwan3.$_sec" >/dev/null 2>&1; then
		uci set "mwan3.$_sec=ipset" ||
			die "无法创建 mwan3 ipset 段 '$_sec'"
	fi
	uci set "mwan3.$_sec.name=$_set" || die "无法写入 mwan3.$_sec.name"
	uci set "mwan3.$_sec.family=ipv4" || die "无法写入 mwan3.$_sec.family"
	uci set "mwan3.$_sec.counters=1" || die "无法写入 mwan3.$_sec.counters"
	uci set "mwan3.$_sec.loadfile=$DATA/$_src.txt" ||
		die "无法写入 mwan3.$_sec.loadfile"
	uci set "mwan3.$_sec.enabled=1" || die "无法写入 mwan3.$_sec.enabled"
	uci set "mwan3.$_sec.isp_split=1" || die "无法写入 mwan3.$_sec.isp_split"
}

_write_rule() {
	_src=$1
	_set=$2
	_pol=$3
	# 规则段名与集合名相同（mwan3.<集合名>）。若用户已有同名 rule
	# 段且没有我们的标记，splice 会把两段合并、用户的 use_policy 条件
	# 被覆盖，之后还会被当成我们的块整段删除——必须在写片段前硬失败。
	_wr_type=$(uci -q get "mwan3.$_set" 2>/dev/null)
	if [ -n "$_wr_type" ]; then
		if [ "$_wr_type" != "rule" ]; then
			die "mwan3 中已存在同名段 '$_set'（类型 $_wr_type，不是 rule）；为保护现有配置，wire 不做改动（冲突段名: mwan3.$_set；请先在 mwan3 中删除/改名，或改用其它集合名）"
		fi
		[ "$(uci -q get "mwan3.$_set.isp_split" 2>/dev/null)" = "1" ] ||
			die "mwan3 中已存在同名 rule 段 '$_set' 且没有 isp_split 标记；为保护现有配置，wire 不做改动（冲突段名: mwan3.$_set；请先在 mwan3 中删除/改名，或改用其它集合名）"
	fi
	{
		printf "config rule '%s'\n" "$_set"
		printf "\toption ipset '%s'\n" "$_set"
		printf "\toption use_policy '%s'\n" "$_pol"
		printf "\toption family 'ipv4'\n"
		printf "\toption enabled '%s'\n" "${RULE_ENABLED:-1}"
		printf "\toption isp_split '1'\n"
		printf "\n"
	} >> "$WORK/rules.txt"
}

# ---- 孤儿 ipset 段收敛（B-4）----
# 段名可以改名/删除/禁用，但 mwan3 的 config ipset 段不会自己消失：
# 旧段仍然 enabled=1、loadfile 指向旧数据文件，mwan3 下次 reload 照样
# 建集合、灌元素，永久驻留。旧版 uninstall 也只看"当前表里的名字"，
# 连卸载都清不掉。
#
# 识别方式只有一个：isp_split=1 标记（_wire_ipset 写入）。绝不按 isp_
# 前缀猜 —— 用户自己的 mwan3 ipset 段可能也叫 isp_xxx。没有标记的段
# （0.2.1-happy 之前的旧版本写的）一律不动，只在日志里列出来。
#
# 当前仍有效的集合名：ISP_TABLE 里第 4 列=1 的行。DNS_TABLE 不引用
# ipset，但把它的字段也纳入保护集合，避免同名段被误删（保守兜底）。
_keep_set_names() {
	_ks=" "
	for _row in $ISP_TABLE; do
		[ -n "$_row" ] || continue
		_old_ifs=$IFS
		IFS=':'
		set -- $_row
		IFS=$_old_ifs
		[ $# -ge 4 ] || continue
		[ "$4" = "1" ] || continue
		_ks="$_ks$2 "
	done
	for _row in ${DNS_TABLE:-}; do
		[ -n "$_row" ] || continue
		_old_ifs=$IFS
		IFS=':'
		set -- $_row
		IFS=$_old_ifs
		for _f in "$@"; do
			case "$_f" in "" | *[!A-Za-z0-9_]*) continue ;; esac
			_ks="$_ks$_f "
		done
	done
	printf '%s\n' "$_ks"
}

# 所有 config rule 引用的 ipset 名（空格分隔，带前后空格便于整词匹配）
_rule_ipset_refs() {
	_ri=" "
	for _ri_sec in $(uci -q show mwan3 2>/dev/null |
		sed -n 's/^mwan3\.\([^=]*\)=rule$/\1/p'); do
		_ri_v=$(uci -q get "mwan3.$_ri_sec.ipset" 2>/dev/null) || continue
		for _ri_x in $_ri_v; do
			case "$_ri_x" in "" | *[!A-Za-z0-9_]*) continue ;; esac
			_ri="$_ri$_ri_x "
		done
	done
	printf '%s\n' "$_ri"
}

_prune_orphan_ipsets() {
	[ -f "$CFG_MWAN3" ] || return 0
	_po_keep=$(_keep_set_names)
	_po_refs=$(_rule_ipset_refs)
	_po_changed=0
	for _po_sec in $(uci -q show mwan3 2>/dev/null |
		sed -n 's/^mwan3\.\([^=]*\)=ipset$/\1/p'); do
		_po_name=$(uci -q get "mwan3.$_po_sec.name" 2>/dev/null) || continue
		[ -n "$_po_name" ] || continue
		# 仍被当前启用的行引用：保留
		case "$_po_keep" in *" $_po_name "*) continue ;; esac
		# 没有标记的段不是本工具写的（或旧版本写的）：绝不删
		if [ "$(uci -q get "mwan3.$_po_sec.isp_split" 2>/dev/null)" != "1" ]; then
			case "$_po_name" in
			isp_*)
				log "提示：ipset 段 $_po_sec（$_po_name）没有 isp_split 标记，保留不动（旧版本或用户自建）"
				;;
			esac
			continue
		fi
		# 仍被任何 mwan3 规则引用：删了会让 mwan3 渲染失败，只告警
		case "$_po_refs" in
		*" $_po_name "*)
			log "警告：孤儿 ipset 段 $_po_sec（$_po_name）仍被 mwan3 规则引用，保留"
			continue
			;;
		esac
		if uci -q delete "mwan3.$_po_sec" 2>/dev/null; then
			log "已清理孤儿 ipset 段 $_po_sec（$_po_name）"
			_po_changed=1
		else
			log "警告：清理 ipset 段 $_po_sec 失败，保留"
		fi
	done
	[ "$_po_changed" = "1" ] && uci -q commit mwan3 2>/dev/null
	return 0
}

# F-14：卸载时的 ipset 段清理已并入 usr/lib/isp-split/teardown.sh
# （cmd_uninstall 调它）。这里不再保留第二份按标记删段的实现——两份实现
# 就是 F-14 的成因：脚本路径与包 postrm 各自维护一份清单，最后谁都没删全。

# ---- 策略/成员（BUG-25）----
#
# mwan3 的 policy 只是"成员列表"，member 才是"接口 + metric/weight"。
# 首次安装的用户往往没有 wan_only/wanb_only 策略（或接口名不同），
# wire 生成 `option use_policy 'wan_only'` 后 mwan3 渲染时整条规则消失，
# 而旧版 wire 只做了 `uci get mwan3.<policy>` 的"段存在"检查——策略存在但
# 成员为空时照样通过，最后表现为"装好了、分流不生效、零提示"。
#
# 这里把"策略存在且有可用成员"抽成一个判定，wire/plan 在动任何配置之前
# 硬失败；init 负责按 ISP_TABLE 创建带 isp_split=1 标记的 policy/member。

# wire 实际会渲染的策略名（去重，保持表内顺序）。
# 只看第 4 列=1 的行：disabled 行不会生成任何 mwan3 rule，它的策略不必
# 存在；init 也不去碰（否则用户 disabled 行里引用的 mwan3 内建策略
# balanced 会因为"无标记"被拦下，首装直接走不下去）。
_isp_policies() {
	_ip_seen=" "
	for _ip_row in $ISP_TABLE; do
		case "$_ip_row" in \#*) continue ;; esac
		_ip_old_ifs=$IFS
		IFS=':'
		set -- $_ip_row
		IFS=$_ip_old_ifs
		[ $# -ge 4 ] || continue
		[ "$4" = "1" ] || continue
		case "$_ip_seen" in *" $3 "*) continue ;; esac
		_ip_seen="$_ip_seen$3 "
		printf '%s\n' "$3"
	done
}

# 策略问题描述：正常时输出空行，否则输出一句人话。
# 返回 0 表示无问题，1 表示有问题（调用方按需使用）。
_policy_problem() {
	_pp_pol=$1
	_pp_type=$(uci -q get "mwan3.$_pp_pol" 2>/dev/null)
	if [ -z "$_pp_type" ]; then
		printf '%s\n' "不存在于 mwan3"
		return 1
	fi
	if [ "$_pp_type" != "policy" ]; then
		printf '%s\n' "同名段存在但不是 policy（类型 $_pp_type）"
		return 1
	fi
	_pp_members=$(uci -q get "mwan3.$_pp_pol.use_member" 2>/dev/null)
	if [ -z "$_pp_members" ]; then
		printf '%s\n' "策略存在但成员为空"
		return 1
	fi
	for _pp_m in $_pp_members; do
		_pp_mtype=$(uci -q get "mwan3.$_pp_m" 2>/dev/null)
		if [ "$_pp_mtype" != "member" ]; then
			printf '%s\n' "引用的成员 '$_pp_m' 不存在"
			return 1
		fi
		_pp_if=$(uci -q get "mwan3.$_pp_m.interface" 2>/dev/null)
		if [ -z "$_pp_if" ]; then
			printf '%s\n' "成员 '$_pp_m' 未绑定接口"
			return 1
		fi
		if [ "$(uci -q get "mwan3.$_pp_if" 2>/dev/null)" != "interface" ]; then
			printf '%s\n' "成员 '$_pp_m' 绑定的接口 '$_pp_if' 在 mwan3 中不存在"
			return 1
		fi
	done
	printf '\n'
	return 0
}

# 一行描述策略成员，供 verify 展示
_policy_desc() {
	_pd_members=$(uci -q get "mwan3.$1.use_member" 2>/dev/null)
	if [ -z "$_pd_members" ]; then
		printf '%s\n' "（无成员）"
		return 0
	fi
	_pd_out=""
	for _pd_m in $_pd_members; do
		_pd_if=$(uci -q get "mwan3.$_pd_m.interface" 2>/dev/null)
		_pd_out="${_pd_out}${_pd_out:+, }$_pd_m(${_pd_if:-?})"
	done
	printf '%s\n' "$_pd_out"
}

# wire/plan 的公共预检：任何一个被引用的策略缺失/空成员都返回 1。
# 只打印问题，不退出——调用方决定是 die 还是继续（目前两者都 die）。
_precheck_policies() {
	_pc_bad=0
	for _pc_pol in $(_isp_policies); do
		_pc_msg=$(_policy_problem "$_pc_pol")
		[ -n "$_pc_msg" ] || continue
		log "错误: 策略 '$_pc_pol' $_pc_msg"
		_pc_bad=$((_pc_bad + 1))
	done
	[ "$_pc_bad" -eq 0 ]
}

# ---------- 接口发现（init） ----------

_mwan3_iface_sections() {
	uci -q show mwan3 2>/dev/null |
		sed -n 's/^mwan3\.\([^=]*\)=interface$/\1/p'
}

# 运行中的 L3 设备（PPPoE 时与配置里的 device 不同）。只做尽力探测：
# ubus 不可用时返回空，不影响 init。
_iface_l3_device() {
	_l3_ubus=""
	command -v ubus >/dev/null 2>&1 || return 0
	_l3_raw=$(ubus call "network.interface.$1" status 2>/dev/null) || return 0
	if command -v jsonfilter >/dev/null 2>&1; then
		_l3_ubus=$(printf '%s' "$_l3_raw" | jsonfilter -e '@.l3_device' 2>/dev/null)
		[ -n "$_l3_ubus" ] ||
			_l3_ubus=$(printf '%s' "$_l3_raw" | jsonfilter -e '@.device' 2>/dev/null)
	fi
	if [ -z "$_l3_ubus" ]; then
		_l3_ubus=$(printf '%s' "$_l3_raw" |
			sed -n 's/.*"l3_device"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
	fi
	printf '%s\n' "$_l3_ubus"
}

_iface_runtime_state() {
	_rs=""
	command -v ubus >/dev/null 2>&1 || {
		printf '%s\n' "-"
		return 0
	}
	_rs_raw=$(ubus call "network.interface.$1" status 2>/dev/null) || {
		printf '%s\n' "down"
		return 0
	}
	if command -v jsonfilter >/dev/null 2>&1; then
		_rs=$(printf '%s' "$_rs_raw" | jsonfilter -e '@.up' 2>/dev/null)
	fi
	case "$_rs" in
	true | 1) printf '%s\n' "up" ;;
	false | 0) printf '%s\n' "down" ;;
	*) printf '%s\n' "-" ;;
	esac
}

# 发现并打印接口清单；可绑定接口（enabled=1 且 family=ipv4）放在
# ISP_INIT_AVAIL（空格分隔，按 mwan3 配置顺序）。
_discover_ifaces() {
	ISP_INIT_AVAIL=""
	echo "== mwan3 接口发现（mwan3 接口 -> network 接口 -> pppoe/静态 设备）"
	printf '  %-9s %-10s %-7s %-12s %-12s %s\n' \
		"接口" "network" "类型" "配置设备" "运行设备" "状态"
	_di_any=0
	for _di_sec in $(_mwan3_iface_sections); do
		_di_any=1
		_di_en=$(uci -q get "mwan3.$_di_sec.enabled" 2>/dev/null)
		[ -n "$_di_en" ] || _di_en=1
		_di_fam=$(uci -q get "mwan3.$_di_sec.family" 2>/dev/null)
		[ -n "$_di_fam" ] || _di_fam=ipv4
		if [ -n "$(uci -q get "network.$_di_sec" 2>/dev/null)" ]; then
			_di_net=$_di_sec
		else
			_di_net="(未关联)"
		fi
		_di_proto=$(uci -q get "network.$_di_sec.proto" 2>/dev/null)
		_di_dev=$(uci -q get "network.$_di_sec.device" 2>/dev/null)
		[ -n "$_di_dev" ] ||
			_di_dev=$(uci -q get "network.$_di_sec.device" 2>/dev/null)
		_di_l3=$(_iface_l3_device "$_di_sec")
		[ -n "$_di_net" ] || _di_net="(未关联)"
		[ -n "$_di_proto" ] || _di_proto="-"
		[ -n "$_di_dev" ] || _di_dev="-"
		[ -n "$_di_l3" ] || _di_l3="-"
		_di_st=$(_iface_runtime_state "$_di_sec")
		if [ "$_di_en" != "1" ]; then
			_di_st="disabled"
		fi
		[ "$_di_fam" = "ipv4" ] || _di_st="$_di_st/$_di_fam"
		printf '  %-9s %-10s %-7s %-12s %-12s %s\n' \
			"$_di_sec" "$_di_net" "$_di_proto" "$_di_dev" "$_di_l3" "$_di_st"
		if [ "$_di_fam" = "ipv4" ] && [ "$_di_en" = "1" ]; then
			ISP_INIT_AVAIL="${ISP_INIT_AVAIL:+$ISP_INIT_AVAIL }$_di_sec"
		fi
	done
	[ "$_di_any" = "1" ] ||
		echo "  （mwan3 中没有 config interface 段）"

	echo
	echo "当前策略绑定:"
	_dp_any=0
	for _dp_pol in $(_isp_policies); do
		_dp_any=1
		printf '  %-14s -> %s\n' "$_dp_pol" "$(_policy_desc "$_dp_pol")"
	done
	[ "$_dp_any" = "1" ] || echo "  （ISP_TABLE 没有引用任何策略）"
}

_iface_in_avail() {
	case " $ISP_INIT_AVAIL " in
	*" $1 "*) return 0 ;;
	esac
	return 1
}

# 第 N 个可绑定接口（超出数量时循环）。--assume-yes 的默认绑定用这个：
# 策略按 ISP_TABLE 出现顺序与接口按 mwan3 配置顺序一一对应。
_nth_iface() {
	_ni_n=0
	for _ni_x in $ISP_INIT_AVAIL; do
		_ni_n=$((_ni_n + 1))
	done
	[ "$_ni_n" -gt 0 ] || return 1
	_ni_want=$((($1 - 1) % _ni_n + 1))
	_ni_i=0
	for _ni_x in $ISP_INIT_AVAIL; do
		_ni_i=$((_ni_i + 1))
		[ "$_ni_i" -eq "$_ni_want" ] && {
			printf '%s\n' "$_ni_x"
			return 0
		}
	done
	return 1
}

# 交互选择：提示写 stderr，结果写 stdout（便于 $(...) 捕获）
_prompt_iface() {
	_pi_pol=$1
	_pi_def=$2
	_pi_try=0
	while [ "$_pi_try" -lt 5 ]; do
		printf '  策略 %s 绑定到哪条线路？[%s] ' "$_pi_pol" "$_pi_def" >&2
		IFS= read -r _pi_ans || _pi_ans=""
		[ -n "$_pi_ans" ] || _pi_ans=$_pi_def
		_pi_sel=""
		case "$_pi_ans" in
		[0-9]*)
			_pi_i=0
			for _pi_x in $ISP_INIT_AVAIL; do
				_pi_i=$((_pi_i + 1))
				[ "$_pi_i" -eq "$_pi_ans" ] 2>/dev/null && _pi_sel=$_pi_x
			done
			;;
		*) _pi_sel=$_pi_ans ;;
		esac
		if _iface_in_avail "$_pi_sel"; then
			printf '%s\n' "$_pi_sel"
			return 0
		fi
		echo "  无效选择: '$_pi_ans'（可用: $ISP_INIT_AVAIL）" >&2
		_pi_try=$((_pi_try + 1))
	done
	die "策略 '$_pi_pol' 的接口选择失败"
}

_init_member_name() {
	printf 'isp_split_%s_m1\n' "$(printf '%s' "$1" | sed 's/[^A-Za-z0-9_]/_/g')"
}

# 写入/更新一个策略的绑定。幂等：同名带标记段只更新成员，不追加。
_init_apply_bind() {
	_ab_pol=$1
	_ab_if=$2
	_ab_type=$(uci -q get "mwan3.$_ab_pol" 2>/dev/null)
	if [ -n "$_ab_type" ]; then
		if [ "$_ab_type" != "policy" ]; then
			die "mwan3 中已存在同名段 '$_ab_pol'（类型 $_ab_type，不是 policy），init 不做改动"
		fi
		if [ "$(uci -q get "mwan3.$_ab_pol.isp_split" 2>/dev/null)" != "1" ]; then
			die "mwan3 中已存在同名策略 '$_ab_pol' 且没有 isp_split 标记；为保护现有配置，init 不做改动（请先在 mwan3 中删除/改名，或改用其它策略名）"
		fi
	else
		uci set "mwan3.$_ab_pol=policy" ||
			die "无法创建 mwan3 策略段 '$_ab_pol'"
	fi

	_ab_mem=$(_init_member_name "$_ab_pol")
	_ab_mtype=$(uci -q get "mwan3.$_ab_mem" 2>/dev/null)
	if [ -n "$_ab_mtype" ]; then
		if [ "$_ab_mtype" != "member" ]; then
			die "mwan3 中已存在同名段 '$_ab_mem'（类型 $_ab_mtype，不是 member），init 不做改动"
		fi
		if [ "$(uci -q get "mwan3.$_ab_mem.isp_split" 2>/dev/null)" != "1" ]; then
			die "mwan3 中已存在同名成员段 '$_ab_mem' 且没有 isp_split 标记；为保护现有配置，init 不做改动"
		fi
	else
		uci set "mwan3.$_ab_mem=member" ||
			die "无法创建 mwan3 成员段 '$_ab_mem'"
	fi

	uci set "mwan3.$_ab_pol.isp_split=1"
	uci set "mwan3.$_ab_mem.interface=$_ab_if"
	uci set "mwan3.$_ab_mem.metric=1"
	uci set "mwan3.$_ab_mem.weight=1"
	uci set "mwan3.$_ab_mem.isp_split=1"
	uci -q delete "mwan3.$_ab_pol.use_member"
	uci add_list "mwan3.$_ab_pol.use_member=$_ab_mem" ||
		die "无法把成员 '$_ab_mem' 写入策略 '$_ab_pol'"

	# 上一版 init 可能生成过别的成员名（例如策略改名后的残留）：
	# 只删本策略命名空间内、带标记且不是当前成员的段。
	_ab_san=$(printf '%s' "$_ab_pol" | sed 's/[^A-Za-z0-9_]/_/g')
	for _ab_old in $(uci -q show mwan3 2>/dev/null |
		sed -n 's/^mwan3\.\([^=]*\)=member$/\1/p'); do
		# 必须精确到 _m<数字>：否则策略 wan 会误删策略 wan_only 的成员
		# （isp_split_wan_only_m1 也以 isp_split_wan_ 开头）。
		case "$_ab_old" in
		isp_split_"$_ab_san"_m[0-9]*) ;;
		*) continue ;;
		esac
		[ "$_ab_old" = "$_ab_mem" ] && continue
		[ "$(uci -q get "mwan3.$_ab_old.isp_split" 2>/dev/null)" = "1" ] || continue
		uci -q delete "mwan3.$_ab_old" &&
			log "已移除策略 $_ab_pol 的旧成员段 $_ab_old"
	done
	return 0
}

# 清理带标记但已不在 ISP_TABLE 里的策略/成员（与 B-4 的 ipset 孤儿清理
# 同一标记体系）。规则仍在引用 / 策略仍在引用的段一律保留并告警。
_prune_orphan_policies() {
	[ -f "$CFG_MWAN3" ] || return 0
	_pop_keep=" $(_isp_policies | tr '\n' ' ') "
	_pop_rule_refs=" "
	for _pop_rsec in $(uci -q show mwan3 2>/dev/null |
		sed -n 's/^mwan3\.\([^=]*\)=rule$/\1/p'); do
		for _pop_x in $(uci -q get "mwan3.$_pop_rsec.use_policy" 2>/dev/null); do
			_pop_rule_refs="$_pop_rule_refs$_pop_x "
		done
	done

	_pop_changed=0
	for _pop_sec in $(uci -q show mwan3 2>/dev/null |
		sed -n 's/^mwan3\.\([^=]*\)=policy$/\1/p'); do
		[ "$(uci -q get "mwan3.$_pop_sec.isp_split" 2>/dev/null)" = "1" ] || continue
		case "$_pop_keep" in *" $_pop_sec "*) continue ;; esac
		case "$_pop_rule_refs" in
		*" $_pop_sec "*)
			log "警告：孤儿策略段 $_pop_sec 仍被 mwan3 规则引用，保留"
			continue
			;;
		esac
		if uci -q delete "mwan3.$_pop_sec" 2>/dev/null; then
			log "已清理孤儿策略段 $_pop_sec"
			_pop_changed=1
		else
			log "警告：清理策略段 $_pop_sec 失败，保留"
		fi
	done

	# 重新统计仍被任何策略引用的成员（包括用户自建的无标记策略）
	_pop_refs=" "
	for _pop_psec in $(uci -q show mwan3 2>/dev/null |
		sed -n 's/^mwan3\.\([^=]*\)=policy$/\1/p'); do
		for _pop_x in $(uci -q get "mwan3.$_pop_psec.use_member" 2>/dev/null); do
			_pop_refs="$_pop_refs$_pop_x "
		done
	done
	for _pop_sec in $(uci -q show mwan3 2>/dev/null |
		sed -n 's/^mwan3\.\([^=]*\)=member$/\1/p'); do
		[ "$(uci -q get "mwan3.$_pop_sec.isp_split" 2>/dev/null)" = "1" ] || continue
		case "$_pop_refs" in *" $_pop_sec "*) continue ;; esac
		if uci -q delete "mwan3.$_pop_sec" 2>/dev/null; then
			log "已清理孤儿成员段 $_pop_sec"
			_pop_changed=1
		else
			log "警告：清理成员段 $_pop_sec 失败，保留"
		fi
	done
	[ "$_pop_changed" = "1" ] && uci -q commit mwan3 2>/dev/null
	return 0
}

# F-14：卸载时的 policy/member 清理同样在 teardown.sh，见上。
# ---------------------------------------------------------------- bootstrap
#
# 净机一键骨架：只保证「mwan3 里有 interface / member / policy 骨架」，
# 让 `init --bind` 能跑、`wire` 的预检能过。
#
# 职责边界（与 init / wire 不打架）：
#   bootstrap  只补骨架（interface + member + policy），不碰 network/firewall
#   init       只决定「哪个策略的成员指向哪个接口」
#   wire       只写集合/规则；预检策略缺失仍然硬失败
#
# 红线（PLAN §1.4，全部"跳过并报告"，不做"自动修正"）：
#   1. 已存在的 mwan3.<name> 段（任何类型/任何 enabled 值）一律不动；
#   2. 已存在的 globals / network.* / firewall.* 只读；
#   3. 同名段没有 isp_split=1 标记 → 认为是用户的，跳过；
#   4. mwan3 不是 nft 版 / 未安装 → 直接拒绝，一个字节都不写。
#
# 事务（A-02 教训）：**不做 uci 全局 revert**。所有写入都发生在
# $WORK/boot-uci.<pid> 这个私有 confdir 里（uci -c），校验通过后用
# _mwan3_atomic_install 做 rename(2) 原子替换。任何一步失败，正式配置
# 一个字节都没动过，因此也就不存在"回滚掉别人的段"的可能。
#
# 灰度（用户 2026-10-30 拍板）：--apply 之后不自动 init、不 wire、不
# reload mwan3，只打印下一步。是否生效由用户显式决定。

# track_ip 默认两个跨 AS 的 anycast。不要用运营商 DNS：那会把"DNS 挂了"
# 误判成"线路 down"（PLAN §1.3）。
BOOT_TRACK_DEFAULT="1.0.0.1 1.1.1.1"

_bt_proto_ok() {
	case "${1:-}" in
	pppoe | dhcp | static) return 0 ;;
	esac
	return 1
}

# network 里所有 interface 段的段名
_bt_net_sections() {
	uci -q show network 2>/dev/null |
		sed -n 's/^network\.\([^=]*\)=interface$/\1/p'
}

# lan 的 device（br-lan 之类）：落在它上面的段一律不是 WAN
_bt_lan_dev() {
	_bt_ld=""
	_bt_ld=$(uci -q get network.lan.device 2>/dev/null) || _bt_ld=""
	[ -n "$_bt_ld" ] || _bt_ld=$(uci -q get network.lan.device 2>/dev/null) || _bt_ld=""
	printf '%s' "$_bt_ld"
}

# ubus 里的 IPv4 地址（拿不到就输出空：只用于展示，不参与判据）
_bt_iface_ipv4() {
	_bt_i4_raw=""
	command -v ubus >/dev/null 2>&1 || return 0
	_bt_i4_raw=$(ubus call "network.interface.$1" status 2>/dev/null) || return 0
	if command -v jsonfilter >/dev/null 2>&1; then
		printf '%s' "$_bt_i4_raw" | jsonfilter -e '@["ipv4-address"][0].address' 2>/dev/null
		return 0
	fi
	printf '%s' "$_bt_i4_raw" |
		sed -n 's/.*"ipv4-address"[^]]*"address"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' |
		head -1
}

# 发现候选 WAN（全程只读）。结果：
#   BT_CAND   空格分隔的候选段名（按 network 配置顺序）
#   BT_SKIP_DISCOVER  形如 "name=原因;name=原因" 的跳过清单
_bt_discover() {
	BT_CAND=""
	BT_SKIP_DISCOVER=""
	_bt_lan=$(_bt_lan_dev)
	for _bt_sec in $(_bt_net_sections); do
		_bt_proto=$(uci -q get "network.$_bt_sec.proto" 2>/dev/null) || _bt_proto=""
		case "$_bt_sec" in
		lan | loopback)
			BT_SKIP_DISCOVER="${BT_SKIP_DISCOVER}${BT_SKIP_DISCOVER:+;}$_bt_sec=管理口/回环"
			continue
			;;
		esac
		if ! _bt_proto_ok "$_bt_proto"; then
			BT_SKIP_DISCOVER="${BT_SKIP_DISCOVER}${BT_SKIP_DISCOVER:+;}$_bt_sec=proto=${_bt_proto:-空}（只认 pppoe/dhcp/static）"
			continue
		fi
		_bt_dev=$(uci -q get "network.$_bt_sec.device" 2>/dev/null) || _bt_dev=""
		[ -n "$_bt_dev" ] || _bt_dev=$(uci -q get "network.$_bt_sec.device" 2>/dev/null) || _bt_dev=""
		if [ -n "${_bt_lan:-}" ] && [ "${_bt_dev:-}" = "$_bt_lan" ]; then
			BT_SKIP_DISCOVER="${BT_SKIP_DISCOVER}${BT_SKIP_DISCOVER:+;}$_bt_sec=设备是 LAN 桥 $_bt_lan"
			continue
		fi
		# static 的纯 v6 段（只有 ip6addr、没有 ipaddr）不是 IPv4 WAN
		if [ "$_bt_proto" = "static" ]; then
			_bt_v4=$(uci -q get "network.$_bt_sec.ipaddr" 2>/dev/null) || _bt_v4=""
			_bt_v6=$(uci -q get "network.$_bt_sec.ip6addr" 2>/dev/null) || _bt_v6=""
			if [ -z "${_bt_v4:-}" ] && [ -n "${_bt_v6:-}" ]; then
				BT_SKIP_DISCOVER="${BT_SKIP_DISCOVER}${BT_SKIP_DISCOVER:+;}$_bt_sec=只有 IPv6 地址"
				continue
			fi
		fi
		BT_CAND="${BT_CAND:+$BT_CAND }$_bt_sec"
	done
}

# mwan3 里是否已有同名段（任何类型都算"已存在"）
_bt_mwan3_exists() {
	_bt_me=$(uci -q get "mwan3.$1" 2>/dev/null) || _bt_me=""
	[ -n "${_bt_me:-}" ]
}

# 已启用的 IPv4 WAN 段数量（判断"mwan3 已配置"的门槛）
_bt_enabled_wan_count() {
	_bt_ew=0
	for _bt_ews in $(_mwan3_iface_sections); do
		_bt_ew_en=$(uci -q get "mwan3.$_bt_ews.enabled" 2>/dev/null) || _bt_ew_en=1
		[ -n "${_bt_ew_en:-}" ] || _bt_ew_en=1
		_bt_ew_fam=$(uci -q get "mwan3.$_bt_ews.family" 2>/dev/null) || _bt_ew_fam=ipv4
		[ -n "${_bt_ew_fam:-}" ] || _bt_ew_fam=ipv4
		[ "$_bt_ew_en" = "1" ] || continue
		[ "$_bt_ew_fam" = "ipv4" ] || continue
		_bt_ew=$((_bt_ew + 1))
	done
	printf '%s' "$_bt_ew"
}

_bt_member_name() {
	printf '%s_m1_w1\n' "$(printf '%s' "$1" | sed 's/[^A-Za-z0-9_]/_/g')"
}

# 计划：BT_CREATE 每行 "type|段名|附属参数"，BT_SKIP / BT_REUSE 为展示清单。
# 只做计算，不写任何东西（dry-run 与 apply 共用同一份计划）。
_bt_build_plan() {
	BT_CREATE=""
	BT_SKIP=""
	BT_REUSE=""
	BT_MEMBERS=""
	BT_NEW_IFACES=""
	_bt_n=0
	for _bt_if in $BT_CAND; do
		_bt_n=$((_bt_n + 1))
		if _bt_mwan3_exists "$_bt_if"; then
			BT_SKIP="${BT_SKIP}${BT_SKIP:+
}  interface $_bt_if  （已存在，跳过）"
			# 已存在的接口段仍可作为成员/策略的绑定目标
			_bt_type=$(uci -q get "mwan3.$_bt_if" 2>/dev/null)
			if [ "$_bt_type" = "interface" ]; then
				BT_MEMBERS="${BT_MEMBERS:+$BT_MEMBERS }$_bt_if"
			fi
			continue
		fi
		BT_CREATE="${BT_CREATE}${BT_CREATE:+
}interface|$_bt_if|$_bt_if"
		BT_NEW_IFACES="${BT_NEW_IFACES:+$BT_NEW_IFACES }$_bt_if"
		BT_MEMBERS="${BT_MEMBERS:+$BT_MEMBERS }$_bt_if"
	done

	# 每条新接口一个成员（<iface>_m1_w1，与 MultiWAN 管理器命名习惯一致）
	for _bt_if in $BT_NEW_IFACES; do
		_bt_m=$(_bt_member_name "$_bt_if")
		if _bt_mwan3_exists "$_bt_m"; then
			BT_SKIP="${BT_SKIP}${BT_SKIP:+
}  member $_bt_m  （已存在，跳过）"
			continue
		fi
		BT_CREATE="${BT_CREATE}${BT_CREATE:+
}member|$_bt_m|$_bt_if"
	done

	# ISP_TABLE 引用的策略：缺失才补。balanced 用全部成员，其它按顺序一一对应。
	_bt_j=0
	_bt_mem_n=0
	for _bt_x in $BT_MEMBERS; do
		_bt_mem_n=$((_bt_mem_n + 1))
	done
	for _bt_pol in $(_isp_policies); do
		_bt_j=$((_bt_j + 1))
		_bt_ptype=$(uci -q get "mwan3.$_bt_pol" 2>/dev/null) || _bt_ptype=""
		if [ -n "${_bt_ptype:-}" ]; then
			BT_SKIP="${BT_SKIP}${BT_SKIP:+
}  policy $_bt_pol  （已存在，跳过）"
			BT_REUSE="${BT_REUSE}${BT_REUSE:+
}  policy $_bt_pol  -> $(_policy_desc "$_bt_pol")"
			continue
		fi
		[ "$_bt_mem_n" -gt 0 ] || {
			BT_SKIP="${BT_SKIP}${BT_SKIP:+
}  policy $_bt_pol  （没有可用成员，跳过）"
			continue
		}
		if [ "$_bt_pol" = "balanced" ]; then
			_bt_use=""
			for _bt_x in $BT_MEMBERS; do
				_bt_use="${_bt_use:+$_bt_use }$(_bt_member_name "$_bt_x")"
			done
		else
			_bt_want=$(((_bt_j - 1) % _bt_mem_n + 1))
			_bt_i=0
			_bt_use=""
			for _bt_x in $BT_MEMBERS; do
				_bt_i=$((_bt_i + 1))
				[ "$_bt_i" -eq "$_bt_want" ] && _bt_use=$(_bt_member_name "$_bt_x")
			done
		fi
		BT_CREATE="${BT_CREATE}${BT_CREATE:+
}policy|$_bt_pol|$_bt_use"
	done
}

# 打印计划（dry-run 与 apply 共用；apply 之后再打一次"已写入"）
_bt_print_plan() {
	_bt_title=$1
	echo
	echo "== 发现（只读）"
	printf '  %-10s %-8s %-6s %-12s %s\n' "接口" "类型" "状态" "运行设备" "IPv4"
	for _bt_if in $BT_CAND; do
		_bt_p=$(uci -q get "network.$_bt_if.proto" 2>/dev/null) || _bt_p="-"
		_bt_st=$(_iface_runtime_state "$_bt_if")
		_bt_l3=$(_iface_l3_device "$_bt_if")
		_bt_ip=$(_bt_iface_ipv4 "$_bt_if")
		printf '  %-10s %-8s %-6s %-12s %s\n' \
			"$_bt_if" "${_bt_p:--}" "$_bt_st" "${_bt_l3:--}" "${_bt_ip:--}"
	done
	[ -n "$BT_CAND" ] || echo "  （没有候选 WAN）"
	if [ -n "${BT_SKIP_DISCOVER:-}" ]; then
		echo
		echo "== 未纳入的 network 段"
		printf '%s\n' "$BT_SKIP_DISCOVER" | tr ';' '\n' | sed 's/^/  · /'
	fi

	echo
	echo "== $_bt_title"
	if [ -n "$BT_CREATE" ]; then
		printf '%s\n' "$BT_CREATE" | while IFS='|' read -r _bt_t _bt_nm _bt_arg; do
			[ -n "${_bt_t:-}" ] || continue
			case "$_bt_t" in
			interface)
				printf '  + interface %-12s enabled=1 family=ipv4 track_ip="%s" track_method=ping initial_state=online isp_split=1\n' \
					"$_bt_nm" "$BT_TRACK"
				;;
			member)
				printf '  + member    %-12s interface=%s metric=1 weight=1 isp_split=1\n' "$_bt_nm" "$_bt_arg"
				;;
			policy)
				printf '  + policy    %-12s use_member="%s" isp_split=1\n' "$_bt_nm" "$_bt_arg"
				;;
			esac
		done
	else
		echo "  （没有需要新建的段）"
	fi
	[ -n "$BT_SKIP" ] && {
		echo "  = 跳过（已存在/不可用，绝不覆盖）"
		printf '%s\n' "$BT_SKIP"
	}
	[ -n "$BT_REUSE" ] && {
		echo "  = 复用（已存在，未改动）"
		printf '%s\n' "$BT_REUSE"
	}
	echo "  · network.* / firewall.* / nft / mwan3.globals 一律只读，不产生任何改动"
}

# 事务写入：私有 confdir 里改 -> commit -> 解析校验 -> rename 原子替换
_bt_apply() {
	_bt_tmp=$WORK/boot-uci.$$
	rm -rf "$_bt_tmp"
	mkdir -p "$_bt_tmp" || die "无法创建事务目录 $_bt_tmp（配置未改动）"
	cp "$CFG_MWAN3" "$_bt_tmp/mwan3" || {
		rm -rf "$_bt_tmp"
		die "无法复制 $CFG_MWAN3 到事务目录（配置未改动）"
	}
	_bt_cleanup() { rm -rf "$_bt_tmp"; }
	# 备份（只作留档；回滚走 isp_split 标记，不靠备份覆盖用户改动）
	mkdir -p "$STATE_DIR" 2>/dev/null || true
	_bt_ts=$(date +%s)
	cp "$CFG_MWAN3" "$STATE_DIR/mwan3.bootstrap.$_bt_ts.bak" 2>/dev/null ||
		log "警告：无法写入备份 $STATE_DIR/mwan3.bootstrap.$_bt_ts.bak（继续）"

	# 1) 写入（只在私有 confdir 里）
	_bt_wr() {
		_bt_t=$1
		_bt_nm=$2
		_bt_arg=$3
		uci -c "$_bt_tmp" set "mwan3.$_bt_nm=$_bt_t" ||
			return 1
		case "$_bt_t" in
		interface)
			uci -c "$_bt_tmp" set "mwan3.$_bt_nm.enabled=1" || return 1
			uci -c "$_bt_tmp" set "mwan3.$_bt_nm.family=ipv4" || return 1
			uci -c "$_bt_tmp" set "mwan3.$_bt_nm.track_method=ping" || return 1
			uci -c "$_bt_tmp" set "mwan3.$_bt_nm.initial_state=online" || return 1
			uci -c "$_bt_tmp" -q delete "mwan3.$_bt_nm.track_ip" 2>/dev/null || true
			for _bt_tip in $BT_TRACK; do
				uci -c "$_bt_tmp" add_list "mwan3.$_bt_nm.track_ip=$_bt_tip" || return 1
			done
			;;
		member)
			uci -c "$_bt_tmp" set "mwan3.$_bt_nm.interface=$_bt_arg" || return 1
			uci -c "$_bt_tmp" set "mwan3.$_bt_nm.metric=1" || return 1
			uci -c "$_bt_tmp" set "mwan3.$_bt_nm.weight=1" || return 1
			;;
		policy)
			uci -c "$_bt_tmp" -q delete "mwan3.$_bt_nm.use_member" 2>/dev/null || true
			for _bt_mm in $_bt_arg; do
				uci -c "$_bt_tmp" add_list "mwan3.$_bt_nm.use_member=$_bt_mm" || return 1
			done
			;;
		esac
		uci -c "$_bt_tmp" set "mwan3.$_bt_nm.isp_split=1" || return 1
		return 0
	}

	_bt_lines=""
	_bt_ok=1
	while IFS='|' read -r _bt_t _bt_nm _bt_arg; do
		[ -n "${_bt_t:-}" ] || continue
		_bt_wr "$_bt_t" "$_bt_nm" "$_bt_arg" || _bt_ok=0
		[ "$_bt_ok" = "1" ] || break
		_bt_lines="${_bt_lines}${_bt_lines:+
}$_bt_t $_bt_nm"
	done <<BOOTPLAN
$BT_CREATE
BOOTPLAN
	if [ "$_bt_ok" != "1" ]; then
		_bt_cleanup
		die "写入段 '$_bt_nm' 失败：正式配置未改动（事务目录已丢弃）"
	fi

	# 2) commit 到事务目录 + 解析校验（旧写法永远通过，所以必须校验新文件）
	if ! uci -c "$_bt_tmp" commit mwan3; then
		_bt_cleanup
		die "事务 commit 失败：正式配置未改动"
	fi
	if ! uci -c "$_bt_tmp" show mwan3 >/dev/null 2>&1; then
		_bt_cleanup
		die "生成的 mwan3 配置无法解析：正式配置未改动"
	fi

	# 3) 复核：每个计划段都在、类型对、带标记（防止 uci 把同名段合并掉）
	while IFS='|' read -r _bt_t _bt_nm _bt_arg; do
		[ -n "${_bt_t:-}" ] || continue
		_bt_got=$(uci -c "$_bt_tmp" get "mwan3.$_bt_nm" 2>/dev/null) || _bt_got=""
		_bt_mark=$(uci -c "$_bt_tmp" get "mwan3.$_bt_nm.isp_split" 2>/dev/null) || _bt_mark=""
		if [ "$_bt_got" != "$_bt_t" ] || [ "$_bt_mark" != "1" ]; then
			_bt_cleanup
			die "校验失败：$_bt_t '$_bt_nm' 未按预期落盘（type=${_bt_got:-空} mark=${_bt_mark:-空}）：正式配置未改动"
		fi
	done <<BOOTPLAN
$BT_CREATE
BOOTPLAN

	# 4) 原子替换（rename），模式沿用原文件
	if ! _mwan3_atomic_install "$_bt_tmp/mwan3"; then
		_bt_cleanup
		die "无法原子替换 $CFG_MWAN3：正式配置未改动（备份 $STATE_DIR/mwan3.bootstrap.$_bt_ts.bak）"
	fi

	# 5) 记录本次新建的段（供 --rollback 展示；真正删除仍以 isp_split 标记为准）
	{
		printf '# %s bootstrap %s\n' "$(_ts_text "$(date +%s)")" "$BT_TRACK"
		printf '%s\n' "$_bt_lines"
	} >>"$STATE_DIR/bootstrap.created" 2>/dev/null || true
	_bt_cleanup
	log "已写入 mwan3 骨架（$(printf '%s\n' "$_bt_lines" | grep -c . ) 个段，全部带 isp_split=1 标记）"
	log "备份: $STATE_DIR/mwan3.bootstrap.$_bt_ts.bak；本次未 reload mwan3（灰度）"
}

# 列出全部 isp_split=1 的 mwan3 段（rollback 预览与"删干净了没有"的复核共用）
_bt_marked_segments() {
	for _bt_ms in ipset interface member policy rule; do
		for _bt_sec in $(uci -q show mwan3 2>/dev/null |
			sed -n "s/^mwan3\.\([^=]*\)=$_bt_ms\$/\1/p"); do
			[ "$(uci -q get "mwan3.$_bt_sec.isp_split" 2>/dev/null)" = "1" ] || continue
			printf '%s %s\n' "$_bt_ms" "$_bt_sec"
		done
	done
}

cmd_bootstrap() {
	_op_lock_acquire || die "另一个 isp-split 操作正在运行（锁: $OP_LOCK），本次未做任何改动；稍后重试"
	_cmd_bootstrap_body "$@"
	_rc=$?
	_op_lock_release
	return $_rc
}

_cmd_bootstrap_body() {
	BT_APPLY=0
	BT_ROLLBACK=0
	BT_FORCE=0
	BT_YES=0
	BT_TRACK=$BOOT_TRACK_DEFAULT
	while [ $# -gt 0 ]; do
		case "$1" in
		--apply) BT_APPLY=1 ;;
		--dry-run) BT_APPLY=0 ;;
		--rollback) BT_ROLLBACK=1 ;;
		--force) BT_FORCE=1 ;;
		--yes | -y) BT_YES=1 ;;
		--track-ip)
			[ $# -ge 2 ] || die "--track-ip 需要参数 \"<ip> [<ip>...]\""
			BT_TRACK=$2
			shift
			;;
		--track-ip=*) BT_TRACK=${1#--track-ip=} ;;
		-h | --help)
			cat <<'BOOTHELP'
用法: isp-split bootstrap [--apply] [--force] [--track-ip "<ip> [<ip>]"]
      isp-split bootstrap --rollback [--yes]

  净机一键骨架：从 network 发现 WAN，补出 mwan3 的 interface/member/policy。
  · 默认 dry-run，只打印将创建/跳过/复用的段，不写任何东西
  · --apply 才写；写入走私有 confdir + 校验 + rename 原子替换
  · 已存在的 mwan3 段（无论 enabled）一律不覆盖、不重命名
  · 已配置的 mwan3（>=2 个启用的 IPv4 接口）默认拒绝补段，需要 --force
  · 不自动 init / wire / reload mwan3（灰度）；结束后打印下一步
  · --rollback 删除全部 isp_split=1 的段（= uninstall 的 bootstrap 部分）
BOOTHELP
			return 0
			;;
		*)
			die "未知参数: '$1'（用法: isp-split bootstrap [--apply] [--force] [--track-ip ...] | --rollback）"
			;;
		esac
		shift
	done

	command -v uci >/dev/null 2>&1 || die "未找到 uci 命令"
	[ -f "$CFG_MWAN3" ] || die "找不到 $CFG_MWAN3：请先安装 nft 版 mwan3（>= 3.6）"

	# ---------------- rollback ----------------
	if [ "$BT_ROLLBACK" = "1" ]; then
		_bt_list=$(_bt_marked_segments)
		if [ -z "$_bt_list" ]; then
			log "没有 isp_split=1 的段，无需回滚"
			return 0
		fi
		echo "== 将删除以下带 isp_split=1 标记的 mwan3 段（手配段一个都不动）"
		printf '%s\n' "$_bt_list" | sed 's/^/  - /'
		echo "  · 仍被其它策略引用的成员段会保留（删了 mwan3 会渲染失败）"
		if [ "$BT_YES" != "1" ]; then
			if [ ! -t 0 ]; then
				die "非交互环境：确认要删除请加 --yes（本次未做任何改动）"
			fi
			printf '确认删除？[y/N] '
			IFS= read -r _bt_ans || _bt_ans=""
			case "$_bt_ans" in
			y | Y | yes | YES) ;;
			*) log "已取消，未做任何改动"; return 1 ;;
			esac
		fi
		[ -f "$LIB/teardown.sh" ] ||
			die "缺少 $LIB/teardown.sh，无法回滚（版本不匹配？）"
		# --i-know-what-im-doing：BASE 可能被 ISP_SPLIT_BASE 覆盖（自检/沙箱），
		# 而 mwan3 作用域只删 UCI 段、从不删 BASE 下的文件，所以这里的显式确认
		# 不比 uninstall（会 rm -rf BASE）的风险高。
		ISP_SPLIT_BASE=$BASE ISP_SPLIT_CONF=$CONF ISP_SPLIT_CRONTAB=$CRONTAB \
			ISP_SPLIT_LIB=$LIB MWAN3_SERVICE=$MWAN3_SERVICE \
			DNSDIR=$DNSDIR DNSMASQ_DEFAULT_DIR=$DNSMASQ_DEFAULT_DIR \
			DNSMASQ_CONF_GLOB=$DNSMASQ_CONF_GLOB \
			sh "$LIB/teardown.sh" mwan3 --i-know-what-im-doing ||
			die "回滚失败（见上方输出）"
		_bt_left=$(_bt_marked_segments)
		if [ -n "$_bt_left" ]; then
			log "回滚后仍残留（被其它策略引用，已保留）："
			printf '%s\n' "$_bt_left" | sed 's/^/  · /'
		else
			log "回滚完成：isp_split=1 的段已全部删除"
		fi
		return 0
	fi

	# ---------------- 发现 + 预检 ----------------
	if ! grep -q render_config_ipsets /lib/mwan3/mwan3.sh 2>/dev/null; then
		die "mwan3 不是 nftables 版（/lib/mwan3/mwan3.sh 里没有 render_config_ipsets）：本项目不支持，未做任何改动。请换用 nft 版 mwan3 (>= 3.6.0)"
	fi

	_bt_discover
	_bt_n=0
	for _bt_x in $BT_CAND; do
		_bt_n=$((_bt_n + 1))
	done
	if [ "$_bt_n" -eq 0 ]; then
		echo "错误: 没有发现可用的 WAN 接口段（network 里没有 proto=pppoe/dhcp/static 的接口）" >&2
		[ -n "${BT_SKIP_DISCOVER:-}" ] && {
			echo "已排除的段：" >&2
			printf '%s\n' "$BT_SKIP_DISCOVER" | tr ';' '\n' | sed 's/^/  · /' >&2
		}
		echo "下一步：" >&2
		echo "  1) LuCI → 网络 → 接口：添加/启用 WAN（PPPoE 或 DHCP），确认它不是 lan" >&2
		echo "  2) 确认 mwan3（nft 版）已安装并至少启动过一次" >&2
		echo "  3) 重跑 isp-split bootstrap（先看不带 --apply 的预演）" >&2
		exit 1
	fi

	_bt_ew=$(_bt_enabled_wan_count)
	if [ "$_bt_ew" -ge 2 ] && [ "$BT_FORCE" != "1" ]; then
		die "mwan3 已配置（$_bt_ew 个启用的 IPv4 接口）：bootstrap 只补缺失骨架，不覆盖既有配置。
若确认要补建缺失段，请加 --force：
  isp-split bootstrap --force            # 预演
  isp-split bootstrap --force --apply    # 写入
（已存在的 interface/member/policy 仍然一律跳过，不会被改名或覆盖）"
	fi

	_bt_build_plan
	_bt_print_plan "将创建（$([ "$BT_APPLY" = "1" ] && printf 'apply' || printf 'dry-run，加 --apply 才写入')）"

	if [ "$BT_APPLY" != "1" ]; then
		echo
		echo "== 下一步（dry-run 未写入任何东西）"
		echo "  isp-split bootstrap --apply    # 真正写入骨架"
		echo "  isp-split init --bind <策略>=<接口> ..."
		echo "  isp-split wire && isp-split sync"
		return 0
	fi

	if [ -z "$BT_CREATE" ]; then
		echo
		log "没有需要新建的段（骨架已完整），配置一个字节都没改"
	else
		_work_writable || die "work 目录不可写（$WORK）：本次未做任何改动"
		_bt_apply
	fi

	echo
	echo "== 下一步（灰度：本次没有 init / wire / reload mwan3）"
	echo "  1) 绑定线路（写 member 的 interface 指向）："
	echo "       isp-split init --bind <策略>=<接口> [--bind ...] --assume-yes"
	echo "     当前策略: $(uci -q show mwan3 2>/dev/null | sed -n 's/^mwan3\.\([^=]*\)=policy$/\1/p' | tr '\n' ' ')"
	echo "     当前接口: $(_mwan3_iface_sections | tr '\n' ' ')"
	echo "  2) 写规则：isp-split wire"
	echo "  3) 生效：  isp-split sync（会 reload mwan3，短暂断流 1-3 秒）"
	echo "  撤销本步： isp-split bootstrap --rollback（只删带 isp_split=1 的段）"
}

# init：发现接口 -> 为 ISP_TABLE 的每个策略绑定成员 -> 跑一次 plan 摘要
# A-02：与 fetch/wire/apply/sync 共用同一把操作锁，避免与 cmd_wire 的
# uci -q revert mwan3 互踩全局 /tmp/.uci 暂存区。
cmd_init() {
	_op_lock_acquire || die "另一个 isp-split 操作正在运行（锁: $OP_LOCK），本次未做任何改动；稍后重试"
	_cmd_init_body "$@"
	_rc=$?
	_op_lock_release
	return $_rc
}

_cmd_init_body() {
	INIT_ASSUME=0
	INIT_BINDS=""
	while [ $# -gt 0 ]; do
		case "$1" in
		--bind)
			[ $# -ge 2 ] || die "--bind 需要参数 <策略>=<接口>"
			_init_add_bind "$2"
			shift 2
			;;
		--bind=*)
			_init_add_bind "${1#--bind=}"
			shift
			;;
		--assume-yes | -y)
			INIT_ASSUME=1
			shift
			;;
		-h | --help)
			echo "用法: isp-split init [--bind <策略>=<接口>]... [--assume-yes]"
			echo "  交互模式：逐个策略选择 mwan3 接口"
			echo "  非交互：--bind 可重复；未绑定的策略在 --assume-yes 下按顺序自动分配"
			return 0
			;;
		*)
			die "未知参数: '$1'（用法: isp-split init [--bind <策略>=<接口>]... [--assume-yes]）"
			;;
		esac
	done

	command -v uci >/dev/null 2>&1 || die "未找到 uci 命令"
	[ -f "$CFG_MWAN3" ] ||
		die "找不到 $CFG_MWAN3：请先安装并启用 mwan3"

	[ -n "$(_isp_policies)" ] ||
		die "ISP_TABLE 没有引用任何策略，无需 init（请先配置 $CONF）"

	_discover_ifaces
	echo
	if [ -z "$ISP_INIT_AVAIL" ]; then
		die "没有可用的 mwan3 IPv4 接口（需要 enabled=1 且 family=ipv4）；请先在 MultiWAN 管理器里创建并启用 WAN 接口"
	fi
	echo "可绑定接口: $ISP_INIT_AVAIL"

	# --bind 的策略必须真的在 ISP_TABLE 里，否则写入的段永远不会被 wire 使用
	for _in_b in $INIT_BINDS; do
		_in_pol=${_in_b%%=*}
		_in_found=0
		for _in_p in $(_isp_policies); do
			[ "$_in_p" = "$_in_pol" ] && _in_found=1
		done
		[ "$_in_found" = "1" ] ||
			die "--bind 的策略 '$_in_pol' 不在 ISP_TABLE 中（可用: $(_isp_policies | tr '\n' ' ')）"
	done

	# 没有 --bind 覆盖全部策略且不是 --assume-yes 时，必须有终端可交互
	_need_prompt=0
	_idx=0
	for _in_pol in $(_isp_policies); do
		_idx=$((_idx + 1))
		_init_bound_iface "$_in_pol" >/dev/null 2>&1 || _need_prompt=1
	done
	if [ "$_need_prompt" = "1" ] && [ "$INIT_ASSUME" != "1" ] && [ ! -t 0 ]; then
		die "当前不是交互终端：请用 --bind <策略>=<接口>（可重复）或 --assume-yes"
	fi

	# 成员段名由策略名派生（非 [A-Za-z0-9_] 统一换成 _）。策略名允许
	# 连字符，所以 wan-only 与 wan_only 会撞成同一个成员段名——提前拦住。
	_in_members=" "
	for _in_pol in $(_isp_policies); do
		_in_m=$(_init_member_name "$_in_pol")
		case "$_in_members" in
		*" $_in_m "*)
			die "策略名冲突：多个策略会生成同名成员段 '$_in_m'，请改名后重试"
			;;
		esac
		_in_members="$_in_members$_in_m "
	done

	echo
	echo "== 将写入的绑定"
	# uci set 只写暂存区，commit 才落盘。中途 die 必须把暂存区撤掉，
	# 否则半套策略会留在 /tmp/.uci/mwan3，被下一次无关的 uci commit 带进配置。
	INIT_DIRTY=0
	_init_cleanup() {
		[ "${INIT_DIRTY:-0}" = "1" ] && uci -q revert mwan3 2>/dev/null
	}
	trap _init_cleanup EXIT INT TERM
	INIT_DIRTY=1
	_idx=0
	for _in_pol in $(_isp_policies); do
		_idx=$((_idx + 1))
		if _in_if=$(_init_bound_iface "$_in_pol"); then
			:
		elif [ "$INIT_ASSUME" = "1" ]; then
			_in_if=$(_nth_iface "$_idx") ||
				die "没有可用接口可分配给策略 '$_in_pol'"
		else
			_in_def=$(_nth_iface "$_idx") || _in_def=""
			_in_if=$(_prompt_iface "$_in_pol" "$_in_def")
		fi
		_iface_in_avail "$_in_if" ||
			die "接口 '$_in_if' 不在可绑定列表内（可用: $ISP_INIT_AVAIL）"
		printf '  %-14s -> %-10s [成员 %s]\n' \
			"$_in_pol" "$_in_if" "$(_init_member_name "$_in_pol")"
		_init_apply_bind "$_in_pol" "$_in_if"
	done

	uci commit mwan3 || die "写入 mwan3 配置失败（暂存区已回滚）"
	INIT_DIRTY=0
	trap - EXIT INT TERM
	log "已写入 mwan3 策略/成员（isp_split=1 标记）"

	_prune_orphan_policies

	echo
	echo "== init 完成，以下为 plan 摘要"
	cmd_plan
}

# 把 --bind 参数追加到 INIT_BINDS；策略不允许重复绑定
_init_add_bind() {
	case "$1" in
	*=*) ;;
	*) die "--bind 参数格式应为 <策略>=<接口>（当前: '$1'）" ;;
	esac
	_ib_pol=${1%%=*}
	_ib_if=${1#*=}
	[ -n "$_ib_pol" ] && [ -n "$_ib_if" ] ||
		die "--bind 的策略和接口都不能为空（当前: '$1'）"
	case " $INIT_BINDS " in
	*" $_ib_pol="*) die "策略 '$_ib_pol' 重复绑定（每次 init 每个策略只能绑定一条线路）" ;;
	esac
	INIT_BINDS="${INIT_BINDS:+$INIT_BINDS }$_ib_pol=$_ib_if"
}

_init_bound_iface() {
	for _ib_b in $INIT_BINDS; do
		[ "${_ib_b%%=*}" = "$1" ] || continue
		printf '%s\n' "${_ib_b#*=}"
		return 0
	done
	return 1
}

# ---------------------------------------------------------------- doctor
#
# 环境体检。判据（探针 P1–P11 + 兼容矩阵六项 + 主机前提五项）只有一份实现：
# usr/lib/isp-split/doctor.sh，tools/router-preflight.sh 也 source 它。
# 这里只负责装配与退出码，绝不重复写判据——两套判据必然漂移（F7 的教训）。
#
# 只读、零副作用、不进 op.lock（不改任何东西，不需要和 wire/fetch 互斥）。
cmd_doctor() {
	[ -f "$LIB/doctor.sh" ] ||
		die "缺少 $LIB/doctor.sh：环境体检的判据实现不在设备上（版本不匹配？）
重装同版本包，或从同版本源码 cp files/usr/lib/isp-split/doctor.sh /usr/lib/isp-split/"
	. "$LIB/doctor.sh"
	doc_main "$@"
}

# 机器可读的策略状态（LuCI 首装提示条用）
cmd_policy_check() {
	_pk_json=0
	[ "${1:-}" = "--json" ] && _pk_json=1
	_pk_ok=1
	_pk_rows=""
	_pk_json_rows=""
	for _pk_pol in $(_isp_policies); do
		_pk_msg=$(_policy_problem "$_pk_pol")
		if [ -n "$_pk_msg" ]; then
			_pk_ok=0
			_pk_rows="${_pk_rows}  ✗ $_pk_pol: $_pk_msg
"
			_pk_json_rows="${_pk_json_rows}${_pk_json_rows:+,}{\"name\":\"$(_lc_json_esc "$_pk_pol")\",\"ok\":false,\"detail\":\"$(_lc_json_esc "$_pk_msg")\"}"
		else
			_pk_rows="${_pk_rows}  ✓ $_pk_pol: $(_policy_desc "$_pk_pol")
"
			_pk_json_rows="${_pk_json_rows}${_pk_json_rows:+,}{\"name\":\"$(_lc_json_esc "$_pk_pol")\",\"ok\":true,\"detail\":\"$(_lc_json_esc "$(_policy_desc "$_pk_pol")")\"}"
		fi
	done
	if [ "$_pk_json" = "1" ]; then
		printf '{"ok":%s,"policies":[%s]}\n' \
			"$([ "$_pk_ok" = "1" ] && printf true || printf false)" "$_pk_json_rows"
	else
		echo "== 策略存在且有成员（wire 渲染前置条件）"
		printf '%s' "$_pk_rows"
		[ "$_pk_ok" = "1" ] ||
			echo "  运行 isp-split init 绑定线路"
	fi
	return 0
}

# 原子替换 mwan3 配置。调用方必须已经用 uci -c 临时目录解析校验过 $1。
# `cat > 目标` 是先截断再写，掉电/被杀会留下半截 mwan3；mv -f 在同文件
# 系统上是 rename(2)，要么旧文件、要么完整新文件。模式沿用原文件，
# 避免把 600 的 /etc/config/* 变成 644。
_mwan3_atomic_install() {
	_mi_src=$1
	[ -f "$_mi_src" ] || return 1
	_mi_mode=$(stat -c %a "$CFG_MWAN3" 2>/dev/null) || _mi_mode=600
	chmod "$_mi_mode" "$_mi_src" 2>/dev/null || true
	mv -f "$_mi_src" "$CFG_MWAN3"
}

# reload 之后的最小形状复核（F-29）：mwan3 reload 的返回码并不可靠
# （nft 批处理失败时也可能返回 0），必须确认每个启用集合真的被
# mwan3_rules 引用。stage 的 RULE_ENABLED=0 是故意不渲染，直接跳过。
_wire_runtime_check() {
	[ "${RULE_ENABLED:-1}" = "1" ] || return 0
	_wrc_any=0
	_wrc_mark() { _wrc_any=1; }
	foreach_enabled _wrc_mark
	[ "$_wrc_any" = "1" ] || return 0

	_wrc_tries=${MWAN3_READY_TRIES:-15}
	_wrc_delay=${MWAN3_READY_DELAY:-1}
	_wrc_i=0
	while :; do
		_wrc_body=$(_mwan3_rules_body)
		_WRC_MISSING=""
		_wrc_chk() {
			if printf '%s\n' "$_wrc_body" | grep -q "@$2 " ||
				printf '%s\n' "$_wrc_body" | grep -q "@$2\$"; then
				return 0
			fi
			_WRC_MISSING="$_WRC_MISSING $2"
		}
		foreach_enabled _wrc_chk
		[ -z "$_WRC_MISSING" ] && return 0
		[ "$_wrc_i" -ge "$_wrc_tries" ] && return 1
		_wrc_i=$((_wrc_i + 1))
		sleep "$_wrc_delay"
	done
}

_cmd_wire_body() {
	_openclash_fakeip_warn
	need_nft
	# 先确认 work 目录可写再动 UCI：否则可能写到一半才发现写不了规则片段。
	_work_writable || die "work 目录不可写（$WORK）：无法写入规则片段，配置未改动"

	# 0. 策略预检（BUG-25）：必须在动 UCI/nft 之前。mwan3 对"策略不存在"
	# 和"策略成员为空"都是静默跳过整条规则，事后 verify 只看到规则没渲染，
	# 根因看不出来。这里硬失败，并给出唯一正确的下一步。
	_precheck_policies ||
		die "策略预检失败：上面的策略在 mwan3 中缺失或没有可用成员，规则无法正确渲染（本次未改动任何配置）。运行 isp-split init 绑定线路后重试"

	# 1. 规则片段（只写 work 目录，不动 UCI）。_write_rule 同时检查
	#    同名 rule 段是否被用户占用；冲突时在写任何 UCI 之前 die。
	: > "$WORK/rules.txt" || die "无法写入 $WORK/rules.txt（work 目录不可写？）"
	_write_all() { _write_rule "$1" "$2" "$3"; }
	foreach_enabled _write_all

	# 一条规则都没有时，splice 会把配置文件里现有的 isp-split 规则块整段删掉。
	# 这是合法操作（用户可能把运营商全禁用了），但必须说出来，不能只打印
	# "规则已注入" 让人以为一切正常。
	[ -s "$WORK/rules.txt" ] ||
		log "警告：ISP_TABLE 中没有启用的运营商，本次规则块为空（已有 isp-split 规则将被移除）"

	# 2. ipset 段名冲突全量预检：所有行都通过才允许进入写阶段。
	#    放在 uci set 之前，避免"第一条已写、第二条冲突"留下暂存区残料。
	_wire_check_all() { _wire_ipset_check "$2"; }
	foreach_enabled _wire_check_all

	# 3. uci set 只写暂存区，不 commit。中途任何 die 都由 trap 撤销，
	#    避免半套 ipset 段留在 /tmp/.uci 被下一次无关 commit 带进配置。
	WIRE_DIRTY=1
	_wire_cleanup() {
		[ "${WIRE_DIRTY:-0}" = "1" ] && uci -q revert mwan3 2>/dev/null
	}
	trap '_wire_cleanup; _op_lock_cleanup' EXIT
	trap '_wire_cleanup; _op_lock_cleanup; exit 130' INT TERM
	_wire_all() { _wire_ipset "$1" "$2"; }
	foreach_enabled _wire_all

	# 4. 生成候选配置：备份原文 -> 文本级重写 ipset 段 -> splice 规则。
	#    此时正式配置仍未被改动（只有 uci 暂存区）。
	cp "$CFG_MWAN3" "$WORK/mwan3.bak" ||
		die "无法备份 $CFG_MWAN3 到 $WORK/mwan3.bak，配置未改动"
	cp "$WORK/mwan3.bak" "$WORK/mwan3.base" ||
		die "无法生成待校验配置（$WORK/mwan3.base），配置未改动"
	_wire_text_all() {
		_wire_ipset_text "$1" "$2" ||
			die "无法在候选配置中重写 ipset 段 'ipset_$2'，配置未改动"
	}
	foreach_enabled _wire_text_all
	awk -v insert="$WORK/rules.txt" -v anchor="$ISP_ANCHOR" \
		-f "$LIB/rules-splice.awk" "$WORK/mwan3.base" > "$WORK/mwan3.new" ||
		die "规则注入失败（锚点缺失或配置不可写），配置未改动"

	# 冒烟检查：必须用新生成的 mwan3.new 做 uci 解析，而不是读旧的
	# /etc/config/mwan3（旧写法永远通过，等于没检查）。把新文件放到临时
	# 目录并命名为 mwan3，校验通过后才替换正式配置。
	_ucitmp=$WORK/uci-check
	rm -rf "$_ucitmp"
	mkdir -p "$_ucitmp" || die "无法创建 uci 校验临时目录"
	cp "$WORK/mwan3.new" "$_ucitmp/mwan3" || {
		rm -rf "$_ucitmp"
		die "无法复制待校验配置，已放弃（备份在 $WORK/mwan3.bak）"
	}
	if ! uci -q -c "$_ucitmp" show mwan3 >/dev/null 2>&1; then
		rm -rf "$_ucitmp"
		die "注入后的 mwan3 配置无法解析，已放弃（备份在 $WORK/mwan3.bak）"
	fi
	rm -rf "$_ucitmp"

	# 5. 内容校验通过后，才原子替换正式配置。
	_mwan3_atomic_install "$WORK/mwan3.new" ||
		die "无法原子替换 $CFG_MWAN3（已放弃，备份在 $WORK/mwan3.bak）"

	# 6. 落盘之后才 commit uci 暂存区。文件里已经是同一份校验过的内容；
	#    commit 失败也只清暂存区并告警，不把已校验的配置回退成旧的。
	if uci -q commit mwan3; then
		WIRE_DIRTY=0
		trap '_op_lock_cleanup' EXIT
		trap '_op_lock_cleanup; exit 130' INT TERM
	else
		uci -q revert mwan3 2>/dev/null
		WIRE_DIRTY=0
		trap '_op_lock_cleanup' EXIT
		trap '_op_lock_cleanup; exit 130' INT TERM
		log "警告：uci commit mwan3 失败；$CFG_MWAN3 已按校验通过的内容原子替换"
	fi
	log "mwan3 配置已写入（备份: $WORK/mwan3.bak），正在 reload…"
	# 7. 收敛孤儿 ipset / policy / member 段：必须放在规则块重写之后 ——
	# 改名时旧规则这一刻才消失；若在注入前做，旧规则仍引用旧段，会被
	# 误判成"保留"。
	_prune_orphan_ipsets
	_prune_orphan_policies
	if ! "$MWAN3_SERVICE" reload; then
		die "mwan3 reload 失败：新配置已写入 $CFG_MWAN3 但未生效（备份: $WORK/mwan3.bak）。回滚: cp $WORK/mwan3.bak $CFG_MWAN3 && $MWAN3_SERVICE reload；或检查 mwan3 日志后重试 wire"
	fi
	if ! _wire_runtime_check; then
		die "mwan3 reload 返回成功，但 nft 规则未按预期渲染（缺失集合:${_WRC_MISSING:- ?}；备份: $WORK/mwan3.bak）。回滚: cp $WORK/mwan3.bak $CFG_MWAN3 && $MWAN3_SERVICE reload；或检查 mwan3 日志后重试 wire"
	fi
	if [ "${RULE_ENABLED:-1}" = "1" ]; then
		log "规则已注入并生效（备份: $WORK/mwan3.bak）"
	else
		log "规则已写入但未启用（stage；备份: $WORK/mwan3.bak）"
	fi
	_fallback_counter ||
		log "警告：兜底计数规则未安装成功（status 的兜底行会缺失或为旧值），可稍后重跑 isp-split apply"
	# reload 刚返回时 mwan3_output 往往还没重建完，这里必须等/重试；
	# 恢复失败要让 wire 以非 0 退出，否则 sync/install 会误报成功。
	_dns_bypass 1 1 ||
		die "DNS 旁路规则恢复失败（mwan3 未就绪或 DNS_TABLE 配置错误，见上方日志）"
}

# F-28：wire 写 WORK 下的候选配置并 reload mwan3，与 fetch/apply 互斥。
cmd_wire() {
	_op_lock_acquire || die "另一个 isp-split 操作正在运行（锁: $OP_LOCK），本次未做任何改动；稍后重试"
	_cmd_wire_body
	_rc=$?
	_op_lock_release
	return $_rc
}

# 只刷新数据并原子刷入，不重写 mwan3 配置、不 reload mwan3。
# 日常定时任务应该用它：规则结构没变，每天重写配置再 reload 是没必要的抖动。
cmd_refresh() {
	_op_lock_acquire || die "另一个 isp-split 操作正在运行（锁: $OP_LOCK），本次未做任何改动；稍后重试"
	cmd_fetch
	cmd_apply
	_op_lock_release
	log "数据已刷新（mwan3 配置未改动）。要改规则映射请用 wire / sync"
}

cmd_sync() {
	_openclash_fakeip_warn
	_op_lock_acquire || die "另一个 isp-split 操作正在运行（锁: $OP_LOCK），本次未做任何改动；稍后重试"
	cmd_fetch
	cmd_wire
	cmd_apply
	_op_lock_release
	log "同步完成，用 isp-split status 验证"
}

# ---------------------------------------------------------------- stage / enable / verify

# 两阶段启用（dark launch）：
#   第一次注入时把规则写成 enabled=0，mwan3 会完全跳过它们；
#   若我们的配置有问题，也只会影响"这些新规则不生效"，
#   不可能波及你原有的分流规则。确认 mwan3 仍健康后再翻正。
cmd_stage() {
	RULE_ENABLED=0
	cmd_wire
	echo
	log "规则已注入但未启用（enabled=0），现有分流配置不受影响。"
	echo "请先确认 mwan3 没被搞坏："
	echo "  nft list chain inet mwan3 mwan3_rules"
	echo "  mwan3 status"
	echo "确认无误后执行: isp-split enable"
}

cmd_enable() {
	RULE_ENABLED=1
	cmd_wire
	cmd_verify
}

cmd_verify() {
	_openclash_fakeip_warn
	need_nft
	echo "== mwan3_rules 链中的规则顺序（自上而下，命中即停）"
	_body=$(_mwan3_rules_body)
	_chain=$(printf '%s\n' "$_body" | sed 's/^[[:space:]]*/  /')
	if [ -n "$_chain" ]; then
		echo "$_chain"
	else
		echo "  ！链为空 —— 这是异常信号，请立刻检查 mwan3 日志"
	fi

	# 第一条 catch-all 的行号。只有真正无条件的兜底才算：
	#   1) ip daddr 0.0.0.0/0（direct policy jump 与 mwan3 3.6 的
	#      jump mwan3_rule_default_rule_v4 间接跳转都覆盖）；
	#   2) mark==0 的 policy jump，且整行是“纯 mark 条件”（可带
	#      meta nfproto ipv4 前缀），没有任何 ip saddr/daddr/tcp/udp 等
	#      前置匹配条件。tcp dport 443 这类用户规则不是无条件兜底。
	# 用户自己的 `ip saddr 192.168.50.9 ... jump mwan3_policy_balanced`（示例地址）
	# 只匹配特定源地址，对其他源是 no-op，绝不能当成 catch-all。
	_ca=$(printf '%s\n' "$_body" | awk '
		/ip daddr 0\.0\.0\.0\/0/ { print NR; exit }
		/^[[:space:]]*(meta nfproto ipv4[[:space:]]+)?meta mark (&|and) 0x0*3f00 == 0x0+/ &&
		/(jump|goto) mwan3_policy_/ &&
		!/ip (saddr|daddr)/ { print NR; exit }
	')

	echo
	echo "== 本工具的运营商规则是否已渲染且在 catch-all 之前"
	# mwan3-nft 3.6 不再为每条规则建独立子链，规则直接内联在 mwan3_rules 里，
	# 形如：ip daddr @isp_tel meta mark & 0x3f00 == 0x0 jump mwan3_policy_wan_only
	_vf() {
		_ln=$(printf '%s\n' "$_body" | grep -n "@$2 " | head -1 | cut -d: -f1)
		if [ -z "$_ln" ]; then
			echo "  ✗ @$2 未出现在 mwan3_rules（enabled=0 或注入失败）"
		elif [ -n "$_ca" ] && [ "$_ln" -ge "$_ca" ]; then
			echo "  ✗ @$2 位于第一条 catch-all 之后（规则不会命中）"
		else
			echo "  ✓ @$2 -> $3"
		fi
	}
	foreach_enabled _vf

	echo
	echo "== 策略存在且有成员（wire 渲染前置条件，BUG-25）"
	_vp_any=0
	for _vp_pol in $(_isp_policies); do
		_vp_any=1
		_vp_msg=$(_policy_problem "$_vp_pol")
		if [ -n "$_vp_msg" ]; then
			echo "  ✗ $_vp_pol: $_vp_msg"
		else
			echo "  ✓ $_vp_pol: $(_policy_desc "$_vp_pol")"
		fi
	done
	[ "$_vp_any" = "1" ] || echo "  （ISP_TABLE 没有引用任何策略）"

	echo
	# 运行时规则自检：mwan3 重启会清掉 apply/dns 运行时插入的规则
	if ! _dns_chain_ok && [ -n "${DNS_TABLE:-}" ]; then
		echo "⚠ DNS 旁路规则缺失（mwan3 重启会清掉运行时插入的规则）"
		echo "  恢复：isp-split dns   （或 isp-split wire）"
	fi
	# 兜底计数器由 COUNT_FALLBACK 控制，与 DNS_TABLE 无关（B-2）。
	if [ "${COUNT_FALLBACK:-1}" = "1" ] &&
	   [ "$(nft list chain inet mwan3 mwan3_rules 2>/dev/null | grep -c "counter name")" -eq 0 ]; then
		echo "⚠ 兜底计数规则缺失（同上）"
		echo "  恢复：isp-split apply"
	fi
	echo
	echo "== 策略成员状态"
	mwan3 status 2>/dev/null | head -24 || echo "  (mwan3 status 不可用)"
}


# ---------------------------------------------------------------- plan (dry-run)

# dnsmasq 是否编译了 nftset（域名→nftset 钉线的前置条件，F-5）。
# 返回：0=支持，1=不支持，2=没有 dnsmasq。
# OpenWrt 基础版 dnsmasq 的编译选项是 no-ipset no-nftset，必须装
# dnsmasq-full 才有。这里只检测并提示，不阻断、不改变任何行为。
_dnsmasq_nftset() {
	command -v dnsmasq >/dev/null 2>&1 || return 2
	_dn_opts=$(dnsmasq --version 2>/dev/null |
		sed -n 's/^Compile time options:[[:space:]]*//p' | head -1)
	# 没有 Compile time options 行的老版本无法证明支持，按不支持处理
	[ -n "$_dn_opts" ] || return 1
	case " $_dn_opts " in
	*" no-nftset "*) return 1 ;;
	*" nftset "* | *" +nftset "*) return 0 ;;
	esac
	return 1
}

# 只读预演：不写任何文件、不发任何 uci/nft 改动。
cmd_plan() {
	_openclash_fakeip_warn
	# 预检放在最前面：策略缺失时后面的"预演"全是假的（mwan3 会把整条
	# 规则静默丢掉），必须先让用户看到根因（BUG-25）。
	_precheck_policies ||
		die "策略预检失败：上面的策略在 mwan3 中缺失或没有可用成员，预演结果不可信。运行 isp-split init 绑定线路后重试"
	echo "== openwrt-isp-split 预演（不做任何改动）"
	echo
	printf '%-14s %-14s %-12s %8s %10s\n' "集合" "来源" "策略" "条目" "状态"
	_p1() {
		_src=$1
		_set=$2
		_pol=$3
		_n="-"
		if [ -s "$DATA/$_src.txt" ]; then
			_n=$(wc -l < "$DATA/$_src.txt" | tr -d ' ')
		else
			_n="无数据"
		fi
		_st="待创建"
		nft list set inet mwan3 "$_set" >/dev/null 2>&1 && _st="已存在"
		printf '%-14s %-14s %-12s %8s %10s\n' "$_set" "$_src" "$_pol" "$_n" "$_st"
	}
	foreach_enabled _p1

	echo
	_sources_report

	echo
	echo "未启用的运营商（其地址段不会被任何策略接管，落到兜底规则）:"
	_dp_body=$(_mwan3_rules_body)
	_dp_any=0
	for row in $ISP_TABLE; do
		_old_ifs=$IFS
		IFS=':'
		set -- $row
		IFS=$_old_ifs
		case "$row" in \#*) continue ;; esac
		[ $# -ge 4 ] ||
			die "ISP_TABLE 行格式错误（应为 源:集合:策略:0|1）: $row"
		_row_check "$1" "$2" "$3" "$4"
		[ "$4" = "0" ] || continue
		_dp_any=1
		_dp_res=""
		nft list set inet mwan3 "$2" >/dev/null 2>&1 && _dp_res="$_dp_res nft集合"
		printf '%s\n' "$_dp_body" | grep -q "@$2 " && _dp_res="$_dp_res nft规则"
		uci -q get "mwan3.ipset_$2" >/dev/null 2>&1 && _dp_res="$_dp_res mwan3配置段"
		if [ -n "$_dp_res" ]; then
			printf '  - %s（集合 %s）: 检测到残留 ->%s\n' "$1" "$2" "$_dp_res"
		else
			printf '  - %s（集合 %s）: 当前 nft/mwan3 中无残留\n' "$1" "$2"
		fi
	done
	[ "$_dp_any" = "1" ] || echo "  （无）"
	echo "  说明: 未启用项不会被 apply 写入；残留的 nft 规则会在下次 wire 重写规则块时移除，"
	echo "        mwan3 ipset 配置段（带 isp_split=1 标记）会在下次 wire 与 uninstall 时清理。"

	echo
	echo "dnsmasq confdir（多线路 DNS 片段写入位置）:"
	# 只读探测：不写 UCI、不写文件。
	_dns_confdir_detect >/dev/null
	_cur=$(_dns_confdir_get)
	_clean=$(_dns_confdir_clean "$_cur")
	_confdir=$(_dns_confdir_first "$DNS_CONFDIR_DIRS")
	case "$DNS_CONFDIR_SOURCE" in
	runtime)
		echo "  将写入 dnsmasq 实际加载目录: $_confdir"
		echo "  探测自: $DNS_CONFDIR_DETAIL（运行配置优先于 UCI${_cur:+；UCI 当前值 $_cur}${_cur:-；UCI confdir 未设置}）"
		if [ -z "$_cur" ]; then
			echo "  说明: UCI confdir 未设置，dnsmasq 实际加载目录由运行配置决定"
			echo "        （OpenClash 接管 DNS 时即为 /tmp/dnsmasq.<实例>.d）；不做改动。"
		else
			echo "  说明: 以运行配置为准，不改写 UCI confdir（避免 OpenClash 关闭后残留）。"
		fi
		;;
	ps)
		echo "  将写入 dnsmasq 实际加载目录: $_confdir"
		echo "  探测自: 运行中的 dnsmasq 进程参数（--conf-dir，优先级高于 UCI）"
		echo "  说明: 不改写 UCI confdir（UCI 当前值: ${_cur:-未设置}）。"
		;;
	uci)
		echo "  当前值: ${_cur:-（未设置）}"
		echo "  探测来源: UCI dhcp.@dnsmasq[0].confdir"
		if [ "$_clean" != "$_cur" ]; then
			echo "  将修改 confdir: ${_clean:-（删除该项，回到默认 $DNSMASQ_DEFAULT_DIR）}（移除旧版写入的 $DNSDIR）"
		else
			echo "  将修改 confdir: 否（片段写入 $_confdir，不覆盖原值）"
		fi
		;;
	*)
		echo "  当前值: 未设置；未发现 dnsmasq 实际运行配置"
		echo "  将使用默认目录: $DNSMASQ_DEFAULT_DIR（不修改 UCI confdir）"
		;;
	esac

	echo
	echo "dnsmasq nftset 能力（域名钉线的前置条件；本版本只检测，不启用钉线）:"
	_dnsmasq_nftset
	_nf=$?
	case "$_nf" in
	0)
		echo "  ✓ 支持 nftset（dnsmasq 编译选项含 nftset）"
		;;
	1)
		echo "  ⚠ 不支持 nftset（dnsmasq 编译选项为 no-nftset 或缺少该选项）"
		echo "    域名钉线不可用；多线路 DNS 的 server=/域名/IP@接口 不受影响。"
		echo "    需要域名→nftset 时请安装 dnsmasq-full。"
		;;
	*)
		echo "  ⚠ 未找到 dnsmasq，无法检测 nftset 能力"
		;;
	esac

	echo
	echo "将会写入的 nft 集合声明（每个启用的运营商一个）:"
	_p3() {
		_src=$1
		_set=$2
		_pol=$3
		echo "  add set inet mwan3 $_set { type ipv4_addr; flags interval; auto-merge; counter; }"
		echo "  flush set inet mwan3 $_set   # 若已存在"
		echo "  add element inet mwan3 $_set { ... $(wc -l < "$DATA/$_src.txt" 2>/dev/null || echo 0) 条 ... }"
	}
	foreach_enabled _p3

	echo
	echo "将会写入的 mwan3 规则（插在第一个匹配 '$ISP_ANCHOR' 的规则之前）:"
	# B-6：mktemp 必须带模板并检查失败。裸 mktemp 在 TMPDIR 不可用时
	# 返回空串，随后 `: > ""` 把错误抛到下一行（can't create : nonexistent
	# directory），用户只看到半截输出。这里直接干净退出。
	_tmp=$(mktemp "${TMPDIR:-/tmp}/isp-split-plan-rules.XXXXXX" 2>/dev/null) ||
		die "无法创建临时文件（TMPDIR=${TMPDIR:-/tmp} 不可用？），plan 未做任何改动"
	: > "$_tmp" || {
		rm -f "$_tmp"
		die "无法写入临时文件 $_tmp，plan 未做任何改动"
	}
	_p4() {
		_src=$1
		_set=$2
		_pol=$3
		printf "  option ipset '%s'  ->  %s\n" "$_set" "$_pol" >> "$_tmp"
	}
	foreach_enabled _p4
	sed 's/^/  /' "$_tmp"
	rm -f "$_tmp"

	echo
	echo "注入后的规则匹配顺序（预演结果）:"
	if [ -f "$CFG_MWAN3" ]; then
		_rtmp=$(mktemp "${TMPDIR:-/tmp}/isp-split-plan-order.XXXXXX" 2>/dev/null) ||
			die "无法创建临时文件（TMPDIR=${TMPDIR:-/tmp} 不可用？），plan 未做任何改动"
		_wtmp=$(mktemp "${TMPDIR:-/tmp}/isp-split-plan-wire.XXXXXX" 2>/dev/null) || {
			rm -f "$_rtmp"
			die "无法创建临时文件（TMPDIR=${TMPDIR:-/tmp} 不可用？），plan 未做任何改动"
		}
		: > "$_wtmp" || {
			rm -f "$_rtmp" "$_wtmp"
			die "无法写入临时文件 $_wtmp，plan 未做任何改动"
		}
		_p5() {
			_src=$1
			_set=$2
			_pol=$3
			printf "config rule '%s'\n\toption ipset '%s'\n\toption use_policy '%s'\n\toption family 'ipv4'\n\toption isp_split '1'\n\n" \
				"$_set" "$_set" "$_pol" >> "$_wtmp"
		}
		foreach_enabled _p5
		awk -v insert="$_wtmp" -v anchor="$ISP_ANCHOR" \
			-f "$LIB/rules-splice.awk" "$CFG_MWAN3" > "$_rtmp" 2>/dev/null &&
			grep -oE "^config rule '[^']+'" "$_rtmp" |
			sed "s/.*'\(.*\)'/\1/" | awk '{ printf "  %d. %s\n", NR, $0 }'
		rm -f "$_rtmp" "$_wtmp"
	else
		echo "  (找不到 $CFG_MWAN3)"
	fi

	echo
	echo "预演结束，未做任何改动。确认无误后执行: isp-split sync"
}

# ---------------------------------------------------------------- status

_human() {
	awk -v n="$1" 'BEGIN {
		split("B KiB MiB GiB TiB", u, " ")
		i = 1
		while (n >= 1024 && i < 5) { n /= 1024; i++ }
		printf "%.1f%s", n, u[i]
	}'
}

# mwan3_rules 链正文（去表头/结尾括号），供 status/verify 复用
_mwan3_rules_body() {
	nft list chain inet mwan3 mwan3_rules 2>/dev/null |
		sed -n '/chain mwan3_rules/,/^[[:space:]]*}/p' |
		grep -vE '^[[:space:]]*(table|chain|type|})'
}

_status_one() {
	_src=$1
	_set=$2
	_pol=$3
	if ! nft list set inet mwan3 "$_set" >/dev/null 2>&1; then
		printf '%-14s %-10s %10s %12s %10s  %s\n' "$_set" "$_src" "缺失" "-" "-" "$_pol"
		return 0
	fi
	_out=$(nft list set inet mwan3 "$_set" 2>/dev/null)
	_n=$(count_set_elements mwan3 "$_set")
	_cnt=$(printf '%s\n' "$_out" | awk '
		/counter packets/ {
			for (i = 1; i <= NF; i++) {
				if ($i == "packets") p += $(i + 1)
				if ($i == "bytes") b += $(i + 1)
			}
		}
		END { printf "%.0f %.0f", p, b }')
	set -- $_cnt
	printf '%-14s %-10s %10s %12s %10s  %s\n' \
		"$_set" "$_src" "$_n" "$1" "$(_human "$2")" "$_pol"
}

# 机器可读输出，供 LuCI 渲染表格
# 运营商显示名（LuCI 卡片标题）；未列出的源名原样显示
_isp_label() {
	case "$1" in
	chinatelecom | chinanet | telecom) printf '%s' "电信" ;;
	unicom_cnc | unicom) printf '%s' "联通" ;;
	cmcc | mobile) printf '%s' "移动" ;;
	cernet) printf '%s' "教育网" ;;
	chinabtn) printf '%s' "铁通" ;;
	gwbn) printf '%s' "长城宽带" ;;
	othernet) printf '%s' "其他/IDC" ;;
	hk) printf '%s' "香港" ;;
	mo) printf '%s' "澳门" ;;
	tw) printf '%s' "台湾" ;;
	*) printf '%s' "$1" ;;
	esac
}

# 机器可读输出（LuCI 表格/卡片直接用）。形状保持 0.1.x 的数组不变，
# 只给每项加了一个 label 字段（运营商中文名），老消费者不受影响。
cmd_status_json() {
	_first=1
	printf '['
	_sj() {
		_src=$1
		_set=$2
		_pol=$3
		_ex=false
		nft list set inet mwan3 "$_set" >/dev/null 2>&1 && _ex=true
		_n=0
		_p=0
		_b=0
		if [ "$_ex" = "true" ]; then
			_n=$(count_set_elements mwan3 "$_set")
			_cnt=$(nft list set inet mwan3 "$_set" 2>/dev/null | awk '
				/counter packets/ {
					for (i = 1; i <= NF; i++) {
						if ($i == "packets") p += $(i + 1)
						if ($i == "bytes") b += $(i + 1)
					}
				}
				END { printf "%.0f %.0f", p, b }')
			set -- $_cnt
			_p=${1:-0}
			_b=${2:-0}
		fi
		[ "$_first" = "1" ] || printf ','
		_first=0
		printf '{"set":"%s","label":"%s","src":"%s","policy":"%s","exists":%s,"elements":%s,"packets":%s,"bytes":%s}' \
			"$_set" "$(_isp_label "$_src")" "$_src" "$_pol" "$_ex" "${_n:-0}" "${_p:-0}" "${_b:-0}"
	}
	foreach_enabled _sj
	if nft list counter inet mwan3 isp_fallback >/dev/null 2>&1; then
		_fbp=$(nft list counter inet mwan3 isp_fallback 2>/dev/null |
			grep -oE "packets [0-9]+" | grep -oE "[0-9]+")
		_fbb=$(nft list counter inet mwan3 isp_fallback 2>/dev/null |
			grep -oE "bytes [0-9]+" | grep -oE "[0-9]+")
		[ "$_first" = "1" ] || printf ','
		printf '{"set":"兜底","label":"兜底","src":"-","policy":"未命中集合","exists":true,"elements":"","packets":%s,"bytes":%s}' \
			"${_fbp:-0}" "${_fbb:-0}"
	fi
	printf ']'
	echo
}

# 环境状态：数据源健康度 / 双线健康 / 策略占比 / 数据时间 / 最近一次 diff。
# 与 status --json（nft 统计）分开放，是为了让后者的数组形状保持不变。
cmd_health_json() {
	_source_check_all
	_state_prune
	# A-08：数字位只接受纯数字，非法一律 0/null。此前
	# ISP_SOURCE_MAX_FAIL_DAYS='3,"injected":"yes"' 会直接拼进 JSON。
	_max_fail=${ISP_SOURCE_MAX_FAIL_DAYS:-0}
	case "$_max_fail" in '' | *[!0-9]*) _max_fail=0 ;; esac
	printf '{"sources":['
	_sfj=1
	for _row in $(_source_rows); do
		_source_split "$_row"
		_last=$(_state_get "$_s_name" last_ok)
		case "${_last:-}" in '' | *[!0-9]*) _last=0 ;; esac
		_fail=$(_state_get "$_s_name" fail)
		case "${_fail:-}" in '' | *[!0-9]*) _fail=0 ;; esac
		_jac=$(_state_get "$_s_name" jaccard)
		case "${_jac:-}" in '' | *[!0-9]*) _jac_json=null ;; *) _jac_json=$_jac ;; esac
		_st="ok"
		_en=true
		if [ "$_s_role" != "primary" ] && [ "$ISP_VERIFY_ENABLE" != "1" ]; then
			_st="disabled"
			_en=false
		elif [ "$_fail" -gt 0 ]; then
			_st="degraded"
		fi
		[ "$_sfj" = "1" ] || printf ','
		_sfj=0
		printf '{"name":"%s","label":"%s","role":"%s","last_ok":%s,"last_ok_text":"%s","fail_days":%s,"jaccard":%s,"max_fail":%s,"status":"%s","enabled":%s}' \
			"$(_lc_json_esc "$_s_name")" "$(_lc_json_esc "$(_isp_label "$_s_name")")" \
			"$(_lc_json_esc "$_s_role")" "$_last" \
			"$(_lc_json_esc "$(_ts_text "$_last")")" "$_fail" "$_jac_json" \
			"$_max_fail" "$(_lc_json_esc "$_st")" "$_en"
	done
	printf ']'

	# 线路状态与策略占比（解析 mwan3 status；没装/没跑时返回空数组）
	_ml=$(_mwan3_lines 2>/dev/null)
	if [ -n "$_ml" ]; then
		printf '%s\n' "$_ml" | awk -F'\t' '
			function clean(s) { gsub(/[^A-Za-z0-9_.-]/, "", s); return s }
			$1 == "L" {
				name = clean($2)
				st[name] = clean($3)
				tr[name] = clean($4)
				if (!(name in seen)) { seen[name] = 1; order[++n] = name }
				next
			}
			$1 == "T" {
				k = clean($2)
				c[k]++
				tip[k, c[k]] = clean($3)
				tst[k, c[k]] = clean($4)
				next
			}
			$1 == "P" {
				p = clean($2)
				if (!(p in pseen)) { pseen[p] = 1; porder[++np] = p }
				m[p]++
				mif[p, m[p]] = clean($3)
				mpc[p, m[p]] = $4 + 0
				next
			}
			END {
				printf ",\"lines\":["
				for (i = 1; i <= n; i++) {
					if (i > 1) printf ","
					printf "{\"iface\":\"%s\",\"state\":\"%s\",\"tracking\":\"%s\",\"tracks\":[", order[i], st[order[i]], tr[order[i]]
					for (j = 1; j <= c[order[i]]; j++) {
						if (j > 1) printf ","
						printf "{\"ip\":\"%s\",\"state\":\"%s\"}", tip[order[i], j], tst[order[i], j]
					}
					printf "]}"
				}
				printf "],\"policies\":["
				for (i = 1; i <= np; i++) {
					if (i > 1) printf ","
					printf "{\"name\":\"%s\",\"members\":[", porder[i]
					for (j = 1; j <= m[porder[i]]; j++) {
						if (j > 1) printf ","
						printf "{\"iface\":\"%s\",\"pct\":%s}", mif[porder[i], j], mpc[porder[i], j]
					}
					printf "]}"
				}
				printf "]"
			}'
	else
		printf ',"lines":[],"policies":[]'
	fi
	if [ "$ISP_VERIFY_ENABLE" = "1" ]; then
		printf ',"verify_enabled":true'
	else
		printf ',"verify_enabled":false'
	fi

	_stamp=$(cat "$DATA/.stamp" 2>/dev/null | tr -d ' \t\r\n')
	case "${_stamp:-}" in '' | *[!0-9]*) _stamp=0 ;; esac
	# A-08：cron 是字符串位，必须完整 JSON 转义（含 LF/CR/引号/反斜杠）。
	_cron_safe=$(_lc_json_esc "$ISP_CRON")
	_next=$(_cron_next_epoch "$ISP_CRON" 2>/dev/null)
	case "${_next:-}" in '' | *[!0-9]*) _next=0 ;; esac
	printf ',"data":{"stamp":%s,"updated":"%s","cron":"%s","next_cron":"%s","next_epoch":%s}' \
		"$_stamp" "$(_lc_json_esc "$(_ts_text "$_stamp")")" "$_cron_safe" \
		"$(_lc_json_esc "$(_ts_text "$_next")")" "$_next"

	_dj=""
	_da=0
	if [ -f "$STATE_DIR/last-diff" ]; then
		_dj=$(awk '{ print $1; exit }' "$STATE_DIR/last-diff" 2>/dev/null)
		_da=$(awk '{ print $2; exit }' "$STATE_DIR/last-diff" 2>/dev/null)
	fi
	case "${_dj:-}" in '' | *[!0-9]*) _dj=null ;; esac
	case "${_da:-}" in '' | *[!0-9]*) _da=0 ;; esac
	printf ',"diff":{"min_jaccard":%s,"at":%s,"at_text":"%s"}' \
		"$_dj" "$_da" "$(_lc_json_esc "$(_ts_text "$_da")")"

	printf '}\n'
}

# 环境状态的文字版（命令行排查用）
cmd_health() {
	case "${1:-}" in
	--json)
		_openclash_fakeip_warn >&2
		cmd_health_json
		return 0
		;;
	esac
	_openclash_fakeip_warn
	_sources_report
	echo
	echo "线路状态:"
	_ml=$(_mwan3_lines 2>/dev/null)
	if [ -n "$_ml" ]; then
		printf '%s\n' "$_ml" | awk -F'\t' '
			$1 == "L" { printf "  %-8s %-8s tracking=%s\n", $2, $3, $4 }
			$1 == "T" { printf "    track %s: %s\n", $3, $4 }
			$1 == "P" { printf "  策略 %-12s %s %s%%\n", $2, $3, $4 }
		'
	else
		echo "  （mwan3 未运行，读不到线路状态）"
	fi
}

cmd_status() {
	if [ "${1:-}" = "--json" ]; then
		need_nft
		# stdout 必须是纯 JSON（LuCI 直接解析），告警只走 stderr
		_openclash_fakeip_warn >&2
		cmd_status_json
		return 0
	fi
	_openclash_fakeip_warn
	need_nft
	printf '%-14s %-10s %10s %12s %10s  %s\n' "集合" "来源" "条目" "命中包" "流量" "策略"
	_st_all() { _status_one "$1" "$2" "$3"; }
	foreach_enabled _st_all
	# status 是只读命令：绝不在这里删除/重插 nft 规则。
	# LuCI 每 10 秒轮询一次，自愈式重插会造成统计缺口和规则抖动。
	if [ "$COUNT_FALLBACK" = "1" ]; then
		_st_fb=$(_mwan3_rules_body | grep 'counter name "isp_fallback"')
		if ! _fallback_rule_ok "$_st_fb"; then
			echo "提示: 兜底计数规则缺失或形状不符（status 只读，不自动修复；运行 isp-split apply 恢复）"
		fi
	fi
	if nft list counter inet mwan3 isp_fallback >/dev/null 2>&1; then
		_fb=$(nft list counter inet mwan3 isp_fallback 2>/dev/null |
			grep -oE "packets [0-9]+ bytes [0-9]+" | head -1)
		_fp=$(echo "$_fb" | awk '{print $2}')
		_fb_b=$(echo "$_fb" | awk '{print $4}')
		# 与其余行走同一个 _human 换算，否则这一列是裸字节数、
		# 既不对齐也没有单位（B-3）。--json 里的 bytes 保持纯数字。
		printf '%-14s %-10s %10s %12s %10s  %s\n' \
			"兜底" "-" "-" "${_fp:-0}" "$(_human "${_fb_b:-0}")" "未命中集合"
	fi
	echo
	echo "注: 命中包/流量为连接首包统计（ct state new；后续包由 conntrack 恢复标记，不再经过规则链）。"
	echo "    DNS（udp/tcp 任一侧 53）不计入兜底，见 COUNT_FALLBACK_SKIP_DNS。"
	echo "    业务吞吐请看接口计数: cat /sys/class/net/pppoe-wan*/statistics/rx_bytes"
	echo
	_sources_report
	_next=$(_cron_next_epoch "$ISP_CRON" 2>/dev/null)
	[ -n "${_next:-}" ] && echo "  下次自动更新: $(_ts_text "$_next")（cron: $ISP_CRON）"
	# 兜底流量状态：先看 UCI 配置意图，再看链里实际渲染形状。
	# mwan3-nft 3.6 对 rule 有两种渲染形态，必须都认：
	#   直接： ip daddr 0.0.0.0/0 ... jump mwan3_policy_<default>
	#   间接： ip daddr 0.0.0.0/0 ... jump mwan3_rule_default_rule_v4
	#          （子链里才是 jump mwan3_policy_<default>）
	# 旧逻辑只看链尾的 policy jump，间接形态会被误报为“渲染异常”。
	_dr_exists=0
	uci -q get mwan3.default_rule_v4 >/dev/null 2>&1 && _dr_exists=1
	_dr_en=$(uci -q get mwan3.default_rule_v4.enabled 2>/dev/null)
	if [ "$_dr_exists" = "0" ] && [ -z "${_dr_en:-}" ]; then
		echo "提示: 未配置 mwan3 的 default_rule_v4（未命中集合的流量将走系统默认路由，不参与负载均衡）"
	elif [ "${_dr_en:-}" = "0" ]; then
		# enabled=0 是合法配置：沿用 BUG-2 的提示，不去查 mwan3 日志。
		echo "提示: mwan3 的 default_rule_v4 已被禁用（enabled=0）"
		echo "      未命中集合的流量将走系统默认路由（不参与负载均衡）。"
		echo "      如需启用：uci set mwan3.default_rule_v4.enabled=1 && uci commit mwan3 && /etc/init.d/mwan3 reload"
	else
		_body=$(_mwan3_rules_body)
		_dr=0
		_dr_kind=""
		_dr_child=""
		_dr_direct=$(printf '%s\n' "$_body" | awk '
			/ip daddr 0\.0\.0\.0\/0/ && /(jump|goto) mwan3_policy_/ { print; exit }
			/^[[:space:]]*(meta nfproto ipv4[[:space:]]+)?meta mark (&|and) 0x0*3f00 == 0x0+/ &&
			/(jump|goto) mwan3_policy_/ &&
			!/ip (saddr|daddr)/ && !/@isp_/ { print; exit }
		')
		if [ -n "$_dr_direct" ]; then
			_dr=1
			_dr_kind=direct
		else
			_dr_indirect=$(printf '%s\n' "$_body" |
				grep -E 'ip daddr 0\.0\.0\.0/0 .*(jump|goto) mwan3_rule_default_rule_v4' |
				head -1)
			if [ -n "$_dr_indirect" ]; then
				_dr=1
				_dr_kind=indirect
				_dr_child=$(printf '%s\n' "$_dr_indirect" |
					grep -oE '(jump|goto) mwan3_rule_[A-Za-z0-9_]+' |
					awk '{print $2}' | head -1)
			fi
		fi
		if [ "$_dr" -gt 0 ]; then
			if [ "$_dr_kind" = "indirect" ]; then
				# 存在间接跳转即视为 default 已渲染；能读到子链时再确认
				# 里面确实有 policy jump，读不到只降级为警告，不再误报异常。
				if [ -n "$_dr_child" ] &&
				   nft list chain inet mwan3 "$_dr_child" 2>/dev/null |
					grep -qE 'meta mark (&|and) 0x0*3f00 == 0x0+ .*(jump|goto) mwan3_policy_'; then
					echo "兜底流量: 已参与负载均衡（default 规则已渲染：$_dr_child 子链内 jump mwan3_policy_*）"
				else
					echo "兜底流量: default 规则已渲染（间接跳转 ${_dr_child:-mwan3_rule_default_rule_v4}）"
					echo "警告: 未能确认子链内的 mark==0 policy jump（可能渲染不完整，请检查 mwan3 日志）"
				fi
			else
				echo "兜底流量: 已参与负载均衡（default 规则已渲染，走其 use_policy 指定的策略）"
			fi
		else
			echo
			echo "提示: mwan3 的 default 规则渲染异常（链中既没有 0.0.0.0/0 直连 policy jump，也没有 jump mwan3_rule_default_rule_v4）"
			echo "      未命中集合的流量将走系统默认路由（不参与负载均衡）。"
			echo "      请检查 mwan3.default_rule_v4 是否存在、enabled 是否为 1，并查看 mwan3 日志。"
		fi
	fi

	echo "规则顺序:"
	nft list chain inet mwan3 mwan3_rules 2>/dev/null |
		sed -n '/chain mwan3_rules/,/^	}/p' |
		grep -vE '^[[:space:]]*(table|chain|type|})' |
		sed 's/^[[:space:]]*/  /'
}

# ---------------------------------------------------------------- probe

cmd_probe() {
	command -v curl >/dev/null 2>&1 || die "probe 需要 curl"
	# A-04：PROBE_URLS 来自被 source 的配置，读取侧先逐个校验；curl 再一律
	# 用 --url 传参，确保以 - 开头的 token 不会被解释成选项（-K/--config）。
	for url in $PROBE_URLS; do
		_url_read_ok "$url" ||
			die "PROBE_URLS 含非法项（必须是 http(s):// URL，且不能是 curl 选项）: $url"
	done
	for url in $PROBE_URLS; do
		echo "== $url"
		printf '  %-16s %12s %12s\n' "接口" "速率" "建连耗时"
		for ifc in $PROBE_IFACES; do
			res=$(curl -sS -o /dev/null -m "$PROBE_TIME" \
				-r "0-$PROBE_BYTES" --interface "$ifc" \
				-w '%{speed_download} %{time_connect}' --url "$url" 2>/dev/null) || {
				printf '  %-16s %12s\n' "$ifc" "失败"
				continue
			}
			set -- $res
			printf '  %-16s %9s MB/s %9s ms\n' "$ifc" \
				"$(awk -v v="$1" 'BEGIN { printf "%.2f", v / 1048576 }')" \
				"$(awk -v v="$2" 'BEGIN { printf "%.0f", v * 1000 }')"
		done
	done
	echo
	echo "按实测结果把 ISP_TABLE 里对应运营商的策略改成更快的那条线。"
	echo "注意：目标站点的 CDN 分配会变，单次结果仅供参考，必要时用域名级覆盖兜底。"
}

# ---------------------------------------------------------------- dns

DNSDIR=${DNSDIR:-/etc/isp-split/dnsmasq.d}
# dnsmasq 未显式配置 confdir 时的默认目录。允许覆盖仅用于离线自检。
DNSMASQ_DEFAULT_DIR=${DNSMASQ_DEFAULT_DIR:-/tmp/dnsmasq.d}
# dnsmasq 实际运行配置的 glob（OpenWrt 为 /var/etc/dnsmasq.conf.<uci section>）。
# 允许覆盖仅用于离线自检；生产环境不要设置。
DNSMASQ_CONF_GLOB=${DNSMASQ_CONF_GLOB:-/var/etc/dnsmasq.conf.*}
DNS_STATE=$BASE/state
# 多线路 DNS 的出线方式：
#   bind = server=/域名/IP@接口（dnsmasq 的 SO_BINDTODEVICE，只有 dnsmasq 支持）
#   mark = server=/域名/IP + nft 给发往该 DNS 的 53 端口打对应策略的标
#          不依赖 @接口，任何能按域名指定上游的解析器都适用；且在 OpenClash
#          接管 DNS 的场景下更抗干扰（我们只看目标 IP，不管是谁发的包）
DNS_MODE=${DNS_MODE:-bind}
# 统计"未命中任何运营商集合"的兜底流量（isp_fallback 计数器，status 里显示）
COUNT_FALLBACK=${COUNT_FALLBACK:-1}
# 兜底计数是否排除 DNS（udp/tcp 任一侧 53）。默认 1=排除：DNS 查询和
# 响应都不是业务流量，计入兜底只会稀释真实业务流量的口径。0=旧行为。
COUNT_FALLBACK_SKIP_DNS=${COUNT_FALLBACK_SKIP_DNS:-1}
DOMAINDIR=${DOMAIN_LIST_DIR:-${DOMAINDIR:-/etc/isp-split/domains}}

# A-05：DNSDIR 是旧版片段目录，既会从 UCI confdir 里被删，也会作为
# `rm -f "$dir/isp-split.conf"` 的删除根。只允许工具自己的 $BASE 子树，
# 或 dnsmasq/OpenClash 的 /tmp/dnsmasq*.d 形态；/etc、/、.. 一律拒绝。
_dnsdir_ok() {
	_ddo=${1:-}
	case "$_ddo" in "" | "/" | "." | ".." | *..*) return 1 ;; esac
	case "$_ddo" in /*) ;; *) return 1 ;; esac
	while [ "$_ddo" != "/" ] && [ "${_ddo%/}" != "$_ddo" ]; do
		_ddo=${_ddo%/}
	done
	case "$_ddo" in
	"$BASE"/*) return 0 ;;
	/tmp/dnsmasq*.d | /tmp/*/dnsmasq*.d) return 0 ;;
	esac
	return 1
}

_dnsdir_check() {
	_dnsdir_ok "$DNSDIR" ||
		die "DNSDIR 非法：'$DNSDIR'（只允许 $BASE 子树或 /tmp/dnsmasq*.d）"
}

# ---- dnsmasq confdir：探测"实际加载目录"，绝不整体覆盖 ----
# 历史版本曾把 dhcp.@dnsmasq[0].confdir 直接改成 $DNSDIR，导致
# OpenClash/adblock 等放在 /tmp/dnsmasq.d 的片段全部失效。
#
# BUG-6：OpenClash 接管 DNS 时，UCI 的 confdir 可能为空，但 dnsmasq init
# 生成的 /var/etc/dnsmasq.conf.cfgXXXX 里写着 conf-dir=/tmp/dnsmasq.cfgXXXX.d。
# 只信 UCI 会把片段写进没人加载的 /tmp/dnsmasq.d，分线 DNS 静默失效。
# 因此按以下优先级探测：
#   a. /var/etc/dnsmasq.conf.* 里的 conf-dir= 行（权威源，可能有多个实例/目录）
#   b. 运行中 dnsmasq 进程的 --conf-dir= 参数（busybox ps 取不到时跳过）
#   c. UCI dhcp.@dnsmasq[0].confdir（兼容没有 /var/etc 的环境）
#   d. 默认 $DNSMASQ_DEFAULT_DIR
#
# _dns_confdir_detect 设置三个全局变量并打印目录（每行一个）：
#   DNS_CONFDIR_SOURCE = runtime | ps | uci | default
#   DNS_CONFDIR_DETAIL = 来源细节（实际运行配置文件 / ps / uci）
#   DNS_CONFDIR_DIRS   = 空格分隔的目录列表
#
# 重要：探测到 runtime/ps 目录时，dns 子命令不再改写 UCI confdir。
# OpenClash 关闭后 UCI 里若残留一个"看起来合理"的 confdir，反而会改变
# dnsmasq 的加载路径。UCI 只在它本身就是权威源时（有值且无运行配置）才处理。
_dns_confdir_get() {
	uci -q get dhcp.@dnsmasq[0].confdir 2>/dev/null || true
}

# 去掉旧版本写入的 $DNSDIR，其余目录原样保留。
# A-05：DNSDIR 未通过白名单时绝不从 UCI 列表里摘目录（防止 /etc 被抹）。
_dns_confdir_clean() {
	_clean_in=${1:-}
	_clean_out=""
	for _clean_d in $_clean_in; do
		if [ "$_clean_d" = "$DNSDIR" ] && _dnsdir_ok "$DNSDIR"; then
			continue
		fi
		_clean_out="${_clean_out:+$_clean_out }$_clean_d"
	done
	printf '%s\n' "$_clean_out"
}

# 解析一个 conf-dir= 的值：容忍尾斜杠、空格分隔的多个目录、逗号后的
# 文件扩展名过滤（dnsmasq 的 conf-dir=<dir>[,<ext>...] 语法），并去重。
_dns_confdir_parse() {
	_parse_out=""
	for _parse_tok in ${1:-}; do
		_parse_dir=${_parse_tok%%,*}
		case "$_parse_dir" in
		\"*\") _parse_dir=${_parse_dir#\"}; _parse_dir=${_parse_dir%\"} ;;
		\'*\') _parse_dir=${_parse_dir#\'}; _parse_dir=${_parse_dir%\'} ;;
		esac
		while [ "$_parse_dir" != "/" ] && [ "${_parse_dir%/}" != "$_parse_dir" ]; do
			_parse_dir=${_parse_dir%/}
		done
		[ -n "$_parse_dir" ] || continue
		case " $_parse_out " in
		*" $_parse_dir "*) continue ;;
		esac
		_parse_out="${_parse_out:+$_parse_out }$_parse_dir"
	done
	printf '%s\n' "$_parse_out"
}

_dns_confdir_detect_emit() {
	for _emit_d in ${DNS_CONFDIR_DIRS:-}; do
		printf '%s\n' "$_emit_d"
	done
}

# 目录能不能作为片段写入目标（安全判定，不是功能判定）：
#   - 必须是绝对路径、已存在的目录
#   - 属主必须是 root
#   - 组/其他人不可写
# 为什么：片段是 root 写的，dnsmasq 也是 root 读的。若目录属于低权用户
# （例如他在 /tmp 下预先建好 conf-dir 目标），他就能在里面放
# isp-split.conf -> /etc/... 之类的符号链接，让 root 写穿到任意文件。
_dns_dir_ok() {
	_dk_d=${1:-}
	case "$_dk_d" in /*) ;; *) return 1 ;; esac
	[ -d "$_dk_d" ] || return 1
	_dk_m=$(ls -ldn "$_dk_d" 2>/dev/null) || return 1
	[ -n "$_dk_m" ] || return 1
	_dk_uid=$(printf '%s\n' "$_dk_m" | awk '{print $3}')
	_dk_mode=$(printf '%s\n' "$_dk_m" | awk '{print $1}')
	[ "$_dk_uid" = "0" ] || return 1
	case "$_dk_mode" in
	?????w* | ????????w*) return 1 ;;
	esac
	return 0
}

_dns_confdir_detect() {
	DNS_CONFDIR_SOURCE=""
	DNS_CONFDIR_DETAIL=""
	DNS_CONFDIR_DIRS=""

	# a) 权威源：dnsmasq 实际运行配置。glob 可能匹配多个实例，逐个读。
	_dcd_dirs=""
	_dcd_files=""
	for _dcd_f in $DNSMASQ_CONF_GLOB; do
		[ -f "$_dcd_f" ] || continue
		_dcd_file_used=0
		while IFS= read -r _dcd_line || [ -n "$_dcd_line" ]; do
			_dcd_line=$(printf '%s' "$_dcd_line" | tr -d '\r' |
				sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
			case "$_dcd_line" in
			conf-dir=*) _dcd_val=${_dcd_line#conf-dir=} ;;
			*) continue ;;
			esac
			# 容忍行尾注释（目录名里出现 # 的极端情况不支持）。
			_dcd_val=${_dcd_val%%#*}
			_dcd_parsed=$(_dns_confdir_parse "$_dcd_val")
			for _dcd_d in $_dcd_parsed; do
				case " $_dcd_dirs " in
				*" $_dcd_d "*) continue ;;
				esac
				# 运行配置是 root 生成的，正常都在这里通过；
				# 通过不了说明目录本身不可信（被低权用户接管），不往里写。
				_dns_dir_ok "$_dcd_d" || {
					log "警告：忽略不可信的 dnsmasq conf-dir（属主非 root 或组/他人可写）: $_dcd_d"
					continue
				}
				_dcd_dirs="${_dcd_dirs:+$_dcd_dirs }$_dcd_d"
				_dcd_file_used=1
			done
		done < "$_dcd_f"
		[ "$_dcd_file_used" = "1" ] || continue
		_dcd_files="${_dcd_files:+$_dcd_files }$_dcd_f"
	done
	if [ -n "$_dcd_dirs" ]; then
		DNS_CONFDIR_SOURCE=runtime
		DNS_CONFDIR_DETAIL=$_dcd_files
		DNS_CONFDIR_DIRS=$_dcd_dirs
		_dns_confdir_detect_emit
		return 0
	fi

	# b) 兜底：运行中的 dnsmasq 进程参数。busybox ps 默认截断命令行，
	#    "ps w" 在 OpenWrt 上能取到完整参数；取不到就继续往下走。
	#
	#    这里只认「命令行里出现了 dnsmasq 的可执行路径」的进程，
	#    不再用 /dnsmasq/ 整行匹配：任何进程只要参数里带
	#    "dnsmasq --conf-dir=/somewhere"（例如 sh -c '... dnsmasq
	#    --conf-dir=/tmp/evil'）都能骗过整行匹配，把 root 的写目标
	#    指到别处。取到的目录还会再过 _dns_dir_ok + 名字检查。
	_dcd_ps=$(ps w 2>/dev/null || ps 2>/dev/null || true)
	if [ -n "$_dcd_ps" ]; then
		_dcd_ps_vals=$(printf '%s\n' "$_dcd_ps" | awk '
			{
				is_dnsmasq = 0
				for (i = 1; i <= NF; i++)
					if ($i ~ /(^|\/)dnsmasq$/) { is_dnsmasq = 1; break }
				if (!is_dnsmasq) next
				for (i = 1; i <= NF; i++) {
					if ($i ~ /^--conf-dir=/) print substr($i, 12)
					else if ($i == "--conf-dir" && i < NF) print $(i + 1)
				}
			}')
		_dcd_ps_dirs=""
		for _dcd_d in $(_dns_confdir_parse "$_dcd_ps_vals"); do
			if ! _dns_dir_ok "$_dcd_d"; then
				log "警告：忽略不可信的 dnsmasq conf-dir（属主非 root 或组/他人可写）: $_dcd_d"
				continue
			fi
			# 进程参数是弱证据：再要求目录名符合 dnsmasq 的 conf-dir 约定，
			# 避免被引导到 /etc、/etc/init.d 这类系统目录。
			case "${_dcd_d##*/}" in
			*dnsmasq*) ;;
			*)
				log "警告：忽略不像 dnsmasq 配置目录的 --conf-dir: $_dcd_d"
				continue
				;;
			esac
			_dcd_ps_dirs="${_dcd_ps_dirs:+$_dcd_ps_dirs }$_dcd_d"
		done
		if [ -n "$_dcd_ps_dirs" ]; then
			DNS_CONFDIR_SOURCE=ps
			DNS_CONFDIR_DETAIL="ps dnsmasq --conf-dir"
			DNS_CONFDIR_DIRS=$_dcd_ps_dirs
			_dns_confdir_detect_emit
			return 0
		fi
	fi

	# c) UCI。注意：即使值里只有旧版的 $DNSDIR，也要让 source=uci，
	#    这样 cmd_dns 能按现状逻辑把旧目录从 UCI 里摘掉（可能摘成空列表）。
	_dcd_uci=$(_dns_confdir_get)
	if [ -n "$_dcd_uci" ]; then
		DNS_CONFDIR_SOURCE=uci
		DNS_CONFDIR_DETAIL="uci dhcp.@dnsmasq[0].confdir"
		DNS_CONFDIR_DIRS=$(_dns_confdir_clean "$_dcd_uci")
		_dns_confdir_detect_emit
		return 0
	fi

	# d) 都没有：dnsmasq 默认目录。
	DNS_CONFDIR_SOURCE=default
	DNS_CONFDIR_DETAIL="dnsmasq 默认目录"
	DNS_CONFDIR_DIRS=$DNSMASQ_DEFAULT_DIR
	_dns_confdir_detect_emit
}

# 探测结果里的第一个目录就是写入目标；列表为空时回落默认目录。
_dns_confdir_first() {
	_first_list=${1:-${DNS_CONFDIR_DIRS:-}}
	for _first_d in $_first_list; do
		printf '%s\n' "$_first_d"
		return 0
	done
	printf '%s\n' "$DNSMASQ_DEFAULT_DIR"
}

# 兼容旧调用点：参数为目录列表，缺省用最近一次探测结果。
_dns_confdir_target() {
	_dns_confdir_first "${1:-}"
}

# 所有可能残留 isp-split.conf 的目录：探测结果 + UCI 当前值 + 默认目录
# + 旧版目录。去重后输出。卸载/重写时用它做彻底清理。
# A-05：DNSDIR 只有在白名单内才参与删除；/etc、/、.. 穿越路径直接忽略。
_dns_confdir_candidates() {
	_cand_out=""
	for _cand_d in ${DNS_CONFDIR_DIRS:-} $(_dns_confdir_get) \
		"$DNSMASQ_DEFAULT_DIR"; do
		[ -n "$_cand_d" ] || continue
		case " $_cand_out " in
		*" $_cand_d "*) continue ;;
		esac
		_cand_out="${_cand_out:+$_cand_out }$_cand_d"
	done
	if _dnsdir_ok "$DNSDIR"; then
		case " $_cand_out " in
		*" $DNSDIR "*) ;;
		*) _cand_out="${_cand_out:+$_cand_out }$DNSDIR" ;;
		esac
	else
		log "警告：忽略白名单外的 DNSDIR（$DNSDIR），不据此删除任何片段" >&2
	fi
	printf '%s\n' "$_cand_out"
}

# A-05：删除片段的最后一道根目录限制。即便候选目录来自 UCI/runtime，
# 也绝不把 /、/etc 或含 .. 的路径当作删除根。
_dns_frag_delete_ok() {
	_fdo=${1:-}
	case "$_fdo" in "" | "/" | "/etc" | "." | ".." | *..*) return 1 ;; esac
	case "$_fdo" in /*) ;; *) return 1 ;; esac
	[ -d "$_fdo" ] || return 1
	return 0
}

_dns_confdir_set() {
	if [ -n "${1:-}" ]; then
		uci -q set "dhcp.@dnsmasq[0].confdir=$1" || return 1
	else
		uci -q delete dhcp.@dnsmasq[0].confdir 2>/dev/null || true
	fi
	uci -q commit dhcp
}

# B-5：升级兼容。旧状态文件名 dnsmasq.confdir.orig 里存的是"我们写进 UCI 的
# 清理值"，名字却在说"原值"（PROPOSAL-0.2.2 §B-5）。新名 dnsmasq.confdir.migrated；
# 旧名存在而新名不存在时读一次、迁到新名，之后只认新名。
# 注意：confdir 的恢复与片段清理不在这里——它们在 teardown.sh（卸载清理的
# 唯一实现），由 isp-split uninstall / install.sh --uninstall / 包 postrm 共用，
# 本文件不再保留第二份实现（F-14）。
_dns_state_adopt() {
	_new=$DNS_STATE/dnsmasq.confdir.migrated
	_old=$DNS_STATE/dnsmasq.confdir.orig
	[ -f "$_new" ] && return 0
	[ -f "$_old" ] || return 0
	if mv -f "$_old" "$_new" 2>/dev/null; then
		log "已把旧状态文件 dnsmasq.confdir.orig 迁移为 dnsmasq.confdir.migrated（B-5）"
	else
		cp -f "$_old" "$_new" 2>/dev/null && rm -f "$_old"
	fi
	return 0
}

# 策略 -> 出接口设备名（策略取第一个成员）
#
# F-13：bind 模式只允许绑定"运行时确实 up 且有 IPv4 地址"的 l3_device。
# 旧实现 ifstatus 没有 l3_device 时回退 uci network.X.device——PPPoE 未就绪
# 窗口里那是底层 VLAN（eth1.10），dnsmasq 会静默 SO_BINDTODEVICE 到没有
# 会话的口上，域名解析全部失败但脚本返回 0。
#
# 现在：
#   · 接口非 up / 无 IPv4 / 无 l3_device -> POLICY_DEV 为空，POLICY_DEV_STATE
#     给出原因（down/no-ip/no-l3），调用方（cmd_dns）跳过绑定并明确告警；
#   · 绝不回退 uci device 去"凑一个名字"，宁可少一条规则也不绑错口。
# 兼容：仍把设备名打印到 stdout，但需要状态时请读 POLICY_DEV_* 全局变量
# （命令替换会丢全局，cmd_dns 改为直接调用后读变量）。
policy_device() {
	POLICY_DEV=""
	POLICY_DEV_STATE="config"
	POLICY_DEV_IFACE=""
	_m=$(uci -q get "mwan3.$1.use_member" 2>/dev/null | awk '{print $1}')
	[ -n "$_m" ] || {
		POLICY_DEV_STATE="no-member"
		return 0
	}
	_i=$(uci -q get "mwan3.$_m.interface" 2>/dev/null)
	[ -n "$_i" ] || {
		POLICY_DEV_STATE="no-iface"
		return 0
	}
	POLICY_DEV_IFACE=$_i

	case "$(_iface_runtime_state "$_i")" in
	up) ;;
	*)
		POLICY_DEV_STATE="down"
		return 0
		;;
	esac

	_pd_raw=$(ifstatus "$_i" 2>/dev/null) || _pd_raw=""
	_d=""
	_ip=""
	if [ -n "$_pd_raw" ]; then
		if command -v jsonfilter >/dev/null 2>&1; then
			_d=$(printf '%s' "$_pd_raw" | jsonfilter -e '@.l3_device' 2>/dev/null)
			# 字段名带连字符，jsonfilter 必须用带引号的下标写法
			_ip=$(printf '%s' "$_pd_raw" |
				jsonfilter -e '@["ipv4-address"][0].address' 2>/dev/null)
		fi
		[ -n "$_d" ] || _d=$(printf '%s\n' "$_pd_raw" |
			sed -n 's/.*"l3_device"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
		# jsonfilter 不在时也不能把"能用的接口"误判成 no-ip：
		# 从 ifstatus 的 ipv4-address 段里取第一个 address。
		[ -n "$_ip" ] || _ip=$(printf '%s\n' "$_pd_raw" |
			sed -n '/"ipv4-address"/,/]/s/.*"address"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' |
			head -1)
	fi
	if [ -z "$_ip" ]; then
		POLICY_DEV_STATE="no-ip"
		return 0
	fi
	if [ -z "$_d" ]; then
		POLICY_DEV_STATE="no-l3"
		return 0
	fi
	POLICY_DEV=$_d
	POLICY_DEV_STATE="ok"
	printf '%s\n' "$_d"
}

# 策略 -> 该线路的 DNS
dns_of_policy() {
	_old=$IFS
	for _r in ${DNS_TABLE:-}; do
		IFS=':'
		set -- $_r
		IFS=$_old
		if [ "${1:-}" = "$_TARGET" ]; then echo "${2:-}"; return 0; fi
	done
	IFS=$_old
	return 0
}

# 多线路 DNS：让不同线路的 DNS 查询从各自接口发出
#
# 依据：dnsmasq 的 server=/域名/IP@接口 语法会用 SO_BINDTODEVICE 绑定出接口，
#      从而保证"从哪条线出去，就用哪条线的 DNS"，让 CDN 返回本运营商节点。
#      这是补齐与爱快差距的关键一环（原理见 docs/DESIGN.md 的
#      「与商业方案（iKuai）的对比」一节）。
#
# 同时要在 mwan3_output 链顶部给这些 DNS 服务器加 return，
# 否则 mwan3 会按目标 IP 重新打标，把 DNS 查询抢到别的线路上。
#
# mwan3_output 是否已经重建完成：
#   - 链存在
#   - hook output 已挂上
#   - 能看到 jump/goto mwan3_rules（mwan3 先建链、后填规则，只 list 成功不够）
_mwan3_output_ready() {
	_mo_out=$(nft list chain inet mwan3 mwan3_output 2>/dev/null) || return 1
	printf '%s\n' "$_mo_out" | grep -q 'hook output' || return 1
	printf '%s\n' "$_mo_out" |
		grep -qE '(^|[[:space:]])(jump|goto)[[:space:]]+mwan3_rules([[:space:]]|$)' ||
		return 1
	return 0
}

# 轮询等待 mwan3 重建完成。0=就绪，1=超时。
_mwan3_wait_ready() {
	_mw_tries=${MWAN3_READY_TRIES:-15}
	_mw_delay=${MWAN3_READY_DELAY:-1}
	_mw_i=0
	while [ "$_mw_i" -lt "$_mw_tries" ]; do
		_mwan3_output_ready && return 0
		_mw_i=$((_mw_i + 1))
		[ "$_mw_i" -lt "$_mw_tries" ] && sleep "$_mw_delay"
	done
	return 1
}

# mwan3_output 中本工具 DNS 旁路规则条数（按唯一 comment 统计）
_dns_rule_count() {
	nft -a list chain inet mwan3 mwan3_output 2>/dev/null |
		grep -c 'comment "isp-split-dns"' || true
}

# DNS 出线规则重放：mwan3 重建链后运行时插入的规则会丢失，
# wire / apply / dns / init.d(开机) 都会调用本函数恢复。
# bind 模式：return 旁路，出线由 dnsmasq 的 @接口 绑定决定
# mark 模式：jump 到该 DNS 所属策略，由 mwan3 打标选路
#
# 参数：
#   $1 = 1 时要求这次必须恢复成功（apply/wire 用）；0 时 mwan3 未运行可跳过（dns 用）
#   $2 = 1 表示调用前刚经历 reload（成功时打印"已恢复"）
# 返回：0 成功；1 mwan3 未就绪/重试耗尽；2 配置错误（重试无意义）
_dns_bypass() {
	_db_required=${1:-0}
	_db_recovered=${2:-0}

	# 先解析出去重后的条目并计算期望条数（每个条目 udp/tcp 各一条）。
	_db_seen=" "
	_db_expected=0
	_db_rows=""
	for _db_row in ${DNS_TABLE:-}; do
		_db_pol=${_db_row%%:*}
		_db_dns=${_db_row#*:}
		# A-07：策略名直接拼进 mwan3_policy_* argv，必须在任何 nft 调用
		# 之前过 _valid_policy（; 空白等都进不了 argv）。
		if ! _valid_policy "$_db_pol"; then
			log "错误：DNS_TABLE 策略名 '$_db_pol' 非法（只允许 [A-Za-z0-9_-]），未恢复 DNS 旁路规则"
			return 2
		fi
		[ -n "$_db_dns" ] || continue
		case "$_db_seen" in
		*" $_db_pol:$_db_dns "*) continue ;;
		esac
		_db_seen="$_db_seen $_db_pol:$_db_dns "
		_db_expected=$((_db_expected + 2))
		_db_rows="$_db_rows $_db_pol:$_db_dns"
	done

	# 配置错误重试也不会变好，必须在等待/插入前就明确报错。
	case "${DNS_MODE:-bind}" in
	bind | mark) ;;
	*)
		log "错误：DNS_MODE='${DNS_MODE:-}' 无效（只能是 bind 或 mark），未恢复 DNS 旁路规则"
		return 2
		;;
	esac
	for _db_kv in $_db_rows; do
		_db_dns=${_db_kv#*:}
		if ! _valid_ipv4 "$_db_dns"; then
			log "错误：DNS_TABLE 中的 '$_db_dns' 不是合法 IPv4（配置错误，未重试）"
			return 2
		fi
	done

	if ! nft list table inet mwan3 >/dev/null 2>&1; then
		[ "$_db_required" = "1" ] &&
			log "错误：table inet mwan3 不存在，无法恢复 DNS 旁路规则"
		[ "$_db_required" = "1" ] && return 1
		return 0
	fi

	# 没有 DNS 条目时只需清理旧规则，不必等待 mwan3 重建。
	if [ "$_db_expected" -eq 0 ]; then
		if _mwan3_output_ready; then
			_db_i=0
			while [ "$_db_i" -lt 256 ]; do
				_db_h=$(nft -a list chain inet mwan3 mwan3_output 2>/dev/null |
					grep 'comment "isp-split-dns"' |
					grep -oE '# handle [0-9]+' | head -1 | grep -oE '[0-9]+')
				[ -n "$_db_h" ] || break
				nft delete rule inet mwan3 mwan3_output handle "$_db_h" 2>/dev/null || break
				_db_i=$((_db_i + 1))
			done
		fi
		return 0
	fi

	_db_tries=${DNS_RETRY_TRIES:-15}
	_db_delay=${DNS_RETRY_DELAY:-1}
	_db_attempt=0
	_db_waited=$_db_recovered
	_db_lasterr="未知错误"

	while [ "$_db_attempt" -lt "$_db_tries" ]; do
		_db_attempt=$((_db_attempt + 1))

		# 链是否已就绪。未就绪时 list 可能成功但 insert 会 EBUSY，
		# 或 insert 成功后被紧接着的 flush 覆盖，所以必须轮询。
		if ! _mwan3_output_ready; then
			_db_waited=1
			_db_lasterr="mwan3_output 未就绪（缺少 hook output 或 jump mwan3_rules）"
			[ "$_db_attempt" -lt "$_db_tries" ] && sleep "$_db_delay"
			continue
		fi

		# mark 模式依赖策略子链；链不存在是配置错误，不应重试。
		if [ "${DNS_MODE:-bind}" = "mark" ]; then
			_db_badpol=0
			for _db_kv in $_db_rows; do
				_db_pol=${_db_kv%%:*}
				if ! nft list chain inet mwan3 "mwan3_policy_$_db_pol" >/dev/null 2>&1; then
					log "错误：DNS 打标目标 mwan3_policy_$_db_pol 不存在（配置错误，未重试）"
					_db_badpol=1
					break
				fi
			done
			[ "$_db_badpol" = "1" ] && return 2
		fi

		# 先按唯一 comment 删除所有旧规则：DNS_TABLE 改了策略/删了条目后，
		# 旧规则不会残留；重试时也不会越插越多。
		_db_i=0
		_db_del_fail=0
		while [ "$_db_i" -lt 256 ]; do
			_db_h=$(nft -a list chain inet mwan3 mwan3_output 2>/dev/null |
				grep 'comment "isp-split-dns"' |
				grep -oE '# handle [0-9]+' | head -1 | grep -oE '[0-9]+')
			[ -n "$_db_h" ] || break
			if ! nft delete rule inet mwan3 mwan3_output handle "$_db_h" 2>/dev/null; then
				_db_del_fail=1
				break
			fi
			_db_i=$((_db_i + 1))
		done
		if [ "$_db_del_fail" = "1" ]; then
			_db_waited=1
			_db_lasterr="删除旧 DNS 旁路规则失败（链正在重建）"
			[ "$_db_attempt" -lt "$_db_tries" ] && sleep "$_db_delay"
			continue
		fi

		_db_fail=0
		for _db_kv in $_db_rows; do
			_db_pol=${_db_kv%%:*}
			_db_dns=${_db_kv#*:}
			for _db_proto in udp tcp; do
				# nft 的 comment 是规则级属性，必须放在规则末尾（verdict 之后）。
				# 写成 comment ... return 会被真 nft 拒绝：
				#   Error: syntax error, unexpected return, expecting end of file
				# 删除循环按 comment 子串匹配，comment 在行尾仍能命中。
				if [ "${DNS_MODE:-bind}" = "mark" ]; then
					_db_err=$(nft insert rule inet mwan3 mwan3_output \
						ip daddr "$_db_dns" "$_db_proto" dport 53 counter \
						jump "mwan3_policy_$_db_pol" comment "isp-split-dns" 2>&1)
				else
					_db_err=$(nft insert rule inet mwan3 mwan3_output \
						ip daddr "$_db_dns" "$_db_proto" dport 53 counter \
						return comment "isp-split-dns" 2>&1)
				fi
				_db_rc=$?
				[ "$_db_rc" -eq 0 ] && continue
				_db_lasterr=$_db_err
				case "$_db_err" in
				"" | *"Device or resource busy"* | *"Resource temporarily unavailable"* | \
					*"No such file or directory"* | *"Interrupted system call"*)
					_db_fail=1
					;;
				*)
					log "错误：DNS 旁路规则 $_db_dns/$_db_proto 插入失败（配置错误，未重试）：$_db_err"
					return 2
					;;
				esac
				break 2
			done
		done

		if [ "$_db_fail" = "1" ]; then
			_db_waited=1
			[ "$_db_attempt" -lt "$_db_tries" ] && sleep "$_db_delay"
			continue
		fi

		# nft insert 返回 0 不代表规则最终还在：mwan3 可能正好在重建链，
		# 前几条插进旧链后就随 flush 消失。必须回读校验条数。
		_db_got=$(_dns_rule_count)
		if [ "$_db_got" -eq "$_db_expected" ]; then
			if [ "$_db_waited" = "1" ] || [ "$_db_attempt" -gt 1 ]; then
				log "mwan3 重建后 DNS 旁路规则已恢复（$_db_expected 条，第 $_db_attempt 次尝试）"
			fi
			return 0
		fi

		_db_waited=1
		_db_lasterr="插入后只看到 $_db_got/$_db_expected 条（链可能被 mwan3 重建覆盖）"
		[ "$_db_attempt" -lt "$_db_tries" ] && sleep "$_db_delay"
	done

	log "错误：DNS 旁路规则未能恢复（已尝试 $_db_attempt 次：$_db_lasterr）"
	return 1
}

# 检查 mwan3_output 里是否已有 DNS 旁路规则。
# nft 实际渲染会插入 "counter packets N bytes N"，匹配时必须容忍。
_dns_chain_ok() {
	nft list chain inet mwan3 mwan3_output 2>/dev/null |
		grep -qE 'dport 53 (counter packets [0-9]+ bytes [0-9]+ )?(return|jump mwan3_policy_)'
}

# 域名合法字符：只允许主机名/后缀实际会用到的字符。外部清单里出现
# 空格、/、#、分号等会破坏 server=/域名/IP 的语法（dnsmasq 可能直接
# 拒绝整份配置，全屋 DNS 中断），必须在拼接前挡掉。
_dns_domain_ok() {
	case "$1" in
	"" | .* | *[!A-Za-z0-9._-]*) return 1 ;;
	esac
	return 0
}

# 解析策略的出线 DNS 与绑定设备（F-13）。
# 成功：_DNS_DNS / _DNS_DEV / _DNS_BIND 就绪，返回 0。
# 失败：已打印明确原因并计入 _DNS_SKIP，返回 1。
# bind 模式只接受 POLICY_DEV_STATE=ok（运行时 up + 有 IPv4 + 有 l3_device），
# 绝不回退 uci network.X.device —— PPPoE 未就绪时那是底层 VLAN。
_dns_policy_bind() {
	_dpb_pol=$1
	_dpb_what=$2
	_TARGET=$_dpb_pol
	_DNS_DNS=$(dns_of_policy)
	if [ -z "$_DNS_DNS" ]; then
		log "跳过 $_dpb_what（策略 $_dpb_pol 缺少 DNS_TABLE 条目）"
		_DNS_SKIP=$((_DNS_SKIP + 1))
		return 1
	fi
	policy_device "$_dpb_pol" >/dev/null
	_DNS_DEV=$POLICY_DEV
	if [ "${DNS_MODE:-bind}" = "bind" ]; then
		case "$POLICY_DEV_STATE" in
		ok) ;;
		down | no-ip | no-l3)
			log "警告：跳过 $_dpb_what（策略 $_dpb_pol 的线路 ${POLICY_DEV_IFACE:-?} 未就绪: $POLICY_DEV_STATE）。此时绑定会指到底层设备/VLAN，已拒绝；等待 WAN up 后重跑 isp-split dns"
			_DNS_SKIP=$((_DNS_SKIP + 1))
			return 1
			;;
		*)
			log "跳过 $_dpb_what（策略 $_dpb_pol 找不到可用成员/接口: $POLICY_DEV_STATE）"
			_DNS_SKIP=$((_DNS_SKIP + 1))
			return 1
			;;
		esac
	fi
	_DNS_BIND=""
	[ "${DNS_MODE:-bind}" = "bind" ] && _DNS_BIND="@$_DNS_DEV"
	return 0
}

# 生成 server= 规则到 <out>。返回后读 _DNS_RULE_N / _DNS_SKIP / _DNS_BAD。
# 外部清单用一次 awk 完成：CR 清理、注释、server=/... 取域名、字符校验、
# 末行无换行（awk 天然处理）都在同一进程里，不再逐行 printf|tr 双 fork。
_dns_emit_rules() {
	_der_out=$1
	_der_err=$_der_out.warn
	_DNS_RULE_N=0
	_DNS_SKIP=0
	_DNS_BAD=0
	_DNS_DNS=""
	_DNS_DEV=""
	_DNS_BIND=""
	: > "$_der_err" || true

	# 1) 内联域名配置：DOMAIN_SPLIT="域名:策略 ..."
	for row in ${DOMAIN_SPLIT:-}; do
		_dom=${row%%:*}
		_pol=${row#*:}
		[ -n "$_dom" ] && [ -n "$_pol" ] || continue
		if ! _dns_domain_ok "$_dom"; then
			log "警告：DOMAIN_SPLIT 里的域名非法，已跳过: $(printf '%s' "$_dom" | cut -c1-80)"
			_DNS_BAD=$((_DNS_BAD + 1))
			continue
		fi
		_dns_policy_bind "$_pol" "$_dom" || continue
		printf 'server=/%s/%s%s\n' "$_dom" "$_DNS_DNS" "$_DNS_BIND" >> "$_der_out" ||
			die "无法写入 $_der_out"
		_DNS_RULE_N=$((_DNS_RULE_N + 1))
	done

	# 2) 外部域名清单：$DOMAINDIR/<策略>.txt
	#    每行一个域名；也兼容 felixonmars/dnsmasq-china-list 的
	#    server=/domain/1.2.3.4 形式（只取域名，DNS 用本线路的）
	for _f in "$DOMAINDIR"/*.txt; do
		[ -f "$_f" ] || continue
		_pol=${_f##*/}
		_pol=${_pol%.txt}
		_dns_policy_bind "$_pol" "$_f" || continue
		# 规则由 awk 直接追加到 out，stdout 只留计数（否则计数会被
		# 追加进片段文件，_DNS_RULE_N 永远是 0）。
		_c=$(awk -v dns="$_DNS_DNS" -v bind="$_DNS_BIND" -v src="$_f" -v out="$_der_out" '
			{ sub(/\r$/, "") }
			/^[[:space:]]*#/ { next }
			/^[[:space:]]*$/ { next }
			{
				line = $0
				d = line
				if (line ~ /^server=\//) {
					sub(/^server=\//, "", d)
					sub(/\/.*/, "", d)
				}
				if (d == "" || d ~ /^\./) next
				if (d !~ /^[A-Za-z0-9._-]+$/) {
					printf "%s 第 %d 行域名非法，已跳过: %s\n", src, NR, substr(line, 1, 80) > "/dev/stderr"
					next
				}
				printf "server=/%s/%s%s\n", d, dns, bind >> out
				n++
			}
			END { print n + 0 }
		' "$_f" 2>>"$_der_err")
		case "$_c" in '' | *[!0-9]*) _c=0 ;; esac
		if [ "$_c" -gt 0 ]; then
			log "$_f: $_c 条域名 -> $_DNS_DNS${_DNS_DEV:+@$_DNS_DEV}"
			_DNS_RULE_N=$((_DNS_RULE_N + _c))
		fi
	done

	if [ -s "$_der_err" ]; then
		while IFS= read -r _w; do
			log "警告：$_w"
			_DNS_BAD=$((_DNS_BAD + 1))
		done < "$_der_err"
	fi
	rm -f "$_der_err"
	return 0
}

# 片段语法自检（F-12）。dnsmasq 缺失 -> 降级告警（不算失败）；
# dnsmasq --test 在当前环境本身跑不起来（容器/无 /dev/urandom）-> 同样降级，
# 否则会把环境问题误判成配置错误。基线能跑而本片段失败才是硬错误。
_dns_check_fragment() {
	_dcf_file=$1
	command -v dnsmasq >/dev/null 2>&1 || {
		log "警告：未找到 dnsmasq，跳过片段语法自检"
		return 0
	}
	if ! dnsmasq --test --conf-file=/dev/null >/dev/null 2>&1; then
		log "警告：dnsmasq --test 在当前环境不可用（基线自检失败），跳过片段语法自检"
		return 0
	fi
	_dcf_out=$(dnsmasq --test --conf-file="$_dcf_file" 2>&1) && return 0
	log "错误：生成的 dnsmasq 片段未通过 dnsmasq --test：$(printf '%s' "$_dcf_out" | head -2 | tr '\n' ' ')"
	return 1
}

# 只回显前几条规则：LuCI 通过 ubus 捕获 stdout，十万条规则全量回显会
# 撑爆响应（F-12）。完整内容始终在文件里。
_dns_print_preview() {
	echo "生成的规则（前 3 条，共 $2 条；完整内容: $1）："
	grep '^server=' "$1" 2>/dev/null | head -n 3 | sed 's/^/  /'
	[ "$2" -gt 3 ] && echo "  …（其余 $(( $2 - 3 )) 条见文件）"
	return 0
}

# A-02：dns 也写 UCI confdir / mwan3 output 链，必须与其它写操作串行。
cmd_dns() {
	_op_lock_acquire || die "另一个 isp-split 操作正在运行（锁: $OP_LOCK），本次未做任何改动；稍后重试"
	_cmd_dns_body "$@"
	_rc=$?
	_op_lock_release
	return $_rc
}

_cmd_dns_body() {
	_dnsdir_check
	need_nft
	mkdir -p "$DNS_STATE" "$DOMAINDIR"

	# 先探测 dnsmasq 真正加载的目录。runtime/ps 是权威源；只有落到 UCI
	# 时才沿用"迁移旧版 $DNSDIR"的现状逻辑。
	_dns_confdir_detect >/dev/null
	_cur=$(_dns_confdir_get)
	# B-5：状态文件改名 dnsmasq.confdir.migrated。它记的不是"原值"，而是
	# "我们写进 UCI 的清理值"（旧版 $DNSDIR 已随卸载消失，恢复原值反而错）。
	# 旧名 dnsmasq.confdir.orig 只在升级兼容时读一次，见 _dns_state_adopt。
	_state=$DNS_STATE/dnsmasq.confdir.migrated
	_dns_state_adopt
	_uci_note="未改写 UCI confdir"
	case "$DNS_CONFDIR_SOURCE" in
	uci)
		_clean=$(_dns_confdir_clean "$_cur")
		if [ "$_clean" != "$_cur" ]; then
			# 只有真的要改 UCI 时才写状态文件；没改过就什么都不留，
			# 卸载时也不会去碰 UCI。
			_new_state=0
			if [ ! -f "$_state" ]; then
				printf '%s\n' "$_clean" > "$_state" ||
					die "无法保存 dnsmasq confdir 状态"
				_new_state=1
			fi
			if ! _dns_confdir_set "$_clean"; then
				[ "$_new_state" = "0" ] || rm -f "$_state"
				die "无法迁移 dnsmasq confdir"
			fi
			log "已从 confdir 移除旧版写入的 $DNSDIR（其余目录保留）"
			_cur=$_clean
			DNS_CONFDIR_DIRS=$_clean
			_uci_note="已迁移 UCI confdir"
		fi
		;;
	*)
		# runtime/ps/default：OpenClash 等接管时 UCI 是摆设，
		# 不改写它，也不为"未发生的修改"留状态文件。
		;;
	esac

	_confdir=$(_dns_confdir_first "$DNS_CONFDIR_DIRS")
	mkdir -p "$_confdir" 2>/dev/null || die "无法创建 dnsmasq confdir: $_confdir"
	_out=$_confdir/isp-split.conf

	# 先写临时文件、最后 rename 到位。
	# 直接 `> "$_out"` 会跟随目标位置的符号链接：片段目录通常是 /tmp 下的
	# dnsmasq conf-dir，只要有人在里面放一个 isp-split.conf -> /etc/xxx，
	# root 就会把 dnsmasq 片段写进 /etc/xxx（截断原文件）。
	# rename 只替换目录项本身，不会跟随链接。
	_out_tmp=$(_tmp_next_to "$_confdir")
	[ -n "$_out_tmp" ] || die "无法在 $_confdir 创建临时文件"

	# 旧版本可能把片段留在 /tmp/dnsmasq.d 或旧 $DNSDIR；换目录时清掉
	# 所有候选位置里的旧片段，避免 dnsmasq 同时加载两份规则。
	for _old_dir in $(_dns_confdir_candidates); do
		[ "$_old_dir" = "$_confdir" ] && continue
		_dns_frag_delete_ok "$_old_dir" || continue
		rm -f "$_old_dir/isp-split.conf" 2>/dev/null || true
	done

	{
		echo "# 本文件由 isp-split 生成，请勿手工修改"
		echo "# 重新生成: isp-split dns"
	} > "$_out_tmp" || {
		rm -f "$_out_tmp"
		die "无法写入 $_out_tmp"
	}

	_dns_emit_rules "$_out_tmp"
	_n=$_DNS_RULE_N
	_skip=$_DNS_SKIP
	_bad=$_DNS_BAD

	# 3) 先做片段语法自检：坏片段在动 nft 之前就拒绝，不留下半套旁路。
	if [ "$_n" -gt 0 ] && ! _dns_check_fragment "$_out_tmp"; then
		rm -f "$_out_tmp"
		die "生成的 dnsmasq 片段未通过语法自检，已放弃（现有片段未改动）"
	fi

	# 4) 处理 DNS 查询的出线（幂等，mwan3 重启后重跑 wire/apply/dns 即恢复）
	# dns 命令即使 mwan3 暂时未就绪也要把 dnsmasq 片段写出来，
	# 所以 required=0；但配置错误必须立即失败，不能静默生成坏片段。
	_dns_bypass 0
	_db_rc=$?
	if [ "$_db_rc" -eq 2 ]; then
		# 配置错误时不要留下可能含坏 DNS 的 dnsmasq 片段
		rm -f "$_out_tmp"
		die "DNS 旁路规则配置错误，请修正 DNS_TABLE / DNS_MODE 后重试"
	elif [ "$_db_rc" -ne 0 ]; then
		log "警告：mwan3 尚未就绪，DNS 旁路规则未全部恢复；稍后执行 isp-split apply 可重试"
	fi

	if [ "$_n" -eq 0 ]; then
		rm -f "$_out_tmp"
		if [ -f "$_out" ] && { [ "$_skip" -gt 0 ] || [ "$_bad" -gt 0 ]; }; then
			# 线路未就绪/清单全非法时不要删掉上一份可用片段：开机时
			# PPPoE 往往还没 up，删掉会让域名分流一直空到下次手动 dns。
			# 明确告警，等 WAN up 后重跑即可覆盖。
			log "警告：本次没有生成任何规则（线路未就绪或清单非法），保留现有片段 $_out 不动；WAN up 后请重跑 isp-split dns"
		else
			rm -f "$_out"
			log "没有域名分流规则（DNS 旁路已按 DNS_TABLE 就位）"
			log "要启用域名分流请配置 DOMAIN_SPLIT，或在 $DOMAINDIR/<策略>.txt 放入域名清单"
		fi
		return 0
	fi

	# 5) 片段原子落位（rename 不跟随符号链接），再让 dnsmasq 重新加载。
	#    runtime/ps 路径不改 UCI，避免覆盖 OpenClash/adblock 等接管配置；
	#    卸载时按同一探测结果清理。
	# mktemp 建的是 0600；dnsmasq 以 dnsmasq 用户读 conf-dir，
	# 片段必须是全局可读的（旧实现直接重定向，权限是 umask 的 0644）。
	chmod 644 "$_out_tmp" 2>/dev/null || true
	mv -f "$_out_tmp" "$_out" || {
		rm -f "$_out_tmp"
		die "无法写入 $_out"
	}
	"$DNSMASQ_SERVICE" restart >/dev/null 2>&1 || log "警告：dnsmasq 重启失败"

	log "已生成 $_n 条域名 DNS 规则 -> $_out（confdir: $_confdir，来源: $DNS_CONFDIR_SOURCE，$_uci_note）"
	[ "$_skip" -gt 0 ] && log "有 $_skip 组因配置缺失/线路未就绪被跳过"
	[ "$_bad" -gt 0 ] && log "有 $_bad 行域名因字符非法被跳过"
	echo
	_dns_print_preview "$_out" "$_n"
}

# ---------------------------------------------------------------- conf

# 配置文件读写接口，供 LuCI 调用。
#
# 设计原则：文件解析/改写全部留在 shell 里做（这里已经被充分测试过），
# LuCI 只负责渲染表单和把值传进来。绝不让前端直接拼配置文件。
#
# 输出用 TSV（制表符分隔）而不是 JSON：值里只有字母数字和 `:._`，
# 不需要转义，前端 split 一下就能用。

# 取配置项的原始值（支持 KEY="单行" 与 KEY="\n多行\n"）
_conf_val() {
	# 第二个参数可指定文件（写入后回读校验用），默认读 $CONF。
	# 逐行去掉 CR：CRLF 保存的配置会让值尾多一个 \r。
	awk -v key="$1" '
		{
			sub(/\r$/, "")
			if (inb) {
				if ($0 ~ /^"[[:space:]]*$/) { inb = 0; next }
				# 多行值的收尾引号也可能跟在最后一行末尾：KEY="a\nb"
				if ($0 ~ /"[[:space:]]*$/) {
					sub(/"[[:space:]]*$/, "", $0)
					out = (out == "" ? "" : out "\n") $0
					inb = 0
					next
				}
				out = (out == "" ? "" : out "\n") $0
				next
			}
			if (index($0, key "=\"") == 1) {
				rest = substr($0, length(key) + 3)
				if (rest ~ /"[[:space:]]*$/) {
					sub(/"[[:space:]]*$/, "", rest)
					out = rest
					inb = 0
				} else {
					out = rest
					inb = 1
				}
			}
		}
		END { print out }
	' "${2:-$CONF}"
}

# 取配置表的值并删掉注释行。conf_get / apply 直接读文件，不能只依赖入口
# 那次清理（B-1）。必须先整行删注释、再按空白展开，顺序不能反。
_conf_table() {
	_strip_comments "$(_conf_val "$1")"
}

conf_get() {
	[ -f "$CONF" ] || die "找不到 $CONF"
	printf 'dns_mode\t%s\n' "$(_conf_val DNS_MODE)"
	printf 'policies\t%s\n' \
		"$(uci -q show mwan3 2>/dev/null | sed -n 's/^mwan3\.\([^=]*\)=policy$/\1/p' | tr '\n' ' ')"
	# 值支持两种书写格式：多行（每行一条）或单行空格分隔。
	# 先把所有空白折叠成换行，再按冒号拆字段——两种格式统一处理。
	_conf_table ISP_TABLE | tr -s ' \t' '\n' |
		awk -F: 'NF >= 4 { printf "isp\t%s\t%s\t%s\t%s\n", $1, $2, $3, $4 }'
	_conf_table DNS_TABLE | tr -s ' \t' '\n' |
		awk -F: 'NF >= 2 { printf "dns\t%s\t%s\n", $1, $2 }'
	_conf_table DOMAIN_SPLIT | tr -s ' \t' '\n' |
		awk -F: 'NF >= 2 { printf "domain\t%s\t%s\n", $1, $2 }'
# lc:begin
	_lc_g=$(_conf_val LINE_CHECK_GEO); [ -n "$_lc_g" ] || _lc_g=1
	_lc_gp=$(_conf_val LINE_CHECK_GEO_PROVIDER); [ -n "$_lc_gp" ] || _lc_gp=auto
	_lc_mi=$(_conf_val LINE_CHECK_MIN_INTERVAL); [ -n "$_lc_mi" ] || _lc_mi=60
	_lc_en=$(_conf_val LINE_CHECK_ENABLE); [ -n "$_lc_en" ] || _lc_en=1
	printf 'line_check_enable\t%s\n' "$_lc_en"
	printf 'line_check_geo\t%s\n' "$_lc_g"
	printf 'line_check_provider\t%s\n' "$_lc_gp"
	printf 'line_check_interval\t%s\n' "$_lc_mi"
# lc:end
}

# ---- 配置字段白名单 ----
# 配置文件会被 root source，sh -n 只查语法、不执行命令替换，不能当安全校验。
# 因此每个字段在写入前都必须按白名单解析，绝不允许 $ ` " ' \ 空白 ; | 进入值里。
_policy_list() {
	uci -q show mwan3 2>/dev/null |
		sed -n 's/^mwan3\.\([^=]*\)=policy$/\1/p' | tr '\n' ' '
}

_valid_src() {
	case "${1:-}" in "" | *[!A-Za-z0-9._-]*) return 1 ;; esac
	return 0
}

_valid_name() {
	case "${1:-}" in "" | *[!A-Za-z0-9_]*) return 1 ;; esac
	[ "${#1}" -le 15 ]
}

_valid_domain() {
	case "${1:-}" in "" | *[!A-Za-z0-9._-]* | .* | *. | *..*) return 1 ;; esac
	return 0
}

_valid_policy() {
	case "${1:-}" in "" | *[!A-Za-z0-9_-]*) return 1 ;; esac
	return 0
}

# ---- 读取侧复校验 ----
# conf set 只保护"写"入口；wire / apply / dns / export / uninstall 都是
# 直接 source 之后读 $ISP_TABLE 的。这些值最终会进 nft、uci 的命令行参数，
# 而 nft 会把命令行参数重新解析一遍（"x;add table inet y" 就是两条语句），
# 所以非法字符必须在读取侧就挡住，不能只在写入侧挡。
_row_check() {
	case "${1:-}" in
	"" | *[!A-Za-z0-9._-]*) die "ISP_TABLE 的源名 '${1:-}' 非法（只允许 [A-Za-z0-9._-]）" ;;
	esac
	case "${2:-}" in
	"" | *[!A-Za-z0-9_]*) die "ISP_TABLE 的集合名 '${2:-}' 非法（只允许 [A-Za-z0-9_]）" ;;
	esac
	[ "${#2}" -le 15 ] ||
		die "ISP_TABLE 的集合名 '$2' 超过 15 字符（mwan3 的规则名限制）"
	case "${3:-}" in
	"" | *[!A-Za-z0-9_-]*) die "ISP_TABLE 的策略名 '${3:-}' 非法（只允许 [A-Za-z0-9_-]）" ;;
	esac
	case "${4:-}" in
	0 | 1) ;;
	*) die "ISP_TABLE 第 4 列只能是 0 或 1（当前: '${4:-}'）" ;;
	esac
}

# 直接对一整行做校验（不拆字段的调用点用）
_row_check_row() {
	# 双保险（B-1）：入口已删注释行，这里再挡一次带前导空白的 # 行，
	# 避免将来新增调用点绕过入口清理时又把注释当数据。
	case "$1" in
	'#'* | [[:space:]]*'#'*) return 0 ;;
	esac
	_rr_ifs=$IFS
	IFS=':'
	set -- $1
	IFS=$_rr_ifs
	[ $# -ge 4 ] ||
		die "ISP_TABLE 行格式错误（应为 源:集合:策略:0|1）: $1"
	_row_check "$1" "$2" "$3" "$4"
}

_valid_ipv4() {
	_ip=${1:-}
	case "$_ip" in
	"" | *[!0-9.]* | .* | *. | *..*) return 1 ;;
	esac
	_old_ifs=$IFS
	IFS=.
	set -- $_ip
	IFS=$_old_ifs
	[ $# -eq 4 ] || return 1
	for _oct in "$@"; do
		case "$_oct" in "" | *[!0-9]*) return 1 ;; esac
		[ "${#_oct}" -le 3 ] || return 1
		[ "$_oct" -le 255 ] || return 1
	done
	return 0
}

# lc:begin
# ---- LINE_CHECK_* 值校验（conf set / conf check 共用）----
# 这些键以前既不在白名单、也无读取侧校验：UA 可注入任意 HTTP 头，
# ECHO_URLS 的元素可被 curl 当成选项（-K<file>）。下面按“最小可用字符集”
# 收口，凡是能变成第二个 argv / shell 元字符的输入一律拒绝。

_conf_lc_ctrl() { # 含 CR/LF/TAB 等控制字符 -> 0
	# A-03：grep 按行匹配，LF 是行分隔符，永远不会出现在被匹配的“行”里。
	# 先剥 CR/LF/TAB 比较长度（任何 LF 都会让长度变短），再用 grep 兜住
	# 其余控制字符（VT/FF/ESC 等）。
	_clc_in=${1:-}
	_clc_stripped=$(printf '%s' "$_clc_in" | tr -d '\r\n\t')
	[ "${#_clc_stripped}" -eq "${#_clc_in}" ] || return 0
	printf '%s' "$_clc_in" | grep -q '[[:cntrl:]]'
}

# 只允许可见 ASCII（0x20-0x7e）。UA/Accept 是 HTTP 头值，出现非 ASCII
# 字节或控制字符都应拒绝，而不是静默清洗。
_conf_lc_visible() {
	_clv_bad=$(printf '%s' "${1:-}" | LC_ALL=C tr -d ' -~')
	[ -z "$_clv_bad" ]
}

_conf_lc_url_ok() {
	case "${1:-}" in
	http://?* | https://?*) ;;
	*) return 1 ;;
	esac
	# 只放行 URL 常规字符；; & | $ ` < > ( ) { } [ ] * ? ! # ' " \ 空格
	# 全部不在集合里，因此 curl 选项注入和 shell 元字符都会被拒。
	_conf_lc_bad=$(printf '%s' "$1" | tr -d 'A-Za-z0-9:/._~%+=-')
	[ -z "$_conf_lc_bad" ] || return 1
	# host 段必须是域名或严格 IPv4 字面量（不含 userinfo/端口）
	_conf_lc_rest=${1#*://}
	_conf_lc_host=${_conf_lc_rest%%/*}
	_conf_lc_host=${_conf_lc_host%%:*}
	case "$_conf_lc_host" in
	'' | -* | .* | *. | *..* | *[!A-Za-z0-9._-]*) return 1 ;;
	esac
	return 0
}

# A-04：读取侧 URL 校验（比 conf 写入侧略宽，允许 ?/# 查询串与片段，
# 但显式拒绝空白、引号、反斜杠、换行、; & | $ ` < > 等会变成 shell/curl
# 额外 argv 的字符）。配合 curl --url，避免 -K 被当成配置文件选项。
_url_read_ok() {
	_uro=${1:-}
	case "$_uro" in
	http://?* | https://?*) ;;
	*) return 1 ;;
	esac
	case "$_uro" in
	*[!A-Za-z0-9:/?#._~%+=,@!-]*) return 1 ;;
	esac
	return 0
}

_conf_lc_urls_ok() {
	_conf_lc_urls_n=0
	_conf_lc_urls_old=$IFS
	_conf_lc_urls_list=$(printf '%s' "${1:-}" | tr ',' ' ' | tr '\t\r\n' '   ')
	IFS=' '
	for _conf_lc_u in $_conf_lc_urls_list; do
		[ -n "$_conf_lc_u" ] || continue
		_conf_lc_url_ok "$_conf_lc_u" || {
			IFS=$_conf_lc_urls_old
			return 1
		}
		_conf_lc_urls_n=$((_conf_lc_urls_n + 1))
	done
	IFS=$_conf_lc_urls_old
	[ "$_conf_lc_urls_n" -gt 0 ]
}

_conf_lc_stun_ok() {
	_conf_lc_stun_old=$IFS
	_conf_lc_stun_list=$(printf '%s' "${1:-}" | tr '\t\r\n' '   ')
	IFS=' '
	for _conf_lc_s in $_conf_lc_stun_list; do
		[ -n "$_conf_lc_s" ] || continue
		case "$_conf_lc_s" in
		*:*) ;;
		*)
			IFS=$_conf_lc_stun_old
			return 1
			;;
		esac
		_conf_lc_sh=${_conf_lc_s%:*}
		_conf_lc_sp=${_conf_lc_s##*:}
		case "$_conf_lc_sh" in
		'' | -* | .* | *. | *..* | *[!A-Za-z0-9._-]*)
			IFS=$_conf_lc_stun_old
			return 1
			;;
		esac
		case "$_conf_lc_sp" in
		'' | *[!0-9]*)
			IFS=$_conf_lc_stun_old
			return 1
			;;
		esac
		[ "$_conf_lc_sp" -ge 1 ] && [ "$_conf_lc_sp" -le 65535 ] || {
			IFS=$_conf_lc_stun_old
			return 1
		}
	done
	IFS=$_conf_lc_stun_old
	# 空清单是合法配置：运行时降级为 unknown，不崩、不探测。
	return 0
}

_conf_lc_domain_ok() {
	case "${1:-}" in '' | -* | .* | *. | *..*) return 1 ;; esac
	_valid_domain "$1"
}

_conf_lc_domains_ok() {
	_conf_lc_dom_old=$IFS
	_conf_lc_dom_list=$(printf '%s' "${1:-}" | tr ',' ' ' | tr '\t\r\n' '   ')
	IFS=' '
	for _conf_lc_d in $_conf_lc_dom_list; do
		[ -n "$_conf_lc_d" ] || continue
		_conf_lc_domain_ok "$_conf_lc_d" || {
			IFS=$_conf_lc_dom_old
			return 1
		}
	done
	IFS=$_conf_lc_dom_old
	return 0
}

_conf_lc_targets_ok() {
	_conf_lc_t_old=$IFS
	_conf_lc_t_list=$(printf '%s' "${1:-}" | tr '\t\r\n' '   ')
	IFS=' '
	for _conf_lc_line in $_conf_lc_t_list; do
		[ -n "$_conf_lc_line" ] || continue
		_conf_lc_save_ifs=$IFS
		IFS=':'
		set -- $_conf_lc_line
		IFS=$_conf_lc_save_ifs
		[ $# -eq 2 ] || {
			IFS=$_conf_lc_t_old
			return 1
		}
		_valid_src "$1" || {
			IFS=$_conf_lc_t_old
			return 1
		}
		_conf_lc_dlist=$(printf '%s' "$2" | tr ',' ' ')
		_conf_lc_dn=0
		IFS=' '
		for _conf_lc_td in $_conf_lc_dlist; do
			[ -n "$_conf_lc_td" ] || continue
			_conf_lc_domain_ok "$_conf_lc_td" || {
				IFS=$_conf_lc_t_old
				return 1
			}
			_conf_lc_dn=$((_conf_lc_dn + 1))
		done
		IFS=$_conf_lc_save_ifs
		[ "$_conf_lc_dn" -gt 0 ] || {
			IFS=$_conf_lc_t_old
			return 1
		}
	done
	IFS=$_conf_lc_t_old
	return 0
}
# lc:end

# 校验某个 KEY 的完整值；失败直接 die（不写文件）
_conf_validate() {
	_key=$1
	_val=$2
	# 整段校验里会做 `for x in $val`；关掉路径名展开，避免值里的 * ? [
	# 被当前目录文件名替换，导致“校验的值”和“写入的值”不是同一份。
	_conf_had_f=0
	case $- in *f*) _conf_had_f=1 ;; esac
	set -f
	# 注释行不参与校验（B-1）：整行删掉后再按空白拆字段。清理必须发生在
	# 空白展开之前，否则 "# 说明:xxx" 会被拆成 "#" 和 "说明:xxx" 两段。
	# 这里只改局部副本，写回文件的内容仍保留用户注释。
	case "$_key" in
	ISP_TABLE | DNS_TABLE | DOMAIN_SPLIT) _val=$(_strip_comments "$_val") ;;
	esac
	case "$_val" in
	*'$'* | *'`'* | *'"'* | *"'"* | *'\'*)
		die "配置值含不允许的字符（\$ \` \" ' \\）"
		;;
	esac
	# ; | 只在会进 uci 参数 / 表字段的键上禁止；UA/ACCEPT 合法地包含 ';'
	# （Accept: text/html;q=0.9），URL 的非法字符由专门的 URL 校验挡。
	case "$_key" in
	ISP_TABLE | DNS_TABLE | DOMAIN_SPLIT)
		case "$_val" in
		*';'* | *'|'*) die "配置值含不允许的字符（; |）" ;;
		esac
		;;
	esac

	_POLS=" $(_policy_list) "
	case "$_key" in
	ISP_TABLE)
		_seen=" "
		_rows=0
		_old_ifs=$IFS
		# 关掉路径名展开：值里若出现 * ? [，`for _line in $_val` 会拿
		# 当前目录的文件名去替换，等于校验的和写入的不是同一份内容。
		set -f
		for _line in $_val; do
			_rows=$((_rows + 1))
			IFS=':'
			set -- $_line
			IFS=$_old_ifs
			[ $# -eq 4 ] || die "行格式必须是 运营商:集合名:策略:0或1 —— 出错行: $_line"
			_valid_src "$1" || die "运营商源名 '$1' 只能包含 [A-Za-z0-9._-]（出错行: $_line）"
			_valid_name "$2" || die "集合名 '$2' 只能包含 [A-Za-z0-9_] 且不超过 15 字符（出错行: $_line）"
			case " $_seen " in
			*" $2 "*) die "集合名 '$2' 重复（出错行: $_line）" ;;
			esac
			_seen="$_seen $2"
			_valid_policy "$3" || die "策略名 '$3' 含非法字符（出错行: $_line）"
			case "$_POLS" in
			*" $3 "*) ;;
			*) die "mwan3 里不存在策略 '$3'，请先在 MultiWAN 管理器创建（出错行: $_line）" ;;
			esac
			case "$4" in
			0 | 1) ;;
			*) die "最后一列只能是 0（禁用）或 1（启用）—— 出错行: $_line" ;;
			esac
		done
		IFS=$_old_ifs
		# 空表等于把工具废掉：wire 会注入空规则块（连带删掉现有规则），
		# apply 之后每次都报"没有启用的运营商"。这种值一律拒绝。
		[ "$_rows" -gt 0 ] ||
			die "ISP_TABLE 不能为空：至少保留一行 运营商:集合名:策略:0|1"
		;;
	DNS_TABLE)
		_old_ifs=$IFS
		set -f
		for _line in $_val; do
			IFS=':'
			set -- $_line
			IFS=$_old_ifs
			[ $# -eq 2 ] || die "格式必须是 策略:DNS地址 —— 出错行: $_line"
			_valid_policy "$1" || die "策略名 '$1' 含非法字符（出错行: $_line）"
			case "$_POLS" in
			*" $1 "*) ;;
			*) die "mwan3 里不存在策略 '$1'（出错行: $_line）" ;;
			esac
			_valid_ipv4 "$2" || die "DNS 地址必须是严格 IPv4（出错行: $_line）"
		done
		IFS=$_old_ifs
		;;
	DOMAIN_SPLIT)
		_old_ifs=$IFS
		set -f
		for _line in $_val; do
			IFS=':'
			set -- $_line
			IFS=$_old_ifs
			[ $# -eq 2 ] || die "格式必须是 域名:策略 —— 出错行: $_line"
			_valid_domain "$1" || die "域名 '$1' 只能包含 [A-Za-z0-9._-]（出错行: $_line）"
			_valid_policy "$2" || die "策略名 '$2' 含非法字符（出错行: $_line）"
			case "$_POLS" in
			*" $2 "*) ;;
			*) die "mwan3 里不存在策略 '$2'（出错行: $_line）" ;;
			esac
		done
		IFS=$_old_ifs
		;;
	DNS_MODE)
		case "$_val" in
		bind | mark) ;;
		*) die "DNS_MODE 只能是 bind 或 mark" ;;
		esac
		;;
# lc:begin
	LINE_CHECK_ENABLE | LINE_CHECK_GEO)
		case "$_val" in
		0 | 1) ;;
		*) die "$_key 只能是 0 或 1" ;;
		esac
		;;
	LINE_CHECK_GEO_PROVIDER)
		case "$_val" in
		auto | baidu | ip-api | off) ;;
		*) die "LINE_CHECK_GEO_PROVIDER 只能是 auto / baidu / ip-api / off" ;;
		esac
		;;
	LINE_CHECK_MIN_INTERVAL)
		case "$_val" in
		'' | *[!0-9]*) die "LINE_CHECK_MIN_INTERVAL 必须是整数秒（15-3600）" ;;
		esac
		[ "$_val" -ge 15 ] && [ "$_val" -le 3600 ] ||
			die "LINE_CHECK_MIN_INTERVAL 必须在 15-3600 之间"
		;;
	LINE_CHECK_UA | LINE_CHECK_ACCEPT)
		[ -n "$_val" ] || die "$_key 不能为空"
		[ "${#_val}" -le 512 ] ||
			die "$_key 过长（最多 512 字符）"
		if _conf_lc_ctrl "$_val"; then
			die "$_key 不能包含 CR/LF/TAB 等控制字符（会注入 HTTP 头）"
		fi
		_conf_lc_visible "$_val" ||
			die "$_key 只能包含可见 ASCII 字符（0x20-0x7e，禁止换行/控制字符/非 ASCII）"
		;;
	LINE_CHECK_ECHO_URLS)
		_conf_lc_urls_ok "$_val" ||
			die "LINE_CHECK_ECHO_URLS 必须是以逗号/空白分隔的 http(s):// URL（只允许 URL 常规字符，不接受 curl 选项或 shell 元字符）"
		;;
	LINE_CHECK_STUN_SERVERS)
		_conf_lc_stun_ok "$_val" ||
			die "LINE_CHECK_STUN_SERVERS 每项必须是 <域名或IPv4>:<端口1-65535>（空清单可接受）"
		;;
	LINE_CHECK_TARGETS)
		_conf_lc_targets_ok "$_val" ||
			die "LINE_CHECK_TARGETS 格式必须是 <运营商源名>:<域名1>,<域名2>（域名只允许 [A-Za-z0-9._-]）"
		;;
	LINE_CHECK_BASELINE)
		_conf_lc_domains_ok "$_val" ||
			die "LINE_CHECK_BASELINE 只能是逗号/空白分隔的域名（只允许 [A-Za-z0-9._-]）"
		;;
	LINE_CHECK_TIMEOUT)
		case "$_val" in
		'' | *[!0-9]*) die "LINE_CHECK_TIMEOUT 必须是整数秒（1-10）" ;;
		esac
		[ "$_val" -ge 1 ] && [ "$_val" -le 10 ] ||
			die "LINE_CHECK_TIMEOUT 必须在 1-10 之间"
		;;
	LINE_CHECK_BUDGET)
		case "$_val" in
		'' | *[!0-9]*) die "LINE_CHECK_BUDGET 必须是整数秒（8-120）" ;;
		esac
		[ "$_val" -ge 8 ] && [ "$_val" -le 120 ] ||
			die "LINE_CHECK_BUDGET 必须在 8-120 之间"
		;;
	LINE_CHECK_GEO_TTL)
		case "$_val" in
		'' | *[!0-9]*) die "LINE_CHECK_GEO_TTL 必须是整数秒（60-604800）" ;;
		esac
		[ "$_val" -ge 60 ] && [ "$_val" -le 604800 ] ||
			die "LINE_CHECK_GEO_TTL 必须在 60-604800 之间"
		;;
# lc:end
	*)
		die "未知配置项 '$_key'"
		;;
	esac
	# die 会直接 exit；正常返回时恢复调用前的 glob 状态。
	[ "$_conf_had_f" = "1" ] || set +f
}

# conf set/check 的小写键 -> 配置变量名。白名单与 _conf_validate 的
# 分支一一对应，避免“能写但没校验”或“校验了但写不进去”。
# lc:begin
_CONF_KEYS_HINT="isp_table / dns_table / domain_split / dns_mode / line_check_enable / line_check_geo / line_check_provider / line_check_interval / line_check_targets / line_check_stun_servers / line_check_echo_urls / line_check_ua / line_check_accept / line_check_timeout / line_check_budget / line_check_geo_ttl / line_check_baseline"
# lc:end
_CONF_KEYS_HINT="${_CONF_KEYS_HINT:-isp_table / dns_table / domain_split / dns_mode}"
_conf_key_norm() {
	# 大小写都接受：文档/评审里常写大写键，内部变量也必须能落盘。
	# 剥离版里这一整段别名表被删除，大写键同样会走“未知配置项”。
	_ck=$(printf '%s' "${1:-}" | tr 'A-Z' 'a-z')
	case "$_ck" in
	isp_table) printf 'ISP_TABLE' ;;
	dns_table) printf 'DNS_TABLE' ;;
	domain_split) printf 'DOMAIN_SPLIT' ;;
	dns_mode) printf 'DNS_MODE' ;;
# lc:begin
	line_check_enable) printf 'LINE_CHECK_ENABLE' ;;
	line_check_geo) printf 'LINE_CHECK_GEO' ;;
	line_check_provider) printf 'LINE_CHECK_GEO_PROVIDER' ;;
	line_check_interval) printf 'LINE_CHECK_MIN_INTERVAL' ;;
	line_check_targets) printf 'LINE_CHECK_TARGETS' ;;
	line_check_stun_servers) printf 'LINE_CHECK_STUN_SERVERS' ;;
	line_check_echo_urls) printf 'LINE_CHECK_ECHO_URLS' ;;
	line_check_ua) printf 'LINE_CHECK_UA' ;;
	line_check_accept) printf 'LINE_CHECK_ACCEPT' ;;
	line_check_timeout) printf 'LINE_CHECK_TIMEOUT' ;;
	line_check_budget) printf 'LINE_CHECK_BUDGET' ;;
	line_check_geo_ttl) printf 'LINE_CHECK_GEO_TTL' ;;
	line_check_baseline) printf 'LINE_CHECK_BASELINE' ;;
# lc:end
	*) return 1 ;;
	esac
}

# 安全重写某个 KEY 的值：写前校验并备份、写后 sh -n 校验、失败自动回滚
# 第三个参数是写入目标，默认正式配置；预检用临时副本，绝不碰 /etc/isp-split.conf。
_conf_write() {
	_key=$1
	_val=$2
	_target=${3:-$CONF}
	_raw_key=$_key
	_key=$(_conf_key_norm "$_raw_key") ||
		die "未知配置项 '$_raw_key'，可选：$_CONF_KEYS_HINT"

	# 写入前必须通过白名单校验；失败即 die，绝不落盘
	_conf_validate "$_key" "$_val"

	# 写临时文件 + 回读校验 + 原子 rename。
	# 旧实现有两个坑：
	#   1) awk 只在遇到 `KEY="` 时才替换，键不存在（用户删掉了某段、
	#      或用了单引号/无引号写法）就原样输出，于是什么都没写，
	#      但仍然打印"已更新"并返回 0 —— 静默丢配置；
	#   2) 直接重定向覆盖正式文件，写到一半失败（磁盘满）可能留下
	#      一份语法合法但被截断的配置，sh -n 查不出来。
	_backup=$_target.bak.$$
	_tmp=$_target.tmp.$$
	cp -f "$_target" "$_backup" || die "无法备份配置"
	# 临时文件先复制原文件再原地截断重写：这样模式/属主跟原文件一致
	# （rename 会把临时文件的权限带过去；OpenWrt 没有 stat，不能靠 chmod 复制）
	cp -f "$_target" "$_tmp" || die "无法创建临时配置文件"

	awk -v key="$_key" -v val="$_val" '
		function emit(v,	n, i, L) {
			n = split(v, L, "\n")
			# 单行值保持单行；多行值写成 KEY="\n...\n"，与默认配置一致
			if (n <= 1) { print key "=\"" v "\""; return }
			print key "=\""
			for (i = 1; i <= n; i++) if (L[i] != "") print L[i]
			print "\""
		}
		{
			sub(/\r$/, "")
			if (inb) {
				# 旧值的续行：直到收尾引号（可能是独立一行，
				# 也可能跟在最后一行内容末尾 KEY="a\nb"）
				if ($0 ~ /"[[:space:]]*$/) { inb = 0 }
				next
			}
			if (index($0, key "=") == 1) {
				found = 1
				rest = substr($0, length(key) + 2)
				# 单行写法：KEY="值" / KEY='值' / KEY=值
				if (rest ~ /^".*"[[:space:]]*$/ || rest !~ /^"/) {
					emit(val)
					next
				}
				# 其余是以 " 开头但没在同一行收尾的多行写法：
				# 输出新值后跳过旧值的续行
				emit(val)
				inb = 1
				next
			}
			print
		}
		END { if (!found) emit(val) }
	' "$_backup" > "$_tmp" ||
		{ rm -f "$_tmp" "$_backup"; die "写入失败，已回滚"; }

	if ! sh -n "$_tmp" 2>/dev/null; then
		rm -f "$_tmp" "$_backup"
		die "改动后的配置语法不合法，已回滚（原文件已恢复）"
	fi

	# 回读校验：文件里读出来的必须就是刚才写入的值。
	# 这一步是"静默不生效"的最后一道防线。
	# 空行不参与比较：写入时会丢掉空行（word splitting 本来也忽略它们），
	# 否则"表里带一个空行"这种正常输入会被误判成写入失败。
	_got=$(_conf_val "$_key" "$_tmp" | awk 'NF')
	_want=$(printf '%s\n' "$_val" | awk 'NF')
	if [ "$_got" != "$_want" ]; then
		rm -f "$_tmp" "$_backup"
		die "写入后校验失败（$_key 的内容与预期不一致），已回滚，原文件未改动"
	fi

	mv -f "$_tmp" "$_target" || {
		rm -f "$_tmp" "$_backup"
		cp -f "$_backup" "$_target" 2>/dev/null
		die "替换配置失败，已回滚"
	}
	rm -f "$_backup"
}

conf_set() {
	_key=${1:-}
	_val=${2:-}
	[ -n "$_key" ] || die "用法: isp-split conf set <项> <值>（可用项: $_CONF_KEYS_HINT）"
	[ -f "$CONF" ] || die "找不到 $CONF"

	# 允许用 | 代替换行（前端传参更省事）。
	# A-03：转换后必须复验——UA/ACCEPT 的“可见 ASCII”规则会把 | 变成
	# 的 LF 抓住；表类键仍允许真正的多行。
	_val=$(printf '%s' "$_val" | tr '|' '\n')

	# _conf_write 内部会先调用 _conf_validate 做白名单校验；
	# 校验失败直接 die，不会留下半写入的配置。
	# 显式传播非 0：校验函数将来若改成 return 而不是 exit，
	# conf_set 也不能继续打印"已更新"并返回 0。
	_conf_write "$_key" "$_val" || return $?

	log "已更新 $_key。要让改动生效："
	echo "  运营/策略映射 -> isp-split wire && isp-split apply"
	echo "  DNS 相关      -> isp-split dns"
}

# 只校验不落盘：LuCI 保存前的行内校验与"保存并生效"之间的最后一道闸
conf_check() {
	_ck_key=${1:-}
	_ck_val=${2:-}
	[ -n "$_ck_key" ] || die "用法: isp-split conf check <项> <值>"
	_ck_val=$(printf '%s' "$_ck_val" | tr '|' '\n')
	_ck_raw=$_ck_key
	_ck_key=$(_conf_key_norm "$_ck_raw") ||
		die "未知配置项 '$_ck_raw'，可选：$_CONF_KEYS_HINT"
	_conf_validate "$_ck_key" "$_ck_val" || return $?
	echo "$_ck_key 校验通过"
}

# 保存前预检：把"还没保存的改动"写进配置的临时副本，再跑 plan 预演。
# 正式配置一个字节都不动，用户能看到"如果保存会变成什么样"。
conf_preview() {
	[ $# -ge 2 ] || die "用法: isp-split conf preview <项> <值> [<项> <值> ...]"
	_pv_tmp=$(mktemp "${TMPDIR:-/tmp}/isp-split-preview.XXXXXX" 2>/dev/null) ||
		die "无法创建临时配置文件"
	cp -f "$CONF" "$_pv_tmp" || { rm -f "$_pv_tmp"; die "无法复制配置"; }

	while [ $# -ge 2 ]; do
		_pv_key=$1
		_pv_val=$(printf '%s' "$2" | tr '|' '\n')
		shift 2
		if ! _conf_write "$_pv_key" "$_pv_val" "$_pv_tmp"; then
			rm -f "$_pv_tmp"
			return 1
		fi
	done

	echo "== 预检：以下预演基于【尚未保存】的改动（$CONF 未被修改）"
	ISP_SPLIT_CONF=$_pv_tmp \
		ISP_SPLIT_BASE="$BASE" \
		ISP_SPLIT_LIB="$LIB" \
		ISP_SPLIT_MWAN3="$CFG_MWAN3" \
		ISP_SPLIT_CRONTAB="$CRONTAB" \
		sh "$0" plan
	_pv_rc=$?
	rm -f "$_pv_tmp"
	return $_pv_rc
}

cmd_conf() {
	_op=${1:-get}
	case "$_op" in
	get) conf_get ;;
	set) shift; conf_set "$@" || return $? ;;
	check) shift; conf_check "$@" || return $? ;;
	preview) shift; conf_preview "$@" || return $? ;;
	*) die "用法: isp-split conf [get | set <项> <值> | check <项> <值> | preview <项> <值> ...]" ;;
	esac
}

# ---------------------------------------------------------------- export

# 把运营商集合导出成代理侧可直接消费的 rule-set 格式。
#
# 为什么要这个：isp-split 只能管"直连"流量（OpenClash 的 TPROXY 标记在更早的
# ip rule 上，代理流量根本不经过 mwan3）。若想在代理侧也按运营商分流，
# 需要把同一份数据交给 mihomo / sing-box。
#
#   mihomo / OpenClash : rule-provider，behavior=ipcidr，format=yaml
#   sing-box           : rule_set，type=local，format=source
#                        （必须是 {"version":1,"rules":[{"ip_cidr":[...]}]}）
cmd_export() {
	_fmt=${1:-}
	_dir=${2:-}
	[ -n "$_fmt" ] || die "用法: isp-split export <mihomo|singbox|text> <输出目录>"
	[ -n "$_dir" ] || die "用法: isp-split export <mihomo|singbox|text> <输出目录>"
	# 先校验格式再建目录：旧写法先 mkdir 后报错，一次手误就留下空目录，
	# 而且格式非法时报的是"没有可导出的数据"，完全指错方向。
	case "$_fmt" in
	mihomo | singbox | text) ;;
	*) die "未知格式 '$_fmt'，可选 mihomo / singbox / text" ;;
	esac
	# 输出目录由调用方给（LuCI 里是委派用户通过 ubus file exec 调过来的）。
	# export 是以 root 身份 mkdir + 写文件，所以必须挡住系统目录：
	# 否则一个只有本应用 ACL 的用户就能让 root 往 /etc/init.d、/etc/crontabs
	# 落地文件（文件名固定 isp-<源>.<ext>，但目录由他选）。
	case "$_dir" in
	/*) ;;
	*) die "输出目录必须是绝对路径: $_dir" ;;
	esac
	case "$_dir" in
	*..*) die "输出目录不能包含 '..': $_dir" ;;
	/etc/init.d | /etc/init.d/* | /etc/uci-defaults | /etc/uci-defaults/* | \
		/etc/crontabs | /etc/crontabs/* | /etc/rc.d | /etc/rc.d/* | \
		/etc/config | /etc/config/* | /etc/hotplug.d | /etc/hotplug.d/* | \
		/etc/profile.d | /etc/profile.d/* | /etc/rc.local.d | /etc/rc.local.d/* | \
		/usr/sbin | /usr/sbin/* | /usr/bin | /usr/bin/* | \
		/bin | /bin/* | /sbin | /sbin/* | /lib | /lib/* | \
		/usr/lib | /usr/lib/* | /usr/share/rpcd | /usr/share/rpcd/* | \
		/usr/share/luci | /usr/share/luci/* | /www | /www/*)
		die "拒绝导出到系统目录: $_dir" ;;
	esac
	mkdir -p "$_dir" || die "无法创建 $_dir"
	# A-09：上面的黑名单按字面匹配，/tmp/link-to-etc 这类目录可以绕过。
	# 建目录后再解析一次真实路径（pwd -P 是 busybox 也有的最小原语），
	# 对真实路径重复同一张黑名单；不解析到真实路径就不写任何文件。
	_dir_real=$(cd "$_dir" 2>/dev/null && pwd -P) || _dir_real=""
	[ -n "$_dir_real" ] || die "无法解析输出目录的真实路径: $_dir"
	for _dchk in "$_dir" "$_dir_real"; do
		case "$_dchk" in
		/ | /etc | /usr | /var | /bin | /sbin | /lib | /boot | /dev | /proc | /sys | /www | /root)
			die "拒绝导出到系统目录: $_dchk（$_dir）" ;;
		/etc/init.d | /etc/init.d/* | /etc/uci-defaults | /etc/uci-defaults/* | \
			/etc/crontabs | /etc/crontabs/* | /etc/rc.d | /etc/rc.d/* | \
			/etc/config | /etc/config/* | /etc/hotplug.d | /etc/hotplug.d/* | \
			/etc/profile.d | /etc/profile.d/* | /etc/rc.local.d | /etc/rc.local.d/* | \
			/usr/sbin | /usr/sbin/* | /usr/bin | /usr/bin/* | \
			/bin | /bin/* | /sbin | /sbin/* | /lib | /lib/* | \
			/usr/lib | /usr/lib/* | /usr/share/rpcd | /usr/share/rpcd/* | \
			/usr/share/luci | /usr/share/luci/* | /www | /www/*)
			die "拒绝导出到系统目录: $_dchk（$_dir 的真实路径）" ;;
		esac
	done

	_n=0
	_old_ifs=$IFS
	for row in $ISP_TABLE; do
		IFS=':'
		set -- $row
		IFS=$_old_ifs
		case "$row" in \#*) continue ;; esac
		[ $# -ge 1 ] || continue
		# 源名会拼进输出文件名，必须校验（集合/策略列不参与导出，允许缺省）
		_valid_src "$1" ||
			die "ISP_TABLE 的源名 '$1' 非法（只允许 [A-Za-z0-9._-]）"
		_src=$1
		_f="$DATA/$_src.txt"
		[ -s "$_f" ] || continue

		# 目标位置若已是符号链接，直接写会写穿到链接指向的文件；
		# 先删掉目录项（rm 删除的是链接本身），再由下面的重定向/ cp 新建。
		case "$_fmt" in
		mihomo) _ext=yaml ;;
		singbox) _ext=json ;;
		*) _ext=txt ;;
		esac
		_out="$_dir/isp-$_src.$_ext"
		# A-09：不再 rm -f 后直接重定向（rm 与重定向之间有 TOCTOU，
		# 且 README 以前根本没删链接）。统一用 _tmp_next_to 建同目录
		# 随机临时文件，写完 rename 落位；rename 不跟随符号链接。
		_out_tmp=$(_tmp_next_to "$_dir")
		[ -n "$_out_tmp" ] || die "无法在 $_dir 创建临时文件（权限/空间？）"

		case "$_fmt" in
		mihomo)
			{
				echo "payload:"
				sed 's/^/  - /' "$_f"
			} > "$_out_tmp" || { rm -f "$_out_tmp"; die "写入 $_out 失败"; }
			;;
		singbox)
			# sing-box 的 source 格式是对象，不是裸数组：
			#   {"version":1,"rules":[{"ip_cidr":["10.0.0.0/8", ...]}]}
			# 裸 JSON 字符串数组是 mihomo/Clash 的 payload 格式，
			# sing-box 读它会直接报 decode rule-set 启动失败。
			{
				printf '{\n  "version": 1,\n  "rules": [\n    { "ip_cidr": [\n'
				awk 'NR > 1 { printf ",\n" } { printf "      \"%s\"", $0 }' "$_f"
				printf '\n    ] }\n  ]\n}\n'
			} > "$_out_tmp" || { rm -f "$_out_tmp"; die "写入 $_out 失败"; }
			;;
		text)
			cp -f "$_f" "$_out_tmp" || { rm -f "$_out_tmp"; die "写入 $_out 失败"; }
			;;
		*)
			rm -f "$_out_tmp"
			die "未知格式 '$_fmt'，可选 mihomo / singbox / text"
			;;
		esac
		chmod 644 "$_out_tmp" 2>/dev/null || true
		mv -f "$_out_tmp" "$_out" || { rm -f "$_out_tmp"; die "落位 $_out 失败"; }
		printf '  %-34s %6s 条\n' "$_out" "$(wc -l < "$_f" | tr -d ' ')"
		_n=$((_n + 1))
	done

	[ "$_n" -gt 0 ] || die "没有可导出的数据，先执行 isp-split fetch"

	# 附一份可直接抄的示例配置
	# A-09：README 也必须走“同目录临时文件 + rename”。旧实现直接
	# `cat > "$_dir/README-*.txt"`，若目标已存在且是指向 /etc/shadow 之类
	# 的符号链接，root 的重定向会写穿链接（数据文件已先 rm，README 漏了）。
	case "$_fmt" in
	mihomo)
		_readme_tmp=$(_tmp_next_to "$_dir")
		[ -n "$_readme_tmp" ] || die "无法在 $_dir 创建 README 临时文件"
		cat > "$_readme_tmp" <<'EOF'
mihomo / OpenClash 用法：

# 1) 放进 rule-provider（OpenClash 可在「规则集」里添加本地文件）
rule-providers:
  isp-telecom:
    type: file
    behavior: ipcidr
    format: yaml
    path: /etc/openclash/rule_provider/isp-chinatelecom.yaml
  isp-unicom:
    type: file
    behavior: ipcidr
    format: yaml
    path: /etc/openclash/rule_provider/isp-unicom_cnc.yaml
  isp-cmcc:
    type: file
    behavior: ipcidr
    format: yaml
    path: /etc/openclash/rule_provider/isp-cmcc.yaml

# 2) 在 rules 里引用（放在 MATCH 之前）
rules:
  - RULE-SET,isp-telecom,电信节点组
  - RULE-SET,isp-unicom,联通节点组
  - RULE-SET,isp-cmcc,联通节点组
  - MATCH,默认组

# 3) 定时更新：cron 里跑 isp-split fetch 后再 export 到 OpenClash 目录
EOF
		[ -s "$_readme_tmp" ] || { rm -f "$_readme_tmp"; die "写入 README-mihomo.txt 失败"; }
		chmod 644 "$_readme_tmp" 2>/dev/null || true
		mv -f "$_readme_tmp" "$_dir/README-mihomo.txt" ||
			{ rm -f "$_readme_tmp"; die "落位 README-mihomo.txt 失败"; }
		;;
	singbox)
		_readme_tmp=$(_tmp_next_to "$_dir")
		[ -n "$_readme_tmp" ] || die "无法在 $_dir 创建 README 临时文件"
		cat > "$_readme_tmp" <<'EOF'
sing-box 用法：

{
  "rule_set": [
    { "type": "local", "tag": "isp-telecom",
      "format": "source", "path": "/etc/sing-box/ruleset/isp-chinatelecom.json" },
    { "type": "local", "tag": "isp-unicom",
      "format": "source", "path": "/etc/sing-box/ruleset/isp-unicom_cnc.json" },
    { "type": "local", "tag": "isp-cmcc",
      "format": "source", "path": "/etc/sing-box/ruleset/isp-cmcc.json" }
  ],
  "route": {
    "rules": [
      { "rule_set": ["isp-telecom"], "outbound": "电信节点" },
      { "rule_set": ["isp-unicom"],  "outbound": "联通节点" },
      { "rule_set": ["isp-cmcc"],    "outbound": "联通节点" }
    ]
  }
}

注意：sing-box 的 local source 规则集是对象格式
      {"version":1,"rules":[{"ip_cidr":[...]}]}，本工具导出的正是该格式。
      裸 JSON 数组（mihomo 的 payload 格式）sing-box 不接受。
EOF
		[ -s "$_readme_tmp" ] || { rm -f "$_readme_tmp"; die "写入 README-singbox.txt 失败"; }
		chmod 644 "$_readme_tmp" 2>/dev/null || true
		mv -f "$_readme_tmp" "$_dir/README-singbox.txt" ||
			{ rm -f "$_readme_tmp"; die "落位 README-singbox.txt 失败"; }
		;;
	esac

	log "已导出 $_n 个集合到 $_dir（格式: $_fmt）"
}

# ---------------------------------------------------------------- rollback

_cmd_rollback_body() {
	_last=$(ls -1d "$BASE/.history"/*/ 2>/dev/null | sort | tail -1)
	[ -n "$_last" ] || die "没有可回滚的历史数据"

	# 快照可能是空的、或上次 fetch 被磁盘满/断电打断只拷了一半。
	# 旧写法先 rm 掉现有数据再 cp，cp 失败被忽略，接着 apply 会把所有集合
	# flush 成空集，最后打印"已生效"返回 0 —— 一次 rollback 静默清空分流。
	# 因此先把快照拷到暂存目录校验，确认无误后才原子替换。
	mkdir -p "$WORK" 2>/dev/null || true
	_stage=$WORK/rollback.new
	rm -rf "$_stage"
	mkdir -p "$_stage" || die "无法创建回滚暂存目录 $_stage"
	if ! cp "$_last"*.txt "$_stage/" 2>/dev/null; then
		rm -rf "$_stage"
		die "历史快照 $_last 为空或已损坏（没有 .txt），拒绝回滚；现有数据未改动"
	fi
	_chk_rb() {
		[ -s "$_stage/$1.txt" ] || {
			rm -rf "$_stage"
			die "历史快照缺少 $1.txt（快照不完整），拒绝回滚；现有数据未改动"
		}
	}
	foreach_enabled _chk_rb

	_old=$BASE/data.old
	rm -rf "$_old"
	if [ -d "$DATA" ]; then
		mv "$DATA" "$_old" || { rm -rf "$_stage"; die "无法替换数据目录，现有数据未改动"; }
	fi
	if ! mv "$_stage" "$DATA"; then
		[ -d "$_old" ] && mv "$_old" "$DATA"
		die "回滚替换失败，已恢复原数据"
	fi
	rm -rf "$_old"
	# 快照里带 .stamp 就沿用，status 才能显示这份数据的真实时间
	[ -f "$_last/.stamp" ] && cp "$_last/.stamp" "$DATA/.stamp" 2>/dev/null

	log "回滚到 $_last"
	cmd_apply
}

# F-28：rollback 同样 mv DATA 并调用 apply，必须与 fetch/wire 互斥。
cmd_rollback() {
	_op_lock_acquire || die "另一个 isp-split 操作正在运行（锁: $OP_LOCK），本次未做任何改动；稍后重试"
	_cmd_rollback_body
	_rc=$?
	_op_lock_release
	return $_rc
}

# ---------------------------------------------------------------- install

# 在指定目录里创建一个不可预测的临时文件（mktemp 用 O_EXCL 创建，
# 低权用户无法用符号链接抢占这个名字）。失败时输出空行。
_tmp_next_to() {
	_td=${1:-/tmp}
	mkdir -p "$_td" 2>/dev/null || _td=/tmp
	mktemp "$_td/.isp-split.XXXXXX" 2>/dev/null
}

# 重写 /etc/crontabs/root：保留非本工具的行，按需追加 $1。
#
# 旧实现用固定的 /tmp/.ispcron 中转：
#   grep -v isp-split /etc/crontabs/root > /tmp/.ispcron
#   echo ... >> /tmp/.ispcron ; mv /tmp/.ispcron /etc/crontabs/root
# /tmp 是世界可写目录，低权用户可以先建 /tmp/.ispcron -> /tmp/x 的符号链接，
# root 的两次重定向就会写穿到 /tmp/x；随后的 mv 还会把符号链接本身搬到
# /etc/crontabs/root，于是 crond 改为读 /tmp/x —— 谁都能改 root 的定时任务。
# 现在临时文件建在 /etc 下（与目标同文件系统，rename 才是原子的）。
_cron_rewrite() {
	_cr_target=$CRONTAB
	mkdir -p "${_cr_target%/*}" 2>/dev/null || return 1
	[ -f "$_cr_target" ] || : >> "$_cr_target" 2>/dev/null || return 1

	_cr_tmp=$(_tmp_next_to "${_cr_target%/*}")
	[ -n "$_cr_tmp" ] || return 1
	grep -v 'isp-split' "$_cr_target" > "$_cr_tmp" 2>/dev/null || true
	if [ -n "${1:-}" ]; then
		printf '%s\n' "$1" >> "$_cr_tmp" || { rm -f "$_cr_tmp"; return 1; }
	fi
	mv -f "$_cr_tmp" "$_cr_target" || { rm -f "$_cr_tmp"; return 1; }
	return 0
}

# ISP_CRON 会原样写进 crontab，而 crontab 是 root 的命令执行上下文：
# 一个 "* * * * * root id > /tmp/x" 就能让 root 跑任意命令。
# 只接受 5 段纯时间字段（数字和 * , - /）。
_cron_expr_ok() {
	# 拆字段时要临时关掉 glob：cron 表达式里合法地包含 *，
	# 不关的话 `for f in $ISP_CRON` 会把 * 展开成当前目录的文件名，
	# 合法表达式反而被判成非法（实测踩过）。
	_ce_had_f=0
	case $- in *f*) _ce_had_f=1 ;; esac
	[ "$_ce_had_f" = "1" ] || set -f
	_ce_n=0
	_ce_bad=0
	for _ce_f in ${1:-}; do
		_ce_n=$((_ce_n + 1))
		case "$_ce_f" in
		"" | *[!0-9*,-/]*) _ce_bad=1 ;;
		esac
	done
	[ "$_ce_had_f" = "1" ] || set +f
	[ "$_ce_bad" = "0" ] && [ "$_ce_n" -eq 5 ]
}

# A-02：install 写 crontab/开机自启，与 uninstall 及 refresh 互斥。
cmd_install() {
	_op_lock_acquire || die "另一个 isp-split 操作正在运行（锁: $OP_LOCK），本次未做任何改动；稍后重试"
	_cmd_install_body "$@"
	_rc=$?
	_op_lock_release
	return $_rc
}

_cmd_install_body() {
	_cron_expr_ok "$ISP_CRON" ||
		die "ISP_CRON 格式非法（必须是 5 段 cron 时间字段）: $ISP_CRON"
	# crontab
	mkdir -p /etc/crontabs
	touch /etc/crontabs/root
	_cron_rewrite "$ISP_CRON /usr/sbin/isp-split refresh >/dev/null 2>&1 # isp-split"
	/etc/init.d/cron restart >/dev/null 2>&1 || true

	/etc/init.d/isp-split enable >/dev/null 2>&1 || true
	log "已注册：每日 $ISP_CRON 自动更新，开机自动应用"
}

# A-02：uninstall 会删 $BASE，必须与 fetch/wire/refresh 互斥。
cmd_uninstall() {
	_op_lock_acquire || die "另一个 isp-split 操作正在运行（锁: $OP_LOCK），本次未做任何改动；稍后重试"
	_cmd_uninstall_body "$@"
	_rc=$?
	_op_lock_release
	return $_rc
}

_cmd_uninstall_body() {
	# F-14：卸载清理只有一份实现——usr/lib/isp-split/teardown.sh。
	# 这里只做"必须由本脚本完成"的部分：mwan3 规则块要用 rules-splice.awk
	# 原地摘除（teardown 不带 awk 依赖），其余（策略/成员/计数器/confdir/
	# cron/文件/LuCI）全部交给它，与 install.sh --uninstall、包 postrm 同源。
	if [ ! -f "$LIB/teardown.sh" ]; then
		die "缺少 $LIB/teardown.sh，无法完整卸载（版本不匹配？改用同版本的 scripts/install.sh --uninstall）"
	fi

	_cron_rewrite "" 2>/dev/null || true
	/etc/init.d/isp-split disable >/dev/null 2>&1 || true
	/etc/init.d/isp-split stop >/dev/null 2>&1 || true

	# 删规则：用空片段做一次注入即可移除（rules-splice 会删掉所有 isp_ 规则）
	mkdir -p "$WORK"
	: > "$WORK/rules.txt"
	if [ -f "$CFG_MWAN3" ]; then
		cp "$CFG_MWAN3" "$WORK/mwan3.bak"
		awk -v insert="$WORK/rules.txt" -v anchor="$ISP_ANCHOR" \
			-f "$LIB/rules-splice.awk" "$WORK/mwan3.bak" > "$WORK/mwan3.new" &&
			_mwan3_atomic_install "$WORK/mwan3.new"
	fi

	# mwan3 段（按 isp_split 标记，含改名/删除的孤儿段）+ runtime 残留 + 文件。
	# 顺序不能反：状态文件在 $BASE 里，先恢复 confdir 再删目录。
	ISP_SPLIT_BASE=$BASE ISP_SPLIT_CONF=$CONF ISP_SPLIT_CRONTAB=$CRONTAB \
		ISP_SPLIT_LIB=$LIB MWAN3_SERVICE=$MWAN3_SERVICE \
		DNSDIR=$DNSDIR DNSMASQ_DEFAULT_DIR=$DNSMASQ_DEFAULT_DIR \
		DNSMASQ_CONF_GLOB=$DNSMASQ_CONF_GLOB \
		sh "$LIB/teardown.sh" all || {
		die "teardown.sh 执行失败，卸载不完整（见上方输出）"
	}
	log "已卸载（nft 集合随 mwan3 reload 一并消失）"
}

_lc_json_esc() {
	printf '%s' "${1:-}" |
		sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/	/ /g' |
		tr -d '\r\n' |
		sed -e 's/[[:cntrl:]]//g'
}

_lc_json_str() { # 输出带引号的 JSON 字符串
	printf '"%s"' "$(_lc_json_esc "$1")"
}

# lc:begin
# ---------------------------------------------------------------- linecheck
#
# 线路体检（0.3.0 MVP）：对每条 WAN 做
#   1. 本机口 IP 分类（公网 / CGNAT / 私网）
#   2. curl --interface 取出口公网 IP（与 WAN IP 交叉判断多层 NAT）
#   3. 同运营商目标四段延迟（DNS / TCP / TLS / 首字节）+ 抖动 + 丢包
#   4. 归属地（百度 / ip-api，可关；只发出口 IP）
#   5. socat so-bindtodevice 双 STUN 探测（同一本地端口、两个不同 IP）
#   6. 公开规则表评分 + 硬上限 + 一句话建议
#
# 设计约束（详见 LINE-CHECK-FEASIBILITY.md）：
#   · 只承诺五态：公网 / 锥形 / 对称（或多出口）/ CGNAT / 未知，
#     不做 NAT1-4 细分——公网 STUN 基本不支持 CHANGE-REQUEST。
#   · TCP 用 curl --interface <l3_device>，UDP 用 socat so-bindtodevice，
#     只绑源 IP 会被 mwan3 的 mark 带回另一条线。
#   · 不改用户 nft 规则；失败只降级单项，不让整张卡变红。
#   · 结果缓存 60s（LINE_CHECK_MIN_INTERVAL），--force 绕过。
#   · BUG-23：真实探测 21-60s，而 rpcd 的 exec 硬超时 30s。所以 LuCI 页面
#     绝不在这里同步 exec：页面只读 state/linecheck/last.json，探测走
#     `--background`（setsid 子进程 + 锁去重），完成后整目录原子替换。

# 默认目标表。只存域名：运行时解析，再用 @isp_* 集合校验归属。
# 不随机抽集合里的 IP（命中率低、像端口扫描、也无法保证 443 可用）。
LC_DEFAULT_TARGETS='
chinatelecom:www.chinatelecom.com.cn,www.21cn.com
unicom_cnc:www.10010.com,www.chinaunicom.com.cn
cmcc:www.migu.cn,www.10086.com.cn
cernet:www.tsinghua.edu.cn,www.pku.edu.cn
'
# 默认 STUN：多服务器，运行时探测，失败自动换下一个。
LC_DEFAULT_STUN='
stun.miwifi.com:3478
stun.douyucdn.cn:18000
stun.freeswitch.org:3478
stun.cloudflare.com:3478
'
# F-23：默认回显源必须国内可达。api.ipify.org / icanhazip.com 在境内运营商
# 网络经常被 RST/超时，造成“HTTP 200 但 exit_ip 空”。下面三个是 2026-10 实测
# 可达的 HTTPS 端点（ip.3322.net 返回裸 IP；myip.ipip.net 返回
# “当前 IP：x.x.x.x”；ddns.oray.com 返回 “Current IP Address: x.x.x.x”）。
LC_DEFAULT_ECHO='https://ip.3322.net,https://myip.ipip.net,https://ddns.oray.com/checkip'
# 部分站点对默认 curl UA 直接 412/418（实测 www.chinatelecom.com.cn），
# 必须带浏览器 UA；Range 只取 1 字节，避免 CDN 把体检当成下载。
LC_DEFAULT_UA='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
LC_DEFAULT_ACCEPT='text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'

LC_CACHE=$STATE_DIR/linecheck

_lc_opt() { # <变量名> <默认值>
	eval "_lc_o=\${$1-}"
	if [ -z "$_lc_o" ]; then _lc_o=$2; fi
	printf '%s' "$_lc_o"
}

_lc_now() { date +%s; }

# /proc/uptime 是百分之一秒精度；比 date +%s 更适合测 STUN 的 10-200ms RTT。
_lc_uptime_ms() {
	awk '{ printf "%d", $1 * 1000 }' /proc/uptime 2>/dev/null || echo 0
}

_lc_int() {
	case "${1:-}" in '' | *[!0-9]*) printf '0' ;; *) printf '%s' "$1" ;; esac
}

# ---- IPv4 ----

_lc_ip_valid() {
	case "${1:-}" in '' | *[!0-9.]* | .* | *. | *..*) return 1 ;; esac
	printf '%s' "$1" | awk -F. 'NF == 4 && $1 <= 255 && $2 <= 255 && $3 <= 255 && $4 <= 255 { ok = 1 } END { exit(ok ? 0 : 1) }'
}

# F-23：回显源不保证只返回裸 IP。ipip.net 返回“当前 IP：x.x.x.x 来自于：…”，
# oray 返回“Current IP Address: x.x.x.x”。从响应里抓第一个**通过严格 IPv4
# 校验**的候选；纯文本/JSON/HTML 都走同一条路径，不额外信任任何格式。
_lc_extract_ip() {
	printf '%s' "${1:-}" |
		grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' 2>/dev/null |
		while IFS= read -r _eip; do
			_lc_ip_valid "$_eip" && { printf '%s' "$_eip"; break; }
		done
}


_lc_in_net() { # <ip> <net> <bits>
	# 用 awk 做 32 位比较：32 位 ARM/MIPS 上 busybox ash 的算术是 long，
	# 直接 $(( )) 会把 192.168.x 这类 >2^31 的整数溢出成负数。
	awk -v ip="$1" -v net="$2" -v bits="$3" 'BEGIN {
		if (split(ip, a, ".") != 4 || split(net, b, ".") != 4) exit 1
		ipn = ((a[1] * 256 + a[2]) * 256 + a[3]) * 256 + a[4]
		nn  = ((b[1] * 256 + b[2]) * 256 + b[3]) * 256 + b[4]
		scale = 2 ^ (32 - bits)
		exit (int(ipn / scale) == int(nn / scale) ? 0 : 1)
	}'
}

# public | cgnat | private | invalid
_lc_ip_class() {
	_ip=${1:-}
	[ -n "$_ip" ] || { printf 'invalid'; return; }
	_lc_ip_valid "$_ip" || { printf 'invalid'; return; }
	case "$_ip" in
	0.* | 127.* | 169.254.* | 198.18.* | 198.19.* | 224.* | 225.* | 226.* | 227.* | 228.* | 229.* | 230.* | 231.* | 232.* | 233.* | 234.* | 235.* | 236.* | 237.* | 238.* | 239.* | 255.*)
		printf 'private'; return ;;
	esac
	_lc_in_net "$_ip" 100.64.0.0 10 && { printf 'cgnat'; return; }
	_lc_in_net "$_ip" 10.0.0.0 8 && { printf 'private'; return; }
	_lc_in_net "$_ip" 172.16.0.0 12 && { printf 'private'; return; }
	_lc_in_net "$_ip" 192.168.0.0 16 && { printf 'private'; return; }
	printf 'public'
}

# ---- 时间/数值 ----

_lc_ms() { # curl 秒 -> 整数毫秒
	case "${1:-}" in '' | *[!0-9.]*) printf '0' ;; esac
	awk -v v="$1" 'BEGIN { if (v <= 0) { print 0; exit } printf "%d", int(v * 1000 + 0.5) }'
}

_lc_median() { # 空格分隔的整数列表
	printf '%s\n' "$1" | tr ' ' '\n' | awk 'NF && $1 ~ /^[0-9]+$/ { a[n++] = $1 }
		END {
			if (n == 0) { print ""; exit }
			for (i = 1; i < n; i++)
				for (j = i + 1; j <= n; j++)
					if (a[j] < a[i]) { t = a[i]; a[i] = a[j]; a[j] = t }
			if (n % 2) print a[(n + 1) / 2]
			else printf "%d", int((a[n / 2] + a[n / 2 + 1]) / 2 + 0.5)
		}'
}

_lc_jitter() { # max-min
	printf '%s\n' "$1" | tr ' ' '\n' | awk '
		NF && $1 ~ /^[0-9]+$/ { if (n == 0 || $1 < mn) mn = $1; if (n == 0 || $1 > mx) mx = $1; n++ }
		END { if (n < 2) print 0; else printf "%d", mx - mn }'
}

_lc_fmt_ms() {
	case "${1:-}" in
	'') printf '—' ;;
	'<1') printf '<1ms' ;;
	*[!0-9]*) printf '—' ;;
	*) printf '%sms' "$1" ;;
	esac
}

# ---- 配置/表 ----

_lc_targets_raw() { _lc_opt LINE_CHECK_TARGETS "$LC_DEFAULT_TARGETS"; }
_lc_stun_raw() { _lc_opt LINE_CHECK_STUN_SERVERS "$LC_DEFAULT_STUN"; }
# A-04：读取侧复校验。直接改 /etc/isp-split.conf 或环境变量可绕过 conf set；
# ECHO_URLS 的每个 token 都必须是 http(s)://，不能是 -K 这类 curl 选项。
_lc_echo_raw() {
	_ler=$(_lc_opt LINE_CHECK_ECHO_URLS "$LC_DEFAULT_ECHO")
	_conf_lc_urls_ok "$_ler" ||
		die "LINE_CHECK_ECHO_URLS 含非法项（必须是 http(s):// URL，且不能是 curl 选项）: $_ler"
	printf '%s' "$_ler"
}
# A-03/A-04：UA 读取侧二次校验，非法直接报错而不是静默清洗。
_lc_ua() {
	_lua=$(_lc_opt LINE_CHECK_UA "$LC_DEFAULT_UA")
	if _conf_lc_ctrl "$_lua"; then
		die "LINE_CHECK_UA 含控制字符（CR/LF/TAB），拒绝用于 curl -A"
	fi
	_conf_lc_visible "$_lua" ||
		die "LINE_CHECK_UA 只能包含可见 ASCII（0x20-0x7e），拒绝用于 curl -A"
	printf '%s' "$_lua"
}

# linecheck 启动时统一把读取侧校验跑一遍。放在这里而不是每个调用点，
# 是因为 _lc_ua/_lc_echo_raw 常在 $(...) 子 shell 里调用：子 shell 里 die
# 只会结束子 shell，父进程会继续用空值跑下去。启动时直接调用才能硬失败。
_lc_read_validate() {
	_lc_ua >/dev/null
	_lrv_acc=$(_lc_opt LINE_CHECK_ACCEPT "$LC_DEFAULT_ACCEPT")
	if _conf_lc_ctrl "$_lrv_acc"; then
		die "LINE_CHECK_ACCEPT 含控制字符（CR/LF/TAB），拒绝用于 curl -H"
	fi
	_conf_lc_visible "$_lrv_acc" ||
		die "LINE_CHECK_ACCEPT 只能包含可见 ASCII（0x20-0x7e），拒绝用于 curl -H"
	_lc_echo_raw >/dev/null
}
_lc_timeout() {
	_t=$(_lc_int "$(_lc_opt LINE_CHECK_TIMEOUT 3)")
	[ "$_t" -ge 1 ] 2>/dev/null || _t=3
	[ "$_t" -le 10 ] 2>/dev/null || _t=10
	printf '%s' "$_t"
}
_lc_budget() {
	_b=$(_lc_int "$(_lc_opt LINE_CHECK_BUDGET 20)")
	[ "$_b" -ge 8 ] 2>/dev/null || _b=20
	printf '%s' "$_b"
}

# 运营商源名 -> nft 集合名（ISP_TABLE 第 2 列）

_lc_set_for() { # <源名>
	_old_ifs=$IFS
	_want=$1
	for _row in $ISP_TABLE; do
		# A-06：入口复用 _row_check_row，坏集合名/策略名在任何 nft argv
		# 之前 die，而不是等 nft 解析失败。
		_row_check_row "$_row"
		IFS=:
		set -- $_row
		IFS=$_old_ifs
		[ "${1:-}" = "$_want" ] && { printf '%s' "${2:-}"; return 0; }
	done
	IFS=$_old_ifs
	return 0
}

# IP 属于哪个运营商集合（本地判断，不外发）
_lc_ip_isp() { # <ip>
	_ip=${1:-}
	_lc_ip_valid "$_ip" || return 0
	# A-06：先校验整张表，再碰 nft；坏集合名连 `nft list table` 都不会发生。
	for _row in $ISP_TABLE; do
		_row_check_row "$_row"
	done
	command -v nft >/dev/null 2>&1 || return 0
	nft list table inet mwan3 >/dev/null 2>&1 || return 0
	_old_ifs=$IFS
	for _row in $ISP_TABLE; do
		# A-06：同 _lc_set_for，整行先过读取侧校验。
		_row_check_row "$_row"
		IFS=:
		set -- $_row
		IFS=$_old_ifs
		[ "${4:-0}" = "1" ] || continue
		[ -n "${2:-}" ] || continue
		if nft get element inet mwan3 "$2" "{ $_ip }" >/dev/null 2>&1; then
			printf '%s' "$1"
			return 0
		fi
	done
	IFS=$_old_ifs
	return 0
}

_lc_valid_host() {
	case "${1:-}" in '' | *[!A-Za-z0-9._-]* | .* | *. | *..*) return 1 ;; esac
	return 0
}

_lc_valid_port() {
	case "${1:-}" in '' | *[!0-9]*) return 1 ;; esac
	[ "$1" -ge 1 ] 2>/dev/null && [ "$1" -le 65535 ] 2>/dev/null
}

# 解析域名 -> 前 N 个 IPv4（系统 resolver；不承诺按线解析）
_lc_resolve() { # <domain> [max]
	_d=${1:-}
	_n=${2:-3}
	_lc_valid_host "$_d" || return 0
	if [ -n "${ISP_SPLIT_LC_RESOLVE_IPS:-}" ]; then
		printf '%s' "$ISP_SPLIT_LC_RESOLVE_IPS" | tr ',' '\n' | head -n "$_n"
		return 0
	fi
	command -v nslookup >/dev/null 2>&1 || return 0
	nslookup "$_d" 2>/dev/null | awk -v max="$_n" '
		/^Name:/ { seen = 1; next }
		seen && /^Address/ {
			ip = $NF
			if (ip ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ && !(ip in got)) {
				got[ip] = 1
				print ip
				n++
				if (n >= max) exit
			}
		}
	'
}

# ---- WAN 枚举 ----

_lc_dev_ip() {
	ip -4 addr show dev "$1" 2>/dev/null |
		awk '/^[[:space:]]*inet / { sub(/\/.*/, "", $2); print $2; exit }'
}

# 输出 TSV：iface<TAB>device<TAB>up(0/1)<TAB>state<TAB>wan_ip
_lc_wans() {
	# 自检/夹具模式：ISP_SPLIT_LC_WANS="wan1|wan1|1|online|100.64.1.2 ..."
	if [ -n "${ISP_SPLIT_LC_WANS:-}" ]; then
		printf '%s\n' "$ISP_SPLIT_LC_WANS" | tr ' ' '\n' | while IFS='|' read -r _a _b _c _d _e; do
			[ -n "$_a" ] || continue
			printf '%s\t%s\t%s\t%s\t%s\n' "$_a" "${_b:-$_a}" "${_c:-1}" "${_d:-online}" "${_e:-}"
		done
		return 0
	fi
	command -v uci >/dev/null 2>&1 || return 0
	_seen=" "
	for _i in $(uci -q show mwan3 2>/dev/null | sed -n 's/^mwan3\.\([^=]*\)=interface$/\1/p'); do
		_en=$(uci -q get "mwan3.$_i.enabled" 2>/dev/null)
		[ "$_en" = "1" ] || continue
		_fam=$(uci -q get "mwan3.$_i.family" 2>/dev/null)
		[ "$_fam" = "ipv6" ] && continue
		_dev=$(ifstatus "$_i" 2>/dev/null | jsonfilter -e '@.l3_device' 2>/dev/null)
		[ -n "$_dev" ] || _dev=$(uci -q get "network.$_i.device" 2>/dev/null)
		[ -n "$_dev" ] || continue
		case "$_seen" in *" $_dev "*) continue ;; esac
		_seen="$_seen$_dev "
		_up=$(ifstatus "$_i" 2>/dev/null | jsonfilter -e '@.up' 2>/dev/null)
		case "$_up" in
		true) _state=online; _upn=1 ;;
		false) _state=offline; _upn=0 ;;
		*) _state=unknown; _upn=0 ;;
		esac
		_ip=$(ifstatus "$_i" 2>/dev/null | jsonfilter -e '@.["ipv4-address"][0].address' 2>/dev/null)
		[ -n "$_ip" ] || _ip=$(_lc_dev_ip "$_dev")
		printf '%s\t%s\t%s\t%s\t%s\n' "$_i" "$_dev" "$_upn" "$_state" "$_ip"
	done
}

# --wan 指定：接受 mwan3 逻辑接口名，也接受运行时设备名（如 pppoe-wan1 / br-lan）
_lc_wan_one() {
	_want=$1
	case "$_want" in '' | *[!A-Za-z0-9_.-]*) return 1 ;; esac
	if [ -n "${ISP_SPLIT_LC_WANS:-}" ]; then
		printf '%s\n' "$ISP_SPLIT_LC_WANS" | tr ' ' '\n' | while IFS='|' read -r _a _b _c _d _e; do
			[ "$_a" = "$_want" ] || [ "${_b:-}" = "$_want" ] || continue
			printf '%s\t%s\t%s\t%s\t%s\n' "$_a" "${_b:-$_a}" "${_c:-1}" "${_d:-online}" "${_e:-}"
			return 0
		done
		return 0
	fi
	_dev=$(ifstatus "$_want" 2>/dev/null | jsonfilter -e '@.l3_device' 2>/dev/null)
	_logical=$_want
	if [ -z "$_dev" ]; then
		# 用 ifindex 判断“真实网络设备”：--wan .. / . 虽然能让 -d 成立
		# （/sys/class/net/.. 存在），但不是设备，不能进 curl/socat。
		if [ -e "/sys/class/net/$_want/ifindex" ]; then
			_dev=$_want
		else
			return 1
		fi
	fi
	_up=$(ifstatus "$_want" 2>/dev/null | jsonfilter -e '@.up' 2>/dev/null)
	case "$_up" in
	true) _state=online; _upn=1 ;;
	false) _state=offline; _upn=0 ;;
	*)
		# --wan 可以是裸设备（如 br-lan / pppoe-wan1）：ifstatus 不认，
		# 用 operstate 判断，否则会把一条好好的线判成“未连接”。
		if [ -e "/sys/class/net/$_dev/ifindex" ]; then
			case "$(cat "/sys/class/net/$_dev/operstate" 2>/dev/null)" in
			up) _state=online; _upn=1 ;;
			down) _state=offline; _upn=0 ;;
			*)
				# PPPoE 的 operstate 常是 unknown；看 ip link 的 UP 标志
				if ip -o link show dev "$_dev" 2>/dev/null | grep -q '<[^>]*UP'; then
					_state=online; _upn=1
				else
					_state=unknown; _upn=0
				fi
				;;
			esac
		else
			_state=unknown
			_upn=0
		fi
		;;
	esac
	[ -e "/sys/class/net/$_dev/ifindex" ] || _upn=0
	_ip=$(ifstatus "$_want" 2>/dev/null | jsonfilter -e '@.["ipv4-address"][0].address' 2>/dev/null)
	[ -n "$_ip" ] || _ip=$(_lc_dev_ip "$_dev")
	printf '%s\t%s\t%s\t%s\t%s\n' "$_logical" "$_dev" "$_upn" "$_state" "$_ip"
}

# ---- 出口 IP ----

# F-23：逐 URL 探测出口 IP，并把每个 URL 的 http_code/err/耗时写进
# LC_EXIT_PROBE（JSON 数组），供页面区分“源不可达”与“真的没出网”。
# 注意：本函数直接设置全局 LC_EXIT_IP / LC_EXIT_PROBE，不能用 $(...) 调用——
# 命令替换会丢掉探测明细。
_lc_exit_ip() { # <device>，设置 LC_EXIT_IP 与 LC_EXIT_PROBE
	_dev=$1
	LC_EXIT_IP=""
	LC_EXIT_PROBE_ITEMS=""
	# 夹具模式：ISP_SPLIT_LC_EXIT_IPS="1.2.3.4,5.6.7.8"，none 表示失败
	if [ -n "${ISP_SPLIT_LC_EXIT_IPS:-}" ]; then
		LC_EXIT_IP=$(printf '%s' "$ISP_SPLIT_LC_EXIT_IPS" | awk -v idx="${LC_IDX:-1}" -F, '
			$idx != "none" && $idx ~ /^[0-9.]+$/ { print $idx }')
		LC_EXIT_PROBE=${ISP_SPLIT_LC_EXIT_PROBE:-[]}
		return 0
	fi
	command -v curl >/dev/null 2>&1 || { LC_EXIT_PROBE="[]"; return 0; }
	_old_ifs=$IFS
	IFS=,
	for _u in $(_lc_echo_raw); do
		IFS=$_old_ifs
		# TMPDIR 可能指向不可写的沙箱目录；/tmp 作为兜底（mktemp 仍是
		# O_EXCL 随机名，不会被预占）。
		_eb=$(mktemp "${TMPDIR:-/tmp}/.isp-split-echo.XXXXXX" 2>/dev/null) ||
			_eb=$(mktemp "/tmp/.isp-split-echo.XXXXXX" 2>/dev/null) || _eb=""
		_ee=$(mktemp "${TMPDIR:-/tmp}/.isp-split-echo2.XXXXXX" 2>/dev/null) ||
			_ee=$(mktemp "/tmp/.isp-split-echo2.XXXXXX" 2>/dev/null) || _ee=""
		if [ -z "$_eb" ] || [ -z "$_ee" ]; then
			rm -f "$_eb" "$_ee"
			continue
		fi
		_meta=$(curl -sS --interface "$_dev" --connect-timeout 3 -m 5 \
			--max-filesize 65536 -A "$(_lc_ua)" -o "$_eb" \
			-w '%{http_code} %{time_total}' --url "$_u" 2>"$_ee")
		_rc=$?
		_code=$(printf '%s' "$_meta" | awk '{ print $1; exit }')
		_tt=$(printf '%s' "$_meta" | awk '{ print $2; exit }')
		case "$_code" in '' | *[!0-9]*) _code=0 ;; *) _code=$((_code + 0)) ;; esac
		_err=$(head -1 "$_ee" 2>/dev/null | tr '\t\r\n' '   ' | tr -s ' ' | cut -c1-160)
		_body=$(head -c 512 "$_eb" 2>/dev/null)
		_ip=""
		case "$_code" in
		2* | 3*) _ip=$(_lc_extract_ip "$_body") ;;
		esac
		_ok=false
		[ -n "$_ip" ] && _ok=true
		if [ "$_ok" = "true" ]; then
			LC_EXIT_IP=$_ip
			_err=""
		else
			[ -n "$_err" ] || _err="no-ip"
		fi
		_ms=$(_lc_ms "${_tt:-0}")
		LC_EXIT_PROBE_ITEMS="${LC_EXIT_PROBE_ITEMS}${LC_EXIT_PROBE_ITEMS:+,}"
		LC_EXIT_PROBE_ITEMS="${LC_EXIT_PROBE_ITEMS}{\"url\":$(_lc_json_str "$_u"),\"http_code\":$(_lc_int "$_code"),\"err\":$(_lc_json_str "$_err"),\"ms\":$(_lc_int "$_ms"),\"ok\":$_ok}"
		rm -f "$_eb" "$_ee"
		[ "$_ok" = "true" ] && break
	done
	IFS=$_old_ifs
	LC_EXIT_PROBE="[${LC_EXIT_PROBE_ITEMS}]"
	return 0
}

# ---- 目标选择（报告 §3.3） ----

_lc_target_domains() { # <源名> -> 空格分隔域名
	_old_ifs=$IFS
	for _row in $(_lc_targets_raw); do
		IFS=:
		set -- $_row
		IFS=$_old_ifs
		if [ "${1:-}" = "${_lc_t_src:-}" ]; then
			printf '%s' "${2:-}" | tr ',' ' '
			return 0
		fi
	done
	IFS=$_old_ifs
	return 0
}

_lc_target_groups() { # <源名>，只输出该运营商的域名；未知则交给跨网基线
	_own=${1:-}
	[ -n "$_own" ] || return 0
	_old_ifs=$IFS
	for _row in $(_lc_targets_raw); do
		IFS=:
		set -- $_row
		IFS=$_old_ifs
		[ "${1:-}" = "$_own" ] || continue
		for _d in $(printf '%s' "${2:-}" | tr ',' ' '); do
			printf '%s\t%s\n' "$_own" "$_d"
		done
	done
	IFS=$_old_ifs
}

# ---- STUN ----
#
# 报文：00 01 00 00 21 12 A4 42 + 12 字节 transaction id（共 20 字节）。
# 响应解析：XOR-MAPPED-ADDRESS(0x0020) / MAPPED-ADDRESS(0x0001)；
# 端口 ^ 0x2112，IPv4 与 21 12 A4 42 逐字节异或；属性按 4 字节对齐。

_lc_stun_build() { # <request-file> -> 打印 txid(24 hex)
	_req=$1
	{ printf '\000\001\000\000\041\022\244\102'; dd if=/dev/urandom bs=12 count=1 2>/dev/null; } >"$_req" ||
		return 1
	hexdump -v -n 12 -s 8 -e '12/1 "%02x"' "$_req"
}

_lc_hex2ip() { # 8 个 hex 字符 -> 点分十进制
	_h=${1:-}
	[ "${#_h}" -eq 8 ] || return 1
	printf '%d.%d.%d.%d' \
		"$((0x${_h%??????}))" \
		"$((0x$(printf '%s' "$_h" | cut -c3-4)))" \
		"$((0x$(printf '%s' "$_h" | cut -c5-6)))" \
		"$((0x$(printf '%s' "$_h" | cut -c7-8)))"
}

# 解析 STUN 响应。成功打印 "ip port"，失败返回 1。
# 校验：长度、消息类型 0x0101、magic cookie、transaction id、属性边界。
_lc_stun_parse() { # <response-file> <expected-txid-hex>
	_f=$1
	_txid=$2
	[ -f "$_f" ] || return 1
	_hex=$(hexdump -v -e '1/1 "%02x"' "$_f" 2>/dev/null | tr -d ' \n')
	[ -n "$_hex" ] || return 1
	_len=$(( ${#_hex} / 2 ))
	[ "$_len" -ge 20 ] || return 1
	[ $((_len % 4)) -eq 0 ] || return 1
	_type=$(printf '%s' "$_hex" | cut -c1-4)
	[ "$_type" = "0101" ] || return 1
	_magic=$(printf '%s' "$_hex" | cut -c9-16)
	[ "$_magic" = "2112a442" ] || return 1
	_got=$(printf '%s' "$_hex" | cut -c17-40)
	[ "$_got" = "$_txid" ] || return 1
	_mlen=$(printf '%s' "$_hex" | cut -c5-8)
	_mlen=$((0x$_mlen))
	# 声明的属性长度不能超出实际报文（坏包 fixture 会触发这里）
	[ $((20 + _mlen)) -le "$_len" ] || return 1

	_pos=20
	_end=$((20 + _mlen))
	while [ "$_pos" -lt "$_end" ]; do
		[ $((_pos + 4)) -le "$_len" ] || return 1
		_at=$(printf '%s' "$_hex" | cut -c$((_pos * 2 + 1))-$((_pos * 2 + 4)))
		_al=$(printf '%s' "$_hex" | cut -c$((_pos * 2 + 5))-$((_pos * 2 + 8)))
		_at=$((0x$_at))
		_al=$((0x$_al))
		_doff=$((_pos + 4))
		[ $((_doff + _al)) -le "$_len" ] || return 1
		case "$_at" in
		32 | 1) # XOR-MAPPED-ADDRESS / MAPPED-ADDRESS
			[ "$_al" -ge 8 ] || return 1
			_fam=$(printf '%s' "$_hex" | cut -c$((_doff * 2 + 3))-$((_doff * 2 + 4)))
			[ "$_fam" = "01" ] || return 1
			_ph=$(printf '%s' "$_hex" | cut -c$((_doff * 2 + 5))-$((_doff * 2 + 8)))
			_ih=$(printf '%s' "$_hex" | cut -c$((_doff * 2 + 9))-$((_doff * 2 + 16)))
			_port=$((0x$_ph))
			if [ "$_at" = "32" ]; then
				_port=$((_port ^ 0x2112))
				_b1=$((0x$(printf '%s' "$_ih" | cut -c1-2) ^ 0x21))
				_b2=$((0x$(printf '%s' "$_ih" | cut -c3-4) ^ 0x12))
				_b3=$((0x$(printf '%s' "$_ih" | cut -c5-6) ^ 0xA4))
				_b4=$((0x$(printf '%s' "$_ih" | cut -c7-8) ^ 0x42))
				_ip="$_b1.$_b2.$_b3.$_b4"
			else
				_ip=$(_lc_hex2ip "$_ih")
			fi
			case "$_ip" in 0.0.0.0) return 1 ;; esac
			printf '%s %s' "$_ip" "$_port"
			return 0
			;;
		esac
		_pos=$((_doff + ((_al + 3) / 4) * 4))
	done
	return 1
}

# 一次 UDP Binding。成功打印 "mapped_ip mapped_port rtt_ms"，失败返回 1。
_lc_stun_once() { # <device> <server_ip> <server_port> <local_port> <timeout>
	_sdev=$1
	_sip=$2
	_sport=$3
	_lport=$4
	_sto=$5

	# SEC S4：旧实现用固定路径 /tmp/isp-lc-stun.$$.req/.resp。$$ 可预测，
	# 且 shell 的 `>`/socat 的 `>` 都会跟随 symlink，本机低权用户只要在
	# /tmp 预铺链接，就能让 root 覆盖/截断任意文件。
	# 现在：umask 077 + mktemp 随机名（O_EXCL 创建，别人无法预占），
	# 创建后立即把同一个 inode 截断为 0；写入前再拒绝 symlink 作为
	# 纵深防御。清理只使用下面两个变量里的真实路径，绝不按 $$ 重拼。
	_sr_old_umask=$(umask)
	umask 077
	_sr_req=$(mktemp "${TMPDIR:-/tmp}/isp-lc-stun.XXXXXXXX" 2>/dev/null)
	LC_STUN_REQ=$_sr_req
	_sr_resp=$(mktemp "${TMPDIR:-/tmp}/isp-lc-stun.XXXXXXXX" 2>/dev/null)
	LC_STUN_RESP=$_sr_resp
	umask "$_sr_old_umask"
	if [ -z "$_sr_req" ] || [ -z "$_sr_resp" ]; then
		[ -n "$_sr_req" ] && rm -f "$_sr_req"
		[ -n "$_sr_resp" ] && rm -f "$_sr_resp"
		LC_STUN_REQ=""
		LC_STUN_RESP=""
		return 1
	fi
	if [ -L "$_sr_req" ] || [ -L "$_sr_resp" ]; then
		rm -f "$_sr_req" "$_sr_resp"
		LC_STUN_REQ=""
		LC_STUN_RESP=""
		return 1
	fi
	if ! : >"$_sr_req" 2>/dev/null || ! : >"$_sr_resp" 2>/dev/null; then
		rm -f "$_sr_req" "$_sr_resp"
		LC_STUN_REQ=""
		LC_STUN_RESP=""
		return 1
	fi

	_txid=$(_lc_stun_build "$_sr_req" 2>/dev/null) || {
		rm -f "$_sr_req" "$_sr_resp"
		LC_STUN_REQ=""
		LC_STUN_RESP=""
		return 1
	}
	_t0=$(_lc_uptime_ms)
	socat -T "$_sto" - UDP4:"$_sip":"$_sport",bind=:"$_lport",so-bindtodevice="$_sdev" \
		<"$_sr_req" >"$_sr_resp" 2>/dev/null
	_rc=$?
	_t1=$(_lc_uptime_ms)
	if [ "$_rc" -ne 0 ]; then
		rm -f "$_sr_req" "$_sr_resp"
		LC_STUN_REQ=""
		LC_STUN_RESP=""
		return 1
	fi
	_res=$(_lc_stun_parse "$_sr_resp" "$_txid")
	rm -f "$_sr_req" "$_sr_resp"
	LC_STUN_REQ=""
	LC_STUN_RESP=""
	[ -n "$_res" ] || return 1
	printf '%s %s %s' "$_res" "$((_t1 - _t0))"
}

_lc_local_port() {
	_p=$(hexdump -n 2 -e '1/2 "%u"' /dev/urandom 2>/dev/null | tr -d ' ')
	case "$_p" in '' | *[!0-9]*) _p=$$ ;; esac
	echo $((30000 + _p % 30001))
}

# 双探测点分类。参数：
#   <wan_ip> <wan_class> <map1|-> <map2|-> <local_port>
# map 形如 "1.2.3.4:40000"。
# 输出：type|confidence|detail
_lc_nat_verdict() {
	_vwan=$1
	_vclass=$2
	_vm1=${3:--}
	_vm2=${4:--}
	_vlport=${5:-0}
	[ "$_vm1" = "-" ] && _vm1=""
	[ "$_vm2" = "-" ] && _vm2=""

	_vip1=${_vm1%:*}
	_vport1=${_vm1#*:}
	_vip2=${_vm2%:*}
	_vport2=${_vm2#*:}

	if [ "$_vclass" = "cgnat" ]; then
		printf 'cgnat|high|WAN IP %s 属于 100.64.0.0/10（运营商级 NAT）' "$_vwan"
		return 0
	fi
	if [ -z "$_vm1" ] && [ -z "$_vm2" ]; then
		printf 'unknown|unknown|两个探测点均无响应（UDP 3478 可能被阻断或服务器不可达）'
		return 0
	fi

	# 映射地址是私网/CGNAT = 不止一层 NAT
	for _vm in "$_vm1" "$_vm2"; do
		[ -n "$_vm" ] || continue
		case "$(_lc_ip_class "${_vm%:*}")" in
		private | cgnat)
			printf 'cgnat|high|出口映射为私网地址 %s，存在多层 NAT，端口映射不可用' "$_vm"
			return 0
			;;
		esac
	done

	if [ -n "$_vm1" ] && [ -n "$_vm2" ]; then
		if [ "$_vm1" = "$_vm2" ]; then
			if [ "$_vclass" = "public" ] && [ "$_vip1" = "$_vwan" ] && [ "$_vport1" = "$_vlport" ]; then
				printf 'public|high|两个探测点映射均为 %s，与 WAN IP/本地端口一致（无端口转换）' "$_vm1"
			else
				printf 'cone|high|两个探测点映射一致（%s），锥形 NAT（未细分）' "$_vm1"
			fi
			return 0
		fi
		printf 'symmetric|high|两个探测点映射不同（%s / %s），对称或多出口 NAT' "$_vm1" "$_vm2"
		return 0
	fi

	# 只有一个探测点成功：不能交叉验证
	_only=$_vm1
	[ -n "$_only" ] || _only=$_vm2
	if [ "$_vclass" = "public" ] && [ "${_only%:*}" = "$_vwan" ] && [ "${_only#*:}" = "$_vlport" ]; then
		printf 'public|medium|单点映射 %s 与 WAN IP/本地端口一致；未能交叉验证' "$_only"
	else
		printf 'cone|medium|仅一个探测点成功（%s），按锥形处理，未能交叉验证' "$_only"
	fi
}

_lc_stun_bad_detail() {
	[ "$LC_STUN_TYPE" = "unknown" ] || return 0
	case "$LC_STUN_MAPS" in
	*"|bad|"*)
		LC_STUN_DETAIL="STUN 响应校验失败（magic cookie / transaction id / 属性边界不符），已丢弃坏包"
		;;
	esac
}

# 探测结果全局变量：
#   LC_STUN_OK(0/1) LC_STUN_TYPE LC_STUN_CONF LC_STUN_DETAIL
#   LC_STUN_MAPS = 每行 server|ip|status|mapped|rtt
_lc_stun_probe() { # <device> <wan_ip> <wan_class>
	_pdev=$1
	_pwan=$2
	_pclass=$3
	LC_STUN_OK=0
	LC_STUN_TYPE=unknown
	LC_STUN_CONF=unknown
	LC_STUN_DETAIL=""
	LC_STUN_MAPS=""

	_lport=$(_lc_local_port)
	LC_STUN_LPORT=$_lport
	# STUN 固定 2s（可行性报告 §2.6 的 socat -T 2）：UDP 首包要么几十 ms
	# 回来，要么就是被丢弃，等 3s 只会让整次体检越过 rpcd 预算。
	_lsto=2

	# 夹具目录模式：用真实二进制包走完整解析（selftest / VM 回归用）。
	# 目录里放 stun-1.bin / stun-2.bin；空文件表示超时。
	if [ -n "${ISP_SPLIT_LC_STUN_FIXTURE_DIR:-}" ]; then
		_i=0
		for _f in "$ISP_SPLIT_LC_STUN_FIXTURE_DIR/stun-1.bin" "$ISP_SPLIT_LC_STUN_FIXTURE_DIR/stun-2.bin"; do
			_i=$((_i + 1))
			if [ -s "$_f" ]; then
				_m=$(_lc_stun_parse "$_f" 112233445566778899aabbcc)
				if [ -n "$_m" ]; then
					LC_STUN_MAPS="${LC_STUN_MAPS}fixture-${_i}|198.51.100.${_i}|ok|$(printf '%s' "$_m" | tr ' ' ':')|1
"
				else
					LC_STUN_MAPS="${LC_STUN_MAPS}fixture-${_i}|198.51.100.${_i}|bad|-|-
"
				fi
			else
				LC_STUN_MAPS="${LC_STUN_MAPS}fixture-${_i}|198.51.100.${_i}|timeout|-|-
"
			fi
		done
		_m1=$(printf '%s
' "$LC_STUN_MAPS" | awk -F'|' '$3=="ok"{print $4; exit}')
		_m2=$(printf '%s
' "$LC_STUN_MAPS" | awk -F'|' '$3=="ok"{n++; if(n==2){print $4; exit}}')
		_v=$(_lc_nat_verdict "$_pwan" "$_pclass" "${_m1:--}" "${_m2:--}" "$_lport")
		LC_STUN_TYPE=${_v%%|*}
		_v=${_v#*|}
		LC_STUN_CONF=${_v%%|*}
		LC_STUN_DETAIL=${_v#*|}
		[ "$LC_STUN_TYPE" != "unknown" ] && LC_STUN_OK=1
		_lc_stun_bad_detail
		return 0
	fi

	# 夹具模式：ISP_SPLIT_LC_STUN="ok:1.2.3.4:40000|timeout"
	if [ -n "${ISP_SPLIT_LC_STUN:-}" ]; then
		_i=0
		_old_ifs=$IFS
		IFS='|'
		for _spec in $ISP_SPLIT_LC_STUN; do
			_i=$((_i + 1))
			IFS=$_old_ifs
			case "$_spec" in
			ok:*)
				_val=${_spec#ok:}
				_mapped=${_val%:*}
				_mport=${_val#*:}
				LC_STUN_MAPS="${LC_STUN_MAPS}fixture-${_i}|198.51.100.${_i}|ok|${_mapped}:${_mport}|1
"
				;;
			bad)
				LC_STUN_MAPS="${LC_STUN_MAPS}fixture-${_i}|198.51.100.${_i}|bad|-|-
"
				;;
			*)
				LC_STUN_MAPS="${LC_STUN_MAPS}fixture-${_i}|198.51.100.${_i}|timeout|-|-
"
				;;
			esac
		done
		IFS=$_old_ifs
		_m1=$(printf '%s\n' "$LC_STUN_MAPS" | awk -F'|' '$3=="ok"{print $4; exit}')
		_m2=$(printf '%s\n' "$LC_STUN_MAPS" | awk -F'|' '$3=="ok"{n++; if(n==2){print $4; exit}}')
		_v=$(_lc_nat_verdict "$_pwan" "$_pclass" "${_m1:--}" "${_m2:--}" "$_lport")
		LC_STUN_TYPE=${_v%%|*}
		_v=${_v#*|}
		LC_STUN_CONF=${_v%%|*}
		LC_STUN_DETAIL=${_v#*|}
		[ "$LC_STUN_TYPE" != "unknown" ] && LC_STUN_OK=1
		_lc_stun_bad_detail
		return 0
	fi

	if [ "${ISP_SPLIT_LC_NO_SOCAT:-0}" = "1" ] || ! command -v socat >/dev/null 2>&1; then
		LC_STUN_DETAIL="socat 未安装，无法做 UDP STUN；安装 socat 后可获得 NAT 类型（opkg install socat / apk add socat）"
		LC_STUN_TYPE=unknown
		LC_STUN_CONF=unknown
		LC_STUN_LPORT=0
		return 0
	fi

	# 解析服务器：每家只做一次 DNS，第一个 A 记录进主列表、第二个进备列表，
	# 主列表优先（保证厂商多样性），避免对同一个失败域名反复 nslookup。
	_srv_main=""
	_srv_alt=""
	_old_ifs=$IFS
	for _s in $(_lc_stun_raw); do
		IFS=$_old_ifs
		_sh=${_s%:*}
		_sp=${_s#*:}
		_lc_valid_host "$_sh" || continue
		_lc_valid_port "$_sp" || continue
		_sips=$(_lc_resolve "$_sh" 2)
		set -- $_sips
		if [ $# -ge 1 ]; then
			_dup=0
			for _x in $_srv_main $_srv_alt; do
				case "$_x" in "$1:"*) _dup=1 ;; esac
			done
			[ "$_dup" = "0" ] && _srv_main="$_srv_main $1:$_sp:$_sh"
		fi
		if [ $# -ge 2 ]; then
			_dup=0
			for _x in $_srv_main $_srv_alt; do
				case "$_x" in "$2:"*) _dup=1 ;; esac
			done
			[ "$_dup" = "0" ] && _srv_alt="$_srv_alt $2:$_sp:$_sh"
		fi
	done
	IFS=$_old_ifs
	# SEC S2：_srv_main/_srv_alt 都为空时，旧写法 "=" 拼出来是一个空格，
	# `[ -z "$_srv_list" ]` 判不出来；随后 `set -- $_srv_list` 得到 0 个
	# 参数，`$1` 在 set -u 下直接让整条 WAN 的结果丢失。这里按拼接后的
	# 非空白内容判空，并保证后面重试分支只在确实有服务器时才展开。
	_srv_list=""
	if [ -n "${_srv_main}${_srv_alt}" ]; then
		_srv_list="$_srv_main $_srv_alt"
	fi
	if [ -z "$_srv_list" ]; then
		LC_STUN_DETAIL="STUN 服务器域名全部解析失败（检查 DNS 或 LINE_CHECK_STUN_SERVERS）"
		LC_STUN_TYPE=unknown
		LC_STUN_CONF=unknown
		return 0
	fi

	_probe() { # <server-spec> <index>
		_ps=$1
		_pi=$2
		_pip=${_ps%%:*}
		_prest=${_ps#*:}
		_pport=${_prest%%:*}
		_phost=${_prest#*:}
		_r=$(_lc_stun_once "$_pdev" "$_pip" "$_pport" "$_lport" "$_lsto")
		_probe_ok=0
		if [ -n "$_r" ]; then
			set -- $_r
			LC_STUN_MAPS="${LC_STUN_MAPS}${_phost}|${_pip}|ok|${1}:${2}|${3}
"
			_probe_ok=1
		else
			LC_STUN_MAPS="${LC_STUN_MAPS}${_phost}|${_pip}|timeout|-|-
"
		fi
	}
	_pn=0
	_pok=0
	for _s in $_srv_list; do
		[ "$_pn" -ge 4 ] && break
		[ -n "${LC_BUDGET:-}" ] && _lc_deadline_hit && break
		[ "$_pok" -ge 2 ] && break
		_pn=$((_pn + 1))
		_probe "$_s" "$_pn"
		[ "${_probe_ok:-0}" = "1" ] && _pok=$((_pok + 1))
	done
	# 一个都没成功且预算还够：把第一个再试一次（UDP 首包偶发丢包）。
	# 即使上面的判空被绕过，也先用 $# 确认展开后确实有参数，绝不裸用 $1。
	if [ "$_pok" -eq 0 ] && ! _lc_deadline_hit; then
		set -- $_srv_list
		[ $# -ge 1 ] && _probe "$1" 1
	fi

	_m1=$(printf '%s\n' "$LC_STUN_MAPS" | awk -F'|' '$3=="ok"{print $4; exit}')
	_m2=$(printf '%s\n' "$LC_STUN_MAPS" | awk -F'|' '$3=="ok"{n++; if(n==2){print $4; exit}}')
	_v=$(_lc_nat_verdict "$_pwan" "$_pclass" "${_m1:--}" "${_m2:--}" "$_lport")
	LC_STUN_TYPE=${_v%%|*}
	_v=${_v#*|}
	LC_STUN_CONF=${_v%%|*}
	LC_STUN_DETAIL=${_v#*|}
	[ "$LC_STUN_TYPE" != "unknown" ] && LC_STUN_OK=1
	_lc_stun_bad_detail
}

# ---- 延迟 / 可用性 ----

# F-24b：DNS 段必须测“这条 WAN 的 DNS”，不能测系统 resolver（127.0.0.1
# 的 dnsmasq 热缓存）。从 DNS_TABLE 的 <策略>:<DNS> 里找策略出接口等于本
# WAN 设备的条目；取不到时回退系统 resolver，并在 JSON 标 dns_scope=local。
# 设置 LC_DNS_SERVER / LC_DNS_SCOPE。
_lc_dns_for_dev() { # <device>
	_lcdd_dev=$1
	LC_DNS_SERVER=""
	LC_DNS_SCOPE="local"
	# 夹具：ISP_SPLIT_LC_DNS_IPS="223.5.5.5,none"（按 LC_IDX 取），或单个
	# ISP_SPLIT_LC_DNS_IP。夹具也走“按线”语义，scope=wan。
	if [ -n "${ISP_SPLIT_LC_DNS_IPS:-}" ]; then
		_lcdd_ip=$(printf '%s' "$ISP_SPLIT_LC_DNS_IPS" | awk -v idx="${LC_IDX:-1}" -F, '
			$idx != "none" { print $idx }')
		case "$_lcdd_ip" in
		'' | none) ;;
		*)
			if _valid_ipv4 "$_lcdd_ip"; then
				LC_DNS_SERVER=$_lcdd_ip
				LC_DNS_SCOPE=wan
			fi
			;;
		esac
		return 0
	fi
	if [ -n "${ISP_SPLIT_LC_DNS_IP:-}" ]; then
		if _valid_ipv4 "$ISP_SPLIT_LC_DNS_IP"; then
			LC_DNS_SERVER=$ISP_SPLIT_LC_DNS_IP
			LC_DNS_SCOPE=wan
		fi
		return 0
	fi
	for _lcdd_row in ${DNS_TABLE:-}; do
		_lcdd_pol=${_lcdd_row%%:*}
		_lcdd_ip=${_lcdd_row#*:}
		[ -n "$_lcdd_ip" ] || continue
		_valid_policy "$_lcdd_pol" || continue
		_valid_ipv4 "$_lcdd_ip" || continue
		_lcdd_pdev=$(policy_device "$_lcdd_pol")
		if [ -n "$_lcdd_pdev" ] && [ "$_lcdd_pdev" = "$_lcdd_dev" ]; then
			LC_DNS_SERVER=$_lcdd_ip
			LC_DNS_SCOPE=wan
			break
		fi
	done
	return 0
}

# curl 是否带 c-ares（--dns-servers 只在 c-ares 构建里可用）。探测一次缓存。
LC_CURL_CARES=""
_lc_curl_cares() {
	if [ -z "$LC_CURL_CARES" ]; then
		case "$(curl --version 2>/dev/null | head -1)" in
		*c-ares*) LC_CURL_CARES=1 ;;
		*) LC_CURL_CARES=0 ;;
		esac
	fi
	[ "$LC_CURL_CARES" = "1" ]
}

# curl 秒 -> 整数毫秒 / "<1" / 空（失败）。0 不再作为“未测”哨兵。
_lc_sec_to_dns_ms() {
	case "${1:-}" in '' | *[!0-9.]*) return 1 ;; esac
	awk -v v="$1" 'BEGIN {
		if (v <= 0) exit 1
		ms = v * 1000
		if (ms < 1) { print "<1"; exit 0 }
		printf "%d\n", int(ms + 0.5)
	}'
}

# 纳秒时钟：busybox date 可能不支持 %N（会原样输出秒），用长度判定，
# 不可用就退回 /proc/uptime 的 10ms 精度。返回值写在 stdout。
_lc_clock_ns() {
	_cn=$(date +%s%N 2>/dev/null) || return 1
	case "$_cn" in '' | *[!0-9]*) return 1 ;; esac
	[ "${#_cn}" -ge 16 ] || return 1
	printf '%s' "$_cn"
}

# nslookup 按指定 server 测一次；成功输出毫秒（或 "<1"），失败输出空。
# busybox nslookup 支持 `nslookup <name> <server>`；没有 timeout 命令时
# 仍受 resolver 自身超时约束，但不会无限阻塞（OpenWrt 默认 5s × 2）。
_lc_dns_nslookup() { # <domain> [server]
	_ldn_dom=$1
	_ldn_srv=${2:-}
	_ldn_t0ns=$(_lc_clock_ns 2>/dev/null) || _ldn_t0ns=""
	_ldn_u0=$(_lc_uptime_ms)
	if [ -n "$_ldn_srv" ]; then
		if command -v timeout >/dev/null 2>&1; then
			_ldn_out=$(timeout 3 nslookup "$_ldn_dom" "$_ldn_srv" 2>&1)
		else
			_ldn_out=$(nslookup "$_ldn_dom" "$_ldn_srv" 2>&1)
		fi
	else
		if command -v timeout >/dev/null 2>&1; then
			_ldn_out=$(timeout 3 nslookup "$_ldn_dom" 2>&1)
		else
			_ldn_out=$(nslookup "$_ldn_dom" 2>&1)
		fi
	fi
	_ldn_rc=$?
	_ldn_u1=$(_lc_uptime_ms)
	_ldn_t1ns=$(_lc_clock_ns 2>/dev/null) || _ldn_t1ns=""
	# 失败特征先排除；再要求至少有一行 Name:（NXDOMAIN/SERVFAIL 没有）
	case "$_ldn_out" in
	*"can't resolve"* | *"timed out"* | *"no servers could be reached"* | *"Connection refused"*)
		return 1 ;;
	esac
	[ "$_ldn_rc" -eq 0 ] || return 1
	printf '%s' "$_ldn_out" | grep -q 'Name:' || return 1

	if [ -n "$_ldn_t0ns" ] && [ -n "$_ldn_t1ns" ]; then
		_ldn_diff=$((_ldn_t1ns - _ldn_t0ns))
		[ "$_ldn_diff" -ge 0 ] 2>/dev/null || return 1
		case "$_ldn_diff" in
		0) printf '<1' ;;
		*)
			_ldn_ms=$((_ldn_diff / 1000000))
			[ "$_ldn_ms" -gt 0 ] 2>/dev/null || _ldn_ms="<1"
			printf '%s' "$_ldn_ms"
			;;
		esac
		return 0
	fi
	# 没有纳秒时钟（busybox date 常见）时，用执行前后 /proc/uptime 的
	# 10ms 精度差。0 表示低于分辨率，输出 "<1"，绝不再回 0。
	_ldn_ms=$((_ldn_u1 - _ldn_u0))
	[ "$_ldn_ms" -gt 0 ] 2>/dev/null || _ldn_ms="<1"
	printf '%s' "$_ldn_ms"
}

# DNS 段：优先 c-ares 的 curl --dns-servers（直接问该 WAN 的 DNS）；
# 没有 c-ares 时用 busybox nslookup <domain> <server>。两者都取不到时，
# 回退系统 resolver 并标 dns_scope=local。设置 LC_DNS_MS / LC_DNS_SCOPE。
_lc_dns_ms() { # <device> <domain>，设置 LC_DNS_MS / LC_DNS_SCOPE
	_dv=$1
	_dm=$2
	LC_DNS_MS=""
	LC_DNS_SCOPE="local"
	LC_DNS_SERVER=""
	if [ -n "${ISP_SPLIT_LC_LAT:-}" ]; then
		# 夹具值按“秒”解释，与 curl -w 的 time_namelookup 同口径：
		# dns=0.4 -> 400ms，dns=0.0004 -> "<1"，缺失 -> null。
		_raw=$(_lc_lat_get dns "")
		LC_DNS_MS=$(_lc_sec_to_dns_ms "$_raw" 2>/dev/null) || LC_DNS_MS=""
		return 0
	fi
	_lc_dns_for_dev "$_dv"

	# c-ares 路径：--dns-servers 只影响 DNS，--interface 仍负责选路。
	if [ -n "$LC_DNS_SERVER" ] && command -v curl >/dev/null 2>&1 && _lc_curl_cares; then
		_meta=$(curl -sS --interface "$_dv" --dns-servers "$LC_DNS_SERVER" \
			--connect-timeout 2 -m 3 -o /dev/null \
			-w '%{time_namelookup} %{http_code}' "https://$_dm/" 2>/dev/null)
		_sec=$(printf '%s' "$_meta" | awk '{ print $1; exit }')
		LC_DNS_MS=$(_lc_sec_to_dns_ms "$_sec" 2>/dev/null) || LC_DNS_MS=""
		[ -n "$LC_DNS_MS" ] && return 0
	fi

	command -v nslookup >/dev/null 2>&1 || return 0
	if [ -n "$LC_DNS_SERVER" ]; then
		LC_DNS_MS=$(_lc_dns_nslookup "$_dm" "$LC_DNS_SERVER") || LC_DNS_MS=""
		[ -n "$LC_DNS_MS" ] && return 0
		# 指定 server 失败时仍回退系统 resolver，但 scope 保持 wan 会误导；
		# 标 local 且不再把失败的 server 写进 JSON。
		LC_DNS_SCOPE=local
		LC_DNS_SERVER=""
	fi
	LC_DNS_MS=$(_lc_dns_nslookup "$_dm") || LC_DNS_MS=""
	return 0
}

_lc_lat_get() { # <key> <default> 从 ISP_SPLIT_LC_LAT="dns=2,tcp=30,..." 取
	_k=$1
	_d=${2-}
	printf '%s' "${ISP_SPLIT_LC_LAT:-}" | tr ',' '\n' | awk -F= -v k="$_k" -v d="$_d" '
		$1 == k { print $2; found = 1; exit }
		END { if (!found) print d }'
}

# 测量一个已校验目标：5 次尝试，前 3 次成功样本用于中位数/抖动。
# 设置 LC_DNS_MS LC_TCP_MS LC_TLS_MS LC_TTFB_MS LC_TOTAL_MS
#      LC_SAMPLES LC_JITTER LC_LOSS LC_ATTEMPTS LC_OK_COUNT
#      LC_HTTP_CODE LC_AVAIL
_lc_measure() { # <device> <domain> <ip> <timeout> [fast]
	_mdev=$1
	_mdom=$2
	_mip=$3
	_mto=$4
	_mfast=${5:-0}
	# 连接超时最多 2s：一条需要 >2s 才能建连的线已经不值得等 3s，
	# 而且失败目标的早停能把整次体检压进 rpcd 预算。
	_ct=$_mto
	[ "$_ct" -gt 2 ] && _ct=2
	LC_DNS_MS=""
	LC_DNS_SCOPE="local"
	LC_DNS_SERVER=""
	_lc_dns_ms "$_mdev" "$_mdom"
	LC_TCP_MS=""
	LC_TLS_MS=""
	LC_TTFB_MS=""
	LC_TOTAL_MS=""
	LC_SAMPLES=""
	LC_JITTER=0
	LC_LOSS=100
	LC_ATTEMPTS=0
	LC_OK_COUNT=0
	LC_HTTP_CODE=0
	LC_AVAIL=none

	if [ -n "${ISP_SPLIT_LC_LAT:-}" ]; then
		LC_TCP_MS=$(_lc_lat_get tcp 0)
		LC_TLS_MS=$(_lc_lat_get tls 0)
		LC_TTFB_MS=$(_lc_lat_get ttfb 0)
		LC_TOTAL_MS=$(_lc_lat_get total 0)
		LC_JITTER=$(_lc_lat_get jitter 0)
		LC_LOSS=$(_lc_lat_get loss 0)
		LC_HTTP_CODE=$(_lc_lat_get http 200)
		LC_ATTEMPTS=$(_lc_lat_get attempts 5)
		LC_OK_COUNT=$((LC_ATTEMPTS - (LC_ATTEMPTS * LC_LOSS / 100)))
		LC_SAMPLES=$(_lc_lat_get samples "$LC_TCP_MS")
		LC_AVAIL=$(_lc_lat_get avail)
		[ -n "$LC_AVAIL" ] || _lc_avail_from "$LC_HTTP_CODE" 1 1
		return 0
	fi

	command -v curl >/dev/null 2>&1 || return 0
	# A-03：这里直接取值并校验，不再用 $(_lc_ua)：命令替换里的 die 只会
	# 结束子 shell，父进程会带着空 UA 继续跑。
	_ua=$(_lc_opt LINE_CHECK_UA "$LC_DEFAULT_UA")
	if _conf_lc_ctrl "$_ua"; then
		die "LINE_CHECK_UA 含控制字符（CR/LF/TAB），拒绝用于 curl -A"
	fi
	_conf_lc_visible "$_ua" ||
		die "LINE_CHECK_UA 只能包含可见 ASCII（0x20-0x7e），拒绝用于 curl -A"
	_acc=$(_lc_opt LINE_CHECK_ACCEPT "$LC_DEFAULT_ACCEPT")
	# A-03：读取侧二次校验（_lc_read_validate 已挡一次，这里防直接调函数）。
	if _conf_lc_ctrl "$_acc"; then
		die "LINE_CHECK_ACCEPT 含控制字符（CR/LF/TAB），拒绝用于 curl -H"
	fi
	_conf_lc_visible "$_acc" ||
		die "LINE_CHECK_ACCEPT 只能包含可见 ASCII（0x20-0x7e），拒绝用于 curl -H"
	_best_code=0
	_any_tls=0
	_any_http=0
	_http_code=0

	while [ "$LC_ATTEMPTS" -lt 5 ]; do
		LC_ATTEMPTS=$((LC_ATTEMPTS + 1))
		_out=$(curl -sS --interface "$_mdev" --resolve "$_mdom:443:$_mip" \
			--connect-timeout "$_ct" -m $((_ct * 2)) \
			-A "$_ua" -H "Accept: $_acc" -r 0-0 -o /dev/null \
			-w '%{time_connect} %{time_appconnect} %{time_starttransfer} %{time_total} %{http_code} %{remote_ip}' \
			"https://$_mdom/" 2>/dev/null)
		_rc=$?
		# curl 失败时 -w 仍会打印（多为 0 000），逐字段解析，不依赖退出码
		set -- $_out
		_c=${1:-0}
		_a=${2:-0}
		_s=${3:-0}
		_t=${4:-0}
		_code=${5:-000}
		_rip=${6:-}
		case "$_code" in '' | *[!0-9]*) _code=0 ;; esac
		_http_code=$_code

		if [ "$_rc" -eq 0 ] && [ -n "$_rip" ] && [ "$_rip" = "$_mip" ]; then
			_tcpm=$(_lc_ms "$_c")
			LC_OK_COUNT=$((LC_OK_COUNT + 1))
			[ -z "$LC_TCP_MS" ] && LC_TCP_MS=$_tcpm
			LC_SAMPLES="$LC_SAMPLES $_tcpm"
			LC_TLS_MS=$(_lc_ms "$_a")
			LC_TTFB_MS=$(_lc_ms "$_s")
			LC_TOTAL_MS=$(_lc_ms "$_t")
			_best_code=$_code
			[ "$_a" != "0.000000" ] && [ "$_a" != "0" ] && _any_tls=1
			[ "$_code" -gt 0 ] 2>/dev/null && _any_http=1
		else
			# 早停：跨网基线只试 1 次；同网目标最多 2 次。避免 5×超时拖死整页
			[ "$LC_OK_COUNT" -eq 0 ] && { [ "$_mfast" = "1" ] || [ "$LC_ATTEMPTS" -ge 2 ]; } && break
		fi
		[ "$LC_OK_COUNT" -ge 3 ] && [ "$LC_ATTEMPTS" -ge 5 ] && break
	done

	LC_SAMPLES=$(printf '%s' "$LC_SAMPLES" | sed 's/^ *//')
	if [ "$LC_OK_COUNT" -gt 0 ]; then
		LC_TCP_MS=$(_lc_median "$LC_SAMPLES")
		LC_JITTER=$(_lc_jitter "$LC_SAMPLES")
		LC_LOSS=$(( (LC_ATTEMPTS - LC_OK_COUNT) * 100 / LC_ATTEMPTS ))
		LC_HTTP_CODE=$_best_code
		if [ "$_any_http" = "1" ]; then
			case "$_best_code" in
			2* | 3*) _lc_avail_from "$_best_code" 1 1 ;;
			4*) _lc_avail_from "$_best_code" 1 1 ;;
			5*) _lc_avail_from "$_best_code" 1 1 ;;
			*) _lc_avail_from "$_best_code" "$_any_tls" 1 ;;
			esac
		elif [ "$_any_tls" = "1" ]; then
			LC_AVAIL=tlsfail
		else
			LC_AVAIL=full
		fi
	else
		# F-24b：空 = 未测/失败；"<1" 也是已测到。
		if [ -n "$LC_DNS_MS" ]; then
			LC_AVAIL=tcpfail
		else
			LC_AVAIL=dnsfail
		fi
		LC_HTTP_CODE=0
	fi
}

_lc_avail_from() { # <http_code> <tls_ok> <tcp_ok>
	case "${1:-0}" in
	2* | 3*) LC_AVAIL=full ;;
	4*) LC_AVAIL=waf ;;
	5*) LC_AVAIL=http5xx ;;
	*)
		if [ "${2:-0}" = "1" ]; then LC_AVAIL=tlsfail
		elif [ "${3:-0}" = "1" ]; then LC_AVAIL=tcpfail
		else LC_AVAIL=dnsfail
		fi
		;;
	esac
}

# ---- 归属地（只发出口 IP） ----

_lc_geo_cache_get() { # <ip> <provider>
	[ -f "$LC_GEO" ] || return 1
	# A-13：只接受四列完整的行。旧缓存若被外部文本写进 TAB/换行，行列会错位；
	# 这里让坏行进不了展示链路，下一次写入会用清洗后的文本覆盖。
	awk -F'\t' -v ip="$1" -v p="$2" -v now="$(_lc_now)" -v ttl="$(_lc_geo_ttl)" '
		NF >= 4 && $1 == ip && $2 == p && (now - $4) < ttl { print $3; exit }' "$LC_GEO" 2>/dev/null
}

# A-13：缓存是 TAB 分隔的 4 列文本，外部返回的 TAB/CR/LF 会破坏行列结构。
# 写前统一清洗（不是 JSON 转义：TSV 里要的是“没有分隔符”，不是反斜杠），
# 并截断到 200 字符，避免单条归属地把 state 撑大。
_lc_geo_cache_clean() {
	printf '%s' "${1:-}" | tr '\t\r\n' '   ' | tr -s ' ' | sed 's/[[:cntrl:]]//g' | cut -c1-200
}

_lc_geo_ttl() {
	_t=$(_lc_int "$(_lc_opt LINE_CHECK_GEO_TTL 86400)")
	[ "$_t" -ge 60 ] 2>/dev/null || _t=86400
	printf '%s' "$_t"
}

_lc_geo_cache_put() { # <ip> <provider> <text>
	[ -n "${1:-}" ] || return 0
	# 写在缓存目录之外：缓存目录整体替换时不能把归属地缓存一起带走（旧实现
	# 既丢缓存，又会在探测中途凭空造出一个没有 meta 的半截缓存目录）。
	mkdir -p "$STATE_DIR" 2>/dev/null || return 0
	_lg_ip=$(_lc_geo_cache_clean "$1")
	_lg_prov=$(_lc_geo_cache_clean "$2")
	_lg_text=$(_lc_geo_cache_clean "$3")
	[ -n "$_lg_ip" ] && [ -n "$_lg_prov" ] || return 0
	printf '%s\t%s\t%s\t%s\n' "$_lg_ip" "$_lg_prov" "$_lg_text" "$(_lc_now)" >>"$LC_GEO" 2>/dev/null || true
	chmod 600 "$LC_GEO" 2>/dev/null || true
	# 只保留最近 200 条，防止长期运行把 state 撑大
	if [ -f "$LC_GEO" ]; then
		tail -n 200 "$LC_GEO" >"$LC_GEO.$$" 2>/dev/null &&
			mv -f "$LC_GEO.$$" "$LC_GEO" 2>/dev/null || rm -f "$LC_GEO.$$"
	fi
}

_lc_geo_query() { # <ip> <provider>
	case "$2" in
	baidu)
		# A-13：主源只走 HTTPS；--max-filesize 防止被控/异常端点用超大 body
		# 在 2s 内灌满 shell 变量（curl -m 只限时不限体积）。
		_r=$(curl -sS -m 2 --connect-timeout 2 --max-filesize 131072 --proto '=https' \
			"https://opendata.baidu.com/api.php?query=$1&resource_id=6006&oe=utf8" 2>/dev/null)
		[ -n "$_r" ] || return 1
		_loc=$(printf '%s' "$_r" | jsonfilter -e '@.data[0].location' 2>/dev/null)
		[ -n "$_loc" ] && [ "$_loc" != "null" ] || return 1
		printf '%s' "$_loc"
		;;
	ip-api)
		# 显式选择才走这里：ip-api 免费版只有 HTTP，明文。默认 auto 不再
		# 自动降级到它（见 docs/SECURITY.md）；仍保留给内网/可信链路用户。
		_r=$(curl -sS -m 2 --connect-timeout 2 --max-filesize 131072 \
			"http://ip-api.com/json/$1?lang=zh-CN&fields=status,country,regionName,city,isp" 2>/dev/null)
		[ -n "$_r" ] || return 1
		_st=$(printf '%s' "$_r" | jsonfilter -e '@.status' 2>/dev/null)
		[ "$_st" = "success" ] || return 1
		_c=$(printf '%s' "$_r" | jsonfilter -e '@.country' 2>/dev/null)
		_rg=$(printf '%s' "$_r" | jsonfilter -e '@.regionName' 2>/dev/null)
		_ci=$(printf '%s' "$_r" | jsonfilter -e '@.city' 2>/dev/null)
		_isp=$(printf '%s' "$_r" | jsonfilter -e '@.isp' 2>/dev/null)
		printf '%s %s %s %s' "$_c" "$_rg" "$_ci" "$_isp" | sed 's/  */ /g; s/^ //; s/ $//'
		;;
	*) return 1 ;;
	esac
}

# 设置 LC_GEO_STATUS(ok|off|noip|unknown) LC_GEO_TEXT LC_GEO_PROVIDER LC_GEO_IP
_lc_geo() { # <exit_ip> <wan_ip> <wan_class>
	_geo_en=$(_lc_opt LINE_CHECK_GEO 1)
	_geo_prov=$(_lc_opt LINE_CHECK_GEO_PROVIDER auto)
	case "$_geo_prov" in auto | baidu | ip-api | off) ;; *) _geo_prov=auto ;; esac
	LC_GEO_PROVIDER=$_geo_prov
	LC_GEO_TEXT=""
	LC_GEO_IP=""
	if [ "$_geo_en" = "0" ] || [ "$_geo_prov" = "off" ]; then
		LC_GEO_STATUS=off
		return 0
	fi
	_ip=${1:-}
	[ -n "$_ip" ] || { LC_GEO_STATUS=noip; return 0; }
	LC_GEO_IP=$_ip

	if [ -n "${ISP_SPLIT_LC_GEO_TEXT:-}" ]; then
		LC_GEO_STATUS=ok
		LC_GEO_TEXT=${ISP_SPLIT_LC_GEO_TEXT%%|*}
		_p=${ISP_SPLIT_LC_GEO_TEXT#*|}
		[ "$_p" != "$ISP_SPLIT_LC_GEO_TEXT" ] && LC_GEO_PROVIDER=$_p
		return 0
	fi

	# A-13：auto 只走 HTTPS 的百度。ip-api 是明文 HTTP，降级为“显式选择”
	# 才启用；不再自动兜底，避免用户在不知情时把出口 IP 暴露给明文链路。
	_provs="baidu"
	[ "$_geo_prov" = "auto" ] || _provs=$_geo_prov
	for _p in $_provs; do
		_c=$(_lc_geo_cache_get "$_ip" "$_p")
		if [ -n "$_c" ]; then
			LC_GEO_STATUS=ok
			LC_GEO_TEXT=$_c
			LC_GEO_PROVIDER=$_p
			return 0
		fi
		_t=$(_lc_geo_query "$_ip" "$_p")
		if [ -n "$_t" ]; then
			LC_GEO_STATUS=ok
			LC_GEO_TEXT=$_t
			LC_GEO_PROVIDER=$_p
			_lc_geo_cache_put "$_ip" "$_p" "$_t"
			return 0
		fi
	done
	LC_GEO_STATUS=unknown
}

# ---- 评分（报告 §6 规则表 + 硬上限） ----

_lc_grade() {
	case "$1" in
	'' | *[!0-9]*) printf 'D' ;;
	*)
		if [ "$1" -ge 85 ]; then printf 'A'
		elif [ "$1" -ge 70 ]; then printf 'B'
		elif [ "$1" -ge 50 ]; then printf 'C'
		else printf 'D'; fi
		;;
	esac
}

_lc_lat_points() {
	case "${1:-}" in '' | *[!0-9]* | 0) printf '0'; return ;; esac
	if [ "$1" -le 20 ]; then printf '30'
	elif [ "$1" -le 40 ]; then printf '26'
	elif [ "$1" -le 70 ]; then printf '20'
	elif [ "$1" -le 110 ]; then printf '12'
	elif [ "$1" -le 160 ]; then printf '5'
	else printf '0'; fi
}

_lc_stab_points() { # <jitter> <loss> -> 0-25
	_j=${1:-0}
	_l=${2:-100}
	case "$_j" in '' | *[!0-9]*) _j=999 ;; esac
	if [ "$_j" -le 5 ]; then _jp=15
	elif [ "$_j" -le 15 ]; then _jp=10
	elif [ "$_j" -le 30 ]; then _jp=5
	else _jp=0; fi
	case "$_l" in '' | *[!0-9]*) _l=100 ;; esac
	if [ "$_l" -eq 0 ]; then _lp=10
	elif [ "$_l" -le 20 ]; then _lp=6
	elif [ "$_l" -le 50 ]; then _lp=2
	else _lp=0; fi
	printf '%s' $((_jp + _lp))
}

_lc_score() { # <nat> <tcp> <jitter> <loss> <avail> <same_isp> <wan_up>
	_snat=$1
	_stcp=$2
	_sjit=${3:-0}
	_sloss=${4:-100}
	_savail=${5:-none}
	_ssame=${6:-0}
	_sup=${7:-0}

	case "$_snat" in
	public) _np=30 ;;
	cone) _np=20 ;;
	symmetric) _np=8 ;;
	cgnat) _np=0 ;;
	*) _np=10 ;;
	esac
	_lp=$(_lc_lat_points "$_stcp")
	# 没有同运营商目标、只有跨网基线：延迟项最高 15 分
	[ "$_ssame" = "1" ] || { [ "$_lp" -gt 15 ] && _lp=15; }
	_sp=$(_lc_stab_points "$_sjit" "$_sloss")
	case "$_savail" in
	full) _ap=15 ;;
	waf) _ap=12 ;;
	http5xx | tlsfail) _ap=8 ;;
	*) _ap=0 ;;
	esac

	if [ "$_sup" != "1" ]; then
		# 线路没连上时四项都不该给分，明细也要如实显示 0，不能只把总分抹成 0
		_np=0; _lp=0; _sp=0; _ap=0
	fi
	_tot=$((_np + _lp + _sp + _ap))
	_caps=""
	_capped=0
	if [ "$_sup" != "1" ]; then
		_tot=0
		_capped=1
		_caps="WAN 未连接/拨号失败：总分 0"
	fi
	case "$_snat" in
	cgnat)
		if [ "$_tot" -gt 69 ]; then _tot=69; _capped=1; fi
		_caps="$_caps
CGNAT/多层 NAT：总分上限 69"
		;;
	symmetric)
		if [ "$_tot" -gt 69 ]; then _tot=69; _capped=1; fi
		_caps="$_caps
对称/多出口 NAT：总分上限 69"
		;;
	esac
	case "$_sloss" in
	'' | *[!0-9]*) _sloss=100 ;;
	esac
	if [ "$_sloss" -gt 20 ]; then
		[ "$_tot" -gt 69 ] && _tot=69 && _capped=1
		_caps="$_caps
丢包 > 20%：总分上限 69"
	fi
	if [ "$_ssame" = "1" ] && { [ -z "$_stcp" ] || [ "$_stcp" -gt 160 ] 2>/dev/null; }; then
		[ "$_tot" -gt 69 ] && _tot=69 && _capped=1
		_caps="$_caps
同网目标 >160ms 或全部超时：总分上限 69"
	fi
	_caps=$(printf '%s\n' "$_caps" | sed '/^$/d')

	SC_TOTAL=$_tot
	SC_GRADE=$(_lc_grade "$_tot")
	SC_NAT_PTS=$_np
	SC_LAT_PTS=$_lp
	SC_STAB_PTS=$_sp
	SC_AVAIL_PTS=$_ap
	SC_CAPS=$_caps
	SC_CAPPED=$_capped
}

_lc_advice() { # <label> <nat> <grade> <loss> <tcp> <same> <socat>
	_adv_label=$1
	_adv_nat=$2
	_adv_grade=$3
	_adv_loss=${4:-0}
	_adv_tcp=${5:-}
	_adv_same=${6:-0}
	_adv_socat=${7:-1}

	case "$_adv_nat" in
	cgnat)
		printf '%s 为 CGNAT/多层 NAT，端口映射不可用；如需外网访问请申请公网 IP，或使用 IPv6/内网穿透。' "$_adv_label" ;;
	symmetric)
		printf '%s 是对称/多出口 NAT，P2P 与端口映射成功率低；日常上网不受影响。' "$_adv_label" ;;
	unknown)
		if [ "$_adv_socat" = "0" ]; then
			printf '%s 的 NAT 类型未知：socat 未安装，安装后可补测（不影响上网）。' "$_adv_label"
		else
			printf '%s 的 NAT 类型未知：UDP STUN 探测失败，可能被运营商阻断 3478；不影响上网。' "$_adv_label"
		fi ;;
	public)
		if [ "$_adv_grade" = "A" ]; then
			printf '%s 公网直连，质量良好，可作主用线路。' "$_adv_label"
		else
			printf '%s 公网直连，端口映射可用；当前质量一般，建议结合评分明细排查。' "$_adv_label"
		fi ;;
	cone)
		printf '%s 为锥形 NAT（未细分），端口映射可能可用，需在防火墙放行后实测。' "$_adv_label" ;;
	*)
		printf '%s 线路状态未知，请先确认 WAN 已连接。' "$_adv_label" ;;
	esac

	if [ "$_adv_loss" -gt 20 ] 2>/dev/null; then
		printf ' 丢包偏高（%s%%），建议检查光猫/线路质量。' "$_adv_loss"
	elif [ -n "$_adv_tcp" ] && [ "$_adv_tcp" -gt 160 ] 2>/dev/null; then
		printf ' 同网延迟偏高（%sms），建议检查线路拥塞或更换目标。' "$_adv_tcp"
	elif [ "$_adv_same" != "1" ]; then
		printf ' 未找到可用的同运营商目标，延迟为跨网参考值。'
	fi
}

# ---- 输出 ----

_lc_isp_label() {
	_l=$(_isp_label "${1:-}")
	[ -n "$_l" ] || _l="未知"
	printf '%s' "$_l"
}

_lc_nat_label() {
	case "$1" in
	public) printf '公网（可直接映射）' ;;
	cone) printf '锥形 NAT（未细分）' ;;
	symmetric) printf '对称/多出口 NAT' ;;
	cgnat) printf 'CGNAT / 多层 NAT' ;;
	*) printf '未知（UDP 探测失败）' ;;
	esac
}

_lc_class_label() {
	case "$1" in
	public) printf '公网' ;;
	cgnat) printf 'CGNAT' ;;
	private) printf '私网' ;;
	*) printf '无地址' ;;
	esac
}

_lc_avail_label() {
	case "$1" in
	full) printf 'DNS/TCP/TLS/HTTP 全通' ;;
	waf) printf '可达但被 WAF 拦截（4xx）' ;;
	http5xx) printf 'HTTP 5xx' ;;
	tlsfail) printf 'TLS 握手失败' ;;
	tcpfail) printf 'TCP 连接失败' ;;
	dnsfail) printf 'DNS 解析失败' ;;
	*) printf '未探测' ;;
	esac
}

_lc_stun_probes_json() {
	printf '['
	_first=1
	printf '%s\n' "$LC_STUN_MAPS" | while IFS='|' read -r _srv _sip _st _map _rtt; do
		[ -n "$_srv" ] || continue
		[ "$_first" = "1" ] || printf ','
		_first=0
		_rttn=0
		case "$_rtt" in '' | *[!0-9]*) _rttn=0 ;; *) _rttn=$_rtt ;; esac
		printf '{"server":%s,"ip":%s,"status":%s,"mapped":%s,"rtt_ms":%s}' \
			"$(_lc_json_str "$_srv")" "$(_lc_json_str "$_sip")" \
			"$(_lc_json_str "$_st")" \
			"$([ "$_map" = "-" ] && printf 'null' || _lc_json_str "$_map")" \
			"$_rttn"
	done
	printf ']'
}

_lc_target_json() {
	if [ -z "${LC_T_DOMAIN:-}" ]; then
		printf 'null'
		return
	fi
	printf '{"domain":%s,"ip":%s,"isp":%s,"label":%s,"mode":%s,"validated":%s}' \
		"$(_lc_json_str "$LC_T_DOMAIN")" "$(_lc_json_str "$LC_T_IP")" \
		"$(_lc_json_str "$LC_T_ISP")" "$(_lc_json_str "$(_isp_label "$LC_T_ISP")")" \
		"$(_lc_json_str "$LC_T_MODE")" \
		"$([ "$LC_T_VALID" = "1" ] && printf 'true' || printf 'false')"
}

_lc_wan_json() {
	_upj=false; [ "$LC_UP" = "1" ] && _upj=true
	_exj=false; [ "$LC_EXIT_OK" = "1" ] && _exj=true
	_mj=false; [ "$LC_MULTI" = "1" ] && _mj=true
	_httpj=false; [ "${LC_HTTP_CODE:-0}" -ge 200 ] 2>/dev/null && [ "${LC_HTTP_CODE:-0}" -lt 500 ] 2>/dev/null && _httpj=true

	_tcpj=null; [ -n "${LC_TCP_MS:-}" ] && _tcpj=$LC_TCP_MS
	# F-24b：dns_ms 只允许 null / 整数 / "<1"；绝不再输出 0。
	_dnsj=null
	case "${LC_DNS_MS:-}" in
	'') ;;
	'<1') _dnsj='"<1"' ;;
	*[!0-9]*) ;;
	*) _dnsj=$LC_DNS_MS ;;
	esac
	_dnssj=null; [ -n "${LC_DNS_SCOPE:-}" ] && _dnssj=$(_lc_json_str "$LC_DNS_SCOPE")
	_dnssrvj=null; [ -n "${LC_DNS_SERVER:-}" ] && _dnssrvj=$(_lc_json_str "$LC_DNS_SERVER")
	_tlsj=null; [ -n "${LC_TLS_MS:-}" ] && _tlsj=$LC_TLS_MS
	_ttfbj=null; [ -n "${LC_TTFB_MS:-}" ] && _ttfbj=$LC_TTFB_MS
	_totalj=null; [ -n "${LC_TOTAL_MS:-}" ] && _totalj=$LC_TOTAL_MS
	_exipj=null; [ -n "${LC_EXIT_IP:-}" ] && _exipj=$(_lc_json_str "$LC_EXIT_IP")
	_wanipj=null; [ -n "${LC_WAN_IP:-}" ] && _wanipj=$(_lc_json_str "$LC_WAN_IP")
	_geolocj=null; [ -n "${LC_GEO_TEXT:-}" ] && _geolocj=$(_lc_json_str "$LC_GEO_TEXT")

	printf '{'
	printf '"iface":%s,"device":%s,"up":%s,"state":%s,"wan_ip":%s,"ip_class":%s,' \
		"$(_lc_json_str "$LC_IFACE")" "$(_lc_json_str "$LC_DEV")" "$_upj" \
		"$(_lc_json_str "$LC_STATE")" "$_wanipj" "$(_lc_json_str "$LC_IP_CLASS")"
	printf '"exit_ip":%s,"exit_ok":%s,"exit_probe":%s,"multi_nat":%s,"isp":%s,"isp_label":%s,' \
		"$_exipj" "$_exj" "${LC_EXIT_PROBE:-[]}" "$_mj" "$(_lc_json_str "${LC_ISP:-}")" \
		"$(_lc_json_str "$(_lc_isp_label "${LC_ISP:-}")")"
	printf '"target":%s,' "$(_lc_target_json)"
	printf '"latency":{"dns_ms":%s,"dns_scope":%s,"dns_server":%s,"tcp_ms":%s,"tls_ms":%s,"ttfb_ms":%s,"total_ms":%s,"jitter_ms":%s,"loss_pct":%s,"attempts":%s,"ok_count":%s,"samples":[%s]},' \
		"$_dnsj" "$_dnssj" "$_dnssrvj" "$_tcpj" "$_tlsj" "$_ttfbj" "$_totalj" \
		"$(_lc_int "${LC_JITTER:-0}")" "$(_lc_int "${LC_LOSS:-100}")" \
		"$(_lc_int "${LC_ATTEMPTS:-0}")" "$(_lc_int "${LC_OK_COUNT:-0}")" \
		"$(printf '%s' "${LC_SAMPLES:-}" | tr ' ' ',' | sed 's/^,//; s/,$//')"
	printf '"http":{"code":%s,"ok":%s},' "$(_lc_int "${LC_HTTP_CODE:-0}")" "$_httpj"
	printf '"geo":{"status":%s,"provider":%s,"location":%s,"ip":%s},' \
		"$(_lc_json_str "${LC_GEO_STATUS:-unknown}")" "$(_lc_json_str "${LC_GEO_PROVIDER:-}")" \
		"$_geolocj" "$([ -n "${LC_GEO_IP:-}" ] && _lc_json_str "$LC_GEO_IP" || printf 'null')"

	printf '"nat":{"type":%s,"label":%s,"confidence":%s,"detail":%s,"socat":%s,"local_port":%s,"probes":%s},' \
		"$(_lc_json_str "$LC_STUN_TYPE")" "$(_lc_json_str "$(_lc_nat_label "$LC_STUN_TYPE")")" \
		"$(_lc_json_str "$LC_STUN_CONF")" "$(_lc_json_str "$LC_STUN_DETAIL")" \
		"$([ "${LC_SOCAT:-1}" = "1" ] && printf 'true' || printf 'false')" \
		"$(_lc_int "${LC_STUN_LPORT:-0}")" "$(_lc_stun_probes_json)"

	_lc_score_json
	printf '}'
}

_lc_score_json() {
	_nat_reason=${LC_STUN_DETAIL:-}
	[ -n "$_nat_reason" ] || _nat_reason="WAN IP ${LC_WAN_IP:-} 分类为 $(_lc_class_label "$LC_IP_CLASS")"
	_lat_val="TCP 建连 ${LC_TCP_MS:-—}ms"
	[ -n "${LC_TCP_MS:-}" ] || _lat_val="不可达"
	_lat_reason="同网目标 TCP 建连中位数 ${LC_TCP_MS:-—}ms"
	[ "$LC_T_SAME" = "1" ] || _lat_reason="跨网基线（无同网目标），延迟项上限 15"
	_stab_val="抖动 ${LC_JITTER:-0}ms / 丢包 ${LC_LOSS:-100}%"
	_stab_reason="${LC_ATTEMPTS:-0} 次尝试，${LC_OK_COUNT:-0} 次成功"
	_av_val=$(_lc_avail_label "$LC_AVAIL")

	printf '"score":{"total":%s,"grade":%s,"breakdown":[' \
		"$(_lc_int "$SC_TOTAL")" "$(_lc_json_str "$SC_GRADE")"
	printf '{"key":"nat","label":"出口类型","value":%s,"points":%s,"max":30,"reason":%s},' \
		"$(_lc_json_str "$(_lc_nat_label "$LC_STUN_TYPE")")" "$SC_NAT_PTS" "$(_lc_json_str "$_nat_reason")"
	printf '{"key":"latency","label":"延迟","value":%s,"points":%s,"max":30,"reason":%s},' \
		"$(_lc_json_str "$_lat_val")" "$SC_LAT_PTS" "$(_lc_json_str "$_lat_reason")"
	printf '{"key":"stability","label":"稳定性","value":%s,"points":%s,"max":25,"reason":%s},' \
		"$(_lc_json_str "$_stab_val")" "$SC_STAB_PTS" "$(_lc_json_str "$_stab_reason")"
	printf '{"key":"availability","label":"可用性","value":%s,"points":%s,"max":15,"reason":%s}],' \
		"$(_lc_json_str "$_av_val")" "$SC_AVAIL_PTS" "$(_lc_json_str "HTTP ${LC_HTTP_CODE:-0}")"
	printf '"caps":[%s],"advice":%s}' \
		"$(printf '%s\n' "$SC_CAPS" | awk 'NF{printf "%s%s", (n++ ? "," : ""), "\"" $0 "\""}')" \
		"$(_lc_json_str "$LC_ADVICE")"
}

_lc_wan_text() {
	_lbl=$(_lc_isp_label "${LC_ISP:-}")
	_tgt="—"
	if [ -n "${LC_T_DOMAIN:-}" ]; then
		_tgt="${LC_T_DOMAIN} → ${LC_T_IP}（$(_isp_label "$LC_T_ISP")，$([ "$LC_T_MODE" = "same_isp" ] && printf '同网' || printf '跨网基线')）"
	fi
	_exit="—"
	[ -n "${LC_EXIT_IP:-}" ] && _exit="${LC_EXIT_IP}（多层 NAT：$([ "$LC_MULTI" = "1" ] && printf '是' || printf '否')）"
	_geo="未查询"
	case "${LC_GEO_STATUS:-}" in
	ok) _geo="${LC_GEO_TEXT}（${LC_GEO_PROVIDER}）" ;;
	off) _geo="未查询（隐私开关关闭）" ;;
	noip) _geo="无公网出口 IP，跳过" ;;
	*) _geo="查询失败" ;;
	esac
	printf '== %s (%s)  %s\n' "$LC_IFACE" "$LC_DEV" "$([ "$LC_UP" = "1" ] && printf '在线' || printf '离线')"
	printf '   WAN IP     %s（%s）\n' "${LC_WAN_IP:-—}" "$(_lc_class_label "$LC_IP_CLASS")"
	printf '   出口 IP    %s\n' "$_exit"
	printf '   运营商     %s\n' "$_lbl"
	printf '   目标       %s\n' "$_tgt"
	printf '   四段延迟   DNS %s · TCP %s · TLS %s · 首字节 %s · 合计 %s\n' \
		"$(_lc_fmt_ms "${LC_DNS_MS:-}")" "$(_lc_fmt_ms "${LC_TCP_MS:-}")" \
		"$(_lc_fmt_ms "${LC_TLS_MS:-}")" "$(_lc_fmt_ms "${LC_TTFB_MS:-}")" \
		"$(_lc_fmt_ms "${LC_TOTAL_MS:-}")"
	printf '   稳定性     抖动 %sms · 丢包 %s%%（%s 次尝试 / %s 次成功）\n' \
		"${LC_JITTER:-0}" "${LC_LOSS:-100}" "${LC_ATTEMPTS:-0}" "${LC_OK_COUNT:-0}"
	printf '   NAT        %s（置信度 %s）\n' "$(_lc_nat_label "$LC_STUN_TYPE")" "$LC_STUN_CONF"
	[ -n "$LC_STUN_DETAIL" ] && printf '              %s\n' "$LC_STUN_DETAIL"
	printf '   归属地     %s\n' "$_geo"
	printf '   评分       %s / 100  %s\n' "$SC_TOTAL" "$SC_GRADE"
	printf '              出口类型    %-18s %2s/30  %s\n' "$(_lc_nat_label "$LC_STUN_TYPE")" "$SC_NAT_PTS" "$LC_STUN_DETAIL"
	printf '              延迟        %-18s %2s/30  %s\n' "$(_lc_fmt_ms "${LC_TCP_MS:-}")" "$SC_LAT_PTS" "$([ "$LC_T_SAME" = "1" ] && printf '同网目标' || printf '跨网基线，上限 15')"
	printf '              稳定性      %-18s %2s/25  %s 次尝试 / %s 次成功\n' "抖动 ${LC_JITTER:-0}ms 丢包 ${LC_LOSS:-100}%" "$SC_STAB_PTS" "${LC_ATTEMPTS:-0}" "${LC_OK_COUNT:-0}"
	printf '              可用性      %-18s %2s/15  HTTP %s\n' "$(_lc_avail_label "$LC_AVAIL")" "$SC_AVAIL_PTS" "${LC_HTTP_CODE:-0}"
	if [ -n "$SC_CAPS" ]; then
		printf '%s\n' "$SC_CAPS" | while IFS= read -r _cap; do
			[ -n "$_cap" ] && printf '              上限        %s\n' "$_cap"
		done
	fi
	printf '   建议       %s\n' "$LC_ADVICE"
}

# ---- 目标挑选 ----

_lc_target_valid() { # <源名> <ip>
	if [ "${ISP_SPLIT_LC_FORCE_SAME:-0}" = "1" ]; then
		return 0
	fi
	_tset=$(_lc_set_for "$1")
	[ -n "$_tset" ] || return 1
	command -v nft >/dev/null 2>&1 || return 1
	nft get element inet mwan3 "$_tset" "{ $2 }" >/dev/null 2>&1
}

_lc_deadline_hit() {
	[ $(( $(_lc_now) - LC_START )) -ge "$LC_BUDGET" ]
}

# 依次尝试：同运营商目标（解析 + @isp_* 校验 + 5 次测量）→ 跨网基线。
# 返回 0=同网目标 1=跨网基线 2=全部失败
_lc_pick_target() { # <device> <isp>
	_ptdev=$1
	_ptisp=${2:-}
	LC_T_DOMAIN=""
	LC_T_IP=""
	LC_T_ISP=""
	LC_T_MODE=none
	LC_T_VALID=0
	LC_T_SAME=0

	while IFS='	' read -r _src _dom; do
		[ -n "${_dom:-}" ] || continue
		_lc_deadline_hit && break
		for _ip in $(_lc_resolve "$_dom" 2); do
			_lc_target_valid "$_src" "$_ip" || continue
			_lc_measure "$_ptdev" "$_dom" "$_ip" "$(_lc_timeout)"
			if [ "${LC_OK_COUNT:-0}" -gt 0 ]; then
				LC_T_DOMAIN=$_dom
				LC_T_IP=$_ip
				LC_T_ISP=$_src
				LC_T_MODE=same_isp
				LC_T_VALID=1
				LC_T_SAME=1
				return 0
			fi
		done
	done <<EOF
$(_lc_target_groups "$_ptisp")
EOF

	# 兜底：跨网基线只用于"链路是否通"，不计入同网延迟分
	for _dom in $(printf '%s' "$(_lc_opt LINE_CHECK_BASELINE 'www.baidu.com,www.qq.com')" | tr ',' ' '); do
		_lc_deadline_hit && break
		for _ip in $(_lc_resolve "$_dom" 1); do
			_lc_measure "$_ptdev" "$_dom" "$_ip" "$(_lc_timeout)" 1
			if [ "${LC_OK_COUNT:-0}" -gt 0 ]; then
				LC_T_DOMAIN=$_dom
				LC_T_IP=$_ip
				LC_T_ISP=""
				LC_T_MODE=baseline
				LC_T_VALID=0
				LC_T_SAME=0
				return 1
			fi
		done
	done
	return 2
}

# ---- 单条 WAN ----

_lc_run_wan() { # <iface> <dev> <up> <state> <wan_ip>
	LC_IFACE=$1
	LC_DEV=$2
	LC_UP=$3
	LC_STATE=$4
	LC_WAN_IP=$5
	LC_IP_CLASS=$(_lc_ip_class "$LC_WAN_IP")
	LC_EXIT_IP=""
	LC_EXIT_PROBE="[]"
	LC_EXIT_OK=0
	LC_MULTI=0
	LC_ISP=""
	LC_T_DOMAIN=""
	LC_T_IP=""
	LC_T_ISP=""
	LC_T_MODE=none
	LC_T_VALID=0
	LC_T_SAME=0
	LC_DNS_MS=""
	LC_DNS_SCOPE="local"
	LC_DNS_SERVER=""
	LC_TCP_MS=""
	LC_TLS_MS=""
	LC_TTFB_MS=""
	LC_TOTAL_MS=""
	LC_SAMPLES=""
	LC_JITTER=0
	LC_LOSS=100
	LC_ATTEMPTS=0
	LC_OK_COUNT=0
	LC_HTTP_CODE=0
	LC_AVAIL=none
	LC_GEO_STATUS=unknown
	LC_GEO_TEXT=""
	LC_GEO_PROVIDER=""
	LC_GEO_IP=""
	LC_STUN_OK=0
	LC_STUN_TYPE=unknown
	LC_STUN_CONF=unknown
	LC_STUN_DETAIL=""
	LC_STUN_MAPS=""
	LC_STUN_LPORT=0

	# 出口 IP：WAN down 时也试一次（可能只是 mwan3 track 失败，链路还在）
	LC_EXIT_IP=""
	LC_EXIT_PROBE="[]"
	_lc_exit_ip "$LC_DEV"
	if _lc_ip_valid "$LC_EXIT_IP"; then
		LC_EXIT_OK=1
	else
		LC_EXIT_IP=""
	fi

	# 只有"WAN IP 本身是公网、出口却是另一个公网 IP"才叫多层 NAT/代理；
	# WAN 是私网/CGNAT 时，出口不同属于正常 NAT，不重复叫多层。
	if [ "$LC_IP_CLASS" = "public" ] && [ "$LC_EXIT_OK" = "1" ] && [ "$LC_EXIT_IP" != "$LC_WAN_IP" ]; then
		LC_MULTI=1
	fi

	# 线路运营商：先看 WAN IP（公网才有意义），再看出口 IP
	if [ "$LC_IP_CLASS" = "public" ]; then
		LC_ISP=$(_lc_ip_isp "$LC_WAN_IP")
	fi
	if [ -z "$LC_ISP" ] && [ "$LC_EXIT_OK" = "1" ]; then
		LC_ISP=$(_lc_ip_isp "$LC_EXIT_IP")
	fi
	# 自检夹具：强制线路运营商（真实环境不设置，始终以 @isp_* 集合为准）
	[ -n "${ISP_SPLIT_LC_ISP:-}" ] && LC_ISP=$ISP_SPLIT_LC_ISP

	if [ "$LC_UP" != "1" ]; then
		LC_STUN_DETAIL="WAN 未连接，跳过探测"
		LC_GEO_STATUS=noip
		LC_ADVICE="线路未连接，先检查拨号/物理链路。"
		LC_STUN_LPORT=0
		_lc_score "$LC_STUN_TYPE" "" 0 100 none 0 0
		return 0
	fi

	# 1) 同网目标 + 四段延迟
	_lc_pick_target "$LC_DEV" "$LC_ISP"
	_pick=$?
	[ "$_pick" = "2" ] && LC_T_MODE=none

	if _lc_deadline_hit; then
		LC_GEO_STATUS=noip
		LC_GEO_TEXT=""
		LC_STUN_DETAIL="超出单次体检预算（${LC_BUDGET}s），已跳过 STUN/归属地"
		LC_STUN_TYPE=unknown
		LC_STUN_CONF=unknown
		_lc_score "$LC_STUN_TYPE" "${LC_TCP_MS:-}" "${LC_JITTER:-0}" "${LC_LOSS:-100}" "$LC_AVAIL" "$LC_T_SAME" "$LC_UP"
		_lc_make_advice
		return 0
	fi

	# 2) 归属地（只发出口 IP；出口取不到但 WAN IP 是公网时用 WAN IP）
	_geo_ip=${LC_EXIT_IP:-}
	if [ -z "$_geo_ip" ] && [ "$LC_IP_CLASS" = "public" ]; then
		_geo_ip=$LC_WAN_IP
	fi
	_lc_geo "$_geo_ip" "$LC_WAN_IP" "$LC_IP_CLASS"

	# 3) STUN 双探测
	LC_STUN_LPORT=$(_lc_local_port)
	_lc_stun_probe "$LC_DEV" "$LC_WAN_IP" "$LC_IP_CLASS"

	# 4) 评分
	_lc_score "$LC_STUN_TYPE" "${LC_TCP_MS:-}" "${LC_JITTER:-0}" "${LC_LOSS:-100}" "$LC_AVAIL" "$LC_T_SAME" "$LC_UP"
	_lc_make_advice
}

_lc_make_advice() {
	_alab=$(_isp_label "${LC_ISP:-}")
	[ -n "$_alab" ] || _alab="WAN ${LC_IFACE}"
	LC_ADVICE=$(_lc_advice "$_alab" "$LC_STUN_TYPE" "$SC_GRADE" "${LC_LOSS:-0}" "${LC_TCP_MS:-}" "$LC_T_SAME" "${LC_SOCAT:-1}")
}

# ---- 缓存 / 锁 / 原子落盘 ----
#
# BUG-24 复盘：旧实现把工作目录叫 <cache>.tmp.<pid>，而探测过程中
# _lc_geo_cache_put 还会顺手 mkdir 出“只有 geo.tsv、没有 meta”的 <cache>
# 目录；一旦本次探测被 rpcd 的 30s exec 超时（或 Ctrl-C / OOM）杀掉：
#   · <cache> 里没有 meta  → 之后每次都被判“缓存未命中”，60s 间隔形同虚设；
#   · <cache>.tmp.<pid>    → 没人清，state 目录里越堆越多（实测
#                            linecheck.tmp.10058 就是这么来的）。
# 现在：meta + last.json 是缓存有效性的充要条件；探测目录由 trap 清理；每次
# 启动再扫一遍 pid 已死的残留；geo 缓存挪到 state/linecheck.geo.tsv，不再写进
# 会被整体替换的缓存目录（旧实现每次探测都会把 geo 缓存一起丢掉）。

LC_CACHE=$STATE_DIR/linecheck
LC_CACHE_PREV=$STATE_DIR/linecheck.prev
LC_LOCK=$STATE_DIR/linecheck.lock
LC_GEO=$STATE_DIR/linecheck.geo.tsv
LC_LOG=$STATE_DIR/linecheck.log
# 探测引擎入口（独立 argv，见文件头注释）。缺失时退回进程内探测。
LC_RUN=${LC_RUN:-$LIB/lc-run}
LC_LOCK_OWNER=0
LC_CHILD_PID=""
_lc_tmp=""
# STUN 临时文件的真实路径（mktemp 结果）。trap 清理必须用它，不能按 $$ 重拼。
LC_STUN_REQ=""
LC_STUN_RESP=""

_lc_cleanup() {
	# 监督者被信号带走时，别把引擎进程留成孤儿继续探测。
	if [ -n "${LC_CHILD_PID:-}" ]; then
		kill "$LC_CHILD_PID" 2>/dev/null || true
		LC_CHILD_PID=""
	fi
	if [ -n "${_lc_tmp:-}" ] && [ -d "$_lc_tmp" ]; then
		rm -rf "$_lc_tmp" 2>/dev/null
	fi
	if [ -n "${LC_STUN_REQ:-}" ]; then
		rm -f "$LC_STUN_REQ" 2>/dev/null
		LC_STUN_REQ=""
	fi
	if [ -n "${LC_STUN_RESP:-}" ]; then
		rm -f "$LC_STUN_RESP" 2>/dev/null
		LC_STUN_RESP=""
	fi
	if [ "${LC_LOCK_OWNER:-0}" = "1" ] && [ -d "$LC_LOCK" ]; then
		[ "$(cat "$LC_LOCK/pid" 2>/dev/null)" = "$$" ] && rm -rf "$LC_LOCK" 2>/dev/null
	fi
	return 0
}

# 信号/退出统一收尾：ash 里 INT/TERM 的 trap 只执行处理函数就继续往下跑，
# 所以信号分支必须显式 exit，否则探测会“被 Ctrl-C 了但还在跑”。
_lc_trap_on() {
	trap '_lc_cleanup' EXIT
	trap '_lc_cleanup; exit 130' INT TERM HUP
	return 0
}

# 崩溃残留清理。分两档：
#   _lc_sweep_tmp   只删 pid 已死的探测目录——任何一次调用（包括命中缓存、
#                   --background 直接返回）都可以安全执行，不会碰到正在跑的探测；
#   _lc_sweep_cache 半截缓存/发布中间态——只在持锁时做，避免和发布者抢。
_lc_sweep_tmp() {
	for _d in "$STATE_DIR"/linecheck.tmp.*; do
		[ -d "$_d" ] || continue
		_p=${_d##*.}
		case "$_p" in '' | *[!0-9]*) continue ;; esac
		kill -0 "$_p" 2>/dev/null || rm -rf "$_d" 2>/dev/null
	done
	return 0
}

_lc_sweep_cache() {
	if [ -d "$LC_CACHE" ] && { [ ! -f "$LC_CACHE/meta" ] || [ ! -f "$LC_CACHE/last.json" ]; }; then
		rm -rf "$LC_CACHE" 2>/dev/null
	fi
	rm -rf "$LC_CACHE_PREV" 2>/dev/null
	return 0
}

_lc_sweep() {
	_lc_sweep_tmp
	_lc_sweep_cache
	return 0
}

# 锁年龄。started 是锁主写入的 epoch 秒；pid/started 写入有窗口期，
# 缺失时退回目录 mtime，绝不能把“还没写”当成 epoch 0（旧实现会算出
# 56 年，于是把刚创建的锁误判成陈旧锁并抢占，SEC S7）。
_lc_lock_age() {
	_t=$(cat "$LC_LOCK/started" 2>/dev/null)
	case "$_t" in '' | *[!0-9]*) _t="" ;; esac
	if [ -z "$_t" ]; then
		_t=$(stat -c %Y "$LC_LOCK" 2>/dev/null)
		case "$_t" in '' | *[!0-9]*) _t="" ;; esac
	fi
	if [ -z "$_t" ]; then
		printf '0'
		return 0
	fi
	_a=$(( $(_lc_now) - _t ))
	[ "$_a" -ge 0 ] 2>/dev/null || _a=0
	printf '%s' "$_a"
}

# 0=拿到锁 1=已有体检在跑（LC_LOCK_PID/LC_LOCK_AGE） 2=建不了锁
#
# 竞态修复（SEC S7）：mkdir 成功是唯一原子“持锁”步骤。之后写 started/pid
# 失败只降级——锁仍然归我们，后来者不得以“内容不全”为由强抢；内容不全
# 时按目录 mtime 计算年龄，5 秒宽限期覆盖 mkdir -> 写文件 -> exec 的窗口。
_lc_lock_try() {
	LC_LOCK_PID=""
	LC_LOCK_AGE=""
	mkdir -p "$STATE_DIR" 2>/dev/null || return 2
	if mkdir "$LC_LOCK" 2>/dev/null; then
		# 先写 started 再写 pid：任一步失败都不影响“已持锁”的事实。
		_lc_now >"$LC_LOCK/started" 2>/dev/null || true
		printf '%s\n' "$$" >"$LC_LOCK/pid" 2>/dev/null || true
		LC_LOCK_OWNER=1
		return 0
	fi
	_p=$(cat "$LC_LOCK/pid" 2>/dev/null)
	case "$_p" in '' | *[!0-9]*) _p="" ;; esac
	_s=$(cat "$LC_LOCK/started" 2>/dev/null)
	case "$_s" in '' | *[!0-9]*) _s="" ;; esac
	_age=$(_lc_lock_age)
	case "$_age" in '' | *[!0-9]*) _age=0 ;; esac
	# 0) 元数据不全（pid/started 都没写全）= 锁刚 mkdir、持锁者还没写完。
	#    这种状态只能判 running：宽限期内绝不做陈旧回收，否则 16 连点
	#    会在“目录已建、文件未落”的窗口里各自抢到一次锁。
	if [ -z "$_p" ] && [ -z "$_s" ]; then
		LC_LOCK_AGE=$_age
		return 1
	fi
	# 1) 进程还活着且锁不超过 5 分钟：认定在跑（防 pid 复用导致的永久锁死）
	if [ -n "$_p" ] && [ "$_age" -lt 300 ] && kill -0 "$_p" 2>/dev/null; then
		LC_LOCK_PID=$_p
		LC_LOCK_AGE=$_age
		return 1
	fi
	# 2) 刚创建/刚交接的锁：pid 可能还没写，或父进程刚退出等 worker 接管。
	#    宽限期内一律按“在跑”处理，绝不走 rm -rf 抢占。
	if [ "$_age" -lt 5 ]; then
		[ -n "$_p" ] && LC_LOCK_PID=$_p
		LC_LOCK_AGE=$_age
		return 1
	fi
	# 3) 真正陈旧：用 rename 原子地把旧锁搬走（同一父目录下只有一个竞争者
	#    能成功），再 mkdir 新锁。这样不会出现 A 刚 rm、B 又 rm 掉 A 新锁。
	_stale=$LC_LOCK.stale.$$
	rm -rf "$_stale" 2>/dev/null
	if mv "$LC_LOCK" "$_stale" 2>/dev/null; then
		rm -rf "$_stale" 2>/dev/null
		if mkdir "$LC_LOCK" 2>/dev/null; then
			_lc_now >"$LC_LOCK/started" 2>/dev/null || true
			printf '%s\n' "$$" >"$LC_LOCK/pid" 2>/dev/null || true
			LC_LOCK_OWNER=1
			return 0
		fi
	fi
	# 抢锁失败（别人先搬走/先建好）：按“已有体检在跑”处理，不报错。
	LC_LOCK_AGE=$_age
	return 1
}

# 缓存有效性：meta + last.json + 每条被请求的 WAN 的 json/txt 都在，且 age < interval。
# 时间戳全是 `date +%s` 的 UTC epoch 秒（与本地时区无关）；age<0（时钟回拨）按失效处理。
_lc_cache_load() { # <请求的 WAN 列表> -> _C_GEN/_C_INT/_C_GEO/_C_PROV/_C_SOC/_C_WANS
	[ -f "$LC_CACHE/meta" ] || return 1
	[ -f "$LC_CACHE/last.json" ] || return 1
	read -r _C_GEN _C_INT _C_GEO _C_PROV _C_SOC _C_WANS <"$LC_CACHE/meta" 2>/dev/null || return 1
	case "${_C_GEN:-}" in '' | *[!0-9]*) return 1 ;; esac
	case "${_C_INT:-}" in '' | *[!0-9]*) return 1 ;; esac
	[ "$_C_INT" -ge 15 ] 2>/dev/null || return 1
	return 0
}

_lc_cache_fresh() { # <请求的 WAN 列表> -> _C_AGE；0=新鲜
	[ -n "${1:-}" ] || return 1
	_lc_cache_load "$1" || return 1
	_C_AGE=$(( $(_lc_now) - _C_GEN ))
	[ "$_C_AGE" -ge 0 ] 2>/dev/null || return 1
	[ "$_C_AGE" -lt "$_C_INT" ] 2>/dev/null || return 1
	for _w in $1; do
		case " $_C_WANS " in *" $_w "*) ;; *) return 1 ;; esac
		[ -f "$LC_CACHE/wan-$_w.json" ] || return 1
		[ -f "$LC_CACHE/wan-$_w.txt" ] || return 1
	done
	return 0
}

# 统一 JSON 输出：<basedir> 里放 wan-*.json / errors.json。
# 新鲜结果、缓存回读、last.json 快照都走这一个函数，避免两套口径。
_lc_json_emit() { # <basedir> <cached:0/1> <age>
	printf '{"version":"%s","command":"linecheck","generated":%s,"cached":%s,"age":%s,"interval":%s,"geo_enabled":%s,"geo_provider":%s,"socat":%s,"wans":[' \
		"$ISP_VERSION" "$_C_GEN" "$([ "$2" = "1" ] && printf true || printf false)" "$3" "$_C_INT" \
		"$([ "$_C_GEO" = "1" ] && printf true || printf false)" "$(_lc_json_str "${_C_PROV:-auto}")" \
		"$([ "$_C_SOC" = "1" ] && printf true || printf false)"
	_first=1
	for _w in ${_C_OUT:-}; do
		[ "$_first" = "1" ] || printf ','
		_first=0
		cat "$1/wan-$_w.json" 2>/dev/null
	done
	printf '],"errors":'
	cat "$1/errors.json" 2>/dev/null || printf '[]'
	printf '}\n'
}

_lc_text_emit() { # <basedir> [cached-age]
	if [ -n "${2:-}" ]; then
		printf '线路体检 · v%s  %s（缓存，%s 秒前）\n' "$ISP_VERSION" "$(_lc_dt "$_C_GEN")" "$2"
	else
		printf '线路体检 · v%s  %s\n' "$ISP_VERSION" "$(_lc_dt "$_C_GEN")"
	fi
	_lc_privacy_line
	for _w in ${_C_OUT:-}; do
		printf '\n'
		cat "$1/wan-$_w.txt" 2>/dev/null
	done
	printf '\n提示：%s 秒内重复执行直接返回缓存；--force 强制重测。\n' "$_C_INT"
}

_lc_cache_serve() { # <json:0/1> <请求的 WAN 列表>
	_lc_cache_fresh "${2:-}" || return 1
	_C_OUT=$2
	if [ "$1" = "1" ]; then
		_lc_json_emit "$LC_CACHE" 1 "$_C_AGE"
	else
		_lc_text_emit "$LC_CACHE" "$_C_AGE"
	fi
	return 0
}

# 原子发布：整个目录对调。两个进程同时发布也不会把新目录塞进旧目录里
# （旧写法 `mv tmp cache` 在 cache 已存在时会把 tmp 变成 cache 的子目录）。
_lc_publish() { # <tmpdir>
	rm -rf "$LC_CACHE_PREV" 2>/dev/null
	[ -d "$LC_CACHE" ] && mv "$LC_CACHE" "$LC_CACHE_PREV" 2>/dev/null
	if mv "$1" "$LC_CACHE" 2>/dev/null; then
		rm -rf "$LC_CACHE_PREV" 2>/dev/null
		# A-14：缓存目录 700、其中每个文件 600。mv 保留临时目录的权限，
		# 但升级前留下的旧 cache 可能还是 755/644，这里统一纠正。
		chmod 700 "$LC_CACHE" 2>/dev/null || true
		for _pf in "$LC_CACHE"/*; do
			[ -f "$_pf" ] || continue
			chmod 600 "$_pf" 2>/dev/null || true
		done
		return 0
	fi
	[ -d "$LC_CACHE_PREV" ] && [ ! -d "$LC_CACHE" ] && mv "$LC_CACHE_PREV" "$LC_CACHE" 2>/dev/null
	return 1
}

_lc_dt() {
	date -d "@${1:-0}" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || date '+%Y-%m-%d %H:%M:%S'
}

_lc_privacy_line() {
	if [ "$(_lc_opt LINE_CHECK_GEO 1)" = "1" ]; then
		_lc_gp=$(_lc_opt LINE_CHECK_GEO_PROVIDER auto)
		[ "$_lc_gp" = "auto" ] && _lc_gp="仅百度 HTTPS（ip-api 需显式选择）"
		[ "$_lc_gp" = "off" ] && _lc_gp="关闭"
		printf '隐私：归属地查询已开启，仅把每条线的出口公网 IP 发给 %s；不发送内网地址/设备名/MAC/流量内容。\n' "$_lc_gp"
	else
		printf '隐私：归属地查询已关闭，本次不发起任何第三方归属地请求。\n'
	fi
}

# ---- 后台体检（BUG-23） ----
#
# 页面 load 只读 state/linecheck/last.json，探测由 `linecheck --background` 起一个
# 脱离 rpcd 会话的子进程去跑；子进程完成后原子替换缓存目录（含 last.json），
# 页面每 2-3s 读一次 last.json 比较 generated。
#
# 返回（永远是单行 JSON，页面据此决定轮询/直接渲染/报错）：
#   {"started":true,"pid":N}                       已在后台开始
#   {"started":true,"finished":true,...}           1 秒内就跑完了（夹具/极快线路）
#   {"started":false,"running":true,"pid":N,...}   已有一次在跑，不重复起
#   {"started":false,"cached":true,...}            60s 缓存仍新鲜，没必要重测
#   {"started":false,"error":"..."}                起不来
_lc_background() { # <force> <only>
	_b_force=$1
	_b_only=$2
	if [ -n "$_b_only" ]; then
		_b_wans=$_b_only
	else
		_b_wans=$(printf '%s\n' "$(_lc_wans)" | awk -F'\t' 'NF>=2{print $1}' | tr '\n' ' ' | sed 's/ $//')
	fi
	if [ "$_b_force" = "0" ] && _lc_cache_fresh "$_b_wans"; then
		printf '{"command":"linecheck","background":true,"started":false,"cached":true,"generated":%s,"age":%s,"interval":%s}\n' \
			"$_C_GEN" "$_C_AGE" "$_C_INT"
		return 0
	fi
	_lc_lock_try
	case $? in
	1)
		printf '{"command":"linecheck","background":true,"started":false,"running":true,"pid":%s,"elapsed":%s}\n' \
			"${LC_LOCK_PID:-0}" "${LC_LOCK_AGE:-0}"
		return 0
		;;
	2)
		printf '{"command":"linecheck","background":true,"started":false,"error":"无法创建锁目录 %s"}\n' "$LC_LOCK"
		return 1
		;;
	esac
	_lc_sweep

	_lc_self=${0:-/usr/sbin/isp-split}
	case "$_lc_self" in
	*/*) ;;
	*) _lc_self=$(command -v "$_lc_self" 2>/dev/null) || _lc_self=/usr/sbin/isp-split ;;
	esac
	set -- linecheck --json --_worker
	[ "$_b_force" = "1" ] && set -- "$@" --force
	[ -n "$_b_only" ] && set -- "$@" --wan "$_b_only"

	: >"$LC_LOG" 2>/dev/null || true
	chmod 600 "$LC_LOG" 2>/dev/null || true
	# setsid：脱离 rpcd 的会话/进程组，exec 返回后不会被一起收走。
	# 三个 fd 全部重定向：父进程退出时 rpcd 的管道不能被子进程挂着，否则
	# exec 仍然要等到探测结束（那正是 BUG-23 的另一半）。
	# 锁的 token：worker 用它确认“我接管的还是父进程刚建的那把锁”。
	if command -v setsid >/dev/null 2>&1; then
		( ISP_SPLIT_LC_LOCK_TOKEN=$$ setsid sh "$_lc_self" "$@" </dev/null >>"$LC_LOG" 2>&1 & )
	else
		( ISP_SPLIT_LC_LOCK_TOKEN=$$ sh "$_lc_self" "$@" </dev/null >>"$LC_LOG" 2>&1 & )
	fi

	# 等 worker 接管锁（busybox sleep 不支持小数）。最多等 2 秒；mkdir 成功
	# 就已持锁，pid 还没写成 worker 时只降级返回，绝不删锁/强抢——那正是
	# 并发 12 次时误判“陈旧锁”、起多个 worker 的窗口（SEC S7）。
	_i=0
	_p=""
	while [ "$_i" -lt 2 ]; do
		sleep 1
		_i=$((_i + 1))
		[ -d "$LC_LOCK" ] || break
		_p=$(cat "$LC_LOCK/pid" 2>/dev/null)
		[ -n "$_p" ] && [ "$_p" != "$$" ] && break
	done
	if [ ! -d "$LC_LOCK" ]; then
		# 2 秒内已经跑完（夹具/极快线路）：直接告诉页面读缓存
		printf '{"command":"linecheck","background":true,"started":true,"finished":true,"pid":0}\n'
		return 0
	fi
	if [ -z "$_p" ] || [ "$_p" = "$$" ]; then
		# 降级：不删锁、不强抢。worker 稍后写入 pid 即自愈；若 worker
		# 确实没起来，锁的 pid 会随父进程退出变成死 pid，下一个调用在
		# 5 秒宽限后按陈旧锁回收。
		printf '{"command":"linecheck","background":true,"started":true,"pid":0,"pending":true}\n'
		return 0
	fi
	printf '{"command":"linecheck","background":true,"started":true,"pid":%s,"started_at":%s}\n' \
		"$_p" "$(cat "$LC_LOCK/started" 2>/dev/null || _lc_now)"
	return 0
}

# ---- 主命令 ----

cmd_linecheck() {
	_lc_json=0
	_lc_force=0
	_lc_only=""
	_lc_bg=0
	_lc_worker=0
	_lc_runner=0
	while [ $# -gt 0 ]; do
		case "$1" in
		--json) _lc_json=1 ;;
		--force) _lc_force=1 ;;
		--background) _lc_bg=1 ;;
		--_worker) _lc_worker=1 ;;
		--_runner) _lc_runner=1 ;;
		--wan)
			shift
			[ $# -gt 0 ] || die "用法: isp-split linecheck [--json] [--wan <接口>] [--force] [--background]"
			_lc_only=$1
			;;
		-h | --help)
			printf '用法: isp-split linecheck [--json] [--wan <接口>] [--force] [--background]\n'
			return 0
			;;
		*)
			die "未知参数 '$1'（用法: isp-split linecheck [--json] [--wan <接口>] [--force] [--background]）"
			;;
		esac
		shift
	done

	[ "$(_lc_opt LINE_CHECK_ENABLE 1)" = "1" ] || die "线路体检已关闭（LINE_CHECK_ENABLE=0）"
	[ "$_lc_worker" = "1" ] && _lc_json=1
	# A-14：体检缓存含公网 IP/归属地/STUN 映射。整个 linecheck 进程内
	# 统一 umask 077，新建目录 700、文件 600；已存在的旧目录/文件在下面
	# 再显式 chmod 一次（umask 只影响新建）。
	umask 077
	# A-03/A-04：读取侧校验必须在任何 curl 之前硬失败（含后台/worker）。
	_lc_read_validate

	LC_START=$(_lc_now)
	LC_BUDGET=$(_lc_budget)
	LC_INTERVAL=$(_lc_int "$(_lc_opt LINE_CHECK_MIN_INTERVAL 60)")
	[ "$LC_INTERVAL" -ge 15 ] 2>/dev/null || LC_INTERVAL=60
	[ "$LC_INTERVAL" -le 3600 ] 2>/dev/null || LC_INTERVAL=3600
	LC_SOCAT=0
	command -v socat >/dev/null 2>&1 && LC_SOCAT=1
	[ "${ISP_SPLIT_LC_NO_SOCAT:-0}" = "1" ] && LC_SOCAT=0

	mkdir -p "$STATE_DIR" 2>/dev/null || true
	chmod 700 "$STATE_DIR" 2>/dev/null || true
	# 每次调用都先扫掉 pid 已死的探测目录（SIGKILL 走不到 trap）。命中缓存时
	# 也扫，否则一个被 rpcd 超时杀掉的前台探测要等到缓存过期才有人收尸。
	_lc_sweep_tmp

	# 后台模式：只判断“要不要起”，绝不在这里探测。
	if [ "$_lc_bg" = "1" ]; then
		_lc_background "$_lc_force" "$_lc_only"
		return $?
	fi

	if [ "$_lc_worker" = "1" ] && [ "$_lc_runner" != "1" ]; then
		# ---- 监督者（--_worker）----
		# 父进程已 mkdir 持锁。这里做三件事：
		#   1) 校验锁还是调用链那一把（token = 父进程 pid），被回收就放弃探测；
		#   2) 把 pid 改成自己——锁的属主从此是本进程，退出时由 trap 删除；
		#   3) 探测交给独立 argv 的引擎进程并 wait。
		# 为什么要分两层：ash 的 $(...) / 管道子 shell 会继承父进程的 argv，
		# 16 连点时 ps 会把 1 个 worker 的 2-3 个命令替换子 shell 误数成
		# “多个并发 worker”（fix29 实测 4 个）。引擎用另一个入口运行后，
		# 进程表里带 "isp-split linecheck" 的只剩唯一这个持锁监督者。
		_tok=${ISP_SPLIT_LC_LOCK_TOKEN:-}
		_cur=$(cat "$LC_LOCK/pid" 2>/dev/null)
		if [ -n "$_tok" ] && [ -n "$_cur" ] && [ "$_cur" != "$_tok" ]; then
			printf 'linecheck: 锁已被其他进程接管，本次放弃探测\n' >&2
			return 3
		fi
		mkdir -p "$LC_LOCK" 2>/dev/null || true
		_lc_now >"$LC_LOCK/started" 2>/dev/null || true
		printf '%s\n' "$$" >"$LC_LOCK/pid" 2>/dev/null || true
		LC_LOCK_OWNER=1
		_lc_trap_on
		if [ -f "$LC_RUN" ]; then
			_lc_self=${0:-/usr/sbin/isp-split}
			case "$_lc_self" in
			*/*) ;;
			*) _lc_self=$(command -v "$_lc_self" 2>/dev/null) || _lc_self=/usr/sbin/isp-split ;;
			esac
			set -- linecheck --json --_worker --_runner
			[ "$_lc_force" = "1" ] && set -- "$@" --force
			[ -n "$_lc_only" ] && set -- "$@" --wan "$_lc_only"
			: >"$LC_LOG" 2>/dev/null || true
	chmod 600 "$LC_LOG" 2>/dev/null || true
			if command -v setsid >/dev/null 2>&1; then
				ISP_SPLIT_LC_SELF=$_lc_self setsid sh "$LC_RUN" "$@" </dev/null >>"$LC_LOG" 2>&1 &
			else
				ISP_SPLIT_LC_SELF=$_lc_self sh "$LC_RUN" "$@" </dev/null >>"$LC_LOG" 2>&1 &
			fi
			LC_CHILD_PID=$!
			wait "$LC_CHILD_PID"
			_rc=$?
			LC_CHILD_PID=""
			return "$_rc"
		fi
		# 引擎入口缺失：保持在进程内探测（功能不受影响，只是进程数不再收敛）。
	fi
	if [ "$_lc_worker" = "1" ]; then
		# ---- 引擎（--_runner）或引擎入口缺失时的退化路径 ----
		# 引擎：锁由监督者持有，绝不写锁、也不删锁。
		# 退化（没有 lc-run）：本进程就是持锁者，必须保留 owner=1 以便退出时删锁。
		if [ "$_lc_runner" = "1" ]; then
			LC_LOCK_OWNER=0
		fi
		_lc_trap_on
	fi

	# 收集 WAN
	if [ -n "$_lc_only" ]; then
		_lc_line=$(_lc_wan_one "$_lc_only") || {
			if [ "$_lc_json" = "1" ]; then
				printf '{"version":"%s","command":"linecheck","generated":%s,"cached":false,"age":0,"interval":%s,"geo_enabled":%s,"geo_provider":"%s","socat":%s,"wans":[],"errors":["找不到接口 %s"]}\n' \
					"$ISP_VERSION" "$LC_START" "$LC_INTERVAL" \
					"$([ "$(_lc_opt LINE_CHECK_GEO 1)" = "1" ] && printf true || printf false)" \
					"$(_lc_json_esc "$(_lc_opt LINE_CHECK_GEO_PROVIDER auto)")" \
					"$([ "$LC_SOCAT" = "1" ] && printf true || printf false)" \
					"$(_lc_json_esc "$_lc_only")"
			else
				printf '线路体检：找不到接口 %s（既不是 mwan3 接口，也不是运行时设备）\n' "$_lc_only" >&2
			fi
			return 1
		}
		_lc_wans_list=$_lc_line
	else
		_lc_wans_list=$(_lc_wans)
	fi
	_lc_wnames=$(printf '%s\n' "$_lc_wans_list" | awk -F'\t' 'NF>=2{print $1}' | tr '\n' ' ' | sed 's/ $//')

	_lc_geo_en=$(_lc_opt LINE_CHECK_GEO 1)
	_lc_geo_prov=$(_lc_opt LINE_CHECK_GEO_PROVIDER auto)
	[ "$_lc_geo_en" = "0" ] && _lc_geo_prov=off
	[ "$_lc_geo_prov" = "off" ] && _lc_geo_en=0
	_C_GEO=$_lc_geo_en
	_C_PROV=$_lc_geo_prov
	_C_SOC=$LC_SOCAT
	_C_INT=$LC_INTERVAL

	# 没有可体检的 WAN：也发布一份 last.json，页面才不会空等 90 秒轮询。
	# 这种缓存不会被 _lc_cache_fresh 当成命中（请求列表为空直接返回 1），
	# 所以不会把后续真实 WAN 的结果挡掉。
	if [ -z "$_lc_wnames" ]; then
		_C_GEN=$LC_START
		_C_OUT=""
		_lc_tmp=$LC_CACHE.tmp.$$
		rm -rf "$_lc_tmp"
		if mkdir -p "$_lc_tmp" 2>/dev/null; then
			printf '%s %s %s %s %s \n' "$LC_START" "$LC_INTERVAL" "$_lc_geo_en" "$_lc_geo_prov" "$LC_SOCAT" >"$_lc_tmp/meta"
			printf '["没有启用的 WAN（检查 mwan3 接口 enabled=1）"]\n' >"$_lc_tmp/errors.json"
			_lc_json_emit "$_lc_tmp" 0 0 >"$_lc_tmp/last.json"
			_lc_publish "$_lc_tmp" || rm -rf "$_lc_tmp"
			_lc_tmp=""
		fi
		if [ "$_lc_json" = "1" ]; then
			if [ -f "$LC_CACHE/last.json" ]; then
				cat "$LC_CACHE/last.json"
			else
				printf '{"version":"%s","command":"linecheck","generated":%s,"cached":false,"age":0,"interval":%s,"geo_enabled":%s,"geo_provider":"%s","socat":%s,"wans":[],"errors":["没有启用的 WAN（检查 mwan3 接口 enabled=1）"]}\n' \
					"$ISP_VERSION" "$LC_START" "$LC_INTERVAL" \
					"$([ "$_lc_geo_en" = "1" ] && printf true || printf false)" "$_lc_geo_prov" \
					"$([ "$LC_SOCAT" = "1" ] && printf true || printf false)"
			fi
		else
			printf '线路体检：没有启用的 WAN。先在 mwan3 里启用至少一个接口，或用 --wan 指定。\n'
		fi
		return 0
	fi

	_C_OUT=$_lc_wnames

	# 缓存：60s 内直接返回；--force 绕过。读缓存阶段零探测、零网络。
	if [ "$_lc_force" = "0" ] && [ "$_lc_worker" != "1" ] && _lc_cache_serve "$_lc_json" "$_lc_wnames"; then
		return 0
	fi

	# 探测：加锁，避免和后台体检 / 另一个 CLI 撞车（BUG-23 的“重复点按钮”）。
	if [ "$_lc_worker" != "1" ]; then
		_lc_lock_try
		case $? in
		1)
			if [ "$_lc_json" = "1" ]; then
				printf '{"version":"%s","command":"linecheck","generated":%s,"cached":false,"age":0,"interval":%s,"running":true,"pid":%s,"elapsed":%s,"wans":[],"errors":["已有体检在运行（pid %s，已 %s 秒），本次不重复探测"]}\n' \
					"$ISP_VERSION" "$LC_START" "$LC_INTERVAL" "${LC_LOCK_PID:-0}" "${LC_LOCK_AGE:-0}" \
					"${LC_LOCK_PID:-0}" "${LC_LOCK_AGE:-0}"
			else
				printf '已有体检在运行（pid %s，已 %s 秒），本次不重复探测。\n' "${LC_LOCK_PID:-0}" "${LC_LOCK_AGE:-0}" >&2
			fi
			return 4
			;;
		2) die "无法创建锁目录 $LC_LOCK" ;;
		esac
		_lc_trap_on
		_lc_sweep
	fi

	_lc_tmp=$LC_CACHE.tmp.$$
	rm -rf "$_lc_tmp"
	mkdir -p "$_lc_tmp" || die "无法创建缓存目录 $_lc_tmp"

	printf '%s\n' "$_lc_wans_list" | while IFS='	' read -r _if _dev _up _state _ip; do
		[ -n "$_if" ] || continue
		_lc_run_wan "$_if" "$_dev" "$_up" "$_state" "$_ip"
		_lc_wan_json >"$_lc_tmp/wan-$_if.json"
		_lc_wan_text >"$_lc_tmp/wan-$_if.txt"
		[ -n "$LC_STUN_DETAIL" ] || true
	done

	# 上面 while 在管道子 shell 里，变量不会带回来；缓存文件已经落盘，
	# 这里按文件重新组装，避免依赖子 shell 状态。
	_C_GEN=$(_lc_now)
	printf '%s %s %s %s %s %s\n' "$_C_GEN" "$LC_INTERVAL" "$_lc_geo_en" "$_lc_geo_prov" "$LC_SOCAT" "$_lc_wnames" >"$_lc_tmp/meta"
	printf '[]\n' >"$_lc_tmp/errors.json"
	# last.json：LuCI 只读这一个文件，必须和 --json 输出逐字节一致。
	_lc_json_emit "$_lc_tmp" 0 0 >"$_lc_tmp/last.json" || die "无法写入 $_lc_tmp/last.json"

	_lc_publish "$_lc_tmp" || { rm -rf "$_lc_tmp"; die "无法写入缓存目录 $LC_CACHE"; }
	_lc_tmp=""

	if [ "$_lc_json" = "1" ]; then
		cat "$LC_CACHE/last.json"
	else
		_lc_text_emit "$LC_CACHE" ""
	fi
	return 0
}

# lc:end

# 自检/单测可以只加载函数，不执行主分发（selftest.sh 用）。
if [ "${ISP_SPLIT_LIB_ONLY:-0}" = "1" ]; then
	return 0 2>/dev/null || exit 0
fi

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

# 三个表值都会被 `for row in $ISP_TABLE` 这种方式展开，而展开会做路径名
# 展开（glob）：值里一旦有 * ? [，当前目录里一个叫
# "x:isp_pwn:wan_only:1" 的文件就能冒充一行运营商配置（实测 plan 里会
# 直接多出一个 isp_pwn 集合）。脚本里还有好几处必须保留 glob
# （ls .history/*、cp out.new/*.txt），不能全局 set -f，所以在入口
# 直接拒绝这几个字符 —— 表里本来只该有 字母数字 : . _ - # 和空白。
_table_no_glob() {
	for _tg_v in "$ISP_TABLE" "${DNS_TABLE:-}" "${DOMAIN_SPLIT:-}"; do
		case "$_tg_v" in
		*'*'* | *'?'* | *'['* | *']'*)
			die "配置表里出现通配符字符（* ? [ ]）：展开时会被替换成当前目录的文件名。请删除这些字符（注释里也不要写）"
			;;
		esac
	done
}
_table_no_glob

case "${1:-}" in
--version | version)
	echo "isp-split $ISP_VERSION"
	exit 0
	;;
fetch) cmd_fetch ;;
diff) shift; cmd_diff "$@" ;;
dnstest) shift; cmd_dnstest "$@" ;;
apply) cmd_apply ;;
init) shift; cmd_init "$@" ;;
bootstrap) shift; cmd_bootstrap "$@" ;;
doctor) shift; cmd_doctor "$@" ;;
wire) cmd_wire ;;
sync) cmd_sync ;;
plan) cmd_plan ;;
check) cmd_check ;;
selftest) cmd_selftest ;;
stage) cmd_stage ;;
enable) cmd_enable ;;
verify) cmd_verify ;;
policy-check) shift; cmd_policy_check "$@" ;;
status) shift; cmd_status "$@" ;;
health) shift; cmd_health "$@" ;;
probe) cmd_probe ;;
# lc:begin
linecheck) shift; cmd_linecheck "$@" ;;
# lc:end
rollback) cmd_rollback ;;
dns) cmd_dns ;;
conf) shift; cmd_conf "$@" ;;
refresh) cmd_refresh ;;
export) shift; cmd_export "$@" ;;
install) cmd_install ;;
uninstall) cmd_uninstall ;;
# 剥离版的固定拒绝桩（FEATURE_LINECHECK=0 时上面的实现分支已被整段删除）。
# 用 glob 匹配命令名而不是写字面量：产物必须通过“零残留 grep”断言，
# 但仍要对已剥离的子命令给出明确、稳定的拒绝文案与退出码。
line*check)
	printf '此版本未包含该功能\n' >&2
	exit 2
	;;
*)
	cat <<EOF
用法: isp-split <命令>

  —— 不改动任何东西 ——
  plan       只读预演：打印将要做什么
  check      nft 干跑校验（nft -c，不提交）
  selftest   在独立临时表里实测加载 1.3 万条，测完删除

  —— 数据 ——
  fetch      下载并重建运营商 IP 段（不动线上流量；多源逐源拉取并过闸门）
  diff       重新计算"主源 vs 校验源"的分歧（基于存档，不联网；超阈值返回 3）
  rollback   回滚到上一份数据

  —— 首次安装 / 上线（建议分两步）——
  bootstrap  净机一键骨架：从 network 发现 WAN，补出 mwan3 的
             interface/member/policy（默认 dry-run，--apply 才写；
             只补缺失、绝不覆盖已有段；不自动 wire/reload）
  doctor     环境体检：六项兼容矩阵 + 主机前提（--json 给 LuCI 用）
  init       发现 mwan3 接口，为 ISP_TABLE 的每个策略创建 policy/member
             交互选择接口；或 --bind <策略>=<接口>（可重复）--assume-yes
  stage      注入规则但保持 enabled=0，验证 mwan3 未被搞坏
  enable     确认无误后真正启用
  verify     检查规则链渲染结果、策略成员与命中情况
  policy-check  检查 ISP_TABLE 引用的策略是否都有成员（--json 给 LuCI 用）

  —— 其他 ——
  apply      原子刷入 nftables 集合（不断流、不重启 mwan3）
  wire       建立 mwan3 集合与规则并插到正确位置（策略缺失会硬失败）
  conf       读写配置：conf get | conf set <项> <值>
             conf check <项> <值>（只校验）| conf preview <项> <值> ...（预演未保存的改动）
  dnstest    测一条线路的 DNS 是否可用：dnstest <策略名>
  refresh    fetch + apply（只更新数据，不动 mwan3 配置）
  sync       fetch + wire + apply
  status     查看条目数与命中统计
  health     数据源健康度 + 双线状态（--json 给 LuCI 用）
  probe      分线实测出口速度
# lc:begin
  linecheck  线路体检：公网/NAT/四段延迟/归属地/评分（--json，--wan，--force）
             --background 后台探测并原子替换缓存（LuCI 用，立刻返回 JSON 状态）
# lc:end
  dns        生成多线路 DNS 配置（域名按线路选 DNS）
  export     导出给代理侧用：export mihomo|singbox|text <目录>
  install    注册开机自启与每日更新
  uninstall  卸载

零风险验证顺序：
  isp-split plan && isp-split check && isp-split selftest
上线顺序：
  isp-split sync && isp-split stage && isp-split enable
EOF
	exit 1
	;;
esac

# 显式把最后一个命令的退出码作为进程退出码：conf set 等命令校验失败时，
# 调用方（LuCI fs.exec、cron、shell）必须能拿到非 0，不能依赖隐式行为。
exit $?
