#!/usr/bin/env bash
# PAC 生产部署(确定性,不信 compose 的 diff 启发式)
#
# 背景:docker compose `up -d --build` 存在跨版本反复复发的已知缺陷 —— 镜像构建了但
# 运行中容器不切新镜像(docker/compose#9308 #9259 等;本项目 2026-06-10 实测中招:
# 服务还跑旧镜像 → migrate 报 "no pending" → 新列没建)。
# 根治:生产部署不依赖 compose 的"要不要重建"判定 —— 显式 build → 显式 force-recreate
# → 部署后硬验证(容器镜像 ID == 新镜像 ID / 迁移无 pending / health 200),任一不过即失败退出。
#
# ⚠️ 整个逻辑包在 main() 里,文件末尾才调用 —— bash 是边读边执行脚本文件的,
#    而本脚本会 git pull 更新自己;不包 main 的话,pull 后 bash 继续按"新文件的字节偏移"
#    读后半段 → 执行旧/错位代码(2026-06-10 实测:验证逻辑跑的是旧版)。
#
# 分支规约:main=生产 / test=测试。脚本默认部署"本机当前 checkout 的分支"(不写死 main),
#   故生产机停在 main、测试机停在 test 即可;临时切别的分支用 DEPLOY_BRANCH 覆盖。
#
# 用法(服务器上,~/pac):
#   bash deploy/deploy-prod.sh                    # 部署当前分支(生产机=main / 测试机=test)
#   DEPLOY_BRANCH=test bash deploy/deploy-prod.sh # 显式指定分支(首次把某机切到该分支)
#   bash deploy/deploy-prod.sh --no-pull          # 跳过 git 拉取(代码已就位时)
set -euo pipefail

log() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; }
warn() { printf '\033[1;33mWARN: %s\033[0m\n' "$*" >&2; }
die() { printf '\033[1;31mFAIL: %s\033[0m\n' "$*" >&2; exit 1; }

