#!/usr/bin/env bash
#
# AIBnB client 安装器（C7 / D-24 阶段①）。macOS + Linux（含 WSL2）。
#
#   分发形态（D-24 / TD-24）：pip 装不上（aibnb-proto 不在任何 index、pip 不认
#   [tool.uv.sources]），一律走 uv + "克隆 wrapper（含 submodules）到本地 → 从本地
#   路径装" —— client 依赖相对路径的 ../proto，裸 git 装必失败。
#
# 步骤：① 检测/装 uv → ② 克隆 wrapper（--recurse-submodules）到 ~/.aibnb/src、更新
# 子模块 → ③ 从本地路径 uv tool install（产出 aibnb-serve/aibnb-chat）→ ④ 交互生成
# client.yaml（coordinator/端口/可选 metro 覆盖，缺省 = C6 自动检测）→ ⑤ aibnb-serve
# doctor 预检 → ⑥ 注册服务单元（launchd / systemd；首次安装不启用、不启动）。
#
# 用法：
#   install.sh [选项]
# 选项：
#   --coordinator URL   coordinator 基址（非交互；缺省会交互询问）
#   --port N            serve 端口（默认 8080）
#   --metro CODE        metro 覆盖（缺省不写 = 交给 C6 GeoIP 自动检测）
#   --branch NAME       wrapper 分支（默认 main）
#   --src DIR           复用已有 wrapper 检出（跳过克隆；须含 client/ 与 proto/）
#   --prefix DIR        把 uv 工具装到临时前缀（本机验证用，不碰全局 ~/.local）
#   --no-service        跳过服务单元安装（只装 CLI + 配置）
#   --no-linger         兼容旧参数；D4-3 起安装器不再自动 enable-linger
#   --yes               非交互：所有询问取默认值
#   --dry-run           只打印将执行的动作，不真正执行（不触网、不装服务）
#   -h | --help         本帮助
set -euo pipefail

WRAPPER_REPO="${AIBNB_WRAPPER_REPO:-https://github.com/marcochen11/AIBnB_Wrapper.git}"
AIBNB_HOME="${AIBNB_HOME:-$HOME/.aibnb}"
SRC_DIR="$AIBNB_HOME/src"
CONFIG="$AIBNB_HOME/client.yaml"
LOG_DIR="$AIBNB_HOME/logs"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$HERE/lib_paths.sh"
CACHE_DIR="$AIBNB_HOME/cache"
HF_HOME="$CACHE_DIR/huggingface"
export HF_HOME

DRY_RUN=0
NO_SERVICE=0
NO_LINGER=0
ASSUME_YES=0
BRANCH="main"
COORDINATOR=""
PORT="8080"
METRO=""
SRC_OVERRIDE=""
PREFIX=""

log()  { printf '\033[1;34m[aibnb]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[aibnb] WARN:\033[0m %s\n' "$*" >&2; }
die()  { printf '\033[1;31m[aibnb] ERROR:\033[0m %s\n' "$*" >&2; exit 1; }

# run: 打印命令；非 dry-run 才执行。
run() {
  printf '  + %s\n' "$*"
  [ "$DRY_RUN" = "1" ] && return 0
  "$@"
}

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

parse_args() {
  while [ $# -gt 0 ]; do
    case "$1" in
      --coordinator) COORDINATOR="${2:-}"; shift 2;;
      --port)        PORT="${2:-}"; shift 2;;
      --metro)       METRO="${2:-}"; shift 2;;
      --branch)      BRANCH="${2:-}"; shift 2;;
      --src)         SRC_OVERRIDE="${2:-}"; shift 2;;
      --prefix)      PREFIX="${2:-}"; shift 2;;
      --no-service)  NO_SERVICE=1; shift;;
      --no-linger)   NO_LINGER=1; shift;;
      --yes)         ASSUME_YES=1; shift;;
      --dry-run)     DRY_RUN=1; shift;;
      -h|--help)     usage;;
      *) die "unknown option: $1 (see --help)";;
    esac
  done
}

