#!/usr/bin/env bash
#
# Deploy do ambiente de TESTE em https://sistema.franquiabv.xyz — servidor BVIP.
#
# Roda NO SERVIDOR, dentro do clone do repositório, como o usuário dono do clone
# -- root também, desde 02/10/2026 (v9). Com outro usuário, o script chama
# `sudo` só onde precisa — nginx, certbot, cron do root e a pasta da tela nova.
# Ver docs/ambiente-teste.md.
#
#     bash scripts/deploy-teste.sh preparar          # 1ª vez: .env, local.php, pilha, dependências
#     bash scripts/deploy-teste.sh base ARQ.sql.gz   # carrega a cópia de scripts/exportar-base-teste.sh
#     bash scripts/deploy-teste.sh publicar          # tela nova (/novo/), nginx do host e TLS
#     bash scripts/deploy-teste.sh atualizar         # depois: puxa o `main` e aplica
#     bash scripts/deploy-teste.sh voltar COMMIT     # volta a um commit conhecido
#     bash scripts/deploy-teste.sh conferir          # só confere; não muda nada
#     bash scripts/deploy-teste.sh tirar             # tira do ar, e fica: só o publicar religa
#
# Variáveis (todas opcionais):
#     DOMINIO=<o DOMINIO_TESTE do .env; sem ele, sistema.franquiabv.xyz>
#     RAMO=main
#     FRONT_RAIZ=/var/www/<DOMINIO>/front
#     EMAIL_CERTBOT=voce@exemplo       (aviso de vencimento do certificado)
#     SENHA_HTTP=sim|nao               (sim: senha do nginx na frente de tudo;
#                                       a escolha fica gravada no .env. SEM
#                                       PADRÃO: sem ela aqui nem no .env, o
#                                       publicar, o atualizar e o voltar
#                                       recusam ligar o vhost -- o D451)
#     CONFIRMAR=sim                    (pula as perguntas; para rodar sem terminal)
#
# ------------------------------------------------------------------------------
# O QUE ESTE AMBIENTE NÃO PODE FAZER, e como o script garante
#
# O endereço é público, e o código carrega duas coisas de produção: a chave do
# Asaas (Asaas.php, D3) e o token das rotinas por URL (D5). Uma rotina de
# royalties chamada daqui cobraria franqueados de verdade. Quatro camadas, e o
# `conferir` mede cada uma:
#
#   1. o nginx do host NEGA as rotinas por URL -- inclusive pelo caminho
#      genérico /mod-*/cronJob, que também chega nelas --, o disparo de
#      WhatsApp, o visagismo e a exportação de vendas por hash
#      (docker/nginx/teste-host.conf.modelo);
#   2. o contêiner do PHP resolve os hosts de saída para o loopback, IPv4 e
#      IPv6 (docker-compose.teste.yml): se uma rota escapar, a chamada morre ali;
#   3. a base é a cópia de scripts/exportar-base-teste.sh -- anonimizada e
#      endurecida na sua máquina, e endurecida de novo aqui
#      (scripts/endurecer-base-teste.sql): sem os ids do Asaas, as chaves, os
#      hashes previsíveis e os nomes em texto que o anonimizador deixa. A senha
#      de todos vira uma aleatória gerada aqui: com o site aberto, a
#      `dev123456` publicada nos documentos daria o administrador a qualquer
#      um. A SENHA HTTP NA FRENTE é a escolha do dono desde 02/10/2026 (o D326:
#      a cópia leva um CPF e 243 telefones que o anonimizador não alcança); o
#      "só noindex" de 24/09 não é mais o padrão, e não há padrão: sem
#      SENHA_HTTP na linha de comando nem no .env, nada liga o vhost (o D451);
#   4. nenhum cron é instalado, e o `conferir` recusa cron que chame este
#      endereço.
#
# E O SITE SÓ ESTÁ NO AR DEPOIS DE TUDO CONFERIDO. O `base`, o `atualizar` e o
# `voltar` tiram o endereço do ar ANTES de mexer -- o nginx da pilha parado E o
# vhost do host desligado, que sobrevive a um reinício do Docker e da máquina
# -- e ele só volta no fim, depois das conferências: pelo próprio `atualizar` e
# `voltar`, ou pelo `publicar` depois de um `base`. Um passo que não chega ao
# fim (erro, Ctrl-C, queda do ssh) deixa o site fora do ar.
#
# E A BASE DIZ DE QUE CÓDIGO É. A cópia sai selada do exportador (o sha256 dos
# arquivos que lhe dão forma), o `base` só carrega a do selo desta árvore e
# grava o selo no próprio banco, e o site só VOLTA com o selo da base igual ao
# do código que está aqui -- venha o código por onde vier. O selo é medido por
# quem religa, e não vigia o site no ar: um `git pull` à mão com o site no ar
# serve o código novo na hora. Tire antes (quarta revisão).
#
# O script nunca toca o MySQL do host (3306, a réplica de produção), a pilha
# de homologação (projeto `franquiabv`) nem outro vhost do nginx. E só um roda
# por vez neste servidor (flock numa trava só, em /var/lock, para o root e para
# quem está no grupo docker, e fechada para os outros -- o D444).
# ------------------------------------------------------------------------------

set -euo pipefail

SUBCOMANDO="${1:-}"

# O SCRIPT RODA DE UMA CÓPIA DE SI MESMO. O bash lê o arquivo aos poucos, e o
# `git merge` do atualizar (ou o checkout do voltar) reescreve este arquivo no
# meio da execução: o resto sairia lido do arquivo novo, na posição do velho.
#
# MENOS O `tirar`, que não troca a árvore: com o disco cheio (o /tmp no mesmo
# disco), a cópia falhava e o set -e matava o freio antes de ele começar
# (sexta revisão).
if [ -z "${DEPLOY_TESTE_RAIZ:-}" ]; then
    DEPLOY_TESTE_RAIZ="$(cd "$(dirname "$0")/.." && pwd)"
    export DEPLOY_TESTE_RAIZ
    if [ "$SUBCOMANDO" != tirar ]; then
        DEPLOY_TESTE_COPIA="$(mktemp)"
        export DEPLOY_TESTE_COPIA
        cp "$0" "$DEPLOY_TESTE_COPIA"
        exec bash "$DEPLOY_TESTE_COPIA" "$@"
    fi
fi

# O QUE FAZER SE O PASSO NÃO CHEGAR AO FIM: o nome de uma função, que a saída
# chama quando o script sai com erro -- `falhar`, o `set -e`, Ctrl-C, a queda do
# ssh (HUP). Quem põe o site no ar ou carrega a base a arma antes, e a desarma
# no fim.
AO_FALHAR=""
TEMPORARIOS=""
ao_sair() {
    local status=$?
    set +e
    # A LIMPEZA NÃO SE INTERROMPE: um segundo Ctrl-C (ou o segundo HUP da queda
    # do ssh) no meio do apagar_base saía pela armadilha e deixava a base.
    #
    # E O CANO MORTO TAMBÉM NÃO A MATA: com a saída num `| tee`, o Ctrl-C mata o
    # tee junto, e o primeiro printf daqui, escrevendo num cano sem leitor,
    # matava o script por SIGPIPE ANTES do freio -- o site ficava no ar e nada
    # ficava escrito (quarta revisão). Ignorado, o printf só falha.
    trap '' HUP INT TERM PIPE USR1 USR2 ALRM XCPU XFSZ VTALRM PROF
    if [ "$status" -ne 0 ] && [ -n "$AO_FALHAR" ]; then
        printf '\n==> o passo não chegou ao fim; %s\n' "$AO_FALHAR" >&2
        if ! "$AO_FALHAR"; then
            if [ "$AO_FALHAR" = apagar_base ]; then
                # A BASE QUE NÃO SAIU não é "o site no ar": ele saiu antes da
                # carga. Era a mesma marca, e o `tirar` que ela receitava a
                # apagava sem tocar a base (quinta revisão). Esta marca só um
                # `base` que chegue ao fim apaga.
                marcar "$BASE_NAO_APAGADA" "a carga não chegou ao fim, e a base não pôde ser apagada (saída $status)" 2>/dev/null
                printf '\n!!  A BASE PODE TER FICADO COM A SENHA PUBLICADA; o site segue fora do ar. Rode o base de novo antes de qualquer publicar.\n' >&2
            else
                # O FREIO FALHOU -- na queda do ssh, o sudo sem terminal pede
                # senha e o vhost fica ligado. O aviso vai para um terminal que
                # pode não existir mais, então fica ESCRITO: o `conferir` o
                # cobra, e só um `tirar` ou um `publicar` que chegue ao fim o apaga.
                marcar "$TALVEZ_NO_AR" "o $AO_FALHAR da saída falhou (saída $status)" 2>/dev/null
                printf '\n!!  O SITE PODE TER FICADO NO AR. Rode, de um terminal (tmux): bash scripts/deploy-teste.sh tirar\n' >&2
            fi
        fi
    fi
    # shellcheck disable=SC2086
    rm -f "${DEPLOY_TESTE_COPIA:-}" $TEMPORARIOS
    exit "$status"
}
trap ao_sair EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
# O CANO MORTO SEM SINAL NENHUM -- `ssh bvip '...'` sem pty que cai, o `| tee`
# de um nohup, o `| less` fechado com q: a escrita seguinte matava o script por
# SIGPIPE, e a saída lia o status do comando ANTERIOR (quase sempre 0) e não
# armava o freio (quinta revisão). Capturado, ele sai com 141, e o freio roda.
# Os filhos não herdam a captura: os canos com `head` e `grep -q` seguem iguais.
trap 'exit 141' PIPE
# E OS OUTROS SINAIS QUE ENCERRAM: sem captura, o bash rodava a saída com o
# status do comando anterior (0), e um `timeout -s ALRM`, um USR1 de um
# gerenciador de processos ou o `ulimit -f` no meio do publicar deixavam o site
# no ar sem sondas e sem freio (sexta revisão). Cada um sai com 128 + o número.
trap 'exit 138' USR1
trap 'exit 140' USR2
trap 'exit 142' ALRM
trap 'exit 152' XCPU
trap 'exit 153' XFSZ
trap 'exit 154' VTALRM
trap 'exit 155' PROF

# UM DEPLOY POR VEZ: um `publicar` rodando junto com um `base` religava o site
# no meio da carga. A trava passa adiante nos `exec` (o descritor 9 é herdado).
#
# UMA TRAVA POR SERVIDOR, E NÃO POR USUÁRIO (08/11/2026, o D444): até a v9 ela
# morava no $HOME de quem rodava, e o root (/root/...) e outro usuário (o $HOME
# dele) rodavam juntos sem um barrar o outro -- com a recusa dizendo "neste
# servidor". Agora é um caminho fixo, fora do $HOME: /var/lock, que no Debian e
# no Ubuntu é o /run/lock (tmpfs, 1777).
#
# ABERTA SÓ PARA LER: o flock(2) trava um descritor de leitura igual, e o
# `exec 9>` de antes (O_CREAT e O_TRUNC) de um usuário sobre o arquivo que o
# OUTRO criou falhava -- pela permissão do dono, e, num diretório 1777, pelo
# fs.protected_regular do kernel (o padrão do systemd). O arquivo não guarda nada.
#
# SÓ QUEM FAZ DEPLOY A ABRE (a verificação da fatia 129): num /var/lock 1777, a
# trava legível por todos deixava QUALQUER usuário do servidor -- o www-data de
# outro vhost, o de outro serviço -- segurá-la (`flock <a trava> sleep
# infinity`) e barrar todos os subcomandos, o `tirar` também, o freio do site
# que leva dado pessoal (o D326); e um arquivo plantado antes no lugar dela
# (modo 000, ou um link pendurado, que o `: >>` do root seguiria e criaria no
# alvo) fazia o mesmo. Agora a trava é um arquivo comum do grupo docker -- quem
# roda o deploy roda o `docker compose`, e está nele, ou é o root --, modo 640,
# fechado para os outros: quem não está no grupo nem a abre. Ela nasce inteira,
# já com o grupo e o modo (um temporário do mktemp, ligado ao nome pelo `ln`,
# que não segue link nem sobrescreve), e não depende do umask de quem a cria
# primeiro (com 077, o 600 do root barrava o outro usuário à toa). A que não é
# assim é alheia: o root a apaga e refaz; quem não é root para, com o comando.
#
# O `tirar` NÃO PARA SE A TRAVA NÃO ABRE (sem /var/lock, só-leitura, alheia): o
# freio é idempotente, e o set -e o matava antes de começar (sexta revisão).
# Outro deploy RODANDO -- alguém do grupo docker a segura -- continua sendo
# recusa, para o tirar e para os outros.
TRAVA_DO_SERVIDOR="/var/lock/franquiabv-teste.trava"
GRUPO_DA_TRAVA="docker"
MOTIVO_DA_TRAVA=""

trava_confiavel() {
    [ ! -L "$TRAVA_DO_SERVIDOR" ] && [ -f "$TRAVA_DO_SERVIDOR" ] || return 1
    [ "$(stat -c %G -- "$TRAVA_DO_SERVIDOR" 2>/dev/null)" = "$GRUPO_DA_TRAVA" ] || return 1
    case "$(stat -c %a -- "$TRAVA_DO_SERVIDOR" 2>/dev/null)" in *0) return 0 ;; esac
    return 1
}

# Abre a trava no descritor 9: 0 aberta; 1 não consegui (MOTIVO_DA_TRAVA diz).
abrir_a_trava() {
    local tentativa tmp
    getent group "$GRUPO_DA_TRAVA" >/dev/null 2>&1 \
        || { MOTIVO_DA_TRAVA="o grupo $GRUPO_DA_TRAVA não existe neste servidor"; return 1; }
    for tentativa in 1 2 3; do
        trava_confiavel && break
        if [ -e "$TRAVA_DO_SERVIDOR" ] || [ -L "$TRAVA_DO_SERVIDOR" ]; then
            if [ "$(id -u)" != 0 ]; then
                MOTIVO_DA_TRAVA="$TRAVA_DO_SERVIDOR não é do deploy (não é um arquivo comum do grupo $GRUPO_DA_TRAVA fechado para os outros; dono: $(stat -c %U -- "$TRAVA_DO_SERVIDOR" 2>/dev/null || echo '?')). Apague-a, e rode de novo: sudo rm -f $TRAVA_DO_SERVIDOR"
                return 1
            fi
            rm -f -- "$TRAVA_DO_SERVIDOR" 2>/dev/null \
                || { MOTIVO_DA_TRAVA="não consegui apagar a trava alheia $TRAVA_DO_SERVIDOR"; return 1; }
        fi
        tmp="$(mktemp "$TRAVA_DO_SERVIDOR.XXXXXX" 2>/dev/null)" \
            || { MOTIVO_DA_TRAVA="não consegui criar a trava em $(dirname "$TRAVA_DO_SERVIDOR") (sem /var/lock? só-leitura?)"; return 1; }
        if ! chmod 640 "$tmp" 2>/dev/null || ! chgrp "$GRUPO_DA_TRAVA" "$tmp" 2>/dev/null; then
            rm -f -- "$tmp"
            MOTIVO_DA_TRAVA="não consegui dar a trava ao grupo $GRUPO_DA_TRAVA: $(id -un) não está nele (quem faz o deploy roda o docker compose)"
            return 1
        fi
        ln -- "$tmp" "$TRAVA_DO_SERVIDOR" 2>/dev/null || true
        rm -f -- "$tmp"
    done
    trava_confiavel \
        || { MOTIVO_DA_TRAVA="$TRAVA_DO_SERVIDOR não ficou um arquivo do grupo $GRUPO_DA_TRAVA fechado para os outros (alguém a troca enquanto rodo?)"; return 1; }
    # O 2>/dev/null NA CHAVE, e não no exec: no exec, ele mandaria o stderr do
    # roteiro inteiro para o nada (as recusas sairiam caladas).
    { exec 9<"$TRAVA_DO_SERVIDOR"; } 2>/dev/null \
        || { MOTIVO_DA_TRAVA="não consegui abrir $TRAVA_DO_SERVIDOR: $(id -un) não está no grupo $GRUPO_DA_TRAVA?"; return 1; }
}

if [ -z "${DEPLOY_TESTE_TRAVADO:-}" ]; then
    command -v flock >/dev/null 2>&1 || { printf 'ABORTADO: falta o flock (util-linux).\n' >&2; exit 1; }
    if abrir_a_trava; then
        flock -n 9 || { printf 'ABORTADO: outro scripts/deploy-teste.sh está rodando neste servidor (a trava %s, do grupo %s).\n' "$TRAVA_DO_SERVIDOR" "$GRUPO_DA_TRAVA" >&2; exit 1; }
    elif [ "$SUBCOMANDO" = tirar ]; then
        printf '\n!!  sem a trava (%s): tiro sem ela.\n' "$MOTIVO_DA_TRAVA" >&2
    else
        printf 'ABORTADO: sem a trava do servidor: %s.\n' "$MOTIVO_DA_TRAVA" >&2
        exit 1
    fi
    export DEPLOY_TESTE_TRAVADO=1
fi

cd "$DEPLOY_TESTE_RAIZ"
RAIZ="$DEPLOY_TESTE_RAIZ"

