#!/usr/bin/env bash
# ZML Trade 一键安装 / ZML Trade one-click installer
#
# 用法 / Usage:
#   curl -fsSL https://api.zmarketlab.com/install.sh | bash
#   bash install.sh                    # 直接运行即可，无需授权码 / no license key needed
#   bash install.sh --reset-admin      # 忘记密码时重置管理员 / reset admin
#
# 设计原则：
# 1. 幂等可续跑——已部署时不重新生成任何密钥、不覆盖数据卷。
# 2. 预检先行——把会导致"装到一半失败"的条件全部前置检查，给出可执行的下一步。
# 3. 不伪造进度——每一步只在真的成功后才打勾。
set -euo pipefail

ZBOT_PROJECT_DIR="${ZBOT_PROJECT_DIR:-/opt/zbot}"
LANG_CHOICE=""
ACTION="install"
BASE_URL="${ZBOT_BASE_URL:-https://api.zmarketlab.com}"
SKIP_NTP_CHECK=0

# 内存下限按**生产实证**定，不按感觉定。
#
# 原来是 3500 MB，而我们自己的三台机器（含两台跑实盘的）全是 2 GB 规格、
# 系统报 1907 MB，长期稳定运行全栈五个容器，常驻约 1260 MB、余量约 640 MB。
# 也就是说那道门槛会把**我们自己的生产配置**挡在外面——每一个能像我们一样
# 跑起来的客户都装不上。这是真跑一遍客户安装命令才发现的：此前的部署路径
# （testserver-deploy.sh）根本不做预检，于是这个矛盾一直没人撞见。
#
# 1800 的取法：2 GB 机器扣掉内核保留后报 1907，要能通过；1 GB 机器报约 950，
# 必须挡住。门槛的职责是拦住装不动的机器，不是替客户做选型。
MIN_MEMORY_MB=1800
MIN_DISK_GB=20
REQUIRED_PORTS=(443)

