#!/bin/bash
# upgrade-guardian.sh — post-upgrade health monitor
# Started by daemon after npm install, runs as detached background process.
# Monitors healthz for 90s and rolls back on failure. The decision is
# single-run: nothing relaunches this guardian after it exits, so any
# crash-count threshold above one run could never be reached.
# This file is NOT inside the npm package — installed once to ~/.grix/bin/.

GRIX_HOME="${GRIX_CONNECTOR_HOME:-$HOME/.grix}"
PENDING_FILE="$GRIX_HOME/data/upgrade-pending.json"
LOCK_FILE="$GRIX_HOME/upgrade-guardian.lock"
LOG_FILE="$GRIX_HOME/log/upgrade.log"
HEALTH_TIMEOUT=90
HEALTH_PORT_FILE="$GRIX_HOME/data/health-port"
HEALTH_PORT=$(cat "$HEALTH_PORT_FILE" 2>/dev/null || echo "19579")
HEALTH_URL="http://127.0.0.1:${HEALTH_PORT}/healthz"
NPM_PACKAGE="grix-connector"

# Ensure directories exist
mkdir -p "$GRIX_HOME/log" "$GRIX_HOME/data"

log() {
    echo "[$(date '+%Y-%m-%dT%H:%M:%S%z')] guardian: $1" >> "$LOG_FILE"
}

# Prevent duplicate guardian instances
if [ -f "$LOCK_FILE" ]; then
    LOCK_PID=$(cat "$LOCK_FILE" 2>/dev/null)
    if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
        exit 0
    fi
    rm -f "$LOCK_FILE"
fi
echo $$ > "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT

# Read pending marker
if [ ! -f "$PENDING_FILE" ]; then
    exit 0
fi

# 用 node 解析 pending：grep/cut 拼 JSON 对转义和 schema 演进都脆弱，而 guardian
# 的激活与回滚本来就依赖 node，可用性等价。输出为单引号安全的 shell 赋值。
PENDING_VARS=$(node -e '
const f = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const q = (v) => "\x27" + String(v ?? "").replace(/\x27/g, "\x27\\\x27\x27") + "\x27";
process.stdout.write([
  "FROM_VERSION=" + q(f.from_version),
  "TARGET_VERSION=" + q(f.target_version),
  "CRASH_COUNT=" + q(f.crash_count ?? 0),
  "PACKAGE_DIR=" + q(f.package_dir),
  "BACKUP_DIR=" + q(f.backup_dir),
].join("\n"));
' "$PENDING_FILE" 2>> "$LOG_FILE") || PENDING_VARS=""
if [ -z "$PENDING_VARS" ]; then
    log "pending file corrupt or unreadable, removing"
    rm -f "$PENDING_FILE"
    exit 0
fi
eval "$PENDING_VARS"

# Validate pending file
if [ -z "$FROM_VERSION" ] || [ -z "$TARGET_VERSION" ]; then
    log "pending file corrupt, missing from_version/target_version, removing"
    rm -f "$PENDING_FILE"
    exit 0
fi

# healthz 必须由目标版本的进程应答才算健康。只看 HTTP 200 会把仍在跑的旧进程
# （或激活间隙被 launchd 拉起的旧 runtime）误判为升级成功。
target_healthy() {
    RESP=$(curl -sf --max-time 5 "$HEALTH_URL" 2>/dev/null) || return 1
    case "$RESP" in
        *"\"version\":\"$TARGET_VERSION\""*) return 0 ;;
        *) return 1 ;;
    esac
}

log "started: from=$FROM_VERSION target=$TARGET_VERSION crash_count=$CRASH_COUNT"

# Wait for old daemon to exit (SIGTERM + shutdown + restart)
sleep 15

# 前向激活：用已装好的目标版本 CLI start 刷新受管 runtime 副本、服务定义并触发重启。
# 放在 guardian（detached 独立进程）而非旧 daemon 进程内执行：旧 daemon 的激活子
# 进程在 systemd 默认 KillMode=control-group 下会随 restart 一起被杀，且旧 daemon
# 在 await 期间被杀后 pending 的失败处理（回滚/上报）执行不到，会与 guardian 脱同步。
# Linux 无受管 runtime 副本（服务直接跑全局包），start 只做服务定义刷新与重启，同样安全。
# 激活失败视同新版本启动失败，落入下方崩溃计数/回滚路径，不删 pending。
TARGET_CLI="$(npm root -g 2>> "$LOG_FILE")/$NPM_PACKAGE/dist/grix.js"
ACTIVATED=0
if [ -f "$TARGET_CLI" ] && node "$TARGET_CLI" start >> "$LOG_FILE" 2>&1; then
    ACTIVATED=1
    log "forward activation succeeded: managed runtime switched to $TARGET_VERSION"
else
    log "forward activation failed for target $TARGET_VERSION; treating as startup failure"
fi

