#!/bin/bash
# AppCenter 前端同步（aaden-cli appcenter-sync 的执行体）。产物由 aaden-php-backend scripts/publish-appcenter-artifact.sh 发布：
#   <base>/<channel>/manifest.json + <base>/blobs/<App>-<sha>.zip
#
# light-update 只发 PHP/，AppCenter 以前只能跟 465MB 的 latest.zip 走；这里单独按应用同步。
#   - 灰度门：manifest 的 rollout = {"devices":[...], "percent":N}，本机在名单里或 deviceId%100 < N 才动；没有 rollout = 全量
#   - 根目录应用：manifest 顶层 rootApps 里的应用装到 docroot 根（/var/www/html/<App>，如老的 /App），其余逻辑同 apps；
#     只认白名单 APPCENTER_ROOT_APPS（默认 "App"），防止 manifest 写错把 PHP 之类的目录换掉。老 CLI 只读 apps，不受影响
#   - 按应用灰度：manifest 带 perAppRollout=true 时，每个应用按自己的 apps.<App>.rollout 判断（没有 = 全量），
#     顶层 rollout 只管 ensureNoCache；老版本 CLI 不认 perAppRollout，照旧整体按顶层 rollout
#   - ensureNoCache：manifest 打开时先确保 AppCenter 的 index.html 带 no-cache（ensureAppCenterNoCache.sh）
#   - manifest 门：本机 .appcenter-manifest 的 sha256 == manifest 的 treeSha256 且 `sha256sum -c` 通过 → 什么都不做，
#     每天只花一次几 KB 的请求。被 latest.zip 全量覆盖过、文件对不上 → 校验失败 → 重新铺一遍（自愈）
#   - 先下载并校验（zipSha256 + 解压后 sha256sum -c），确认无误才动线上目录
#   - 保留上一代：旧目录里「旧 index.html 用得到、新版没有」的文件拷进新目录（appcenterRefs.py），
#     正开着的旧页面懒加载 chunk 时不会 404；更早手工覆盖堆下来的历史文件就此清掉
#   - 整目录 rename 替换，旧目录留一份在 BACKUP；健康检查失败就换回去
#   - 不碰 PHP、不碰数据库，所以不需要 TSE 门
#
# 用法: appCenterSync.sh [channel]
#   APPCENTER_BASE       产物根地址，默认 S3；测试可用 file:///var/tmp/...（curl 都支持）
#   APPCENTER_APPS       只同步这些应用（空格分隔）；不传 = manifest 里列出的全部应用，发哪些由发布端决定
#   APPCENTER_ROOT_APPS  允许同步到 docroot 根的应用白名单（空格分隔），默认 "App"
#   APPCENTER_DEVICE_ID  本机 deviceId（CLI 会传）；不传就问本机 PHP
#   APPCENTER_RESULT     结果行另存的文件（CLI 会传）
# 结果行（同时写进 $RESULT 给 CLI 上报）：APP_OK <App> <sha12> | APP_UP_TO_DATE <App> | APP_SKIP <App> <原因> |
#   APP_ROLLED_BACK <App> | APP_ABORT_* <App> | APP_SKIP_ROLLOUT <App> | NOCACHE_* | SKIP_ROLLOUT | SKIP_DEPS | SKIP_LOCKED | ABORT_*
# 退出码：0=全部成功、无需更新或不在灰度内；1=有失败（失败的已回滚或未动线上目录）
set -uo pipefail
BASE="${APPCENTER_BASE:-https://aaden-backend.s3.eu-central-1.amazonaws.com/appcenter}"
CHANNEL="${1:-stable}"
DOCROOT=/var/www/html
WEB=$DOCROOT/AppCenter
ROOT_ALLOW=" ${APPCENTER_ROOT_APPS:-App} "
STAGE=/var/tmp/aaden-appcenter-stage
BACKUP=/var/lib/aaden-appcenter-backup
LOCK=/var/tmp/aaden-appcenter.lock
RESULT="${APPCENTER_RESULT:-/var/tmp/aaden-appcenter-result.txt}"   # CLI 传一个自己建的临时文件，读回来上报
HERE="$(cd "$(dirname "$0")" && pwd)"
PW='Innerken322.'
sudoS(){ echo "$PW" | sudo -S bash -c "$*"; }
res(){ echo "$*"; echo "$*" >> "$RESULT"; }

