#!/usr/bin/env bash
# =============================================================================
# AIBnB host installer — D-24 阶段① / task B10.
#
#   一行安装（macOS + Linux/WSL2）：uv 分发 + 持续化服务单元 + doctor 预检。
#   无 HTML 界面。不做 Windows 原生 / 签名公证 / 自动更新（D-24 范围外）。
#
# 用法：
#   ./install.sh [--dry-run] [--yes] [--coordinator-url URL] [--coordinator-token TOKEN]
#                [--invite INV-…] [--metro IATA] [--data-host ADDR] [--data-port N]
#                [--no-service] [--nohup] [-h|--help]
#
#   --dry-run          只打印将执行的动作，不改动系统（CI/自查用）
#   --yes              非交互：缺省值直接采用，不提问
#   --data-host        B76/TD-196：对外广播/绑定地址（跨机可达的 IP）。缺省自动探测：
#                      tailscale > CGNAT(100.64/10) > 公网地址；**只探到私网/回环时
#                      fail-loud 要求本参数显式给值**——绝不静默把 192.168.x 当对外地址
#   --coordinator-url  coordinator 控制面 URL（http://host:port）
#   --coordinator-token  B29/TD-112：控制面认证 token（可选，不传即不配——host 请求
#                      不带 Authorization 头，私有部署/coordinator 未开认证零影响；
#                      coordinator 开了 auth_enabled 时必须传一个在其 token 池里的值）
#   --invite           B30/D-38：无头邀请码自举（与 --coordinator-token 互斥，后者优先）。
#                      装机期兑换邀请码换 host_token 写进 host.yaml；网络失败不阻断装机，
#                      改写 invite_code 交首启/doctor 补（人话提示，不 hard-fail）
#   --metro            metro 覆盖（IATA 城市码）；缺省=留空走 D-23 GeoIP 自动检测
#   --data-port        数据面端口（默认 51843，B34：避开 WireGuard 默认 51820 撞号）
#   --no-service       只装 aibnb-host + 配置 + doctor，不注册服务单元
#   --nohup            systemd 不可用时用 nohup 兜底（明示丧失自启，仅当本会话存活）
#
# TD-24 布局坑：host 的 pyproject 依赖 aibnb-proto 走 wrapper 相对路径（../proto），
#   **从裸 host 仓库直装必失败**。故本脚本先克隆 wrapper（--recurse-submodules）到
#   ~/.aibnb/src，再从本地 host 路径装——proto 与 host 并列，../proto 可解析。
# =============================================================================
set -euo pipefail