# ── 双语文案表：单一来源，不写两份脚本 ────────────────────────────────────────
msg() {
    local key="$1"; shift
    local zh en
    case "$key" in
    choose_lang)      zh="请选择语言 / Choose language:"; en="请选择语言 / Choose language:" ;;
    need_root)        zh="需要 root 权限运行（请用 sudo）"; en="Root privileges required (use sudo)" ;;
    need_x86)         zh="当前仅支持 x86_64 架构，检测到：$1"; en="Only x86_64 is supported, detected: $1" ;;
    checking)         zh="正在检查运行环境…"; en="Checking environment..." ;;
    docker_missing)   zh="未检测到 Docker。安装脚本可以自动安装它，或你也可以先自行安装后重跑本脚本。"
                      en="Docker not found. This installer can install it for you, or install it yourself and re-run." ;;
    docker_install_q) zh="现在自动安装 Docker？[Y/n] "; en="Install Docker now? [Y/n] " ;;
    docker_ok)        zh="Docker 就绪"; en="Docker ready" ;;
    docker_needs_consent) zh="未检测到 Docker，且当前不是交互式终端，因此不做静默安装。
  请先自行安装 Docker，或在确认同意后设置 ZBOT_INSTALL_DOCKER=1 重跑本脚本。"
                      en="Docker is missing and this is not an interactive terminal, so nothing was installed.
  Install Docker yourself, or re-run with ZBOT_INSTALL_DOCKER=1 to consent." ;;
    compose_missing)  zh="Docker Compose 插件缺失，请安装 docker-compose-plugin 后重试"
                      en="Docker Compose plugin missing; install docker-compose-plugin and retry" ;;
    port_busy)        zh="端口 $1 已被占用。请释放该端口，或停止占用它的服务后重跑。"
                      en="Port $1 is already in use. Free it or stop the conflicting service, then re-run." ;;
    low_memory)       zh="可用内存不足：检测到 $1 MB，至少需要 ${MIN_MEMORY_MB} MB"
                      en="Insufficient memory: found $1 MB, need at least ${MIN_MEMORY_MB} MB" ;;
    low_disk)         zh="磁盘空间不足：检测到 $1 GB 可用，至少需要 ${MIN_DISK_GB} GB"
                      en="Insufficient disk: found $1 GB free, need at least ${MIN_DISK_GB} GB" ;;
    ntp_bad)          zh="系统时间未与网络同步。交易所会因时间偏差拒绝签名请求，必须先修复。
  修复方法： timedatectl set-ntp true
  确认后重跑本脚本；如确知无误可加 --skip-ntp-check 跳过。"
                      en="System clock is not NTP-synchronised. Exchanges reject signed requests on clock skew.
  Fix with: timedatectl set-ntp true
  Then re-run; use --skip-ntp-check to override if you are sure." ;;
    ntp_ok)           zh="系统时间已同步"; en="Clock synchronised" ;;
    precheck_ok)      zh="环境检查全部通过"; en="All environment checks passed" ;;
    downloading)      zh="正在下载系统镜像（约 300 MB，取决于网络可能需要几分钟）…"
                      en="Downloading system image (~300 MB, may take a few minutes)..." ;;
    verify_sig)       zh="正在校验签名与完整性…"; en="Verifying signature and integrity..." ;;
    loading)          zh="正在载入镜像…"; en="Loading images..." ;;
    download_failed)  zh="下载安装内容失败。请检查网络是否能访问 zmarketlab.com，然后重跑本脚本——已下载的部分会跳过，不会从头再来。"
                      en="Download failed. Check that this machine can reach zmarketlab.com, then re-run; completed steps are skipped." ;;
    bad_signature)    zh="发布内容的签名校验未通过，安装已停止。这说明下载到的内容不是我们签发的版本，请勿继续，并联系我们。"
                      en="Release signature verification failed; installation stopped. The downloaded content is not signed by us. Do not proceed—please contact us." ;;
    load_failed)      zh="载入镜像失败。通常是磁盘空间不足，请清理后重跑本脚本。"
                      en="Loading images failed, usually due to insufficient disk space. Free up space and re-run." ;;
    image_identity)   zh="镜像身份与签名清单不一致，安装已停止。请勿继续使用这份下载内容，并联系我们。"
                      en="Image identity does not match the signed manifest; installation stopped. Do not use this download—please contact us." ;;
    starting_db)      zh="正在启动数据库…"; en="Starting database..." ;;
    bootstrapping)    zh="正在准备数据库…"; en="Preparing the database..." ;;
    bootstrap_failed) zh="数据库准备失败，已中止安装（未启动系统）。请重试；已完成的步骤会跳过。"
                      en="Database preparation failed; installation stopped before starting the system. Re-run; completed steps are skipped." ;;
    starting_all)     zh="正在启动系统…"; en="Starting system..." ;;
    waiting_health)   zh="正在等待系统就绪…"; en="Waiting for the system to become ready..." ;;
    install_done)     zh="安装完成"; en="Installation complete" ;;
    install_unhealthy) zh="系统在 240 秒内没有变健康。上面的访问地址仍然有效，请先按下一条提示放行 TCP 443；
  若端口已放行仍打不开，把 docker compose -f /opt/zbot/compose.yaml logs --tail 50 的输出发给我们。"
                      en="The system did not become healthy within 240s. The URL above is still valid—first allow TCP 443 as noted below.
  If the port is open and it still fails, send us the output of: docker compose -f /opt/zbot/compose.yaml logs --tail 50" ;;
    env_lost_volume_kept) zh="检测到数据卷仍在，但配置文件 .env 已丢失。
  直接重装会生成新的数据库口令，而卷里的数据库仍认旧口令，必定启动失败。
  请二选一：
    1) 保留数据：找回原来的 /opt/zbot/.env 再重跑本脚本；
    2) 不要数据（会清空全部交易记录）：先执行
       docker volume rm zbot_pg-data zbot_zbot-data zbot_webdist zbot_caddy-data zbot_agent-state zbot_backups
       再重跑本脚本。"
                      en="Data volumes exist but the .env config file is gone.
  A fresh install would generate a new database password while the volume still expects the old one, so startup would fail.
  Choose one:
    1) Keep data: restore the original /opt/zbot/.env and re-run;
    2) Discard data (erases all trading records): run
       docker volume rm zbot_pg-data zbot_zbot-data zbot_webdist zbot_caddy-data zbot_agent-state zbot_backups
       then re-run." ;;
    reset_failed)     zh="管理员重置失败。请确认数据库容器在运行（docker compose -f /opt/zbot/compose.yaml ps），然后重试。"
                      en="Administrator reset failed. Check that the database container is running (docker compose -f /opt/zbot/compose.yaml ps) and retry." ;;
    reset_done)       zh="已退回未注册状态。请打开访问地址重新注册管理员；交易所密钥与历史数据均已保留。"
                      en="Returned to the unregistered state. Open the URL to register an administrator again; exchange keys and historical data are preserved." ;;
    already_installed) zh="检测到已有部署，本次不重新生成任何密钥，只做版本与健康核对。"
                      en="Existing deployment detected; no secrets regenerated, only version and health checks." ;;
    entry_header)     zh="访问入口"; en="Access" ;;
    creds_url)        zh="访问地址"; en="URL" ;;
    register_hint)    zh="打开上面的地址，按提示注册管理员用户名和密码——它们只保存在你自己的服务器上。"
                      en="Open the URL above and register your administrator username and password. They are stored only on your own server." ;;
    # 预检只能确认端口在**本机**空闲，确认不了云安全组是否放行——
    # 于是会出现"安装全绿却打不开网页"，而这是客户最难自查的一类问题：
    # 机器上一切正常，浏览器只有超时。一行提示省掉大量工单。
    firewall_hint)    zh="打不开？多数云主机默认拦截入站连接，请在云控制台的安全组/防火墙里放行 TCP 443（只需这一个端口）。"
                      en="Cannot open it? Most cloud hosts block inbound traffic by default—allow TCP 443 in your security group or firewall (this is the only port needed)." ;;
    esac
    if [ "$LANG_CHOICE" = "en" ]; then printf '%s\n' "$en"; else printf '%s\n' "$zh"; fi
}

