#!/usr/bin/env bash
# ============================================================================
# Rollin Host — Wizard de Setup VPS
#
# Uso:
#   bash <(curl -sSL setup.rollinhost.com.br)
#
# Padrão de UX (estilo SetupOrion): menu numerado, com hint do que cada stack
# faz, instala uma por vez e volta pro menu. Cores Rollin.
#
# Fluxo:
#   1. Pré-vôo (root, OS suportado)
#   2. Atualiza pacotes + dependências base
#   3. Configura swap se a RAM for baixa
#   4. Firewall UFW (22/80/443)
#   5. Instala Docker + Compose + cria a rede 'rollin-net'
#   6. Pede e-mail pra Let's Encrypt
#   7. Menu interativo: você digita o número da stack, ele instala, volta
#      pro menu. Repita até terminar. Digite 99 pra sair.
#
# Stacks v1 (curadas pro perfil Rollin — n8n / agentes IA / atendimento):
#   01 Traefik       — reverse proxy + HTTPS (RECOMENDADO 1º)
#   02 Portainer     — painel web Docker
#   03 n8n           — automação de fluxos + agentes IA
#   04 Evolution API — integração WhatsApp
#   05 Chatwoot      — atendimento omnichannel
#   06 Typebot       — builder visual de chatbots
#   07 Flowise       — orquestração de fluxos LLM
#   08 Uptime Kuma   — monitoramento de uptime
#   09 NocoDB        — banco de dados no-code (estilo Airtable)
#
# Adicionar mais stacks: crie uma função install_<nome>(), adicione no menu
# (show_menu) e no switch (case) — o resto da estrutura já cuida do HTTPS,
# rede, credenciais, log etc.
#
# Idempotente. Log: /var/log/rollin-setup.log. Credenciais: /root/rollin-credentials.txt
# ============================================================================
set -euo pipefail

ROLLIN_VERSION="2026.05.21"
LOG_FILE="/var/log/rollin-setup.log"
CRED_FILE="/root/rollin-credentials.txt"
NET="rollin-net"
TZ_DEFAULT="America/Sao_Paulo"
ACME_EMAIL=""

# Modo staging do Let's Encrypt — APENAS pra testes (reset repetido da VPS).
# Ativado com:  RH_ACME_STAGING=1 bash setup.sh
# O CA staging tem limite altíssimo e não esgota o rate limit de produção; em
# troca, o cert é "de mentira" (navegador reclama — normal em teste). Em branco
# = produção (cert real), que é o que os colaboradores usam.
ACME_STAGING="${RH_ACME_STAGING:-}"
ACME_CASERVER_LINE=""
if [ -n "$ACME_STAGING" ]; then
    ACME_CASERVER_LINE=$'\n      - --certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory'
fi

# NÃO usar `exec > >(tee ...)` aqui: esse process substitution conflita com o
# `bash <(curl -fsSL ...)` do comando público (que TAMBÉM é process substitution)
# e trava/bufferiza o wizard no meio. O usuário vê tudo no terminal; as
# credenciais ficam salvas em $CRED_FILE.

# ---------- Cores (paleta Rollin: roxo, dourado, verde, vermelho) ----------
if [ -t 1 ]; then
    C_R=$'\e[31m'; C_G=$'\e[32m'; C_Y=$'\e[33m'; C_B=$'\e[34m'
    C_M=$'\e[35m'; C_C=$'\e[36m'; C_W=$'\e[1;37m'; C_DIM=$'\e[2m'; C_N=$'\e[0m'
else
    C_R=''; C_G=''; C_Y=''; C_B=''; C_M=''; C_C=''; C_W=''; C_DIM=''; C_N=''
fi
log()  { echo "${C_G}✓${C_N} $*"; }
warn() { echo "${C_Y}⚠${C_N} $*"; }
err()  { echo "${C_R}✗${C_N} $*" >&2; }
hr()   { echo; echo "${C_M}━━━━━━━━━━ $* ━━━━━━━━━━${C_N}"; }

banner() {
    local subtitle="${1:-}"
cat <<EOF
${C_M}
╔════════════════════════════════════════════════════════════════════════╗
║                                                                        ║
║      ${C_W}██████╗  ██████╗ ██╗     ██╗     ██╗███╗   ██╗${C_M}                    ║
║      ${C_W}██╔══██╗██╔═══██╗██║     ██║     ██║████╗  ██║${C_M}                    ║
║      ${C_W}██████╔╝██║   ██║██║     ██║     ██║██╔██╗ ██║${C_M}   H  O  S  T       ║
║      ${C_W}██╔══██╗██║   ██║██║     ██║     ██║██║╚██╗██║${C_M}                    ║
║      ${C_W}██║  ██║╚██████╔╝███████╗███████╗██║██║ ╚████║${C_M}                    ║
║      ${C_W}╚═╝  ╚═╝ ╚═════╝ ╚══════╝╚══════╝╚═╝╚═╝  ╚═══╝${C_M}                    ║
║                                                                        ║
║       ${C_C}A primeira cloud especializada em IA na América Latina${C_M}           ║
║                                                                        ║
║       ${C_DIM}─────────────────────────────────────────────────────────${C_M}        ║
║       ${C_W}Suporte${C_M}    suporte@rollinhost.com.br                             ║
║       ${C_W}WhatsApp${C_M}   (19) 3199-2720                                        ║
║       ${C_W}Site${C_M}       rollinhost.com.br                                     ║
║       ${C_DIM}─────────────────────────────────────────────────────────${C_M}        ║
║                                                                        ║
╚════════════════════════════════════════════════════════════════════════╝${C_N}

            ${C_W}${subtitle}${C_N}   ${C_DIM}v${ROLLIN_VERSION}${C_N}

EOF
}

footer() {
cat <<EOF

${C_M}─────────────────────────────────────────────────────────────────────────${C_N}
  ${C_W}Rollin Host${C_N}  ${C_DIM}·${C_N}  A primeira cloud especializada em IA
  ${C_W}Suporte${C_N} suporte@rollinhost.com.br   ${C_W}WhatsApp${C_N} (19) 3199-2720
  ${C_W}Site${C_N} rollinhost.com.br
${C_M}─────────────────────────────────────────────────────────────────────────${C_N}

EOF
}

# ---------- Pre-flight ----------
require_root() {
    if [ "$(id -u)" -ne 0 ]; then
        err "Precisa rodar como root. Tente:"
        err "  sudo bash <(curl -sSL setup.rollinhost.com.br)"
        exit 1
    fi
}

detect_os() {
    [ -f /etc/os-release ] || { err "Sistema sem /etc/os-release."; exit 1; }
    . /etc/os-release
    case "${ID:-}" in
        ubuntu|debian) log "Sistema: ${PRETTY_NAME:-$ID}" ;;
        *) err "Sistema $ID não suportado. Use Ubuntu 22+ ou Debian 11+."; exit 1 ;;
    esac
}

install_deps() {
    hr "Dependências base"
    # VPS recém-criada roda apt automático no 1º boot (unattended-upgrades /
    # cloud-init) e segura o lock do dpkg → "Could not get lock". Aguarda liberar.
    local n=0
    while fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock >/dev/null 2>&1; do
        [ "$n" -eq 0 ] && warn "Sistema rodando atualizações automáticas — aguardando o apt liberar..."
        sleep 5; n=$((n+1)); [ "$n" -ge 90 ] && break   # no máximo ~7 min
    done
    # Retry do update/install (cobre lock tardio ou fuser ausente na imagem)
    n=0
    until apt-get update -qq 2>/dev/null; do
        n=$((n+1)); [ "$n" -ge 60 ] && { err "apt indisponível (lock do sistema ou rede). Espere ~1 min e rode de novo."; exit 1; }
        sleep 5
    done
    n=0
    until DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
            curl wget ca-certificates gnupg lsb-release jq ufw apache2-utils tzdata >/dev/null 2>&1; do
        n=$((n+1)); [ "$n" -ge 60 ] && { err "apt install falhou (lock ou rede). Espere ~1 min e rode de novo."; exit 1; }
        sleep 5
    done
    timedatectl set-timezone "$TZ_DEFAULT" 2>/dev/null || true
    log "Pacotes base OK"
}

# ---------- Helpers ----------
rand_pwd() { tr -dc 'A-Za-z0-9' </dev/urandom | head -c "${1:-24}"; echo; }