# ---- 可覆盖参数（环境变量优先，供测试/高级用户） ----
WRAPPER_REPO="${AIBNB_WRAPPER_REPO:-https://github.com/marcochen11/AIBnB_Wrapper.git}"
WRAPPER_REF="${AIBNB_WRAPPER_REF:-main}"
canonical_physical_path() {
    local candidate="$1" current name suffix="" physical
    current="$candidate"
    while [ ! -e "$current" ] && [ ! -L "$current" ]; do
        name="${current##*/}"
        current="${current%/*}"
        [ -n "$current" ] || current="/"
        [ -n "$name" ] || return 2
        suffix="/$name$suffix"
    done
    [ -d "$current" ] || return 2
    physical="$(CDPATH= cd -- "$current" 2>/dev/null && pwd -P)" || return 2
    printf '%s%s\n' "${physical%/}" "$suffix"
}
canonical_absolute_path() {
    local name="$1" value="$2" must_exist="$3" physical
    case "$value" in
        /*) ;;
        *) printf '[aibnb ERROR] %s must be a canonical absolute path: %s\n' "$name" "$value" >&2; return 2 ;;
    esac
    case "$value" in
        *'~'*|*'$'*|*//*|*/./*|*/../*|*/.|*/..|*/)
            printf '[aibnb ERROR] %s must use canonical path spelling: %s\n' "$name" "$value" >&2
            return 2
            ;;
    esac
    [ "$must_exist" -eq 0 ] || [ -d "$value" ] || {
        printf '[aibnb ERROR] %s must name an existing canonical directory: %s\n' "$name" "$value" >&2
        return 2
    }
    physical="$(canonical_physical_path "$value")" || {
        printf '[aibnb ERROR] %s cannot be physically resolved: %s\n' "$name" "$value" >&2
        return 2
    }
    [ "$physical" = "$value" ] || {
        printf '[aibnb ERROR] %s must not contain a symlink ancestor: %s\n' "$name" "$value" >&2
        return 2
    }
    printf '%s\n' "$physical"
}
validate_product_root() {
    local raw_root="${AIBNB_HOME-}" parent
    HOME_PHYS="$(canonical_absolute_path HOME "${HOME-}" 0)" || return 2
    AIBNB_HOME="${raw_root:-$HOME/.aibnb}"
    AIBNB_HOME_PHYS="$(canonical_absolute_path AIBNB_HOME "$AIBNB_HOME" 0)" || return 2
    parent="${AIBNB_HOME_PHYS%/*}"; [ -n "$parent" ] || parent="/"
    if [ "$AIBNB_HOME_PHYS" = "/" ] || [ "$AIBNB_HOME_PHYS" = "$HOME_PHYS" ] \
        || [ "$parent" = "/" ]; then
        printf '[aibnb ERROR] AIBNB_HOME must be a canonical non-broad product root: %s\n' "$AIBNB_HOME" >&2
        return 2
    fi
    case "$HOME_PHYS/" in
        "$AIBNB_HOME_PHYS/"*)
            printf '[aibnb ERROR] AIBNB_HOME must not be an ancestor of HOME: %s\n' "$AIBNB_HOME" >&2
            return 2
            ;;
    esac
}
validate_product_root || exit 2
validate_host_targets() {
    local expected_root="$AIBNB_HOME/host" expected_config="$AIBNB_HOME/host.yaml"
    AIBNB_HOST_ROOT="${AIBNB_HOST_ROOT:-$expected_root}"
    CONFIG_PATH="${AIBNB_HOST_CONFIG:-$expected_config}"
    if [ "$AIBNB_HOST_ROOT" != "$expected_root" ]; then
        printf '[aibnb ERROR] AIBNB_HOST_ROOT must be the canonical host root: %s\n' "$expected_root" >&2
        return 2
    fi
    if ! canonical_absolute_path AIBNB_HOST_ROOT "$AIBNB_HOST_ROOT" 0 >/dev/null; then
        printf '[aibnb ERROR] AIBNB_HOST_ROOT must be the canonical host root: %s\n' "$expected_root" >&2
        return 2
    fi
    if [ "$CONFIG_PATH" != "$expected_config" ] || [ -L "$CONFIG_PATH" ]; then
        printf '[aibnb ERROR] AIBNB_HOST_CONFIG must be the canonical host config: %s\n' "$expected_config" >&2
        return 2
    fi
}
validate_host_targets || exit 2
SRC_DIR="$AIBNB_HOME/src"
VENV_DIR="$AIBNB_HOST_ROOT/venv"
LOG_DIR="$AIBNB_HOST_ROOT/logs"
HF_HOME="$AIBNB_HOME/cache/huggingface"
export HF_HOME

migrate_hf_cache() {
    local old="$HOME/.cache/huggingface" old_phys hf_phys src rel dst backup i
    [ -d "$old" ] || { RUN mkdir -p "$HF_HOME"; return 0; }
    RUN mkdir -p "$HF_HOME"
    [ "$DRY_RUN" -eq 1 ] && { printf '  + merge HF cache into %s\n' "$HF_HOME"; return 0; }
    old_phys="$(CDPATH= cd -- "$old" 2>/dev/null && pwd -P)" || old_phys=""
    hf_phys="$(CDPATH= cd -- "$HF_HOME" 2>/dev/null && pwd -P)" || hf_phys=""
    if [ -z "$old_phys" ] || [ -z "$hf_phys" ]; then
        warn "HF cache physical root resolution failed (old='$old', target='$HF_HOME'); migration skipped"
        return 0
    fi
    if [ "$old_phys" = "$hf_phys" ]; then
        say "HF cache same physical root ($old_phys) — migration no-op"
        return 0
    fi
    if [ "$old_phys" = "/" ] || [ "$hf_phys" = "/" ]; then
        warn "HF cache physical roots overlap (old='$old_phys', target='$hf_phys'); migration skipped"
        return 0
    fi
    case "$hf_phys/" in
        "$old_phys/"*)
            warn "HF cache physical roots overlap (target is inside old); migration skipped"
            return 0
            ;;
    esac
    case "$old_phys/" in
        "$hf_phys/"*)
            warn "HF cache physical roots overlap (old is inside target); migration skipped"
            return 0
            ;;
    esac
    find "$old_phys" \( -type f -o -type l \) -print0 | while IFS= read -r -d '' src; do
        rel="${src#$old_phys/}"; dst="$hf_phys/$rel"; mkdir -p "$(dirname "$dst")"
        if [ ! -e "$dst" ]; then mv "$src" "$dst"
        elif [ "$src" -nt "$dst" ]; then
            backup="$dst.pre-d4-1"; [ -e "$backup" ] || cp -p "$dst" "$backup"; mv "$src" "$dst"
        else
            backup="$dst.legacy-d4-1"; i=1
            while [ -e "$backup" ]; do i=$((i + 1)); backup="$dst.legacy-d4-1-$i"; done
            mv "$src" "$backup"
        fi
    done
    find "$old_phys" -depth -type d -empty -delete 2>/dev/null || true
}

