#!/usr/bin/env bash
#
# up_prod.sh — sobe e atualiza a produção com segurança (spec 17).
#
# Contrato:
#   ./up_prod.sh [--dry-run] [--no-pull] [--skip-migrate] [--branch <nome>]
#
# Propriedades: idempotente · sem interação · com lock · com rollback · nunca imprime segredos.
# Todas as fases rodam DENTRO do container (nunca no host). ADR-005/008/013/022/030/031.
#
set -Eeuo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"

# G-06: o `.env` guarda APP_KEY/DB/S3 e não pode ser legível por outro usuário do host.
# O umask vale para todo arquivo criado pelo deploy; o chmod corrige um `.env` que já
# existia com 644 (host compartilhado/cPanel).
umask 077
[ -f .env ] && chmod 600 .env || true

# ---------------------------------------------------------------- flags ----
DRY_RUN=0
PULL=1
MIGRATE=1
ALLOW_DIRTY=0
BRANCH=""
BACKUP_ONLY=0

usage() {
    cat <<'TXT'
up_prod.sh — subir/atualizar produção (spec 17)

  --dry-run        mostra o plano sem alterar nada
  --no-pull        não faz `git pull` (usa o código atual do checkout)
  --skip-migrate   não roda `migrate` (apenas quando o schema já está aplicado)
  --branch <nome>  faz checkout/seguimento da branch antes do pull
  --allow-dirty    permite deploy com alterações locais (rollback NÃO fará reset)
  --backup-only    só gera (e valida) o backup do banco, sem deploy — operação do dia a dia
  -h, --help       esta ajuda

Backup (ADR-042/F6.I2): **sempre** antes do build (um `pg_dump -Fc` é gerado em
`$STORAGE_PATH/backups/` e validado com `pg_restore --list`; dump que falha, sai vazio ou
ilegível ABORTA o deploy). Primeiro deploy (sem banco) pula o backup e avisa. Retenção:
`BACKUP_KEEP` (padrão 7). Cópia fora do host: `BACKUP_OFFSITE=1` + `BACKUP_DISK` (`s3` padrão,
`offsite` para um endpoint externo via `OFFSITE_*` — ADR-030/042). RustFS local **não** é
offsite: o script avisa.

Build: sempre `docker compose build --no-cache` (imagem limpa em produção). A validação
(`make check`) é **dev-only** — em produção o gate é o CI; o deploy só sobe um commit validado.
TXT
}

while [ $# -gt 0 ]; do
    case "$1" in
        --dry-run) DRY_RUN=1 ;;
        --no-pull) PULL=0 ;;
        --skip-migrate) MIGRATE=0 ;;
        --branch) shift; BRANCH="${1:-}"; [ -n "$BRANCH" ] || { echo "--branch exige um nome" >&2; exit 2; } ;;
        --branch=*) BRANCH="${1#*=}" ;;
        --allow-dirty) ALLOW_DIRTY=1 ;;
        --backup-only) BACKUP_ONLY=1 ;;
        -h | --help) usage; exit 0 ;;
        *) echo "flag desconhecida: $1" >&2; usage >&2; exit 2 ;;
    esac
    shift
done

# ------------------------------------------------------------- utilidades ----
if [ -t 1 ]; then
    C_RESET=$'\033[0m'; C_DIM=$'\033[2m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_RED=$'\033[31m'
else
    C_RESET=""; C_DIM=""; C_GRN=""; C_YEL=""; C_RED=""
fi

ts() { date -u +%Y-%m-%dT%H:%M:%SZ; }
log() { printf '%s %s\n' "${C_DIM}[$(ts)]${C_RESET}" "$*"; }
ok() { printf '%s %s%s%s\n' "${C_DIM}[$(ts)]${C_RESET}" "$C_GRN" "$*" "$C_RESET"; }
warn() { printf '%s %s%s%s\n' "${C_DIM}[$(ts)]${C_RESET}" "$C_YEL" "AVISO: $*" "$C_RESET"; }
die() { printf '%s %s%s%s\n' "${C_DIM}[$(ts)]${C_RESET}" "$C_RED" "ERRO: $*" "$C_RESET" >&2; exit 1; }

mkdir -p storage/logs
LOG_FILE="storage/logs/deploy-$(date -u +%Y%m%d-%H%M%S).log"
DEPLOY_COMMIT="?"
now_ms() {
    local value
    value="$(date +%s%3N 2>/dev/null || true)"
    case "$value" in
        ''|*[!0-9]*) value=$(( $(date +%s) * 1000 )) ;;
    esac
    printf '%s' "$value"
}
START_MS=$(now_ms)