main() {
  cd "$(dirname "$0")/.."

  # ⭐ 机器形态自检(2026-07-26 事故防线)——「该托管却没带 COMPOSE_MANAGED=1」直接退出。
  #
  # 事故经过:在 friday 生产机漏了 COMPOSE_MANAGED=1 跑本脚本 → 不叠加 managed override →
  #   docker-compose.prod.yml 里 DATABASE_URL 硬编码成 postgres:5432(容器内自带 PG),
  #   于是当场起了 pac-postgres-1 / pac-redis-1 两个空容器,并把 29 条迁移建到那个**空库**上。
  #   托管库本身没被写坏,但机器上从此留下一个「schema 齐全的空库」——
  #   下次再漏 COMPOSE_MANAGED,service 会顺利连上它、health 200、三项验证全绿,
  #   **生产静默服务空库且无任何告警**。原本"空 PG 起不来会崩"的天然安全网就此失效。
  #
  # 判据不依赖人记得加环境变量,而是问机器自己:.env 的 DATABASE_URL 指向哪儿。
  #   指向 postgres:5432 / localhost → 自带 DB 形态,COMPOSE_MANAGED 可不设
  #   指向别的主机(RDS 等)         → 托管形态,必须 COMPOSE_MANAGED=1,否则退出
  local SVC_ENV="apps/pac-service/.env"
  if [[ -f "$SVC_ENV" ]]; then
    local dburl
    dburl=$(grep -E '^\s*DATABASE_URL=' "$SVC_ENV" | tail -1 | cut -d= -f2- | tr -d '"'"'"' ')
    if [[ -n "$dburl" && "$dburl" != *"@postgres:"* && "$dburl" != *"@localhost:"* && "$dburl" != *"@127.0.0.1:"* ]]; then
      # 托管形态。注意只打印主机名,不打印含口令的整串。
      local dbhost="${dburl#*@}"; dbhost="${dbhost%%/*}"
      [[ "${COMPOSE_MANAGED:-0}" == "1" ]] || die \
"本机是【托管 DB 形态】(.env 的 DATABASE_URL 指向 $dbhost),但没设 COMPOSE_MANAGED=1。
       这样跑会起自带 PG/Redis 容器、把迁移建到空库,并跳过私网可达性验证。
       正确命令:  COMPOSE_MANAGED=1 bash deploy/deploy-prod.sh
       (若确实想用自带 DB,请改 .env 的 DATABASE_URL 指向 postgres:5432)"
    fi
  fi

  # 托管 DB/Redis:COMPOSE_MANAGED=1 叠加 override(不启自带 PG/Redis,URL 走 .env 的托管端点)
  local COMPOSE=(docker compose -f docker-compose.prod.yml)
  [[ "${COMPOSE_MANAGED:-0}" == "1" ]] && COMPOSE+=(-f docker-compose.managed.yml)
  COMPOSE+=(--env-file apps/pac-service/.env --env-file apps/pac-web/.env)
  local SERVICES=(pac-migrate pac-service pac-web)
  # 语音 ASR 可选(compose profile=asr),默认不部署;要语音助手用 WITH_ASR=1 bash deploy/deploy-prod.sh
  [[ "${WITH_ASR:-0}" == "1" ]] && SERVICES+=(pac-asr)

  # 部署哪个分支:规约 main=生产 / test=测试。默认跟随本机当前 checkout 的分支(不写死),
  # 故生产机(在 main)自然部署 main、测试机(在 test)自然部署 test;需临时切分支再用 DEPLOY_BRANCH 覆盖。
  local BRANCH="${DEPLOY_BRANCH:-$(git rev-parse --abbrev-ref HEAD)}"

  if [[ "${1:-}" != "--no-pull" ]]; then
    log "拉取分支 $BRANCH(fetch + checkout + ff-only)"
    git fetch origin "$BRANCH"
    # 本地没有该分支则从 origin 建跟踪分支;已在则切过去。随后 --ff-only 拉(有分歧即失败退出,不静默 reset)
    git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH" "origin/$BRANCH"
    git pull --ff-only origin "$BRANCH"
    # pull 可能更新了本脚本(SERVICES 列表/验证逻辑)→ re-exec 新版执行后续步骤,
    # 否则本轮跑的是 pull 前加载的旧版(2026-06-10 实测:新加的 pac-asr 服务没被 build)。
    # DEPLOY_BRANCH 经 env 继承给 exec,再算 BRANCH 也一致。
    log "re-exec 最新脚本(--no-pull)"
    exec bash "$0" --no-pull
  fi
  log "HEAD: $(git log --oneline -1)"

  log "build 镜像(显式,不和 up 混)"
  "${COMPOSE[@]}" build "${SERVICES[@]}"

  # ⭐ 主路径仍是直接 force-recreate —— **它在 docker 上是零停机的**。
  #   2026-08-04 三组实测(0.5s 间隔探 /health,部署全程采样):
  #     docker + 直接 force-recreate   → 停机 0.00s(535 样本全 200)
  #     docker + 先 stop 再 recreate   → 停机 15.97s ❌
  #     podman + 先 stop 再 recreate   → 停机  5.44s
  #   compose 的 force-recreate 是逐服务滚动(新容器起来再切旧的),而"先全 stop"会造出
  #   真空期 —— 把零停机做成了十几秒停机。所以**不能**为了兼容 rootless 就无条件加 stop。
  #
  #   rootless podman(运维刻意选的形态:非 root 账号 + netavark/pasta,攻击面更小)下,
  #   force-recreate 要删**运行中**的容器,而 rootless 杀不掉它的网络进程:
  #     rootless netns: kill network process: permission denied
  #   → 仅在此时回退:清掉失败留下的 `<hash>_<proj>-<svc>-1` 半成品容器,stop 后重建。
  #   代价是那次部署有几秒停机,但只发生在 rootless 环境,docker 侧行为完全不变。
  #   ⚠️ 永远不退回裸 `up -d`:那正是本脚本要绕开的 compose diff 缺陷(见文件头)。
  log "force-recreate(不信 compose 的重建判定)"
  if ! "${COMPOSE[@]}" up -d --force-recreate "${SERVICES[@]}"; then
    warn "force-recreate 失败(疑似 rootless 删不掉运行中容器的网络进程),回退:清残留 → stop → 重建"
    local proj_r svc_r leftover
    proj_r=$(basename "$PWD")
    for svc_r in "${SERVICES[@]}"; do
      # 失败会留下带 hash 前缀的半成品容器(如 b69b5bec_pac-pac-service-1),不清则重建报名字冲突
      leftover=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -E "_${proj_r}-${svc_r}-1$" || true)
      [[ -n "$leftover" ]] && docker rm -f $leftover >/dev/null 2>&1 && log "  清理残留容器 $leftover"
    done
    "${COMPOSE[@]}" stop "${SERVICES[@]}" || true
    "${COMPOSE[@]}" up -d --force-recreate "${SERVICES[@]}"
  fi

  # ── 部署后硬验证:任一不过 = 部署失败 ─────────────────────────────
  log "验证 1/3:容器跑的镜像 == 刚构建的镜像"
  local proj svc want got
  proj=$(basename "$PWD")
  for svc in pac-service pac-web; do
    want=$(docker image inspect "${proj}-${svc}" --format '{{.Id}}')
    got=$(docker inspect "${proj}-${svc}-1" --format '{{.Image}}')
    [[ "$want" == "$got" ]] || die "$svc 容器仍在旧镜像(want ${want:7:12} got ${got:7:12})— compose 又没重建"
    echo "  $svc OK (${want:7:12})"
  done

  log "验证 2/3:数据库迁移无 pending(migrate 容器 exit 0 且 deploy 干净)"
  local mexit status_out
  mexit=$(docker inspect "${proj}-pac-migrate-1" --format '{{.State.ExitCode}}')
  [[ "$mexit" == "0" ]] || die "migrate 容器 exit=$mexit(看 docker logs ${proj}-pac-migrate-1)"
  # 注 1:prisma 的"新版本提示框"位置不固定(异步)→ 不能 tail 截断,全量输出里找;
  # 注 2:pipefail 下 `cmd | grep -q` 会因 grep 提前退出给 cmd 发 SIGPIPE → 管道 141 误判失败
  #       → 先落变量再 grep。
  status_out=$(docker exec "${proj}-pac-service-1" sh -c 'npx prisma migrate status 2>&1' || true)
  grep -qiE 'database schema is up to date' <<<"$status_out" \
    || die "迁移有 pending / 状态异常: $(tail -2 <<<"$status_out")"
  echo "  migrate OK"

  log "验证 3/3:health + web"
  local i code webcode
  for i in $(seq 1 30); do
    code=$(curl -s -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:3101/pac/v1/health || true)
    [[ "$code" == "200" ]] && break
    sleep 2
  done
  [[ "${code:-}" == "200" ]] || die "service health=$code"
  # / 现为 307→/plans(next.config redirects),直接验 /plans 的 200
  webcode=$(curl -s -m 30 -o /dev/null -w '%{http_code}' http://127.0.0.1:3100/plans || true)
  [[ "$webcode" == "200" ]] || die "web http=$webcode"
  echo "  health 200 / web 200(loopback)"

  # 托管环境(COMPOSE_MANAGED=1)= 远端网关(如 friday 的 lb1)经【ECS 私网 IP】到达应用。
  # loopback 验证过不代表网关能到:若应用绑成 127.0.0.1,私网 IP 会 refused → 公网 502。
  # 这里额外验私网 IP 可达,把"loopback 通、网关不通"的漂移在部署当场拦下(2026-07-18 踩过)。
  if [[ "${COMPOSE_MANAGED:-0}" == "1" ]]; then
    log "验证 3b:私网 IP 可达(网关路径,防 loopback-only 绑定)"
    local priv phealth pweb
    priv=$(ip route get 1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1);exit}}')
    [[ -n "$priv" ]] || die "拿不到私网 IP(ip route get 1 解析失败)"
    phealth=$(curl -s -m 5 -o /dev/null -w '%{http_code}' "http://$priv:3101/pac/v1/health" || true)
    [[ "$phealth" == "200" ]] || die "私网 $priv:3101 health=$phealth — 应用可能绑成 loopback,网关够不到(会 502)。托管环境需 web/service 绑 0.0.0.0(见 docker-compose.managed.yml)"
    pweb=$(curl -s -m 30 -o /dev/null -w '%{http_code}' "http://$priv:3100/plans" || true)
    [[ "$pweb" == "200" ]] || die "私网 $priv:3100 web=$pweb — 同上(loopback 绑定 → 网关 502)"
    echo "  私网 $priv health 200 / web 200(网关路径 OK)"
  fi

  log "部署成功 ✅  $(git log --oneline -1)"
}

main "$@"