# O ÚLTIMO valor da chave no .env, COMO O DOCKER COMPOSE O LÊ: sem as aspas em
# volta, e sem o comentário no fim de um valor sem aspas. Lido cru, um
# `DOMINIO_TESTE="sistema.franquiabv.xyz"` que o compose aceita virava um domínio
# com aspas, e o tirar recusava antes de frear (sexta revisão).
do_env() {
    local v
    v="$(grep -E "^$1=" .env 2>/dev/null | tail -n 1 | cut -d= -f2- || true)"
    v="${v%$'\r'}"
    case "$v" in
        \"*\"*) v="${v#\"}"; v="${v%%\"*}" ;;
        \'*\'*) v="${v#\'}"; v="${v%%\'*}" ;;
        *) v="${v%%[[:space:]]#*}"; v="${v%"${v##*[![:space:]]}"}" ;;
    esac
    printf '%s' "$v"
}

# O DOMÍNIO: o dado na linha de comando; sem ele, o que o .env grava
# (`DOMINIO_TESTE`); e só sem os dois, o padrão. Até a v6 só o `tirar` adotava
# o do .env, e o `publicar` sem DOMINIO num ambiente de outro nome recusava
# mandando "rode sem DOMINIO" a quem já rodava sem (quinta revisão).
DOMINIO_DADO="${DOMINIO:-}"
DOMINIO_DO_ENV="$(do_env DOMINIO_TESTE)"
DOMINIO="${DOMINIO:-${DOMINIO_DO_ENV:-sistema.franquiabv.xyz}}"
RAMO="${RAMO:-main}"
FRONT_RAIZ_DADO="${FRONT_RAIZ:-}"
FRONT_RAIZ="${FRONT_RAIZ:-/var/www/$DOMINIO/front}"
ACME_RAIZ="/var/www/letsencrypt"
VHOST="/etc/nginx/sites-available/$DOMINIO.conf"
VHOST_LIGADO="/etc/nginx/sites-enabled/$DOMINIO.conf"
HTPASSWD="/etc/nginx/.htpasswd-$DOMINIO"
SENHA_HTTP="${SENHA_HTTP:-}"
EMAIL_CERTBOT="${EMAIL_CERTBOT:-}"
IMAGEM_NODE="node:22-bookworm-slim"

# O ESTADO DESTE CLONE, dentro dele e fora do git (.gitignore): a senha, o
# histórico e as duas marcas do "fora do ar". Por clone, e não no $HOME: dois
# clones do mesmo usuário liam e apagavam as marcas um do outro (terceira
# revisão do deploy).
ESTADO="$RAIZ/.deploy-teste"
ARQUIVO_SENHA="$ESTADO/senha"
HISTORICO="$ESTADO/historico"
# O `tirar` de propósito: o atualizar e o voltar não religam; só o publicar.
TIRADO="$ESTADO/tirado"
# Um freio que falhou na saída (o tirar_do_ar de um passo interrompido).
TALVEZ_NO_AR="$ESTADO/talvez-no-ar"
# Um `base` que não chegou ao fim e cuja limpeza falhou: a base pode ter a
# senha publicada. Só um `base` que termine a apaga.
BASE_NAO_APAGADA="$ESTADO/base-nao-apagada"
# No `tirar`, nada disto para o freio (disco cheio, só-leitura): as marcas
# dele já toleram a escrita que falha (sexta revisão).
( umask 077 && mkdir -p "$ESTADO" ) || [ "$SUBCOMANDO" = tirar ]
# O QUE A v4 GUARDAVA NO $HOME vem uma vez para cá; a pendência de base de lá
# não vale mais -- quem decide agora é o selo (abaixo).
[ -f "$ARQUIVO_SENHA" ] || [ ! -f "$HOME/.franquiabv-teste-senha" ] || cp -p "$HOME/.franquiabv-teste-senha" "$ARQUIVO_SENHA" || [ "$SUBCOMANDO" = tirar ]
[ -f "$HISTORICO" ] || [ ! -f "$HOME/.franquiabv-teste-deploys" ] || cp -p "$HOME/.franquiabv-teste-deploys" "$HISTORICO" || [ "$SUBCOMANDO" = tirar ]

# Os hosts que docker-compose.teste.yml aponta para o loopback, e que o teste
# nunca alcança. Os dois do Google (fcm.googleapis.com e oauth2.googleapis.com)
# saíram desta lista em 11/10/2026 (a resposta 74 do dono, D461): o teste os
# corta como a homologação, e os alcança só com o docker-compose.teste-firebase.yml
# no COMPOSE_FILE, para o push pelo Firebase num aparelho de verdade
# (docs/ambiente-teste.md, "O push pelo Firebase no teste").
HOSTS_MORTOS="api.asaas.com sandbox.asaas.com onesignal.com api.onesignal.com www.iagentesms.com.br waha.linkwise.digital evolution.linkwise.digital app.yungas.com.br api.zoomtec.com.br mail.barbearia.vip"
# OS DOIS DO GOOGLE, à parte (a verificação de 11/10/2026): o `conferir` os exige
# no loopback como os de cima SEMPRE que o COMPOSE_FILE do .env não traz o
# docker-compose.teste-firebase.yml -- o teste de sempre os corta, e a medida no
# contêiner continua a dizê-lo. Só com o arquivo ligado eles saem da medida. A
# lista é a do `$foraDoTeste` do scripts/conferir-provedores-no-loopback.php, que
# a segura igual; e o scripts/ensaiar-hosts-mortos-do-deploy.sh roda o `conferir`
# daqui com o Google aberto, com e sem o arquivo.
HOSTS_DO_GOOGLE="fcm.googleapis.com oauth2.googleapis.com"
ARQUIVO_DO_FIREBASE="docker-compose.teste-firebase.yml"

# Os arquivos sem os quais este deploy não se sustenta (o `voltar` recusa commit sem eles).
FERRAMENTAS="docker-compose.teste.yml docker/php/php.teste.ini docker/nginx/teste-host.conf.modelo scripts/deploy-teste.sh scripts/endurecer-base-teste.sql scripts/conferir-base-teste.sql"

# As camadas de proteção: voltar para um commit em que elas são outras pede confirmação.
CAMADAS="docker-compose.yml docker-compose.homologacao.yml docker-compose.teste.yml docker/nginx/default.conf docker/nginx/teste-host.conf.modelo docker/php/php.teste.ini config/autoload/global.php scripts/endurecer-base-teste.sql scripts/conferir-base-teste.sql"

# O que muda o esquema: não há rodador de migração, e o mysql.sql só carrega num volume novo.
ESQUEMA_RE='^(mysql\.sql|scripts/migracao-[^/]*\.sql|sql/)'

# O SELO DA CÓPIA (terceira revisão do deploy): o sha256 dos arquivos que dão
# forma à cópia, sem os CR -- o checkout no Windows pode tê-los. O exportador o
# grava na primeira linha do dump; o `base` só carrega a cópia cujo selo é o
# desta árvore, e grava no banco o selo da base; e o site só volta com esse
# selo igual ao do código que está aqui.
#
# MEDIR, E NÃO MARCAR. A v4 gravava uma pendência quando o atualizar via a
# mudança -- e o código que chegava por outro caminho (um `git pull` à mão, um
# sinal entre o merge e a marca, o `_aplicar` direto) religava o site com a
# base velha. E a pendência sumia com QUALQUER `base`, até com a cópia antiga.
#
# É O MESMO CÁLCULO de scripts/exportar-base-teste.sh. Se os dois divergirem,
# nenhuma cópia entra: a falha é fechada.
ARQUIVOS_DA_COPIA="scripts/anonimizar.sql.modelo scripts/anonimizar.sh scripts/exportar-base-teste.sh scripts/endurecer-base-teste.sql scripts/conferir-base-teste.sql"
# O QUE O `base` FAZ NA BASE além desses arquivos -- a senha de todos, as
# sessões. Suba o número quando o cmd_base mudar nisso: a base carregada antes
# deixa de valer.
VERSAO_DA_CARGA=1

# A VERSÃO DAS GARANTIAS DESTE SCRIPT (quinta revisão, 26/09/2026). O `voltar`
# e o `atualizar` só aceitam alvo cujo script diga pelo menos a mínima -- e,
# como o script de lá confere o mesmo, a recusa vale também para o voltar
# SEGUINTE, feito a partir dele. Sem isto, da v6 se voltava a um commit com o
# script da v5, e dali um segundo voltar chegava à v4, que religa o site
# tirado. Suba a VERSAO_DO_DEPLOY quando uma revisão mudar o freio; suba a
# mínima quando o alvo antigo não puder mais decidir se o site volta.
#
# A v9 (02/10/2026) aceita root, e a mínima subiu junto: o script de uma v8 recusa
# root, e um `voltar` feito como root para ela trocava o código, tirava o site e
# morria no `_aplicar` de lá -- com o site fora e nenhum subcomando da v8 que o
# root pudesse rodar para sair dali. Agora a recusa vem antes de mexer.
VERSAO_DO_DEPLOY=9
VERSAO_MINIMA_DO_ALVO=9

# ------------------------------------------------------------------------------
# utilidades

# A DICA vai junto de toda reprovação do subcomando que a define (o conferir).
DICA_AO_FALHAR=""
falhar() {
    printf '\nABORTADO: %s\n' "$*" >&2
    [ -z "$DICA_AO_FALHAR" ] || printf '%s\n' "$DICA_AO_FALHAR" >&2
    exit 1
}
passo()  { printf '\n==> %s\n' "$*"; }
ok()     { printf '    ok  %s\n' "$*"; }

dc() { docker compose "$@"; }

confirmar() {
    [ "${CONFIRMAR:-}" = "sim" ] && return 0
    [ -t 0 ] || falhar "sem terminal para confirmar. Rode com CONFIRMAR=sim se é isso mesmo."
    printf '%s (digite: sim) ' "$1"
    local resposta
    read -r resposta
    [ "$resposta" = "sim" ] || falhar "cancelado."
}

precisa() {
    command -v "$1" >/dev/null 2>&1 || falhar "falta o comando '$1' neste servidor."
}

mesmo_conteudo() {
    [ "$(sha256sum < "$1")" = "$(sha256sum < "$2")" ]
}

# Uma marca do estado (ver ESTADO): quando, e o motivo.
marcar() {
    ( umask 077 && printf '%s -- %s\n' "$(date -Iseconds)" "$2" > "$1" )
}

# O domínio de outra rodada, com os caminhos que dependem dele.
usar_dominio() {
    case "$1" in
        ''|*[!a-z0-9.-]*|.*|*..*) falhar "DOMINIO_TESTE='$1' no .env: só letras minúsculas, dígitos, ponto e hífen." ;;
    esac
    DOMINIO="$1"
    VHOST="/etc/nginx/sites-available/$DOMINIO.conf"
    VHOST_LIGADO="/etc/nginx/sites-enabled/$DOMINIO.conf"
    HTPASSWD="/etc/nginx/.htpasswd-$DOMINIO"
    # E A PASTA DA TELA NOVA, se ninguém a deu: calculada no começo com o
    # domínio ignorado, ela reprovava nas guardas, e o tirar parava antes do
    # vhost (sexta revisão, pelo cenário V34).
    [ -n "$FRONT_RAIZ_DADO" ] || FRONT_RAIZ="/var/www/$DOMINIO/front"
}

# O PROJETO, e nada mais do .env: é o que o tirar_do_ar precisa. O freio de
# emergência não pode depender do resto da configuração estar certo -- era
# justamente quando ela acendia que o `tirar` recusava (terceira revisão).
ler_projeto() {
    [ -f .env ] || falhar "sem .env: não sei qual projeto é o deste teste."
    PROJETO="$(do_env COMPOSE_PROJECT_NAME)"
    case "$PROJETO" in
        '') falhar ".env sem COMPOSE_PROJECT_NAME." ;;
        franquiabv) falhar "o projeto 'franquiabv' é o da homologação do BVIP; o teste usa outro nome (franquiabv-teste)." ;;
    esac
}

# O selo da cópia desta árvore -- ou, com um commit, o da cópia que aquele
# código exportaria. Ver ARQUIVOS_DA_COPIA.
selo_da_copia() {
    local f
    for f in $ARQUIVOS_DA_COPIA; do
        if [ -n "${1:-}" ]; then
            printf '%s %s\n' "$f" "$(git show "$1:$f" 2>/dev/null | tr -d '\r' | sha256sum | cut -c1-64)"
        else
            printf '%s %s\n' "$f" "$(tr -d '\r' < "$f" | sha256sum | cut -c1-64)"
        fi
    done | sha256sum | cut -c1-64
}

# O selo que a base carregada por este código teria: o da cópia e a versão da carga.
selo_da_base() {
    local versao="$VERSAO_DA_CARGA"
    if [ -n "${1:-}" ]; then
        versao="$(git show "$1:scripts/deploy-teste.sh" 2>/dev/null | sed -n 's/^VERSAO_DA_CARGA=//p' | head -n 1)" || true
    fi
    printf '%s:%s' "$(selo_da_copia "${1:-}")" "${versao:-sem-versao}"
}
# ------------------------------------------------------------------------------
# guardas: valem para todos os subcomandos

guardas() {
    # O git, e não `-d .git`: num `git worktree`, o .git é um arquivo.
    git rev-parse --git-dir >/dev/null 2>&1 && [ -f docker-compose.teste.yml ] || falhar "rode de dentro do clone do repositório."
    # ROOT SERVE, por decisão do dono em 02/10/2026 ("prefiro rodar como root
    # mesmo, remova essa limitação"). Até a v8 só o `tirar` rodava como root: o
    # medo era o git e os arquivos do clone mudarem de dono no meio. O que vale
    # é rodar sempre com o MESMO usuário que é dono do clone -- num clone do
    # root, o root --; e o sudo das partes de sistema passa direto como root.

    case "$(hostname)" in
        *barbeariavip*|*produc*)
            falhar "este servidor ($(hostname)) tem nome de produção. O ambiente de teste mora no BVIP." ;;
    esac

    precisa realpath
    # OS DOIS ENTRAM NO sed DO VHOST, EM rm -rf E EM chmod -R: só caracteres
    # simples, e a pasta resolvida tem de continuar embaixo de /var/www/ --
    # '/var/www/..' passava no padrão, e o chmod caía em /var.
    case "$DOMINIO" in
        ''|*[!a-z0-9.-]*|.*|*..*) falhar "DOMINIO='$DOMINIO': só letras minúsculas, dígitos, ponto e hífen." ;;
    esac
    case "$FRONT_RAIZ" in
        *[!A-Za-z0-9._/-]*) falhar "FRONT_RAIZ='$FRONT_RAIZ': só letras, dígitos, ponto, hífen, sublinhado e barra." ;;
    esac
    case "$(realpath -m "$FRONT_RAIZ" 2>/dev/null || echo /)" in
        "$FRONT_RAIZ") ;;
        *) falhar "FRONT_RAIZ='$FRONT_RAIZ' não é um caminho canônico (sem '..', '.', barra dobrada ou no fim)." ;;
    esac
    # E EMBAIXO DA PASTA DO PRÓPRIO DOMÍNIO: /var/www/<outro site> também é
    # /var/www/, e o script faz rm -rf e chmod -R ali.
    case "$FRONT_RAIZ" in
        "/var/www/$DOMINIO/"?*) ;;
        *) falhar "FRONT_RAIZ='$FRONT_RAIZ' fora de /var/www/$DOMINIO/: o script apaga e troca pastas ali." ;;
    esac

    precisa docker
    precisa git
    precisa curl
    precisa openssl
    precisa python3
    docker compose version >/dev/null 2>&1 || falhar "falta o 'docker compose' (v2)."
}