# ---- 命令行开关 ----
DRY_RUN=0
ASSUME_YES=0
COORD_URL=""
COORD_TOKEN=""
INVITE_CODE=""
METRO=""
DATA_HOST=""        # B76/TD-196：显式对外地址；空 = 自动探测（私网不静默采用）
DATA_PORT="51843"   # B34：避开 WireGuard 默认 UDP 51820 撞号（同段动态区，无知名占用）；仅新装机
WANT_SERVICE=1
NOHUP_FALLBACK=0

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATE_DIR="$SCRIPT_DIR/templates"

# ---- 输出助手 ----
say()  { printf '\033[1;36m[aibnb]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[aibnb WARN]\033[0m %s\n' "$*" >&2; }
die()  { printf '\033[1;31m[aibnb FAIL]\033[0m %s\n' "$*" >&2; exit 1; }

# RUN：dry-run 只打印，否则执行。仅用于无管道/重定向的简单命令。
RUN() {
    printf '  + %s\n' "$*"
    if [ "$DRY_RUN" -eq 0 ]; then
        "$@"
    fi
}

usage() { sed -n '2,38p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0; }

# ---- 解析参数 ----
parse_args() {
    while [ "$#" -gt 0 ]; do
        case "$1" in
            --dry-run)         DRY_RUN=1 ;;
            --yes|-y)          ASSUME_YES=1 ;;
            --coordinator-url) COORD_URL="${2:-}"; shift ;;
            --coordinator-url=*) COORD_URL="${1#*=}" ;;
            --coordinator-token) COORD_TOKEN="${2:-}"; shift ;;
            --coordinator-token=*) COORD_TOKEN="${1#*=}" ;;
            --invite)          INVITE_CODE="${2:-}"; shift ;;
            --invite=*)        INVITE_CODE="${1#*=}" ;;
            --metro)           METRO="${2:-}"; shift ;;
            --metro=*)         METRO="${1#*=}" ;;
            --data-host)       DATA_HOST="${2:-}"; shift ;;
            --data-host=*)     DATA_HOST="${1#*=}" ;;
            --data-port)       DATA_PORT="${2:-}"; shift ;;
            --data-port=*)     DATA_PORT="${1#*=}" ;;
            --no-service)      WANT_SERVICE=0 ;;
            --nohup)           NOHUP_FALLBACK=1 ;;
            -h|--help)         usage ;;
            *)                 die "unknown argument: $1 (use --help)" ;;
        esac
        shift
    done
}

# ---- 平台检测 ----
detect_os() {          # darwin | linux
    case "$(uname -s)" in
        Darwin) echo "darwin" ;;
        Linux)  echo "linux" ;;
        *)      die "unsupported OS: $(uname -s) (macOS / Linux / WSL2 only)" ;;
    esac
}

is_wsl() { grep -qiE "microsoft|wsl" /proc/version 2>/dev/null; }

detect_platform() {    # cuda | metal | mock —— 写进 config.platform
    if command -v nvidia-smi >/dev/null 2>&1 || [ -x /usr/lib/wsl/lib/nvidia-smi ]; then
        echo "cuda"
    elif [ "$(uname -s)" = "Darwin" ] && [ "$(uname -m)" = "arm64" ]; then
        echo "metal"
    else
        echo "mock"
    fi
}

