#!/usr/bin/env bash
# backup.sh — copia de segurança de banco e arquivos.
#
# Backup que nunca foi restaurado não e backup. Este script sempre VERIFICA o
# que gerou: confere que o arquivo existe, que abre, e que não está vazio.
#
# Uso:
#   backup.sh --host <ip> --user <deploy> --action now --confirmed
#   backup.sh --host <ip> --user <deploy> --action schedule --at "03:00" --keep 7 --confirmed
#   backup.sh --host <ip> --user <deploy> --action list
#   backup.sh --host <ip> --user <deploy> --action pull --file <nome>   # traz para a máquina local
#
# Códigos: 0 ok | 2 argumento | 10 sem confirmação | 13 conexão | 20 backup inválido

set -uo pipefail

HOST=""; USER_NAME="deploy"; PORT=22; KEY_PATH=""
ACTION="now"; APP_PATH="/srv/app"; BACKUP_DIR="/srv/backups"
KEEP=7; AT="03:00"; FILE=""; DEST="."; CONFIRMED=0

while [ $# -gt 0 ]; do
  case "$1" in
    --host) HOST="$2"; shift 2 ;;
    --user) USER_NAME="$2"; shift 2 ;;
    --port) PORT="$2"; shift 2 ;;
    --key) KEY_PATH="$2"; shift 2 ;;
    --action) ACTION="$2"; shift 2 ;;
    --app-path) APP_PATH="$2"; shift 2 ;;
    --backup-dir) BACKUP_DIR="$2"; shift 2 ;;
    --keep) KEEP="$2"; shift 2 ;;
    --at) AT="$2"; shift 2 ;;
    --file) FILE="$2"; shift 2 ;;
    --dest) DEST="$2"; shift 2 ;;
    --confirmed) CONFIRMED=1; shift ;;
    *) echo "argumento desconhecido: $1" >&2; exit 2 ;;
  esac
done

[ -n "$HOST" ] || { echo "ERRO: --host é obrigatório" >&2; exit 2; }

