#!/usr/bin/env bash
# Atualiza o DeskcommCRM na VPS: código novo + banco + app — com BACKUP antes e
# CHECAGEM DE SAÚDE depois. Um comando só, pensado pra quem não é técnico:
#
#   bash hostgator-setup-kit/update.sh
#
# Flags:
#   --force        instala a versão pedida mesmo que ela seja igual ou ANTERIOR
#                  à que já está aqui (é o jeito explícito de voltar no tempo)
#   --skip-backup  pula o backup automático (não recomendado)
#   --to <tag>     instala essa tag em vez da mais recente publicada
# Absoluto e resolvido ANTES do `enter_project`, que faz `cd`: depois dele um
# `dirname "$0"` relativo apontaria para o lugar errado, e o único sintoma seria
# um script do kit "não encontrado" no meio da atualização.
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
source "$KIT_DIR/_common.sh"
# O aviso que assume a porta enquanto o CRM esta parado. Fica em arquivo
# proprio porque so a ATUALIZACAO para o CRM — install.sh e agent.sh sourceiam
# `_common.sh` e nao tem o que anunciar.
# shellcheck source=manutencao.sh
source "$KIT_DIR/manutencao.sh"
enter_project
aviso_rede_do_supabase

# ── 0-. O laço do diagnóstico (#1955) ───────────────────────────────────────
# Toda execução termina com um arquivo legível — inclusive as que MORREM antes
# de chegar ao banco (preflight, backup, checkout), que é justamente onde não
# havia rastro nenhum: o que sobrava era a cauda do log que o agente guarda, e
# o apagão de resolver da issue não deixava rastro do POR QUÊ.
#
# O gatilho do passo do banco (`trap restaurar_servicos EXIT INT TERM HUP`)
# SUBSTITUI este — e de lá para baixo quem escreve o mesmo arquivo é
# `restaurar_servicos`, que chama esta mesma função. Os dois caminhos escrevem
# no MESMO arquivo, e o diagnóstico é silencioso: nunca na stdout.
DIAGNOSTICO_ARQUIVO="$PROJECT_DIR/.deskcomm-update-diagnostico.log"
UPDATE_STATUS="em andamento"
UPDATE_ETAPA="início, antes de decidir a versão"
trap diagnostico_de_atualizacao EXIT

FORCE=""; SKIP_BACKUP=""; TARGET_TAG=""
while [ $# -gt 0 ]; do
  case "$1" in
    --force) FORCE=1 ;;
    --skip-backup) SKIP_BACKUP=1 ;;
    --to) shift; TARGET_TAG="$1" ;;
  esac
  shift
done

# ── 0-. Esta cópia do repo é a dona dos contêineres? ─────────────────────────
# Antes do cron e antes do git: uma segunda cópia que atualiza por cima recria o
# parque com o .env DELA. Foi o que deixou o WhatsApp de uma VPS real três dias
# em 401. Ver `recusar_projeto_de_outra_arvore` em _common.sh.
recusar_projeto_de_outra_arvore || die "Atualização interrompida para não quebrar a instalação que está no ar."

# Single-server: o Supabase desta VPS também tem dono. E o e-mail de acesso
# (GoTrue) acompanha o SMTP do CRM AQUI, antes da decisão de versão: é este
# comando que o instalador ensina a rodar depois de configurar /admin/email, e
# "já está na versão mais recente" sairia sem entregar a troca.
if [ "${SINGLE_SERVER:-0}" = "1" ]; then
  recusar_supabase_de_outra_arvore || die "Atualização interrompida para não mexer no Supabase de outra instalação."
  # A porta direta do GoTrue acompanha o `signup_mode` da instalação (#1653).
  # Antes do SMTP de propósito: é o caminho que roda MESMO quando o update não
  # tem nada a atualizar (a saída "você já está na versão mais recente" fica
  # mais abaixo), então quem trocou "só convite" na tela e rodou o update leva
  # o `DISABLE_SIGNUP` no mesmo comando — e é ele que fecha
  # `POST /auth/v1/signup` para quem tem a anon key. Esta chamada roda com o
  # kit ANTERIOR ao checkout; a da versão nova fica dentro de
  # `atualizar_supabase_single_server` (_common.sh), mais abaixo.
  if sincronizar_signup_mode_do_gotrue; then
    dc_supabase up -d --no-deps auth >/dev/null 2>&1 || c_ylw "⚠ Não consegui reiniciar o auth do Supabase com o modo de cadastro (#1653)."
  fi
  if sincronizar_smtp_do_gotrue; then
    dc_supabase up -d --no-deps auth >/dev/null 2>&1 || c_ylw "⚠ Não consegui reiniciar o auth do Supabase com o SMTP do CRM."
  else
    # #2755 — o aviso diz POR QUE rodar de novo: o app não leva o SMTP de
    # /admin/email ao GoTrue, quem leva é a chamada logo acima. Sem o porquê,
    # "rode de novo" parecia ordem sem motivo. Não citamos o painel de
    # Atualização: o botão dele só aparece quando há versão nova.
    c_ylw "⚠ Sem SMTP no CRM: 'esqueci a senha' e a confirmação de cadastro não enviam e-mail. Configure em /admin/email e rode bash hostgator-setup-kit/update.sh de novo (ele roda mesmo sem versão nova) — é ele que leva o SMTP ao serviço de login."
  fi
fi

# ── 0. Liga o agente da tela ANTES de qualquer decisão de versão ─────────────
# Instalar o cron aqui, e não no fim, é o que faz o bootstrap ter fim: os
# caminhos "já está na versão mais recente" e "essa versão é anterior à sua"
# saem do script mais abaixo, e se o cron dependesse deles a atualização pela
# tela nunca ligaria justamente em quem já está em dia. É idempotente.
setup_update_agent_cron

# ── 0+. O par VAPID também em quem não vai atualizar nada ───────────────────
# A tela de Notificações manda rodar a atualização, sem --force. Quem já está na
# última versão sai pela saída "nada a atualizar" logo abaixo, e quem segue a
# `main` sai pela recusa de versão anterior: sem esta chamada, nos dois o par
# nunca nasceria. Só na lacuna, nunca troca um par existente (D6).
#
# Aqui o par só vai para o .env. Quem sobe o app e o worker de novo para lê-lo é
# `subir_para_ler_o_par`, e SÓ nas duas saídas sem atualização: no caminho de
# atualização de verdade o `up -d` lá embaixo já recria os dois, e as recusas de
# uma atualização real (registro fora, árvore suja) prometem não ter parado
# nada — ali o par liga na atualização que vier depois.
PAR_VAPID_CRIADO=""
PAR_LIGADO_AGORA=""
completar_par_vapid .env
subir_para_ler_o_par() {
  [ -n "$PAR_VAPID_CRIADO" ] || return 0
  PAR_VAPID_CRIADO=""
  # `never`: recriar para ler o .env, nunca trocar de imagem. Com tag de canal
  # (`always`) o compose baixaria o topo do canal antes de recriar — app novo sem
  # o baseline, logo antes de uma recusa que diz que nada mudou.
  if ( export APP_PULL_POLICY=never WORKER_PULL_POLICY=never; dc up -d --no-deps app worker ) >/dev/null 2>&1; then
    PAR_LIGADO_AGORA=1
    c_grn "$(t "✓ app e worker reiniciados para ligar os avisos com o app fechado")"
  else
    c_ylw "$(t "⚠ Criei as chaves dos avisos com o app fechado, mas não consegui reiniciar o app e o worker. Os avisos ligam quando eles reiniciarem, o que acontece sozinho na próxima atualização.")"
  fi
}

# ── 1. Tem atualização mesmo? ────────────────────────────────────────────────
step "Procurando atualizações"
git fetch --tags --quiet origin 2>/dev/null || c_ylw "⚠ não consegui falar com o GitHub — sigo com o código que já está aqui."
# A AUTORIDADE é a release publicada, NUNCA a maior tag — ver
# `ultima_release_estavel` em _common.sh. Um `TARGET_TAG` passado à mão
# continua valendo (instalar uma versão específica é operação legítima de
# quem sabe o que está fazendo); o que deixou de existir é ESCOLHER sozinho a
# maior tag, que instalaria código sem release publicada.
[ -n "$TARGET_TAG" ] || TARGET_TAG="$(ultima_release_estavel)"
[ -n "$TARGET_TAG" ] || die "Não encontrei nenhuma versão publicada para instalar. (Tag existir não basta: o alvo é a última release estável publicada no GitHub. Se o servidor não conseguiu falar com a API, tente de novo mais tarde; para instalar uma versão específica, passe --to vX.Y.Z.)"
git rev-parse --verify --quiet "${TARGET_TAG}^{commit}" >/dev/null \
  || die "Não conheço a versão $TARGET_TAG aqui. Confira o nome (ex.: v1.1.0) ou tente de novo quando o servidor conseguir falar com o GitHub."