: > "$RESULT" 2>/dev/null || { RESULT=$(mktemp); : > "$RESULT"; }

MISSING=""
for c in curl python3 unzip sha256sum flock tar cut comm; do command -v "$c" >/dev/null || MISSING="$MISSING $c"; done
if [ -n "$MISSING" ]; then res "SKIP_DEPS$MISSING"; exit 0; fi
if ! echo "$PW" | sudo -S true 2>/dev/null; then res "ABORT_SUDO_AUTH"; exit 1; fi

# 同一时间只跑一个（auto-upgrade 与手动触发可能撞上）。只读打开即可 flock，root/aaden 谁先建文件都行
[ -e "$LOCK" ] || touch "$LOCK"
exec 9<"$LOCK"
flock -n 9 || { res "SKIP_LOCKED"; exit 0; }

MANIFEST=$(curl -fsSL --max-time 30 "$BASE/$CHANNEL/manifest.json") \
  || { echo "取不到 $BASE/$CHANNEL/manifest.json"; res "ABORT_MANIFEST"; exit 1; }
# 读 manifest：mf apps.Admin.zip / mf ensureNoCache / mf eligible <deviceId> / mf eligible_app <App> <deviceId> [apps|rootApps]
mf(){
  printf '%s' "$MANIFEST" | python3 -c '
import json, sys
m = json.load(sys.stdin)
def gate(r, dev):
    if r is None:
        return "yes"
    try:
        d = int(dev)
    except ValueError:
        return "no"
    ok = d in [int(x) for x in r.get("devices", [])] or d % 100 < int(r.get("percent", 0))
    return "yes" if ok else "no"
if sys.argv[1] == "eligible":
    print(gate(m.get("rollout"), sys.argv[2])); sys.exit()
if sys.argv[1] == "eligible_app":
    # eligible_app <App> <deviceId> [apps|rootApps]
    kind = sys.argv[4] if len(sys.argv) > 4 else "apps"
    if m.get("perAppRollout") is True:
        app = (m.get(kind) or {}).get(sys.argv[2]) or {}
        print(gate(app.get("rollout"), sys.argv[3]))
    else:
        print(gate(m.get("rollout"), sys.argv[3]))
    sys.exit()
v = m
for k in sys.argv[1].split("."):
    v = v.get(k) if isinstance(v, dict) else None
print("" if v is None else (str(v).lower() if isinstance(v, bool) else v))
' "$@"
}

# ---- 灰度门 ----
DEVICE_ID="${APPCENTER_DEVICE_ID:-}"
if [ -z "$DEVICE_ID" ] || [ "$DEVICE_ID" = "-1" ]; then
  DEVICE_ID=$(curl -s --max-time 10 "http://localhost/PHP/AccessLog.php?op=deviceId" \
    | python3 -c 'import json,sys; print(json.load(sys.stdin).get("content",""))' 2>/dev/null)
fi
TOP_ELIGIBLE=$(mf eligible "${DEVICE_ID:-x}")
if [ "$TOP_ELIGIBLE" != "yes" ] && [ "$(mf perAppRollout)" != "true" ]; then
  res "SKIP_ROLLOUT device=${DEVICE_ID:-?}"; exit 0
fi

FAILED=0
# ---- index.html no-cache（先于换前端：让之后拿到的 index.html 都不再被启发式缓存）----
if [ "$TOP_ELIGIBLE" = "yes" ] && [ "$(mf ensureNoCache)" = "true" ]; then
  NC=$(bash "$HERE/ensureAppCenterNoCache.sh"); NC_CODE=$?
  printf '%s\n' "$NC" | sed '$d'
  res "$(printf '%s\n' "$NC" | tail -1)"
  [ "$NC_CODE" = 0 ] || FAILED=1