step()  { printf '  \033[0;34m•\033[0m %s\n' "$(msg "$@")"; }
ok()    { printf '  \033[0;32m✓\033[0m %s\n' "$(msg "$@")"; }
fail()  { printf '\n  \033[0;31m✗\033[0m %s\n\n' "$(msg "$@")" >&2; exit 1; }

detect_language() {
    [ -n "$LANG_CHOICE" ] && return 0
    case "${LANG:-}${LC_ALL:-}" in
        zh*|*zh_CN*|*zh_TW*) LANG_CHOICE="zh" ;;
        *) LANG_CHOICE="en" ;;
    esac
    # 交互式终端里让用户确认，避免容器/远程环境的 LANG 误判。
    if [ -t 0 ]; then
        printf '%s\n' "$(msg choose_lang)"
        printf '    1) 中文    2) English\n'
        printf '  [1/2]: '
        read -r choice || true
        case "$choice" in 2|e|E|en|English) LANG_CHOICE="en" ;; 1|c|C|zh|中文) LANG_CHOICE="zh" ;; esac
    fi
}

parse_args() {
    while [ $# -gt 0 ]; do
        case "$1" in
            # 未知参数一律静默忽略（末尾的 *）。授权码那两个分支因此不必特判：
            # 它们本是给"照旧文档粘贴命令"的客户留的兼容垫片，而垫片自己有 bug——
            # `--license` 作末位参数时 `shift 2` 在 set -euo pipefail 下返回非零，
            # 恰恰在它要防止的那一刻杀死安装器。删掉之后由 * 分支接住，行为更对。
            --lang) LANG_CHOICE="${2:-}"; shift 2 ;;
            --lang=*) LANG_CHOICE="${1#*=}"; shift ;;
            --reset-admin) ACTION="reset-admin"; shift ;;
            --skip-ntp-check) SKIP_NTP_CHECK=1; shift ;;
            --dir) ZBOT_PROJECT_DIR="${2:-}"; shift 2 ;;
            *) shift ;;
        esac
    done
    case "$LANG_CHOICE" in zh|en) ;; *) LANG_CHOICE="" ;; esac
}

# ── 安装 Docker：只在真正执行安装时发生，且非交互环境必须显式同意 ────────────
ensure_docker() {
    command -v docker >/dev/null 2>&1 && return 0
    printf '  %s\n' "$(msg docker_missing)"
    if [ -t 0 ]; then
        printf '  %s' "$(msg docker_install_q)"
        read -r answer || true
        case "$answer" in [nN]*) exit 1 ;; esac
    elif [ "${ZBOT_INSTALL_DOCKER:-0}" != "1" ]; then
        # 非交互环境（CI、管道、自动化）不做静默系统改动：
        # 要么先自行安装 Docker，要么显式设置 ZBOT_INSTALL_DOCKER=1 表示同意。
        fail docker_needs_consent
    fi
    curl -fsSL https://get.docker.com | sh
}