CURRENT_TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || true)"

# O código estar em dia NÃO significa que o app está: quem roda é a imagem.
# Uma atualização interrompida depois do checkout (queda de rede, falta de
# memória no meio do docker pull) deixa o repositório novo e a imagem velha — e
# a partir dali TODO update.sh respondia "já está na versão mais recente",
# prendendo o CRM na versão antiga sem nenhuma saída visível para o dono.
# Também cobre imagem republicada sem commit novo (rebuild de segurança).
# (Veio da `main`; a versão por tag cai exatamente na mesma armadilha, porque a
# comparação de tags também fica satisfeita com a imagem velha no lugar.)
image_desatualizada() {
  # O fallback vem de `IMG_APP` (_common.sh, sourceado no topo deste arquivo) e não de
  # um literal: num fork com namespace próprio, o literal apontava para a
  # imagem do UPSTREAM, e um `.env` sem APP_IMAGE comparava o digest local
  # contra um registry que não é o dele.
  local img="${APP_IMAGE:-${IMG_APP}:latest}" local_d remote_d
  local_d="$(docker image inspect "$img" --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}' 2>/dev/null | sed 's/.*@//')"
  [ -z "$local_d" ] && return 0                 # nem baixada ainda → atualizar
  remote_d="$(docker buildx imagetools inspect "$img" 2>/dev/null | awk '/^Digest:/{print $2; exit}')"
  [ -z "$remote_d" ] && return 1                # sem como consultar → não forçar
  [ "$local_d" != "$remote_d" ]
}

MESMA_TAG=""
[ "$CURRENT_TAG" = "$TARGET_TAG" ] && MESMA_TAG=1

if [ -n "$MESMA_TAG" ] && [ -z "$FORCE" ] && ! image_desatualizada; then
  # ⛔ O AVISO TAMBÉM DESCE AQUI — esta saída é anterior ao `manutencao_desce`
  # do fluxo normal (mais abaixo) e ao `restaurar_servicos` do caminho de erro.
  #
  # MEDIDO numa VPS real: uma atualização morreu logo depois de `manutencao_sobe`
  # (tag nova já no disco, imagem antiga ainda rodando). O aviso ficou de pé com
  # o apelido de rede `app`, e o Caddy passou a entregar ELE — 503 em tudo:
  # site, crons e webhook do WAHA. O app estava saudável o tempo todo.
  #
  # A volta por cima não existia: como o `git checkout` da tag JÁ tinha
  # acontecido, toda execução seguinte caía nesta linha, dizia "nada a
  # atualizar" e saía — sem nunca tocar no aviso. O CRM ficou 6h30 fora do ar
  # e nem o botão da tela voltava, porque o agente do host também levava 503.
  #
  # `manutencao_desce` é `docker rm -f ... || true`: idempotente, custa nada
  # quando não há aviso nenhum de pé, que é o caso comum desta saída.
  if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$NOME_DA_MANUTENCAO"; then
    manutencao_desce
    c_ylw "⚠ Havia um aviso de manutenção preso de uma atualização anterior — removido."
    c_ylw "  Enquanto ele estava de pé, o CRM respondia 503 para todo mundo."
    # Aviso preso = a execução anterior morreu no meio (banco e/ou imagem pela
    # metade); "nada a atualizar" sozinho deixaria o app na imagem antiga.
    c_ylw "  A atualização anterior não terminou. Para concluí-la:"
    c_ylw "    bash hostgator-setup-kit/update.sh --to $TARGET_TAG --force"
  fi
  c_grn "✓ Você já está na versão mais recente ($TARGET_TAG). Nada a atualizar."
  subir_para_ler_o_par
  exit 0
fi

# Alvo que JÁ está contido no que roda aqui = andar pra trás, não pra frente.
# Numa instalação que segue a `main`, `git describe --exact-match` é vazio: a
# comparação de tags acima passa batido e, sem esta guarda, o script instalaria
# alegremente uma versão MAIS VELHA que a instalada — desligando o que o dono
# já tem (foi assim que este próprio botão se autodestruiria, voltando pra uma
# imagem que não conhece o agente de atualização). Recusar é o padrão; voltar
# no tempo continua possível, mas só quando alguém pede de propósito.
# Quando o alvo é a MESMA tag já instalada, a guarda não se aplica: não há para
# onde voltar no tempo — só a imagem é que ficou para trás.
if [ -z "$FORCE" ] && [ -z "$MESMA_TAG" ]; then
  is_already_in_head "$TARGET_TAG" && CONTIDA=0 || CONTIDA=$?
  case "$CONTIDA" in
    0) subir_para_ler_o_par
       refuse "A versão $TARGET_TAG é ANTERIOR à que já está instalada neste servidor.
     Instalar ela seria voltar no tempo e desligar coisas que você já tem.
     Não mexi em nada${PAR_LIGADO_AGORA:+ além das chaves dos avisos com o app fechado}: nem no banco, nem na versão do app — está tudo como estava.
     Se você REALMENTE quer voltar para a $TARGET_TAG, rode:
       bash hostgator-setup-kit/update.sh --to $TARGET_TAG --force" ;;
    2) refuse "Não consegui ter CERTEZA de que a versão $TARGET_TAG é mais nova que a instalada
     aqui — a cópia do código neste servidor veio abreviada e eu não consegui completá-la
     (o servidor precisa conseguir falar com o GitHub para isso).
     Prefiro não mexer a arriscar te levar para uma versão anterior sem querer.
     Não mexi em nada. Tente de novo em alguns minutos; se insistir, confira a internet do
     servidor. Para instalar assim mesmo, por sua conta:
       bash hostgator-setup-kit/update.sh --to $TARGET_TAG --force" ;;
  esac
fi

# Aqui já sabemos que há atualização real ou --force. Em ARM64, a release
# inteira (quatro imagens + WAHA efetiva) é conferida no índice OCI antes de
# backup, checkout, banco ou contêiner: recusar aqui não deixa nada pela metade.
# Em AMD64 não há este pré-voo: toda imagem publicada tem amd64, e o preflight
# geral abaixo (#1955) já confere as quatro imagens em toda arquitetura.
# Com DESKCOMM_BUILD_LOCAL o escape da #1955 vale aqui também: quem pediu
# construção local não depende do registro, e recusá-la aqui a anularia
# justamente com o registro fora.
PLATAFORMA_HOST="$(plataforma_oci_do_host "$(uname -m 2>/dev/null || true)" 2>/dev/null || true)"
if [ "$PLATAFORMA_HOST" = linux/arm64 ] && ! build_local_pedido && ! preflight_plataforma_atualizacao "$TARGET_TAG" "$PLATAFORMA_HOST"; then
  refuse "A versão $TARGET_TAG não está completa para $PLATAFORMA_HOST ou a WAHA configurada é incompatível. Não alterei banco, imagens nem código. Confira o registry e repita."
fi
if [ -n "$MESMA_TAG" ] && [ -n "$FORCE" ]; then
  # Com --force na mesma tag ninguém conferiu a imagem: quem chega aqui pediu
  # para refazer (é a saída que a própria atualização ensina quando o banco não
  # termina limpo). Dizer "o app está rodando uma imagem antiga" seria inventar.
  if [ -n "$SKIP_BACKUP" ]; then
    c_ylw "Refazendo a versão $TARGET_TAG, como pedido (--force): banco de novo, e confere o app."
  else
    c_ylw "Refazendo a versão $TARGET_TAG, como pedido (--force): backup e banco de novo, e confere o app."
  fi
elif [ -n "$MESMA_TAG" ]; then
  c_ylw "O código já está na $TARGET_TAG, mas o app está rodando uma imagem antiga. Vou atualizar a imagem."
else
  c_ylw "Vou atualizar para a versão $TARGET_TAG com segurança."
fi