save_cred() {
    [ -f "$CRED_FILE" ] || { touch "$CRED_FILE"; chmod 600 "$CRED_FILE"; }
    {
        echo "# $(date '+%F %T')  $1"
        shift
        for kv in "$@"; do echo "  $kv"; done
        echo
    } >> "$CRED_FILE"
}

# Remove volume Docker órfão de uma instalação anterior (sem container ativo).
# Por quê: ao reinstalar uma stack o script gera uma senha NOVA de Postgres,
# mas se o volume PGDATA velho ainda existe o Postgres ignora a senha nova
# (só inicializa no primeiro boot) → "password authentication failed". Como o
# volume velho é inacessível com a senha nova, é seguro descartá-lo.
# Uso: drop_orphan_volume <nome_do_volume> <nome_do_container_guard>
drop_orphan_volume() {
    local vol="$1" guard="$2"
    if docker volume inspect "$vol" >/dev/null 2>&1 \
       && ! docker ps -a --format '{{.Names}}' | grep -q "^${guard}$"; then
        warn "Volume '$vol' de instalação anterior detectado — removendo (senha nova não casaria)"
        docker volume rm "$vol" >/dev/null 2>&1 || true
    fi
    return 0
}

# Spinner animado com ETA. Roda um comando em background, mostra tempo
# decorrido e estimativa. Em TTY (ssh -t) anima; sem TTY printa uma linha.
SPINNER_CHARS=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')

run_with_spinner() {
    # run_with_spinner "label visível" eta_segundos "comando completo"
    local label="$1" eta="${2:-60}" cmd="$3"
    local start logfile pid code total
    start=$(date +%s)
    logfile=$(mktemp /tmp/rh-task-XXXXXX.log)

    # Roda em background, captura todo o output (stdout+stderr) no logfile
    bash -c "$cmd" > "$logfile" 2>&1 &
    pid=$!

    if [ -t 1 ]; then
        # TTY: spinner animado
        local i=0 elapsed pct char
        while kill -0 "$pid" 2>/dev/null; do
            elapsed=$(( $(date +%s) - start ))
            pct=$(( elapsed * 100 / eta ))
            [ $pct -gt 99 ] && pct=99
            char="${SPINNER_CHARS[$((i % ${#SPINNER_CHARS[@]}))]}"
            printf "\r  ${C_C}%s${C_N} %s  ${C_DIM}%ss decorridos / ~%ss estimado (%s%%)${C_N}    " \
                   "$char" "$label" "$elapsed" "$eta" "$pct"
            sleep 0.12
            i=$((i + 1))
        done
    else
        # Sem TTY: uma linha só, depois espera
        printf "  ${C_C}...${C_N} %s ${C_DIM}(estimado: %ss)${C_N}\n" "$label" "$eta"
    fi

    code=0
    wait "$pid" || code=$?
    total=$(( $(date +%s) - start ))

    if [ "$code" -eq 0 ]; then
        printf "\r  ${C_G}✓${C_N} %s  ${C_DIM}(%ss)${C_N}                                                              \n" "$label" "$total"
        rm -f "$logfile"
        return 0
    fi

    printf "\r  ${C_R}✗${C_N} %s  ${C_DIM}(falhou em %ss)${C_N}                                                       \n" "$label" "$total"
    err "Últimas 30 linhas do log:"
    sed 's/^/    /' < "$logfile" | tail -30
    err "Log completo: $logfile"
    return "$code"
}

# Banner explicativo ANTES de cada download — avisa tamanho, tempo e que NÃO
# deve fechar a janela. Faz a experiência ficar previsível: o cliente não fica
# olhando o spinner sem saber se vai durar 30 segundos ou 8 minutos.
print_download_banner() {
    # print_download_banner "Nome da stack" "imagens/tamanho" "tempo estimado"
    local name="$1" size="$2" time="$3"
    echo
    echo "  ${C_M}╭─────────────────────────────────────────────────────────────────────╮${C_N}"
    printf "  ${C_M}│${C_N}  ${C_W}📦  Instalando: %-52s${C_M}│${C_N}\n" "$name"
    echo "  ${C_M}│${C_N}                                                                     ${C_M}│${C_N}"
    printf "  ${C_M}│${C_N}     ${C_C}•${C_N} Imagens:        ${C_DIM}%-49s${C_M}│${C_N}\n" "$size"
    printf "  ${C_M}│${C_N}     ${C_C}•${C_N} Tempo estimado: ${C_DIM}%-49s${C_M}│${C_N}\n" "$time"
    echo "  ${C_M}│${C_N}                                                                     ${C_M}│${C_N}"
    echo "  ${C_M}│${C_N}     ${C_Y}⚠  NÃO feche esta janela — o download segue em andamento${C_N}      ${C_M}│${C_N}"
    echo "  ${C_M}│${C_N}     ${C_DIM}(pode parecer parado durante a extração — é normal)${C_N}          ${C_M}│${C_N}"
    echo "  ${C_M}╰─────────────────────────────────────────────────────────────────────╯${C_N}"
    echo
}

# Imprime o prompt via printf em STDOUT (em vez de `read -p` que escreve em
# stderr e fica buffered em SSH não-interativo). Assim a pergunta SEMPRE
# aparece no terminal antes do read bloquear — o usuário sabe que tem que
# digitar algo. Sem isso a UX ficava invisível em ssh sem -t.

ask_input() {
    # ask_input "Texto da pergunta" [valor padrão]
    # IMPORTANTE: termina com `return 0` explícito — sem isso o último
    # `[ -z "$REPLY" ] && ...` pode retornar 1 quando o usuário digita algo,
    # o que com `set -e` no topo do script MATARIA o processo na hora
    # (SSH fecharia a conexão sem aviso). Esse era o bug do "saiu sem pedir
    # o menu" depois do e-mail.
    local prompt="$1" default="${2:-}"
    echo
    if [ -n "$default" ]; then
        printf "  ${C_C}?${C_N} ${C_W}%s${C_N}\n" "$prompt"
        printf "    ${C_DIM}(padrão: %s — ENTER aceita)${C_N}\n" "$default"
    else
        printf "  ${C_C}?${C_N} ${C_W}%s${C_N}\n" "$prompt"
    fi
    printf "  ${C_C}>${C_N} "
    read -r REPLY || true
    if [ -z "$REPLY" ] && [ -n "$default" ]; then
        REPLY="$default"
    fi
    return 0
}

confirm() {
    # confirm "pergunta sim/não"  → retorna 0 se sim
    echo
    printf "  ${C_C}?${C_N} ${C_W}%s${C_N}\n" "$1"
    printf "    ${C_DIM}[s = sim, n = não — ENTER assume não]${C_N}\n"
    printf "  ${C_C}>${C_N} "
    local r
    read -r r
    [[ "$r" =~ ^[sSyY] ]]
}

# Valida formato de subdomínio. Pega erro comum de digitar e-mail em vez de
# subdomínio (portainer@rollinhost.com.br ← errado).
validate_domain() {
    local d
    d=$(echo "$1" | tr '[:upper:]' '[:lower:]' | xargs)  # lowercase + trim
    if [[ "$d" == *@* ]]; then
        err "❌ '$d' parece um e-mail (tem @). Pra subdomínio use ponto:"
        err "   formato correto:  subdominio.seudominio.com.br"
        err "   (ex: portainer.rollinhost.com.br — com PONTO, não @)"
        return 1
    fi
    if ! [[ "$d" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$ ]]; then
        err "❌ Domínio inválido: '$d'"
        err "   Use só letras minúsculas, números, hífen e ponto."
        err "   Formato: subdominio.dominio.com.br"
        return 1
    fi
    return 0
}

# Pede um subdomínio, valida o formato e mostra uma tela de CONFIRMAÇÃO com
# opção de corrigir antes de prosseguir — assim o usuário pega erro de digitação
# na hora, não lá no fim quando a URL não funciona. Devolve o valor já limpo
# (minúsculas + sem espaços) em REPLY. Retorna 1 se deixar em branco (= pular).
ask_domain() {
    local prompt="$1" d
    while true; do
        ask_input "$prompt"
        d=$(printf '%s' "$REPLY" | tr '[:upper:]' '[:lower:]' | xargs)
        if [ -z "$d" ]; then
            REPLY=""; return 1
        fi
        if ! validate_domain "$d"; then
            warn "Vamos digitar de novo."
            continue
        fi
        echo
        echo "  ${C_W}Confira o subdomínio digitado:${C_N}"
        echo "    ${C_C}➜  $d${C_N}"
        if confirm "Está correto?"; then
            REPLY="$d"
            return 0
        fi
        warn "Sem problema — digita de novo, com calma."
    done
}