detect_os() {
  case "$(uname -s)" in
    Darwin) OS="macos";;
    Linux)  OS="linux";;
    *) die "unsupported OS: $(uname -s) (only macOS + Linux/WSL2)";;
  esac
  IS_WSL=0
  if [ "$OS" = "linux" ] && grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null; then
    IS_WSL=1
  fi
  log "platform: $OS$( [ "$IS_WSL" = 1 ] && echo ' (WSL2)')"
}

ensure_uv() {
  if command -v uv >/dev/null 2>&1; then
    log "uv present: $(command -v uv)"
    return 0
  fi
  log "uv not found → installing via official bootstrap (astral.sh)"
  if [ "$DRY_RUN" = "1" ]; then
    printf '  + curl -LsSf https://astral.sh/uv/install.sh | sh\n'
    return 0
  fi
  curl -LsSf https://astral.sh/uv/install.sh | sh
  # uv 装到 ~/.local/bin 或 ~/.cargo/bin —— 补进本进程 PATH。
  export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
  command -v uv >/dev/null 2>&1 || die "uv install seemed to run but uv still not on PATH"
  log "uv installed: $(command -v uv)"
}

ensure_src() {
  if [ -n "$SRC_OVERRIDE" ]; then
    [ -d "$SRC_OVERRIDE/client" ] && [ -d "$SRC_OVERRIDE/proto" ] \
      || die "--src $SRC_OVERRIDE must contain both client/ and proto/"
    SRC_DIR="$(cd "$SRC_OVERRIDE" && pwd)"
    log "using existing wrapper checkout: $SRC_DIR"
    return 0
  fi
  run mkdir -p "$AIBNB_HOME"
  if [ -d "$SRC_DIR/.git" ]; then
    log "wrapper checkout exists → updating ($SRC_DIR)"
    run git -C "$SRC_DIR" fetch --prune origin
    run git -C "$SRC_DIR" checkout "$BRANCH"
    run git -C "$SRC_DIR" pull --ff-only origin "$BRANCH"
    run git -C "$SRC_DIR" submodule update --init --recursive
  else
    log "cloning wrapper (with submodules) → $SRC_DIR"
    run git clone --branch "$BRANCH" --recurse-submodules "$WRAPPER_REPO" "$SRC_DIR"
  fi
  # TD-24 前提校验：client 与 proto 必须同在（相对路径 ../proto 才解析得了）。
  if [ "$DRY_RUN" != "1" ]; then
    [ -d "$SRC_DIR/client" ] && [ -d "$SRC_DIR/proto" ] \
      || die "checkout missing client/ or proto/ (submodules not initialized?)"
  fi
}

install_client() {
  log "installing aibnb-client from local path (TD-24: uv resolves ../proto)"
  local env_prefix=""
  if [ -n "$PREFIX" ]; then
    # 临时前缀：本机验证时不污染全局工具目录。
    run mkdir -p "$PREFIX/tools" "$PREFIX/bin"
    export UV_TOOL_DIR="$PREFIX/tools"
    export UV_TOOL_BIN_DIR="$PREFIX/bin"
    env_prefix="UV_TOOL_DIR=$PREFIX/tools UV_TOOL_BIN_DIR=$PREFIX/bin "
    log "temp prefix: tools=$UV_TOOL_DIR bin=$UV_TOOL_BIN_DIR"
  fi
  # `uv tool install <client-dir>` 读 client/pyproject.toml 的 [tool.uv.sources]
  # aibnb-proto = { path = "../proto" } → 从 $SRC_DIR/proto 装协议包。--reinstall 支持重装/升级。
  printf '  + %suv tool install --reinstall %s\n' "$env_prefix" "$SRC_DIR/client"
  [ "$DRY_RUN" = "1" ] && return 0
  uv tool install --reinstall "$SRC_DIR/client"
  resolve_serve_bin
  log "installed: aibnb-serve → $AIBNB_SERVE_BIN"
}