# Log estruturado (ADR-024): uma linha JSON por evento, sem segredos.
json_log() {
    local action="$1" extra="${2:-}"
    printf '{"ts":"%s","module":"deploy","action":"%s","commit":"%s","total_ms":%s%s}\n' \
        "$(ts)" "$action" "$DEPLOY_COMMIT" "$(elapsed_ms)" "$extra" >>"$LOG_FILE"
}
elapsed_ms() {
    local now; now=$(now_ms)
    [ "$START_MS" = "0" ] && { echo 0; return; }
    echo $((now - START_MS))
}

# Executa (ecoando o comando) salvo em --dry-run.
run() {
    printf '%s %s' "${C_DIM}[$(ts)]${C_RESET}" "+"
    printf ' %q' "$@"
    printf '\n'
    if [ "$DRY_RUN" = 1 ]; then return 0; fi
    "$@"
}

# Lê um valor do `.env` sem `source` (evita execução de código do arquivo).
env_value() {
    [ -f .env ] || return 0
    grep -E "^$1=" .env | head -1 | cut -d= -f2- | sed -E 's/^"(.*)"$/\1/; s/^'"'"'(.*)'"'"'$/\1/' || true
}

# ------------------------------------------------------------ pré-checagem ----
# Runtime resolvido em um lugar. Produção é **Docker por decisão** (ADR-045): podman é
# opt-in exclusivo de desenvolvimento e é recusado aqui, antes de qualquer efeito
# (backup, build, migrate) — nunca se gera um deploy que ninguém reproduz.
# shellcheck source=tools/runtime.sh
. "$ROOT/tools/runtime.sh"
[ "$RUNTIME" = "docker" ] || die "produção roda Docker; podman é opt-in de dev e não é suportado em produção (ADR-045)."
command -v "${ENGINE[0]}" >/dev/null 2>&1 || die "${ENGINE[0]} não encontrado no PATH."
"${ENGINE[@]}" compose version >/dev/null 2>&1 || die "plugin '${ENGINE[0]} compose' indisponível."
command -v git >/dev/null 2>&1 || die "git não encontrado no PATH."
command -v curl >/dev/null 2>&1 || die "curl não encontrado no PATH (usado no healthcheck)."

[ -f .env ] || die "'.env' ausente. Rode: cp .env.example .env  (e ajuste os segredos)."
[ -d .git ] || die "não é um clone git ('.git' ausente)."

APP_ENV_V="$(env_value APP_ENV)"
if [ "$APP_ENV_V" != "production" ]; then
    warn "APP_ENV='${APP_ENV_V:-<vazio>}' (esperado 'production'). Seguindo mesmo assim."
fi
[ -n "$(env_value APP_KEY)" ] || warn "APP_KEY vazio — será gerada (php artisan key:generate)."

# Segredos **obrigatórios**: sem eles o Compose sobe com o fallback silencioso (ou nem
# sobe) e o deploy avança até o banco com a senha de exemplo. Falha aqui, antes de
# qualquer efeito (backup, build, migrate) — o nome da variável vai na mensagem.
for required in DB_PASSWORD REDIS_PASSWORD AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
    value="$(env_value "$required")"
    if [ -z "$value" ]; then
        die "variável obrigatória ausente no .env: ${required} (cp .env.example .env e defina)."
    fi
    if [ "$value" = "change-me" ]; then
        die "variável obrigatória ainda com o valor de exemplo no .env: ${required}."
    fi