dns_warning() {
    # Aceita 1 OU MAIS domínios como argumentos separados (ex: Typebot passa
    # builder + viewer). Valida cada um antes de avisar. NÃO passar os domínios
    # numa string só ("a e b") — quebra a validação.
    local d
    for d in "$@"; do
        validate_domain "$d" || return 1
    done

    # Avisa que precisa apontar DNS antes de instalar uma stack com domínio
    echo
    echo "  ${C_Y}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${C_N}"
    echo "  ${C_Y}⚠  ATENÇÃO — passo obrigatório antes de continuar${C_N}"
    echo "  ${C_Y}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${C_N}"
    echo
    if [ "$#" -gt 1 ]; then
        echo "  Aponte ${C_W}estes domínios${C_N} pro IP desta VPS:"
        for d in "$@"; do echo "    • ${C_W}$d${C_N}"; done
    else
        echo "  Aponte o domínio ${C_W}$1${C_N} pro IP desta VPS:"
    fi
    echo "    ↳ registro tipo ${C_W}A${C_N} no seu DNS (Cloudflare, Registro.br, etc.)"
    echo "    ↳ ${C_R}NÃO use proxy do Cloudflare (nuvem laranja)${C_N} — deixa em DNS-only"
    echo "    ↳ espere ~30 segundos pra propagar"
    echo
    echo "  ${C_DIM}Sem isso, o Let's Encrypt não consegue emitir o HTTPS.${C_N}"
    confirm "Já apontou o DNS e ele propagou?" \
        || { warn "Tranquilo — aponta o DNS e volta ao menu quando estiver pronto."; return 1; }
    return 0
}

# ---------- Swap ----------
setup_swap() {
    hr "Swap"
    if swapon --show 2>/dev/null | grep -q '/'; then log "Swap já configurado"; return; fi
    local mem_mb; mem_mb=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo)
    if [ "$mem_mb" -ge 4096 ]; then log "RAM ${mem_mb}MB suficiente — swap pulado"; return; fi
    local size="2G"; [ "$mem_mb" -lt 2048 ] && size="4G"
    fallocate -l "$size" /swapfile
    chmod 600 /swapfile
    mkswap /swapfile >/dev/null
    swapon /swapfile
    grep -q '/swapfile' /etc/fstab || echo "/swapfile none swap sw 0 0" >> /etc/fstab
    log "Swap de $size criado"
}

# ---------- Firewall ----------
setup_firewall() {
    hr "Firewall (UFW)"
    ufw --force reset >/dev/null
    ufw default deny incoming >/dev/null
    ufw default allow outgoing >/dev/null
    ufw allow 22/tcp  comment 'ssh'   >/dev/null
    ufw allow 80/tcp  comment 'http'  >/dev/null
    ufw allow 443/tcp comment 'https' >/dev/null
    ufw --force enable >/dev/null
    log "UFW ativo (22, 80, 443)"
}

# ---------- Banner MOTD (login SSH) ----------
# Instala um banner Rollin Host que aparece a cada login SSH (em vez do MOTD
# padrão da Contabo/Hetzner). Identidade visual na sessão do cliente sempre.
setup_motd() {
    # Roda silencioso — o cliente vê o resultado naturalmente no próximo login
    # SSH (banner Rollin) e no prompt (rln-vps-XXX). Sem cabeçalho de seção.

    # Hostname: troca pré-fixos de provedor (Contabo/Hetzner/etc.) por rln-vps-*.
    # Cliente NÃO deve ver "vmi3313312" no prompt — tem que ver "rln-vps-XXX".
    local cur new_hn id
    cur=$(hostname)
    case "$cur" in
        rln-vps-*|rollin-*)
            : # já está branded, nada a fazer
            ;;
        vmi*|v22*|*.contaboserver.net|*hetzner*|*.your-server.de|ubuntu*|cloud*|debian*|localhost*)
            # Extrai número do hostname provedor, ou gera aleatório
            id=$(echo "$cur" | grep -oE '[0-9]+' | head -1)
            [ -z "$id" ] && id=$(tr -dc '0-9' </dev/urandom | head -c 5)
            new_hn="rln-vps-$id"
            hostnamectl set-hostname "$new_hn" 2>/dev/null || hostname "$new_hn"
            # Atualiza /etc/hosts pra o novo hostname resolver (evita warning sudo)
            if grep -q "127.0.1.1" /etc/hosts; then
                sed -i "s/127.0.1.1.*/127.0.1.1 $new_hn/" /etc/hosts
            else
                echo "127.0.1.1 $new_hn" >> /etc/hosts
            fi
            ;;
        *)
            : # hostname customizado pelo cliente, respeitar
            ;;
    esac

    # Banner em formato CAIXA — idêntico ao que o módulo Contabo do WHMCS
    # injeta via cloud-init (contabo.php:462). Mantém UX consistente entre
    # VPS provisionada pelo painel Rollin e VPS instalada manualmente via
    # setup.sh. O grande banner ASCII só aparece DURANTE o install (one-shot);
    # esse aqui é o de cada login.
    cat > /etc/update-motd.d/00-rollin-host <<'MOTD_SCRIPT'
#!/bin/bash
printf "\n"
printf "  +------------------------------------------+\n"
printf "  |                                          |\n"
printf "  |          R O L L I N   H O S T           |\n"
printf "  |          ---------------------           |\n"
printf "  |          Cloud Server  -  Brasil         |\n"
printf "  |                                          |\n"
printf "  |          painel.rollinhost.com.br        |\n"
printf "  |          contato@rollinhost.com.br       |\n"
printf "  |                                          |\n"
printf "  +------------------------------------------+\n"
printf "\n"
MOTD_SCRIPT
    chmod +x /etc/update-motd.d/00-rollin-host

    # Cria /etc/issue.net (banner pré-login, opcional mas adiciona presença)
    cat > /etc/issue.net <<'ISSUE'
Rollin Host Cloud Server
Acesso autorizado somente. Atividades sao registradas.
ISSUE

    # Zera o /etc/motd estático (alguns sistemas exibem ele junto).
    : > /etc/motd

    # Remove banners de outros provedores (Contabo, Hetzner) que aparecem
    # na pasta — mantém só os helpers padrão do Ubuntu e o nosso.
    find /etc/update-motd.d/ -type f \
        \( -iname "*contabo*" -o -iname "*hetzner*" -o -iname "*aws*" -o -iname "*provider*" \) \
        -delete 2>/dev/null || true

    # Desliga a "Canonical news" feed (texto promocional que polui o MOTD).
    [ -f /etc/default/motd-news ] && \
        sed -i 's/^ENABLED=1/ENABLED=0/' /etc/default/motd-news 2>/dev/null || true
}

# ---------- Docker ----------
install_docker() {
    hr "Docker + Compose"
    if command -v docker >/dev/null 2>&1; then
        log "Docker já presente: $(docker --version)"
    else
        curl -fsSL https://get.docker.com | sh >/dev/null 2>&1
        systemctl enable --now docker
        log "Docker instalado"
    fi
    docker compose version >/dev/null 2>&1 || { err "Docker Compose v2 não encontrado"; exit 1; }
    docker network inspect "$NET" >/dev/null 2>&1 \
        || docker network create "$NET" >/dev/null
    log "Rede docker '$NET' pronta"
}

# ============================================================================
# STACKS — cada install_<nome>() é auto-contida:
#   - pede o(s) subdomínio(s)
#   - cria pasta /opt/rollin/<nome>/
#   - gera senhas aleatórias e salva em $CRED_FILE
#   - escreve docker-compose.yml com labels do Traefik (HTTPS auto)
#   - sobe a stack
# ============================================================================