resolve_serve_bin() {
  if [ -n "$PREFIX" ] && [ -x "$PREFIX/bin/aibnb-serve" ]; then
    AIBNB_SERVE_BIN="$PREFIX/bin/aibnb-serve"
  elif command -v aibnb-serve >/dev/null 2>&1; then
    AIBNB_SERVE_BIN="$(command -v aibnb-serve)"
  elif [ -x "$HOME/.local/bin/aibnb-serve" ]; then
    AIBNB_SERVE_BIN="$HOME/.local/bin/aibnb-serve"
  else
    AIBNB_SERVE_BIN="$HOME/.local/bin/aibnb-serve"  # 服务模板用的标称路径
  fi
}

ask() {  # ask VAR "prompt" "default"
  local __var="$1" __prompt="$2" __default="$3" __ans=""
  if [ "$ASSUME_YES" = "1" ] || [ "$DRY_RUN" = "1" ] || [ ! -t 0 ]; then
    eval "$__var=\"\$__default\""
    return 0
  fi
  printf '%s [%s]: ' "$__prompt" "$__default" >&2
  read -r __ans || __ans=""
  [ -z "$__ans" ] && __ans="$__default"
  eval "$__var=\"\$__ans\""
}

gen_config() {
  if [ -f "$CONFIG" ] && [ "$DRY_RUN" != "1" ]; then
    log "config exists, keeping it: $CONFIG (edit manually or rm to regenerate)"
    # 仍尽量读回 coordinator/port 供服务单元用。
    local c p
    c="$(sed -n 's/^coordinator_url:[[:space:]]*//p' "$CONFIG" 2>/dev/null | head -1)"
    p="$(sed -n 's/^serve_port:[[:space:]]*//p' "$CONFIG" 2>/dev/null | head -1)"
    [ -n "$c" ] && COORDINATOR="$c"
    [ -n "$p" ] && PORT="$p"
    return 0
  fi
  # 缺省留空（不再烤死假默认 127.0.0.1:8000——fresh 用户会"永远未连接"）：装完由应用 UI
  # 设置面板填协调器地址（写回 client.yaml 热生效）。serve 对空 coordinator 亦韧性起 UI。
  [ -z "$COORDINATOR" ] && ask COORDINATOR "coordinator URL (blank = set later in the app UI)" ""
  ask PORT  "serve port" "$PORT"
  ask METRO "metro override (blank = C6 auto-detect via GeoIP)" "$METRO"
  log "writing config → $CONFIG"
  run mkdir -p "$AIBNB_HOME"
  if [ "$DRY_RUN" = "1" ]; then
    printf '  + write %s (coordinator=%s port=%s metro=%s)\n' \
      "$CONFIG" "$COORDINATOR" "$PORT" "${METRO:-<auto>}"
    return 0
  fi
  {
    echo "# AIBnB client 配置（install.sh 生成）。键见 client/src/aibnb_client/config.py。"
    echo "coordinator_url: $COORDINATOR"
    echo "serve_port: $PORT"
    if [ -n "$METRO" ]; then
      echo "metro: $METRO   # 覆盖优先（D-23）"
    else
      echo "# metro: 未设 → C6 启动期 GeoIP 自动检测（失败即 UNKNOWN，client 侧无害 D-22）"
    fi
  } > "$CONFIG"
  # 注：coordinator_url 是**配置单一来源**（D-30）——服务单元只传 --config，aibnb-serve 从此键读 URL。
}