# Monitor loop: wait for healthz to report the target version (only after successful activation)
if [ $ACTIVATED -eq 1 ]; then
    ELAPSED=0
    while [ $ELAPSED -lt $HEALTH_TIMEOUT ]; do
        if target_healthy; then
            # healthz 通过=新版健康。这里不删 pending：删了会抢在新进程的
            # handlePendingOnStartup 之前,使其读不到 pending、success 回执发不出去
            # (后端只见 installed 不见 success)。交由新进程上报 success 后自行删 pending,
            # guardian 只负责崩溃回滚。
            log "healthz passed for $TARGET_VERSION, upgrade healthy; leaving pending for in-process success report"
            exit 0
        fi
        sleep 3
        ELAPSED=$((ELAPSED + 3))
    done

    # Extra grace period for slow systems
    sleep 15
    if target_healthy; then
        # 同上:不删 pending,交由进程内 handlePendingOnStartup 上报 success 并删除。
        log "healthz passed for $TARGET_VERSION (delayed), upgrade healthy; leaving pending for in-process success report"
        exit 0
    fi
fi

# Health check failed (or forward activation failed). This guardian runs exactly
# once per upgrade — no supervisor relaunches it — so the verdict is made here:
# roll back now instead of counting crashes toward a threshold that can never
# be reached again.
# 回滚前用 node 原位递增并持久化 crash_count：保留 pending 里的其余字段
# （transaction_id、路径等），恢复后的旧运行时上报 rolled_back 时要带真实计数。
CRASH_COUNT=$(node -e '
const fs = require("fs");
const file = process.argv[1];
const f = JSON.parse(fs.readFileSync(file, "utf8"));
f.crash_count = (Number(f.crash_count) || 0) + 1;
const tmp = file + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(f));
fs.renameSync(tmp, file);
process.stdout.write(String(f.crash_count));
' "$PENDING_FILE" 2>> "$LOG_FILE") || CRASH_COUNT=$((CRASH_COUNT + 1))
log "healthz timeout or forward activation failed, crash_count=$CRASH_COUNT; rolling back"

# 新版升级器会把完整旧包保留在同文件系统的固定备份目录。优先原子恢复，
# 不依赖网络/npm，也不会再次制造全局 bin 缺失窗口。
case "$PACKAGE_DIR" in
    */node_modules/*)
        if [ -n "$BACKUP_DIR" ] && [ "$BACKUP_DIR" = "${PACKAGE_DIR}.grix-upgrade-backup" ] && [ -d "$BACKUP_DIR" ]; then
            FAILED_DIR="${PACKAGE_DIR}.grix-upgrade-failed-$$"
            log "atomic rollback: restoring $BACKUP_DIR"
            rm -rf "$FAILED_DIR"
            if { [ ! -e "$PACKAGE_DIR" ] || mv "$PACKAGE_DIR" "$FAILED_DIR"; } \
               && mv "$BACKUP_DIR" "$PACKAGE_DIR"; then
                rm -rf "$FAILED_DIR"
                # 原子恢复只换回全局包；macOS 服务跑的是受管 runtime 副本，必须用
                # 旧包 CLI start 刷新副本并触发重启，激活成功才删 pending。
                NPM_ROOT="$(npm root -g 2>> "$LOG_FILE")"
                ROLLBACK_CLI="$NPM_ROOT/$NPM_PACKAGE/dist/grix.js"
                if [ -n "$NPM_ROOT" ] && [ -f "$ROLLBACK_CLI" ] \
                   && node "$ROLLBACK_CLI" start >> "$LOG_FILE" 2>&1; then
                    log "atomic rollback succeeded and managed runtime activated; leaving pending for rollback report"
                    exit 0
                fi
                # 激活失败不退出：落到下面的 npm 回滚路径再试一次（npm install 旧版幂等）。
                log "atomic rollback package restored but managed runtime activation failed; falling through to npm rollback"
            else
                log "atomic rollback failed; restoring current package before npm fallback"
                if [ ! -e "$PACKAGE_DIR" ] && [ -e "$FAILED_DIR" ]; then
                    mv "$FAILED_DIR" "$PACKAGE_DIR" 2>/dev/null || true
                fi
            fi
        fi
        ;;
esac

log "rollback: installing ${NPM_PACKAGE}@${FROM_VERSION}"
# P5: 先用默认源,失败再显式回退官方 npm(默认源可能是淘宝镜像,旧版若没同步同样会失败)。
if npm install -g "${NPM_PACKAGE}@${FROM_VERSION}" --prefer-online --no-audit --no-fund >> "$LOG_FILE" 2>&1 \
   || { log "rollback via default registry failed, retrying official npm"; \
        npm install -g "${NPM_PACKAGE}@${FROM_VERSION}" --registry https://registry.npmjs.org --prefer-online --no-audit --no-fund >> "$LOG_FILE" 2>&1; }; then
    log "rollback package installed; activating managed runtime"
    NPM_ROOT="$(npm root -g 2>> "$LOG_FILE")"
    ROLLBACK_CLI="$NPM_ROOT/$NPM_PACKAGE/dist/grix.js"
    if [ -n "$NPM_ROOT" ] && [ -f "$ROLLBACK_CLI" ] \
       && node "$ROLLBACK_CLI" start >> "$LOG_FILE" 2>&1; then
        log "rollback succeeded and managed runtime activated; leaving pending for rollback report"
    else
        log "ROLLBACK ACTIVATION FAILED — package restored but managed runtime was not switched"
    fi
else
    log "ROLLBACK FAILED — manual intervention required"
fi
exit 0