# Tudo que entra em comando remoto é validado pela forma.
erro_arg() { echo "ERRO: $1" >&2; exit 2; }
case "$APP_PATH" in /*) ;; *) erro_arg "--app-path precisa ser caminho absoluto" ;; esac
case "$APP_PATH" in *[!A-Za-z0-9._/-]*|*..*) erro_arg "--app-path com caractere inválido" ;; esac
case "$BACKUP_DIR" in /*) ;; *) erro_arg "--backup-dir precisa ser caminho absoluto" ;; esac
case "$BACKUP_DIR" in *[!A-Za-z0-9._/-]*|*..*) erro_arg "--backup-dir com caractere inválido" ;; esac
case "$KEEP" in ""|*[!0-9]*) erro_arg "--keep precisa ser número" ;; esac
case "$AT" in [0-9][0-9]:[0-9][0-9]|[0-9]:[0-9][0-9]) ;; *) erro_arg "--at no formato HH:MM" ;; esac
case "$USER_NAME" in ""|*[!a-z0-9_-]*) erro_arg "--user inválido" ;; esac

SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ForwardAgent=no -o ConnectTimeout=10 -p "$PORT")
[ -n "$KEY_PATH" ] && SSH_OPTS+=(-i "${KEY_PATH/#\~/$HOME}" -o IdentitiesOnly=yes)
TARGET="${USER_NAME}@${HOST}"

# ── list: somente leitura ────────────────────────────────────────────────────
if [ "$ACTION" = "list" ]; then
  ssh "${SSH_OPTS[@]}" "$TARGET" "
    D='${BACKUP_DIR}'
    if [ ! -d \"\$D\" ]; then echo 'backups=nenhum (backup ainda não configurado)'; exit 0; fi
    echo \"espaco_livre=\$(df -Ph \"\$D\" | tail -n1 | awk '{print \$4}')\"
    echo \"total=\$(du -sh \"\$D\" 2>/dev/null | cut -f1)\"
    echo '---'
    ls -lht \"\$D\" 2>/dev/null | tail -n +2 | awk '{print \$9, \$5, \$6, \$7, \$8}'
    echo '---'
    crontab -l 2>/dev/null | grep sshepherd-backup || echo 'agendamento=nenhum'
  " 2>&1
  exit $?
fi

# ── pull: traz um backup para a máquina local ────────────────────────────────
if [ "$ACTION" = "pull" ]; then
  [ -n "$FILE" ] || { echo "ERRO: --file é obrigatório para pull" >&2; exit 2; }
  # Só NOME de arquivo. Com caminho, `--file ../app/x` virava leitura de qualquer
  # arquivo que o usuário de operação alcança, por fora de todo o resto.
  case "$FILE" in
    */*|*..*|-*) echo "ERRO: --file aceita só o nome do arquivo, sem barra nem '..'" >&2; exit 2 ;;
  esac
  case "$FILE" in
    *[!A-Za-z0-9._-]*) echo "ERRO: nome de backup inválido: '${FILE}'" >&2; exit 2 ;;
  esac
  # Só o que o runner gera. `--backup-dir /srv/app/shared/logs --file app.log`
  # passava pela regra do nome e virava leitura de qualquer arquivo.
  if ! printf '%s' "$FILE" | grep -qE '^(db|shared|pre-restore)-[0-9]{8}-[0-9]{6}\.(sql|tar)\.gz$'; then
    echo "ERRO: '${FILE}' não tem o nome de um backup gerado pelo SSHepherd (db-DATA-HORA.sql.gz, shared-DATA-HORA.tar.gz)" >&2
    exit 2
  fi
  # Backup carrega dado de usuário, e pode carregar segredo guardado no banco.
  # Trazer para a máquina local é decisão de pessoa: exige teclado, como o
  # comando livre do executor. O agente entrega o comando pronto e a pessoa roda.
  if [ ! -t 0 ]; then
    echo "RECUSADO: 'pull' traz dado de usuário para esta máquina e exige terminal." >&2
    echo "  Rode você, no seu terminal:" >&2
    printf '  bash .sshepherd/scripts/sh/backup.sh --host %s --user %s' "$HOST" "$USER_NAME" >&2
    [ -n "$KEY_PATH" ] && printf ' --key %q' "$KEY_PATH" >&2
    printf ' --action pull --file %s --dest <pasta-fora-do-projeto>\n' "$FILE" >&2
    echo "BACKUP=precisa_de_teclado"
    exit 10
  fi
  # Dump de banco dentro da pasta do projeto acaba no repositório ou no próximo
  # deploy. Se a pasta atual tem o SSHepherd instalado, ela é o projeto.
  DEST_ABS=$(cd "$DEST" 2>/dev/null && pwd -P) || { echo "ERRO: destino inexistente: ${DEST}" >&2; exit 2; }
  if [ -d "./.sshepherd" ]; then
    PROJ_ABS=$(pwd -P)
    case "$DEST_ABS/" in
      "$PROJ_ABS"/*)
        echo "RECUSADO: ${DEST_ABS} está dentro da pasta do projeto." >&2
        echo "  Backup carrega dado de usuário. Escolha um --dest fora do projeto," >&2
        echo "  e fora de pasta sincronizada com a nuvem." >&2
        exit 2 ;;
    esac
  fi
  echo "Baixando ${FILE} para ${DEST}/ ..."
  # Arquivo comum, não symlink: scp segue link, e link dentro de uma pasta do
  # usuário de operação aponta para onde ele quiser.
  if ! ssh "${SSH_OPTS[@]}" "$TARGET" "F='${BACKUP_DIR}/${FILE}'; [ -f \"\$F\" ] && [ ! -L \"\$F\" ]"; then
    echo "ERRO: ${BACKUP_DIR}/${FILE} não existe ou não é um arquivo comum." >&2
    exit 2
  fi
  # Backup que só existe no mesmo servidor não protege contra perda da máquina.
  scp "${SSH_OPTS[@]}" "${TARGET}:${BACKUP_DIR}/${FILE}" "${DEST}/" || exit 13
  echo "Baixado. Guarde essa copia FORA do servidor: backup no mesmo disco"
  echo "não protege contra perda da máquina."
  exit 0
fi

# ── now / schedule: alteram o servidor ───────────────────────────────────────
if [ "$CONFIRMED" -ne 1 ]; then
  echo "RECUSADO: '${ACTION}' altera o servidor e exige --confirmed." >&2
  exit 10
fi

if [ "$ACTION" = "now" ] || [ "$ACTION" = "schedule" ]; then
  echo "--------------------------------------------"
  echo " BACKUP em ${HOST}"
  echo "  destino  : ${BACKUP_DIR}"
  echo "  aplicação: ${APP_PATH}"
  echo "  manter   : ${KEEP} copias"
  [ "$ACTION" = "schedule" ] && echo "  agendado : todo dia as ${AT}"
  echo "--------------------------------------------"
fi

# Script instalado no servidor. Fica la para o agendamento chamar.
RUNNER=$(cat <<'RUNNER_SCRIPT'
#!/usr/bin/env bash
# Gerado pelo SSHepherd. Roda sozinho pelo agendamento, sem agente nenhum.
set -uo pipefail
APP_PATH="__APP_PATH__"
BACKUP_DIR="__BACKUP_DIR__"
KEEP=__KEEP__
STAMP=$(date -u +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP_DIR"

FALHAS=0

# Espaço: backup que enche o disco derruba a aplicação que ele deveria proteger.
LIVRE_MB=$(df -Pm "$BACKUP_DIR" | tail -n1 | awk '{print $4}')
if [ "${LIVRE_MB:-0}" -lt 500 ]; then
  echo "ERRO: menos de 500 MB livres em $BACKUP_DIR. Backup abortado."
  exit 7
fi

# ── banco ────────────────────────────────────────────────────────────────────
# Qual banco é vem dos metadados que o db.sh grava (sem segredo). O cofre, onde
# está a senha, não é legível por quem roda este backup, e não precisa ser:
# pg_dump e mysqldump entram pelo sudo restrito, não pela senha da aplicação.
# Servidor sem metadados (banco configurado antes do cofre): lê a URL do
# shared/.env antigo, só para saber o nome.
META="$APP_PATH/shared/.sshepherd-db"
ENGINE=""; DBNAME=""
if [ -f "$META" ]; then
  ENGINE=$(grep -E '^engine=' "$META" | head -n1 | cut -d= -f2 | tr -dc 'a-z')
  DBNAME=$(grep -E '^dbname=' "$META" | head -n1 | cut -d= -f2 | tr -dc 'A-Za-z0-9_')
elif [ -f "$APP_PATH/shared/.env" ]; then
  DBURL=$(grep -E '^DATABASE_URL=' "$APP_PATH/shared/.env" 2>/dev/null | head -n1 | cut -d= -f2-)
  case "$DBURL" in postgres*) ENGINE=postgres ;; mysql*) ENGINE=mysql ;; esac
  DBNAME="${DBURL##*/}"; DBNAME="${DBNAME%%\?*}"; DBNAME=$(printf '%s' "$DBNAME" | tr -dc 'A-Za-z0-9_')
  unset DBURL