# ── 预检：所有会导致"装到一半失败"的条件都必须前置暴露 ──────────────────────
precheck() {
    step checking

    [ "$(id -u)" = "0" ] || fail need_root
    local arch; arch="$(uname -m)"
    [ "$arch" = "x86_64" ] || fail need_x86 "$arch"

    # 预检只读：绝不安装、启动或修改任何东西。缺依赖时报告可执行的下一步即可。
    # Docker 的安装属于安装流程，由 ensure_docker 在预检之前显式完成。
    command -v docker >/dev/null 2>&1 || fail docker_missing
    docker compose version >/dev/null 2>&1 || fail compose_missing
    ok docker_ok

    local port
    for port in "${REQUIRED_PORTS[@]}"; do
        if ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${port}\$"; then
            # 已是本部署占用则不算冲突（幂等重跑）。
            #
            # 判据取自 `docker ps` 而不是 compose：预检跑在 cd 之前，工作目录是
            # 客户粘贴命令的任意位置，而 compose 默认**从当前工作目录**发现配置——
            # 这正是本文件 compose() 包装器写明"必须显式 -f"的原因。此前这里是
            # 全文件唯一漏掉 -f 的调用，于是空工程成功返回、grep 不到 caddy，
            # 幂等重跑在一台健康机器上必然死于"端口已被占用"，与它自称的意图相反。
            if ! docker ps --filter "name=zbot-caddy" --filter "status=running" \
                 --format '{{.Names}}' 2>/dev/null | grep -q .; then
                fail port_busy "$port"
            fi
        fi
    done

    local mem_mb; mem_mb=$(( $(awk '/MemTotal/ {print $2}' /proc/meminfo) / 1024 ))
    [ "$mem_mb" -ge "$MIN_MEMORY_MB" ] || fail low_memory "$mem_mb"

    local disk_gb; disk_gb=$(df -BG --output=avail "$(dirname "$ZBOT_PROJECT_DIR")" 2>/dev/null | tail -1 | tr -dc '0-9')
    [ -n "$disk_gb" ] && [ "$disk_gb" -ge "$MIN_DISK_GB" ] || fail low_disk "${disk_gb:-0}"

    # 时钟同步：偏差会让交易所拒绝签名请求，是最隐蔽的一类故障。
    if [ "$SKIP_NTP_CHECK" = "0" ]; then
        local synced=""
        if command -v timedatectl >/dev/null 2>&1; then
            synced="$(timedatectl show -p NTPSynchronized --value 2>/dev/null || true)"
        fi
        if [ "$synced" != "yes" ]; then
            fail ntp_bad
        fi
        ok ntp_ok
    fi

    ok precheck_ok
}

main() {
    parse_args "$@"
    detect_language

    ensure_docker
    precheck

    if [ "$ACTION" = "reset-admin" ]; then
        cd "$ZBOT_PROJECT_DIR"
        # 忘记密码 = 把这台机器退回**未注册**，客户像第一次那样自己重新注册。
        #
        # 这里不再生成、不再打印、不再落盘任何口令。旧做法是"重设一个新口令并
        # 交付给客户"，那条链在本仓库失败过四次，每次都在同一个位置：生成了、
        # 打印了、落盘了，却没能真正生效,而每一层都报成功。删掉"交付口令"
        # 这件事,那一整类失败就不存在了。
        #
        # 走**本机 root-only Unix socket**（LocalAdminTransport）：能 docker exec
        # 进这个容器的人就是宿主 root，也就是这台机器的持有者。它由运行中的进程
        # 自己处理，库与内存在同一个动作里一起变——旧做法是外部进程直接写库，
        # 运行中的后端拿着内存里的旧哈希继续比对，于是重置"成功"了客户却登不进去。
        # 因此这里也**不需要重启**。
        #
        # 二进制走**绝对路径**：容器 PATH 里没有 `zbot`（entrypoint 用的就是
        # `/opt/zbot/zbot`）。写成裸 `zbot` 会得到 `executable file not found`，
        # 而这条路径平时没人走，错了也要等到某个客户忘记密码那天才暴露。
        local reset_result
        reset_result="$(compose exec -T zbot /opt/zbot/zbot local post /api/auth/local-recovery/reset 2>/dev/null)" \
            || fail reset_failed
        # 判据必须容忍空白：本机 CLI 输出的是**格式化** JSON（`"ok": true`，冒号后
        # 有空格），而 HTTP 直出的是紧凑 JSON。写死 `"ok":true` 只匹配后者——
        # 于是重置明明成功了，安装器却报失败。同文件里其它几处判据早就是容错写法，
        # 是我这一处写严了。真机跑一次才暴露：接口返回什么形状，要看，不要猜。
        printf '%s' "$reset_result" | grep -Eq '"ok"[[:space:]]*:[[:space:]]*true' \
            || fail reset_failed
        # 重置**不重启任何东西**（进程内完成），因此这里不能沿用 STACK_HEALTHY 的
        # 默认值 0 ——那会让 print_entry 打出一句「系统未在 240 秒内变健康」的假警报，
        # 而系统明明一直在跑。真机上就是这样：重置成功、机器正常，客户却收到一条
        # 让他去查日志的告警。现探一次，秒级返回。
        wait_backend_healthy 30
        printf '\n  %s\n\n' "$(msg reset_done)"
        print_entry
        exit 0
    fi

    # 这里曾经**强制**客户输入 ZBOT-XXXXX-XXXXX-XXXXX-XXXXX 才肯安装，
    # 而授权码机制早已被产品废弃：licenses 表已删，身份主键改为机器指纹，
    # `/v1/activate` 根本不发送这个码，全仓也没有任何一处读取它。
    #
    # 也就是说，它拦下了每一位客户，换来的产品价值是零——纯粹的摩擦与客服工单。
    # 装机不需要任何码：机器用指纹注册，随即出现在运营后台的客户实例列表里，
    # 等级由我们在后台设定。客户少一步，我们多一个统一入口。
    mkdir -p "$ZBOT_PROJECT_DIR"
    cd "$ZBOT_PROJECT_DIR"

    if [ -f "$ZBOT_PROJECT_DIR/.env" ]; then
        printf '  %s\n' "$(msg already_installed)"
    else
        write_env_file
    fi
    generate_fallback_certificate
    load_images
    start_database
    prepare_database
    start_stack
    cap_host_logs
    print_entry
}