preflight() {
  log "preflight: aibnb-serve doctor"
  # 配置单一来源（D-30）：doctor 只传 --config，coordinator 由 doctor 从 coordinator_url 读。
  if [ "$DRY_RUN" = "1" ]; then
    printf '  + aibnb-serve doctor --config %s --port %s\n' "$CONFIG" "$PORT"
    return 0
  fi
  resolve_serve_bin
  if "$AIBNB_SERVE_BIN" doctor --config "$CONFIG" --port "$PORT"; then
    log "doctor: PASS"
  else
    die "doctor reported a hard failure (see above); fix and re-run (or --no-service to skip)"
  fi
}

render_template() {  # render_template SRC DST
  # 注：模板已不含 __COORDINATOR__（D-30 配置单一来源：URL 只住 client.yaml，单元只传 --config）。
  sed \
    -e "s#__AIBNB_SERVE__#${AIBNB_SERVE_BIN}#g" \
    -e "s#__CONFIG__#${CONFIG}#g" \
    -e "s#__PORT__#${PORT}#g" \
    -e "s#__LOG_DIR__#${LOG_DIR}#g" \
    -e "s#__PATH__#${SERVICE_PATH}#g" \
    -e "s#__HF_HOME__#${HF_HOME}#g" \
    "$1" > "$2"
}

_launchctl_wait_gone() {
  local target="$1" i=0
  while [ "$i" -lt 50 ]; do
    launchctl print "$target" >/dev/null 2>&1 || return 0
    i=$((i + 1))
    sleep 0.2
  done
  return 1
}

_launchctl_verify_job() {
  local target="$1" plist="$2" expected_bin="$3" state loaded_path loaded_program
  if ! state="$(launchctl print "$target" 2>/dev/null)"; then
    warn "launchd job is not loaded (target=${target})"
    return 1
  fi
  loaded_path="$(
    printf '%s\n' "$state" | sed -n 's/^[[:space:]]*path = //p' | head -n 1
  )"
  loaded_program="$(
    printf '%s\n' "$state" | sed -n 's/^[[:space:]]*program = //p' | head -n 1
  )"
  if [ "$loaded_path" != "$plist" ]; then
    warn "launchd job loaded the wrong plist (target=${target}, expected=${plist})"
    return 1
  fi
  if [ -z "$expected_bin" ] || [ "$loaded_program" != "$expected_bin" ]; then
    warn "launchd job has stale ProgramArguments (target=${target}, expected=${expected_bin:-?})"
    return 1
  fi
}

_launchctl_bootstrap_stopped() {
  local target="$1" uid="$2" plist="$3" backup quiet state
  local bootstrap_status=0 restore_status=0
  backup="$(mktemp "${plist}.active.XXXXXX")" || return 1
  quiet="$(mktemp "${plist}.quiet.XXXXXX")" || { rm -f "$backup"; return 1; }
  if ! cp -p "$plist" "$backup" || ! cp -p "$plist" "$quiet"; then
    rm -f "$backup" "$quiet"
    return 1
  fi
  /usr/libexec/PlistBuddy -c 'Delete :KeepAlive' "$quiet" >/dev/null 2>&1 || true
  /usr/libexec/PlistBuddy -c 'Delete :RunAtLoad' "$quiet" >/dev/null 2>&1 || true
  if /usr/libexec/PlistBuddy -c 'Print :KeepAlive' "$quiet" >/dev/null 2>&1 \
      || /usr/libexec/PlistBuddy -c 'Print :RunAtLoad' "$quiet" >/dev/null 2>&1; then
    rm -f "$backup" "$quiet"
    return 1
  fi
  if ! mv -f "$quiet" "$plist"; then
    rm -f "$backup" "$quiet"
    return 1
  fi
  launchctl bootstrap "gui/${uid}" "$plist" || bootstrap_status=$?
  mv -f "$backup" "$plist" || restore_status=$?
  [ "$bootstrap_status" = 0 ] || return "$bootstrap_status"
  [ "$restore_status" = 0 ] || return "$restore_status"
  state="$(launchctl print "$target" 2>/dev/null)" || return 1
  ! printf '%s\n' "$state" | grep -Eq '^[[:space:]]*pid = [0-9]+'
}