fi

if [ -n "$ENGINE" ] && [ -n "$DBNAME" ]; then
  DBFILE="$BACKUP_DIR/db-$STAMP.sql.gz"
  case "$ENGINE" in
    postgres)
      if sudo -n -u postgres pg_dump "$DBNAME" 2>/dev/null | gzip > "$DBFILE"; then
        echo "banco=ok $(basename "$DBFILE")"
      else
        echo "banco=FALHOU"; FALHAS=$((FALHAS+1)); rm -f "$DBFILE"
      fi ;;
    mysql)
      if sudo -n mysqldump --single-transaction "$DBNAME" 2>/dev/null | gzip > "$DBFILE"; then
        echo "banco=ok $(basename "$DBFILE")"
      else
        echo "banco=FALHOU"; FALHAS=$((FALHAS+1)); rm -f "$DBFILE"
      fi ;;
    *) echo "banco=tipo desconhecido, pulado" ;;
  esac
else
  echo "banco=nenhum configurado"
fi

# ── arquivos compartilhados (uploads, o que sobrevive ao deploy) ─────────────
# Segredo não entra no backup. Com o cofre ele nem está aqui; sem o cofre
# (compose) o .env fica em shared/ e é excluído de propósito: backup viaja,
# e chave que viaja é chave que vaza. Recadastrar chave é barato.
SHAREDFILE="$BACKUP_DIR/shared-$STAMP.tar.gz"
if tar -czf "$SHAREDFILE" -C "$APP_PATH" --exclude='shared/.env' --exclude='shared/.env.*' shared 2>/dev/null; then
  echo "arquivos=ok $(basename "$SHAREDFILE")"