# ── 1.5 Preflight: dá para atualizar sem derrubar o que está no ar? (#1955) ──
# MEDIDO numa instalação real, reproduzido 2x: sem esta pergunta o update parava
# os serviços e só descobria a hora que o registro não respondia — aí o fallback
# de construção local consumiu a memória inteira da VPS (OOM no `next-build`) e
# o app ficou 502 até um restart manual do Docker.
#
# ANTES do backup, do checkout e de QUALQUER `docker stop`, de propósito: quando
# o preflight falha, `refuse` devolve RC 3 (o agent.sh lê como "esta atualização
# nunca começou" e não tenta desfazer nada) e a versão ATUAL segue no ar.
step "Conferindo se dá para atualizar (preflight)"
if ! MOTIVO_PREFLIGHT="$(preflight_atualizacao "$TARGET_TAG")"; then
  refuse "Atualização NÃO começou — nada foi parado, nada foi baixado, e a versão atual segue no ar.
  Motivo: $MOTIVO_PREFLIGHT
  Resolva o motivo acima e rode de novo:
    bash hostgator-setup-kit/update.sh"
fi
if build_local_pedido; then
  c_grn "✓ preflight — o Docker respondeu; construção local pedida (DESKCOMM_BUILD_LOCAL), sem depender do registro."
else
  c_grn "✓ preflight — as quatro imagens da $TARGET_TAG estão prontas no registro."
fi

# ── 1.6 A volta fica de prontidão (#2757) ──────────────────────────────────
# Daqui para baixo a atualização VAI mexer no que está no ar. Esta marca é o
# que autoriza o agente do host a voltar sozinho, sem ninguém do outro lado,
# se o app não responder mais depois disto — é o caso em que o painel de
# atualização está justamente dentro do app que parou, e nenhum botão salva.
#
# Nasce SÓ aqui: antes do preflight nada foi tocado (o `refuse` devolve RC 3 e
# a versão atual segue no ar), e uma marca sem atualização de verdade seria
# rollback disparado sobre uma queda qualquer. E só com a versão que ESTAVA no
# ar — instalação fora de release não tem tag para onde voltar, e aí
# `vigia_armar` fica calada em vez de prometer uma volta impossível.
#
# Quem DESARMA é o agente, quando vê o app respondendo (ou quando a janela de
# validade fecha) — não este script: ele termina com o app saudável, mas é a
# vigência de alguns minutos depois que prova que a versão nova continuou de
# pé sob os primeiros requests e sob o primeiro cron novo.
#
# EXCETO quando esta execução É a volta: andar pra trás não arma. Sem isto a
# própria volta gravaria uma marca nova (o `vigia_encerrar` do agente corre
# ANTES de chamar este script), e o agente — vendo o app ainda fora do ar,
# porque a queda não era da versão — voltaria de novo daqui a dois ciclos,
# para a release que acabou de ser largada. As duas trocas se alternariam a
# cada ~10 min, com backup e migration de cada vez, até a janela de duas
# horas fechar: o laço que a issue apontou como risco principal. Uma ida,
# uma vigília — quem está saindo não vigia.
VOLTA_NO_TEMPO=""
if [ -z "$MESMA_TAG" ]; then
  # O MESMO critério da recusa lá em cima (e do agente): alvo contido no que
  # roda aqui = versão anterior. `2` (não consegui ter certeza) não é volta
  # comprovada, e aí armar continua sendo o lado seguro.
  is_already_in_head "$TARGET_TAG" && VOLTA_NO_TEMPO=1 || VOLTA_NO_TEMPO=""
fi
if [ -n "$VOLTA_NO_TEMPO" ]; then
  c_ylw "Volta para a $TARGET_TAG — a vigência pós-atualização não re-arma: quem está voltando não vigia."
else
  vigia_armar "$CURRENT_TAG"
fi

# ── 2. Backup de segurança ANTES de tocar no banco ───────────────────────────
if [ -z "$SKIP_BACKUP" ]; then
  step "Backup de segurança (antes de mexer no banco)"
  # BACKUP_ESPERA_LOCK: dois caminhos chamam o backup.sh (este e o cron diário),
  # e no mesmo segundo eles disputam a mesma pasta (#4222). Aqui não vale "o
  # outro está fazendo": este backup é a rede de segurança DESTA atualização, e
  # seguir sem ele seria pior que esperar o outro terminar. A trava é do
  # backup.sh; aqui só se diz que ela pode ser esperada.
  if BACKUP_ESPERA_LOCK=300 bash "$KIT_DIR/backup.sh"; then
    c_grn "✓ backup feito. Ele só volta num banco VAZIO, não por cima deste (restore.sh; ver hostgator-setup-kit/README.md)."
  else
    if [ -n "${DESKCOMM_AGENT_REPORT:-}" ] || [ ! -t 0 ]; then
      die "O backup preventivo falhou. Atualização automática interrompida para proteger os dados."
    fi
    c_ylw "⚠ o backup falhou. A atualização NÃO apaga dados (só reorganiza os contatos),"
    c_ylw "  mas o ideal é ter backup."
    read -r -p "Deseja continuar MESMO SEM BACKUP? Digite 'CONTINUAR': " conf
    [ "$conf" = "CONTINUAR" ] || die "Atualização cancelada pelo operador para investigar a falha do backup."
  fi
fi
# Avisa o agente do host (se for ele quem está dirigindo) — é o que faz a tela
# de atualização avançar passo a passo enquanto o app ainda está de pé.
[ -n "${DESKCOMM_AGENT_REPORT:-}" ] && eval "${DESKCOMM_AGENT_REPORT_CMD}" backup

# ── 3. Código novo ───────────────────────────────────────────────────────────
step "Baixando o código novo"
if ! git checkout --quiet "$TARGET_TAG" 2>&1; then
  die "Não consegui trocar para a versão $TARGET_TAG (parece haver mudanças locais que divergem).
     Rode 'git status' pra ver, ou peça ajuda. NÃO mexi no banco — está tudo como estava."
fi

# As funções do kit são carregadas na linha 16, ANTES deste checkout — então,
# sem esta releitura, o resto desta atualização roda com as funções da versão
# ANTIGA, e todo conserto que viva numa função do kit só chega na atualização
# SEGUINTE. Foi medido numa VPS de produção em 17/09/2026: depois de atualizar
# para a versão que conserta a linha do cron (que deixava um segredo escrito no
# crontab, e portanto no syslog), a linha antiga continuava lá — o conserto
# existia no disco e não tinha rodado. Duas passadas para aplicar um conserto é
# o mesmo que exigir passo manual de quem opera a VPS, e a doutrina de
# packaging proíbe.
#
# `_common.sh` só define funções e constantes no topo (`set -euo pipefail`,
# COMPOSE, cores, REFUSED_RC), então reler é idempotente: nada é reexecutado
# com efeito. O que muda é de onde vêm as funções daqui para baixo.
source "$KIT_DIR/_common.sh"
# E o aviso de manutenção pelo MESMO motivo, na mesma linha do raciocínio acima:
# ele também é carregado no topo, também é só definição de função, e o passo que
# o USA (a pausa do banco) vem depois daqui. Sem esta linha o parágrafo acima
# valeria para `_common.sh` e seria falso para o kit — um conserto na página de
# manutenção chegaria uma atualização atrasada, que é exatamente o defeito que a
# releitura existe para fechar.
source "$KIT_DIR/manutencao.sh"

# Single-server: o Supabase vai para a versão pinada no código novo ANTES do
# banco (o passo 4 pausa peças dele, e um `up` depois as religaria).
if [ "${SINGLE_SERVER:-0}" = "1" ]; then
  # Esta função também sincroniza o modo de cadastro com o GoTrue (#1653). A
  # chamada mora DENTRO dela, e não numa linha aqui, porque é o corpo dela que
  # o update.sh antigo executa na atualização que traz o conserto.
  atualizar_supabase_single_server || die "O Supabase desta VPS não subiu (erro acima). NÃO mexi no banco do CRM."
fi

[ -n "${DESKCOMM_AGENT_REPORT:-}" ] && eval "${DESKCOMM_AGENT_REPORT_CMD}" codigo