# ---- data_host 探测（B76/TD-196；AUDIT P1-26）----
# 旧 detect_ip 取 en0/hostname -I 的第一个地址——多网卡/NAT 机器上是 192.168.x 私网
# 地址；它被写进 data_host 当对外广播地址后，跨机直连 TCP 必死、iroh 打洞偶尔能通 →
# 症状呈**间歇性**（M5Pro 实撞：同档四次成一次），极易误判成网络抖动而不是配置错误。
# 现在：tailscale CLI > CGNAT 段(100.64/10,tailscale 网卡) > 全局可路由地址；
# 只探到私网/回环时 **fail-loud** 要求 --data-host 显式给值——绝不静默采用私网 IP。

ip_kind() {            # <ipv4> -> loopback|linklocal|private|cgnat|global
    case "$1" in
        127.*)                                  echo loopback ;;
        169.254.*)                              echo linklocal ;;
        10.*|192.168.*)                         echo private ;;
        172.1[6-9].*|172.2[0-9].*|172.3[01].*)  echo private ;;
        100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) echo cgnat ;;
        *)                                      echo global ;;
    esac
}

local_ip_candidates() {   # 本机 IPv4 候选，每行一个（bash3.2 + set -u：不依赖数组）
    {
        if [ "$(uname -s)" = "Darwin" ]; then
            ifconfig 2>/dev/null | awk '$1=="inet"{print $2}'
        else
            hostname -I 2>/dev/null | tr ' ' '\n'
        fi
    } | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' || true
}

# 设 RESOLVED_DATA_HOST；只探到私网/回环时 die（--data-host / 交互输入可解）。
# 交互路径不能进命令替换子壳（read 的提示与 printf -v 都会丢），故走全局变量。
resolve_data_host() {
    RESOLVED_DATA_HOST=""
    if [ -n "$DATA_HOST" ]; then
        RESOLVED_DATA_HOST="$DATA_HOST"
        case "$(ip_kind "$DATA_HOST")" in
            private|loopback) warn "explicit --data-host $DATA_HOST is not routable across machines — trusting you (LAN/test setup)" ;;
        esac
        return 0
    fi
    local ts cand kind pick_cgnat="" pick_global="" seen=""
    if command -v tailscale >/dev/null 2>&1; then
        ts="$(tailscale ip -4 2>/dev/null | head -1 || true)"
        if [ -n "$ts" ]; then
            say "data_host: using tailscale address $ts"
            RESOLVED_DATA_HOST="$ts"
            return 0
        fi
    fi
    while IFS= read -r cand; do
        [ -n "$cand" ] || continue
        kind="$(ip_kind "$cand")"
        seen="${seen:+$seen }$cand($kind)"
        case "$kind" in
            cgnat)  [ -n "$pick_cgnat" ] || pick_cgnat="$cand" ;;
            global) [ -n "$pick_global" ] || pick_global="$cand" ;;
        esac
    done <<EOF
$(local_ip_candidates)
EOF
    if [ -n "$pick_cgnat" ]; then
        say "data_host: using tailscale/CGNAT interface address $pick_cgnat"
        RESOLVED_DATA_HOST="$pick_cgnat"
        return 0
    fi
    if [ -n "$pick_global" ]; then
        say "data_host: using globally routable address $pick_global"
        RESOLVED_DATA_HOST="$pick_global"
        return 0
    fi
    if [ "$ASSUME_YES" -eq 0 ] && [ "$DRY_RUN" -eq 0 ] && [ -t 0 ]; then
        prompt DATA_HOST "no routable address detected (saw: ${seen:-none}); data_host to advertise" ""
        if [ -n "$DATA_HOST" ]; then
            RESOLVED_DATA_HOST="$DATA_HOST"
            return 0
        fi
    fi
    die "cannot pick data_host: only private/loopback addresses detected (${seen:-none}).
A LAN address advertised as data_host is unreachable from peer machines: direct TCP
always times out, and iroh hole-punching only sometimes masks it as flaky network
(TD-196; M5Pro: 1-in-4 intermittent). Re-run with --data-host <routable-ip> (e.g.
this machine's tailscale 100.x address), or write data_host into the config manually."
}

# ---- 步骤 1：确保 uv ----
ensure_uv() {
    if command -v uv >/dev/null 2>&1; then
        say "uv present: $(uv --version 2>/dev/null || echo '?')"
        return
    fi
    say "uv not found — installing (astral.sh official installer)"
    if [ "$DRY_RUN" -eq 1 ]; then
        printf '  + curl -LsSf https://astral.sh/uv/install.sh | sh\n'
        return
    fi
    curl -LsSf https://astral.sh/uv/install.sh | sh
    # 安装器把 uv 放到 ~/.local/bin（或 XDG_BIN_HOME）——本会话补进 PATH
    export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
    command -v uv >/dev/null 2>&1 || die "uv install failed (not on PATH after install)"
}

