#!/usr/bin/env bash
# ============================================================
# AnyTuck 多租户中继一键安装（兼容 Ubuntu / Debian / CentOS / RHEL / Rocky / Alma / Fedora / openSUSE / Arch）
#
# 两种运行方式：
#   1) 已上传本项目目录：cd 进目录后  sudo bash install.sh
#   2) 远程一键（与单用户中继同站根路径，脚本名 multiple-install.sh）：
#        curl -fsSL https://anytuck-relay.aican.app/multiple-install.sh | sudo bash
#      脚本会自动下载项目源码到 /opt/anytuck-relay-multiple 再安装。
#      可用环境变量覆盖：ANYTUCK_MULTI_SRC_URL、ANYTUCK_MULTI_INSTALL_DIR。
#
# 卸载：
#   sudo bash install.sh uninstall
#   # 或远程脚本同名：
#   curl -fsSL https://anytuck-relay.aican.app/multiple-install.sh | sudo bash -s -- uninstall
#   # 或已安装后：anytuck-multiple uninstall / 菜单「卸载」
#
# 完成后：写好 .env、构建并启动容器、安装 `anytuck-multiple`；打印 WSS 与管理面板地址（随机初始密码，强制改密）。
# ============================================================
set -euo pipefail

# 远程一键：与单用户包同站根路径（anytuck-relay.aican.app），不另开子目录
SRC_URL="${ANYTUCK_MULTI_SRC_URL:-https://anytuck-relay.aican.app/anytuck-relay-multiple.tar.gz}"
INSTALL_DIR="${ANYTUCK_MULTI_INSTALL_DIR:-/opt/anytuck-relay-multiple}"

PROJECT_DIR=""  # 由 resolveProject 决定（在地目录或下载解压目录）
ENV_FILE=""     # resolveProject 后据 PROJECT_DIR 设定
CONFIG_DIR="/etc/anytuck-multiple"
CONFIG_FILE="$CONFIG_DIR/config"

# 运行期变量（camelCase）
osId=""
osName=""
pkgMgr=""
composeFile="docker-compose.yml"
composeProfiles=""
sharedNetwork=""
dnsHint=""
uiLang="zh"   # zh | en，安装时选择，写入 .env 的 RELAY_LANG

# L 按当前界面语言返回文案：L "中文" "English"
L() { if [[ "$uiLang" == "en" ]]; then printf '%s' "$2"; else printf '%s' "$1"; fi; }

info() { printf '\033[36m%s\033[0m\n' "$*"; }
ok() { printf '\033[32m%s\033[0m\n' "$*"; }
warn() { printf '\033[33m%s\033[0m\n' "$*"; }
err() { printf '\033[31m%s\033[0m\n' "$*" >&2; }

# requireRoot 确认以 root 运行（需要写 /usr/local/bin 与 /etc、装包、控 docker）。
requireRoot() {
	if [[ $EUID -ne 0 ]]; then
		err "$(L "请用 root 运行：sudo bash install.sh" "Run as root: sudo bash install.sh")"
		exit 1
	fi
}

# requireTty 确保有可交互的控制终端。经 `curl | sudo bash` 运行时 stdin 是脚本本身，
# 交互 read 必须从 /dev/tty 读；若连 /dev/tty 都没有则提示改用下载后运行。
requireTty() {
	if [[ ! -e /dev/tty ]]; then
		err "$(L "非交互环境无法读取输入。请改为下载后运行：" "Non-interactive shell; download and run locally:")"
		err "  curl -fsSL https://anytuck-relay.aican.app/multiple-install.sh -o multiple-install.sh && sudo bash multiple-install.sh"
		exit 1
	fi
}

# detectOs 从 /etc/os-release 读取发行版并选定包管理器。
detectOs() {
	if [[ -r /etc/os-release ]]; then
		# shellcheck disable=SC1091
		. /etc/os-release
		osId="${ID:-}"
		osName="${PRETTY_NAME:-$osId}"
	fi
	for m in apt-get dnf yum zypper pacman; do
		if command -v "$m" >/dev/null 2>&1; then pkgMgr="$m"; break; fi
	done
	info "$(L "系统：${osName:-未知}  包管理器：${pkgMgr:-未知}" "OS: ${osName:-unknown}  package manager: ${pkgMgr:-unknown}")"
	if [[ -z "$pkgMgr" ]]; then
		warn "$(L "未识别包管理器，若缺少 curl/docker 需你手动安装。" "Unknown package manager; install curl/docker manually if missing.")"
	fi
}

# pkgInstall 用检测到的包管理器安装软件包（失败不致命，交由调用方判断）。
pkgInstall() {
	case "$pkgMgr" in
		apt-get) apt-get update -y >/dev/null 2>&1 || true; apt-get install -y "$@" ;;
		dnf) dnf install -y "$@" ;;
		yum) yum install -y "$@" ;;
		zypper) zypper -n install "$@" ;;
		pacman) pacman -Sy --noconfirm "$@" ;;
		*) return 1 ;;
	esac
}