# ---------- 01 Traefik ----------
install_traefik() {
    hr "01 · Traefik (reverse proxy + HTTPS)"
    if docker ps -a --format '{{.Names}}' | grep -q '^rollin-traefik$'; then
        log "Traefik já instalado"; return
    fi
    mkdir -p /opt/rollin/traefik/letsencrypt
    touch /opt/rollin/traefik/letsencrypt/acme.json
    chmod 600 /opt/rollin/traefik/letsencrypt/acme.json

    # Dashboard do Traefik é OPCIONAL: dá uma UI web com routers, certs e
    # serviços (útil pra debug), mas adiciona uma URL admin a mais. Protegida
    # por basic auth gerada automaticamente. Default: NÃO expor.
    local dash_domain="" dash_user="" dash_pwd="" dash_hash=""
    local dash_extra_cmd="" dash_labels=""
    if confirm "Expor o dashboard do Traefik num subdomínio? (debug visual de routers e certs)"; then
        if ask_domain "Subdomínio do dashboard (ex: traefik.seudominio.com.br):"; then
            dash_domain="$REPLY"
        else
            dash_domain=""
        fi
        if [ -n "$dash_domain" ] && dns_warning "$dash_domain"; then
            dash_user="admin"
            dash_pwd=$(rand_pwd 16)
            # htpasswd gera hash bcrypt com $$ — dentro do docker-compose
            # o `$` precisa virar `$$` pra não ser interpretado como variável.
            dash_hash=$(htpasswd -nbB "$dash_user" "$dash_pwd" | sed -e 's/\$/\$\$/g')
            dash_extra_cmd=$'\n      - --api.dashboard=true\n      - --api.insecure=false'
            dash_labels=$(cat <<LABELS

    labels:
      - traefik.enable=true
      - traefik.http.routers.dashboard.rule=Host(\`$dash_domain\`)
      - traefik.http.routers.dashboard.entrypoints=websecure
      - traefik.http.routers.dashboard.tls.certresolver=le
      - traefik.http.routers.dashboard.service=api@internal
      - traefik.http.routers.dashboard.middlewares=dashboard-auth
      - traefik.http.middlewares.dashboard-auth.basicauth.users=$dash_hash
LABELS
)
        else
            warn "Dashboard pulado — Traefik vai subir só como proxy"
            dash_domain=""
        fi
    fi

    cat > /opt/rollin/traefik/docker-compose.yml <<COMPOSE
services:
  traefik:
    # traefik:v3.7.1 — as versões 3.4.x e anteriores travavam a negociação de
    # versão da API Docker em 1.24, que o daemon 28+/29+ RECUSA ("client version
    # 1.24 is too old, minimum is 1.40") deixando o Traefik cego pros containers
    # (sem router, sem cert, 404/cert default). A 3.7.x corrigiu a negociação e
    # resolve sozinha, sem precisar mexer no daemon. NÃO usar :latest (muda sem
    # aviso e pode quebrar) — versão fixa garante reprodutibilidade.
    image: traefik:v3.7.1
    container_name: rollin-traefik
    restart: unless-stopped
    ports: ["80:80", "443:443"]
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./letsencrypt:/letsencrypt
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=$NET
      - --entrypoints.web.address=:80
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.web.http.redirections.entrypoint.scheme=https
      - --entrypoints.websecure.address=:443
      - --certificatesresolvers.le.acme.tlschallenge=true
      - --certificatesresolvers.le.acme.email=$ACME_EMAIL
      - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json${ACME_CASERVER_LINE}${dash_extra_cmd}${dash_labels}
    networks: [rollin]

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Traefik" "1 imagem · ~150 MB" "30 a 60 segundos"
    run_with_spinner "Subindo Traefik" 60 \
        "cd /opt/rollin/traefik && docker compose up -d --quiet-pull"

    if [ -n "$dash_domain" ]; then
        save_cred "Traefik (dashboard)" \
            "url=https://$dash_domain" \
            "user=$dash_user" \
            "password=$dash_pwd"
        log "Traefik rodando — dashboard: https://$dash_domain  user=$dash_user"
    else
        log "Traefik rodando — HTTPS automático pras próximas stacks"
    fi
}

# ---------- 02 Portainer ----------
install_portainer() {
    hr "02 · Portainer"
    local domain
    ask_domain "Subdomínio do Portainer (ex: portainer.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    mkdir -p /opt/rollin/portainer
    cat > /opt/rollin/portainer/docker-compose.yml <<COMPOSE
services:
  portainer:
    image: portainer/portainer-ce:latest
    container_name: rollin-portainer
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - portainer_data:/data
    labels:
      - traefik.enable=true
      - traefik.http.routers.portainer.rule=Host(\`$domain\`)
      - traefik.http.routers.portainer.entrypoints=websecure
      - traefik.http.routers.portainer.tls.certresolver=le
      - traefik.http.services.portainer.loadbalancer.server.port=9000
    networks: [rollin]

volumes:
  portainer_data:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Portainer" "1 imagem · ~120 MB" "30 a 60 segundos"
    run_with_spinner "Subindo Portainer" 60 \
        "cd /opt/rollin/portainer && docker compose up -d --quiet-pull"
    save_cred "Portainer" "url=https://$domain" "setup=crie admin na primeira visita (≤5 min)"
    log "Portainer no ar: https://$domain"
}

# ---------- 03 n8n ----------
install_n8n() {
    hr "03 · n8n"
    local domain
    ask_domain "Subdomínio do n8n (ex: n8n.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    local pg_pwd  n8n_user="admin"  n8n_pwd  n8n_enc
    pg_pwd=$(rand_pwd 24); n8n_pwd=$(rand_pwd 16); n8n_enc=$(rand_pwd 40)

    mkdir -p /opt/rollin/n8n
    cat > /opt/rollin/n8n/docker-compose.yml <<COMPOSE
services:
  postgres:
    image: postgres:16-alpine
    container_name: rollin-n8n-pg
    restart: unless-stopped
    environment:
      POSTGRES_DB: n8n
      POSTGRES_USER: n8n
      POSTGRES_PASSWORD: $pg_pwd
    volumes: [n8n_pg:/var/lib/postgresql/data]
    networks: [rollin]

  n8n:
    image: n8nio/n8n:latest
    container_name: rollin-n8n
    restart: unless-stopped
    depends_on: [postgres]
    environment:
      N8N_HOST: $domain
      N8N_PORT: 5678
      N8N_PROTOCOL: https
      WEBHOOK_URL: https://$domain/
      N8N_EDITOR_BASE_URL: https://$domain/
      GENERIC_TIMEZONE: $TZ_DEFAULT
      TZ: $TZ_DEFAULT
      N8N_ENCRYPTION_KEY: $n8n_enc
      N8N_BASIC_AUTH_ACTIVE: "true"
      N8N_BASIC_AUTH_USER: $n8n_user
      N8N_BASIC_AUTH_PASSWORD: $n8n_pwd
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: n8n
      DB_POSTGRESDB_PASSWORD: $pg_pwd
    volumes: [n8n_data:/home/node/.n8n]
    labels:
      - traefik.enable=true
      - traefik.http.routers.n8n.rule=Host(\`$domain\`)
      - traefik.http.routers.n8n.entrypoints=websecure
      - traefik.http.routers.n8n.tls.certresolver=le
      - traefik.http.services.n8n.loadbalancer.server.port=5678
    networks: [rollin]

volumes:
  n8n_data:
  n8n_pg:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    # Descarta PGDATA órfão de instalação anterior (senha nova não casaria).
    drop_orphan_volume "n8n_n8n_pg" "rollin-n8n-pg"

    print_download_banner "n8n + Postgres" "2 imagens · ~750 MB" "2 a 4 minutos"
    run_with_spinner "Subindo n8n + Postgres" 240 \
        "cd /opt/rollin/n8n && docker compose up -d --quiet-pull"
    save_cred "n8n" "url=https://$domain" "user=$n8n_user" "password=$n8n_pwd" "encryption_key=$n8n_enc" "postgres_pwd=$pg_pwd"
    log "n8n no ar: https://$domain  user=$n8n_user  pwd=$n8n_pwd"
}

# ---------- 04 Evolution API ----------
install_evolution() {
    hr "04 · Evolution API (WhatsApp)"
    local domain
    ask_domain "Subdomínio da Evolution (ex: evolution.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    # Evolution API v2: ao contrário da v1 (standalone), a v2 EXIGE Postgres +
    # Redis. Sem `DATABASE_PROVIDER` válido ela entra em loop de crash
    # ("Error: Database provider invalid") e nem fica de pé → 000/404.
    local api_key pg_pwd redis_pwd
    api_key=$(rand_pwd 32); pg_pwd=$(rand_pwd 24); redis_pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/evolution
    cat > /opt/rollin/evolution/docker-compose.yml <<COMPOSE
services:
  evo-postgres:
    image: postgres:16-alpine
    container_name: rollin-evo-pg
    restart: unless-stopped
    environment:
      POSTGRES_DB: evolution
      POSTGRES_USER: evolution
      POSTGRES_PASSWORD: $pg_pwd
    volumes: [evo_pg:/var/lib/postgresql/data]
    networks: [rollin]

  evo-redis:
    image: redis:7-alpine
    container_name: rollin-evo-redis
    restart: unless-stopped
    command: ["redis-server", "--requirepass", "$redis_pwd"]
    networks: [rollin]

  evolution:
    # v2 fixa (NÃO :latest) pra reprodutibilidade pros colaboradores.
    image: atendai/evolution-api:v2.1.1
    container_name: rollin-evolution
    restart: unless-stopped
    depends_on: [evo-postgres, evo-redis]
    environment:
      SERVER_URL: https://$domain
      AUTHENTICATION_API_KEY: $api_key
      # Banco (obrigatório na v2)
      DATABASE_ENABLED: "true"
      DATABASE_PROVIDER: postgresql
      DATABASE_CONNECTION_URI: postgresql://evolution:$pg_pwd@evo-postgres:5432/evolution?schema=public
      DATABASE_CONNECTION_CLIENT_NAME: evolution
      DATABASE_SAVE_DATA_INSTANCE: "true"
      DATABASE_SAVE_DATA_NEW_MESSAGE: "true"
      DATABASE_SAVE_MESSAGE_UPDATE: "true"
      DATABASE_SAVE_DATA_CONTACTS: "true"
      DATABASE_SAVE_DATA_CHATS: "true"
      # Cache em Redis (recomendado na v2)
      CACHE_REDIS_ENABLED: "true"
      CACHE_REDIS_URI: redis://default:$redis_pwd@evo-redis:6379/6
      CACHE_REDIS_PREFIX_KEY: evolution
      CACHE_REDIS_SAVE_INSTANCES: "false"
      CACHE_LOCAL_ENABLED: "false"
      QRCODE_LIMIT: "30"
      LANGUAGE: pt-BR
    volumes:
      - evolution_instances:/evolution/instances
    labels:
      - traefik.enable=true
      - traefik.http.routers.evolution.rule=Host(\`$domain\`)
      - traefik.http.routers.evolution.entrypoints=websecure
      - traefik.http.routers.evolution.tls.certresolver=le
      - traefik.http.services.evolution.loadbalancer.server.port=8080
    networks: [rollin]

volumes:
  evo_pg:
  evolution_instances:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    # Descarta PGDATA órfão de instalação anterior (senha nova não casaria).
    drop_orphan_volume "evolution_evo_pg" "rollin-evo-pg"

    print_download_banner "Evolution API v2 (API + Postgres + Redis)" "3 imagens · ~600 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Evolution API (roda migração do banco)" 180 \
        "cd /opt/rollin/evolution && docker compose up -d --quiet-pull"
    # URL aponta pro /manager — a UI da v2 (a raiz / não serve interface).
    save_cred "Evolution API" "url=https://$domain/manager" "api_key=$api_key" "postgres_pwd=$pg_pwd" "redis_pwd=$redis_pwd"
    log "Evolution API no ar: https://$domain/manager  (API key salva)"
}

# ---------- 05 Chatwoot ----------
install_chatwoot() {
    hr "05 · Chatwoot (atendimento omnichannel)"
    local domain
    ask_domain "Subdomínio do Chatwoot (ex: chat.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    local pg_pwd  redis_pwd  secret
    pg_pwd=$(rand_pwd 24); redis_pwd=$(rand_pwd 24); secret=$(rand_pwd 64)
    mkdir -p /opt/rollin/chatwoot
    cat > /opt/rollin/chatwoot/docker-compose.yml <<COMPOSE
services:
  cw-postgres:
    # pgvector/pgvector:pg16 — Chatwoot 3+ usa a extensao vector no schema
    # (PG::UndefinedFile: vector.control). A imagem postgres:14-alpine padrao
    # NAO tem essa extensao. Essa imagem oficial do pgvector ja vem com tudo.
    image: pgvector/pgvector:pg16
    container_name: rollin-cw-pg
    restart: unless-stopped
    environment:
      POSTGRES_DB: chatwoot
      POSTGRES_USER: chatwoot
      POSTGRES_PASSWORD: $pg_pwd
    volumes: [cw_pg:/var/lib/postgresql/data]
    networks: [rollin]

  cw-redis:
    image: redis:7-alpine
    container_name: rollin-cw-redis
    restart: unless-stopped
    command: ["redis-server", "--requirepass", "$redis_pwd"]
    networks: [rollin]

  chatwoot:
    image: chatwoot/chatwoot:latest
    container_name: rollin-chatwoot
    restart: unless-stopped
    depends_on: [cw-postgres, cw-redis]
    entrypoint: docker/entrypoints/rails.sh
    command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"]
    environment:
      RAILS_ENV: production
      FRONTEND_URL: https://$domain
      SECRET_KEY_BASE: $secret
      POSTGRES_HOST: cw-postgres
      POSTGRES_USERNAME: chatwoot
      POSTGRES_PASSWORD: $pg_pwd
      POSTGRES_DATABASE: chatwoot
      REDIS_URL: redis://:$redis_pwd@cw-redis:6379
      DEFAULT_LOCALE: pt_BR
      TZ: $TZ_DEFAULT
    volumes: [cw_storage:/app/storage]
    labels:
      - traefik.enable=true
      - traefik.http.routers.chatwoot.rule=Host(\`$domain\`)
      - traefik.http.routers.chatwoot.entrypoints=websecure
      - traefik.http.routers.chatwoot.tls.certresolver=le
      - traefik.http.services.chatwoot.loadbalancer.server.port=3000
    networks: [rollin]

  chatwoot-worker:
    image: chatwoot/chatwoot:latest
    container_name: rollin-cw-worker
    restart: unless-stopped
    depends_on: [cw-postgres, cw-redis, chatwoot]
    entrypoint: docker/entrypoints/rails.sh
    command: ["bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml"]
    environment:
      RAILS_ENV: production
      SECRET_KEY_BASE: $secret
      POSTGRES_HOST: cw-postgres
      POSTGRES_USERNAME: chatwoot
      POSTGRES_PASSWORD: $pg_pwd
      POSTGRES_DATABASE: chatwoot
      REDIS_URL: redis://:$redis_pwd@cw-redis:6379
    volumes: [cw_storage:/app/storage]
    networks: [rollin]

volumes:
  cw_pg:
  cw_storage:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    # Descarta PGDATA órfão de instalação anterior, senão a senha nova gerada
    # acima não bate com o banco já inicializado (password authentication failed).
    drop_orphan_volume "chatwoot_cw_pg" "rollin-cw-pg"

    # Chatwoot é o pesado — imagem ~1.2GB + postgres + redis. O `docker compose
    # run` aqui dispara o pull dos 3, depois roda o rails db:chatwoot_prepare.
    print_download_banner "Chatwoot (Rails + Postgres + Redis)" "3 imagens · ~1.5 GB" "5 a 8 minutos"
    run_with_spinner "Baixando imagens e preparando banco do Chatwoot" 420 \
        "cd /opt/rollin/chatwoot && docker compose run --rm chatwoot bundle exec rails db:chatwoot_prepare"
    run_with_spinner "Subindo Chatwoot (web + worker)" 60 \
        "cd /opt/rollin/chatwoot && docker compose up -d --quiet-pull"
    save_cred "Chatwoot" "url=https://$domain" "postgres_pwd=$pg_pwd" "redis_pwd=$redis_pwd" "secret_key=$secret" "setup=crie superadmin em https://$domain/installation/onboarding"
    log "Chatwoot no ar: https://$domain (crie o superadmin no primeiro acesso)"
}

# ---------- 06 Typebot ----------
install_typebot() {
    hr "06 · Typebot (builder de chatbots)"
    local b_domain v_domain
    ask_domain "Subdomínio do BUILDER do Typebot (ex: builder.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    b_domain="$REPLY"
    ask_domain "Subdomínio do VIEWER do Typebot (ex: bot.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    v_domain="$REPLY"
    dns_warning "$b_domain" "$v_domain" || return 0

    local pg_pwd  enc_key
    pg_pwd=$(rand_pwd 24); enc_key=$(rand_pwd 32)
    mkdir -p /opt/rollin/typebot
    cat > /opt/rollin/typebot/docker-compose.yml <<COMPOSE
services:
  tb-postgres:
    image: postgres:16-alpine
    container_name: rollin-tb-pg
    restart: unless-stopped
    environment:
      POSTGRES_DB: typebot
      POSTGRES_USER: typebot
      POSTGRES_PASSWORD: $pg_pwd
    volumes: [tb_pg:/var/lib/postgresql/data]
    networks: [rollin]

  typebot-builder:
    image: baptistearno/typebot-builder:latest
    container_name: rollin-tb-builder
    restart: unless-stopped
    depends_on: [tb-postgres]
    environment:
      DATABASE_URL: postgresql://typebot:$pg_pwd@tb-postgres:5432/typebot
      ENCRYPTION_SECRET: $enc_key
      NEXTAUTH_URL: https://$b_domain
      NEXT_PUBLIC_VIEWER_URL: https://$v_domain
      DISABLE_SIGNUP: "false"
    labels:
      - traefik.enable=true
      - traefik.http.routers.tb-builder.rule=Host(\`$b_domain\`)
      - traefik.http.routers.tb-builder.entrypoints=websecure
      - traefik.http.routers.tb-builder.tls.certresolver=le
      - traefik.http.services.tb-builder.loadbalancer.server.port=3000
    networks: [rollin]

  typebot-viewer:
    image: baptistearno/typebot-viewer:latest
    container_name: rollin-tb-viewer
    restart: unless-stopped
    depends_on: [tb-postgres]
    environment:
      DATABASE_URL: postgresql://typebot:$pg_pwd@tb-postgres:5432/typebot
      ENCRYPTION_SECRET: $enc_key
      NEXTAUTH_URL: https://$b_domain
      NEXT_PUBLIC_VIEWER_URL: https://$v_domain
    labels:
      - traefik.enable=true
      - traefik.http.routers.tb-viewer.rule=Host(\`$v_domain\`)
      - traefik.http.routers.tb-viewer.entrypoints=websecure
      - traefik.http.routers.tb-viewer.tls.certresolver=le
      - traefik.http.services.tb-viewer.loadbalancer.server.port=3000
    networks: [rollin]

volumes:
  tb_pg:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    # Descarta PGDATA órfão de instalação anterior (senha nova não casaria).
    drop_orphan_volume "typebot_tb_pg" "rollin-tb-pg"

    print_download_banner "Typebot (builder + viewer + Postgres)" "3 imagens · ~1.0 GB" "3 a 5 minutos"
    run_with_spinner "Subindo Typebot (builder + viewer + Postgres)" 300 \
        "cd /opt/rollin/typebot && docker compose up -d --quiet-pull"
    save_cred "Typebot" "builder=https://$b_domain" "viewer=https://$v_domain" "encryption_secret=$enc_key" "postgres_pwd=$pg_pwd"
    log "Typebot no ar: builder https://$b_domain · viewer https://$v_domain"
}

# ---------- 07 Flowise ----------
install_flowise() {
    hr "07 · Flowise (orquestração LLM)"
    local domain
    ask_domain "Subdomínio do Flowise (ex: flowise.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    local fw_user="admin"; local fw_pwd; fw_pwd=$(rand_pwd 16)
    mkdir -p /opt/rollin/flowise
    cat > /opt/rollin/flowise/docker-compose.yml <<COMPOSE
services:
  flowise:
    image: flowiseai/flowise:latest
    container_name: rollin-flowise
    restart: unless-stopped
    environment:
      PORT: 3000
      FLOWISE_USERNAME: $fw_user
      FLOWISE_PASSWORD: $fw_pwd
    volumes: [flowise_data:/root/.flowise]
    labels:
      - traefik.enable=true
      - traefik.http.routers.flowise.rule=Host(\`$domain\`)
      - traefik.http.routers.flowise.entrypoints=websecure
      - traefik.http.routers.flowise.tls.certresolver=le
      - traefik.http.services.flowise.loadbalancer.server.port=3000
    networks: [rollin]

volumes:
  flowise_data:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Flowise" "1 imagem · ~600 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Flowise" 180 \
        "cd /opt/rollin/flowise && docker compose up -d --quiet-pull"
    save_cred "Flowise" "url=https://$domain" "user=$fw_user" "password=$fw_pwd"
    log "Flowise no ar: https://$domain  user=$fw_user  pwd=$fw_pwd"
}

# ---------- 08 Uptime Kuma ----------
install_uptime_kuma() {
    hr "08 · Uptime Kuma (monitoramento)"
    local domain
    ask_domain "Subdomínio do Uptime Kuma (ex: status.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    mkdir -p /opt/rollin/uptime-kuma
    cat > /opt/rollin/uptime-kuma/docker-compose.yml <<COMPOSE
services:
  uptime-kuma:
    image: louislam/uptime-kuma:1
    container_name: rollin-uptime-kuma
    restart: unless-stopped
    volumes: [uptime_data:/app/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.uptime.rule=Host(\`$domain\`)
      - traefik.http.routers.uptime.entrypoints=websecure
      - traefik.http.routers.uptime.tls.certresolver=le
      - traefik.http.services.uptime.loadbalancer.server.port=3001
    networks: [rollin]

volumes:
  uptime_data:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Uptime Kuma" "1 imagem · ~250 MB" "1 a 2 minutos"
    run_with_spinner "Subindo Uptime Kuma" 120 \
        "cd /opt/rollin/uptime-kuma && docker compose up -d --quiet-pull"
    save_cred "Uptime Kuma" "url=https://$domain" "setup=crie admin no primeiro acesso"
    log "Uptime Kuma no ar: https://$domain (crie admin no 1º acesso)"
}

# ---------- 09 NocoDB ----------
install_nocodb() {
    hr "09 · NocoDB (banco no-code estilo Airtable)"
    local domain
    ask_domain "Subdomínio do NocoDB (ex: nocodb.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    local pg_pwd; pg_pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/nocodb
    cat > /opt/rollin/nocodb/docker-compose.yml <<COMPOSE
services:
  noco-postgres:
    image: postgres:16-alpine
    container_name: rollin-noco-pg
    restart: unless-stopped
    environment:
      POSTGRES_DB: nocodb
      POSTGRES_USER: nocodb
      POSTGRES_PASSWORD: $pg_pwd
    volumes: [noco_pg:/var/lib/postgresql/data]
    networks: [rollin]

  nocodb:
    image: nocodb/nocodb:latest
    container_name: rollin-nocodb
    restart: unless-stopped
    depends_on: [noco-postgres]
    environment:
      NC_DB: "pg://noco-postgres:5432?u=nocodb&p=$pg_pwd&d=nocodb"
      NC_PUBLIC_URL: https://$domain
    volumes: [noco_data:/usr/app/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.nocodb.rule=Host(\`$domain\`)
      - traefik.http.routers.nocodb.entrypoints=websecure
      - traefik.http.routers.nocodb.tls.certresolver=le
      - traefik.http.services.nocodb.loadbalancer.server.port=8080
    networks: [rollin]

volumes:
  noco_pg:
  noco_data:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    # Descarta PGDATA órfão de instalação anterior (senha nova não casaria).
    drop_orphan_volume "nocodb_noco_pg" "rollin-noco-pg"

    print_download_banner "NocoDB + Postgres" "2 imagens · ~950 MB" "3 a 4 minutos"
    run_with_spinner "Subindo NocoDB + Postgres" 240 \
        "cd /opt/rollin/nocodb && docker compose up -d --quiet-pull"
    save_cred "NocoDB" "url=https://$domain" "postgres_pwd=$pg_pwd" "setup=crie super-admin no 1º acesso"
    log "NocoDB no ar: https://$domain"
}

# ---------- 10 Open Claw ----------
# OpenClaw: hub de agentes IA (dashboard "Control UI" na porta 18789, que é o
# próprio gateway). Imagem pronta no ghcr. Sem banco. Token de acesso gerado.
# ATENÇÃO: a doc oficial não publica o compose — esta config é a melhor
# reconstrução a partir da doc; pode precisar de ajuste no 1º teste (bind
# address / command / onboarding). Ver [[project_rollin_setup_wizard]].
install_openclaw() {
    hr "10 · Open Claw (hub de agentes IA)"
    local domain
    ask_domain "Subdomínio do Open Claw (ex: openclaw.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    # A Control UI do Open Claw SEMPRE exige device pairing (trava nativa, a doc
    # confirma — sem bypass). Estratégia que FUNCIONA (validada): sobe com
    # --allow-unconfigured (modo estável), protegido pelo TOKEN do gateway; um
    # serviço auto-aprovador aprova o pareamento do navegador em segundos. NÃO
    # usamos basic auth do Traefik — ela causa popup de senha EM LOOP por causa
    # do WebSocket da Control UI (re-pede credencial a cada conexão).
    local token; token=$(rand_pwd 40)
    mkdir -p /opt/rollin/openclaw
    cat > /opt/rollin/openclaw/docker-compose.yml <<COMPOSE
services:
  openclaw:
    image: ghcr.io/openclaw/openclaw:latest
    container_name: rollin-openclaw
    restart: unless-stopped
    command: ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
    environment:
      OPENCLAW_GATEWAY_TOKEN: $token
      OPENCLAW_SKIP_ONBOARDING: "1"
      OPENCLAW_HOST: 0.0.0.0
      HOST: 0.0.0.0
    volumes:
      - openclaw_config:/home/node/.openclaw
      - openclaw_secrets:/home/node/.config/openclaw
    labels:
      - traefik.enable=true
      - traefik.http.routers.openclaw.rule=Host(\`$domain\`)
      - traefik.http.routers.openclaw.entrypoints=websecure
      - traefik.http.routers.openclaw.tls.certresolver=le
      - traefik.http.services.openclaw.loadbalancer.server.port=18789
    networks: [rollin]

volumes:
  openclaw_config:
  openclaw_secrets:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Open Claw" "1 imagem · ~500 MB" "2 a 3 minutos"
    # CORS: libera a origem pública (senão "origem do navegador não permitida").
    local oc_origins
    oc_origins='[{"path":"gateway.controlUi.allowedOrigins","value":["http://localhost:18789","http://127.0.0.1:18789","https://'"$domain"'"]}]'
    run_with_spinner "Configurando origens do Open Claw (Control UI)" 120 \
        "cd /opt/rollin/openclaw && docker compose run --rm openclaw node openclaw.mjs config set --batch-json '$oc_origins'"
    run_with_spinner "Subindo Open Claw" 60 \
        "cd /opt/rollin/openclaw && docker compose up -d --quiet-pull"

    # Auto-aprovador de pareamento. A doc oficial do Open Claw é categórica: a
    # Control UI no navegador SEMPRE exige device pairing manual — não há bypass
    # por config (trusted-proxy/autoApprove só valem pra role:node). Este serviço
    # aprova os pareamentos pendentes em segundos, tornando o acesso automático.
    # A proteção real é a basic auth do Traefik (só quem tem a senha gera pairing).
    cat > /opt/rollin/openclaw/auto-approve.sh <<'APPROVER'
#!/bin/bash
# Aprova pareamentos pendentes da Control UI do Open Claw em loop.
while true; do
  for id in $(docker exec rollin-openclaw node openclaw.mjs devices list 2>/dev/null \
              | grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'); do
    docker exec rollin-openclaw node openclaw.mjs devices approve "$id" >/dev/null 2>&1
  done
  sleep 3
done
APPROVER
    chmod +x /opt/rollin/openclaw/auto-approve.sh
    cat > /etc/systemd/system/rollin-openclaw-approver.service <<'UNIT'
[Unit]
Description=Rollin - auto-aprovador de pareamento do Open Claw
After=docker.service
Requires=docker.service

[Service]
ExecStart=/opt/rollin/openclaw/auto-approve.sh
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
UNIT
    systemctl daemon-reload >/dev/null 2>&1 || true
    systemctl enable --now rollin-openclaw-approver.service >/dev/null 2>&1 || true

    save_cred "Open Claw" "url=https://$domain" "api_key=$token" \
        "acesso=abra a URL, cole a api_key no campo 'Token do Gateway' e clique Conectar; o pareamento do navegador é aprovado automaticamente em ~3s (sem popup de senha)"
    log "Open Claw no ar: https://$domain (cole o token; pareamento auto-aprovado)"
}

# ---------- 11 Hermes ----------
# Hermes Agent (Nous Research): dashboard (9119) + gateway/API OpenAI-compatible
# (8642). Imagem pronta no Docker Hub. Sem banco — estado em /opt/data. Auth da
# API por API_SERVER_KEY (gerado). Expõe os dois em subdomínios separados.
install_hermes() {
    hr "11 · Hermes (agente IA — dashboard + gateway)"
    local dash_domain api_domain
    ask_domain "Subdomínio do DASHBOARD do Hermes (ex: hermes.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    dash_domain="$REPLY"
    ask_domain "Subdomínio do GATEWAY/API do Hermes (ex: hermes-api.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    api_domain="$REPLY"
    dns_warning "$dash_domain" "$api_domain" || return 0

    local api_key; api_key=$(rand_pwd 32)
    mkdir -p /opt/rollin/hermes
    cat > /opt/rollin/hermes/docker-compose.yml <<COMPOSE
services:
  hermes:
    image: nousresearch/hermes-agent:latest
    container_name: rollin-hermes
    restart: unless-stopped
    command: gateway run
    environment:
      HERMES_DASHBOARD: "1"
      HERMES_DASHBOARD_HOST: 0.0.0.0
      HERMES_DASHBOARD_PORT: "9119"
      API_SERVER_ENABLED: "true"
      API_SERVER_HOST: 0.0.0.0
      API_SERVER_KEY: $api_key
    volumes:
      - hermes_data:/opt/data
    labels:
      - traefik.enable=true
      # Dashboard → 9119
      - traefik.http.routers.hermes-dash.rule=Host(\`$dash_domain\`)
      - traefik.http.routers.hermes-dash.entrypoints=websecure
      - traefik.http.routers.hermes-dash.tls.certresolver=le
      - traefik.http.routers.hermes-dash.service=hermes-dash
      - traefik.http.services.hermes-dash.loadbalancer.server.port=9119
      # Gateway/API → 8642
      - traefik.http.routers.hermes-api.rule=Host(\`$api_domain\`)
      - traefik.http.routers.hermes-api.entrypoints=websecure
      - traefik.http.routers.hermes-api.tls.certresolver=le
      - traefik.http.routers.hermes-api.service=hermes-api
      - traefik.http.services.hermes-api.loadbalancer.server.port=8642
    networks: [rollin]

volumes:
  hermes_data:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Hermes Agent" "1 imagem · ~1.0 GB" "2 a 4 minutos"
    run_with_spinner "Subindo Hermes (dashboard + gateway)" 240 \
        "cd /opt/rollin/hermes && docker compose up -d --quiet-pull"
    # url=dashboard, viewer=api (o resumo mostra as 2 URLs + a API key)
    save_cred "Hermes Agent" "url=https://$dash_domain" "viewer=https://$api_domain" "api_key=$api_key"
    log "Hermes no ar: dashboard https://$dash_domain · gateway https://$api_domain"
}

# ============================================================================
# MENU
# ============================================================================

show_menu() {
    echo
    echo "${C_M}╔══════════════════════════════════════════════════════════════════════╗"
    echo "║  ${C_W}Catálogo Rollin Host${C_M} — escolha o que instalar:                    ║"
    echo "╚══════════════════════════════════════════════════════════════════════╝${C_N}"
    echo
    echo "  ${C_Y}[ 01 ]${C_N}  ${C_W}Traefik${C_N}         ${C_DIM}— reverse proxy + HTTPS automático ${C_G}(comece por ele)${C_N}"
    echo "  ${C_Y}[ 02 ]${C_N}  ${C_W}Portainer${C_N}       ${C_DIM}— painel web pra gerenciar Docker${C_N}"
    echo "  ${C_Y}[ 03 ]${C_N}  ${C_W}n8n${C_N}             ${C_DIM}— automação de fluxos + agentes IA${C_N}"
    echo "  ${C_Y}[ 04 ]${C_N}  ${C_W}Evolution API${C_N}   ${C_DIM}— integração com WhatsApp${C_N}"
    echo "  ${C_Y}[ 05 ]${C_N}  ${C_W}Chatwoot${C_N}        ${C_DIM}— atendimento omnichannel (chat, e-mail, redes)${C_N}"
    echo "  ${C_Y}[ 06 ]${C_N}  ${C_W}Typebot${C_N}         ${C_DIM}— builder visual de chatbots${C_N}"
    echo "  ${C_Y}[ 07 ]${C_N}  ${C_W}Flowise${C_N}         ${C_DIM}— orquestração de fluxos LLM (low-code)${C_N}"
    echo "  ${C_Y}[ 08 ]${C_N}  ${C_W}Uptime Kuma${C_N}     ${C_DIM}— monitoramento de uptime dos seus serviços${C_N}"
    echo "  ${C_Y}[ 09 ]${C_N}  ${C_W}NocoDB${C_N}          ${C_DIM}— banco no-code (estilo Airtable)${C_N}"
    echo "  ${C_Y}[ 10 ]${C_N}  ${C_W}Open Claw${C_N}       ${C_DIM}— hub de agentes IA (dashboard + gateway)${C_N}"
    echo "  ${C_Y}[ 11 ]${C_N}  ${C_W}Hermes${C_N}          ${C_DIM}— agente IA Nous Research (dashboard + API)${C_N}"
    echo
    echo "  ${C_R}[ 99 ]${C_N}  Sair (mostra resumo das credenciais)"
    echo
}

# Imprime UMA aplicação no resumo: nome + status (healthcheck) + URL(s) + como
# acessar (login/senha gerado, API key, ou "crie no 1º acesso"). Chamada pelo
# parser do show_summary a cada bloco do arquivo de credenciais.
_summary_emit_app() {
    local name="$1" url="$2" url2="$3" user="$4" pass="$5" apikey="$6" ck="$7"
    [ -z "$name" ] && return 0
    local code status=""
    if [ -n "$url" ]; then
        code=$(curl -s $ck -o /dev/null -w "%{http_code}" --max-time 6 "$url" 2>/dev/null)
        [ -z "$code" ] && code="000"
        case "$code" in
            2*|3*|401) status="${C_G}● no ar${C_N}" ;;
            000)       status="${C_Y}● iniciando (1–2 min)${C_N}" ;;
            *)         status="${C_Y}● HTTP $code${C_N}" ;;
        esac
    fi
    printf "  ${C_W}%s${C_N}  %b\n" "$name" "$status"
    [ -n "$url" ]  && printf "    ${C_C}%s${C_N}\n" "$url"
    [ -n "$url2" ] && printf "    ${C_C}%s${C_N}\n" "$url2"
    if [ -n "$user" ] && [ -n "$pass" ]; then
        printf "    🔑 ${C_W}login:${C_N} %s   ${C_W}senha:${C_N} %s\n" "$user" "$pass"
    elif [ -n "$apikey" ]; then
        printf "    🔑 ${C_W}API key:${C_N} %s\n" "$apikey"
    else
        printf "    🔑 ${C_DIM}crie o login/admin no primeiro acesso${C_N}\n"
    fi
    echo
}

show_summary() {
    hr "Resumo da instalação"

    if [ ! -s "$CRED_FILE" ]; then
        warn "Nenhuma stack foi instalada nesta sessão."
        footer
        return
    fi

    echo
    echo "  ${C_W}🔗 Suas aplicações — acesso e credenciais:${C_N}"
    [ -n "$ACME_STAGING" ] && echo "  ${C_Y}(modo staging: cert de teste — navegador vai reclamar, é esperado)${C_N}"
    echo
    # Em staging o cert é "de mentira"; -k faz o healthcheck aceitá-lo.
    local ck=""; [ -n "$ACME_STAGING" ] && ck="-k"
    # Acumula os campos de cada bloco e imprime quando bate o próximo "#" (ou fim).
    local name="" url="" url2="" user="" pass="" apikey="" line
    while IFS= read -r line; do
        case "$line" in
            "#"*)
                _summary_emit_app "$name" "$url" "$url2" "$user" "$pass" "$apikey" "$ck"
                name=$(printf '%s' "$line" | sed -E 's/^#[[:space:]]*[0-9-]+[[:space:]]+[0-9:]+[[:space:]]+//')
                url=""; url2=""; user=""; pass=""; apikey=""
                ;;
            *url=https*)      url=$(printf  '%s' "$line" | sed -E 's/^[[:space:]]*url=//') ;;
            *builder=https*)  url=$(printf  '%s' "$line" | sed -E 's/^[[:space:]]*builder=//') ;;
            *viewer=https*)   url2=$(printf '%s' "$line" | sed -E 's/^[[:space:]]*viewer=//') ;;
            *user=*)          user=$(printf '%s' "$line" | sed -E 's/^[[:space:]]*user=//') ;;
            *password=*)      pass=$(printf '%s' "$line" | sed -E 's/^[[:space:]]*password=//') ;;
            *api_key=*)       apikey=$(printf '%s' "$line" | sed -E 's/^[[:space:]]*api_key=//') ;;
        esac
    done < "$CRED_FILE"
    _summary_emit_app "$name" "$url" "$url2" "$user" "$pass" "$apikey" "$ck"   # último bloco

    echo "  ${C_DIM}Tudo (incl. senhas internas de banco) salvo em $CRED_FILE — veja com: cat $CRED_FILE${C_N}"
    echo "  ${C_DIM}Apps com '● iniciando' sobem em segundos — recarregue no navegador.${C_N}"

    footer
}

# ============================================================================
# MAIN
# ============================================================================
main() {
    banner "Wizard de Setup VPS — instale stacks de produção com 1 clique"
    if [ -n "$ACME_STAGING" ]; then
        warn "MODO STAGING ativo — certificados de TESTE (Let's Encrypt staging)."
        warn "Use só pra validar a instalação. Em produção, rode SEM RH_ACME_STAGING."
    fi
    require_root
    detect_os
    install_deps
    setup_swap
    setup_firewall
    setup_motd       # hostname Rollin + MOTD em caixa (esconde provedor)
    install_docker

    hr "Configuração inicial"
    echo
    echo "  Antes de escolher as stacks, vou te perguntar uma coisa só:"
    echo "    ${C_W}1.${C_N} ${C_W}Seu e-mail${C_N} ${C_DIM}— pra emissão de certificados HTTPS via Let's Encrypt${C_N}"
    echo
    echo "  ${C_DIM}Depois, pra cada stack escolhida, eu pergunto o subdomínio dela${C_N}"
    echo "  ${C_DIM}(ex: n8n.seudominio.com.br). Cancela com Ctrl+C a qualquer momento.${C_N}"

    while [ -z "$ACME_EMAIL" ]; do
        ask_input "Seu e-mail (será usado pra emitir HTTPS pelos seus domínios — não recebe spam)"
        ACME_EMAIL="$REPLY"
        if [ -z "$ACME_EMAIL" ]; then
            warn "E-mail é obrigatório. Tenta de novo."
        fi
    done
    log "E-mail registrado: ${C_W}$ACME_EMAIL${C_N}"

    # Loop do menu
    while true; do
        show_menu
        ask_input "Digite o número da stack que quer instalar (ex: 03 = n8n) ou 99 pra sair"
        # set +e em volta do case: uma stack que falhe (DNS cancelado, erro de
        # rede, etc.) NUNCA pode matar o wizard inteiro — tem que voltar ao menu.
        # (sem isso, o `set -e` global aborta o script no primeiro erro de stack)
        set +e
        case "$REPLY" in
            01|1)  install_traefik ;;
            02|2)  install_portainer ;;
            03|3)  install_n8n ;;
            04|4)  install_evolution ;;
            05|5)  install_chatwoot ;;
            06|6)  install_typebot ;;
            07|7)  install_flowise ;;
            08|8)  install_uptime_kuma ;;
            09|9)  install_nocodb ;;
            10)    install_openclaw ;;
            11)    install_hermes ;;
            99|q|Q|sair|exit) show_summary; exit 0 ;;
            "") ;;
            *) warn "Opção inválida: $REPLY"; sleep 1; set -e; continue ;;
        esac
        set -e
        echo
        echo
        printf "  ${C_C}>${C_N} ${C_DIM}Pressione ENTER pra voltar ao menu...${C_N}"
        read -r _
    done
}

main "$@"