# ---- 步骤 2：克隆 / 更新 wrapper（含 submodules） ----
fetch_wrapper() {
    if [ -d "$SRC_DIR/.git" ]; then
        say "wrapper source present at $SRC_DIR — updating"
        RUN git -C "$SRC_DIR" fetch --recurse-submodules origin "$WRAPPER_REF"
        RUN git -C "$SRC_DIR" checkout "$WRAPPER_REF"
        RUN git -C "$SRC_DIR" pull --recurse-submodules origin "$WRAPPER_REF"
        RUN git -C "$SRC_DIR" submodule update --init --recursive
    else
        say "cloning wrapper (+submodules) to $SRC_DIR"
        RUN mkdir -p "$AIBNB_HOME"
        RUN git clone --recurse-submodules --branch "$WRAPPER_REF" \
            "$WRAPPER_REPO" "$SRC_DIR"
    fi
}

# ---- 步骤 3：canonical host venv（从本地 host 路径，解析 ../proto） ----
install_tool() {
    say "installing aibnb-host into canonical venv $VENV_DIR (from $SRC_DIR/host)"
    RUN mkdir -p "$AIBNB_HOST_ROOT"
    RUN uv venv --clear "$VENV_DIR"
    RUN uv pip install --python "$VENV_DIR/bin/python" --reinstall "$SRC_DIR/host"
    export PATH="$VENV_DIR/bin:$PATH"
}

# 解析已安装的 aibnb-host 绝对路径（服务单元里要写绝对路径）
resolve_host_bin() {
    local bin="$VENV_DIR/bin/aibnb-host"
    if [ "$DRY_RUN" -eq 0 ] && [ ! -x "$bin" ]; then
        die "aibnb-host missing from canonical venv after install: $bin"
    fi
    printf '%s\n' "$bin"
}

# ---- 步骤 4：生成最小配置 ----
prompt() {   # prompt VAR "message" "default"
    local __var="$1" __msg="$2" __def="$3" __ans=""
    if [ "$ASSUME_YES" -eq 1 ] || [ "$DRY_RUN" -eq 1 ] || [ ! -t 0 ]; then
        printf -v "$__var" '%s' "$__def"
        return
    fi
    read -r -p "$__msg [$__def]: " __ans || true
    printf -v "$__var" '%s' "${__ans:-$__def}"
}