done

if [ -n "$(env_value DATA_PATH)" ] && [ ! -d "$(env_value DATA_PATH)" ]; then
    warn "DATA_PATH='$(env_value DATA_PATH)' não existe — será criado pelo Docker (precisa de permissão)."
fi

git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "'.git' inválido."
DIRTY=0
if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null || [ -n "$(git status --porcelain 2>/dev/null)" ]; then
    DIRTY=1
fi
if [ "$DIRTY" = 1 ]; then
    if [ "$ALLOW_DIRTY" = 0 ]; then
        die "árvore de trabalho com alterações. Commit antes ou rode com --allow-dirty."
    fi
    warn "árvore suja (--allow-dirty): o rollback NÃO fará 'git reset --hard'."
fi

# --------------------------------------------------------------- compose ----
# Artefato único (ADR-032): `APP_ENV` é o ÚNICO interruptor de perfil. Não existe
# `docker-compose.prod.yml` — procurar um arquivo que não existe era código morto.
# `COMPOSE_FILES` é o artefato único do deploy (produção = Docker, ADR-045). O literal
# `-f docker-compose.yml` é intencional (gate negativo em security-check).
COMPOSE_FILES=(-f docker-compose.yml)
ENV_FILES=(--env-file .env)
PROJECT_NAME="$(bash "$ROOT/tools/project-name.sh")"
COMPOSE=("${ENGINE[@]}" compose -p "$PROJECT_NAME" "${ENV_FILES[@]}" "${COMPOSE_FILES[@]}")

WEB_PORT="$(env_value WEB_PORT)"; WEB_PORT="${WEB_PORT:-50700}"
HEALTH_URL="http://127.0.0.1:${WEB_PORT}/up"
DATA_PATH_V="$(env_value DATA_PATH)"; DATA_PATH_V="${DATA_PATH_V:-./s3-local}"
STORAGE_PATH_V="$(env_value STORAGE_PATH)"; STORAGE_PATH_V="${STORAGE_PATH_V:-./storage}"
BACKUP_DIR="${STORAGE_PATH_V%/}/backups"
BACKUP_KEEP="$(env_value BACKUP_KEEP)"; BACKUP_KEEP="${BACKUP_KEEP:-7}"
case "$BACKUP_KEEP" in ''|*[!0-9]*) warn "BACKUP_KEEP inválido ('$BACKUP_KEEP') — mantendo 7."; BACKUP_KEEP=7 ;; esac
[ "$BACKUP_KEEP" -ge 1 ] || BACKUP_KEEP=1
BACKUP_OFFSITE="$(env_value BACKUP_OFFSITE)"; BACKUP_OFFSITE="${BACKUP_OFFSITE:-0}"
BACKUP_DISK="$(env_value BACKUP_DISK)"; BACKUP_DISK="${BACKUP_DISK:-s3}"
OFFSITE_BUCKET_V="$(env_value OFFSITE_BUCKET)"
OFFSITE_MEDIA_BUCKET_V="$(env_value OFFSITE_MEDIA_BUCKET)"
AWS_ENDPOINT_V="$(env_value AWS_ENDPOINT)"

# G-15: o disco `s3` aponta para o RustFS do próprio host — ele não sobrevive à perda do host.
if [ "$BACKUP_OFFSITE" = 1 ] && [ "$BACKUP_DISK" = "s3" ]; then
    case "$AWS_ENDPOINT_V" in
        '' | *storage:9000* | *127.0.0.1* | *//localhost*)
            warn "BACKUP_OFFSITE=1 com o RustFS local NÃO é cópia fora do host — configure OFFSITE_BUCKET/OFFSITE_ENDPOINT e BACKUP_DISK=offsite (G-15)."
            ;;
    esac