# O .env certo: projeto próprio, os três arquivos de compose, senhas trocadas.
conferir_env() {
    [ -f .env ] || falhar "sem .env. Rode primeiro: bash scripts/deploy-teste.sh preparar"

    PROJETO="$(do_env COMPOSE_PROJECT_NAME)"
    DB_NAME="$(do_env DB_NAME)"
    APP_PORT="$(do_env APP_PORT)"
    PORTA_APP="${APP_PORT##*:}"

    [ -n "$PROJETO" ] || falhar ".env sem COMPOSE_PROJECT_NAME."
    [ "$PROJETO" != "franquiabv" ] || falhar "o projeto 'franquiabv' é o da homologação do BVIP; o teste usa outro nome (franquiabv-teste)."
    case "$(do_env COMPOSE_FILE)" in
        *docker-compose.teste.yml*) ;;
        *) falhar ".env sem o docker-compose.teste.yml no COMPOSE_FILE: sem ele, a saída para o Asaas e o WhatsApp fica aberta." ;;
    esac
    case "$DB_NAME" in
        ''|*produc*|*prod) falhar "DB_NAME='$DB_NAME' vazio ou com nome de produção." ;;
    esac
    case "$APP_PORT" in
        127.0.0.1:*) ;;
        *) falhar "APP_PORT='$APP_PORT' precisa ser 127.0.0.1:<porta> — o Docker passa por cima do ufw." ;;
    esac
    # A senha de exemplo no .env quer dizer que ele foi copiado à mão, e o banco
    # pode ter nascido com ela: o MYSQL_PASSWORD só vale no primeiro `up` do
    # volume, e um .env novo com o volume velho deixa a aplicação sem banco.
    ! grep -q 'TROCADA-PELO-SCRIPT' .env \
        || falhar ".env com a senha de exemplo. Apague o .env E o banco de teste (docker compose down -v, só neste projeto) e rode 'preparar' de novo."

    # O DOMÍNIO TAMBÉM FICA: o vhost que o 'base' desliga e o que o 'publicar'
    # liga são o do DOMINIO da rodada. Um 'base' sem a mesma variável do
    # 'publicar' desligava um vhost que não existe, sem aviso, e o de verdade
    # seguia no ar.
    local dominio_gravado outros
    dominio_gravado="$(do_env DOMINIO_TESTE)"
    if [ -z "$dominio_gravado" ]; then
        if [ "${1:-}" != "so-ler" ]; then
            # NA PRIMEIRA GRAVAÇÃO, o domínio da rodada vira o oficial -- e um
            # vhost de OUTRO nome apontando para a porta deste ambiente quer
            # dizer que o oficial é aquele: um servidor publicado com outro
            # DOMINIO, na primeira rodada desta versão, gravava o domínio
            # errado, e o `tirar` desligava um vhost que não existia (terceira
            # revisão). Os arquivos do nginx se leem sem sudo.
            outros="$(grep -RlE "proxy_pass[[:space:]]+https?://(127[.]0[.]0[.]1|localhost):${PORTA_APP}([/;[:space:]]|\$)" \
                /etc/nginx/sites-enabled /etc/nginx/conf.d 2>/dev/null | grep -v "/${DOMINIO}[.]conf\$" || true)"
            [ -z "$outros" ] \
                || falhar "o .env ainda não diz o domínio, e outro vhost já aponta para a porta $PORTA_APP deste ambiente: $outros. Rode com DOMINIO=<o domínio dele>."
            printf '\n# O domínio do teste (scripts/deploy-teste.sh): toda rodada é deste.\nDOMINIO_TESTE=%s\n' "$DOMINIO" >> .env
        fi
    elif [ "$dominio_gravado" != "$DOMINIO" ]; then
        falhar "o .env é do domínio $dominio_gravado, e esta rodada é de $DOMINIO. Rode com DOMINIO=$dominio_gravado -- ou, se trocou de domínio de propósito, tire o site do ar com o domínio antigo e apague a linha DOMINIO_TESTE do .env."
    fi

    # A SENHA HTTP É UMA ESCOLHA QUE FICA: dada na linha de comando, vai para o
    # .env; sem ela, vale a do .env. Antes, a rodada seguinte sem a variável
    # tirava a senha do nginx sem avisar. O `conferir` só lê (argumento
    # so-ler): ele não grava a escolha.
    if [ -n "$SENHA_HTTP" ]; then
        case "$SENHA_HTTP" in sim|nao) ;; *) falhar "SENHA_HTTP='$SENHA_HTTP': use sim ou nao." ;; esac
        # PELO PUBLICAR, que não troca o código: o atualizar puxava o main, e
        # depois de um `voltar` ligar a senha trazia de volta o código de que se
        # tinha voltado (quarta revisão).
        # O PUBLICAR RELIGA: com o site tirado de propósito, trocar a senha
        # por ele desfazia o tirar (quinta revisão). Aí, a linha do .env.
        [ "${1:-}" != "so-ler" ] || falhar "o 'conferir' não muda nada; para trocar a senha HTTP, rode: SENHA_HTTP=$SENHA_HTTP bash scripts/deploy-teste.sh publicar -- que religa o site. Com o site tirado de propósito, troque só a linha SENHA_HTTP do .env: o próximo publicar a aplica."
        if grep -q '^SENHA_HTTP=' .env; then
            sed -i "s/^SENHA_HTTP=.*/SENHA_HTTP=$SENHA_HTTP/" .env
        else
            printf '\n# A senha HTTP do nginx do host (scripts/deploy-teste.sh).\nSENHA_HTTP=%s\n' "$SENHA_HTTP" >> .env
        fi
    else
        # Lida do .env, também tem de ser exatamente sim ou nao: 'SENHA_HTTP="sim"'
        # ou um comentário na linha viravam 'nao' em silêncio.
        #
        # E SEM PADRÃO (08/11/2026, o D451): sem a linha, o `nao` de 24/09 era
        # posto aqui, só na memória, e o primeiro publicar de um .env novo -- o
        # .env.teste.example não tem a linha -- punha o ERP no ar ABERTO, com o
        # `conferir` dizendo "ok". A escolha do dono desde 02/10 é a senha (o
        # D326). Vazia, ela segue vazia: quem lê só o "sim" a trata como sem
        # senha, e quem liga o vhost recusa (exigir_a_escolha_da_senha).
        SENHA_HTTP="$(do_env SENHA_HTTP)"
        case "$SENHA_HTTP" in
            ''|sim|nao) ;;
            *) falhar "SENHA_HTTP='$SENHA_HTTP' no .env: escreva SENHA_HTTP=sim ou SENHA_HTTP=nao, sem aspas nem comentário na linha." ;;
        esac
    fi
}

# A SENHA HTTP TEM DE TER SIDO ESCOLHIDA, na linha de comando ou no .env, antes
# de o vhost ligar (08/11/2026, o D451, a opção (a) da proposta: não muda o que
# um .env com a linha já diz). Chamada no começo do publicar, do atualizar, do
# voltar e do _aplicar -- antes de qualquer freio ou troca -- e de novo no
# instalar_vhost, o ponto por onde os três passam: o `atualizar` de um site fora
# SEM a marca do `tirar` (o `base`, ou o freio de um passo que não chegou ao fim)
# religava pelo por_no_ar com o padrão, aberto. Roda depois do conferir_env.
exigir_a_escolha_da_senha() {
    [ -z "$SENHA_HTTP" ] || return 0
    falhar "a senha HTTP não foi escolhida: o .env não tem a linha SENHA_HTTP, e a rodada não a deu. Nada liga o vhost sem a escolha (o D451; a do dono desde 02/10/2026 é a senha ligada, o D326). Com a senha:
    sudo htpasswd -c $HTPASSWD <usuario>
    SENHA_HTTP=sim bash scripts/deploy-teste.sh publicar
(o publicar grava a escolha no .env; depois dele, o atualizar e o voltar a seguem). Sem a senha, só por decisão nova do dono: SENHA_HTTP=nao bash scripts/deploy-teste.sh publicar"
}

# O COMANDO DE RELIGAR, como as frases o mandam: com a senha, enquanto o .env não
# diz a escolha -- quem copiava o `publicar` impresso num .env sem a linha
# publicava aberto (o D451). Também no fim do `base`: o texto que ele imprime
# depois da carga não é a carga (o selo é o dos ARQUIVOS_DA_COPIA e da
# VERSAO_DA_CARGA, e o cmd_base não muda no que faz na base).
comando_de_publicar() {
    if [ -n "$(do_env SENHA_HTTP)" ]; then
        printf 'bash scripts/deploy-teste.sh publicar'
    else
        printf 'sudo htpasswd -c %s <usuario>, e: SENHA_HTTP=sim bash scripts/deploy-teste.sh publicar' "$HTPASSWD"
    fi
}

# O que o docker compose vai de fato subir: nome, loopback, o corte de saída e o
# nginx que não volta sozinho. Lido do JSON, porta por porta: contar linhas
# 'published:' contra 'host_ip:' aprovava o `ports: ["3306"]`, que o Docker
# publica em 0.0.0.0 numa porta qualquer e que sai sem nenhuma das duas.
#
# Roda também DEPOIS do merge (o `_aplicar` é o script da árvore nova): um
# commit que publique porta fora do loopback não chega a subir.
conferir_compose() {
    local resumo nome portas fora saida reinicio
    resumo="$(dc config --format json | python3 -c '
import json, sys
c = json.load(sys.stdin)
servicos = c.get("services") or {}
portas, fora = 0, []
for nome, s in servicos.items():
    for p in s.get("ports") or []:
        portas += 1
        if p.get("host_ip") != "127.0.0.1" or not str(p.get("published") or ""):
            fora.append("%s:%s" % (nome, p.get("target")))
    # A rede do host escuta em todas as interfaces sem passar por ports.
    if s.get("network_mode") == "host":
        fora.append("%s:network_mode=host" % nome)
app = servicos.get("app") or {}
print(c.get("name") or "-", portas, ",".join(fora) or "-",
      "sim" if "api.asaas.com" in json.dumps(app.get("extra_hosts")) else "nao",
      (servicos.get("nginx") or {}).get("restart") or "no")
')" || falhar "docker compose config falhou (ou o python3 não leu o JSON)."
    read -r nome portas fora saida reinicio <<<"$resumo"

    [ "$nome" = "$PROJETO" ] || falhar "o compose não sobe o projeto '$PROJETO' (sobe '$nome')."
    [ "$portas" -gt 0 ] && [ "$fora" = "-" ] || falhar "há porta publicada fora de 127.0.0.1 ou sem porta no host: $fora."
    [ "$saida" = "sim" ] || falhar "o compose não tem o corte de saída (docker-compose.teste.yml)."
    # 'always' religa o nginx parado à mão quando o Docker reinicia -- e o site
    # voltava ao ar no meio de uma carga que não terminou.
    [ "$reinicio" != "always" ] || falhar "o nginx da pilha está com restart: always; o docker-compose.teste.yml o põe em unless-stopped."
    ok "compose: projeto $PROJETO, $portas porta(s), todas no loopback, saída cortada, nginx sem restart: always"
}

# ------------------------------------------------------------------------------
# conferências que só leem

# A configuração QUE A APLICAÇÃO CARREGA, montada como ela monta -- os
# module.config.php e TODO config/autoload/{,*.}{global,local}.php, pelo
# gerenciador de módulos --, lida de dentro do contêiner. Antes eram só
# global.php + local.php: um development.local.php (o `composer
# development-enable` o cria) ligava o erro na página com o `conferir` verde.
# E a aplicação tem de ENTRAR no banco com o usuário dela: um .env novo com o
# volume velho deixava todo pedido em "Access denied" e o `preparar` dizia
# "Pilha no ar".
conferir_config() {
    dc exec -T -e DOMINIO="$DOMINIO" -e BANCO_ESPERADO="$DB_NAME" app php -r '
        require "vendor/autoload.php";
        $erros = [];
        foreach (glob("config/autoload/*.local.php") ?: [] as $f) {
            $erros[] = "$f existe: neste ambiente so o local.php vale (um *.local.php ganha dele)";
        }
        if (file_exists("config/development.config.php")) {
            $erros[] = "config/development.config.php existe: o modo de desenvolvimento esta ligado";
        }
        $appConfig = require "config/application.config.php";
        $sm = new Laminas\ServiceManager\ServiceManager();
        (new Laminas\Mvc\Service\ServiceManagerConfig($appConfig["service_manager"] ?? []))->configureServiceManager($sm);
        $sm->setService("ApplicationConfig", $appConfig);
        $sm->get("ModuleManager")->loadModules();
        $c = $sm->get("config");
        $pegar = function (array $caminho) use ($c) {
            $v = $c;
            foreach ($caminho as $p) {
                $v = is_array($v) && array_key_exists($p, $v) ? $v[$p] : null;
            }
            return $v;
        };
        $banco = (array) $pegar(["doctrine", "connection", "orm_default", "params"]);
        if (($banco["dbname"] ?? null) !== getenv("BANCO_ESPERADO")) {
            $erros[] = "o banco da aplicacao nao e o " . getenv("BANCO_ESPERADO") . " do .env";
        } else {
            try {
                new PDO(
                    sprintf("mysql:host=%s;port=%s;dbname=%s", $banco["host"] ?? "", $banco["port"] ?? "", $banco["dbname"]),
                    (string) ($banco["user"] ?? ""),
                    (string) ($banco["password"] ?? ""),
                    [PDO::ATTR_TIMEOUT => 10]
                );
            } catch (Throwable $e) {
                $erros[] = "a aplicacao nao entra no banco com o usuario dela: " . $e->getMessage();
            }
        }
        if ($pegar(["mail", "host"]) !== "mailpit") {
            $erros[] = "mail.host nao e mailpit: o e-mail sairia pelo SMTP de producao";
        }
        if ($pegar(["sistema", "url"]) !== "https://" . getenv("DOMINIO") . "/") {
            $erros[] = "sistema.url nao e https://" . getenv("DOMINIO") . "/";
        }
        // AS CREDENCIAIS, e não o bloco: o module.config.php do ISApi traz a URL
        // do Yungas e o agent_id da Zoomtec, que não autenticam nada.
        foreach ([["onesignal", "app", "rest_api_key"], ["onesignal", "webpush", "rest_api_key"], ["sms", "usuario"], ["zoomtec", "bearer_token"],
                  ["yungas", "apikey"], ["yungas", "email"], ["yungas", "password"]] as $k) {
            if (!empty($pegar($k))) {
                $erros[] = implode(".", $k) . " preenchido";
            }
        }
        if ($pegar(["view_manager", "display_exceptions"]) !== null) {
            $erros[] = "view_manager.display_exceptions definido: a pagina de erro mostraria SQL";
        }
        if (!in_array(strtolower((string) ini_get("display_errors")), ["", "0", "off"], true)) {
            $erros[] = "display_errors ligado (php.teste.ini nao carregou?)";
        }
        if (!in_array(strtolower((string) ini_get("session.cookie_secure")), ["1", "on"], true)) {
            $erros[] = "session.cookie_secure desligado: o cookie da sessao iria em texto claro";
        }
        if ($erros) {
            fwrite(STDERR, implode("\n", $erros) . "\n");
            exit(1);
        }
    ' || falhar "a configuração do teste não está segura (acima)."
    ok "config (como a aplicação a monta): e-mail no Mailpit, sistema.url certo, integrações vazias, erro fora da página, cookie Secure, banco que aceita a aplicação"
}

# AS QUATRO PERGUNTAS, e TODO endereço de volta tem de ser de loopback. Medido no
# ensaio de 25/09/2026, com só o IPv4 no /etc/hosts: `ahosts` e `ahostsv4`
# devolviam 127.0.0.1 e o `ahostsv6` voltava vazio (o contêiner não tinha IPv6
# e o getaddrinfo filtra a família) -- os três verdes --, e só o `hosts`, que
# pergunta IPv6 sem esse filtro, devolvia o endereço REAL do onesignal.com. Num
# servidor com IPv6 no Docker, esse endereço é alcançável.
#
# E A RESPOSTA VAZIA REPROVA: sem getent na imagem, ou com o contêiner caído,
# nada volta -- e "nada fora do loopback" passaria sem ter medido.
#
# E O GOOGLE, FORA DO ARQUIVO DO FIREBASE (a verificação de 11/10/2026): com os
# dois nomes fora de HOSTS_MORTOS, o teste de sempre seguia cortando o Google
# (o docker-compose.teste.yml e a homologação), mas esta medida deixou de
# conferi-lo -- o contêiner não recriado, ou a lista do teste sem um nome no
# BVIP, passavam verdes. Agora eles entram na medida sempre que o COMPOSE_FILE
# não traz o docker-compose.teste-firebase.yml; com ele, saem (lá o Google
# resolve de verdade, de propósito), e os outros dez seguem medidos.
conferir_hosts_mortos() {
    local h todos fora lista="$HOSTS_MORTOS $HOSTS_DO_GOOGLE" google="e os dois do Google, que o teste de sempre corta"
    case "$(do_env COMPOSE_FILE)" in
        *"$ARQUIVO_DO_FIREBASE"*)
            lista="$HOSTS_MORTOS"
            google="fora o Google, que o $ARQUIVO_DO_FIREBASE do COMPOSE_FILE abre"
            ;;
    esac
    for h in $lista; do
        todos="$(dc exec -T app sh -c "getent hosts '$h'; getent ahosts '$h'; getent ahostsv4 '$h'; getent ahostsv6 '$h'; true" \
            | awk '{print $1}' | sort -u)" || falhar "não consegui perguntar ao contêiner app por $h."
        [ -n "$todos" ] || falhar "no contêiner, $h não resolveu para nada: a conferência não mediu (falta o getent? a linha do extra_hosts?)."
        fora="$(printf '%s\n' "$todos" | grep -vxE '127\.0\.0\.1|::1|::ffff:127\.0\.0\.1' || true)"
        [ -z "$fora" ] || falhar "no contêiner, $h ainda resolve para fora do loopback: $(printf '%s' "$fora" | tr '\n' ' ')"
    done
    ok "saída: $(printf '%s' "$lista" | wc -w) hosts de integração resolvem só para o loopback (IPv4 e IPv6), $google"
}

# MYSQL_PWD, e não -p: o aviso de senha na linha de comando iria para o stderr
# junto com os erros de verdade.
sql() {
    dc exec -T -e MYSQL_PWD=root mysql mysql -uroot --default-character-set=utf8mb4 -N -B "$DB_NAME"
}