restore_launchd_state() {
  local target="$1" uid="$2" plist="$3" old_path="$4" old_backup="$5"
  local old_program="$6" was_loaded="$7" was_running="$8" was_disabled="$9"
  launchctl bootout "$target" >/dev/null 2>&1 || true
  _launchctl_wait_gone "$target" || return 1
  if [ -n "$old_backup" ]; then
    [ -n "$old_path" ] || return 1
    mkdir -p "$(dirname "$old_path")" || return 1
    cp -p "$old_backup" "$old_path" || return 1
  else
    rm -f "$plist" || return 1
  fi
  if [ -n "$old_path" ] && [ "$old_path" != "$plist" ]; then
    rm -f "$plist" || return 1
  fi
  if [ "$was_loaded" = "1" ]; then
    [ -n "$old_path" ] && [ -n "$old_program" ] || return 1
    launchctl enable "$target" >/dev/null 2>&1 || return 1
    if [ "$was_running" = "1" ]; then
      launchctl bootstrap "gui/${uid}" "$old_path" || return 1
    else
      _launchctl_bootstrap_stopped "$target" "$uid" "$old_path" || return 1
    fi
    _launchctl_verify_job "$target" "$old_path" "$old_program" || return 1
    if [ "$was_running" = "1" ]; then
      launchctl kickstart -k "$target" || return 1
    fi
  fi
  if [ "$was_disabled" = "1" ]; then
    launchctl disable "$target" || return 1
  else
    launchctl enable "$target" || return 1
  fi
}