else
  echo "arquivos=FALHOU"; FALHAS=$((FALHAS+1)); rm -f "$SHAREDFILE"
fi

# ── VERIFICACAO: backup que não abre não e backup ────────────────────────────
for f in "$DBFILE" "$SHAREDFILE"; do
  [ -f "$f" ] || continue
  TAM=$(stat -c%s "$f" 2>/dev/null || echo 0)
  if [ "$TAM" -lt 100 ]; then
    echo "verificacao=FALHOU $(basename "$f") tem $TAM bytes, praticamente vazio"
    FALHAS=$((FALHAS+1)); continue
  fi
  if ! gzip -t "$f" 2>/dev/null; then
    echo "verificacao=FALHOU $(basename "$f") está corrompido"
    FALHAS=$((FALHAS+1)); continue
  fi
  echo "verificacao=ok $(basename "$f") $(numfmt --to=iec "$TAM" 2>/dev/null || echo "$TAM bytes")"
done

# ── rotação: so remove depois de confirmar que o novo presta ─────────────────
if [ "$FALHAS" -eq 0 ]; then
  for prefixo in db shared; do
    ls -1t "$BACKUP_DIR/$prefixo-"* 2>/dev/null | tail -n +$((KEEP + 1)) | while read -r antigo; do
      rm -f "$antigo" && echo "rotacao=removido $(basename "$antigo")"
    done
  done
else
  echo "rotacao=pulada, houve falha; nenhum backup antigo foi apagado"
fi

echo "falhas=$FALHAS"
[ "$FALHAS" -eq 0 ] || exit 20
echo "backup=concluído $STAMP"
RUNNER_SCRIPT
)

RUNNER="${RUNNER//__APP_PATH__/$APP_PATH}"
RUNNER="${RUNNER//__BACKUP_DIR__/$BACKUP_DIR}"
RUNNER="${RUNNER//__KEEP__/$KEEP}"

# Instala o runner no servidor
ssh "${SSH_OPTS[@]}" "$TARGET" "
  sudo -n install -d -o '${USER_NAME}' -g '${USER_NAME}' '${BACKUP_DIR}'
  mkdir -p ~/.sshepherd
  cat > ~/.sshepherd/backup-runner.sh
  chmod 755 ~/.sshepherd/backup-runner.sh
" <<< "$RUNNER" || { echo "FALHOU ao instalar o runner de backup." >&2; exit 13; }

if [ "$ACTION" = "schedule" ]; then
  HORA="${AT%%:*}"; MINUTO="${AT##*:}"
  ssh "${SSH_OPTS[@]}" "$TARGET" "
    LINHA='${MINUTO} ${HORA} * * * \$HOME/.sshepherd/backup-runner.sh >> \$HOME/.sshepherd/backup.log 2>&1 # sshepherd-backup'
    ( crontab -l 2>/dev/null | grep -v 'sshepherd-backup'; echo \"\$LINHA\" ) | crontab -
    echo 'agendado=sim'
    crontab -l | grep sshepherd-backup
  " 2>&1 || exit 13
  echo ""
  echo "Agendado. Vou rodar uma vez agora para provar que funciona."
fi

# Sempre executa uma vez: agendamento que nunca rodou não prova nada.
echo ""
echo "== executando backup =="
OUT=$(ssh "${SSH_OPTS[@]}" "$TARGET" '$HOME/.sshepherd/backup-runner.sh' 2>&1)
CODE=$?
printf '%s\n' "$OUT" | sed -E 's#(postgresql|mysql)://[^:]+:[^@]+@#\1://[REDIGIDO]@#g'

if [ $CODE -ne 0 ]; then
  echo "" >&2
  echo "O BACKUP FALHOU. Não considere este servidor protegido." >&2
  exit $CODE
fi

echo ""
echo "Backup verificado: os arquivos existem, abrem e não estão vazios."
echo ""
echo "ATENÇÃO: essas copias estão no MESMO disco do servidor. Se a máquina"
echo "morrer, elas morrem junto. Baixe uma copia de tempos em tempos:"
echo "  backup.sh --host ${HOST} --action pull --file <nome>"
exit 0