# A base, pela régua de scripts/conferir-base-teste.sql. Dois modos:
#
#   carga  logo depois do `base`: os vinte e um números, e a senha de todos igual;
#   uso    pela vida inteira do ambiente: só os seis que o uso normal não mexe
#          (chaves, ids do Asaas, links da produção, hashes previsíveis). O
#          login grava token_web e um cliente novo pode ter e-mail real: cobrar
#          isso deixava o `conferir` vermelho para sempre depois do 1º login.
BASE_VAZIA=nao
# A régua e o endurecimento que rodam. O `base` os troca por cópias tiradas no
# começo, conferidas contra o selo do dump: um `git pull` à mão no meio da carga
# rodava os de OUTRO código (quarta revisão).
REGUA="scripts/conferir-base-teste.sql"
ENDURECIMENTO="scripts/endurecer-base-teste.sql"
conferir_base() {
    local modo="${1:-uso}" usuarios linha hash erros status=0 motivo
    # "VAZIO DEPOIS DE UM BASE QUE FALHOU?" SÓ COM A TABELA AUSENTE (ERROR 1146),
    # como no selo_registrado: com o mysql parado -- logo depois de um reboot,
    # ou de um `down` --, qualquer erro virava esse palpite, e o conferir dizia
    # que a base boa se perdera (quinta revisão).
    erros="$(mktemp)"
    usuarios="$(printf 'SELECT COUNT(*) FROM usuarios;\n' | sql 2>"$erros")" || status=$?
    if [ "$status" -ne 0 ]; then
        motivo="$(head -n 2 "$erros" | tr '\n' ' ')"
        if grep -q 'ERROR 1146' "$erros"; then
            rm -f "$erros"
            falhar "o banco $DB_NAME não tem a tabela usuarios (vazio depois de um 'base' que falhou?)."
        fi
        rm -f "$erros"
        falhar "não consegui ler o banco $DB_NAME (o mysql do teste está no ar? docker compose up -d mysql): $motivo"
    fi
    rm -f "$erros"
    # BASE VAZIA: é o estado antes do primeiro `base`, e passa -- menos logo
    # depois de uma carga, em que quer dizer que o dump foi para outro lugar.
    if [ "$usuarios" = "0" ]; then
        [ "$modo" != "carga" ] || falhar "a carga não trouxe nenhum colaborador para o banco $DB_NAME."
        BASE_VAZIA=sim
        ok "base: vazia (carregue com: bash scripts/deploy-teste.sh base ARQ.sql.gz)"
        return 0
    fi

    # A FORMA DA LINHA, e não a contagem: todos zero menos o último, que é 1
    # (legenda em scripts/conferir-base-teste.sql).
    linha="$(sql < "$REGUA")" || falhar "não consegui conferir o banco $DB_NAME."
    local forma_carga='^(0 ){6,}1$' forma_uso='^0 0 0 0 0 0( [0-9]+)+$'
    if [ "$modo" = "carga" ]; then
        [[ "$linha" =~ $forma_carga ]] \
            || falhar "a base carregada não está pronta: '$linha' -- todos têm de ser zero, menos o último, que é 1 (a legenda está em scripts/conferir-base-teste.sql)."
    else
        [[ "$linha" =~ $forma_uso ]] \
            || falhar "a base tem o que age sobre gente ou dinheiro: '$linha' -- os seis primeiros têm de ser zero (legenda em scripts/conferir-base-teste.sql). Veja qual acendeu antes de recarregar: recarregar troca a base inteira."
    fi

    # A senha publicada nos documentos NÃO entra.
    hash="$(printf 'SELECT senha FROM usuarios ORDER BY id LIMIT 1;\n' | sql)" || falhar "não consegui ler a senha do colaborador de menor id."
    dc exec -T -e H="$hash" app php -r 'exit(password_verify("dev123456", getenv("H")) ? 1 : 0);' \
        || falhar "a senha publicada (dev123456) entra no colaborador de menor id."
    ok "base ($modo): $usuarios colaboradores, sem chaves, ids do Asaas, hashes previsíveis nem a senha publicada"
}

# O SELO GRAVADO NA BASE (ver ARQUIVOS_DA_COPIA): numa tabela do próprio banco
# de teste, e não num arquivo. A base recriada, apagada ou carregada por outro
# caminho não tem selo nenhum -- e aí o site não volta.
#
# SÓ A TABELA AUSENTE É "SEM SELO" (ERROR 1146): o resto é "não consegui ler".
# Com o mysql parado -- depois de um `down`, ou subindo no boot --, qualquer
# erro virava "sem selo", e o atualizar culpava a base e receitava exportar de
# novo e apagar a base boa (quarta revisão).
#
# QUEM CHAMA ATRIBUI E SAI: `x="$(selo_registrado)" || exit 1`. O falhar daqui
# roda num subshell, e dentro de um `[ ]` o erro seria só um texto vazio.
#
# O VALOR SÓ DO STDOUT, e o stderr à parte: juntos, um aviso do docker (o
# `config.json` ilegível avisa a cada chamada) entrava no selo lido, o `base`
# acusava "não consegui gravar o selo" e apagava a base boa (quinta revisão).
selo_registrado() {
    local saida erros status=0 motivo
    erros="$(mktemp)"
    saida="$(printf 'SELECT `valor` FROM `_deploy_teste` WHERE `chave` = %s;\n' "'selo'" | sql 2>"$erros")" || status=$?
    if [ "$status" -eq 0 ]; then
        rm -f "$erros"
        printf '%s' "$saida" | tr -d '\r\n'
        return 0
    fi
    if grep -q 'ERROR 1146' "$erros"; then
        rm -f "$erros"
        return 0
    fi
    motivo="$(head -n 2 "$erros" | tr '\n' ' ')"
    rm -f "$erros"
    falhar "não consegui ler o selo da base no banco $DB_NAME (o mysql do teste está no ar? docker compose up -d mysql): $motivo"
}

registrar_selo() {
    printf 'CREATE TABLE IF NOT EXISTS `_deploy_teste` (`chave` VARCHAR(32) NOT NULL PRIMARY KEY, `valor` VARCHAR(160) NOT NULL) ENGINE=InnoDB;\nREPLACE INTO `_deploy_teste` (`chave`, `valor`) VALUES (%s, %s);\n' \
        "'selo'" "'$1'" | sql
}

# Nada põe o site no ar com uma base que não é a deste código.
recusar_se_base_de_outro_codigo() {
    local registrado
    registrado="$(selo_registrado)" || exit 1
    [ "$registrado" != "$(selo_da_base)" ] || return 0
    if [ -z "$registrado" ]; then
        falhar "a base carregada não tem selo (carregada antes desta versão do deploy, ou por um 'base' que não terminou): o site fica fora do ar até um 'base' com a cópia exportada pelo código de $(git rev-parse --short HEAD)."
    fi
    falhar "a base carregada é de outro código -- o anonimizador, o exportador, o endurecimento, a régua ou a carga mudaram. Exporte a cópia com o código de $(git rev-parse --short HEAD) (scripts/exportar-base-teste.sh, na sua máquina), traga-a, e rode o 'base' e: $(comando_de_publicar)."
}

# O APP QUE RODA É O DA CONFIGURAÇÃO EM DISCO? O compose recria um serviço
# quando a configuração diverge -- e um `up` do nginx, sem --no-deps, recriava
# o app DEPOIS das conferências: ia ao ar um app que ninguém conferiu (terceira
# revisão). A etiqueta que o compose põe no contêiner diz com que configuração
# ele nasceu.
conferir_app_da_config() {
    local id esperado atual
    id="$(dc ps -q app 2>/dev/null | head -n 1)" || true
    [ -n "$id" ] || falhar "o contêiner app não está no ar."
    esperado="$(dc config --hash app 2>/dev/null | awk '$1 == "app" {print $2}')" || true
    atual="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.config-hash"}}' "$id" 2>/dev/null)" || true
    [ -n "$esperado" ] && [ "$esperado" = "$atual" ] \
        || falhar "o app que roda não é o da configuração em disco (o compose mudou depois de ele subir): o que foi conferido não seria o que vai ao ar. Rode o 'publicar' de novo: ele tira o site, recria o app e o confere antes de religar."
    ok "app: o que roda nasceu da configuração que está em disco"
}

conferir_cron() {
    local padrao achado
    # PELA PORTA, E NÃO PELO NOME DO HOST: 0.0.0.0:<porta>, 127.0.0.2, o nome da
    # máquina -- qualquer endereço com a porta do app chega ao nginx da pilha
    # sem passar pelo do host (terceira revisão).
    padrao="$DOMINIO|:$PORTA_APP([^0-9]|\$)"
    achado="$(crontab -l 2>/dev/null | grep -E "$padrao" || true)"
    achado="$achado$(sudo crontab -l 2>/dev/null | grep -E "$padrao" || true)"
    # Os crontabs de TODOS os usuários (/var/spool/cron), as pastas periódicas e
    # os timers do systemd: quatro lugares só deixavam passar os outros. DO
    # SYSTEMD, TODOS: os do sistema, os de usuário (~/.config/systemd/user, que
    # um `systemctl --user` instala sem sudo) e os transitórios do
    # `systemd-run --on-calendar` -- só /etc/systemd/system era lido (quarta
    # revisão).
    # -D skip: SÓ ARQUIVO, NUNCA FIFO, SOCKET OU DISPOSITIVO (14/10/2026, o D491). O
    # systemd mantém em /run/user/<uid>/systemd/inaccessible/ um FIFO de modo 000
    # que só o root abre -- e o grep -R, como root, o abria para ler e esperava
    # para sempre quem escrevesse: o conferir do BVIP parou aqui, sem saída nenhuma,
    # por mais de dez minutos. Um timer ou um cron é arquivo comum.
    achado="$achado$(sudo grep -D skip -RlE "$padrao" /etc/cron.d /etc/crontab /etc/cron.hourly /etc/cron.daily \
        /etc/cron.weekly /etc/cron.monthly /var/spool/cron \
        /etc/systemd/system /etc/systemd/user /etc/xdg/systemd/user /usr/lib/systemd/system /usr/lib/systemd/user \
        /run/systemd/system /run/systemd/transient /run/systemd/user 2>/dev/null || true)"
    # AS DE USUÁRIO, pasta por pasta, e não o /home inteiro: o clone mora lá, e
    # a doc dele cita o domínio. As três casas delas: ~/.config/systemd,
    # ~/.local/share/systemd e as transitórias do `systemd-run --user`, em
    # /run/user/<uid>/systemd -- as duas últimas faltavam (quinta revisão).
    # UMA PASTA, UM ARGUMENTO: a lista sem aspas partia "/home/joao silva" em
    # dois, e o grep lia um home inteiro (quinta revisão).
    local -a dirs_de_usuario=()
    mapfile -t dirs_de_usuario < <(sudo find /home /root /run/user -maxdepth 4 -type d \
        \( -path '*/.config/systemd' -o -path '*/.local/share/systemd' -o -path '/run/user/*/systemd' \) 2>/dev/null || true)
    [ "${#dirs_de_usuario[@]}" -eq 0 ] || achado="$achado$(sudo grep -D skip -RlE "$padrao" "${dirs_de_usuario[@]}" 2>/dev/null || true)"
    [ -z "$achado" ] || falhar "há cron chamando este ambiente — as rotinas cobram e mandam mensagem: $achado"
    ok "cron: nada agendado contra este ambiente"
}

codigo() {
    curl -sS -o /dev/null -w '%{http_code}' --max-time 90 --resolve "$DOMINIO:443:127.0.0.1" "https://$DOMINIO$1" || echo 000
}

# O RELOAD DO NGINX NÃO ESPERA (14/10/2026, o D492): o `systemctl reload` só manda o
# sinal ao mestre e volta, e o nginx do BVIP -- uns treze sites com certificado --
# ainda servia a configuração anterior quando a primeira sonda chegou: o certificado
# de outro site ("no alternative certificate subject name matches"), e o publicar
# tirou o site do ar com o vhost certo e o certificado certo em disco. Antes das
# sondas, até 30 s esperando o TLS deste domínio fechar (o curl sem -f sai 0 com
# qualquer código, o 401 da senha também); se não fechar, as sondas dizem por quê.
esperar_o_vhost_no_ar() {
    local i
    for i in $(seq 1 30); do
        curl -s -o /dev/null --max-time 5 --resolve "$DOMINIO:443:127.0.0.1" "https://$DOMINIO/" && return 0
        sleep 1
    done
    return 1
}

cabecalhos() {
    curl -sSI --max-time 60 --resolve "$DOMINIO:443:127.0.0.1" "https://$DOMINIO$1" || true
}

com_senha_http() {
    sudo grep -qE '^[[:space:]]*auth_basic[[:space:]]' "$VHOST" 2>/dev/null
}

# De fora para dentro, pelo nginx do host, sem sair da máquina.
conferir_publico() {
    if ! sudo test -f "/etc/letsencrypt/live/$DOMINIO/fullchain.pem"; then
        ok "público: ainda sem certificado (rode 'publicar')"
        return 0
    fi

    local p c corpo ativo h
    # SONDAS que não existem, embaixo de cada caminho negado: com a regra, 403;
    # sem ela, a aplicação responderia 404, ou o login -- e nenhuma rotina real é
    # chamada. O /mod-*/cronJob é o mesmo controlador das rotinas por outro
    # caminho: as rotas genéricas dos módulos aceitam o apelido de qualquer um.
    # A GRAFIA TROCADA também: /API/cronjobs passava pelo 403 e chegava ao PHP
    # (lá, sem rota -- mas a garantia era a do nginx). Os chatbots de /rh e
    # companhia são da conta de PRODUÇÃO do Zapier, sem login.
    for p in /api/cronjobs/sonda-do-deploy /api/cronjobs /API/Cronjobs/sonda-do-deploy \
             /api/marketing/sonda-do-deploy /api/visagismo/sonda-do-deploy \
             /api/unidade/sonda-do-deploy /Api/Unidade/sonda-do-deploy \
             /mod-configuracoes/whatsapp/sonda-do-deploy \
             /mod-cadastros/cronJob/sonda-do-deploy /mod-vendas/cronjob/sonda-do-deploy \
             /mod-cadastros/whatsapp/sonda-do-deploy /index.php/sonda-do-deploy /INDEX.PHP/sonda-do-deploy \
             /ocra_service_manager_yuml /rh /ajudantevip /invencivel /MotivacionalVip; do
        c="$(codigo "$p")"
        # O 000 É O CURL QUE NÃO CHEGOU (TLS, tempo), e não a regra que sumiu:
        # com o certificado vencido, o publicar mandava procurar a regra errada
        # (quarta revisão).
        case "$c" in
            403) ;;
            000*) falhar "$p não respondeu: o curl não chegou ao nginx do host (o erro está acima -- certificado vencido? tempo esgotado?)." ;;
            *) falhar "$p respondeu $c; devia ser 403 (a regra do nginx do host sumiu?)." ;;
        esac
    done
    ok "público: rotinas, disparo de mensagem, visagismo, exportação de vendas e chatbots de produção negados (403), em qualquer grafia"

    # A ESCOLHA GRAVADA MANDA, e não o que o vhost diz de si: com SENHA_HTTP=sim
    # e o vhost sem senha, o site estava aberto e a sonda achava normal.
    if [ "$SENHA_HTTP" = "sim" ] && ! com_senha_http; then
        falhar "SENHA_HTTP=sim no .env, e o vhost instalado não pede senha."
    fi
    # E SEM ESCOLHA NENHUMA, o site no ar sem senha veio do padrão de antes (o
    # `nao` que a v9 punha na memória, o D451): o `conferir` dizia "ok público".
    if [ -z "$SENHA_HTTP" ] && ! com_senha_http; then
        falhar "o site está no ar SEM a senha HTTP, e o .env não tem a linha SENHA_HTTP: foi publicado pelo padrão de antes (o D451), e não por escolha. Tire-o do ar (bash scripts/deploy-teste.sh tirar), e religue com a escolha: $(comando_de_publicar)"
    fi

    if com_senha_http; then
        for p in / /novo/; do
            c="$(codigo "$p")"
            [ "$c" = "401" ] || falhar "$p respondeu $c; com a senha HTTP ligada, devia ser 401."
        done
        ok "público: senha HTTP na frente de / e /novo/ (401)"
    else
        c="$(codigo /)"
        case "$c" in 200|302) ;; *) falhar "/ respondeu $c." ;; esac

        corpo="$(curl -sS --max-time 60 --resolve "$DOMINIO:443:127.0.0.1" "https://$DOMINIO/novo/")" || falhar "/novo/ não respondeu."
        ativo="$(printf '%s' "$corpo" | grep -oE '/novo/assets/[^"]+\.js' | head -n 1 || true)"
        [ -n "$ativo" ] || falhar "/novo/ não trouxe a tela nova (index sem /novo/assets/)."
        c="$(codigo "$ativo")"
        [ "$c" = "200" ] || falhar "$ativo respondeu $c."
        ok "público: ERP em /, tela nova em /novo/"
    fi

    for p in / /novo/; do
        h="$(cabecalhos "$p")"
        printf '%s' "$h" | grep -qi '^x-robots-tag: noindex' || falhar "$p sem o X-Robots-Tag: noindex."
        printf '%s' "$h" | grep -qi '^strict-transport-security:' || falhar "$p sem o Strict-Transport-Security."
    done
    ok "público: noindex e HSTS em / e /novo/"
}

# ------------------------------------------------------------------------------
# passos que mudam o ambiente