# ── 安装步骤 ──────────────────────────────────────────────────────────────
# 顺序由真实约束决定，不是随手排的：
#   1) 数据库先起——迁移要它；
#   2) `zbot migrate` 建好全部表结构；
#   3) 全栈最后起。未注册是**合法运行状态**（后端照常起、照常提供网页），
#      因此这里不再有"先造管理员再 up"那一步——管理员由客户打开网页自己注册。
compose() {
    local observation_docker_root
    observation_docker_root="$(docker info --format '{{.DockerRootDir}}')" || return 1
    [ -d "$observation_docker_root/containers" ] || return 1
    export ZBOT_DOCKER_ROOT_DIR="$observation_docker_root"

    # 必须显式 -f：compose 默认从**当前工作目录**发现配置文件，只给
    # --project-directory 时它可能找不到配置却仍以空工程成功返回。
    docker compose \
        --project-directory "$ZBOT_PROJECT_DIR" \
        -f "$ZBOT_PROJECT_DIR/compose.yaml" \
        "$@"
}

write_env_file() {
    # 数据卷还在、而 .env 没了 —— 这是个**必然失败且毫无指引**的组合。
    #
    # 客户手工 `rm -rf /opt/zbot` 后重跑安装器就会撞上：这里会生成一个全新的
    # postgres 口令写进新 .env，而 pg-data 卷里的库仍然认旧口令，于是 migrate
    # 死在 `password authentication failed for user "zbot"`——一句底层报错，
    # 客户既看不懂也不知道下一步。真机演练撞到过。
    #
    # 在这里拦住，并把两条路都说清楚：保数据就找回 .env，不要数据就连卷一起删。
    if [ ! -f "$ZBOT_PROJECT_DIR/.env" ] \
       && docker volume inspect zbot_pg-data >/dev/null 2>&1; then
        fail env_lost_volume_kept
    fi
    local pg_pass jwt
    pg_pass="$(openssl rand -hex 24)"
    jwt="$(openssl rand -hex 32)"
    umask 077
    cat > "$ZBOT_PROJECT_DIR/.env" <<ENVEOF
POSTGRES_DB=zbot
POSTGRES_USER=zbot
POSTGRES_PASSWORD=${pg_pass}
DATABASE_URL=postgres://zbot:${pg_pass}@postgres:5432/zbot
ZBOT_JWT_SECRET=${jwt}
ZBOT_EDITION=user
LISTEN_ADDR=0.0.0.0
LISTEN_PORT=8000
ZBOT_ROOT=/opt/zbot
ZBOT_PROJECT_DIR=${ZBOT_PROJECT_DIR}
ZBOT_VENDOR_API_BASE=${BASE_URL}
ENVEOF
    chmod 600 "$ZBOT_PROJECT_DIR/.env"
}