write_config() {
    local platform ip
    platform="$(detect_platform)"

    if [ -f "$CONFIG_PATH" ] && [ "$DRY_RUN" -eq 0 ]; then
        say "config exists at $CONFIG_PATH — leaving as-is (edit manually to change)"
        return
    fi
    # B76/TD-196：探测在"确定要写新配置"之后才跑——已有配置的重装/升级不受
    # fail-loud 影响；只探到私网时 die（或交互补填），绝不静默写私网地址。
    resolve_data_host
    ip="$RESOLVED_DATA_HOST"
    [ -n "$COORD_URL" ] || prompt COORD_URL "coordinator URL (http://host:port)" \
        "http://COORDINATOR-HOST:8000"
    prompt DATA_PORT "data-plane port" "$DATA_PORT"
    prompt METRO "metro override (IATA; blank = GeoIP auto-detect, D-23)" "$METRO"

    local metro_line="# metro:            # optional override; blank = D-23 GeoIP auto-detect"
    [ -n "$METRO" ] && metro_line="metro: $METRO"

    # B29/TD-112：coordinator_token 只经 --coordinator-token 传入，**不**交互式 prompt
    # （token 是密态，read -r -p 会明文回显进终端/shell 历史——不合适）。默认不配（空=
    # 不发 Authorization 头，零回归），公网 coordinator 开了 auth_enabled 才需要传。
    local token_line="# coordinator_token:  # optional; only if coordinator has auth_enabled"
    # B30/D-38：无头邀请码自举占位（默认注释；装机兑换失败时改写成一次性 invite_code）。
    local invite_line="# invite_code:  # B30/D-38 headless bootstrap; set if install-time redeem failed (first start redeems it)"
    if [ -n "$COORD_TOKEN" ]; then
        token_line="coordinator_token: $COORD_TOKEN"
        # B30/D-38 互斥：两者都给 → coordinator-token 优先，invite 忽略（人话告知）。
        [ -n "$INVITE_CODE" ] && \
            warn "both --coordinator-token and --invite given — using --coordinator-token (invite ignored)"
    elif [ -n "$INVITE_CODE" ]; then
        # B30/D-38：装机期兑换邀请码换 host_token（薄 HTTP，见 aibnb_host.invite）。网络失败
        # **不阻断装机**——改写 invite_code 落盘，首启/doctor 补（人话提示，非 hard-fail）。
        if [ "$DRY_RUN" -eq 1 ]; then
            printf '  + %s redeem --coordinator-url %s --invite <INV> → coordinator_token (invite_code on failure)\n' \
                "$(resolve_host_bin)" "$COORD_URL"
        else
            local rbin rc=0 rout
            rbin="$(resolve_host_bin)"
            rout="$("$rbin" redeem --coordinator-url "$COORD_URL" --invite "$INVITE_CODE" \
                --platform "$platform" --machine-name "$(hostname 2>/dev/null || echo host)" 2>&1)" \
                || rc=$?
            if [ "$rc" -eq 0 ]; then
                token_line="coordinator_token: $rout"   # 兑换成功：写 host_token（不回显）
                say "invite redeemed → host_token written to host.yaml"
            else
                warn "invite redeem failed: $rout — writing invite_code; first start / doctor will retry"
                invite_line="invite_code: $INVITE_CODE"
            fi
        fi
    fi

    say "writing config → $CONFIG_PATH (platform=$platform)"
    if [ "$DRY_RUN" -eq 1 ]; then
        printf '  + write %s:\n' "$CONFIG_PATH"
        printf '      coordinator_url: %s\n      data_host: %s\n      data_port: %s\n      platform: %s\n      transport: iroh\n      iroh_register: ticket\n      %s\n      %s\n      %s\n' \
            "$COORD_URL" "$ip" "$DATA_PORT" "$platform" "$metro_line" "$token_line" "$invite_line"
        return
    fi
    mkdir -p "$AIBNB_HOME"
    cat > "$CONFIG_PATH" <<EOF
# AIBnB host config — generated by install.sh ($(date -u +%Y-%m-%dT%H:%M:%SZ))
coordinator_url: $COORD_URL
data_host: $ip           # advertise/bind addr for direct TCP peers (iroh ticket mode 另定)
data_port: $DATA_PORT
platform: $platform
transport: iroh           # B28/TD-08 公网默认：QUIC P2P + NAT 打洞。已知双方可直连
                           # （同内网/受控集群自测）时改回 tcp 逃生舱：transport: tcp
iroh_register: ticket      # D-38 产品默认：Register 携带 iroh 票据（peer_addr）——缺此行则
                           # 默认 tcp 双平面(M3 政策)，票据永不上报，公网 grant 无票据可用
$metro_line
$token_line
$invite_line
EOF
}

# ---- 步骤 5：doctor 预检 ----
run_doctor() {
    local bin; bin="$(resolve_host_bin)"
    say "running doctor pre-flight (device eligibility)"
    if [ "$DRY_RUN" -eq 1 ]; then
        printf '  + %s doctor --config %s\n' "$bin" "$CONFIG_PATH"
        return
    fi
    # doctor 硬门槛不达标返回非零：安装继续但显式告警（不静默）
    if "$bin" doctor --config "$CONFIG_PATH"; then
        say "doctor: ready"
    else
        warn "doctor reported hard-fails (see above). host may not qualify to serve; " \
             "fix the ❌ items then re-run. Continuing service install anyway."
    fi
}

print_manual_command() {
    local bin command
    bin="$(resolve_host_bin)"
    printf -v command 'AIBNB_HOME=%q HF_HOME=%q %q --config %q' \
        "$AIBNB_HOME" "$HF_HOME" "$bin" "$CONFIG_PATH"
    say "run manually: $command"
}

# ---- 步骤 6：渲染 + 安装服务单元 ----
sed_replacement() {
    printf '%s' "$1" | sed 's/[\\&|]/\\&/g'
}