# TUDO MENOS O NGINX DA PILHA: ele é a porta do endereço público, e quem o sobe
# é o fim de cada subcomando, depois das conferências. Um `up` de tudo aqui
# religava o site antes de a base ser conferida.
subir_e_ajustar() {
    local servicos
    servicos="$(dc config --services | grep -vx nginx | tr '\n' ' ')"
    passo "subindo a pilha $PROJETO, menos o nginx: $servicos"
    # shellcheck disable=SC2086
    dc up -d --build --wait $servicos

    passo "dependências do PHP"
    dc exec -T app composer install --no-interaction --prefer-dist --no-progress
    # SEM O public/app.json, como as duas do atualizar e do voltar (o D549): a
    # tela o grava no clone, e com ele mudado o _aplicar do atualizar parava
    # aqui, com o site já fora, culpando o composer.
    [ -z "$(mudancas_versionadas)" ] \
        || falhar "o composer mudou arquivo versionado (plugin?): $(mudancas_versionadas | head -n 5)"

    passo "pastas graváveis, local.php e proxies do Doctrine"
    dc exec -T app sh -c '
        set -e
        cd /var/www/html
        mkdir -p data/cache data/logs data/logs/agenda_json data/DoctrineORMModule/Proxy \
                 public/arquivos public/contratos public/banners public/assistentes public/vip-class \
                 public/visagismo public/logs public/layout/img/avatar/cache public/layout/img/avatar/tmp
        chown -R www-data:www-data data public/arquivos public/contratos public/banners public/assistentes \
                 public/vip-class public/visagismo public/logs public/layout/img/avatar/cache public/layout/img/avatar/tmp
        chgrp www-data public/layout/img/avatar && chmod 2775 public/layout/img/avatar
        chown root:www-data config/autoload/local.php && chmod 640 config/autoload/local.php
        rm -rf data/DoctrineORMModule/Proxy/*
    '

    passo "portão do local.php"
    dc exec -T app php scripts/verificar-local-php.php
}

cmd_preparar() {
    guardas

    passo "servidor: $(hostname)"
    confirmar "Preparar o ambiente de TESTE de $DOMINIO NESTE servidor?"

    if [ ! -f .env ]; then
        passo "gerando o .env (senhas aleatórias)"
        cp .env.teste.example .env
        chmod 600 .env
        sed -i "s/^DB_PASSWORD=.*/DB_PASSWORD=$(openssl rand -hex 16)/; s/^PG_PASSWORD=.*/PG_PASSWORD=$(openssl rand -hex 16)/" .env
    fi
    conferir_env
    conferir_compose

    # O NGINX DA PILHA SAI, e só o publicar o põe de volta: o preparar recria o
    # app, e num servidor com o vhost ligado o endereço servia o app novo antes
    # de qualquer conferência. Sem sudo aqui, este é o caminho que se pode tirar.
    dc rm -s -f nginx >/dev/null 2>&1 || true
    [ -z "$(dc ps -a -q nginx 2>/dev/null)" ] || falhar "não consegui tirar o nginx da pilha; o preparar não recria o app com ele servindo."

    if [ -z "$(dc ps -q 2>/dev/null)" ] && command -v ss >/dev/null 2>&1; then
        local p
        for p in $(dc config | grep 'published:' | grep -oE '[0-9]+'); do
            ! ss -tln | grep -qE "[:.]$p\b" || falhar "a porta $p já está em uso neste servidor (outra pilha?). Troque-a no .env."
        done
    fi

    if [ ! -f config/autoload/local.php ]; then
        passo "gerando o config/autoload/local.php"
        grep -q "'url' => 'http://localhost:8080/'" docker/config/local.php.docker \
            || falhar "docker/config/local.php.docker mudou: não achei a linha do sistema.url."
        sed "s#'url' => 'http://localhost:8080/'#'url' => 'https://$DOMINIO/'#" docker/config/local.php.docker > config/autoload/local.php
        grep -q "'url' => 'https://$DOMINIO/'" config/autoload/local.php || falhar "o sistema.url não foi trocado."
    fi

    subir_e_ajustar
    conferir_config
    conferir_hosts_mortos
    conferir_base uso

    # O PUBLICAR COM A SENHA enquanto o .env não diz a escolha (o D451): este
    # era o comando que, copiado do terminal, publicava aberto.
    local publicar_com_a_escolha
    if [ -n "$(do_env SENHA_HTTP)" ]; then
        publicar_com_a_escolha="         EMAIL_CERTBOT=voce@exemplo bash scripts/deploy-teste.sh publicar   (a senha HTTP segue o .env: SENHA_HTTP=$(do_env SENHA_HTTP))"
    else
        publicar_com_a_escolha="         sudo htpasswd -c $HTPASSWD <usuario>
         SENHA_HTTP=sim EMAIL_CERTBOT=voce@exemplo bash scripts/deploy-teste.sh publicar
     (a senha HTTP na frente é a escolha do dono desde 02/10/2026, o D326; sem a
     escolha, o publicar recusa ligar o vhost)"
    fi

    cat <<FIM

Pilha pronta, SEM o nginx da pilha: quem o sobe é o publicar, com as sondas
de fora e o cron conferidos. Próximos passos:

  1. Carregar a cópia de scripts/exportar-base-teste.sh (gerada na sua máquina):
         bash scripts/deploy-teste.sh base ~/base-teste.sql.gz
  2. Publicar a tela nova, o nginx e o TLS (o DNS de $DOMINIO já aponta para cá?):
$publicar_com_a_escolha
FIM
}

cmd_base() {
    local arquivo="${1:-}" nova hash linhas
    guardas
    conferir_env
    conferir_compose

    [ -n "$arquivo" ] && [ -f "$arquivo" ] || falhar "informe o dump: bash scripts/deploy-teste.sh base ~/base-teste.sql.gz"
    case "$arquivo" in
        *.sql.gz) ;;
        *) falhar "espero o .sql.gz de scripts/exportar-base-teste.sh (docs/ambiente-teste.md). Dump cru não entra neste servidor." ;;
    esac

    # O CONTEÚDO, E NÃO O NOME, antes de carregar -- e de parar o site. Uma
    # leitura só do dump, que separa as linhas que importam num temporário:
    #
    #   - "-- Dump completed" no fim: uma exportação que morreu no meio deixava
    #     um .gz íntegro e cortado, e a carga morria depois de `usuarios`;
    #   - nada de USE nem CREATE DATABASE (o `mysqldump --databases`): a carga
    #     ia para outro banco, e a régua conferia o que já estava aqui;
    #   - e-mail que não é .invalid em clientes, usuarios ou sócios: é o dump
    #     cru. O e-mail achado não é mostrado.
    #
    # SEM PIPEFAIL, em subshell, nos canos que terminam em `grep -q`: o que acha
    # o primeiro fecha o cano, quem vem antes morre com SIGPIPE, e com o
    # pipefail a linha inteira voltaria FALSA justamente quando achou.
    passo "conferindo o dump antes de carregar (demora: ele é lido inteiro)"
    gunzip -t "$arquivo" || falhar "o arquivo não é um gzip íntegro."

    # O SELO, PRIMEIRO: a cópia que o código desta árvore exportaria. Sem ele, ou
    # com outro, é a cópia de outro anonimizador -- a do mesmo nome, que ficou no
    # $HOME, entrava e apagava a pendência (terceira revisão).
    local selo_do_dump
    selo_do_dump="$( (set +o pipefail; gunzip -c "$arquivo" | head -n 1) | sed -n 's/^-- franquiabv-copia: \([0-9a-f]*\)$/\1/p')" || true
    [ -n "$selo_do_dump" ] \
        || falhar "a cópia não tem selo: foi exportada por um scripts/exportar-base-teste.sh anterior a esta versão. Exporte de novo com o código de $(git rev-parse --short HEAD). Nada foi carregado."
    [ "$selo_do_dump" = "$(selo_da_copia)" ] \
        || falhar "a cópia foi exportada por outro código: o anonimizador, o exportador, o endurecimento ou a régua de lá não são os daqui. Exporte de novo com o código de $(git rev-parse --short HEAD). Nada foi carregado."
    # A RÉGUA E O ENDURECIMENTO DESTE INSTANTE são os que rodam (ver REGUA), e
    # a árvore tem de continuar com o selo do dump depois de copiá-los.
    REGUA="$(mktemp)"
    ENDURECIMENTO="$(mktemp)"
    TEMPORARIOS="$TEMPORARIOS $REGUA $ENDURECIMENTO"
    cp scripts/conferir-base-teste.sql "$REGUA"
    cp scripts/endurecer-base-teste.sql "$ENDURECIMENTO"
    [ "$selo_do_dump" = "$(selo_da_copia)" ] \
        || falhar "a árvore mudou enquanto eu a lia (um git pull à mão?). Nada foi carregado."
    ok "dump: selado pelo código desta árvore"

    linhas="$(mktemp)"
    TEMPORARIOS="$TEMPORARIOS $linhas"
    # A EXTRAÇÃO TEM DE CHEGAR AO FIM, e é medida como o grep seguinte: o gunzip
    # sai com 0, e o grep com 0 (achou) ou 1 (não achou). O `|| true` de antes
    # engolia o disco cheio no /tmp, e o que viesse depois do corte passava sem
    # ser lido (terceira revisão).
    local extracao=0
    ( set +o pipefail
      gunzip -c "$arquivo" \
          | grep -E '^(INSERT INTO `(clientes|usuarios|unidades_socios)` |USE |CREATE DATABASE|-- Dump completed)' > "$linhas"
      s=("${PIPESTATUS[@]}")
      [ "${s[0]}" -eq 0 ] || exit 3
      [ "${s[1]}" -le 1 ] || exit "${s[1]}"
      exit 0 ) || extracao=$?
    [ "$extracao" -eq 0 ] \
        || falhar "não consegui separar as linhas do dump (saída $extracao -- disco cheio em $(dirname "$linhas")?). Nada foi carregado."
    # A ÚLTIMA LINHA, e não uma linha qualquer: dois gzip emendados, com o
    # segundo cortado, tinham o '-- Dump completed' do primeiro no meio. Nos
    # DOIS lugares: no arquivo, e no que a extração separou.
    [ "$(gunzip -c "$arquivo" | tail -n 1 | cut -c1-17)" = "-- Dump completed" ] \
        || falhar "o dump não termina com '-- Dump completed': está cortado (a exportação parou no meio?). Nada foi carregado."
    [ "$(tail -n 1 "$linhas" | cut -c1-17)" = "-- Dump completed" ] \
        || falhar "a extração do dump não chegou ao fim: a última linha separada não é o '-- Dump completed'. Nada foi carregado."

    # O CÓDIGO DO GREP, e não o `if`: 0 achou, 1 não achou, e o resto é "não
    # consegui medir". Com o `if`, um grep morto ou com erro valia "nenhum
    # e-mail real", e o dump passava (medido em 25/09/2026, matando o grep).
    local achou=0
    grep -qE '^(USE |CREATE DATABASE)' "$linhas" || achou=$?
    case "$achou" in
        0) falhar "o dump troca de banco (USE / CREATE DATABASE: mysqldump --databases?), e a carga iria para outro lugar. Gere com scripts/exportar-base-teste.sh. Nada foi carregado." ;;
        1) ;;
        *) falhar "não consegui conferir o dump (grep saiu $achou). Nada foi carregado." ;;
    esac
    # Os de antes do último só contam quando ele NÃO achou: quando acha, ele
    # fecha o cano, e o SIGPIPE deles é o esperado. Quando não acha, ele leu
    # tudo, e um erro antes dele (2) queria dizer que não houve o que ler.
    achou=0
    ( set +o pipefail
      grep -E '^INSERT INTO' "$linhas" | grep -oE "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" | grep -qviE "\.invalid\$"
      s=("${PIPESTATUS[@]}")
      [ "${s[2]}" -eq 1 ] || exit "${s[2]}"
      [ "${s[0]}" -le 1 ] && [ "${s[1]}" -le 1 ] || exit 3
      exit 1 ) || achou=$?
    case "$achou" in
        0) falhar "o dump traz e-mail que não é .invalid em clientes, usuarios ou sócios: não é a cópia anonimizada. Nada foi carregado." ;;
        1) ;;
        *) falhar "não consegui conferir os e-mails do dump (saída $achou). Nada foi carregado." ;;
    esac
    grep -qE '^INSERT INTO `usuarios` .*@exemplo\.invalid' "$linhas" \
        || falhar "o dump não traz colaboradores anonimizados (@exemplo.invalid). Nada foi carregado."
    rm -f "$linhas"
    ok "dump: completo, no banco certo, sem e-mail real em clientes, usuarios nem sócios"

    passo "vai SUBSTITUIR o banco $DB_NAME do projeto $PROJETO por $arquivo"
    confirmar "Confirma? O site sai do ar, o que está no banco de teste hoje é apagado, e ele só volta pelo 'publicar'."
    sudo -v || falhar "o base precisa de sudo (para desligar o vhost enquanto a base entra)."

    # O SITE SAI DO AR ANTES, E NÃO VOLTA AQUI: o dump chega com a senha
    # publicada (dev123456), e a troca vem minutos depois. Fora do ar pelos
    # dois caminhos, conferidos -- sem o nginx da pilha E com o vhost desligado.
    # Se um deles não sai, nada é carregado. Quem religa é o `publicar`, que
    # confere tudo antes.
    passo "tirando o site do ar"
    tirar_do_ar || falhar "não consegui tirar o site do ar pelos dois caminhos (acima). Nada foi carregado."

    # SE A CARGA NÃO CHEGAR AO FIM, A BASE SAI: um `base` que morreu no meio
    # (erro, Ctrl-C, queda do ssh) deixava a senha publicada no banco. E o selo
    # só é gravado no fim: uma base pela metade -- mesmo a que um segundo sinal
    # impediu de apagar -- não tem selo, e o `publicar` a recusa.
    AO_FALHAR=apagar_base
    dc up -d --wait mysql app
    # O APP RELÊ O php.ini DO TESTE: arquivo único montado só é relido com o
    # contêiner reiniciado (o git troca o inode), e o site está fora do ar. Sem
    # isto, a receita de migração terminava com o publicar recusando o cookie
    # sem Secure (terceira revisão).
    dc restart app
    dc up -d --wait app

    passo "recriando o banco $DB_NAME vazio"
    recriar_banco

    passo "carregando (demora: são milhões de linhas)"
    gunzip -c "$arquivo" | grep -v GTID_PURGED | sql

    passo "endurecendo a base (scripts/endurecer-base-teste.sql) e trocando a senha de todos"
    sql < "$ENDURECIMENTO"
    nova="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | cut -c1-16)"
    [ "${#nova}" -eq 16 ] || falhar "não consegui gerar a senha aleatória."
    hash="$(dc exec -T -e NOVA="$nova" app php -r 'echo password_hash(getenv("NOVA"), PASSWORD_BCRYPT);')"
    case "$hash" in \$2y\$*) ;; *) falhar "não consegui gerar o hash da senha." ;; esac
    printf "UPDATE usuarios SET senha = '%s';\n" "$hash" | sql

    # A SESSÃO ABERTA com a senha velha não sobrevive à troca: arquivos em /tmp
    # do contêiner (session.save_handler = files).
    dc exec -T app sh -c 'rm -f /tmp/sess_*; rm -rf /var/www/html/data/DoctrineORMModule/Proxy/*'

    conferir_base carga
    # O SELO QUE FOI CONFERIDO NO DUMP, e não o recalculado da árvore: um `git
    # pull` à mão durante a carga selava a cópia de A com o selo de B, e o
    # publicar de B a religava (quarta revisão). E a árvore tem de estar ainda
    # nele, com a régua e o endurecimento que rodaram -- senão a base sai.
    [ "$(selo_da_copia)" = "$selo_do_dump" ] && mesmo_conteudo "$REGUA" scripts/conferir-base-teste.sql \
        && mesmo_conteudo "$ENDURECIMENTO" scripts/endurecer-base-teste.sql \
        || falhar "a árvore mudou durante a carga (um git pull à mão?): a base foi moldada pelo código de antes, e é apagada. Rode o base de novo, com a cópia do código que está aqui."
    local gravado
    registrar_selo "$selo_do_dump:$VERSAO_DA_CARGA"
    gravado="$(selo_registrado)" || exit 1
    [ "$gravado" = "$selo_do_dump:$VERSAO_DA_CARGA" ] || falhar "não consegui gravar o selo da base no banco $DB_NAME."
    AO_FALHAR=""
    # A pendência da v4 não vale mais: quem decide agora é o selo. E a base
    # que um `base` anterior não conseguiu apagar foi trocada por esta.
    rm -f "$HOME/.franquiabv-teste-base-pendente" "$BASE_NAO_APAGADA"

    ( umask 077 && printf '%s\n' "$nova" > "$ARQUIVO_SENHA" )

    cat <<FIM

Base carregada, endurecida e conferida. Todos os colaboradores entram com:

    login: user<id>   (o user1 é do grupo administrador)
    senha: $nova

A senha fica guardada só neste servidor, em $ARQUIVO_SENHA (modo 600).

O SITE SEGUE FORA DO AR. Para religá-lo, depois de conferir tudo de novo:

    $(comando_de_publicar)
FIM
}

# O banco de teste vazio. Os privilégios do usuário da aplicação ficam em
# mysql.db e sobrevivem ao DROP/CREATE.
recriar_banco() {
    printf 'DROP DATABASE IF EXISTS `%s`; CREATE DATABASE `%s` CHARACTER SET utf8mb4;\n' "$DB_NAME" "$DB_NAME" \
        | dc exec -T -e MYSQL_PWD=root mysql mysql -uroot
}

# Chamada pela saída quando o `base` não chega ao fim.
apagar_base() {
    # O RESULTADO É O DA LIMPEZA, e não o do aviso (ver tirar_do_ar).
    if recriar_banco; then
        printf '    !!  a base de teste foi APAGADA; o site segue FORA DO AR. Gere a cópia com scripts/exportar-base-teste.sh e rode o base de novo.\n' >&2 || true
        return 0
    fi
    printf '    !!  não consegui apagar a base de teste, e ela pode estar com a senha publicada. O site segue FORA DO AR: não rode o publicar antes de um base que termine.\n' >&2 || true
    return 1
}

instalar_front() {
    construir_front
    trocar_front
}

# A build, em frontend/dist: não toca o que é servido.
construir_front() {
    passo "construindo a tela nova (--base=/novo/) num contêiner $IMAGEM_NODE"
    docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e npm_config_cache=/tmp/npm \
        -v "$RAIZ/frontend:/app" -w /app "$IMAGEM_NODE" \
        sh -c 'npm ci --no-audit --no-fund && npm run build -- --base=/novo/'
    grep -q 'src="/novo/assets/' frontend/dist/index.html || falhar "a build não saiu com --base=/novo/."
}