fi
if [ "$BACKUP_OFFSITE" = 1 ] && [ "$BACKUP_DISK" = "offsite" ] && [ -z "$OFFSITE_BUCKET_V" ]; then
    warn "BACKUP_DISK=offsite sem OFFSITE_BUCKET — o push do backup vai falhar."
fi
DB_NAME="$(env_value DB_DATABASE)"; DB_NAME="${DB_NAME:-concordia}"
DB_USER="$(env_value DB_USERNAME)"; DB_USER="${DB_USER:-concordia}"

# ------------------------------------------------------------------ lock ----
# Impede dois deploys simultâneos (spec 17 §1). O fd 9 fica preso até o fim.
exec 9>"storage/.deploy.lock"
flock -n 9 || die "já existe um deploy em andamento (lock: storage/.deploy.lock)."

ROLLBACK_REF="$(git rev-parse HEAD)"
DEPLOY_COMMIT="$ROLLBACK_REF"
# Só arma o rollback depois do backup: falha antes dele não mexeu em nada (evita reset inútil).
ROLLBACK_ARMED=0

log "deploy iniciado (dry-run=${DRY_RUN} pull=${PULL} migrate=${MIGRATE} branch=${BRANCH:-<atual>})"
log "commit atual: $DEPLOY_COMMIT"
log "log: $LOG_FILE"
json_log "start" ",\"dry_run\":${DRY_RUN},\"pull\":${PULL},\"migrate\":${MIGRATE}"

# --------------------------------------------------------------- rollback ----
rollback() {
    local rc="$1"
    if [ "$DRY_RUN" = 1 ]; then return 0; fi
    if [ "$ROLLBACK_ARMED" != 1 ]; then
        warn "falha antes do backup/build — nada foi alterado; rollback não necessário."
        return 0
    fi
    printf '\n%s %s%s%s\n' "${C_DIM}[$(ts)]${C_RESET}" "$C_RED" "!! falha (rc=$rc) — rollback para $ROLLBACK_REF" "$C_RESET" >&2
    json_log "rollback" ",\"rc\":${rc}"
    if [ "$ALLOW_DIRTY" = 0 ]; then
        git reset --hard "$ROLLBACK_REF" >/dev/null 2>&1 || warn "git reset --hard falhou."
    else
        warn "rollback sem 'git reset --hard' (--allow-dirty) — nenhum commit foi revertido."
    fi
    "${COMPOSE[@]}" exec -T php-fpm composer install --no-dev --optimize-autoloader --classmap-authoritative || true
    "${COMPOSE[@]}" exec -T php-fpm php artisan optimize:clear || true
    "${COMPOSE[@]}" exec -T php-fpm php artisan core:cache-modules || true
    "${COMPOSE[@]}" exec -T php-fpm php artisan optimize || true
    "${COMPOSE[@]}" exec -T php-fpm sh -c 'kill -USR2 1' || true
    warn "rollback concluído — verifique $LOG_FILE."
}
trap 'rc=$?; if [ "$rc" -ne 0 ]; then rollback "$rc"; fi; exit "$rc"' EXIT

# -------------------------------------------------------- espera de saúde ----
wait_healthy() {
    local svc="$1" timeout="${2:-180}" waited=0 cid status
    while [ "$waited" -lt "$timeout" ]; do
        cid="$("${COMPOSE[@]}" ps -q "$svc" 2>/dev/null || true)"
        if [ -n "$cid" ]; then
            status="$("${ENGINE[@]}" inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null || echo unknown)"
            [ "$status" = "healthy" ] && { ok "  $svc: healthy"; return 0; }
        fi
        sleep 3
        waited=$((waited + 3))
    done
    die "$svc não ficou healthy em ${timeout}s."
}