# ── 4. Banco: schema + correções de dados (schema ANTES do app) ──────────────
# O baseline é idempotente e auto-curativo. Re-aplicar numa base que JÁ existe
# gera erros do tipo "já existe" / "multiple primary keys" — isso é ESPERADO e
# inofensivo (são objetos que já estavam lá). Filtramos esse ruído e só
# mostramos problemas de verdade. Erro de disputa com o app no ar (deadlock)
# faz o arquivo ser aplicado de novo: ver `reaplicar_baseline` em _common.sh.
# Re-aplicar o baseline é DDL, então vai por `url_do_schema` (_common.sh) e não
# pela string do app: numa instalação em Supabase próprio, com a role menor no
# `.env` como recomendamos, este passo passava a falhar em silêncio a cada
# atualização — e é o update.sh que entrega migration nova ao clone (issue #192).
# ── NINGUÉM FALA COM O BANCO ENQUANTO ELE MUDA ───────────────────────────────
#
# Medido nesta instalação, no mesmo dia e com o mesmo arquivo:
#   tudo de pé ................................ 113 travamentos
#   CRM parado ................................  60 travamentos
#   CRM + rest + realtime + studio parados ....   0 travamentos
#
# Travamento aqui não é lentidão: quando o `create policy` trava, o `drop` que
# veio antes já valeu. A regra some, o banco nega a leitura em silêncio, e a
# tela fica vazia — indistinguível de "não há nada aqui".
#
# Custa ~16s (medido: parar 10,3s, subir 6,0s) numa atualização cuja mediana
# real é 308s e cuja variação natural entre duas rodadas foi de 785s. Fica
# abaixo do ruído que já existe.
#
# O `trap` é o que impede um erro no meio de deixar a instalação pela metade:
# qualquer saída — sucesso, erro ou interrupção — devolve as peças do Supabase.
# EXIT nao basta: interrupcao (Ctrl+C, cron matando a rodada, reinicio da
# maquina) nao passa por ele em todos os casos — e o desfecho seria a
# instalacao com as pecas do banco paradas, que foi o que se mediu.
trap restaurar_servicos EXIT INT TERM HUP