trocar_front() {
    passo "instalando em $FRONT_RAIZ/novo (troca atômica)"
    sudo mkdir -p "$FRONT_RAIZ"
    sudo rm -rf "$FRONT_RAIZ/novo.entrando" "$FRONT_RAIZ/novo.anterior"
    sudo cp -a frontend/dist "$FRONT_RAIZ/novo.entrando"
    sudo chmod -R a+rX "$FRONT_RAIZ"
    if sudo test -d "$FRONT_RAIZ/novo"; then
        sudo mv "$FRONT_RAIZ/novo" "$FRONT_RAIZ/novo.anterior"
    fi
    sudo mv "$FRONT_RAIZ/novo.entrando" "$FRONT_RAIZ/novo"
    # O COMMIT DA BUILD, fora da pasta servida: é por ele, e não pelo HEAD de
    # antes do merge, que o `atualizar` decide se reconstrói. Se esta linha
    # falhar depois da troca, a marca fica velha e a próxima rodada reconstrói.
    git rev-parse HEAD | sudo tee "$FRONT_RAIZ/novo.commit" >/dev/null
}

front_precisa_de_build() {
    local feito
    [ "${FORCAR_FRONT:-nao}" = "sim" ] && return 0
    sudo test -f "$FRONT_RAIZ/novo/index.html" || return 0
    feito="$(sudo cat "$FRONT_RAIZ/novo.commit" 2>/dev/null || true)"
    [ -n "$feito" ] && git cat-file -e "$feito^{commit}" 2>/dev/null || return 0
    [ -n "$(git diff --name-only "$feito" HEAD -- frontend/)" ]
}

# Instala, testa e recarrega. Se o nginx -t reprovar, devolve o anterior -- e o
# LINK COMO ESTAVA: com o site tirado do ar (vhost desligado), o link deixado
# para trás religava o vhost velho no próximo reload de qualquer um (o do
# certbot, o de outro site do servidor).
ativar_vhost() {
    local novo="$1" estava_ligado=nao
    sudo test -L "$VHOST_LIGADO" && estava_ligado=sim
    if sudo test -f "$VHOST"; then
        sudo cp "$VHOST" "$VHOST.anterior"
    fi
    sudo install -m 644 "$novo" "$VHOST"
    rm -f "$novo"
    sudo ln -sf "$VHOST" "$VHOST_LIGADO"

    if ! sudo nginx -t; then
        if sudo test -f "$VHOST.anterior"; then
            sudo cp "$VHOST.anterior" "$VHOST"
        else
            sudo rm -f "$VHOST"
            estava_ligado=nao
        fi
        [ "$estava_ligado" = "sim" ] || sudo rm -f "$VHOST_LIGADO"
        sudo nginx -t || true
        falhar "o nginx reprovou o vhost novo; o anterior foi devolvido (ligado só se já estava) e nada foi recarregado."
    fi
    sudo systemctl reload nginx
    ok "nginx: $VHOST no ar"
}

# Tira o endereço do ar por DOIS caminhos, e CONFERE os dois: o contêiner do
# nginx da pilha sai (removido, e não só parado: um contêiner que não existe não
# volta por política de reinício nenhuma, nem o de uma versão antiga, criado com
# `always`), e o vhost do host é desligado e recarregado. O arquivo fica em
# sites-available, e o `publicar` religa os dois.
#
# Devolve 1 se um dos caminhos falhou -- e quem vai mexer (base, atualizar,
# voltar) PARA ali: com o `nginx -t` do host reprovado por outro site, o vhost
# seguia carregado na memória dele, e a carga rodava protegida por um caminho só.
# A METADE DA PILHA do freio: o nginx do projeto do teste, e mais nada. Só
# precisa do projeto -- nem do domínio, nem do sudo --, e por isso o `tirar` a
# roda antes de qualquer guarda (sexta revisão). O motivo de falhar fica em
# PROBLEMA_DA_PILHA.
#
# O PROJETO EXPLÍCITO (-p): sem .env, o compose adotaria o nome da pasta como
# projeto -- e poderia ser o de outra pilha. SEM ELE, SÓ A METADE DO DOCKER FICA
# POR FAZER: o vhost do host depende só do domínio, e o freio parava inteiro no
# .env sem COMPOSE_PROJECT_NAME (quarta revisão).
PROBLEMA_DA_PILHA=""
tirar_pilha() {
    PROBLEMA_DA_PILHA=""
    case "${PROJETO:-}" in
        ''|franquiabv)
            PROBLEMA_DA_PILHA="sem o projeto do teste (COMPOSE_PROJECT_NAME do .env), não sei qual nginx da pilha tirar -- rode: docker compose -p <projeto do teste> rm -s -f nginx" ;;
        *)
            docker compose -p "$PROJETO" rm -s -f nginx >/dev/null 2>&1 || PROBLEMA_DA_PILHA="não consegui remover o nginx da pilha"
            [ -z "$(docker compose -p "$PROJETO" ps -a -q nginx 2>/dev/null)" ] || PROBLEMA_DA_PILHA="${PROBLEMA_DA_PILHA:+$PROBLEMA_DA_PILHA; }o nginx da pilha continua existindo" ;;
    esac
    [ -z "$PROBLEMA_DA_PILHA" ]
}

# O freio da saída de um `tirar` que parou antes do vhost: só a pilha.
tirar_so_a_pilha() {
    tirar_pilha && return 0
    printf '    !!  %s.\n' "$PROBLEMA_DA_PILHA" >&2 || true
    return 1
}

tirar_do_ar() {
    local problema="" dominio_re host_medido=sim
    tirar_pilha || true
    problema="$PROBLEMA_DA_PILHA"

    # SEM SUDO NÃO É "O NGINX REPROVOU": na queda do ssh, o sudo perde o
    # terminal e pede senha, e a mensagem de antes culpava o nginx -t.
    if ! sudo -n true 2>/dev/null; then
        problema="${problema:+$problema; }o sudo pede senha (sem terminal -- a queda do ssh?): o vhost do host NÃO foi desligado"
    elif sudo test -e "$VHOST_LIGADO" && ! sudo grep -q 'scripts/deploy-teste.sh' "$VHOST_LIGADO"; then
        # SÓ O VHOST DESTE AMBIENTE, que diz de onde veio (o modelo e o
        # provisório do certificado): com o DOMINIO de outro site -- engano, ou
        # exportado no shell --, o freio desligava o vhost DELE (quarta revisão).
        problema="${problema:+$problema; }$VHOST_LIGADO não foi gerado por scripts/deploy-teste.sh: não é deste ambiente, e não o desligo (o DOMINIO desta rodada é outro?)"
    else
        local estava_ligado=nao
        ! sudo test -e "$VHOST_LIGADO" || estava_ligado=sim
        sudo rm -f "$VHOST_LIGADO" || problema="${problema:+$problema; }não consegui desligar $VHOST_LIGADO"
        if ! sudo nginx -t; then
            # "SEGUE CARREGADO" SÓ QUANDO HAVIA O QUE TIRAR: com o link já fora do
            # disco (um tirar anterior), a frase afirmava de um site fora que ele
            # estava no ar (quinta revisão). Aí é "não consegui medir".
            if [ "$estava_ligado" = sim ]; then
                problema="${problema:+$problema; }o nginx do host reprovou o próprio teste (outro site com a config quebrada?) e não foi recarregado: o link saiu do disco, mas o vhost SEGUE CARREGADO na memória dele"
            elif [ -z "$problema" ]; then
                # O LINK JÁ FORA E A PILHA SEM NGINX: nada chega à aplicação,
                # carregue o nginx do host o que carregar. Contar isso como freio
                # que falhou gravava o talvez-no-ar de um site fora (sexta
                # revisão). Fica o aviso.
                printf '    !!  o nginx do host reprovou o próprio teste (outro site com a config quebrada?), e não recarreguei; o link deste domínio já não estava ligado e a pilha está sem nginx: nada chega à aplicação.\n' >&2 || true
                host_medido=nao
            else
                problema="${problema:+$problema; }o nginx do host reprovou o próprio teste (outro site com a config quebrada?): o link deste domínio já não estava ligado, mas sem recarregar não consigo medir se o que ele carrega ainda atende $DOMINIO"
            fi
        elif ! sudo systemctl reload nginx; then
            problema="${problema:+$problema; }o nginx do host não recarregou"
        else
            # O EFEITO, E NÃO O NOME DO ARQUIVO: o que o nginx carrega não pode
            # ter o domínio. Um vhost ligado como arquivo comum, ou com outro
            # nome, seguia atendendo com o 'ok' na tela (terceira revisão).
            dominio_re="$(printf '%s' "$DOMINIO" | sed 's/[.]/[.]/g')"
            if ( set +o pipefail; sudo nginx -T 2>/dev/null | grep -qE "server_name[^;]*[[:space:]]${dominio_re}([[:space:];]|\$)" ); then
                problema="${problema:+$problema; }o nginx do host ainda atende $DOMINIO por outro arquivo: $(sudo grep -RlE "server_name[^;]*[[:space:]]${dominio_re}" /etc/nginx/sites-enabled /etc/nginx/conf.d 2>/dev/null | tr '\n' ' ')"
            fi
        fi
    fi
    if [ -n "$problema" ]; then
        printf '    !!  %s.\n' "$problema" >&2
        return 1
    fi
    # O RESULTADO É O DO FREIO, e não o da escrita: com o terminal morto (a
    # queda do ssh, e o sudo sem senha), o `ok` falhava, a função devolvia 1, e
    # a saída gravava "talvez no ar" para um site que tinha saído (quarta
    # revisão).
    if [ "$host_medido" = nao ]; then
        ok "fora do ar pela pilha: sem o nginx da pilha, e o link deste domínio fora do nginx do host (o que ele carrega não foi medido)" || true
    else
        ok "fora do ar: sem o nginx da pilha, e o nginx do host sem nada de $DOMINIO" || true
    fi
    return 0
}

# O vhost a partir do modelo. Idempotente: roda em todo deploy.
instalar_vhost() {
    local versao http2_listen="" http2_diretiva="" auth tmp outros

    # grep -R, e não -r: o sites-enabled é feito de links simbólicos, e o -r
    # não os segue -- a conferência passaria vazia.
    outros="$(sudo grep -RlE "server_name[^;]*[[:space:]]${DOMINIO}[[:space:];]" /etc/nginx/sites-enabled /etc/nginx/conf.d 2>/dev/null \
        | grep -v "^$VHOST_LIGADO\$" || true)"
    [ -z "$outros" ] || falhar "outro vhost já atende $DOMINIO: $outros"

    # http2 on; existe a partir do nginx 1.25.1; antes, vai no listen.
    versao="$(sudo nginx -v 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n 1)" || falhar "não achei o nginx do host."
    if printf '1.25.1\n%s\n' "$versao" | sort -V -C; then
        http2_diretiva="    http2 on;"
    else
        http2_listen=" http2"
    fi

    # A ESCOLHA, DE NOVO, AQUI: é o ponto por onde o publicar, o atualizar e o
    # voltar passam para ligar o vhost (o D451).
    exigir_a_escolha_da_senha
    if [ "$SENHA_HTTP" = "sim" ]; then
        sudo test -f "$HTPASSWD" || falhar "SENHA_HTTP=sim sem $HTPASSWD. Crie com: sudo htpasswd -c $HTPASSWD <usuario>"
        auth="    auth_basic \"Teste Barbearia VIP\";\n    auth_basic_user_file $HTPASSWD;"
    else
        auth="    # Sem senha HTTP: SENHA_HTTP=nao escolhido no .env, contra a escolha do\n    # dono de 02/10/2026 (D326). Protegem o login do ERP com a senha\n    # aleatoria do 'base', o noindex e as rotas negadas abaixo."
    fi

    tmp="$(mktemp)"
    sed -e "s#__DOMINIO__#$DOMINIO#g" \
        -e "s#__PORTA_APP__#$PORTA_APP#g" \
        -e "s#__FRONT_RAIZ__#$FRONT_RAIZ#g" \
        -e "s#__HTTP2_NO_LISTEN__#$http2_listen#g" \
        -e "s#^__HTTP2_DIRETIVA__\$#$http2_diretiva#" \
        -e "s|^__AUTH__\$|$auth|" \
        docker/nginx/teste-host.conf.modelo > "$tmp"
    ! grep -q '__[A-Z0-9_]*__' "$tmp" || falhar "sobrou marcador no vhost gerado: $(grep -o '__[A-Z0-9_]*__' "$tmp" | sort -u | tr '\n' ' ')"

    ativar_vhost "$tmp"
}

obter_certificado() {
    local tmp renovar=""
    if sudo test -f "/etc/letsencrypt/live/$DOMINIO/fullchain.pem"; then
        # O CERTIFICADO QUE VENCE TAMBÉM: com o site tirado, o vhost desligado
        # não serve o desafio na porta 80, a renovação automática falha, e em
        # uns 60 dias o publicar reprovava para sempre, culpando a regra do
        # nginx (quarta revisão). Faltando uma semana, renova pelo mesmo
        # caminho da primeira vez.
        sudo openssl x509 -checkend 604800 -noout -in "/etc/letsencrypt/live/$DOMINIO/fullchain.pem" >/dev/null 2>&1 && return 0
        renovar="--keep-until-expiring"
    fi
    sudo certbot --version >/dev/null 2>&1 || falhar "falta o certbot neste servidor (apt install certbot)."

    passo "certificado TLS para $DOMINIO (Let's Encrypt, pela porta 80)${renovar:+ -- o atual vence em menos de uma semana}"
    getent hosts "$DOMINIO" >/dev/null || falhar "$DOMINIO não resolve. Crie o registro A apontando para este servidor e rode de novo."

    # Enquanto não há certificado, a porta 80 só responde ao desafio do
    # Let's Encrypt; o resto é 503. A aplicação nunca passa por HTTP aberto.
    tmp="$(mktemp)"
    cat > "$tmp" <<VHOST
# provisório (scripts/deploy-teste.sh): só o desafio do certificado
server {
    listen 80;
    listen [::]:80;
    server_name $DOMINIO;
    location ^~ /.well-known/acme-challenge/ { root $ACME_RAIZ; default_type text/plain; }
    location / { add_header X-Robots-Tag "noindex, nofollow, noarchive" always; return 503; }
}
VHOST
    sudo mkdir -p "$ACME_RAIZ"
    ativar_vhost "$tmp"

    # shellcheck disable=SC2086
    sudo certbot certonly --webroot -w "$ACME_RAIZ" -d "$DOMINIO" $renovar \
        ${EMAIL_CERTBOT:+--email "$EMAIL_CERTBOT"} \
        --deploy-hook "systemctl reload nginx" \
        || falhar "o certbot não emitiu o certificado (DNS apontando para cá? porta 80 aberta?)."
}

# Tudo o que tem de valer ANTES de o endereço chegar à aplicação.
conferir_antes_de_publicar() {
    [ -n "$(dc ps --status running -q app 2>/dev/null)" ] || falhar "o contêiner app não está no ar; rode 'preparar'."
    conferir_config
    conferir_hosts_mortos
    conferir_base uso
}

cmd_publicar() {
    local dominio_gravado construiu=nao
    guardas
    ler_projeto
    # O DOMÍNIO, ANTES DE ARMAR O FREIO: armado antes da conferência do
    # domínio, o freio de um publicar com o DOMINIO de outro site desligava o
    # vhost DAQUELE site ao reprovar (quarta revisão).
    dominio_gravado="$(do_env DOMINIO_TESTE)"
    [ -z "$dominio_gravado" ] || [ "$dominio_gravado" = "$DOMINIO" ] \
        || falhar "o .env é do domínio $dominio_gravado, e esta rodada é de $DOMINIO. Rode sem DOMINIO, ou com DOMINIO=$dominio_gravado. Nada foi mexido."
    sudo -v || falhar "o publicar precisa de sudo (nginx, certbot e $FRONT_RAIZ)."

    # ARMADA ANTES DE TUDO QUE PODE REPROVAR: o publicar também roda com o site
    # no ar (para trocar a tela nova ou o vhost), e uma conferência que reprova
    # sobre o estado vivo -- a saída aberta, a base com chave, o .env fora do
    # padrão -- derruba o endereço, em vez de deixá-lo no ar com o ABORTADO na
    # tela. Antes ela vinha depois do conferir_env (terceira revisão).
    AO_FALHAR=tirar_do_ar
    conferir_env
    exigir_a_escolha_da_senha
    conferir_compose

    # A TELA NOVA SE CONSTRÓI COM O SITE NO AR: a build não toca o que é
    # servido. A troca vem depois das conferências.
    if front_precisa_de_build; then
        construir_front
        construiu=sim
    fi

    # O SITE SAI ANTES DE O APP MUDAR: o `up` recria o app que divergiu do
    # compose em disco e o restart relê o php.ini, e com o endereço servindo o
    # público falava com um app que ninguém tinha conferido até a reprovação
    # (quarta revisão -- o mesmo que a terceira corrigiu no _aplicar).
    passo "tirando o site do ar enquanto o app troca e é conferido"
    tirar_do_ar || falhar "não consegui tirar o site do ar pelos dois caminhos (acima). Nada foi publicado."

    # O APP DA CONFIGURAÇÃO DE AGORA, E COM O php.ini RELIDO: o `up` recria o
    # que divergiu do compose em disco, e o restart relê o arquivo montado (o
    # git troca o inode). O que as conferências medem é o que vai ao ar.
    passo "o app da configuração em disco"
    dc up -d --wait mysql app
    dc restart app
    dc up -d --wait app
    conferir_antes_de_publicar

    if [ "$construiu" = sim ]; then
        trocar_front
    else
        ok "tela nova: a instalada já é deste commit"
    fi
    obter_certificado
    por_no_ar desfaz-o-tirar

    printf '\nPublicado: https://%s/  (ERP)   https://%s/novo/  (tela nova)\n' "$DOMINIO" "$DOMINIO"
}