# ------------------------------------------------------- backup do banco ----
# `pg_dump -Fc` do banco do V7 ANTES do `migrate` (ADR-042/F6.I2.T1). Sem backup não há migração:
# qualquer falha (json inválido, arquivo vazio, dump ilegível) aborta o deploy e aciona o rollback.
prune_backups() {
    local keep="${BACKUP_KEEP:-7}"
    case "$keep" in ''|*[!0-9]*) warn "BACKUP_KEEP inválido ('$keep') — mantendo 7."; keep=7 ;; esac
    [ "$keep" -ge 1 ] || keep=1
    # Determinístico: ordena por nome (timestamp UTC no nome), apaga os mais antigos além do limite.
    ls -1 "$BACKUP_DIR"/db-*.dump 2>/dev/null | sort -r | tail -n +"$((keep + 1))" | while read -r old; do
        rm -f "$old" && log "  backup antigo removido: $(basename "$old")"
    done
}

db_present() {
    [ -n "$("${COMPOSE[@]}" ps -q postgres 2>/dev/null)" ] || return 1
    "${COMPOSE[@]}" exec -T postgres psql -U "$DB_USER" -d postgres -tAc \
        "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" 2>/dev/null | grep -q 1
}

backup_db() {
    local ts file tmp bytes
    ts="$(date -u +%Y%m%dT%H%M%SZ)"
    file="$BACKUP_DIR/db-${ts}.dump"
    tmp="${file}.partial"
    log "[0.5/10] backup do banco (pg_dump -Fc) → ${file}"

    if [ "$DRY_RUN" = 1 ]; then
        printf '%s + pg_dump -Fc → %s (dry-run)\n' "${C_DIM}[$(ts)]${C_RESET}" "$file"
        return 0
    fi

    mkdir -p "$BACKUP_DIR"
    if ! "${COMPOSE[@]}" exec -T postgres pg_dump -Fc --no-owner --no-acl -U "$DB_USER" -d "$DB_NAME" > "$tmp"; then
        rm -f "$tmp"
        die "pg_dump falhou — deploy abortado (sem backup não há migrate)."
    fi
    if [ ! -s "$tmp" ]; then
        rm -f "$tmp"
        die "backup vazio — deploy abortado."
    fi
    if ! "${COMPOSE[@]}" exec -T postgres pg_restore --list < "$tmp" > /dev/null; then
        rm -f "$tmp"
        die "backup ilegível (pg_restore --list falhou) — deploy abortado."
    fi

    mv -f "$tmp" "$file"
    bytes="$(wc -c < "$file" | tr -d ' ')"
    ok "  backup ok: $(basename "$file") (${bytes} bytes)"
    prune_backups

    if [ "$BACKUP_OFFSITE" = 1 ]; then
        run "${COMPOSE[@]}" exec -T php-fpm php artisan core:backup:push "backups/$(basename "$file")" --disk="$BACKUP_DISK"
        # Retenção também fora do host (custo + LGPD/ADR-019). Falha aqui **não** derruba o deploy:
        # o backup local acabou de ser validado; a poda é higiene.
        "${COMPOSE[@]}" exec -T php-fpm php artisan core:backup:prune --keep="$BACKUP_KEEP" --disk="$BACKUP_DISK" >/dev/null 2>&1 \
            || warn "poda da cópia remota falhou (backup local está íntegro)."
    fi

    json_log "backup" ",\"phase\":5.9,\"bytes\":${bytes}"
}

# ------------------------------------------------- backup avulso (operação) ----
# `--backup-only`: roda só o backup (depois das definições) e sai — não puxa código nem migra.
if [ "$BACKUP_ONLY" = 1 ]; then
    backup_db
    ok "backup avulso concluído (nenhum deploy foi feito)."
    exit 0
fi