install_service_macos() {
  local service_dir="$AIBNB_HOME/client/service"
  local plist="$service_dir/com.aibnb.client.plist"
  local helper="$service_dir/aibnb-client-service"
  local legacy="$HOME/Library/LaunchAgents/com.aibnb.client.plist"
  local uid target state
  local SERVICE_WAS_RUNNING=0 SERVICE_WAS_DISABLED=0
  uid="$(id -u)"
  target="gui/${uid}/com.aibnb.client"
  if launchctl print-disabled "gui/${uid}" 2>/dev/null \
      | grep -Eq '"?com\.aibnb\.client"?[[:space:]]*=>[[:space:]]*true'; then
    SERVICE_WAS_DISABLED=1
  fi
  log "installing manual launchd service definition → $plist"
  run mkdir -p "$service_dir" "$LOG_DIR"
  if [ "$DRY_RUN" = "1" ]; then
    printf '  + preserve existing running/stopped state; bootout only managed label if loaded\n'
    printf '  + render %s → %s (KeepAlive.SuccessfulExit=false)\n' "$HERE/templates/com.aibnb.client.plist" "$plist"
    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 0
  fi
  local SERVICE_WAS_LOADED=0 primary_status
  local SERVICE_OLD_PLIST_PATH="" SERVICE_OLD_PLIST_BACKUP="" SERVICE_OLD_PROGRAM=""
  if state="$(launchctl print "$target" 2>/dev/null)"; then
    SERVICE_WAS_LOADED=1
    SERVICE_OLD_PLIST_PATH="$(
      printf '%s\n' "$state" | sed -n 's/^[[:space:]]*path = //p' | head -n 1
    )"
    SERVICE_OLD_PROGRAM="$(
      printf '%s\n' "$state" | sed -n 's/^[[:space:]]*program = //p' | head -n 1
    )"
    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
      # The legacy KeepAlive=true definition represented an always-running user choice.
      SERVICE_WAS_RUNNING=1
    fi
  fi
  if [ -z "$SERVICE_OLD_PLIST_PATH" ]; then
    if [ -f "$plist" ]; then SERVICE_OLD_PLIST_PATH="$plist"
    elif [ -f "$legacy" ]; then SERVICE_OLD_PLIST_PATH="$legacy"
    fi
  fi
  if [ "$SERVICE_WAS_LOADED" = "1" ]; then
    [ -n "$SERVICE_OLD_PLIST_PATH" ] && [ -f "$SERVICE_OLD_PLIST_PATH" ] \
      || die "cannot capture loaded launchd plist before upgrade (target=${target})"
    if [ -z "$SERVICE_OLD_PROGRAM" ] && [ -x /usr/libexec/PlistBuddy ]; then
      SERVICE_OLD_PROGRAM="$(
        /usr/libexec/PlistBuddy -c 'Print :ProgramArguments:0' "$SERVICE_OLD_PLIST_PATH" 2>/dev/null || true
      )"
    fi
    [ -n "$SERVICE_OLD_PROGRAM" ] \
      || die "cannot capture launchd ProgramArguments before upgrade (target=${target})"
  fi
  if [ -n "$SERVICE_OLD_PLIST_PATH" ]; then
    SERVICE_OLD_PLIST_BACKUP="$(mktemp "${TMPDIR:-/tmp}/aibnb-client-plist.XXXXXX")"
    if ! cp -p "$SERVICE_OLD_PLIST_PATH" "$SERVICE_OLD_PLIST_BACKUP"; then
      rm -f "$SERVICE_OLD_PLIST_BACKUP"
      die "failed to snapshot launchd plist before upgrade: $SERVICE_OLD_PLIST_PATH"
    fi
  fi
  if (
    if [ "$SERVICE_WAS_LOADED" = "1" ]; then
      launchctl bootout "$target" 2>/dev/null || true
      _launchctl_wait_gone "$target" || \
        die "launchd service still loaded after bootout (target=${target}); refusing stale upgrade"
    fi
    rm -f "$legacy" || { primary_status=$?; warn "failed to remove legacy launchd definition: $legacy"; exit "$primary_status"; }
    render_template "$HERE/templates/com.aibnb.client.plist" "$plist" || {
      primary_status=$?; warn "failed to render launchd definition: $plist"; exit "$primary_status"
    }
    install -m 0755 "$HERE/templates/aibnb-client-service" "$helper" || {
      primary_status=$?; warn "failed to install launchd helper: $helper"; exit "$primary_status"
    }
    if [ "$SERVICE_WAS_RUNNING" = "1" ]; then
      "$helper" start || {
        primary_status=$?; warn "failed to restart launchd service during upgrade"; exit "$primary_status"
      }
      _launchctl_verify_job "$target" "$plist" "$AIBNB_SERVE_BIN" || {
        primary_status=$?; warn "failed to verify replacement launchd service"; exit "$primary_status"
      }
      [ "$SERVICE_WAS_DISABLED" = "0" ] || launchctl disable "$target" || {
        primary_status=$?; warn "failed to restore disabled launchd choice"; exit "$primary_status"
      }
    elif [ "$SERVICE_WAS_LOADED" = "1" ]; then
      "$helper" load-stopped || exit $?
      _launchctl_verify_job "$target" "$plist" "$AIBNB_SERVE_BIN" || exit $?
      [ "$SERVICE_WAS_DISABLED" = "0" ] || launchctl disable "$target" || exit $?
    fi
  ); then
    [ -z "$SERVICE_OLD_PLIST_BACKUP" ] || rm -f "$SERVICE_OLD_PLIST_BACKUP"
  else
    primary_status=$?
    if restore_launchd_state \
        "$target" "$uid" "$plist" "$SERVICE_OLD_PLIST_PATH" "$SERVICE_OLD_PLIST_BACKUP" \
        "$SERVICE_OLD_PROGRAM" "$SERVICE_WAS_LOADED" "$SERVICE_WAS_RUNNING" \
        "$SERVICE_WAS_DISABLED"; then
      [ -z "$SERVICE_OLD_PLIST_BACKUP" ] || rm -f "$SERVICE_OLD_PLIST_BACKUP"
      warn "launchd upgrade failed; original state restored; primary failure preserved"
    else
      warn "launchd rollback also failed; primary failure preserved (target=${target})"
    fi
    return "$primary_status"
  fi
  if [ "$SERVICE_WAS_RUNNING" = "1" ]; then
    log "running service replaced in place; logs at $LOG_DIR/serve.{out,err}.log"
  else
    log "service remains stopped; start explicitly: $helper start"
  fi
}