# ensureTool 确保某命令存在，缺失则尝试装对应包。
ensureTool() {
	local bin="$1" pkg="${2:-$1}"
	command -v "$bin" >/dev/null 2>&1 && return 0
	warn "$(L "缺少 $bin，尝试安装 $pkg ..." "Missing $bin, trying to install $pkg ...")"
	pkgInstall "$pkg" >/dev/null 2>&1 || true
	command -v "$bin" >/dev/null 2>&1
}

# checkDocker 确保 Docker 已装、守护进程在跑、且有 compose v2；缺 Docker 询问自动安装。
checkDocker() {
	if ! command -v docker >/dev/null 2>&1; then
		warn "$(L "未检测到 Docker。" "Docker not found.")"
		read -rp "$(L "是否自动安装 Docker（官方 get.docker.com 脚本）？[Y/n] " "Install Docker automatically (get.docker.com)? [Y/n] ")" ans </dev/tty
		if [[ -z "${ans}" || "${ans,,}" == "y" ]]; then
			ensureTool curl curl || { err "$(L "需要 curl 才能安装 Docker，请手动安装 curl 后重试。" "curl is required to install Docker.")"; exit 1; }
			curl -fsSL https://get.docker.com | sh || { err "$(L "Docker 安装失败，请参考 https://docs.docker.com/engine/install/ 手动安装。" "Docker install failed; see https://docs.docker.com/engine/install/")"; exit 1; }
		else
			err "$(L "请先安装 Docker（https://docs.docker.com/engine/install/）后重试。" "Install Docker first: https://docs.docker.com/engine/install/")"
			exit 1
		fi
	fi

	# 守护进程是否在运行（未运行则尝试拉起）
	if ! docker info >/dev/null 2>&1; then
		warn "$(L "Docker 守护进程未运行，尝试启动 ..." "Docker daemon not running, starting ...")"
		if command -v systemctl >/dev/null 2>&1; then
			systemctl enable --now docker >/dev/null 2>&1 || true
		elif command -v service >/dev/null 2>&1; then
			service docker start >/dev/null 2>&1 || true
		fi
		docker info >/dev/null 2>&1 || { err "$(L "Docker 守护进程无法启动，请检查 systemctl status docker。" "Cannot start Docker daemon; check systemctl status docker.")"; exit 1; }
	fi

	# compose v2
	if ! docker compose version >/dev/null 2>&1; then
		warn "$(L "未检测到 Docker Compose v2 插件，尝试安装 ..." "Docker Compose v2 not found, installing ...")"
		pkgInstall docker-compose-plugin >/dev/null 2>&1 || true
		docker compose version >/dev/null 2>&1 || {
			err "$(L "缺少 docker compose v2。请安装 docker-compose-plugin（或升级 Docker）后重试。" "Missing docker compose v2; install docker-compose-plugin (or upgrade Docker).")"
			exit 1
		}
	fi
	ok "$(L "Docker 就绪：" "Docker ready: ")$(docker --version)"
}

# resolveProject 定位项目源码：就地目录含源码则用之；否则从 SRC_URL 下载解压到 INSTALL_DIR。
resolveProject() {
	local here
	here="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)"
	if [[ -n "$here" && -f "$here/relay/Dockerfile" ]]; then
		PROJECT_DIR="$here"
		info "$(L "使用就地项目目录：$PROJECT_DIR" "Using local project directory: $PROJECT_DIR")"
	else
		info "$(L "未在本地发现项目源码，从 $SRC_URL 下载 ..." "Project sources not found locally; downloading from $SRC_URL ...")"
		ensureTool curl curl || ensureTool wget wget || { err "$(L "需要 curl 或 wget 才能下载项目源码。" "curl or wget is required to download sources.")"; exit 1; }
		ensureTool tar tar || true
		local tmp; tmp="$(mktemp -d)"
		if command -v curl >/dev/null 2>&1; then
			curl -fsSL "$SRC_URL" -o "$tmp/src.tgz" || { err "$(L "下载失败：$SRC_URL" "Download failed: $SRC_URL")"; exit 1; }
		else
			wget -qO "$tmp/src.tgz" "$SRC_URL" || { err "$(L "下载失败：$SRC_URL" "Download failed: $SRC_URL")"; exit 1; }
		fi
		tar xzf "$tmp/src.tgz" -C "$tmp" || { err "$(L "解压失败，请确认 $SRC_URL 是 .tar.gz。" "Extract failed; ensure $SRC_URL is a .tar.gz.")"; exit 1; }
		local dockerfile root
		dockerfile="$(find "$tmp" -maxdepth 4 -type f -path '*/relay/Dockerfile' | head -n1)"
		[[ -n "$dockerfile" ]] || { err "$(L "包内未找到 relay/Dockerfile，包结构不对。" "relay/Dockerfile not found in archive.")"; exit 1; }
		root="$(cd "$(dirname "$dockerfile")/.." && pwd)"
		mkdir -p "$INSTALL_DIR"
		cp -a "$root/." "$INSTALL_DIR/"
		rm -rf "$tmp"
		PROJECT_DIR="$INSTALL_DIR"
		ok "$(L "项目源码已就绪：$PROJECT_DIR" "Project sources ready: $PROJECT_DIR")"
	fi
	ENV_FILE="$PROJECT_DIR/.env"
}