generate_fallback_certificate() {
    local dir="$ZBOT_PROJECT_DIR/certs/local"
    # 判据是"成对且匹配"，不是"cert 文件存在"：重签流程先写 key 后写 cert，
    # 中间被杀（断电/OOM）会留下新 key + 旧 cert 的半态——caddy 起不来，
    # 而只看 fallback.pem 的判据会认为无需修复。
    if [ -f "$dir/fallback.pem" ] && [ -f "$dir/fallback-key.pem" ] && \
       [ "$(openssl x509 -noout -modulus -in "$dir/fallback.pem" 2>/dev/null)" = \
         "$(openssl rsa  -noout -modulus -in "$dir/fallback-key.pem" 2>/dev/null)" ]; then
        return 0
    fi
    mkdir -p "$dir"
    local ip
    ip="$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}')"
    # 十年自签：兜底通道不依赖任何外部签发方，因此也永不会因续期失败而失联。
    openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \
        -keyout "$dir/fallback-key.pem" -out "$dir/fallback.pem" \
        -subj "/CN=zbot-local" \
        -addext "subjectAltName=IP:${ip:-127.0.0.1},IP:127.0.0.1,DNS:localhost" >/dev/null 2>&1
    chmod 600 "$dir/fallback-key.pem"
}

# 发布签名公钥**内嵌**在安装器里，不从网上取。
#
# 从渠道下载公钥再用它验渠道的签名，等于让被验的一方自带答案——中间人换掉
# 签名的同时换掉公钥即可。安装器自身的可信来自 `curl https://api.zmarketlab.com` 的
# TLS，公钥必须随它一起下来才形成闭环。
RELEASE_PUBLIC_KEY='-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEApCP8ahb9EvraXhfpc66veep8AaaKcg+lTTCc301WKhY=
-----END PUBLIC KEY-----'

load_images() {
    local work; work="$(mktemp -d)"
    # shellcheck disable=SC2064
    trap "rm -rf '$work'" RETURN

    step downloading
    local channel="$BASE_URL/trade-releases"
    # 可变入口只用于发现版本，所有可信输入再从同一个不可变版本目录读取。
    curl -fsS --max-time 60 --max-filesize 1048576 "$channel/manifest.json" -o "$work/manifest.json" || fail download_failed
    local release_version
    release_version=$(python3 -c 'import json,re,sys;v=json.load(open(sys.argv[1]))["version"];assert isinstance(v,str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,191}",v);print(v)' "$work/manifest.json") || fail bad_signature
    channel="$channel/releases/$release_version"
    curl -fsS --max-time 60 --max-filesize 1048576 "$channel/manifest.json" -o "$work/manifest.json" || fail download_failed
    curl -fsS --max-time 60 --max-filesize 64 "$channel/manifest.json.sig" -o "$work/manifest.sig" || fail download_failed

    step verify_sig
    printf '%s' "$RELEASE_PUBLIC_KEY" > "$work/pub.pem"
    openssl pkeyutl -verify -rawin -pubin -inkey "$work/pub.pem" \
        -in "$work/manifest.json" -sigfile "$work/manifest.sig" >/dev/null 2>&1 \
        || fail bad_signature

    python3 -c 'import json,sys;assert json.load(open(sys.argv[1]))["version"] == sys.argv[2]' "$work/manifest.json" "$release_version" || fail bad_signature

    # 签名通过之后，清单里的内容才可以当作事实使用。
    local app_id agent_id
    app_id=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["customer_image"].get("app_image_id") or "")' "$work/manifest.json")
    agent_id=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["customer_image"].get("agent_image_id") or "")' "$work/manifest.json")
    [ -n "$app_id" ] && [ -n "$agent_id" ] || fail bad_signature

    # 归档名带内容哈希，拼不出来，从固定地址的指针取。
    curl -fsS --max-time 60 --max-filesize 1048576 "$channel/latest/image-archive.json" -o "$work/pointer.json" || fail download_failed
    local archive_name
    archive_name=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["archive"])' "$work/pointer.json")
    [[ "$archive_name" =~ ^(trade|zbot)-app-[A-Za-z0-9._-]+\.tar$ ]] || fail download_failed
    curl -fsS --max-time 900 --max-filesize 2147483648 "$channel/latest/$archive_name" -o "$work/images.tar" || fail download_failed
    python3 - "$work/manifest.json" "$work/images.tar" <<'PY_ARCHIVE'
import hashlib, json, os, sys
manifest = json.load(open(sys.argv[1]))
with open(sys.argv[2], 'rb') as archive:
    digest_state = hashlib.sha256()
    for chunk in iter(lambda: archive.read(1024 * 1024), b''): digest_state.update(chunk)
    digest = digest_state.hexdigest()