step "Atualizando o banco de dados"
# O que sobrou de errado no banco, para ser repetido no FIM da execução.
# Vazio = o banco terminou limpo (ou não havia baseline para aplicar).
BANCO_INCOMPLETO=""
# As linhas que repetir NÃO cura: as que não são de disputa nem de conexão
# (permissão, dado). Vazio com BANCO_INCOMPLETO cheio = só o banco ocupado.
BANCO_RESTANTE=""
# O que fazer, dito por causa, no passo 4 e de novo no fim. Rodar o update.sh sem
# --force responderia "já está na versão mais recente" e não tocaria no banco.
# O backup não é saída aqui: o restore.sh só restaura num banco vazio (#2120).
orientar_banco_incompleto() {
  # As duas metades SOMAM: uma lista pode ter disputa (que repetir cura) e erro de
  # permissão ou de dado (que não). Escolher uma só escondia a ação possível.
  if [ "$BANCO_RESTANTE" != "$BANCO_INCOMPLETO" ]; then
    c_ylw "  Parte não aplicou porque o banco seguiu ocupado ou fora de alcance nas $BASELINE_PASSADAS passadas."
    c_ylw "  Confira se o banco está no ar e repita a atualização, de preferência num horário de pouco"
    c_ylw "  movimento (reaplica o banco; o site pode piscar por alguns segundos):"
    c_ylw "    bash hostgator-setup-kit/update.sh --to $TARGET_TAG --force"
  fi
  case "$BANCO_RESTANTE" in
    "") ;;
    *permission\ denied*|*must\ be\ owner*|*permissão\ negada*)
      c_ylw "  Há erros de PERMISSÃO, e esses repetir não cura: a conexão do .env não é o dono do banco."
      c_ylw "  Num Supabase próprio, declare SUPABASE_DB_ADMIN_URL no .env — é ela que roda o schema — e"
      c_ylw "  repita a atualização:"
      c_ylw "    bash hostgator-setup-kit/update.sh --to $TARGET_TAG --force" ;;
    *)
      c_ylw "  O resto dos erros acima repetir não cura: guarde a mensagem e peça ajuda." ;;
  esac
  c_ylw "  O backup feito antes desta atualização NÃO volta por cima deste banco (o restore.sh só"
  c_ylw "  restaura num banco vazio). Se precisar voltar o banco ao estado anterior, peça ajuda."
}
if [ -f supabase/baseline.sql ]; then
  # O aviso PRIMEIRO: entre pausar e anunciar, quem estivesse com a tela aberta
  # veria o erro do navegador, que e o desfecho que esta onda existe para tirar.
  manutencao_sobe
  pausar_o_que_fala_com_o_banco
  # Extensões que o schema exige (idempotente; iguais ao install.sh).
  pg_container postgres:17-alpine psql "$(url_do_schema)" -c \
    "create extension if not exists vector with schema public; create extension if not exists citext with schema public; create extension if not exists pg_trgm with schema public;" \
    >/dev/null 2>&1 || true

  # ── O LOG DO BANCO FICA GUARDADO ──────────────────────────────────────────
  #
  # Ele era descartado: a saída do psql servia só para o filtro de erros e
  # morria com a função. O que o agente guarda em `system_update_runs.log_tail`
  # é a CAUDA da atualização — Docker e reinício —, e o banco acontece antes.
  #
  # Medido em 2026-09-12, numa instalação real: duas regras de isolamento
  # sumiram durante uma atualização, o funil ficou vazio para todo mundo, e não
  # houve como saber por quê — a evidência tinha sido jogada fora. A única coisa
  # que restou foi a hipótese.
  #
  # O segundo argumento de `reaplicar_baseline` já existe para isto e recebe
  # TODAS as passadas, cada uma com cabeçalho — melhor que a saída da última.
  if reaplicar_baseline "$PROJECT_DIR/supabase/baseline.sql" "$PROJECT_DIR/.deskcomm-banco.log"; then
    if [ "$BASELINE_PASSADAS" -gt 1 ]; then
      c_grn "✓ banco atualizado na passada $BASELINE_PASSADAS — as anteriores não aplicaram tudo (banco ocupado ou conexão instável; o que faltou está listado acima)."
    else
      c_grn "✓ banco atualizado (e conversas reorganizadas, se havia bagunça)."
    fi
  else
    BANCO_INCOMPLETO="$BASELINE_INESPERADO"
    BANCO_RESTANTE="$(printf '%s\n' "$BANCO_INCOMPLETO" | grep -viE "$BASELINE_ERROS_DE_DISPUTA" || true)"
    c_ylw "⚠ Apareceram avisos no banco que NÃO são os esperados:"
    # Sem `| head`: com pipefail, o head que fecha cedo mata o printf com SIGPIPE
    # numa lista grande — e o set -e derrubava o script aqui, antes do aviso de
    # PERMISSÃO, que foi escrito justamente para ela.
    listar_erros_do_banco "$BANCO_INCOMPLETO" 20
    c_ylw "  O app pode ainda funcionar."
    orientar_banco_incompleto
  fi
  # ── E AS REGRAS DE ISOLAMENTO SÃO CONFERIDAS ──────────────────────────────
  #
  # ## Por que isto existe
  #
  # O baseline aplica cada regra como APAGAR e depois CRIAR — é o único jeito
  # portável, porque o Postgres não tem `create or replace policy`. E esta
  # atualização roda SEM parar em erro, de propósito, para um clone bagunçado
  # conseguir se curar.
  #
  # As duas coisas juntas têm um desfecho ruim: se o "criar" falha, o "apagar"
  # já valeu. A regra some, a atualização segue e reporta SUCESSO. Com a regra
  # de leitura ausente e a segurança por linha ligada, o Postgres nega tudo —
  # sem erro, sem aviso. A tela mostra uma lista vazia, que é indistinguível de
  # "não há nada aqui".
  #
  # Medido: o dono de uma instalação descobriu horas depois, pelo funil vazio, e
  # não pela atualização que tinha acabado de dizer "concluída com sucesso".
  #
  # ## A régua, e por que não é "toda regra que o arquivo cria"
  #
  # O baseline CRIA e depois APAGA a mesma regra de propósito em vários pontos —
  # é assim que uma regra antiga vira três novas (`conversations_agent_write`
  # virou insert/update/delete). Contar toda criação daria falso positivo em
  # cima de decisão deliberada, e falso positivo derruba a confiança no aviso
  # inteiro. Vale a ÚLTIMA operação de cada regra no arquivo: quem termina
  # criada é esperada; quem termina apagada, não.
  #
  # ── E A COMPARAÇÃO RODA EM ORDEM DE BYTES, SEMPRE ─────────────────────────
  #
  # ⛔ `sort` e `comm` precisam concordar na ordenação. No GNU coreutils os dois
  # seguem o mesmo locale e concordam (medido: Ubuntu 20.04–25.10, Debian 12,
  # AlmaLinux 8/9 dão 0 em C, C.UTF-8, en_US e pt_BR). No Ubuntu 26.04, que troca
  # o coreutils pelo uutils (Rust, 0.8.0), o `sort` ordena pelo locale e o `comm`
  # compara BYTES: sob en_US/pt_BR.UTF-8, `orgs_select` cai entre `org_guardrail_*`
  # e `org_voice_calls_*`, o `comm` perde o passo — "comm: file 2 is not in
  # sorted order" na stderr — e o que devolve depois é lixo.
  #
  # MEDIDO numa instalação real, 2026-09-28: com as 114 regras TODAS no banco, a
  # comparação acusou 2 faltando (`org_voice_calls_admin_write` e
  # `org_voice_calls_select`). O alarme falso faz o script tentar recriar as
  # duas, o banco responde "already exists", a conferência seguinte tropeça no
  # mesmo erro de ordenação — e a atualização PARA, deixando o aviso de
  # manutenção de pé. O CRM passou 8 horas em 503 com o banco íntegro, e a tela
  # mandava o dono procurar regra que nunca faltou.
  #
  # A cura é forçar o locale da comparação para `C`, que é ordem de bytes: aí o
  # `sort` produz exatamente o que o `comm` espera, em qualquer ambiente.
  #
  # ⚠️ `LC_COLLATE=C` NÃO basta, e a diferença custa uma sessão de depuração:
  # o POSIX dá precedência a `LC_ALL` sobre `LC_COLLATE`, então basta alguém
  # exportar `LC_ALL=…UTF-8` — systemd, um `docker exec`, o terminal de quem
  # roda o update à mão — para o pin virar enfeite e o defeito voltar inteiro.
  # MEDIDO, com as 114 regras reais: `LC_ALL=C` devolve 0 em qualquer condição;
  # `LC_COLLATE=C` devolve 0 com `LC_ALL` vazio e 2 com `LC_ALL` preenchido.
  # `LC_ALL=C` vale só para os comandos abaixo — não alcança as mensagens.
  esperadas="$(awk '
    match($0, /drop policy if exists "?[a-zA-Z0-9_]+"? on public\.[a-zA-Z0-9_]+/) {
      linha = substr($0, RSTART, RLENGTH); acao = "drop"
    }
    match($0, /create policy "?[a-zA-Z0-9_]+"? on public\.[a-zA-Z0-9_]+/) {
      linha = substr($0, RSTART, RLENGTH); acao = "create"
    }
    acao != "" {
      gsub(/.*policy (if exists )?"?/, "", linha); gsub(/"? on public\./, "|", linha)
      estado[linha] = acao; acao = ""
    }
    END { for (k in estado) if (estado[k] == "create") print k }
  ' supabase/baseline.sql | LC_ALL=C sort -u)"

  # ── E SÓ SE COBRE QUEM TEM A RELAÇÃO NO BANCO ──────────────────────────────
  #
  # MEDIDO na issue #1897: as 8 regras de honorários moram DENTRO do corpo de
  # public.fn_honorarios_provisionar() (supabase/baseline.sql:36801, primeira
  # policy em :36896), e essa função só executa quando um administrador chama
  # fn_modulo_instalar('honorarios', …) — criar a função não cria tabela nenhuma,
  # como a própria migration 0480 / ADR-0002 avisa. Num VPS SEM o módulo,
  # honorarios_contratos e honorarios_parcelas não existem: o awk de cima enxerga
  # o `create policy` no TEXTO do arquivo, a recriação responde
  # `relation does not exist`, a segunda conferência acusa as MESMAS 8 e o script
  # sai em 1 com o CRM parado — era a atualização inteira de toda instalação sem
  # o módulo de honorários (a tela mostrava as 8 e mais nada).
  #
  # A régua passa a cobrir só policy cuja RELAÇÃO já existe em `public`. É mais
  # genérico do que caçar `$f$`/`$$` no texto: cobre os próximos módulos da
  # ADR-0002, venham eles por corpo de função, por migration ou por qualquer
  # outra forma de escrever o baseline. E NÃO afrouxa nada: policy de tabela que
  # EXISTE continua sendo cobrada, que é o caso para o qual o aviso existe — a
  # regra que some do banco com a tabela de pé continua derrubando a atualização.
  #
  # ⚠️ Se a consulta vier VAZIA (banco fora do ar, URL trocada), NÃO se filtra.
  # Sem a lista de relações, filtrar derrubaria `esperadas` inteira e o ✓ sairia
  # com "0 declaradas" — o aviso viraria mudo exatamente quando ninguém consegue
  # ler o banco. Vale o comportamento antigo, que é barulhento: tudo é cobrado e
  # a conferência para. Surdo nunca.
  tabelas="$(pg_container -i postgres:17-alpine psql "$(url_do_schema)" -t -A -c \
    "select c.relname from pg_class c join pg_namespace n on n.oid=c.relnamespace
       where n.nspname='public';" 2>/dev/null | LC_ALL=C sort -u)"

  if [ -n "$tabelas" ]; then
    # `esperadas` é `regra|tabela`; o filtro olha só a tabela. A saída do awk
    # segue a ordem do SEGUNDO arquivo (o `esperadas` já ordenado), e o
    # segundo `sort` reforça o MESMO pino de antes (LC_ALL=C): o `comm` logo
    # abaixo lê em `LC_ALL=C` e não perdoa entrada fora de ordem.
    esperadas="$(awk 'NR == FNR { existe[$0] = 1; next }
      { split($0, par, "[|]"); if (existe[par[2]]) print }' \
      <(printf '%s\n' "$tabelas") <(printf '%s\n' "$esperadas") | LC_ALL=C sort -u)"
  fi

  existentes="$(pg_container -i postgres:17-alpine psql "$(url_do_schema)" -t -A -F'|' -c \
    "select p.polname, c.relname from pg_policy p join pg_class c on c.oid=p.polrelid
       join pg_namespace n on n.oid=c.relnamespace where n.nspname='public';" 2>/dev/null | LC_ALL=C sort -u)"

  faltando="$(LC_ALL=C comm -23 <(printf '%s\n' "$esperadas") <(printf '%s\n' "$existentes") || true)"

  if [ -n "$faltando" ]; then
    # ── RECRIAR AS QUE FALTAM, NUNCA REAPLICAR O ARQUIVO ─────────────────────
    #
    # ⚠️ Isto corrige o que este script fazia antes: reaplicar o baseline inteiro
    # e conferir de novo. Aquilo era o que eu tinha feito no servidor, e a
    # medição mostrou que NÃO FECHA — reaplicar não converge. A segunda passada
    # devolveu `conversations_select` e levou embora `conversations_agent_insert`;
    # a terceira trocou o conjunto outra vez. Cada passada sorteia, porque cada
    # passada é a mesma corrida de APAGAR e CRIAR 92 vezes.
    #
    # Recriar só o que falta é um punhado de comandos rápidos, com muito menos
    # superfície para travar. E roda com os serviços ainda PARADOS, que é a
    # única janela sem disputa.
    #
    # E não é uma segunda cópia das 92 declarações: o comando sai do PRÓPRIO
    # `baseline.sql`, recortado dele. Nada aqui sabe o que uma regra diz.
    c_ylw "⚠ Faltaram regras de isolamento. Recriando exatamente as que faltam…"
    faltam_arq="$PROJECT_DIR/.deskcomm-regras-faltando.txt"
    printf '%s\n' "$faltando" > "$faltam_arq"

    # A régua junta o comando INTEIRO — uma regra real ocupa várias linhas, e
    # recortar só a primeira produziria SQL sem predicado e sem `;`, que falha
    # deixando a impressão de que tentou. E vale a ÚLTIMA operação de cada
    # regra: quem o arquivo cria e depois apaga de propósito não é recriada.
    # /!\ O arquivo do que falta entra como PRIMEIRO ARQUIVO do awk, e nao por
    # `-v`. MEDIDO: `awk -v var=valor` processa sequencias de escape no valor,
    # entao um caminho do Windows (C:\Users\...) perde as barras e o awk le um
    # arquivo que nao existe — devolvendo vazio, EM SILENCIO, como se nada
    # faltasse. Numa VPS Linux nao doeria; o teste pegou antes de virar aposta.
    recria="$(awk '
      NR == FNR { sub(/[ \t\r]+$/, "", $0); if ($0 != "") quero[$0] = 1; next }
      /create policy|drop policy if exists/ { buf = ""; coletando = 1 }
      coletando { buf = buf $0 "\n" }
      coletando && /;[ \t]*$/ {
        coletando = 0
        if (match(buf, /drop policy if exists "?[a-zA-Z0-9_]+"? on public\.[a-zA-Z0-9_]+/)) { k = substr(buf, RSTART, RLENGTH); acao = "drop" }
        else if (match(buf, /create policy "?[a-zA-Z0-9_]+"? on public\.[a-zA-Z0-9_]+/)) { k = substr(buf, RSTART, RLENGTH); acao = "create" }
        else next
        gsub(/.*policy (if exists )?"?/, "", k); gsub(/"? on public\./, "|", k)
        estado[k] = acao; if (acao == "create") texto[k] = buf
      }
      END { for (k in quero) if (estado[k] == "create") printf "%s", texto[k] }
    ' "$faltam_arq" supabase/baseline.sql)"

    if [ -n "$recria" ]; then
      printf '%s\n' "$recria" | pg_container -i postgres:17-alpine \
        psql "$(url_do_schema)" >> "$PROJECT_DIR/.deskcomm-banco.log" 2>&1 || true
    fi
    rm -f "$faltam_arq"

    existentes="$(pg_container -i postgres:17-alpine psql "$(url_do_schema)" -t -A -F'|' -c \
      "select p.polname, c.relname from pg_policy p join pg_class c on c.oid=p.polrelid
         join pg_namespace n on n.oid=c.relnamespace where n.nspname='public';" 2>/dev/null | LC_ALL=C sort -u)"
    # Mesma ordenação da primeira conferência, e pelo mesmo motivo: `LC_ALL=C`
    # é o que faz `sort` e `comm` concordarem. Ver o bloco acima.
    faltando="$(LC_ALL=C comm -23 <(printf '%s\n' "$esperadas") <(printf '%s\n' "$existentes") || true)"
  fi

  if [ -n "$faltando" ]; then
    c_red "⛔ REGRAS DE ISOLAMENTO AUSENTES — NÃO use o sistema até resolver."
    c_red "   Sem elas o banco NEGA a leitura em silêncio: telas aparecem VAZIAS,"
    c_red "   sem erro nenhum, e isso é indistinguível de 'não há dados'."
    printf '%s\n' "$faltando" | sed 's/|/ na tabela /; s/^/   • /' | head -20
    c_ylw "   O log do banco está em .deskcomm-banco.log — mande-o para o suporte."
    c_ylw "   O backup NÃO volta por cima deste banco (restore.sh só em banco vazio): peça ajuda."
    # ⛔ E A ATUALIZAÇÃO PARA AQUI.
    #
    # Antes ela seguia: imprimia este bloco vermelho e ia para o passo 5, que
    # sobe o app com a imagem nova. O CRM voltava ao ar sem regra de isolamento,
    # mostrando tela vazia para todo mundo — e o vermelho já tinha rolado para
    # fora da tela. Foi assim que o dono da instalação descobriu pelo funil,
    # horas depois, e não pela atualização.
    #
    # O `trap` (logo acima do passo do banco) devolve as peças do Supabase e
    # deixa o CRM parado de propósito. Um CRM fora do ar é um problema visível
    # que alguém resolve; um CRM no ar sem isolamento, não.
    REGRAS_FALTANDO="$faltando"
    exit 1
  else
    c_grn "✓ regras de isolamento conferidas ($(printf '%s\n' "$esperadas" | grep -c . ) declaradas, todas no lugar)."
    # ── O BANCO RELIGA AQUI, e nao no fim do script ──────────────────────────
    #
    # MEDIDO na instalacao real em 2026-09-13: as pecas pararam as 03:10:18 e o
    # script so terminou as 03:13:09. QUASE TRES MINUTOS sem o Supabase — e nao
    # por falha: por DESENHO. A volta so acontecia no gatilho de saida, depois
    # de baixar imagem, recriar conteiner e esperar o healthcheck do app.
    #
    # Nada disso precisa do Supabase parado. O que precisava era o DDL, e ele
    # acabou na linha de cima — junto com a conferencia das regras, que e o
    # unico motivo de esperar ate aqui em vez de religar antes.
    #
    # Fica no ramo do SUCESSO de proposito: com regra faltando o script sai no
    # `exit 1` acima, e a volta das pecas vira responsabilidade do gatilho de
    # saida — que religa o banco e deixa o CRM parado, como deve.
    religar_o_supabase
  fi