# genToken 生成 32 字节十六进制随机令牌（优先 openssl，回退 /dev/urandom）。
genToken() {
	if command -v openssl >/dev/null 2>&1; then
		openssl rand -hex 32
	else
		head -c32 /dev/urandom | od -An -tx1 | tr -d ' \n'
	fi
}

# setEnv 更新（或追加）.env 中某个键。
setEnv() {
	local key="$1" val="$2" escaped
	escaped=$(printf '%s' "$val" | sed -e 's/[\\|&]/\\&/g')
	if grep -qE "^${key}=" "$ENV_FILE"; then
		sed -i "s|^${key}=.*|${key}=${escaped}|" "$ENV_FILE"
	else
		printf '%s=%s\n' "$key" "$val" >>"$ENV_FILE"
	fi
}

# ask 读取一个输入项（可带默认值）。始终从 /dev/tty 读，兼容 curl|bash 一键运行。
ask() {
	local prompt="$1" default="${2:-}" var
	if [[ -n "$default" ]]; then
		read -rp "$prompt [$default]: " var </dev/tty; var="${var:-$default}"
	else
		read -rp "$prompt: " var </dev/tty
	fi
	printf '%s' "$var"
}

# listInterfaces 列出候选网卡（排除 lo），辅助动态 IPv6 选择。
listInterfaces() {
	if command -v ip >/dev/null 2>&1; then
		ip -o link show 2>/dev/null | awk -F': ' '{print $2}' | grep -v '^lo$' | tr '\n' ' '
	fi
}

# portInUse 判断某 TCP 端口是否已被监听（ss / netstat / lsof 依次回退）。
portInUse() {
	local p="$1"
	if command -v ss >/dev/null 2>&1; then
		ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$p\$" && return 0
	elif command -v netstat >/dev/null 2>&1; then
		netstat -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$p\$" && return 0
	elif command -v lsof >/dev/null 2>&1; then
		lsof -iTCP:"$p" -sTCP:LISTEN >/dev/null 2>&1 && return 0
	fi
	return 1
}

# whoHoldsPort 打印占用端口的进程信息（尽力而为）。
whoHoldsPort() {
	local p="$1"
	if command -v ss >/dev/null 2>&1; then
		ss -ltnp 2>/dev/null | grep -E "[:.]$p " || true
	elif command -v lsof >/dev/null 2>&1; then
		lsof -iTCP:"$p" -sTCP:LISTEN 2>/dev/null || true
	fi
}

# 共存模式探测结果（供 ask 默认值）
suggestedSharedNetwork="edge_shared"
suggestedProxyContainer=""

# isBuiltinDockerNet 判断是否为 Docker 内置网络（不宜当作「共享业务网」）。
isBuiltinDockerNet() {
	case "$1" in
		bridge|host|none|ingress) return 0 ;;
		*) return 1 ;;
	esac
}

# isLikelyReverseProxy 根据容器名与镜像判断是否为常见反代。
isLikelyReverseProxy() {
	local name="$1" image="$2"
	local n i
	n=$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')
	i=$(printf '%s' "$image" | tr '[:upper:]' '[:lower:]')
	# 镜像关键字（优先）
	[[ "$i" == *nginx-proxy-manager* || "$i" == *jc21/nginx-proxy-manager* ]] && return 0
	[[ "$i" == *traefik* ]] && return 0
	[[ "$i" == *caddy* && "$n" == *caddy* ]] && return 0
	# 容器名
	[[ "$n" == *nginx-proxy-manager* || "$n" == npm || "$n" == *npm*proxy* ]] && return 0
	[[ "$n" == *traefik* ]] && return 0
	return 1
}

# listProxyCandidates 列出正在运行的疑似反代容器名（空格分隔）。
listProxyCandidates() {
	local name image out=""
	while IFS=$'\t' read -r name image; do
		[[ -z "$name" ]] && continue
		if isLikelyReverseProxy "$name" "$image"; then
			out="${out}${out:+ }$name"
		fi
	done < <(docker ps --format '{{.Names}}\t{{.Image}}' 2>/dev/null || true)
	printf '%s' "$out"
}