service_value() {
    local kind="$1" value="$2"
    case "$value" in *[[:cntrl:]]*)
        printf '[aibnb ERROR] service path contains a control character\n' >&2
        return 2
        ;;
    esac
    if [ "$kind" = xml ]; then
        printf '%s' "$value" | sed \
            -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g'
    else
        printf '%s' "$value" | sed \
            -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/%/%%/g' -e 's/\$/$$/g'
    fi
}

render_template() {   # render_template SRC DEST
    local src="$1" dest="$2" bin path kind render_config render_home render_logs render_path render_hf
    bin="$(resolve_host_bin)"
    path="$VENV_DIR/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"   # /usr/sbin:metal 探针要 sysctl(2026-08-06 真机 TD-92 同病)
    say "rendering $(basename "$src") → $dest"
    if [ "$DRY_RUN" -eq 1 ]; then
        printf '  + render with AIBNB_HOST_BIN=%s CONFIG=%s LOG_DIR=%s\n' \
            "$bin" "$CONFIG_PATH" "$LOG_DIR"
        return
    fi
    mkdir -p "$(dirname "$dest")" "$LOG_DIR"
    case "$src" in *.plist.template) kind=xml ;; *) kind=systemd ;; esac
    bin="$(service_value "$kind" "$bin")"
    render_config="$(service_value "$kind" "$CONFIG_PATH")"
    render_home="$(service_value "$kind" "$AIBNB_HOME")"
    render_logs="$(service_value "$kind" "$LOG_DIR")"
    render_path="$(service_value "$kind" "$path")"
    render_hf="$(service_value "$kind" "$HF_HOME")"
    sed -e "s|@AIBNB_HOST_BIN@|$(sed_replacement "$bin")|g" \
        -e "s|@AIBNB_HOST_CONFIG@|$(sed_replacement "$render_config")|g" \
        -e "s|@AIBNB_HOME@|$(sed_replacement "$render_home")|g" \
        -e "s|@LOG_DIR@|$(sed_replacement "$render_logs")|g" \
        -e "s|@PATH@|$(sed_replacement "$render_path")|g" \
        -e "s|@HF_HOME@|$(sed_replacement "$render_hf")|g" \
        "$src" > "$dest"
}

install_launchd() {
    local service_dir="$AIBNB_HOST_ROOT/service"
    local plist="$service_dir/com.aibnb.host.plist"
    local helper="$service_dir/aibnb-host-service"
    local legacy="$HOME/Library/LaunchAgents/com.aibnb.host.plist"
    local target state
    local SERVICE_WAS_RUNNING=0 SERVICE_WAS_DISABLED=0
    target="gui/$(id -u)/com.aibnb.host"
    if launchctl print-disabled "gui/$(id -u)" 2>/dev/null \
        | grep -Eq '"?com\.aibnb\.host"?[[:space:]]*=>[[:space:]]*true'; then
        SERVICE_WAS_DISABLED=1
    fi
    if [ "$DRY_RUN" -eq 0 ] && state="$(launchctl print "$target" 2>/dev/null)"; then
        if printf '%s\n' "$state" | grep -Eq '^[[:space:]]*pid = [0-9]+|^[[:space:]]*last exit code = [1-9][0-9]*'; then
            SERVICE_WAS_RUNNING=1
        elif [ -f "$legacy" ]; then
            SERVICE_WAS_RUNNING=1
        fi
        launchctl bootout "$target" 2>/dev/null || true
    fi
    render_template "$TEMPLATE_DIR/com.aibnb.host.plist.template" "$plist"
    say "registering manual launchd definition (SuccessfulExit=false: exit 0 stops; nonzero including exit 17 relaunches after explicit start)"
    if [ "$DRY_RUN" -eq 1 ]; then
        printf '  + preserve existing running/stopped state; bootout only managed label if loaded\n'
        printf '  + install explicit helper → %s\n' "$helper"
        printf '  + rm -f %s  (legacy auto-scanned definition)\n' "$legacy"
        printf '  + fresh/stopped service remains stopped; explicit start: %s start (launchctl bootstrap + kickstart)\n' "$helper"
        return
    fi
    rm -f "$legacy"
    install -m 0755 "$TEMPLATE_DIR/aibnb-host-service" "$helper"
    if [ "$SERVICE_WAS_RUNNING" -eq 1 ]; then
        "$helper" start
        [ "$SERVICE_WAS_DISABLED" -eq 0 ] || launchctl disable "$target"
        say "running launchd service replaced in place (logs: $LOG_DIR/host.{out,err}.log)"
    else
        say "service remains stopped; start explicitly: $helper start"
    fi
}