else
  c_ylw "⚠ supabase/baseline.sql não encontrado — pulei a parte do banco."
fi
# Retentativa não cura estes: a migration NÃO chegou, e seguir daqui trocava o
# app por cima de um banco pela metade com status 0 — o "deu certo" do cron.
# DEPOIS da conferência das regras de isolamento, nunca antes: ela recria as que
# faltam e, se não conseguir, mantém o CRM parado. Sair antes dela deixaria o
# trap subir o app sem regra — tela vazia para todo mundo.
[ -z "$BANCO_RESTANTE" ] || die "O banco NÃO terminou limpo e os erros acima repetir não cura: a migration NÃO chegou. A atualização PARA aqui."
[ -n "${DESKCOMM_AGENT_REPORT:-}" ] && eval "${DESKCOMM_AGENT_REPORT_CMD}" banco

# ── 4.5 E-mails de acesso, para quem já estava instalado ────────────────────
# Só COM o token no ambiente, e por isso duas coisas:
#
#  - é assim que um clone ANTIGO recebe os e-mails com a marca dele. O
#    `install.sh` dele nunca chamou este passo (ele não existia), e nenhuma
#    atualização toca em config de auth por conta própria;
#  - sem o token, o script imprimiria o passo manual — útil UMA vez, na
#    instalação, e ruído em toda atualização a partir daí. Atualização que
#    resmunga toda vez ensina a ignorar a saída dela.
#
# E sem o token, UMA vez na vida: quem instalou antes de a entrevista pedir o
# token tem o Site URL do projeto em `localhost:3000` — o link de "esqueci minha
# senha" leva a uma máquina que não existe fora do laptop de quem desenvolve.
# Esse parque não é alcançado por nada: o `install.sh` dele não perguntou o
# token, e o bloco acima só roda com token. Sem esta linha, a população
# REALMENTE quebrada hoje nunca fica sabendo.
#
# Uma vez, e nunca mais — o marcador em disco garante isso, que é o que separa
# um recado de um resmungo mensal. E o texto CONFERE, não acusa: quem já
# configurou à mão está certo, e ler "seus e-mails estão quebrados" numa
# atualização que correu bem seria alarme falso na cara de quem fez tudo certo.
AVISO_SITE_URL=""
MARCA_AVISO_SITE_URL="$PROJECT_DIR/.deskcomm-site-url-avisado"
if [ -n "${SUPABASE_ACCESS_TOKEN:-}" ]; then
  bash "$KIT_DIR/marca-emails.sh" --projeto "$PROJECT_DIR" || true
  : > "$MARCA_AVISO_SITE_URL" 2>/dev/null || true   # o passo automático rodou
elif [ ! -e "$MARCA_AVISO_SITE_URL" ]; then
  AVISO_SITE_URL=1
fi

# ── 5. App novo ──────────────────────────────────────────────────────────────
step "Baixando a versão nova do app e reiniciando"
# Imagem da TAG publicada (não "latest" solto): garante que o código (checkout
# acima) e a imagem do container sejam sempre da mesma versão. Gravada no .env,
# não só exportada: o compose lê a imagem de lá, e um `up -d` rodado à mão
# depois voltaria pro ":latest" do install — desfazendo a atualização.
#
# As TRÊS imagens são gravadas juntas, na mesma versão. O worker e o scheduler
# passaram a ter imagem publicada porque, antes, eram `build:`-only no compose:
# `dc pull` os PULAVA ("Skipped - No image to be pulled") e o `dc up -d` abaixo
# recriava o contêiner sobre a imagem velha, sem `--build`. Resultado: o worker
# — que é o runtime do agente de IA — ficava congelado no código do dia da
# instalação e atravessava todas as atualizações. Esta é a linha que conserta
# isso para o parque já instalado, sem que ninguém precise editar arquivo.
#
# `gravar_imagens` também resolve o pull_policy pela mutabilidade da tag: como
# aqui o alvo é sempre uma tag de versão (imutável), sai `missing`. Isso além de
# tudo desfaz o "missing" que um rollback anterior deixava para trás — antes ele
# ficava no .env para sempre, e o `up -d` manual do dono nunca mais puxava nada.
# Lido ANTES de `gravar_imagens` corrigir — senão a informação some. Este é o
# estado que a execução ANTERIOR deixou, e o dono nunca soube: o `update.sh`
# antigo grava só `APP_IMAGE`, e o worker fica seguindo um canal móvel.
PIN_FALTANDO_ANTES="$(pin_incompleto .env)"