# -------------------------------------------------- bucket S3 idempotente ----
ensure_bucket() {
    if [ "$DRY_RUN" = 1 ]; then
        printf '%s +\n' "${C_DIM}[$(ts)]${C_RESET} php artisan storage:ensure-bucket"
        return 0
    fi
    local out rc=0
    out="$("${COMPOSE[@]}" exec -T php-fpm php artisan storage:ensure-bucket --no-interaction 2>&1)" || rc=$?
    if [ "$rc" -eq 0 ]; then
        ok "  bucket S3 garantido"
    else
        # Sem tolerância: uma falha aqui significa mídia sem balde (404 em produção) — não há
        # "comando ausente" a perdoar, o comando é parte do app.
        printf '%s\n' "$out" >&2
        die "falha ao garantir o bucket S3 (ver $LOG_FILE)."
    fi

    # O bucket **offsite da mídia** (D7): o destino do backup. Só é garantido quando está
    # configurado (OFFSITE_MEDIA_BUCKET) — sem destino não há o que garantir.
    if [ -n "${OFFSITE_MEDIA_BUCKET_V:-}" ]; then
        rc=0
        out="$("${COMPOSE[@]}" exec -T php-fpm php artisan storage:ensure-bucket --disk=offsite-media --no-interaction 2>&1)" || rc=$?
        if [ "$rc" -eq 0 ]; then
            ok "  bucket offsite da mídia garantido"
        else
            printf '%s\n' "$out" >&2
            die "falha ao garantir o bucket offsite da mídia (ver $LOG_FILE)."
        fi
    else
        warn "  OFFSITE_MEDIA_BUCKET vazio: a mídia não tem cópia fora do host."
    fi
}

# =========================================================== FASES =========
# 0) Lock já adquirido acima; commit registrado.

# 0.4) Retenção do WAL arquivado (G-14). Roda **antes** do backup: o `archive_command`
# não poda nada e o diretório cresce até o disco encher — com o disco cheio, o `pg_dump`
# do fase 0.5 falha. A poda é por idade e idempotente; quem tem cron diário (o runbook
# traz a linha) ganha a poda de todo dia, e o deploy cobre quem não tem.
phase_start=$(elapsed_ms)
if [ "$DRY_RUN" = 0 ]; then
    bash "$ROOT/tools/wal-retention.sh" --json || warn "retenção do WAL falhou (não aborta o deploy)."
else
    log "[0.4/10] retenção do WAL pulada (--dry-run)."
fi
json_log "wal_retention" ",\"phase\":0.4,\"duration_ms\":$(( $(elapsed_ms) - phase_start ))"

# 0.5) Backup SEMPRE antes do build (ADR-042/F6.I2). Primeiro deploy não tem banco: pula.
phase_start=$(elapsed_ms)
if [ "$DRY_RUN" = 1 ] || db_present; then
    backup_db
else
    log "[0.5/10] banco ainda não existe — backup pulado (primeiro deploy)."
fi
ROLLBACK_ARMED=1
json_log "backup_phase" ",\"phase\":0.5,\"duration_ms\":$(( $(elapsed_ms) - phase_start ))"

# 1) Código
if [ "$PULL" = 1 ]; then
    phase_start=$(elapsed_ms)
    run git fetch --prune --tags
    if [ -n "$BRANCH" ]; then
        run git checkout "$BRANCH"
        run git pull --ff-only origin "$BRANCH"
    else
        run git pull --ff-only
    fi
    json_log "code" ",\"phase\":1,\"duration_ms\":$(( $(elapsed_ms) - phase_start ))"
else
    log "[1/10] pull desabilitado (--no-pull)."
fi

# 2) Imagens
phase_start=$(elapsed_ms)
if [ "$PULL" = 1 ]; then
    run "${COMPOSE[@]}" pull --ignore-pull-failures
else
    log "[2/10] pull de imagens desabilitado (--no-pull)."
fi
run "${COMPOSE[@]}" build --no-cache php-fpm
json_log "images" ",\"phase\":2,\"duration_ms\":$(( $(elapsed_ms) - phase_start ))"