# containerNonBuiltinNets 列出容器所在非内置网络（空格分隔）。
containerNonBuiltinNets() {
	local cname="$1" n out=""
	for n in $(docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cname" 2>/dev/null); do
		isBuiltinDockerNet "$n" && continue
		out="${out}${out:+ }$n"
	done
	printf '%s' "$out"
}

# pickSuggestedNetwork 根据反代容器所在网络选出建议的共享网络名。
pickSuggestedNetwork() {
	local proxy="$1" n nets best=""
	# 已存在 edge_shared 时优先
	if docker network inspect edge_shared >/dev/null 2>&1; then
		printf '%s' "edge_shared"
		return 0
	fi
	if [[ -n "$proxy" ]]; then
		nets=$(containerNonBuiltinNets "$proxy")
		for n in $nets; do
			if [[ "$n" == "edge_shared" ]]; then
				printf '%s' "edge_shared"
				return 0
			fi
			[[ -z "$best" ]] && best="$n"
		done
		if [[ -n "$best" ]]; then
			printf '%s' "$best"
			return 0
		fi
	fi
	printf '%s' "edge_shared"
}

# discoverCoexistHints 探测本机反代与网络，填充 suggested* 并打印给用户看。
discoverCoexistHints() {
	local candidates first nets
	suggestedSharedNetwork="edge_shared"
	suggestedProxyContainer=""
	info "\n$(L "==== 共存模式：自动探测反代 ====" "==== Coexist mode: detect reverse proxy ====")"

	if ! command -v docker >/dev/null 2>&1; then
		warn "$(L "未检测到 docker，将使用默认值 edge_shared。" "Docker not found; defaulting to edge_shared.")"
		return 0
	fi

	candidates=$(listProxyCandidates)
	if [[ -n "$candidates" ]]; then
		# shellcheck disable=SC2086
		first=$(set -- $candidates; printf '%s' "$1")
		suggestedProxyContainer="$first"
		ok "$(L "检测到疑似反代容器：" "Likely reverse-proxy container(s):") $candidates"
		if [[ $(set -- $candidates; echo $#) -gt 1 ]]; then
			info "$(L "将预填第一个：$first（不对请改）" "Pre-filling the first: $first (edit if wrong)")"
		fi
		nets=$(containerNonBuiltinNets "$first")
		if [[ -n "$nets" ]]; then
			info "$(L "该容器当前业务网络（非 bridge）：" "Its non-builtin networks:") $nets"
		else
			info "$(L "该容器目前只在默认 bridge，建议新建/使用 edge_shared 再接入。" "Container is only on default bridge; recommend edge_shared and join it.")"
		fi
	else
		warn "$(L "未自动识别反代容器（如 NPM / Traefik）。下面可手填容器名，或回车跳过稍后手动接入。" "No reverse-proxy container auto-detected (NPM/Traefik…). Enter name below, or leave empty and join later.")"
	fi

	suggestedSharedNetwork=$(pickSuggestedNetwork "$suggestedProxyContainer")
	ok "$(L "建议共享网络名：" "Suggested shared network:") $suggestedSharedNetwork"
	info "$(L "提示：直接回车接受预填；不对则改写后再回车。" "Tip: press Enter to accept prefill; edit if wrong.")"
}

# ensureSharedNetwork 确保共享外部网络存在，并把已有反代容器接入（默认用探测结果）。
ensureSharedNetwork() {
	docker network inspect "$sharedNetwork" >/dev/null 2>&1 || {
		docker network create "$sharedNetwork" >/dev/null
		ok "$(L "已创建共享网络 $sharedNetwork" "Created shared network $sharedNetwork")"
	}
	local proxyName nets
	proxyName=$(ask "$(L "反代容器名（接入共享网络；回车=预填/跳过）" "Reverse-proxy container (join network; Enter=prefill/skip)")" "${suggestedProxyContainer:-}")
	if [[ -z "$proxyName" ]]; then
		info "$(L "跳过接入。需要时可执行：docker network connect $sharedNetwork <容器名> && docker restart <容器名>" "Skipped join. Later: docker network connect $sharedNetwork <name> && docker restart <name>")"
		return 0
	fi
	# 已在目标网则提示；否则 connect
	nets=$(docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$proxyName" 2>/dev/null || true)
	if [[ " $nets " == *" $sharedNetwork "* ]]; then
		ok "$(L "$proxyName 已在网络 $sharedNetwork" "$proxyName already on $sharedNetwork")"
	else
		if docker network connect "$sharedNetwork" "$proxyName" >/dev/null 2>&1; then
			ok "$(L "已把 $proxyName 接入 $sharedNetwork" "Joined $proxyName to $sharedNetwork")"
		else
			warn "$(L "接入 $proxyName 失败，请检查容器名是否正确。" "Failed to join $proxyName; check the container name.")"
			return 0
		fi
	fi
	info "$(L "建议重启反代使上游 DNS 生效：docker restart $proxyName" "Restart reverse proxy so upstream DNS refreshes: docker restart $proxyName")"
	local ans
	read -rp "$(L "现在重启 $proxyName？[Y/n] " "Restart $proxyName now? [Y/n] ")" ans </dev/tty
	if [[ -z "$ans" || "${ans,,}" == "y" ]]; then
		docker restart "$proxyName" >/dev/null 2>&1 \
			&& ok "$(L "已重启 $proxyName" "Restarted $proxyName")" \
			|| warn "$(L "重启失败，请手动：docker restart $proxyName" "Restart failed; run: docker restart $proxyName")"
	fi
}

# chooseFreePort 让用户选端口，若被占用则给出占用者并循环重选；
# 结果写入全局 chosenPort（不用命令替换，以免日志被吞进返回值）。
chosenPort=""
chooseFreePort() {
	local defaultPort="$1" port
	while true; do
		port=$(ask "$(L "对外 HTTPS 端口" "Public HTTPS port")" "$defaultPort")
		if portInUse "$port"; then
			warn "$(L "端口 $port 已被占用：" "Port $port is in use:")"
			whoHoldsPort "$port"
			warn "$(L "若被 Nginx Proxy Manager / nginx 占用，建议改用「与已有反代共存」模式（重跑脚本选 5），或换一个空闲端口。" "If held by Nginx Proxy Manager / nginx, use coexist mode (re-run and choose 5), or pick a free port.")"
			read -rp "$(L "换其它端口重试？[Y/n] " "Try another port? [Y/n] ")" retry </dev/tty
			if [[ -n "$retry" && "${retry,,}" != "y" ]]; then
				chosenPort="$port"; return 0
			fi
		else
			chosenPort="$port"; return 0
		fi
	done
}

# chooseLang 安装开始时选择界面语言（同时决定 Web 面板语言）。
chooseLang() {
	echo
	echo "Language / 语言"
	echo "  1) 中文"
	echo "  2) English"
	local ans
	ans=$(ask "选择 1/2 · Choose 1/2" "1")
	case "$ans" in
		2|en|EN|english|English) uiLang="en" ;;
		*) uiLang="zh" ;;
	esac
	ok "$(L "界面语言：中文（命令行与 Web 面板）" "UI language: English (CLI and web panel)")"
}

# collectConfig 交互收集配置并写入 .env，同时确定编排文件 / profile / DNS 提醒。
collectConfig() {
	info "$(L "==== 基础配置 ====" "==== Basic configuration ====")"
	local domain port
	domain=$(ask "$(L "完整中继域名（客户端连它，如 relay.example.com）" "Relay domain (clients connect here, e.g. relay.example.com)")")

	info "\n$(L "==== 选择部署方式 ====" "==== Deployment mode ====")"
	if [[ "$uiLang" == "en" ]]; then
		cat <<'EOF'
 1) Fixed IPv4 (VPS, bind 443/custom port, auto TLS)
 2) Fixed IPv6 (host network, auto TLS)
 3) Dynamic IPv6 (home WAN, auto AAAA + auto TLS)
 4) Dynamic IPv4 (home WAN, auto A via public-IP URL + auto TLS)
 5) Coexist with existing reverse proxy (e.g. NPM; no host ports; TLS by proxy)
