#!/usr/bin/env bash
# Zynklog local updater. Requires Bash and Docker Compose; no host Node/Python.
set -Eeuo pipefail
umask 077
API_REPO=ghcr.io/kauanfrodrigues98/zynklog-api
WEB_REPO=ghcr.io/kauanfrodrigues98/zynklog-front
MARKER='# Managed by Zynklog updater v1'
ROOT="${ZYNKLOG_INSTALL_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)}"
cd -- "$ROOT"
ROOT=$(pwd -P)
usage() {
  cat <<'HELP'
Uso (na pasta da instalação):
  ./zynklog status
  ./zynklog update v1.1.0 --dry-run
  ./zynklog update v1.1.0
  ./zynklog update v1.1.0 --skip-backup
Opções: --yes (confirmar sem interação), --dry-run (somente plano local).
Backup é feito por padrão. Não há downgrade nem rollback automático de migrations.
Windows: execute em WSL com integração do Docker Desktop habilitada.
HELP
}
fail() { printf 'Erro: %s\n' "$*" >&2; exit 1; }
[[ ${1:-} != -h && ${1:-} != --help && $# -gt 0 ]] || { usage; exit 0; }
COMMAND=$1; shift
[[ $COMMAND == status || $COMMAND == update ]] || { usage; exit 1; }
TARGET=''; DRY=0; YES=0; BACKUP=1
if [[ $COMMAND == update ]]; then
  TARGET=${1:-}; [[ -n $TARGET ]] || fail 'Informe a versão publicada, por exemplo: ./zynklog update v1.1.0'
  shift
  [[ $TARGET =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail 'Use uma tag estável no formato vX.Y.Z.'
fi
while [[ $# -gt 0 ]]; do
  case $1 in --dry-run) DRY=1;; --yes) YES=1;; --skip-backup) BACKUP=0;; *) fail "Opção desconhecida: $1";; esac
  shift
done
[[ -z ${COMPOSE_FILE:-} ]] || fail 'COMPOSE_FILE personalizado não é suportado; use o pacote Compose padrão.'
if [[ -f .env ]] && grep -Eq '^[[:space:]]*(export[[:space:]]+)?COMPOSE_FILE[[:space:]]*=' .env; then
  fail 'O .env define COMPOSE_FILE. Esta primeira versão suporta somente a descoberta padrão do Compose.'
fi
BASE=''; COUNT=0
for file in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
  if [[ -f $file ]]; then BASE=$file; COUNT=$((COUNT+1)); fi
done
[[ $COUNT == 1 ]] || fail 'Mantenha exatamente um arquivo compose.yaml/compose.yml/docker-compose.yml nesta pasta.'
[[ ! -L $BASE && ! -L compose.override.yaml ]] || fail 'Arquivos Compose com links simbólicos não são suportados.'
for file in compose.override.yml docker-compose.override.yml docker-compose.override.yaml; do
  [[ ! -e $file ]] || fail "Override personalizado encontrado ($file). Não será sobrescrito."
done
if [[ -e compose.override.yaml ]]; then
  [[ $(head -n 1 compose.override.yaml) == "$MARKER" ]] || fail 'compose.override.yaml pertence ao usuário. Não será sobrescrito.'
fi
command -v docker >/dev/null || fail 'Docker não encontrado.'
docker info >/dev/null 2>&1 || fail 'Inicie o Docker Desktop/Engine.'
docker compose version >/dev/null || fail 'Docker Compose v2 é necessário.'
docker compose start --help | grep -q -- '--wait-timeout' || fail 'Atualize o Docker Compose: start --wait-timeout não está disponível.'
docker compose up --help | grep -q -- '--wait-timeout' || fail 'Atualize o Docker Compose: up --wait-timeout não está disponível.'
# Never print resolved Compose config: it may contain credentials.
SERVICES=$(docker compose config --services)
for service in init api web postgres redis; do
  grep -Fxq "$service" <<< "$SERVICES" || fail "O pacote esperado não foi encontrado: serviço $service ausente."
done
INFRASTRUCTURE=(postgres redis)
if grep -Fxq mailpit <<< "$SERVICES"; then INFRASTRUCTURE+=(mailpit); fi
MANAGED=(api web "${INFRASTRUCTURE[@]}")
HAS_CADDY=0
if grep -Fxq caddy <<< "$SERVICES"; then MANAGED+=(caddy); HAS_CADDY=1; fi
IDS=(); IMAGES=(); PROJECT=''; HELPER=''; VOLUMES=''
for service in "${MANAGED[@]}"; do
  id=$(docker compose ps -aq "$service")
  [[ -n $id && $id != *$'\n'* ]] || fail "É necessário um único container existente de $service."
  project=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$id")
  [[ -n $project && $project != '<no value>' ]] || fail 'Container sem identificação de projeto Compose.'
  [[ -z $PROJECT || $PROJECT == "$project" ]] || fail 'Os containers pertencem a projetos diferentes.'
  PROJECT=$project
  image=$(docker inspect --format '{{.Config.Image}}' "$id")
  IDS+=("$id"); IMAGES+=("$image")
  if [[ $service == api ]]; then HELPER=$(docker inspect --format '{{.Image}}' "$id"); fi
  if [[ $COMMAND == update ]]; then
    [[ $(docker inspect --format '{{.State.Running}}' "$id") == true ]] || fail "Inicie a instalação antes de atualizar: $service está parado."
    mounts=$(docker inspect --format '{{range .Mounts}}{{.Type}}|{{.Name}}|{{.Destination}}|{{.Source}}{{println}}{{end}}' "$id")
    while IFS='|' read -r kind volume destination source; do
      [[ -n $kind ]] || continue
      if [[ $kind == bind && $service == caddy && $destination == /etc/caddy/Caddyfile && $source == "$ROOT/Caddyfile" && -f Caddyfile && ! -L Caddyfile ]]; then continue; fi
      [[ $kind == volume && -n $volume ]] || fail "Montagem personalizada em $service ($destination). Backup automático indisponível para este layout."
      [[ $volume =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] || fail 'Nome de volume inválido.'
      VOLUMES+="$volume"$'\n'
    done <<< "$mounts"
  fi
done
printf 'Instalação: %s\nAPI: %s\nFrontend: %s\n' "$PROJECT" "${IMAGES[0]}" "${IMAGES[1]}"
if [[ $COMMAND == status ]]; then docker compose ps; exit 0; fi
for pair in "${IMAGES[0]}|$API_REPO" "${IMAGES[1]}|$WEB_REPO"; do
  old=${pair%%|*}; repo=${pair#*|}
  [[ $old == "$repo":v* ]] || fail 'A instalação atual não usa as imagens oficiais com tags estáveis.'
  version=${old##*:}; [[ $version =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-beta\.[0-9]+)?$ ]] || fail 'A versão atual precisa ser uma tag oficial estável ou beta.'
  version=${version%%-beta.*}
  IFS=. read -r a b c <<< "${version#v}"
  IFS=. read -r x y z <<< "${TARGET#v}"
  if ((10#$x < 10#$a || (10#$x == 10#$a && 10#$y < 10#$b) || (10#$x == 10#$a && 10#$y == 10#$b && 10#$z < 10#$c))); then
    fail 'Downgrade recusado. Reverter imagens não desfaz migrations; consulte o procedimento de recuperação.'
  fi
done
[[ -n $VOLUMES ]] || fail 'Nenhum volume persistente encontrado; não é seguro continuar.'
VOLUMES=$(printf '%s' "$VOLUMES" | sort -u | sed '/^$/d')
for volume in $VOLUMES; do docker volume inspect "$volume" >/dev/null; done
if [[ ${IMAGES[0]} == "$API_REPO:$TARGET" && ${IMAGES[1]} == "$WEB_REPO:$TARGET" ]]; then
  printf 'A instalação já usa %s. Nenhuma atualização necessária.\n' "$TARGET"; exit 0
fi
printf '\nDestino: %s (API e frontend)\n' "$TARGET"
if [[ $BACKUP == 1 ]]; then printf 'Backup: automático, com todos os serviços parados.\n'; else printf 'Backup: DISPENSADO explicitamente. Não haverá ponto de recuperação criado pelo atualizador.\n'; fi
printf 'Somente imagens da aplicação serão alteradas. Banco, Redis, portas, configurações e volumes serão preservados.\n'
if [[ $DRY == 1 ]]; then
  printf 'Simulação local concluída: sem downloads, backup, mudanças de arquivos ou containers. A existência da tag no GHCR ainda não foi verificada.\n'; exit 0
fi
if [[ $YES != 1 ]]; then
  [[ -t 0 ]] || fail 'Terminal não interativo: use --yes para autorizar a operação.'
  read -r -p 'Haverá uma breve indisponibilidade. Continuar? [s/N] ' answer
  [[ $answer == s || $answer == S ]] || { printf 'Cancelado.\n'; exit 0; }
fi
LOCK=.zynklog-update.lock
mkdir "$LOCK" 2>/dev/null || fail 'Outra atualização pode estar em andamento. Verifique .zynklog-update.lock antes de tentar novamente.'
printf '%s\n' "$$" > "$LOCK/pid"
PHASE=preflight; STOPPED=0; HAD_OVERRIDE=0; WORK=''; BACKUP_DIR=''
cleanup() {
  code=$?
  trap - EXIT INT TERM
  set +e
  if [[ $code != 0 ]]; then
    printf '\nAtualização interrompida na fase: %s.\n' "$PHASE" >&2
    if [[ $PHASE == migrating ]]; then
      docker compose stop web api >/dev/null 2>&1
      printf 'API e frontend foram parados. Migrations podem ter sido aplicadas: não foi feito rollback automático.\nConsulte ATUALIZAR.md antes de tentar recuperar a instalação.\n' >&2
    else
      if [[ $PHASE == configured ]]; then
        if [[ $HAD_OVERRIDE == 1 ]]; then cp "$WORK/previous.override.yaml" compose.override.yaml; else rm -f compose.override.yaml; fi
      fi
      if [[ $STOPPED == 1 ]]; then
        docker compose start --wait --wait-timeout 300 "${MANAGED[@]}" >/dev/null 2>&1 || printf 'Não foi possível retomar todos os containers antigos; confira docker compose ps.\n' >&2
      fi
    fi
    [[ -z $BACKUP_DIR ]] || printf 'Diretório de recuperação: %s/%s (verifique o marcador COMPLETE).\n' "$ROOT" "$BACKUP_DIR" >&2
  fi
  [[ -z $WORK ]] || rm -rf -- "$WORK"
  rm -f "$LOCK/pid"; rmdir "$LOCK" 2>/dev/null
  exit "$code"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
index=0
for service in "${MANAGED[@]}"; do
  [[ $(docker compose ps -aq "$service") == "${IDS[$index]}" ]] || fail 'A instalação mudou durante a verificação. Execute o atualizador novamente.'
  index=$((index+1))
done
WORK=$(mktemp -d .zynklog-update.XXXXXX)
if [[ -f compose.override.yaml ]]; then HAD_OVERRIDE=1; cp compose.override.yaml "$WORK/previous.override.yaml"; fi
cat > "$WORK/target.yaml" <<YAML
$MARKER
# Change versions with ./zynklog update. Do not remove this file after updating.
services:
  init:
    image: $API_REPO:$TARGET
  api:
    image: $API_REPO:$TARGET
  web:
    image: $WEB_REPO:$TARGET
YAML
# Download both matching images before any downtime or version change.
docker pull "$API_REPO:$TARGET"
docker pull "$WEB_REPO:$TARGET"
docker compose -f "$BASE" -f "$WORK/target.yaml" config --quiet
if [[ $BACKUP == 1 ]]; then
  PHASE=backup
  BACKUP_DIR="backups/$(date -u +%Y%m%dT%H%M%SZ)-to-$TARGET-$$"
  mkdir -p "$BACKUP_DIR"
  cp "$BASE" "$BACKUP_DIR/$BASE"
  [[ ! -f .env ]] || cp .env "$BACKUP_DIR/.env"
  [[ $HAS_CADDY == 0 ]] || cp Caddyfile "$BACKUP_DIR/Caddyfile"
  [[ $HAD_OVERRIDE == 0 ]] || cp compose.override.yaml "$BACKUP_DIR/compose.override.yaml"
  printf 'project=%s\nold_api=%s\nold_web=%s\ntarget=%s\n' "$PROJECT" "${IMAGES[0]}" "${IMAGES[1]}" "$TARGET" > "$BACKUP_DIR/installation.txt"
  printf '%s\n' "$VOLUMES" > "$BACKUP_DIR/volumes.txt"
  STOPPED=1
  docker compose stop "${MANAGED[@]}"
  for volume in $VOLUMES; do
    printf 'Backup do volume %s...\n' "$volume"
    docker run --rm --network none --user 0 --entrypoint tar \
      --mount "type=volume,source=$volume,target=/source,readonly" \
      "$HELPER" -czf - -C /source . > "$BACKUP_DIR/$volume.tar.gz"
    [[ -s $BACKUP_DIR/$volume.tar.gz ]] || fail "Backup vazio: $volume"
  done
  printf 'Backup completo dos volumes parados.\n' > "$BACKUP_DIR/COMPLETE"
fi
PHASE=configured
cp "$WORK/target.yaml" "$WORK/commit.yaml"
mv "$WORK/commit.yaml" compose.override.yaml
# Verify the standard docker compose commands actually discover this override.
images=$(docker compose config --images)
grep -Fxq "$API_REPO:$TARGET" <<< "$images" || fail 'O Compose não carregou o override de imagens automaticamente.'
grep -Fxq "$WEB_REPO:$TARGET" <<< "$images" || fail 'O Compose não carregou o override do frontend automaticamente.'
if [[ $STOPPED == 1 ]]; then
  docker compose start --wait --wait-timeout 180 "${INFRASTRUCTURE[@]}"
fi
PHASE=migrating
# Existing infrastructure is reused; never recreate or upgrade PostgreSQL/Redis here.
docker compose stop web api
docker compose up -d --no-deps --pull never --wait --wait-timeout 300 api
docker compose up -d --no-deps --pull never --wait --wait-timeout 180 web
if [[ $HAS_CADDY == 1 ]]; then docker compose start --wait --wait-timeout 180 caddy; fi
PHASE=done
printf '\nZynklog atualizado para %s. API e frontend responderam às verificações de saúde do Compose.\n' "$TARGET"
[[ -z $BACKUP_DIR ]] || printf 'Backup preservado em: %s/%s\n' "$ROOT" "$BACKUP_DIR"
printf 'Atualize a página no navegador. compose.override.yaml mantém a versão escolhida para os próximos comandos docker compose.\n'
docker compose ps