systemd_user_ok() {
    command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1
}

install_systemd() {
    local unit="$HOME/.config/systemd/user/aibnb-host.service"
    local SERVICE_WAS_REGISTERED=0 SERVICE_WAS_ENABLED=0 SERVICE_WAS_RUNNING=0
    if [ "$DRY_RUN" -eq 0 ] && [ -e "$unit" ]; then
        SERVICE_WAS_REGISTERED=1
        systemctl --user is-enabled --quiet aibnb-host 2>/dev/null && SERVICE_WAS_ENABLED=1 || true
        systemctl --user is-active --quiet aibnb-host 2>/dev/null && SERVICE_WAS_RUNNING=1 || true
    fi
    render_template "$TEMPLATE_DIR/aibnb-host.service.template" "$unit"
    say "registering systemd user unit (Restart=on-failure; exit 0 stops, exit 17 relaunches)"
    if [ "$DRY_RUN" -eq 1 ]; then
        printf '  + preserve existing enabled/running state (systemctl is-enabled + is-active)\n'
        printf '  + systemctl --user daemon-reload\n'
        printf '  + fresh install remains disabled/stopped; existing enabled/running state is restored\n'
        return
    fi
    systemctl --user daemon-reload
    [ "$SERVICE_WAS_ENABLED" -eq 0 ] || systemctl --user enable aibnb-host >/dev/null
    if [ "$SERVICE_WAS_RUNNING" -eq 1 ]; then
        systemctl --user restart aibnb-host
        say "running systemd service replaced in place"
    elif [ "$SERVICE_WAS_REGISTERED" -eq 1 ]; then
        say "existing service remains stopped"
    else
        say "service registered disabled/stopped"
    fi
    say "start explicitly: systemctl --user start aibnb-host"
}

install_nohup() {
    local bin; bin="$(resolve_host_bin)"
    warn "no systemd — falling back to nohup. NO AUTOSTART: dies on reboot/logout." \
         "Fix by enabling systemd (see install/README.md) and re-running."
    say "starting via nohup (logs → $LOG_DIR/host.out.log)"
    if [ "$DRY_RUN" -eq 1 ]; then
        printf '  + nohup %s --config %s >> %s/host.out.log 2>&1 &\n' \
            "$bin" "$CONFIG_PATH" "$LOG_DIR"
        return
    fi
    mkdir -p "$LOG_DIR"
    nohup "$bin" --config "$CONFIG_PATH" >> "$LOG_DIR/host.out.log" 2>&1 &
    say "nohup pid: $! (record it; stop with: kill $!)"
}

install_service() {
    local os; os="$(detect_os)"
    if [ "$os" = "darwin" ]; then
        install_launchd
        return
    fi
    # linux / wsl2
    if [ "$NOHUP_FALLBACK" -eq 1 ]; then
        install_nohup
        return
    fi
    if systemd_user_ok; then
        install_systemd
    else
        if is_wsl; then
            warn "WSL2 without systemd. Enable it: add to /etc/wsl.conf:" \
                 "  [boot]\\n  systemd=true" "then (in Windows) run: wsl --shutdown," \
                 "reopen WSL, and re-run this installer. See install/README.md."
        else
            warn "systemd --user not available on this Linux."
        fi
        die "systemd user session unavailable. Re-run with --nohup to start without" \
            " autostart, or enable systemd first (recommended for a persistent host)."
    fi
}

# ---- 主流程 ----
main() {
    parse_args "$@"
    migrate_hf_cache
    say "AIBnB host installer (D-24 阶段① / B10)$([ "$DRY_RUN" -eq 1 ] && echo '  [DRY-RUN]')"
    say "home=$AIBNB_HOME  src=$SRC_DIR  config=$CONFIG_PATH"
    ensure_uv
    fetch_wrapper
    install_tool
    write_config
    run_doctor
    if [ "$WANT_SERVICE" -eq 1 ]; then
        install_service
    else
        say "--no-service: skipping service unit install"
        say "manual CLI pins HF_HOME to $HF_HOME before importing runtime dependencies"
        print_manual_command
    fi
    say "done.$([ "$DRY_RUN" -eq 1 ] && echo '  (dry-run — nothing changed)')"
}

# main-guard：被测试 source 时不执行 main（对纯函数单测；offline_install.sh 同款）
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
    main "$@"
fi