# O snapshot que torna o rollback possível (#1955, critério 4): os OITO pins de
# versão do `.env` ANTES de gravar os novos. É a última janela em que a versão
# anterior ainda está escrita — depois de `gravar_imagens` + dos `export` logo
# abaixo, TODO `up -d` seguinte (inclusive o da volta e o do gatilho de saída)
# já aponta para a versão nova. É por isso que a instalação da issue terminou
# com os serviços em `Created`: a "volta" usava imagens que não existiam.
armar_rollback_de_versao .env

VERSAO_ALVO="${TARGET_TAG#v}"
export APP_IMAGE="${IMG_APP}:${VERSAO_ALVO}"
export WORKER_IMAGE="${IMG_WORKER}:${VERSAO_ALVO}"
export SCHEDULER_IMAGE="${IMG_SCHEDULER}:${VERSAO_ALVO}"
export VOICE_AGENT_IMAGE="${IMG_VOICE_AGENT}:${VERSAO_ALVO}"
# `gravar_imagens` também cria, só na lacuna, o par VAPID dos avisos com o app
# fechado (`completar_par_vapid`), e nunca troca um par que já existe. Mora lá
# dentro, e não numa linha daqui, porque é ela que o update.sh ANTIGO chama
# depois de reler o kit: assim o par chega já na primeira atualização, antes do
# pull e do `up -d` abaixo.
gravar_imagens .env "$VERSAO_ALVO"

# Os segredos da chamada de voz (spec 18), para quem instalou antes dela existir.
# LACUNA apenas — chave presente, mesmo vazia, é decisão de quem opera. Isto NÃO
# liga a feature: sem `voz` em COMPOSE_PROFILES o serviço nem é criado. O que
# isto compra é o dia em que o dono QUISER ligar não começar por inventar dois
# segredos num editor dentro da VPS, que é o passo manual que a doutrina de
# packaging proíbe.
VOZ_CRIADA="$(completar_segredos_da_voz .env)" || VOZ_CRIADA=""
[ -n "$VOZ_CRIADA" ] && c_ylw "  (preparei as credenciais da chamada de voz no .env — ela segue DESLIGADA)"

# `dc pull` falha se alguma das três imagens ainda não existir no registro
# (quatro com o profile `telefonia`, que liga o voice-agent) — o
# que acontece numa instalação atualizando para a primeira versão publicada
# depois desta mudança, ou se um run de publicação quebrou. Worker, scheduler e
# voz têm `build:` ao lado do `image:`, e o Compose os constrói sozinho em
# QUALQUER falha de pull (medido — não só na de arquitetura): pior que puxar,
# melhor que não atualizar. O `app` fica SEM `build:` de propósito (#1060): se a
# imagem dele não veio, o `up -d` logo abaixo FALHA e quem decide é o portão
# `build_local_permitido` — construir aqui ou recusar, conforme o registro
# responde ou não.
if ! dc pull; then
  # A mensagem distingue os dois casos porque a CAUSA é diferente, e uma frase
  # tranquilizadora sobre o caso errado é o pior desfecho possível. Se a do app
  # veio, o que faltar em worker, scheduler ou voz o Compose constrói sozinho no
  # `up -d` (medido: qualquer falha de pull, não só a de arquitetura). Se a do
  # app NÃO veio, ele não tem `build:` no compose de produção de propósito: o
  # `up -d` falha e quem decide é o portão `build_local_permitido` — com o
  # registro respondendo ele constrói a versão aqui; sem resposta do registro ele
  # RECUSA, de propósito (guarda da #1955). "Construído aqui" sem essa ressalva
  # prometeria justamente o que o portão nega no pior caso. Os dois diagnósticos
  # também contam coisas distintas: "o app veio e falta outra peça" × "o app não
  # veio", que aponta para a versão ainda não publicada (ou o pacote privado).
  if dc pull app >/dev/null 2>&1; then
    c_ylw "⚠ Não consegui puxar todas as imagens da versão ${VERSAO_ALVO}."
    c_ylw "  A do app veio; o que faltar é construído aqui (mais lento, mesmo resultado)."
  else
    c_ylw "⚠ Não consegui puxar a imagem do APP na versão ${VERSAO_ALVO}."
    c_ylw "  Causas comuns: a versão ainda está publicando, ou o pacote está privado no GHCR."
    c_ylw "  Vou tentar subir mesmo assim — se o up -d falhar, quem decide o passo"
    c_ylw "  seguinte é a recuperação: com o registro respondendo ela constrói as"
    c_ylw "  imagens desta versão aqui (15 a 25 min); sem resposta do registro ela"
    c_ylw "  recusa, de propósito, para não gastar a memória desta VPS."
    c_ylw "  Se não subir, rode de novo em alguns minutos."
  fi
fi
# A rede do proxy externo é declarada como EXTERNA no compose: se ela sumiu
# (um `docker network prune`, ou o `down -v` que o próprio kit ensina como
# caminho de recomeço), o `up -d` abaixo morre em "network X declared as
# external, but could not be found" — e este script roda sozinho pelo agent.sh,
# então ninguém está lendo a tela para decifrar isso. Mesma função do install.sh.
garantir_rede_do_proxy
# Antes de qualquer `up -d`: a configuração NOVA do Caddy (Caddyfile desta
# versão + caddy-sites/ do operador + imagem já puxada) passa pela conferência.
# Recusada, nem o `up -d` do parque nem a recriação lá embaixo tocam no proxy
# que está no ar (#2724; ver conferir_config_do_proxy em _common.sh).
PROXY_RECUSA=""
case "${REVERSE_PROXY:-caddy}" in
traefik|npm|cloudflared) ;;
*) conferir_config_do_proxy || true ;;
esac
# O `up -d` falha por imagem ausente no disco e, com ele, a atualização inteira:
# numa VPS de arquitetura diferente da das imagens publicadas o `pull` acima não
# traz nada. Worker, scheduler e voz têm `build:` ao lado do `image:` e o
# Compose os constrói sozinho em QUALQUER falha de pull (medido — tag
# inexistente, registro fora por DNS, "no matching manifest"); o `app` NÃO tem,
# de propósito (#1060) — é a falta da imagem dele que faz o `up -d` falhar e
# entrega a decisão a esta guarda, única que julga quando o registro não
# responde. Sem ela o script terminava como se tivesse dado certo e o dono
# ficava na versão velha sem saber; pelo botão "Atualizar" do site, pior: o
# agente roda sozinho no cron e não há ninguém lendo a tela para desconfiar.
#
# O gatilho é o CÓDIGO DE SAÍDA, nunca o texto do erro — arquitetura da VPS, tag
# ainda publicando, pacote privado no registro e registro fora do ar caem no
# mesmo caminho, sem depender de casar em inglês uma frase que o Docker muda. O
# custo é o pior caso: um `up -d` que falhe por outro motivo gasta o build antes
# de desistir. É o preço de não adivinhar.
# ⛔ O AVISO DESCE AQUI, e nao no gatilho de saida.
#
# MEDIDO na atualizacao real para a v1.17.21: o gatilho roda depois de mais
# quatro etapas — baixar imagem, recriar, conferir saude, conferir automacoes. E
# o roteamento do aviso tem prioridade 500, ACIMA da regra do app. Resultado: o
# CRM voltava ao ar e quem abrisse continuava vendo "estamos atualizando" por
# minutos, com o sistema ja funcionando. Aviso que mente e pior que aviso nenhum:
# a pessoa vai embora achando que o sistema esta fora.
#
# `restaurar_servicos` segue chamando o mesmo `manutencao_desce` — ele e
# `docker rm -f ... || true`, idempotente de proposito, e la ele cobre o caminho
# de ERRO, onde este ponto aqui nunca chega a ser alcancado.
manutencao_desce
CONSTRUIU_AQUI=""
if ! subir_o_parque; then
  # ── O PORTÃO DO BUILD LOCAL (#1955, critério 2) ─────────────────────────
  # O gatilho continua sendo o CÓDIGO DE SAÍDA do `up -d` (nunca o texto do
  # erro — arquitetura, tag publicando, pacote privado e registro fora caem no
  # mesmo caminho), mas a CONSTRUÇÃO deixou de ser o que acontece SOZINHO
  # quando quem falhou é o REGISTRO: foi o que transformou um apagão de DNS em
  # OOM no `next-build` e a instalação em 502.
  #
  # Quem tem registro respondendo continua se recuperando sozinho — é a
  # recuperação de arquitetura do #1060/#1143 e ela não pode sumir. Quem quer
  # construir de propósito pede com DESKCOMM_BUILD_LOCAL=1.
  if ! build_local_permitido "$VERSAO_ALVO"; then
    c_red "✖ A atualização não terminou: o registro de imagens não responde, e a construção local está DESLIGADA por padrão."
    c_ylw "  Sem resposta do registro (DNS/rede) a construção local gastaria a memória"
    c_ylw "  desta VPS e deixaria os serviços parados — é exatamente o defeito da #1955."
    c_ylw "  A versão anterior volta sozinha no fim desta saída; o diagnóstico fica em:"
    c_ylw "    .deskcomm-update-diagnostico.log"
    c_ylw "  Para construir as imagens aqui DE PROPÓSITO (mais lento, exige memória):"
    c_ylw "    DESKCOMM_BUILD_LOCAL=1 bash hostgator-setup-kit/update.sh --to $TARGET_TAG --force"
    exit 1
  fi
  if construir_aqui_e_subir "$VERSAO_ALVO"; then
    CONSTRUIU_AQUI=1
  else
    c_red "✖ A atualização não terminou: nem as imagens prontas desta versão nem a construção aqui funcionaram."
    c_ylw "  A versão anterior volta sozinha no fim desta saída. O erro está logo acima;"
    c_ylw "  para reproduzir só a construção: docker compose $(dc_files) -f ${COMPOSE_BUILD} build"
    exit 1
  fi