EOF
	else
		cat <<'EOF'
 1) 固定 IPv4（VPS，本机占用 443/自定义端口，自动签证书）
 2) 固定 IPv6（本机占用端口，host 网络，自动签证书）
 3) 动态 IPv6（家宽，自动维护 AAAA + 自动签证书）
 4) 动态 IPv4（家宽/动态公网，自动维护 A + 自动签证书）
 5) 与已有反代共存（已装 Nginx Proxy Manager 等，本项目不占端口，TLS 交给反代）
EOF
	fi
	local mode
	mode=$(ask "$(L "选择 1/2/3/4/5" "Choose 1/2/3/4/5")" "1")

	[[ -f "$ENV_FILE" ]] || cp "$PROJECT_DIR/.env.example" "$ENV_FILE"
	setEnv RELAY_DOMAIN "$domain"
	setEnv RELAY_LANG "$uiLang"

	case "$mode" in
		1|2|3|4)
			local email cfToken
			email=$(ask "$(L "ACME 证书邮箱" "ACME email")")
			cfToken=$(ask "$(L "Cloudflare API Token（Zone:Read + DNS:Edit）" "Cloudflare API Token (Zone:Read + DNS:Edit)")")
			setEnv ACME_EMAIL "$email"
			setEnv CLOUDFLARE_API_TOKEN "$cfToken"
			;;
	esac

	case "$mode" in
		1)
			chooseFreePort "443"; port="$chosenPort"
			setEnv RELAY_HTTPS_PORT "$port"
			composeFile="docker-compose.yml"; composeProfiles=""
			dnsHint="$(L "请在 Cloudflare 给 ${domain} 添加 A 记录 -> 本机公网 IPv4（DNS only，关闭橙色代理）" "Add an A record for ${domain} in Cloudflare -> this host public IPv4 (DNS only, orange cloud OFF)")"
			;;
		2)
			chooseFreePort "443"; port="$chosenPort"
			setEnv RELAY_HTTPS_PORT "$port"
			composeFile="docker-compose.home.yml"; composeProfiles=""
			dnsHint="$(L "请在 Cloudflare 给 ${domain} 添加 AAAA 记录 -> 本机公网 IPv6（DNS only）" "Add an AAAA record for ${domain} in Cloudflare -> this host public IPv6 (DNS only)")"
			;;
		3)
			chooseFreePort "16666"; port="$chosenPort"
			setEnv RELAY_HTTPS_PORT "$port"
			composeFile="docker-compose.home.yml"; composeProfiles="ddns"
			info "$(L "可用网卡" "Interfaces"): $(listInterfaces)"
			local iface
			iface=$(ask "$(L "对外网卡名（读取全局 IPv6）" "WAN interface (for global IPv6)")")
			setEnv DDNS_RECORD_TYPE "AAAA"
			setEnv DDNS_IP_SOURCE "interface"
			setEnv DDNS_INTERFACE "$iface"
			dnsHint="$(L "AAAA 记录将由 ddns 自动维护（该域名需托管在同一 Cloudflare 账户）" "AAAA will be maintained by ddns (domain must be on the same Cloudflare account)")"
			;;
		4)
			chooseFreePort "16666"; port="$chosenPort"
			setEnv RELAY_HTTPS_PORT "$port"
			composeFile="docker-compose.yml"; composeProfiles="ddns"
			setEnv DDNS_RECORD_TYPE "A"
			setEnv DDNS_IP_SOURCE "url"
			dnsHint="$(L "A 记录将由 ddns 经公网 IP 查询自动维护；请在路由器把 ${port}/tcp（及 udp，若需要）转发到本机。域名需在同一 Cloudflare 账户。" "A record will be maintained by ddns via public-IP lookup; forward ${port}/tcp (and udp if needed) on your router to this host. Domain must be on the same Cloudflare account.")"
			;;
		5)
			setEnv RELAY_HTTPS_PORT "443"
			discoverCoexistHints
			sharedNetwork=$(ask "$(L "共享 docker 网络名" "Shared Docker network name")" "$suggestedSharedNetwork")
			setEnv SHARED_NETWORK "$sharedNetwork"
			composeFile="docker-compose.npm.yml"; composeProfiles=""
			dnsHint="$(L "在反代（如 NPM）后台加 Proxy Host：${domain} -> 转发到 anytuck_relay_multiple:8443 (http)，务必开启 Websockets/WSS；证书由反代签发（Let's Encrypt）。接共享网后若 502 可 docker restart 反代容器。" "In reverse proxy (e.g. NPM) add Proxy Host: ${domain} -> anytuck_relay_multiple:8443 (http), enable Websockets; TLS by proxy. If 502 after joining network, docker restart the proxy container.")"
			;;
		*)
			err "$(L "无效选择" "Invalid choice")"; exit 1 ;;
	esac
	ok ".env written -> $ENV_FILE"
}