# O ÚLTIMO PASSO de quem põe o endereço no ar -- o publicar e o _aplicar --,
# depois de tudo conferido: o nginx da pilha, o vhost e as sondas de fora. Se
# as sondas reprovarem, ou o passo não chegar ao fim, o site sai de novo.
#
# E O QUE SÓ ELE CONFERE ANTES: o `tirar` de propósito, o selo da base (a base
# é a deste código?), o cron (a camada 4) e o app (é o da configuração que as
# conferências mediram?).
por_no_ar() {
    AO_FALHAR=tirar_do_ar
    # O PUBLICAR RELIGA DE PROPÓSITO (desfaz-o-tirar), e é o único -- mas a
    # marca só sai no fim, com o site conferido: apagada antes, um publicar que
    # reprovava levava junto a intenção de ficar fora (quarta revisão).
    [ "${1:-}" = "desfaz-o-tirar" ] || [ ! -f "$TIRADO" ] \
        || falhar "o site foi tirado do ar de propósito ($(cat "$TIRADO")): só o 'publicar' o religa."
    [ ! -f "$BASE_NAO_APAGADA" ] \
        || falhar "a última carga não chegou ao fim, e a base não pôde ser apagada ($(cat "$BASE_NAO_APAGADA")): ela pode ter a senha publicada. Rode o base de novo, com a cópia exportada."
    recusar_se_base_de_outro_codigo
    conferir_cron
    conferir_app_da_config
    passo "pondo o site no ar"
    # --no-deps: o nginx depende do app, e sem ele o compose recriava o app que
    # divergiu da configuração -- depois de tudo conferido (terceira revisão).
    dc up -d --wait --no-deps nginx
    instalar_vhost
    esperar_o_vhost_no_ar || true
    conferir_publico
    # O SITE ESTÁ NO AR, E CONFERIDO: um freio que tinha falhado antes não vale mais.
    rm -f "$TALVEZ_NO_AR"
    [ "${1:-}" != "desfaz-o-tirar" ] || rm -f "$TIRADO"
    AO_FALHAR=""
}

# A MUDANÇA VERSIONADA DESTE CLONE, sem o public/app.json (20/10/2026, o Dnovo-9
# do desenho da Versão do aplicativo; o D549, consertado em 08/11/2026). A tela
# antiga e a nova da versão do aplicativo GRAVAM esse arquivo, que é versionado,
# e publicar uma versão no servidor de teste travava o deploy seguinte em "há
# mudança versionada". O resto segue recusado: o servidor não é lugar de editar.
mudancas_versionadas() {
    git status --porcelain --untracked-files=no -- . ':(exclude)public/app.json'
}

# O app.json MUDADO AQUI fica enquanto nenhum commit o toca: o `merge --ff-only`
# e o `checkout` levam a mudança local de um arquivo que o alvo não muda. Se o
# alvo o muda, os dois param -- e o atualizar e o voltar já teriam tirado o site.
# Por isso a recusa vem ANTES do freio, e diz o que fazer com o arquivo.
recusar_se_o_alvo_toca_o_app_json() {
    local alvo="$1"
    git diff --quiet HEAD -- public/app.json && return 0
    git diff --quiet HEAD "$alvo" -- public/app.json && return 0
    falhar "o public/app.json deste clone foi mudado (pela tela da versão do aplicativo?), e o código de $(git rev-parse --short "$alvo") também o muda: a troca pararia nele, com o site já fora. Nada foi mexido. Guarde a versão de agora se quiser (cp public/app.json ~/app.json-do-teste), devolva o arquivo (git checkout -- public/app.json) e rode de novo."
}

# O ESQUEMA ENTRE DOIS COMMITS pede decisão antes de trocar o código: não há
# rodador de migração, e o mysql.sql só carrega num volume novo
# (ACEITAR_ESQUEMA=sim depois de aplicar o ALTER à mão).
#
# O "DE" É O ÚLTIMO CÓDIGO APLICADO (base_do_esquema), e não o HEAD: um `git
# pull` à mão move o HEAD, e o atualizar comparava o commit novo com ele mesmo
# -- a migração que o pull trouxe ia ao ar sem o ALTER (sexta revisão).
mudancas_que_pedem_decisao() {
    local de="$1" para="$2" esquema
    esquema="$(git diff --name-only "$de" "$para" | grep -E "$ESQUEMA_RE" || true)"
    if [ -n "$esquema" ] && [ "${ACEITAR_ESQUEMA:-nao}" != "sim" ]; then
        falhar "o esquema muda entre $(git rev-parse --short "$de") e $(git rev-parse --short "$para"):
$esquema
Aplique o ALTER à mão no banco de teste, ou recarregue a base com 'base', e rode de novo com ACEITAR_ESQUEMA=sim."
    fi
}

# O ÚLTIMO COMMIT QUE O DEPLOY APLICOU: a segunda coluna da última linha do
# histórico (o aplicar a grava). Vazio antes do primeiro atualizar.
ultimo_aplicado() {
    [ -f "$HISTORICO" ] || return 0
    tail -n 1 "$HISTORICO" | awk '{print $2}'
}

ultimo_aplicado_dito() {
    local ultimo
    ultimo="$(ultimo_aplicado)"
    [ -z "$ultimo" ] || printf ', e o último aplicado foi %s' "$(git rev-parse --short "$ultimo" 2>/dev/null || printf '%s' "$ultimo")"
}

# O código cujo esquema o banco tem: o último aplicado, ou o HEAD sem histórico.
base_do_esquema() {
    local ultimo
    ultimo="$(ultimo_aplicado)"
    if [ -n "$ultimo" ] && git rev-parse --verify --quiet "$ultimo^{commit}" >/dev/null; then
        git rev-parse "$ultimo^{commit}"
    else
        git rev-parse HEAD
    fi
}

# A ÁRVORE MUDOU POR FORA DO DEPLOY? Com histórico, é o HEAD que não é o último
# aplicado. Sem ele (antes do primeiro atualizar), a regra da v7: a base tem
# selo, e ele já não é o da árvore.
arvore_mudou_por_fora() {
    local ultimo registrado
    ultimo="$(ultimo_aplicado)"
    if [ -n "$ultimo" ] && git rev-parse --verify --quiet "$ultimo^{commit}" >/dev/null; then
        [ "$(git rev-parse HEAD)" != "$(git rev-parse "$ultimo^{commit}")" ]
        return
    fi
    registrado="$(selo_registrado)" || exit 1
    [ -n "$registrado" ] && [ "$registrado" != "$(selo_da_base)" ]
}

# A BASE E O CÓDIGO NOVO, antes de mexer: com o selo do alvo diferente do da
# base carregada, o código novo deixa o site fora do ar até um `base` com a
# cópia dele -- e isso é escolha de quem roda (ACEITAR_BASE=sim), feita antes da
# troca. A GARANTIA NÃO É ESTA PERGUNTA: é o por_no_ar da árvore nova, que mede
# de novo, com os arquivos dela.
decidir_a_base() {
    local alvo="$1" curto registrado medida=0 estado
    registrado="$(selo_registrado)" || exit 1
    [ "$registrado" != "$(selo_da_base "$alvo")" ] || return 0
    [ "${ACEITAR_BASE:-nao}" != "sim" ] || return 0
    # A ÁRVORE MUDOU POR FORA DO DEPLOY (um `git pull` à mão), e o site pode
    # estar NO AR com a base de outro código. Recusar prometendo "o site fica
    # fora" era dizer o contrário do que estava na tela (quinta revisão).
    #
    # POR FORA É O HEAD QUE NÃO É O ÚLTIMO APLICADO, e não o selo: depois do
    # `ACEITAR_BASE=sim atualizar` da doc, o selo da base também difere do da
    # árvore, e a acusação de "git pull à mão" com "o site pode estar no ar" era
    # falsa -- o site estava fora; e com a base sem selo (a de um servidor que
    # rodou uma versão anterior), o pull à mão passava despercebido (sexta
    # revisão). E o site é MEDIDO, para a frase dizer o que é.
    if arvore_mudou_por_fora; then
        fora_do_ar_medido || medida=$?
        case "$medida" in
            0) estado="o site está fora do ar (medido)" ;;
            *) estado="e o site pode estar NO AR com a base de outro código" ;;
        esac
        falhar "a árvore mudou por fora do deploy (está em $(git rev-parse --short HEAD)$(ultimo_aplicado_dito)) -- um git pull à mão? --, $estado. Rode agora: bash scripts/deploy-teste.sh tirar -- depois, com ACEITAR_BASE=sim, o atualizar; a cópia exportada do código novo; o base; e o publicar."
    fi
    # A BASE SEM SELO NO AR: nenhum publicar desta versão a religa, então o
    # site no ar com ela veio por fora -- uma versão anterior do script, ou um
    # `up` à mão (sexta revisão).
    if [ -z "$registrado" ]; then
        fora_do_ar_medido || medida=$?
        [ "$medida" = 0 ] \
            || falhar "a base carregada não tem selo, e o site NÃO está provadamente fora do ar: nenhum publicar desta versão o religa assim, então ele foi posto no ar por fora (uma versão anterior deste script? um docker compose up à mão?). Rode agora: bash scripts/deploy-teste.sh tirar -- depois, a cópia exportada do código novo, o base e o publicar."
    fi
    curto="$(git rev-parse --short "$alvo")"
    falhar "a base carregada não é a do código de $curto -- o anonimizador, o exportador, o endurecimento, a régua ou a carga mudaram, ou a base não tem selo. Com o código novo, o site fica fora do ar até um 'base' com a cópia que ele exporta. Se é isso:
    ACEITAR_BASE=sim bash scripts/deploy-teste.sh ...        (o site fica fora do ar)
    bash scripts/exportar-base-teste.sh                       (na sua máquina, com o código de $curto)
    bash scripts/deploy-teste.sh base ~/base-teste.sql.gz
    $(comando_de_publicar)"
}

# O commit alvo precisa saber continuar o deploy (o subcomando _aplicar) -- E
# TER AS GARANTIAS DESTE: é o script dele que decide se o site volta. O da v4
# (6bcfe44 a dddf8d1) não mede o selo nem conhece o `tirar` de propósito, e o
# `ACEITAR_BASE=sim voltar` para lá religava o site com a base de outro código,
# até um site tirado -- com a mensagem prometendo "fica fora do ar" (quarta
# revisão). Um `main` que reverta a v5 cai aqui também.
sabe_continuar() {
    local script curto
    curto="$(git rev-parse --short "$1")"
    script="$(git show "$1:scripts/deploy-teste.sh" 2>/dev/null)" || true
    # HERE-STRING, e não `printf | grep -q`: com o pipefail, o grep que acha
    # fecha o cano, o printf de 70 KB morre com SIGPIPE, e o cano volta falso
    # justamente quando achou.
    grep -q '^    _aplicar)' <<<"$script" \
        || falhar "o scripts/deploy-teste.sh de $curto é anterior ao _aplicar: não sei continuar o deploy nele (docs/ambiente-teste.md)."
    grep -q '^VERSAO_DA_CARGA=' <<<"$script" \
        && grep -q '^recusar_se_base_de_outro_codigo()' <<<"$script" \
        && grep -q '^TIRADO=' <<<"$script" \
        || falhar "o scripts/deploy-teste.sh de $curto não mede o selo da base nem respeita o 'tirar' (é anterior a b908498): o script de lá religaria o site sem essas garantias. Para desfazer uma mudança do aplicativo, reverta-a num commit novo no $RAMO e rode o 'atualizar'."
    # E A VERSÃO DAS GARANTIAS (ver VERSAO_DO_DEPLOY): o script da v5 tem as três
    # marcas acima, e o `sabe_continuar` dele só olha o `_aplicar` -- de lá, um
    # segundo voltar chegava à v4 (quinta revisão).
    local versao
    versao="$(sed -n 's/^VERSAO_DO_DEPLOY=\([0-9][0-9]*\)$/\1/p' <<<"$script" | head -n 1)"
    [ -n "$versao" ] && [ "$versao" -ge "$VERSAO_MINIMA_DO_ALVO" ] \
        || falhar "o scripts/deploy-teste.sh de $curto é de uma versão do deploy anterior à $VERSAO_MINIMA_DO_ALVO: o script de lá não recusa, por sua vez, um alvo sem o selo -- e um voltar seguinte religaria o site sem as garantias. Para desfazer uma mudança do aplicativo, reverta-a num commit novo no $RAMO e rode o 'atualizar'."
}

# O RESTO DO DEPLOY RODA COM O SCRIPT DA ÁRVORE NOVA. A cópia em execução é a
# do commit anterior: conferir o commit N com as sondas e os hosts do N-1
# deixava passar o que o N acrescentou. A trava (descritor 9) vai junto.
continuar_na_arvore_nova() {
    rm -f "${DEPLOY_TESTE_COPIA:-}"
    unset DEPLOY_TESTE_RAIZ DEPLOY_TESTE_COPIA
    export FORCAR_FRONT="${FORCAR_FRONT:-nao}"
    exec bash "$RAIZ/scripts/deploy-teste.sh" _aplicar "$1"
}

# O código novo, com o site FORA DO AR desde antes da troca (o atualizar e o
# voltar o tiraram): tudo é conferido, e só no fim ele volta.
aplicar() {
    local depois="$1" curto

    # O SITE SAI ANTES DE TUDO -- de novo, se o atualizar ou o voltar já o
    # tiraram: é idempotente. O `_aplicar` chamado à mão recriava o app três
    # vezes com o endereço servindo, e uma conferência reprovada o deixava no ar
    # (terceira revisão).
    AO_FALHAR=tirar_do_ar
    tirar_do_ar || falhar "não consegui tirar o site do ar pelos dois caminhos (acima). Nada foi aplicado."

    dc up -d --wait mysql app
    conferir_base uso

    subir_e_ajustar
    # Arquivo único montado (o php.ini do teste) só é relido com o contêiner
    # reiniciado: o git troca o inode. O nginx está parado, e o `up` que o
    # religa no fim monta o default.conf de novo.
    dc restart app
    dc up -d --wait app

    conferir_config
    conferir_hosts_mortos

    if front_precisa_de_build; then
        instalar_front
    else
        ok "tela nova: frontend/ não mudou desde a build instalada"
    fi

    printf '%s %s %s\n' "$(date -Iseconds)" "$depois" "$(git log -1 --format=%s "$depois" | cut -c1-80)" >> "$HISTORICO"
    curto="$(git rev-parse --short "$depois")"

    # OS TRÊS CASOS EM QUE O CÓDIGO TROCA E O SITE FICA FORA -- e é o estado
    # certo, e não um erro:
    if [ -f "$TIRADO" ]; then
        printf '\nCódigo em %s. O site segue FORA DO AR: foi tirado de propósito (%s).\nPara religar, depois de conferir: %s\n' "$curto" "$(cat "$TIRADO")" "$(comando_de_publicar)"
        return 0
    fi
    # Sem certificado, quem sobe o nginx é o publicar: subi-lo aqui abria o
    # 127.0.0.1:<porta> sem o cron conferido (terceira revisão).
    if ! sudo test -f "/etc/letsencrypt/live/$DOMINIO/fullchain.pem"; then
        printf '\nCódigo em %s. Sem certificado ainda: o site entra no ar pelo publicar (%s).\n' "$curto" "$(comando_de_publicar)"
        return 0
    fi
    local registrado
    registrado="$(selo_registrado)" || exit 1
    if [ "$registrado" != "$(selo_da_base)" ]; then
        printf '\nCódigo em %s, e o site FORA DO AR: a base carregada não é a deste código (ou não tem selo).\nExporte a cópia com o código de %s (scripts/exportar-base-teste.sh, na sua máquina), rode o base, e: %s\n' "$curto" "$curto" "$(comando_de_publicar)"
        return 0
    fi

    por_no_ar
    printf '\nNo ar: %s  (%s)\n' "$curto" "$(git log -1 --format=%s "$depois" | cut -c1-80)"
}

cmd_atualizar() {
    local antes depois
    guardas
    conferir_env
    exigir_a_escolha_da_senha
    conferir_compose
    sudo -v || falhar "o atualizar precisa de sudo (nginx e $FRONT_RAIZ)."

    [ -z "$(mudancas_versionadas)" ] \
        || falhar "há mudança versionada neste clone; o servidor não é lugar de editar: $(mudancas_versionadas | head -n 5)"

    passo "buscando origin/$RAMO"
    git fetch origin "$RAMO"
    antes="$(git rev-parse HEAD)"
    depois="$(git rev-parse "origin/$RAMO")"

    mudancas_que_pedem_decisao "$(base_do_esquema)" "$depois"
    sabe_continuar "$depois"
    decidir_a_base "$depois"
    # ANTES DE TIRAR O SITE: o merge pararia no app.json mudado pela tela, com o
    # site já fora (o D549).
    recusar_se_o_alvo_toca_o_app_json "$depois"

    # FORA DO AR ANTES DA TROCA: o código responde pelo volume no instante do
    # merge, e a base, a configuração e as sondas só são conferidas depois.
    passo "tirando o site do ar durante a troca"
    tirar_do_ar || falhar "não consegui tirar o site do ar pelos dois caminhos (acima). Nada foi trocado."

    if [ "$(git rev-parse --abbrev-ref HEAD)" != "$RAMO" ]; then
        git checkout "$RAMO"
    fi
    git merge --ff-only "origin/$RAMO"
    # NADA A MARCAR DEPOIS DO MERGE: a árvore nova mede o selo sozinha. Um sinal
    # entre o merge e a linha seguinte deixava o código novo sem a pendência.
    continuar_na_arvore_nova "$depois"
}

