#!/usr/bin/env bash
#
# scripts/lib/common.sh — contrato comum dos ciclos de vida de produção (init/update).
#
# NÃO é um entrypoint e NUNCA depende de `make`: as fases falam direto com Docker/Compose,
# git, curl, tar e com os helpers de `tools/`. O arquivo concentra o que é transversal
# (dry-run, log, leitura SEGURA do .env, nome único do projeto, validação de ambiente,
# healthcheck), para que cada fase em scripts/init|update seja pequena e testável sozinha.
#
# Contrato herdado pelas fases (exportado pelo orquestrador):
#   ROOT SCRIPTS ENV_FILE SCRIPT_KIND DRY_RUN ALLOW_DIRTY NO_PULL BUILD_NO_CACHE
#   PROJECT_NAME COMPOSE_PROJECT_NAME PHP_IMAGE PHP_TAG UID GID MOUNT_LABEL
#   LOG_FILE RUN_ID STATE_FILE NODE_IMAGE
#   (o array COMPOSE é reconstruído por project_resolve em cada processo)
#
# Regras:
#   - DRY_RUN=1 é o padrão: nenhuma mutação (docker up/down/build, git, arquivo, tar...).
#   - Segredos nunca são impressos nem vão para a linha de comando do host.
#   - `set -Eeuo pipefail` é responsabilidade do entrypoint/fase, não deste arquivo.

# shellcheck disable=SC2034
COMMON_SH_SOURCED=1

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)"
SCRIPTS="$ROOT/scripts"
ENV_FILE="$ROOT/.env"
SCRIPT_KIND="${SCRIPT_KIND:-script}"
DRY_RUN="${DRY_RUN:-1}"
ALLOW_DIRTY="${ALLOW_DIRTY:-0}"
NO_PULL="${NO_PULL:-0}"
BUILD_NO_CACHE="${BUILD_NO_CACHE:-1}"
NODE_IMAGE="${NODE_IMAGE:-node:26-alpine@sha256:6f08a523443ddedf52941874c203475b0ad503e859ad70cb26ab8be9a517dbd5}"
STATE_FILE="${STATE_FILE:-$ROOT/storage/backups/.${SCRIPT_KIND}-state}"
LOG_FILE="${LOG_FILE:-}"
RUN_ID="${RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)}"
export RUN_ID

case "$DRY_RUN" in
    0 | 1) ;;
    *)
        printf 'common: DRY_RUN inválido (%s); use 0 ou 1.\n' "$DRY_RUN" >&2
        exit 2
        ;;
esac

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

# O checkout é a referência de todos os caminhos relativos (DATA_PATH, storage, tools).
cd "$ROOT"

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

# Diagnóstico no local exato da falha: sem isto um `postgres` unhealthy aborta o `up`
# com uma linha genérica do Compose e nenhuma pista do motivo real.
dump_compose_logs() {
    [ "$DRY_RUN" = 1 ] && return 0
    [ "${#COMPOSE[@]}" -gt 0 ] || return 0
    "${COMPOSE[@]}" ps 2>&1 || true
    local svc
    for svc in postgres valkey storage php-fpm queue web; do
        printf '\n--- logs %s (últimas 80) ---\n' "$svc"
        "${COMPOSE[@]}" logs --no-color --tail=80 "$svc" 2>&1 || true
    done
}

# Executa (ou só descreve, em dry-run) um comando. Nunca recebe segredo em argv.
run() {
    if [ "$DRY_RUN" = 1 ]; then
        printf '%s [dry-run] %s\n' "$(ts)" "$(printf '%q ' "$@")"
        return 0
    fi
    printf '%s + %s\n' "$(ts)" "$(printf '%q ' "$@")"
    "$@"
}

ensure_dir() {
    if [ "$DRY_RUN" = 1 ]; then
        log "[dry-run] mkdir -p $1"
        return 0
    fi
    mkdir -p "$1"
}