restore_systemd_state() {
  local unit="$1" was_registered="$2" was_enabled="$3" was_running="$4" old_backup="$5"
  if [ "$was_registered" = "0" ]; then
    systemctl --user stop aibnb-client >/dev/null 2>&1 || true
    rm -f "$unit" || return 1
    systemctl --user daemon-reload || return 1
    [ ! -e "$unit" ] || return 1
    ! systemctl --user is-enabled --quiet aibnb-client 2>/dev/null || return 1
    ! systemctl --user is-active --quiet aibnb-client 2>/dev/null || return 1
    return 0
  fi
  local restore_tmp
  [ -n "$old_backup" ] && [ -f "$old_backup" ] || return 1
  restore_tmp="$(mktemp "${unit}.rollback.XXXXXX")" || return 1
  if ! cp -p "$old_backup" "$restore_tmp" || ! mv -f "$restore_tmp" "$unit"; then
    rm -f "$restore_tmp"
    return 1
  fi
  systemctl --user daemon-reload || return 1
  if [ "$was_running" = "1" ]; then
    systemctl --user enable aibnb-client >/dev/null || return 1
    systemctl --user start aibnb-client || return 1
    [ "$was_enabled" = "1" ] || systemctl --user disable aibnb-client >/dev/null || return 1
  else
    systemctl --user stop aibnb-client >/dev/null 2>&1 || true
    if [ "$was_enabled" = "1" ]; then
      systemctl --user enable aibnb-client >/dev/null || return 1
    elif [ "$was_registered" = "1" ]; then
      systemctl --user disable aibnb-client >/dev/null || return 1
    fi
  fi
}

render_systemd_unit_atomic() {
  local src="$1" unit="$2" rendered status
  rendered="$(mktemp "${unit}.new.XXXXXX")" || return 1
  render_template "$src" "$rendered" || {
    status=$?; rm -f "$rendered"; return "$status"
  }
  chmod 0644 "$rendered" || { status=$?; rm -f "$rendered"; return "$status"; }
  mv -f "$rendered" "$unit" || { status=$?; rm -f "$rendered"; return "$status"; }
}