assert digest == manifest['archive']['sha256']
assert os.path.getsize(sys.argv[2]) == manifest['archive']['size_bytes']
PY_ARCHIVE


    # 部署拓扑：compose.yaml 决定跑哪些容器、挂哪些卷——其中包括
    # /var/run/docker.sock，等同宿主 root。所以它按**签名清单里的哈希**核验，
    # 不能像归档指针那样靠事后核验镜像 ID 兜底：篡改 compose 就等于拿下整台
    # 机器，而镜像 ID 再正确也拦不住。
    local f expected actual
    for f in compose.yaml Caddyfile; do
        case "$f" in
            compose.yaml) expected=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["docker_deploy"]["compose"]["sha256"])' "$work/manifest.json" 2>/dev/null || echo "") ;;
            Caddyfile)    expected=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["docker_deploy"]["caddyfile"]["sha256"])' "$work/manifest.json" 2>/dev/null || echo "") ;;
        esac
        [ -n "$expected" ] || fail bad_signature
        curl -fsS --max-time 60 --max-filesize 1048576 "$channel/latest/$f" -o "$work/$f" || fail download_failed
        actual=$(sha256sum "$work/$f" | cut -d' ' -f1)
        [ "$actual" = "$expected" ] || fail image_identity
    done

    step loading
    docker load -i "$work/images.tar" >/dev/null 2>&1 || fail load_failed

    # 真正的准入判据：跑起来的那份内容的镜像 ID，必须等于**签名清单**里的值。
    #
    # 比校验归档哈希更强——归档换个打包方式哈希就变，镜像 ID 不变；而且它认证
    # 的是最终 docker run 的对象，中间任何一环被掉包都过不了这一关。
    #
    # 管家同样要验：它挂 /var/run/docker.sock，等同宿主 root，load 进来一个
    # 未经认证的管家镜像等于把整台机器交出去。
    local loaded_app loaded_agent
    loaded_app=$(docker images --no-trunc --format '{{.Repository}}:{{.Tag}} {{.ID}}' \
        | awk '$1 ~ /^zbot-app:/ && $2 == "'"$app_id"'" {print $1; exit}')
    [ -n "$loaded_app" ] || fail image_identity
    loaded_agent=$(docker images --no-trunc --format '{{.Repository}}:{{.Tag}} {{.ID}}' \
        | awk '$1 ~ /^zbot-agent:/ && $2 == "'"$agent_id"'" {print $1; exit}')
    [ -n "$loaded_agent" ] || fail image_identity

    # compose 只认 :current。归档里管家用的是中性标签 zbot-agent:staged
    # （避免更新时旧管家 load 后自毁，见交付 SOP），首装必须由这里提升。
    docker tag "$loaded_app" zbot-app:current
    docker tag "$loaded_agent" zbot-agent:current

    # 核验通过的部署配置落到工程目录（临时目录随本函数一起销毁）。
    install -m 0644 "$work/compose.yaml" "$ZBOT_PROJECT_DIR/compose.yaml"

    # Caddyfile 只在**首装**时落一份初始版本，之后就是**客户的文件**：
    # 重跑安装器不覆盖，更新链也不下发（下发白名单只有 compose.yaml）。
    #
    # 客户想上自己的域名与真证书，就在这份文件里自己加 site block——我们不读取、
    # 不改写、也不需要知道他的域名。曾经我们管过这件事（模板 + 渲染器 + 一个隐藏
    # 环境变量），代价是两台生产机的站点块被一次重新生成整块删掉，证书退回自签。
    if [ ! -f "$ZBOT_PROJECT_DIR/Caddyfile" ]; then
        install -m 0644 "$work/Caddyfile" "$ZBOT_PROJECT_DIR/Caddyfile"
    fi

    # 基础镜像同样固定到 :current，让 compose 只有一套镜像来源，
    # 不在客户机上留 latest 这种会漂移的引用。
    local base
    # 只拉真的在 compose 里被消费的基础镜像。曾经这里还拉 cloudflared——
    # 隧道服务删除时漏了这行，而它在 `|| fail download_failed` 里：Docker Hub
    # 限流会因为一个没人用的镜像中止整个安装，还向客户报"请检查能否访问 zmarketlab.com"。
    for base in "postgres:17-alpine zbot-postgres:current" \
                "caddy:2-alpine zbot-caddy:current"; do
        set -- $base
        docker image inspect "$2" >/dev/null 2>&1 && continue
        docker pull -q "$1" >/dev/null 2>&1 || fail download_failed
        docker tag "$1" "$2"
    done
}