# Lê uma chave do .env SEM `source` (o .env pode conter qualquer coisa; sourcing é execução).
env_value() {
    local key="$1" def="${2:-}" raw=''
    if [ -f "$ENV_FILE" ]; then
        raw="$(grep -E "^${key}=" "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2- || true)"
        raw="${raw%$'\r'}"
        raw="$(printf '%s' "$raw" | sed -E 's/^"(.*)"$/\1/; s/^'\''(.*)'\''$/\1/')"
        if [ -n "$raw" ]; then
            printf '%s' "$raw"
            return 0
        fi
    fi
    printf '%s' "$def"
}

env_has() {
    [ -f "$ENV_FILE" ] && grep -qE "^$1=" "$ENV_FILE"
}

# Atualiza (ou acrescenta) UMA chave no .env, preservando modo e demais linhas.
# Nunca use para segredos (o valor apareceria no log).
env_set() {
    local key="$1" val="$2" tmp
    [ -f "$ENV_FILE" ] || die ".env ausente (${ENV_FILE})"
    if [ "$DRY_RUN" = 1 ]; then
        log "[dry-run] .env: ${key}=${val}"
        return 0
    fi
    tmp="$(mktemp "${ENV_FILE}.XXXXXX")"
    awk -v k="$key" -v v="$val" '
        BEGIN { FS = OFS = "=" }
        $1 == k { print k "=" v; found = 1; next }
        { print }
        END { if (!found) print k "=" v }
    ' "$ENV_FILE" >"$tmp"
    chmod --reference="$ENV_FILE" "$tmp" 2>/dev/null || chmod 600 "$tmp"
    mv -f "$tmp" "$ENV_FILE"
}

url_host() {
    printf '%s' "$1" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://##; s#/.*$##; s#^[^@]*@##; s#:[0-9]+$##'
}

is_git() {
    [ -d "$ROOT/.git" ] && git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1
}

git_commit() {
    if is_git; then
        git -C "$ROOT" rev-parse --short HEAD
    else
        printf 'sem-git'
    fi
}

# Nome do projeto: explícito (.env/ambiente) vence; senão slug(APP_NAME)+hash8 do checkout.
# Também fixa a imagem php-fpm POR PROJETO: nada de tag compartilhada entre instalações.
project_resolve() {
    local explicit img tag
    explicit="$(env_value COMPOSE_PROJECT_NAME)"
    [ -n "${COMPOSE_PROJECT_NAME:-}" ] && explicit="$COMPOSE_PROJECT_NAME"
    if [ -n "$explicit" ]; then
        PROJECT_NAME="$explicit"
        PROJECT_PINNED=1
    else
        PROJECT_NAME="$(bash "$ROOT/tools/project-name.sh")"
        PROJECT_PINNED=0
    fi
    case "$PROJECT_NAME" in
        [a-z0-9]*) ;;
        *) die "nome de projeto Compose inválido (deve começar com [a-z0-9]): '${PROJECT_NAME}'" ;;
    esac
    case "$PROJECT_NAME" in
        *[!a-z0-9_-]*) die "nome de projeto Compose inválido (use [a-z0-9_-]): '${PROJECT_NAME}'" ;;
    esac
    export COMPOSE_PROJECT_NAME="$PROJECT_NAME"

    img="$(env_value PHP_IMAGE)"
    [ -n "${PHP_IMAGE:-}" ] && img="$PHP_IMAGE"
    case "$img" in
        '' | concordia/php-fpm) img="${PROJECT_NAME}-php-fpm" ;;
    esac
    PHP_IMAGE="$img"
    tag="$(env_value PHP_TAG)"
    PHP_TAG="${PHP_TAG:-${tag:-local}}"
    case "$PHP_TAG" in
        '' | *[!A-Za-z0-9_.-]*) die "PHP_TAG inválido: '${PHP_TAG}'" ;;
    esac
    export PHP_IMAGE PHP_TAG

    export UID 2>/dev/null || true
    GID="${GID:-$(id -g)}"
    export GID
    MOUNT_LABEL=":Z"
    export MOUNT_LABEL
    export CONTAINER_RUNTIME=docker

    COMPOSE=(docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml")
    resolve_stack
    runtime_vars
}