# 3) Subir
phase_start=$(elapsed_ms)
if [ "$DRY_RUN" = 0 ]; then
    # Volumes do host devem pertencer ao usuário do deploy (RustFS roda non-root, ADR-031).
    # O helper também recria o guard do git (s3-local/.gitignore) — ver tools/data-dirs.sh.
    bash "$ROOT/tools/data-dirs.sh" "$DATA_PATH_V" \
        || die "não foi possível criar '$DATA_PATH_V' (permissão?). Ajuste DATA_PATH no .env."
fi
run "${COMPOSE[@]}" up -d --remove-orphans
json_log "up" ",\"phase\":3,\"duration_ms\":$(( $(elapsed_ms) - phase_start ))"

# 4) Esperar infra saudável
if [ "$DRY_RUN" = 0 ]; then
    log "[4/10] aguardando postgres/valkey/storage..."
    wait_healthy postgres 180
    wait_healthy valkey 120
    wait_healthy storage 120
    # O worker tem healthcheck (D5): um worker morto deixa o trabalho assíncrono
    # (mídia, auditoria) parado sem ninguém notar.
    wait_healthy queue 120
    wait_healthy php-fpm 120

    # 4.6) Runtime PHP corrigido (CVE-2026-109618/109619). O build é `--no-cache`,
    # mas uma imagem/tag antiga ainda pode ser reutilizada; o deploy não segue com
    # um PHP vulnerável. 8.4.27+ / 8.5.12+ são as primeiras releases corrigidas.
    php_runtime="$("${COMPOSE[@]}" exec -T php-fpm php -r 'echo PHP_VERSION;' | tr -d '\r\n')"
    case "$php_runtime" in
        8.4.*)
            php_patch="${php_runtime#8.4.}"
            case "$php_patch" in ''|*[!0-9]*) die "PHP runtime inválido: '$php_runtime'" ;; esac
            [ "$php_patch" -ge 27 ] || die "PHP $php_runtime < 8.4.27 (php2shell) — deploy abortado."
            ;;
        8.5.*)
            php_patch="${php_runtime#8.5.}"
            case "$php_patch" in ''|*[!0-9]*) die "PHP runtime inválido: '$php_runtime'" ;; esac
            [ "$php_patch" -ge 12 ] || die "PHP $php_runtime < 8.5.12 (php2shell) — deploy abortado."
            ;;
        *) die "PHP $php_runtime fora dos branches corrigidos (8.4.27+/8.5.12+) — deploy abortado." ;;
    esac
    log "[4.6/10] PHP runtime corrigido: $php_runtime"
    json_log "php_runtime" ",\"phase\":4.6,\"version\":\"${php_runtime}\""
fi

# 4.5) APP_KEY (clone limpo)
if [ -z "$(env_value APP_KEY)" ]; then
    run "${COMPOSE[@]}" exec -T php-fpm php artisan key:generate --force
fi

# 5) Dependências PHP
phase_start=$(elapsed_ms)
run "${COMPOSE[@]}" exec -T php-fpm composer install --no-dev --optimize-autoloader --classmap-authoritative
json_log "composer" ",\"phase\":5,\"duration_ms\":$(( $(elapsed_ms) - phase_start ))"

# 5.5) Assets (Vite) — build nunca em runtime (ADR-002/008), sempre offline/pinado.
if [ -f package.json ] && [ -f package-lock.json ]; then
    phase_start=$(elapsed_ms)
    run "${ENGINE[@]}" run --rm "${RUN_ARGS[@]}" -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$ROOT":/app"$MOUNT_LABEL" -w /app \
        node:26-alpine@sha256:6f08a523443ddedf52941874c203475b0ad503e859ad70cb26ab8be9a517dbd5 \
        sh -c 'npm ci && npm run build'
    json_log "assets" ",\"phase\":5.5,\"duration_ms\":$(( $(elapsed_ms) - phase_start ))"
fi