fi

# 清单第 67 列起是路径（64 位 hash + 两个空格）
paths(){ cut -c67- "$1" | LC_ALL=C sort; }
tree_ok(){ # $1=目录 $2=期望 treeSha256
  [ -f "$1/.appcenter-manifest" ] \
    && [ "$(sha256sum < "$1/.appcenter-manifest" | cut -d' ' -f1)" = "$2" ] \
    && (cd "$1" && sha256sum -c --quiet --strict .appcenter-manifest >/dev/null 2>&1)
}

sync_app(){
  # $1=应用名 $2=apps（AppCenter/<App>，默认）或 rootApps（docroot 根 /<App>）
  local APP="$1" KIND="${2:-apps}" LIVE NEW BAK URLP TAG TREE ZIP ZSHA
  if [ "$KIND" = "rootApps" ]; then
    LIVE="$DOCROOT/$APP"; NEW="$STAGE/root-$APP"; BAK="root-$APP"; URLP="$APP"; TAG="/$APP"
  else
    LIVE="$WEB/$APP"; NEW="$STAGE/$APP"; BAK="$APP"; URLP="AppCenter/$APP"; TAG="$APP"
  fi
  TREE=$(mf "$KIND.$APP.treeSha256"); ZIP=$(mf "$KIND.$APP.zip"); ZSHA=$(mf "$KIND.$APP.zipSha256")
  if [ -z "$TREE" ]; then res "APP_SKIP $TAG 不在manifest里"; return 0; fi
  if [ "$(mf eligible_app "$APP" "${DEVICE_ID:-x}" "$KIND")" != "yes" ]; then res "APP_SKIP_ROLLOUT $TAG"; return 0; fi
  # manifest 来自网络：只接受预期格式，防止路径穿越
  if ! [[ "$TREE" =~ ^[0-9a-f]{64}$ && "$ZSHA" =~ ^[0-9a-f]{64}$ && "$ZIP" =~ ^blobs/[A-Za-z0-9._-]+\.zip$ ]]; then
    res "APP_ABORT_BAD_MANIFEST $TAG"; return 1
  fi

  # ---- manifest 门 ----
  if tree_ok "$LIVE" "$TREE"; then res "APP_UP_TO_DATE $TAG"; return 0; fi
  echo "$TAG: 本机与 manifest 不一致 → 同步到 ${TREE:0:12}"

  # ---- 下载 + 校验（此时还没动线上目录）----
  sudoS "rm -rf '$NEW' '$NEW.zip' '$NEW.failed'"
  curl -fsSL --max-time 1200 "$BASE/$ZIP" -o "$NEW.zip" || { res "APP_ABORT_DOWNLOAD $TAG"; return 1; }
  [ "$(sha256sum "$NEW.zip" | cut -d' ' -f1)" = "$ZSHA" ] || { res "APP_ABORT_CHECKSUM $TAG"; rm -f "$NEW.zip"; return 1; }
  mkdir -p "$NEW" && unzip -q -o "$NEW.zip" -d "$NEW"; rm -f "$NEW.zip"
  if ! { [ -f "$NEW/index.html" ] && tree_ok "$NEW" "$TREE"; }; then
    res "APP_ABORT_BAD_ARTIFACT $TAG"; rm -rf "$NEW"; return 1
  fi
  # zip 里带的是打包时的旧 mtime；浏览器按 Last-Modified 的年龄启发式缓存，所以改成现在（只改 mtime 不改内容，不影响校验）
  touch "$NEW/index.html"

  # ---- 保留上一代 ----
  local CARRIED=0
  if [ -d "$LIVE" ]; then
    python3 "$HERE/appcenterRefs.py" "$LIVE" | LC_ALL=C sort > "$NEW.old" \
      || { res "APP_ABORT_REFS $TAG"; rm -rf "$NEW" "$NEW.old"; return 1; }
    paths "$NEW/.appcenter-manifest" > "$NEW.new"
    LC_ALL=C comm -23 "$NEW.old" "$NEW.new" > "$NEW.carry"
    CARRIED=$(wc -l < "$NEW.carry" | tr -d ' ')
    # 用 tar 而不是 rsync：老设备不一定装了 rsync；清单里的文件都确实存在（appcenterRefs 只列存在的）
    [ "$CARRIED" -gt 0 ] && tar -C "$LIVE" -cf - -T "$NEW.carry" | tar -C "$NEW" -xf -
    rm -f "$NEW.old" "$NEW.new" "$NEW.carry"
  fi
  echo "$TAG: 保留上一代文件 $CARRIED 个"

  # ---- 替换：两次 rename，中间只有毫秒级空窗 ----
  sudoS "mkdir -p '$BACKUP' && rm -rf '$BACKUP/$BAK' && chown -R www-data:www-data '$NEW'"
  [ -d "$LIVE" ] && sudoS "mv '$LIVE' '$BACKUP/$BAK'"
  sudoS "mv '$NEW' '$LIVE'"

  # ---- 健康检查：首页、入口 js、文件校验 ----
  local HEALTHY=1 MAIN c
  MAIN=$(grep -oE 'src=["'"'"']?[^"'"'"' >]+\.js' "$LIVE/index.html" | grep -vE '//' | head -1 | sed -E 's/^src=["'"'"']?(\.\/)?//')
  for p in "" "$MAIN"; do
    c=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost/$URLP/$p" --max-time 15)
    echo "  健康检查 /$URLP/$p -> $c"; [ "$c" = "200" ] || HEALTHY=0
  done
  tree_ok "$LIVE" "$TREE" || { echo "  线上目录校验失败"; HEALTHY=0; }

  if [ "$HEALTHY" != "1" ]; then
    sudoS "mv '$LIVE' '$NEW.failed'; [ -d '$BACKUP/$BAK' ] && mv '$BACKUP/$BAK' '$LIVE'"
    res "APP_ROLLED_BACK $TAG"; return 1
  fi
  res "APP_OK $TAG ${TREE:0:12} carried=$CARRIED"
}