install_service_linux() {
  local unit="$HOME/.config/systemd/user/aibnb-client.service"
  local SERVICE_WAS_REGISTERED=0 SERVICE_WAS_ENABLED=0 SERVICE_WAS_RUNNING=0
  if ! command -v systemctl >/dev/null 2>&1; then
    warn "systemctl not available$( [ "$IS_WSL" = 1 ] && echo ' (WSL: enable systemd in /etc/wsl.conf → [boot] systemd=true, then wsl --shutdown)')"
    warn "skipping service unit; run manually: aibnb-serve --config $CONFIG --port $PORT"
    return 0
  fi
  log "installing systemd user unit → $unit"
  run mkdir -p "$HOME/.config/systemd/user" "$LOG_DIR"
  if [ "$DRY_RUN" = "1" ]; then
    printf '  + preserve existing enabled/running state (systemctl is-enabled + is-active)\n'
    printf '  + render %s → %s (Restart=on-failure RestartSec=5)\n' "$HERE/templates/aibnb-client.service" "$unit"
    printf '  + systemctl --user daemon-reload\n'
    printf '  + fresh install remains disabled/stopped; existing enabled/running state is restored\n'
    return 0
  fi
  if [ -e "$unit" ]; then
    SERVICE_WAS_REGISTERED=1
    systemctl --user is-enabled --quiet aibnb-client 2>/dev/null && SERVICE_WAS_ENABLED=1 || true
    systemctl --user is-active --quiet aibnb-client 2>/dev/null && SERVICE_WAS_RUNNING=1 || true
  fi
  local primary_status SERVICE_OLD_UNIT_BACKUP=""
  if [ "$SERVICE_WAS_REGISTERED" = "1" ]; then
    SERVICE_OLD_UNIT_BACKUP="$(mktemp "${TMPDIR:-/tmp}/aibnb-client-unit.XXXXXX")"
    if ! cp -p "$unit" "$SERVICE_OLD_UNIT_BACKUP"; then
      rm -f "$SERVICE_OLD_UNIT_BACKUP"
      die "failed to snapshot systemd user unit before upgrade: $unit"
    fi
  fi
  if (
    render_systemd_unit_atomic "$HERE/templates/aibnb-client.service" "$unit" || {
      primary_status=$?; warn "failed to render systemd user unit: $unit"; exit "$primary_status"
    }
    systemctl --user daemon-reload || {
      primary_status=$?; warn "systemd daemon-reload failed"; exit "$primary_status"
    }
    if [ "$SERVICE_WAS_ENABLED" = "1" ]; then
      systemctl --user enable aibnb-client >/dev/null || {
        primary_status=$?; warn "failed to restore enabled state"; exit "$primary_status"
      }
    fi
    if [ "$SERVICE_WAS_RUNNING" = "1" ]; then
      systemctl --user restart aibnb-client || {
        primary_status=$?; warn "failed to restart running service"; exit "$primary_status"
      }
    fi
  ); then
    [ -z "$SERVICE_OLD_UNIT_BACKUP" ] || rm -f "$SERVICE_OLD_UNIT_BACKUP"
  else
    primary_status=$?
    if restore_systemd_state \
        "$unit" "$SERVICE_WAS_REGISTERED" "$SERVICE_WAS_ENABLED" "$SERVICE_WAS_RUNNING" \
        "$SERVICE_OLD_UNIT_BACKUP"; then
      warn "systemd upgrade failed; original state restored; primary failure preserved"
    else
      warn "systemd rollback also failed; primary failure preserved (aibnb-client)"
    fi
    [ -z "$SERVICE_OLD_UNIT_BACKUP" ] || rm -f "$SERVICE_OLD_UNIT_BACKUP"
    return "$primary_status"
  fi
  if [ "$SERVICE_WAS_RUNNING" = "1" ]; then
    log "running service replaced in place"
  elif [ "$SERVICE_WAS_REGISTERED" = "1" ]; then
    log "existing service remains stopped"
  else
    log "service registered disabled/stopped"
  fi
  log "start explicitly: systemctl --user start aibnb-client"
}

install_service() {
  [ "$NO_SERVICE" = "1" ] && { log "--no-service: skipping service unit"; return 0; }
  # 服务单元里 PATH 需含 uv 装的 bin 目录（aibnb-serve 所在）。
  SERVICE_PATH="$HOME/.local/bin:$HOME/.cargo/bin:/usr/local/bin:/usr/bin:/bin"
  resolve_serve_bin
  if [ "$OS" = "macos" ]; then install_service_macos; else install_service_linux; fi
}

main() {
  parse_args "$@"
  [ "$DRY_RUN" = "1" ] && log "DRY-RUN: no side effects (no network, no service install)"
  detect_os
  aibnb_migrate_hf_cache "$HOME/.cache/huggingface" "$HF_HOME" "$DRY_RUN"
  ensure_uv
  ensure_src
  install_client
  gen_config
  preflight
  install_service
  log "done. serve endpoint: http://127.0.0.1:$PORT/ui  (loopback only — remote via SSH tunnel)"
  if [ "$NO_SERVICE" = "1" ]; then
    log "start manually: aibnb-serve --config $CONFIG --port $PORT"
  fi
}

if [ "${BASH_SOURCE[0]}" = "$0" ]; then
  main "$@"
fi