fi

# O Caddyfile entra no container por bind mount de UM ARQUIVO, e bind mount de
# arquivo fica preso ao inode. O `git pull` não edita o arquivo: escreve outro e
# renomeia, gerando inode novo — o container continua lendo o antigo, para
# sempre. Medido nesta VPS: host inode 3283869, container 3271833, com o
# conteúdo velho lá dentro.
#
# Sem este force-recreate, TODA mudança de proxy enviada numa atualização
# (inclusive correção de segurança na borda) some em silêncio: o update diz
# "concluída" e a configuração antiga segue valendo.
#
# Com proxy externo não há Caddy para recriar — e não basta o profile inativo
# do override: nomear o serviço explicitamente (`up -d ... caddy`) ATIVA o
# profile dele no Compose e sobe o contêiner assim mesmo, indo bater de frente
# com o Traefik nas portas 80/443. O resultado era um "⚠ não consegui recriar o
# proxy" em TODA atualização de quem usa proxy externo: alarme falso, num
# momento em que o dono precisa confiar no que está lendo.
case "${REVERSE_PROXY:-caddy}" in
traefik|npm|cloudflared)
  c_grn "✓ proxy externo (${REVERSE_PROXY}): o Caddy não é usado aqui — nada a recarregar"
  ;;
*)
  recriar_o_proxy || true
  ;;
esac

# ── 6. O app voltou no ar? ───────────────────────────────────────────────────
step "Conferindo se o app voltou no ar"
ok=""
wait_app_healthy 20 3 >/dev/null && ok=1
if [ -n "$ok" ]; then
  # ── E OS OUTROS SERVIÇOS? (#1955, critério 6) ─────────────────────────
  # O app responder não é o parque inteiro de pé: worker, scheduler e o proxy
  # sobem no MESMO `up -d` e podem ter ficado em `Created`/`Exited` pela mesma
  # imagem que faltou. A promessa da issue é "saiem saudáveis OU o rollback
  # roda" — e rollback só roda se alguém CONSTATAR o serviço fora. Lista vazia
  # (o `ps` não respondeu) não derruba nada: só se devolve a versão anterior
  # havendo um serviço positivamente fora do ar.
  FORA_DO_AR="$(servicos_fora_do_ar || true)"
  if [ -n "$FORA_DO_AR" ]; then c_red "✖ O app respondeu, mas estes serviços NÃO subiram: $FORA_DO_AR"; c_ylw "  Voltando para a versão anterior automaticamente — o desfecho está no fim desta saída."; exit 1; fi
  # Daqui para baixo a atualização ACABOU: é o único ponto em que todas as
  # peças provaram estar de pé, e por isso é aqui que o rollback é desarmado.
  UPDATE_STATUS="concluído"
  desarmar_rollback_de_versao
  # O marcador que a guarda de arquitetura lê (#1778). Instalações antigas só
  # seriam reconhecidas pelo contêiner — que um `down` sem `-v` apaga. Gravar
  # aqui, com o app saudável, fecha esse caso a partir desta atualização.
  # Falhar em gravar não desfaz nada: a guarda segue caindo no sinal do
  # contêiner para arquiteturas que ainda não têm imagens publicadas.
  marcar_instalacao_feita "$TARGET_TAG" || true
  if [ -n "$BANCO_INCOMPLETO" ]; then
    c_ylw "⚠ App no ar e saudável, mas o banco NÃO terminou limpo — o que fazer está no fim desta saída."
  else
    c_grn "✓ Atualização concluída — app no ar e saudável."
    # Dito AQUI, no fim, porque é o que sobra na tela do site: o agent.sh manda o
    # rabo da saída, e o build local encheu as linhas de cima com a própria
    # construção. Sem esta frase o dono lê "concluída" e não faz ideia de que a
    # VPS dele passou 20 minutos construindo imagens.
    if [ -n "$CONSTRUIU_AQUI" ]; then
      c_ylw "  (as três imagens desta versão foram construídas aqui nesta VPS: as"
      c_ylw "   prontas não servem para a arquitetura dela. Toda atualização aqui"
      c_ylw "   segue o mesmo caminho — é mais lento e não precisa de nada manual.)"
    fi
  fi
  # Dito no fim, e não no início, porque é aqui que o dono lê. Se a execução
  # anterior deixou o pin pela metade, ele nunca soube — a tela dizia "concluída"
  # e o worker seguia um canal móvel. Agora ele sabe que existiu e que acabou.
  if [ -n "$PIN_FALTANDO_ANTES" ]; then
    c_ylw "  (de quebra: a versão de $PIN_FALTANDO_ANTES estava solta e foi fixada agora)"
  fi
  # Dito aqui pelo mesmo motivo do pin: é no fim que o dono lê.
  if [ -n "$AVISO_SITE_URL" ]; then
    # O texto depende da topologia (single-server não tem o que conferir):
    # ver aviso_do_site_url, em _common.sh.
    aviso_do_site_url "${NEXT_PUBLIC_APP_URL:-https://SEU_DOMINIO}"
    : > "$MARCA_AVISO_SITE_URL" 2>/dev/null || true
  fi
else
  c_ylw "⚠ Atualizei, mas o app não respondeu 'ok'. Veja os logs:"
  c_ylw "  docker compose $(dc_files) logs --tail=50 app"
  # Código de saída != 0: é o que o agent.sh usa pra saber que precisa voltar
  # pra imagem anterior (guardada por ele ANTES do pull). Sem isso, não existe
  # rede de proteção — o app novo, quebrado, ficaria no ar sem ninguém saber.
  exit 1
fi

# ── 7. Automações (cron do drain de eventos; o da tela já subiu no bloco 0) ──
step "Conferindo as automações"
ensure_encryption_key .env
setup_event_log_drain_cron

# ── Fim: o banco que não terminou limpo é a ÚLTIMA coisa na tela ─────────────
# Na v1.27.3 de uma VPS real o aviso do passo 4 ficou soterrado por centenas de
# linhas do docker pull, e as últimas linhas da tela eram ✓ verdes. É aqui,
# depois de tudo, que o dono lê.
if [ -n "$BANCO_INCOMPLETO" ]; then
  step "Atenção: o banco NÃO terminou limpo nesta atualização"
  c_ylw "  Os avisos completos estão no passo \"Atualizando o banco de dados\", acima."
  orientar_banco_incompleto
fi