# 不指定就同步 manifest 里的全部应用（营业中一直开着的 App/KDS 等，发布端先别放进 manifest）
APPS="${APPCENTER_APPS:-$(printf '%s' "$MANIFEST" | python3 -c 'import json, sys; print(" ".join(sorted(json.load(sys.stdin).get("apps", {}))))')}"
sudoS "mkdir -p '$STAGE' && chown $(id -un):$(id -gn) '$STAGE'"
for APP in $APPS; do
  [[ "$APP" == /* ]] && continue   # APPCENTER_APPS 里 /<App> 指的是根目录应用，下面单独处理
  if ! [[ "$APP" =~ ^[A-Za-z0-9_-]+$ ]]; then res "APP_SKIP $APP 名字不合法"; continue; fi
  sync_app "$APP" apps || FAILED=1
done
# 根目录应用（老的 /App 等）：只同步白名单里的；APPCENTER_APPS 指定了就只按它过滤
ROOT_APPS=$(printf '%s' "$MANIFEST" | python3 -c 'import json, sys; print(" ".join(sorted(json.load(sys.stdin).get("rootApps") or {})))')
for APP in $ROOT_APPS; do
  if ! [[ "$APP" =~ ^[A-Za-z0-9_-]+$ ]] || [[ "$ROOT_ALLOW" != *" $APP "* ]]; then res "APP_SKIP /$APP 不在根目录白名单"; continue; fi
  if [ -n "${APPCENTER_APPS:-}" ] && [[ " $APPCENTER_APPS " != *" /$APP "* ]]; then continue; fi
  sync_app "$APP" rootApps || FAILED=1
done
exit $FAILED