# startStack 构建并启动容器（共存模式先备好共享网络）。
startStack() {
	info "\n$(L "==== 构建并启动（首次编译需数分钟）====" "==== Build and start (first build may take several minutes) ====")"
	cd "$PROJECT_DIR"
	if [[ "$composeFile" == "docker-compose.npm.yml" ]]; then
		ensureSharedNetwork
		SHARED_NETWORK="$sharedNetwork" docker compose -f "$composeFile" up -d --build
	elif [[ -n "$composeProfiles" ]]; then
		COMPOSE_PROFILES="$composeProfiles" docker compose -f "$composeFile" up -d --build
	else
		docker compose -f "$composeFile" up -d --build
	fi
	ok "$(L "容器已启动" "Containers started")"
}

# installCommand 安装 /usr/local/bin/anytuck-multiple 管理命令并写入部署配置。
installCommand() {
	install -m 0755 "$PROJECT_DIR/anytuck-multiple" /usr/local/bin/anytuck-multiple
	mkdir -p "$CONFIG_DIR"
	cat >"$CONFIG_FILE" <<EOF
PROJECT_DIR=$PROJECT_DIR
COMPOSE_FILE=$composeFile
COMPOSE_PROFILES=$composeProfiles
SHARED_NETWORK=$sharedNetwork
RELAY_LANG=$uiLang
EOF
	chmod 0644 "$CONFIG_FILE"
	ok "$(L "已安装管理命令：anytuck-multiple" "Installed CLI: anytuck-multiple")"
}

# waitHealth 短轮询本机 healthz（不依赖证书命令）。
waitHealth() {
	local i
	info "$(L "等待中继就绪..." "Waiting for relay...")"
	for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
		if curl -fsS "http://127.0.0.1:8443/healthz" >/dev/null 2>&1 \
			|| docker compose -f "$PROJECT_DIR/$composeFile" exec -T relay wget -qO- http://127.0.0.1:8443/healthz >/dev/null 2>&1; then
			ok "$(L "中继 healthz 正常" "Relay healthz OK")"
			return 0
		fi
		sleep 5
	done
	warn "$(L "healthz 暂未就绪，可用 anytuck-multiple logs 查看" "healthz not ready yet; check anytuck-multiple logs")"
}

# waitAndShowCert 安装结束后短轮询证书状态（最多约 90s），再打印 anytuck-multiple cert 摘要。
waitAndShowCert() {
	local i
	if [[ "$composeFile" == "docker-compose.npm.yml" ]]; then
		info "$(L "证书由反代管理；尝试对外探测 TLS ..." "TLS is handled by the reverse proxy; probing ...")"
		anytuck-multiple cert 2>/dev/null || true
		return 0
	fi
	info "$(L "等待证书签发（最多约 90s，DNS-01 通常 30–60s）..." "Waiting for certificate (up to ~90s; DNS-01 often 30–60s)...")"
	for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18; do
		if anytuck-multiple cert 2>/dev/null | grep -qiE '已签发|issued'; then
			ok "$(L "证书已就绪" "Certificate ready")"
			anytuck-multiple cert 2>/dev/null || true
			return 0
		fi
		sleep 5
	done
	warn "$(L "等待超时，当前证书状态如下（稍后可用 anytuck-multiple info / cert 再查）：" "Timed out; current certificate status (retry with anytuck-multiple info / cert):")"
	anytuck-multiple cert 2>/dev/null || true
}