start_database() {
    step starting_db
    compose up -d postgres
    local waited=0
    # 超时判断必须放在**循环体开头**，且循环体最后一条要是必然成功的命令。
    #
    # 原来写成 `[ "$waited" -ge 180 ] && fail starting_db` 收尾：条件不满足时
    # 整个循环体返回非零，`until` 复合命令继承这个状态，函数随之返回非零，
    # `set -e` 就地杀掉脚本——**不打印任何错误**。真机上撞到过两次：安装停在
    # 起库之后，退出码看起来正常，客户完全不知道发生了什么。
    until [ "$(docker inspect -f '{{.State.Health.Status}}' "$(compose ps -q postgres)" 2>/dev/null)" = "healthy" ]; do
        if [ "$waited" -ge 180 ]; then fail starting_db; fi
        sleep 2
        waited=$((waited + 2))
    done
}

# 建库结构。**不再创建任何管理员**——默认安装不带账户口令，客户打开网页自己注册。
#
# 此前这里生成一个随机一次性口令、经 stdin 交给 bootstrap-admin、再把它打印并
# 落盘给客户。整条链连同 credentials.txt 一起删除：口令从此只有一个来源，
# 客户注册时自己设的，我们从不持有、也不经手。
prepare_database() {
    step bootstrapping
    compose run --rm --no-deps -T zbot migrate >/dev/null || fail bootstrap_failed
}

# 等后端可应答，把结论写进 STACK_HEALTHY。
#
# 探针结果**必须留下**。这里刻意不 fail：脚本死在这一步就永远不会打印管理员
# 密码，而系统往往其实已经起来了，客户拿到的是一台登不进去的机器——那比慢
# 更糟。但"不 fail"不等于"可以谎称成功"：此前无论 3 秒就健康还是 240 秒
# 都没起来，两条路都走向同一句"安装完成"，探针唯一的产物只剩等待时间。
# 现在把真相带出去，凭据照打，结论照实说。
wait_backend_healthy() { # wait_backend_healthy <超时秒>
    local waited=0 limit="${1:-240}"
    STACK_HEALTHY=0
    until curl -fsS -k "https://127.0.0.1/api/health" >/dev/null 2>&1; do
        if [ "$waited" -ge "$limit" ]; then return 0; fi
        sleep 3
        waited=$((waited + 3))
    done
    STACK_HEALTHY=1
}

start_stack() {
    step starting_all
    compose up -d
    step waiting_health
    wait_backend_healthy 240
}

# 宿主日志封顶。容器日志已由 compose 的 json-file 10m×3 硬性有界；宿主 journald
# 默认可长到磁盘 10%，客户机无人运维，必须同样有界。best-effort：写不进去或
# 重启失败都不影响安装结论——这是兜底，不是主链。
cap_host_logs() {
    mkdir -p /etc/systemd/journald.conf.d 2>/dev/null || return 0
    printf '[Journal]\nSystemMaxUse=512M\n' \
        > /etc/systemd/journald.conf.d/zbot-host-log-cap.conf 2>/dev/null || return 0
    systemctl restart systemd-journald 2>/dev/null || true
}

# 打印入口信息。**没有凭据可打印**——账户由客户自己在网页上注册。
#
# 此前这里还写一份 credentials.txt（0600）。那个文件随口令一起退役：
# 没有口令之后它只剩访问地址和 TLS 指纹，而"文件内容与屏幕输出不一致"
# 恰恰是本仓库出过的一类 bug。少一个文件，就少一处要保持同步的东西。
print_entry() {
    local ip fingerprint
    ip="$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}')"
    fingerprint="$(openssl x509 -in "$ZBOT_PROJECT_DIR/certs/local/fallback.pem" -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2)"
    printf '\n  ┌─ %s ─\n' "$(msg entry_header)"
    printf '  │  %-12s https://%s\n' "$(msg creds_url)" "${ip:-<本机IP>}"
    printf '  │  %-12s %s\n' "TLS SHA-256" "${fingerprint:-<不可用>}"
    printf '  └─\n\n'
    printf '  %s\n' "$(msg register_hint)"
    printf '  %s\n\n' "$(msg firewall_hint)"
    if [ "${STACK_HEALTHY:-0}" = "1" ]; then
        printf '  \033[0;32m✓\033[0m %s\n\n' "$(msg install_done)"
    else
        printf '  \033[0;33m!\033[0m %s\n\n' "$(msg install_unhealthy)"
    fi
}

main "$@"