# Qual stack está VIVA para esta instalação? A do nome novo ou, na primeira execução após
# a ADR-055, a stack anterior do próprio checkout (que o update adota). `STACK_COMPOSE` é
# usada pelo backup/start/down da adoção; `COMPOSE` continua sendo o destino.
resolve_stack() {
    local legacy count
    STACK_PROJECT=''
    ADOPT=0
    if docker ps -a --filter "label=com.docker.compose.project=${PROJECT_NAME}" --format '{{.Names}}' 2>/dev/null | grep -q .; then
        STACK_PROJECT="$PROJECT_NAME"
    else
        legacy="$(checkout_stacks)"
        count="$(printf '%s\n' "$legacy" | sed '/^$/d' | wc -l | tr -d ' ')"
        if [ "$count" = "1" ]; then
            STACK_PROJECT="$legacy"
            ADOPT=1
        fi
    fi
    if [ -n "$STACK_PROJECT" ]; then
        STACK_COMPOSE=(docker compose -p "$STACK_PROJECT" --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml")
    else
        STACK_COMPOSE=()
    fi
    export STACK_PROJECT ADOPT
}

# Variáveis derivadas (não validam nada; validate_env valida).
runtime_vars() {
    APP_ENV_V="$(env_value APP_ENV)"
    APP_URL_V="$(env_value APP_URL)"
    WEB_PORT="$(env_value WEB_PORT 50700)"
    DATA_PATH_V="$(env_value DATA_PATH ./s3-local)"
    STORAGE_PATH_V="$(env_value STORAGE_PATH ./storage)"
    BACKUP_KEEP="$(env_value BACKUP_KEEP 7)"
    BACKUP_DISK_V="$(env_value BACKUP_DISK s3)"
    AWS_ENDPOINT_V="$(env_value AWS_ENDPOINT http://storage:9000)"
    OFFSITE_MEDIA_BUCKET_V="$(env_value OFFSITE_MEDIA_BUCKET)"

    case "$DATA_PATH_V" in
        /*) DATA_ABS="$(realpath -m -- "$DATA_PATH_V")" ;;
        *) DATA_ABS="$(realpath -m -- "$ROOT/$DATA_PATH_V")" ;;
    esac
    case "$STORAGE_PATH_V" in
        /*) STORAGE_ABS="$(realpath -m -- "$STORAGE_PATH_V")" ;;
        *) STORAGE_ABS="$(realpath -m -- "$ROOT/$STORAGE_PATH_V")" ;;
    esac
    BACKUP_DIR="${STORAGE_ABS%/}/backups"
    PORTAL_HOST="$(url_host "$APP_URL_V")"
    case "$PORTAL_HOST" in
        '') CMS_HOST='' ;;
        *) CMS_HOST="cms.${PORTAL_HOST}" ;;
    esac
}

validate_web_port() {
    case "$WEB_PORT" in
        '' | *[!0-9]*) die "WEB_PORT inválida: '${WEB_PORT}'" ;;
    esac
    [ "$WEB_PORT" -ge 1024 ] && [ "$WEB_PORT" -le 65535 ] ||
        die "WEB_PORT fora da faixa 1024-65535: '${WEB_PORT}'"
}

backup_keep() {
    case "$BACKUP_KEEP" in
        '' | *[!0-9]*) BACKUP_KEEP=7 ;;
    esac
    [ "$BACKUP_KEEP" -ge 1 ] || BACKUP_KEEP=1
}

# Validação de ambiente de PRODUÇÃO. `mode` = init (APP_KEY vazia é esperada) | update (obrigatória).
validate_env() {
    local mode="$1" v key
    [ -f "$ENV_FILE" ] || die ".env ausente em ${ENV_FILE}. Rode: cp .env.example .env"

    v="$(env_value APP_NAME)"
    [ -n "$v" ] || die "APP_NAME ausente no .env"
    [ "$APP_ENV_V" = production ] ||
        die "APP_ENV='${APP_ENV_V:-<vazio>}' — estes scripts são de produção (APP_ENV=production)."
    case "$APP_URL_V" in
        https://*) ;;
        *) die "APP_URL deve ser https:// em produção (valor: '${APP_URL_V:-<vazio>}')." ;;
    esac
    [ -n "$PORTAL_HOST" ] || die "APP_URL sem host válido: '${APP_URL_V}'"
    case "$PORTAL_HOST" in
        example.gov.br | example.com | localhost | 127.0.0.1 | 0.0.0.0)
            die "APP_URL ainda é placeholder ('${PORTAL_HOST}'); defina o domínio real."
            ;;
    esac

    for key in DB_DATABASE DB_USERNAME DB_PASSWORD REDIS_PASSWORD AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
        v="$(env_value "$key")"
        [ -n "$v" ] || die "variável obrigatória ausente no .env: ${key}"
        case "$v" in
            change-me | CHANGE_ME | changeme | example | your-* | xxxx*)
                die "variável '${key}' ainda com valor de exemplo no .env."
                ;;
        esac
    done

    v="$(env_value DB_CONNECTION pgsql)"
    [ "$v" = pgsql ] || die "DB_CONNECTION='${v}' — o V7 é PostgreSQL (pgsql)."
    v="$(env_value DB_HOST postgres)"
    [ "$v" = postgres ] ||
        die "DB_HOST='${v}' — o backup/restore destes scripts cobre apenas o postgres do Compose."

    v="$(env_value CONTAINER_RUNTIME docker)"
    [ "$v" = docker ] ||
        die "CONTAINER_RUNTIME='${v}' — produção roda Docker (ADR-045); podman é opt-in de desenvolvimento."

    case "$DATA_ABS" in
        "$ROOT"/*)
            warn "DATA_PATH dentro do checkout (${DATA_ABS}); em cPanel/LVE a cota do home pode esgotar antes do PGDATA. Para produção use um caminho fora da cota (ADR-006)."
            ;;
    esac

    validate_web_port
    backup_keep

    if [ "$mode" = update ] && [ -z "$(env_value APP_KEY)" ]; then
        die "APP_KEY vazia em update. Rode o init (ou gere a chave) antes de atualizar."
    fi
}

require_tools() {
    local t
    for t in docker git flock curl tar gzip sha256sum stat awk sed grep sort cut mktemp realpath find df date; do
        command -v "$t" >/dev/null 2>&1 || die "comando obrigatório ausente no PATH: ${t}"
    done
    docker compose version >/dev/null 2>&1 || die "plugin 'docker compose' indisponível."
    docker info --format '{{.ServerVersion}}' >/dev/null 2>&1 || die "daemon Docker inacessível."
    [ "$(id -u)" -ne 0 ] ||
        die "rode como o usuário do deploy (não root): arquivos do checkout ficariam root-owned."
    if docker info --format '{{.SecurityOptions}}' 2>/dev/null | grep -qi rootless; then
        warn "Docker rootless detectado: o container postgres (entrypoint root) pode falhar no bind/cota; os logs da fase de up mostrarão o motivo."
    fi
}

check_dirty() {
    if ! is_git; then
        warn "checkout sem .git — verificação de árvore suja indisponível (modo teste)."
        return 0
    fi
    if [ -n "$(git -C "$ROOT" status --porcelain 2>/dev/null)" ]; then
        if [ "$ALLOW_DIRTY" = 1 ]; then
            warn "árvore de trabalho suja (--allow-dirty): o update não fará reset de código."
        else
            die "árvore de trabalho com alterações. Faça commit ou rode com --allow-dirty."
        fi
    fi
}

disk_check() {
    local free_kb
    free_kb="$(df -Pk "$ROOT" | awk 'NR==2 { print $4 }')"
    case "$free_kb" in '' | *[!0-9]*) warn "não foi possível ler o espaço livre"; return 0 ;; esac
    if [ "$free_kb" -lt 262144 ]; then
        die "menos de 256 MiB livres em ${ROOT} (${free_kb} KiB): build/backup podem falhar."
    fi
    if [ "$free_kb" -lt 2097152 ]; then
        warn "menos de 2 GiB livres em ${ROOT} (${free_kb} KiB)."
    fi
}

project_collision() {
    local owners
    owners="$(docker ps -a --filter "label=com.docker.compose.project=${PROJECT_NAME}" \
        --format '{{.Label "com.docker.compose.project.working_dir"}}' |
        sed '/^$/d' | sort -u | grep -vxF "$ROOT" || true)"
    [ -z "$owners" ] ||
        die "projeto Compose '${PROJECT_NAME}' já é usado por outro checkout: ${owners//$'\n'/, }. Defina COMPOSE_PROJECT_NAME no .env."
}

# Porta publicada é o único recurso que o nome do projeto não isola.
port_collision() {
    local owners
    owners="$(docker ps --filter "publish=${WEB_PORT}" --format '{{.Names}}	{{.Label "com.docker.compose.project"}}	{{.Label "com.docker.compose.project.working_dir"}}' |
        while IFS=$'\t' read -r container project workdir; do
            [ -n "$container" ] || continue
            [ "$project" = "$PROJECT_NAME" ] && continue
            # A stack anterior DESTE checkout (adoção) pode estar na mesma porta: o update
            # a derruba antes de subir o projeto novo. Outro checkout nunca é permitido.
            [ -n "$workdir" ] && [ "$workdir" = "$ROOT" ] && continue
            printf '%s\n' "$container"
        done)"
    [ -z "$owners" ] ||
        die "WEB_PORT=${WEB_PORT} já é publicada por '${owners//$'\n'/, }'. Use uma porta própria no .env e ajuste o ProxyPass do Apache (ADR-005/055)."
}

# Stacks deste checkout sob OUTRO nome (instalação anterior à ADR-055). Não são adotadas aqui:
# iniciar um projeto novo com o PGDATA antigo num volume anônimo pareceria "banco vazio".
checkout_stacks() {
    docker ps -a --filter "label=com.docker.compose.project.working_dir=$ROOT" \
        --format '{{.Label "com.docker.compose.project"}}' |
        sed '/^$/d' | sort -u | grep -vxF "$PROJECT_NAME" || true
}

refuse_legacy_stack() {
    local old
    old="$(checkout_stacks)"
    if [ -n "$old" ]; then
        die "instalação anterior deste checkout sob outro projeto Compose (${old//$'\n'/, }). Rode scripts/update_prod.sh: ele faz o backup, adota o PGDATA com tools/pgdata-adopt.sh e sobe o projeto novo (ADR-055)."
    fi
}

# Marcador de posse do DATA_PATH: impede que dois checkouts apontem para o mesmo PGDATA.
write_owner_marker() {
    local marker="$DATA_ABS/.concordia-owner"
    if [ "$DRY_RUN" = 1 ]; then
        log "[dry-run] marcador de posse: ${marker}"
        return 0
    fi
    ensure_dir "$DATA_ABS"
    {
        printf 'project=%s\n' "$PROJECT_NAME"
        printf 'checkout=%s\n' "$ROOT"
        printf 'image=%s:%s\n' "$PHP_IMAGE" "$PHP_TAG"
        printf 'created=%s\n' "$(ts)"
    } >"$marker"
    chmod 600 "$marker"
}

acquire_lock() {
    if [ "$DRY_RUN" = 1 ]; then
        log "[dry-run] lock não adquirido (nenhuma mutação será feita)"
        return 0
    fi
    mkdir -p "$ROOT/storage"
    exec 9>"$ROOT/storage/.deploy.lock"
    flock -n 9 || die "outro deploy está em andamento (lock: storage/.deploy.lock)."
}

service_health() {
    local svc="$1" id state
    id="$("${COMPOSE[@]}" ps -q "$svc" 2>/dev/null || true)"
    if [ -z "$id" ]; then
        printf 'ausente'
        return 0
    fi
    state="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$id" 2>/dev/null || printf 'desconhecido')"
    printf '%s' "$state"
}

# `start` (que preserva o container antigo) não tem `--wait`: a espera é explícita.
wait_healthy() {
    local svc="$1" timeout="${2:-180}" i=0 state
    while [ "$i" -lt "$timeout" ]; do
        state="$(service_health "$svc")"
        case "$state" in
            healthy | running)
                log "  ${svc}: ${state}"
                return 0
                ;;
            unhealthy | exited | dead)
                dump_compose_logs
                die "serviço '${svc}' em estado '${state}'"
                ;;
        esac
        sleep 2
        i=$((i + 2))
    done
    dump_compose_logs
    die "timeout (${timeout}s) aguardando '${svc}' (último estado: $(service_health "$svc"))"
}

wait_stack() {
    local timeout="${1:-180}" svc
    for svc in postgres valkey storage php-fpm queue web; do
        wait_healthy "$svc" "$timeout"
    done
}

http_code() {
    local host="$1" path="$2"
    curl -sS -o /dev/null -w '%{http_code}' --max-time 10 -H "Host: ${host}" \
        "http://127.0.0.1:${WEB_PORT}${path}" 2>/dev/null || true
}

expect_http() {
    local host="$1" path="$2" want="$3" code
    code="$(http_code "$host" "$path")"
    case " ${want} " in
        *" ${code} "*) ok "HTTP ${code} — http://127.0.0.1:${WEB_PORT}${path} (Host: ${host})" ;;
        *) die "HTTP ${code:-<sem resposta>} em ${path} (Host: ${host}); esperado: ${want}" ;;
    esac
}

state_set() {
    local key="$1" val="$2"
    [ "$DRY_RUN" = 1 ] && return 0
    mkdir -p "$(dirname "$STATE_FILE")"
    printf '%s=%s\n' "$key" "$val" >>"$STATE_FILE"
}

state_get() {
    [ -f "$STATE_FILE" ] || return 0
    grep -E "^$1=" "$STATE_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || true
}

phase_begin() {
    PHASE_NAME="$1"
    PHASE_START="$(date +%s)"
    export PHASE_NAME
    step "[${PHASE_NAME}] início"
    trap 'phase_err "$LINENO"' ERR
}

phase_err() {
    local rc=$?
    printf '%s %sERRO:%s fase %s falhou na linha %s (exit %s)\n' \
        "${C_DIM}$(ts)${C_RESET}" "$C_RED" "$C_RESET" "${PHASE_NAME:-?}" "$1" "$rc" >&2
}

phase_end() {
    local elapsed=$(( $(date +%s) - PHASE_START ))
    ok "[${PHASE_NAME}] concluída em ${elapsed}s"
}

# ------------------------------------------------------------------ orquestração
# Executa as fases em ordem num único log (tee), com lock global e resumo final.
# Uso: run_lifecycle <init|update> <00-fase> [01-fase ...]
run_lifecycle() {
    local kind="$1"
    shift
    local -a phases=("$@")
    local phase failed='' v
    RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
    SCRIPT_KIND="$kind"
    export RUN_ID SCRIPT_KIND
    STATE_FILE="$ROOT/storage/backups/.${SCRIPT_KIND}-state"
    export STATE_FILE

    mkdir -p "$ROOT/storage/logs"
    LOG_FILE="$ROOT/storage/logs/${SCRIPT_KIND}-$(
        [ "$DRY_RUN" = 1 ] && printf 'dry-run' || printf 'execute'
    )-${RUN_ID}.log"
    : >"$LOG_FILE"
    chmod 600 "$LOG_FILE" 2>/dev/null || true
    export LOG_FILE

    exec > >(tee -a "$LOG_FILE") 2>&1

    printf '%s\n' "================================================================"
    printf '%s\n' " ${SCRIPT_KIND^^} PROD — $(ts)"
    printf '%s\n' " checkout : ${ROOT}"
    printf '%s\n' " projeto  : ${PROJECT_NAME}"
    printf '%s\n' " imagem   : ${PHP_IMAGE}:${PHP_TAG}"
    printf '%s\n' " porta    : 127.0.0.1:${WEB_PORT} (Host ${PORTAL_HOST:-?})"
    printf '%s\n' " env      : ${ENV_FILE}"
    printf '%s\n' " commit   : $(git_commit)"
    printf '%s\n' " docker   : $(docker --version 2>/dev/null | cut -d, -f1)"
    printf '%s\n' " modo     : $([ "$DRY_RUN" = 1 ] && printf 'DRY-RUN (padrão; nada será alterado)' || printf 'EXECUÇÃO (--execute)')"
    printf '%s\n' "================================================================"
    printf '\n'

    if [ "$DRY_RUN" = 0 ]; then
        mkdir -p "$ROOT/storage/backups"
        : >"$STATE_FILE"
        chmod 600 "$STATE_FILE" 2>/dev/null || true
    fi

    acquire_lock

    for phase in "${phases[@]}"; do
        if ! bash "$SCRIPTS/$kind/${phase}.sh"; then
            failed="$phase"
            break
        fi
    done

    printf '\n'
    step "RESUMO"
    log "modo    : $([ "$DRY_RUN" = 1 ] && printf 'DRY-RUN' || printf 'EXECUÇÃO')"
    log "projeto : ${PROJECT_NAME}"
    log "imagem  : ${PHP_IMAGE}:${PHP_TAG}"
    log "porta   : 127.0.0.1:${WEB_PORT}"
    log "URL     : ${APP_URL_V:-?}"
    log "commit  : $(git_commit)"
    for v in BEFORE_COMMIT AFTER_COMMIT BACKUP_DB BACKUP_STORAGE BACKUP_STORAGE_APP BACKUP_SHA IMAGE_ID VENDOR_PRE; do
        local val
        val="$(state_get "$v")"
        if [ -n "$val" ]; then
            log "$(printf '%-11s' "${v}:") ${val}"
        fi
    done
    log "log     : ${LOG_FILE}"

    if [ -n "$failed" ]; then
        printf '\n%s %sRESULTADO: FALHA%s (fase %s). Log: %s\n' \
            "$(ts)" "$C_RED" "$C_RESET" "$failed" "$LOG_FILE" >&2
        exit 1
    fi

    if [ "$DRY_RUN" = 1 ]; then
        ok "RESULTADO: DRY-RUN OK (nada foi alterado). Para aplicar: --execute"
    else
        ok "RESULTADO: OK"
        case "$kind" in
            init)
                log "próximo: aponte o ProxyPass do Apache (WHM) para 127.0.0.1:${WEB_PORT} e valide ${APP_URL_V}/up"
                ;;
            update)
                if [ -n "$(state_get BACKUP_DB)" ]; then
                    log "backup do banco: $(state_get BACKUP_DB)"
                fi
                if [ -n "$(state_get BACKUP_STORAGE)" ]; then
                    log "backup de storage: $(state_get BACKUP_STORAGE)"
                fi
                ;;
        esac
    fi
}

# Resolve o projeto já no source: toda fase tem COMPOSE/PROJECT_NAME disponíveis.
project_resolve