# finish 打印 WSS、管理面板登录信息与初始随机密码（强制改密）。
finish() {
	local addr admin port domain initPass tmpInit i
	domain=$(grep -E '^RELAY_DOMAIN=' "$ENV_FILE" | cut -d= -f2-)
	port=$(grep -E '^RELAY_HTTPS_PORT=' "$ENV_FILE" | cut -d= -f2-); port=${port:-443}
	if [[ "$port" == "443" ]]; then
		addr="wss://${domain}/ws"
		admin="https://${domain}/admin/"
	else
		addr="wss://${domain}:${port}/ws"
		admin="https://${domain}:${port}/admin/"
	fi

	waitHealth
	# distroless 无 cat：compose cp 到临时文件；启动稍慢时重试几次
	initPass=""
	tmpInit="$(mktemp)"
	for i in 1 2 3 4 5 6 7 8 9 10; do
		if (cd "$PROJECT_DIR" && COMPOSE_PROFILES="${composeProfiles}" docker compose -f "$composeFile" cp "relay:/data/admin.initial" "$tmpInit" 2>/dev/null); then
			initPass=$(tr -d '\r\n' <"$tmpInit" || true)
			[[ -n "$initPass" ]] && break
		fi
		sleep 1
	done
	rm -f "$tmpInit"

	echo
	ok  "=================================================="
	ok  "$(L " 中继服务多用户版安装完成" " Multi-user relay service installed")"
	echo "--------------------------------------------------"
	if [[ "$uiLang" == "en" ]]; then
		echo "   Client WSS     : $addr"
		echo "   Admin panel    : $admin"
		echo "   Username       : admin"
		echo "   Password       : ${initPass:-<not ready yet — run: anytuck-multiple info>}"
	else
		echo "   客户端 WSS     : $addr"
		echo "   Web 管理面板   : $admin"
		echo "   登录用户名     : admin"
		echo "   登录密码       : ${initPass:-<尚未就绪，请执行：anytuck-multiple info>}"
	fi
	ok  "=================================================="
	if [[ -n "$initPass" ]]; then
		warn "$(L "请立即打开面板登录（用户名 admin，密码见上方），并在「设置」中修改密码。" "Open the admin panel now (user admin, password above) and change it under Settings.")"
	else
		warn "$(L "未读到初始密码时，请执行：sudo anytuck-multiple info" "If password missing, run: sudo anytuck-multiple info")"
	fi
	warn "$(L "下一步：$dnsHint" "Next: $dnsHint")"
	echo
	waitAndShowCert
	echo
	if [[ "$uiLang" == "en" ]]; then
		echo "Host management:"
		echo "  anytuck-multiple           menu"
		echo "  anytuck-multiple info      WSS / admin / password / cert / connectivity"
		echo "  anytuck-multiple cert      certificate status only"
		echo "  anytuck-multiple iface     change WAN interface (dyn IPv6)"
		echo "  anytuck-multiple lang      switch CLI/panel language"
		echo "  anytuck-multiple logs      logs"
		echo "  anytuck-multiple restart   restart"
		echo "  anytuck-multiple uninstall uninstall"
	else
		echo "宿主机管理："
		echo "  anytuck-multiple           菜单"
		echo "  anytuck-multiple info      查看 WSS / 面板 / 密码 / 证书 / 连通性"
		echo "  anytuck-multiple cert      只看证书状态"
		echo "  anytuck-multiple iface     更换对外网卡（动态 IPv6）"
		echo "  anytuck-multiple lang      切换 CLI/面板语言"
		echo "  anytuck-multiple logs      日志"
		echo "  anytuck-multiple restart   重启"
		echo "  anytuck-multiple uninstall 卸载"
	fi
}