cmd_voltar() {
    local alvo="${1:-}" antes f
    guardas
    conferir_env
    exigir_a_escolha_da_senha
    conferir_compose
    sudo -v || falhar "o voltar precisa de sudo (nginx e $FRONT_RAIZ)."

    [ -n "$alvo" ] || falhar "informe o commit: bash scripts/deploy-teste.sh voltar <commit> (os anteriores estão em $HISTORICO)"
    git rev-parse --verify --quiet "$alvo^{commit}" >/dev/null || falhar "commit $alvo não existe neste clone (git fetch?)."
    [ -z "$(mudancas_versionadas)" ] || falhar "há mudança versionada neste clone: $(mudancas_versionadas | head -n 5)"

    # UM COMMIT SEM AS FERRAMENTAS desmontaria a pilha: o checkout apagaria o
    # compose de teste e este script, e o passo seguinte abortaria com o código
    # velho no ar e nada para voltar.
    for f in $FERRAMENTAS; do
        git cat-file -e "$alvo:$f" 2>/dev/null \
            || falhar "o commit $(git rev-parse --short "$alvo") não tem $f: é anterior a este deploy. Escolha um de $HISTORICO."
    done
    sabe_continuar "$alvo"

    # O ESQUEMA E A BASE, como no atualizar: voltar para antes de uma coluna
    # nova põe no ar um código que não a conhece.
    mudancas_que_pedem_decisao "$(base_do_esquema)" "$alvo"
    decidir_a_base "$alvo"
    # E O app.json mudado pela tela: o checkout pararia nele, com o site já fora (o D549).
    recusar_se_o_alvo_toca_o_app_json "$alvo"

    # AS CAMADAS DE PROTEÇÃO VÊM DO COMMIT ALVO: voltar para antes de um aperto
    # nelas aplica a versão mais frouxa.
    # shellcheck disable=SC2086
    if ! git diff --quiet "$alvo" HEAD -- $CAMADAS; then
        passo "ATENÇÃO: as camadas de proteção são diferentes em $(git rev-parse --short "$alvo")"
        # shellcheck disable=SC2086
        git diff --stat "$alvo" HEAD -- $CAMADAS
        confirmar "Voltar mesmo assim, com as camadas do commit antigo?"
    fi

    passo "tirando o site do ar durante a troca"
    tirar_do_ar || falhar "não consegui tirar o site do ar pelos dois caminhos (acima). Nada foi trocado."

    antes="$(git rev-parse HEAD)"
    passo "voltando de $(git rev-parse --short "$antes") para $(git rev-parse --short "$alvo")"
    git checkout --detach "$alvo"
    printf '\nPara voltar ao ramo depois: bash scripts/deploy-teste.sh atualizar\n'
    export FORCAR_FRONT=sim
    continuar_na_arvore_nova "$(git rev-parse HEAD)"
}

# Interno: o atualizar e o voltar continuam aqui, com o script da árvore nova.
cmd__aplicar() {
    local depois="${1:-}"
    [ -n "$depois" ] && [ "$(git rev-parse HEAD)" = "$(git rev-parse --verify --quiet "$depois^{commit}")" ] \
        || falhar "o _aplicar é interno: o atualizar e o voltar o chamam com o commit em que o clone está."
    guardas
    conferir_env
    exigir_a_escolha_da_senha
    conferir_compose
    sudo -v || falhar "o deploy precisa de sudo (nginx e $FRONT_RAIZ)."
    aplicar "$depois"
}

cmd_conferir() {
    guardas
    # O CONFERIR SÓ LÊ, E NÃO TIRA O SITE: o que acender fica no ar até alguém
    # rodar o `tirar`. Dito em toda reprovação (quarta revisão).
    DICA_AO_FALHAR="O conferir não tira o site do ar. Se ele está no ar e isto acendeu: bash scripts/deploy-teste.sh tirar"
    # UM FREIO QUE FALHOU (o tirar_do_ar de um passo interrompido) pode ter
    # deixado o site no ar: vem antes de tudo -- antes do .env também, que
    # acendendo escondia o aviso (quarta revisão).
    [ ! -f "$TALVEZ_NO_AR" ] \
        || falhar "o último passo não conseguiu tirar o site do ar ($(cat "$TALVEZ_NO_AR")). Rode, de um terminal: bash scripts/deploy-teste.sh tirar"
    [ ! -f "$BASE_NAO_APAGADA" ] \
        || falhar "a última carga não chegou ao fim, e a base não pôde ser apagada ($(cat "$BASE_NAO_APAGADA")): ela pode ter a senha publicada. Rode o base de novo, com a cópia exportada."
    conferir_env so-ler
    conferir_compose
    passo "conferindo $DOMINIO ($PROJETO)"
    # O CRON E O ENDEREÇO PRIMEIRO: são as duas camadas que protegem de fora, e
    # nada que acenda depois -- o app divergente, a base -- pode esconder as duas
    # (quarta revisão: o app divergente abortava antes delas).
    sudo -v || falhar "o conferir precisa de sudo para ler o cron do root e o certificado."
    conferir_cron
    # O ENDEREÇO, MEDIDO -- e não o link em disco. Pelo link, um vhost ainda
    # carregado (o nginx -t reprovado por outro site) ou ligado como arquivo
    # comum passava por "fora do ar", e o conferir dizia "Tudo conferido" com o
    # site no ar; e o vhost ligado sem a pilha abortava em 502 antes da base
    # (terceira revisão).
    local publico=sem-certificado medida=0 ids rodando
    if ! sudo test -f "/etc/letsencrypt/live/$DOMINIO/fullchain.pem"; then
        ok "público: ainda sem certificado (rode 'publicar')"
    else
        case "$(codigo /)" in
            # O 000 NÃO PROVA "FORA": ele junta "o nginx não atende o domínio"
            # com "o curl recusou" -- certificado vencido, tempo esgotado --, e o
            # conferir pulava as sondas com o site no ar (quarta revisão). Fora
            # é o que o tirar_do_ar mede: nenhum nginx da pilha, e nada do
            # domínio no nginx do host. E o que não se lê é "não medi", e não
            # "no ar" (quinta revisão).
            000*)
                fora_do_ar_medido || medida=$?
                case "$medida" in
                    0) ok "público: fora do ar -- sem o nginx da pilha, e o nginx do host sem nada de $DOMINIO"
                       publico=fora ;;
                    1) falhar "o endereço não respondeu (o erro do curl está acima: certificado vencido? tempo esgotado?), e o site não está provadamente fora: a pilha tem nginx. As sondas não puderam ser medidas." ;;
                    3) falhar "o endereço não respondeu (o erro do curl está acima: certificado vencido? tempo esgotado?), e o site não está provadamente fora: o nginx do host ainda atende $DOMINIO. As sondas não puderam ser medidas." ;;
                    *) falhar "o endereço não respondeu, e não consegui medir se o site está fora: o nginx -T do host ou a lista da pilha falharam (a configuração de outro site do servidor?)." ;;
                esac ;;
            # O 502 SÓ É "MEIO FORA" SEM O NGINX DA PILHA: com ela de pé, é a
            # aplicação que não responde (o php-fpm), e as sondas eram puladas
            # com o site no ar (quinta revisão).
            502)
                ids="$(docker compose -p "$PROJETO" ps -a -q nginx 2>/dev/null)" \
                    || falhar "o / respondeu 502, e não consegui listar o nginx da pilha para saber por quê. As sondas não puderam ser medidas."
                if [ -n "$ids" ]; then
                    # PARADO NÃO É "DE PÉ": o `ps -a` lista o contêiner parado, e a
                    # frase mandava atrás do php-fpm quem tinha o nginx parado --
                    # que um start ou um up religa sem conferência (sexta revisão).
                    rodando="$(docker compose -p "$PROJETO" ps -q nginx 2>/dev/null)" \
                        || falhar "o / respondeu 502, e não consegui listar o nginx da pilha para saber por quê. As sondas não puderam ser medidas."
                    [ -n "$rodando" ] \
                        || falhar "o / respondeu 502, e o nginx da pilha existe PARADO: um start ou um up o religa sem conferência. Rode: bash scripts/deploy-teste.sh tirar"
                    falhar "o / respondeu 502 com o nginx da pilha de pé: é a aplicação que não responde (o php-fpm?), e as sondas não puderam ser medidas."
                fi
                ok "público: MEIO FORA -- o vhost do host está ligado e o nginx da pilha não (502): nada chega à aplicação. O 'tirar' desliga o vhost; o 'publicar' religa os dois"
                publico=meio ;;
            *)  conferir_publico
                publico=no-ar ;;
        esac
    fi
    # O "TIRADO" QUE RESPONDE: a marca é a intenção, e não a prova -- um tirar
    # que falhou pelos dois caminhos deixava só ela, e o conferir, medindo o
    # site no ar, dizia "tirado do ar de propósito" e "Tudo conferido" (quinta
    # revisão).
    if [ -f "$TIRADO" ] && [ "$publico" = no-ar ]; then
        falhar "o site foi tirado do ar de propósito ($(cat "$TIRADO")), e o endereço RESPONDE: o freio não pegou. Rode, de um terminal: bash scripts/deploy-teste.sh tirar"
    fi
    # E O "TIRADO" PELA METADE: o tirar tira pelos dois caminhos, e o vhost do
    # host ligado (502, sem a pilha) quer dizer que ele não chegou ao fim -- e um
    # `up` à mão religava o site inteiro sem conferência (sexta revisão).
    if [ -f "$TIRADO" ] && [ "$publico" = meio ]; then
        falhar "o site foi tirado do ar de propósito ($(cat "$TIRADO")), e o vhost do host segue ligado (502, sem a pilha): o tirar não chegou ao fim pelos dois caminhos. Rode, de um terminal: bash scripts/deploy-teste.sh tirar"
    fi
    [ -n "$(dc ps --status running -q app 2>/dev/null)" ] || falhar "o contêiner app não está no ar."
    conferir_config
    conferir_hosts_mortos
    conferir_app_da_config
    conferir_base uso
    [ "$BASE_VAZIA" = sim ] || recusar_se_base_de_outro_codigo
    [ ! -f "$TIRADO" ] || ok "tirado do ar de propósito ($(cat "$TIRADO")): só o publicar o religa"
    printf '\nTudo conferido.\n'
}

# FORA DO AR, COM PROVA: nenhum contêiner nginx no projeto, e o nginx do host
# sem nenhum server_name do domínio. O que não se consegue ler não é prova.
#
# 0 fora; 1 a pilha tem nginx; 3 o nginx do host atende o domínio; 2 não medi.
fora_do_ar_medido() {
    local ids conf dominio_re
    ids="$(docker compose -p "$PROJETO" ps -a -q nginx 2>/dev/null)" || return 2
    [ -z "$ids" ] || return 1
    conf="$(sudo nginx -T 2>/dev/null)" || return 2
    dominio_re="$(printf '%s' "$DOMINIO" | sed 's/[.]/[.]/g')"
    if grep -qE "server_name[^;]*[[:space:]]${dominio_re}([[:space:];]|\$)" <<<"$conf"; then
        return 3
    fi
    return 0
}

# Só tira do ar -- pelos dois caminhos, conferidos. É o primeiro passo de um
# servidor que ainda roda uma versão anterior deste script (docs/ambiente-teste.md),
# e o que se roda quando algo acende no `conferir`.
#
# O FREIO NÃO DEPENDE DA CONFIGURAÇÃO: ele lê do .env só o projeto e o domínio.
# Até a v4 ele conferia o .env inteiro antes -- e recusava justamente quando o
# .env acendia (a saída sem o corte, a porta fora do loopback), deixando o site
# no ar (terceira revisão). O .env é conferido DEPOIS, e o que acender é dito.
#
# E FICA: o atualizar e o voltar não religam um site tirado de propósito; só o
# publicar, que confere tudo de novo.
cmd_tirar() {
    local dominio_gravado aviso sem_marca=nao
    # ANTES DE TOCAR EM ALGO, só o que não depende do .env: o clone e o nome do
    # servidor. As outras guardas -- o DOMINIO no padrão, a FRONT_RAIZ -- vêm
    # DEPOIS da intenção e da metade da pilha: elas recusavam antes de qualquer
    # freio, e o site ficava no ar (sexta revisão).
    git rev-parse --git-dir >/dev/null 2>&1 && [ -f docker-compose.teste.yml ] || falhar "rode de dentro do clone do repositório."
    case "$(hostname)" in
        *barbeariavip*|*produc*)
            falhar "este servidor ($(hostname)) tem nome de produção. O ambiente de teste mora no BVIP." ;;
    esac
    # O PROJETO, SE HOUVER: sem ele, o tirar_do_ar faz a metade do vhost e diz
    # o comando da outra. O ler_projeto abortava antes de qualquer freio
    # (quarta revisão).
    PROJETO=""
    [ ! -f .env ] || PROJETO="$(do_env COMPOSE_PROJECT_NAME)"
    # A INTENÇÃO, ANTES DO FREIO: um tirar que falhava pela metade (o nginx -t
    # reprovado por outro site) não gravava nada, e o atualizar seguinte
    # religava o site que se mandou tirar (quarta revisão). E SEM PARAR O FREIO
    # se ela não se grava -- disco cheio, só-leitura: com o set -e, a escrita
    # falha matava o script antes de qualquer freio (quinta revisão).
    marcar "$TIRADO" "tirado por $(id -un)" 2>/dev/null || sem_marca=sim
    # A METADE DA PILHA, JÁ, COM O FREIO ARMADO: o tirar não armava a saída, e a
    # queda do ssh (o cano morto, o HUP) no meio dele deixava o site no ar só com
    # a marca (sexta revisão). A metade do vhost vem depois das guardas, porque
    # depende do domínio.
    AO_FALHAR=tirar_so_a_pilha
    tirar_pilha || true
    dominio_gravado="$(do_env DOMINIO_TESTE)"
    # O FREIO TIRA ESTE AMBIENTE, e o domínio dele é o do .env. Com outro
    # DOMINIO dado -- digitado errado, ou exportado no shell --, o tirar
    # recusava ANTES de qualquer freio, e o site ficava no ar (quinta revisão).
    # Agora tira o do .env e diz que o dado foi ignorado. O vhost de outro site
    # nunca é tocado: o freio só desliga o que o script gerou.
    if [ -n "$dominio_gravado" ] && [ "$dominio_gravado" != "$DOMINIO" ]; then
        printf '\n!!  DOMINIO=%s ignorado: o .env é do domínio %s, e é ele que sai do ar.\n' "$DOMINIO" "$dominio_gravado" >&2
        usar_dominio "$dominio_gravado"
    fi
    guardas tirar
    # SEM SUDO, O FREIO AINDA FAZ A METADE QUE PODE (a pilha), e diz a outra:
    # o `sudo -v || falhar` de antes parava antes da intenção e da pilha
    # (quinta revisão).
    sudo -v || printf '\n!!  o sudo não abriu: tiro o nginx da pilha, e o vhost do host fica por fazer (abaixo).\n' >&2
    AO_FALHAR=tirar_do_ar
    passo "tirando $DOMINIO do ar"
    if ! tirar_do_ar; then
        # O FREIO QUE FALHOU FICA ESCRITO, como na saída: só o "tirado" dizia
        # "tirado de propósito" a um conferir que media o site no ar (quinta
        # revisão).
        marcar "$TALVEZ_NO_AR" "o tirar não conseguiu tirar o site do ar" 2>/dev/null || true
        if [ "$sem_marca" = sim ]; then
            falhar "não consegui tirar o site do ar pelos dois caminhos (acima), nem gravar a intenção em $TIRADO."
        fi
        falhar "não consegui tirar o site do ar pelos dois caminhos (acima). A intenção ficou gravada: o atualizar e o voltar não o religam."
    fi
    # O SITE SAIU: daqui em diante, só avisos.
    AO_FALHAR=""
    [ "$sem_marca" = nao ] \
        || falhar "o site saiu do ar, mas a intenção não foi gravada em $TIRADO (disco cheio? só-leitura?): o atualizar e o voltar podem religá-lo."
    rm -f "$TALVEZ_NO_AR" 2>/dev/null || true
    printf '\nFora do ar, e fica: o atualizar e o voltar não o religam. Para religar, depois de conferir tudo: %s\n' "$(comando_de_publicar)"

    if ! aviso="$( (conferir_env so-ler) 2>&1 >/dev/null )"; then
        printf '\n!!  o .env acende -- conserte antes do publicar:\n%s\n' "$(printf '%s\n' "$aviso" | grep -v '^$' | sed 's/^/    /')" >&2
    fi
}

uso() {
    # O cabeçalho deste arquivo, até a primeira linha de traços.
    awk 'NR > 2 { if (/^# ----/) exit; sub(/^# ?/, ""); print }' "$0"
    exit "${1:-0}"
}

case "${1:-}" in
    preparar)  shift; cmd_preparar "$@" ;;
    base)      shift; cmd_base "$@" ;;
    publicar)  shift; cmd_publicar "$@" ;;
    atualizar) shift; cmd_atualizar "$@" ;;
    voltar)    shift; cmd_voltar "$@" ;;
    conferir)  shift; cmd_conferir "$@" ;;
    tirar)     shift; cmd_tirar "$@" ;;
    _aplicar)  shift; cmd__aplicar "$@" ;;
    ''|-h|--help|ajuda) uso 0 ;;
    *) printf 'Subcomando desconhecido: %s\n\n' "$1" >&2; uso 1 ;;
esac