# 6) Dados
phase_start=$(elapsed_ms)
if [ "$MIGRATE" = 1 ]; then
    # Backup já aconteceu na fase 0.5 (antes do build) — daqui só o schema muda.
    # Evidência (G-19): registrar a lista de pendências antes de aplicar — um `migrate`
    # inesperado fica visível no log/JSON, não só no estado do banco.
    run "${COMPOSE[@]}" exec -T php-fpm php artisan migrate:status
    run "${COMPOSE[@]}" exec -T php-fpm php artisan migrate --force --isolated
else
    log "[6/10] migrate pulado (--skip-migrate)."
fi
json_log "migrate" ",\"phase\":6,\"duration_ms\":$(( $(elapsed_ms) - phase_start ))"

# 7) Otimizar (+ bucket S3; symlink público removido — ADR-030, domínio vive no `s3`)
phase_start=$(elapsed_ms)
run "${COMPOSE[@]}" exec -T php-fpm php artisan optimize:clear
run "${COMPOSE[@]}" exec -T php-fpm php artisan core:cache-modules
run "${COMPOSE[@]}" exec -T php-fpm php artisan optimize
ensure_bucket
# SBOM determinístico do release (ADR-020): mesma fonte que o `make check`.
run "${COMPOSE[@]}" exec -T php-fpm sh -c 'php tools/sbom.php > sbom.cdx.json'
json_log "optimize" ",\"phase\":7,\"duration_ms\":$(( $(elapsed_ms) - phase_start ))"

# 8) Recarregar FPM/queue/Caddy
phase_start=$(elapsed_ms)
# G-19: o USR2 é reload gracioso — o master do FPM mantém o PID e recicla os workers.
# Registrar o PID antes/depois é a prova de que o OPcache foi limpo sem downtime.
fpm_master_pid() {
    [ "$DRY_RUN" = 1 ] && { printf '(dry-run)'; return 0; }
    "${COMPOSE[@]}" exec -T php-fpm sh -c 'pgrep -f "php-fpm: master" | head -1' 2>/dev/null | tr -d '\r' || true
}
fpm_pid_before="$(fpm_master_pid)"
run "${COMPOSE[@]}" exec -T php-fpm sh -c 'kill -USR2 1'
run "${COMPOSE[@]}" exec -T php-fpm php artisan queue:restart
run "${COMPOSE[@]}" exec -T web caddy validate --config /etc/caddy/Caddyfile
# Caddyfile com `admin off` (ADR-020): reload = restart controlado do `web`.
run "${COMPOSE[@]}" restart web
fpm_pid_after="$(fpm_master_pid)"
log "OPcache recarregado (USR2): master do FPM ${fpm_pid_before} → ${fpm_pid_after} (PID igual = reload, não restart)."
json_log "reload" ",\"phase\":8,\"fpm_pid\":\"${fpm_pid_before}\",\"fpm_pid_after\":\"${fpm_pid_after}\",\"duration_ms\":$(( $(elapsed_ms) - phase_start ))"

# 9) Health
phase_start=$(elapsed_ms)
if [ "$DRY_RUN" = 0 ]; then
    tries=0
    until curl -fsS -o /dev/null "$HEALTH_URL"; do
        tries=$((tries + 1))
        [ "$tries" -ge 30 ] && die "healthcheck falhou: $HEALTH_URL"
        sleep 2
    done
    ok "  $HEALTH_URL respondeu 200"
else
    log "[9/10] healthcheck pulado (--dry-run)."
fi
json_log "health" ",\"phase\":9,\"duration_ms\":$(( $(elapsed_ms) - phase_start ))"

# 10) Evidência
if [ "$DRY_RUN" = 0 ]; then
    run "${COMPOSE[@]}" exec -T php-fpm php artisan about --only=environment,cache,drivers
fi

trap - EXIT
ok "deploy concluído em $(elapsed_ms) ms (commit $DEPLOY_COMMIT)."
json_log "done"
log "log: $LOG_FILE"