# loadInstalledConfig 读取已安装配置（卸载用）；无配置时回退到 INSTALL_DIR。
loadInstalledConfig() {
	composeFile="docker-compose.yml"
	composeProfiles=""
	sharedNetwork=""
	if [[ -f "$CONFIG_FILE" ]]; then
		# shellcheck disable=SC1090
		source "$CONFIG_FILE"
		PROJECT_DIR="${PROJECT_DIR:-$INSTALL_DIR}"
		composeFile="${COMPOSE_FILE:-docker-compose.yml}"
		composeProfiles="${COMPOSE_PROFILES:-}"
		sharedNetwork="${SHARED_NETWORK:-}"
	elif [[ -d "$INSTALL_DIR" && -f "$INSTALL_DIR/relay/Dockerfile" ]]; then
		PROJECT_DIR="$INSTALL_DIR"
		warn "未找到 ${CONFIG_FILE}，按默认安装目录卸载：$PROJECT_DIR"
	else
		err "$(L "未检测到已安装的中继服务多用户版（无 ${CONFIG_FILE}，且 $INSTALL_DIR 不存在）。" "Multi-user relay service not found (no ${CONFIG_FILE}, and $INSTALL_DIR missing).")"
		exit 1
	fi
	ENV_FILE="$PROJECT_DIR/.env"
	# 卸载文案语言：读已装 .env
	if [[ -f "$ENV_FILE" ]]; then
		uiLang=$(grep -E '^RELAY_LANG=' "$ENV_FILE" 2>/dev/null | head -n1 | cut -d= -f2- || true)
		[[ "$uiLang" == "en" ]] || uiLang="zh"
	fi
	# 从 .env 推断 compose 文件（配置缺失时的兜底）
	if [[ ! -f "$PROJECT_DIR/$composeFile" ]]; then
		if [[ -f "$PROJECT_DIR/docker-compose.home.yml" ]]; then
			composeFile="docker-compose.home.yml"
		elif [[ -f "$PROJECT_DIR/docker-compose.npm.yml" ]]; then
			composeFile="docker-compose.npm.yml"
		fi
	fi
	# 动态 DNS（IPv6 interface / IPv4 url）若启用了 ddns profile，卸载时也要带上才能停掉 ddns 容器
	if [[ -z "$composeProfiles" ]]; then
		if [[ "$composeFile" == "docker-compose.home.yml" ]] && grep -qE '^DDNS_INTERFACE=.+' "$ENV_FILE" 2>/dev/null; then
			composeProfiles="ddns"
		elif [[ "$composeFile" == "docker-compose.yml" ]] && grep -qE '^DDNS_RECORD_TYPE=A' "$ENV_FILE" 2>/dev/null; then
			composeProfiles="ddns"
		fi
	fi
}

# uninstallStack 卸载：停容器、删命令与配置；可选删数据卷与项目目录。
# 自包含实现，**不委托** anytuck-multiple——旧版 CLI 没有 uninstall 子命令，
# 委托会打印用法并返回 1，在 set -e 下中止整个卸载流程。
uninstallStack() {
	loadInstalledConfig
	local ans delvol delproj
	read -rp "$(L "确认卸载中继服务多用户版？将停止并移除容器 [y/N] " "Uninstall multi-user relay service and remove containers? [y/N] ")" ans </dev/tty
	[[ "${ans,,}" == "y" ]] || { echo "$(L "已取消" "Cancelled")"; return; }

	info "$(L "停止容器（compose=$composeFile profiles=${composeProfiles:-无}）..." "Stopping containers (compose=$composeFile profiles=${composeProfiles:-none})...")"
	read -rp "$(L "是否同时删除数据卷（证书 + 租户/管理员数据将丢失）？[y/N] " "Also delete volumes (certs + tenant/admin data lost)? [y/N] ")" delvol </dev/tty
	(
		cd "$PROJECT_DIR"
		export COMPOSE_PROFILES="$composeProfiles"
		export SHARED_NETWORK="${sharedNetwork:-edge_shared}"
		if [[ "${delvol,,}" == "y" ]]; then
			docker compose -f "$composeFile" down -v || true
		else
			docker compose -f "$composeFile" down || true
		fi
	)

	rm -f /usr/local/bin/anytuck-multiple
	rm -rf "$CONFIG_DIR"

	local delimg
	read -rp "$(L "是否删除本项目构建的 Docker 镜像（edge/relay/ddns，可省数百 MB）？[y/N] " "Delete images built by this project (edge/relay/ddns, can free hundreds of MB)? [y/N] ")" delimg </dev/tty
	if [[ "${delimg,,}" == "y" ]]; then
		removeImages
	fi

	read -rp "$(L "是否删除项目目录 $PROJECT_DIR ？[y/N] " "Delete project directory $PROJECT_DIR? [y/N] ")" delproj </dev/tty
	if [[ "${delproj,,}" == "y" ]]; then
		rm -rf "$PROJECT_DIR"
		ok "$(L "已卸载（含项目目录）" "Uninstalled (including project directory)")"
	else
		ok "$(L "已卸载命令与配置（项目目录 $PROJECT_DIR 保留，可自行删除）" "Uninstalled command and config (project dir $PROJECT_DIR kept)")"
	fi
}

# removeImages 删除本项目构建的镜像（仅匹配 anytuck-relay-multiple/ 前缀，不动其它镜像）。
removeImages() {
	local imgs
	imgs=$(docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep '^anytuck-relay-multiple/' || true)
	if [[ -z "$imgs" ]]; then
		info "无本项目镜像可删"
		return
	fi
	# shellcheck disable=SC2086
	docker rmi $imgs >/dev/null 2>&1 || warn "部分镜像删除失败（可能仍被容器引用）"
	ok "已删除镜像：$(echo "$imgs" | tr '\n' ' ')"
}

main() {
	requireRoot
	requireTty
	if [[ "${1:-}" == "uninstall" ]]; then
		uninstallStack
		exit 0
	fi
	# 语言须最先选：后续 Docker 安装/下载源码提示都走 L()
	chooseLang
	detectOs
	checkDocker
	resolveProject
	collectConfig
	startStack
	installCommand
	finish
}

main "$@"