#!/usr/bin/env bash
# opnexus — Update, Rollback und Backup für eine OPNexus-Installation aus
# fertigen Release-Images (siehe README, "Release-Images & CI").
#
#   opnexus status            Version, Container, Datenbank-Revision
#   opnexus update [VERSION]  Neue Images ziehen, wechseln, auf "gesund" warten;
#                             bei Fehler automatisch zurückrollen
#   opnexus rollback          Letztes Update rückgängig machen
#   opnexus backup            Sofort-Backup der OPNexus-Datenbank
#   opnexus backup --full [DATEI]
#                             Vollsicherung: Datenbank UND alle Geheimnisse
#                             (Firewall-Zugänge, 2FA, VPN-/Zertifikatsschlüssel,
#                             Konfigurations-Backups) in einer verschlüsselten
#                             Datei (.opxfull). Passphrase wird abgefragt
#                             (oder OPNEXUS_BACKUP_PASSPHRASE).
#   opnexus restore DATEI     Vollsicherung zurückspielen (überschreibt Datenbank
#                             und Geheimnisse dieser Installation; --yes: ohne Rückfrage)
#   opnexus password [NAME]   Neues Zufallspasswort für den Admin setzen und
#                             anzeigen (--yes: ohne Rückfrage)
#   opnexus export [--out VERZEICHNIS] [--kinds a,b]
#                             Konfiguration als Code: alle zentralen Objekte als
#                             YAML (opnexus-config.yaml), ohne Zugänge/Schlüssel.
#   opnexus apply DATEI [--dry-run] [--prune] [--yes] [--json]
#                             Datei prüfen und in die Entwürfe übernehmen. Zeigt
#                             zuerst den Plan. Schreibt nie auf Firewalls; ausgerollt
#                             wird danach wie immer über Vorschau → Ausrollen.
#                             Exit: 0 ok/keine Unterschiede, 2 Unterschiede (--dry-run),
#                             3 Datei ungültig, 4 blockiert/veraltet, 1 Fehler.
#   opnexus status --porcelain  Zustand als key=value-Zeilen (für den Updater)
#
# Läuft im Verzeichnis mit docker-compose.yml und .env (oder OPNEXUS_DIR=...).
# Braucht: docker (compose v2), bash. OPNEXUS_IMAGE_REPO muss in .env stehen.
#
# Exit-Codes von `update`: 0 = läuft auf der neuen Version, 3 = Update ist
# fehlgeschlagen und wurde sauber zurückgerollt, 1 = sonstiger Fehler.
#
# Umgebung (nur für den Updater-Container, siehe updater/server.py):
#   OPNEXUS_UPDATER=1        `up` fasst nur backend/frontend/proxy an — der
#                            Updater-Container selbst wird nie aus seinem
#                            eigenen Lauf heraus neu gestartet.
#   OPNEXUS_PHASE_FILE=PATH  hängt bei jedem Schritt eine Zeile "phase" an.
set -euo pipefail

START_DIR="$PWD"   # relative paths of arguments (export --out, apply DATEI) mean "from where the user stands"
here="$(dirname "$(readlink -f "$0")")"
# Im Release-Bundle liegt das Skript neben docker-compose.yml, im Repo in scripts/.
if [ -f "$here/docker-compose.yml" ]; then default_dir="$here"; else default_dir="$here/.."; fi
cd "${OPNEXUS_DIR:-$default_dir}"

ENV_FILE=".env"
STATE_FILE=".opnexus-update-state"
SERVICES=(backend frontend proxy)
TIMEOUT="${OPNEXUS_UPDATE_TIMEOUT:-240}"

c() { docker compose "$@" 2> >(grep -v 'level=warning' >&2); }
say() { printf '\033[1m==>\033[0m %s\n' "$*"; }
# Schritt für den Updater-Container (Fortschrittsanzeige im Webinterface).
phase() { [ -z "${OPNEXUS_PHASE_FILE:-}" ] || echo "$1" >> "$OPNEXUS_PHASE_FILE"; }
die() { printf '\033[31mFehler:\033[0m %s\n' "$*" >&2; exit 1; }

env_get() { sed -n "s/^$1=//p" "$ENV_FILE" 2>/dev/null | tail -n1; }

# .env enthält Zugangsdaten: nur für den Besitzer lesbar (ältere Anleitungen legten sie mit 0644 an).
protect_env() {
  [ -f "$ENV_FILE" ] || return 0
  local mode; mode=$(stat -c %a "$ENV_FILE" 2>/dev/null) || return 0
  case "$mode" in 600|400) ;; *) chmod 600 "$ENV_FILE" 2>/dev/null && say "$ENV_FILE war für andere Benutzer lesbar ($mode) — jetzt 0600." ;; esac
}

# Der Update-Zustand ist Text, kein Skript: nur bekannte Schlüssel mit harmlosen Zeichen, nie ausgeführt. Setzt die
# Variablen im Aufrufer (dort als local deklarieren).
read_update_state() {
  local key value
  for key in started prev_version prev_setting prev_db_revision target; do
    value=$(sed -n "s/^$key=//p" "$STATE_FILE" | tail -n1)
    [[ "$value" =~ ^[A-Za-z0-9._-]*$ ]] || die "$STATE_FILE enthält einen ungültigen Wert für $key — er wird nicht verwendet."
    printf -v "$key" '%s' "$value"
  done
}

DB_USER="$(env_get POSTGRES_USER)"; DB_USER="${DB_USER:-opnexus}"
DB_NAME="$(env_get POSTGRES_DB)"; DB_NAME="${DB_NAME:-opnexus}"

env_set() {
  if grep -q "^$1=" "$ENV_FILE" 2>/dev/null; then
    sed -i "s|^$1=.*|$1=$2|" "$ENV_FILE"
  else
    printf '%s=%s\n' "$1" "$2" >> "$ENV_FILE"
  fi
}

# Postgres-Client-Tools aus dem Backend-Image (Client 17): Die Pre-Upgrade-
# Dumps schreibt das Backend mit pg_dump 17 — das kann pg_restore 16 (Sidecar)
# NICHT lesen. Also Dump UND Restore immer mit diesen Tools.
pgtool() {
  local tool="$1"; shift
  # Passwort über die Umgebung durchreichen (`-e PGPASSWORD` ohne Wert), nicht als
  # Argument — sonst stünde es in der Prozessliste.
  PGPASSWORD="$(env_get POSTGRES_PASSWORD)" c run --rm --no-deps -T -e PGPASSWORD --entrypoint "$tool" backend "$@"
}

# Der Updater-Container ist ein optionaler Dienst (Compose-Profil "updater"):
# nur wenn er aktiv ist, wird sein Image mitgezogen — sonst würde ein Update
# daran scheitern, dass es für eine ältere Version noch kein Updater-Image gibt.
updater_enabled() {
  local profiles
  profiles="${COMPOSE_PROFILES:-$(env_get COMPOSE_PROFILES)}"
  [[ ",$profiles," == *",updater,"* ]]
}

overlay_enabled() {
  local profiles
  profiles="${COMPOSE_PROFILES:-$(env_get COMPOSE_PROFILES)}"
  [[ ",$profiles," == *",overlay,"* ]]
}

gui_enabled() {
  local profiles
  profiles="${COMPOSE_PROFILES:-$(env_get COMPOSE_PROFILES)}"
  [[ ",$profiles," == *",gui,"* ]]
}

# Welche Dienste `up` anfasst: im Updater-Container nur die Anwendung, auf dem
# Host (CLI) alles — dort darf sich auch der Updater selbst erneuern.
up_targets() {
  if [ -n "${OPNEXUS_UPDATER:-}" ]; then
    printf '%s\n' "${SERVICES[@]}"
    # Der WireGuard-Hub (Profil "overlay") läuft mit dem Backend-Image und muss mit.
    overlay_enabled && echo wg-hub
    # Ebenso das GUI-Gateway (Profil "gui"): Backend-Image, muss zur Backend-Version passen.
    gui_enabled && echo gui-gateway
  fi
  return 0
}

psql_db() { c exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" -Atq "$@"; }

db_revision() {
  psql_db -c "SELECT version_num FROM alembic_version" 2>/dev/null || true
}

running_version() {
  c exec -T backend python -c 'from app.version import APP_VERSION; print(APP_VERSION)' 2>/dev/null || true
}

service_state() {  # healthy | unhealthy | starting | running | (leer)
  local h st id started
  h=$(c ps "$1" --format '{{.Health}}' 2>/dev/null || true)
  if [ -n "$h" ]; then echo "$h"; return; fi
  st=$(c ps "$1" --format '{{.State}}' 2>/dev/null || true)
  # Ohne Healthcheck (Proxy) heißt "running" nur: der Prozess wurde gerade gestartet. Ein Container, der nach
  # einer Sekunde wieder abstürzt, muss als "starting" gelten, sonst meldet das Update "fertig" bei kaputtem
  # Proxy (so geschehen mit 1.27.0). "Läuft seit mindestens 10 s" heißt: stabil.
  if [ "$st" = running ]; then
    id=$(c ps -q "$1" 2>/dev/null | head -n1 || true)
    started=$(docker inspect -f '{{.State.StartedAt}}' "$id" 2>/dev/null || true)
    if [ -n "$started" ] && [ $(( $(date +%s) - $(date -d "$started" +%s 2>/dev/null || echo 0) )) -lt 10 ]; then st=starting; fi
  fi
  echo "$st"
}

wait_healthy() {
  local deadline=$((SECONDS + TIMEOUT)) all svc st
  while [ "$SECONDS" -lt "$deadline" ]; do
    all=1
    for svc in "${SERVICES[@]}"; do
      st=$(service_state "$svc")
      case "$st" in healthy|running) ;; *) all=0 ;; esac
    done
    [ "$all" = 1 ] && return 0
    sleep 3
  done
  return 1
}

require_release_mode() {
  [ -f docker-compose.yml ] || die "docker-compose.yml nicht gefunden (OPNEXUS_DIR setzen?)"
  [ -f "$ENV_FILE" ] || die "$ENV_FILE nicht gefunden"
  [ -n "$(env_get OPNEXUS_IMAGE_REPO)" ] || die "OPNEXUS_IMAGE_REPO ist in $ENV_FILE nicht gesetzt — dieses Kommando arbeitet mit fertigen Release-Images (lokale Builds: git pull && docker compose up -d --build)."
}

cmd_status_porcelain() {
  require_release_mode
  echo "repo=$(env_get OPNEXUS_IMAGE_REPO)"
  echo "version_setting=$(env_get OPNEXUS_VERSION)"
  echo "running_version=$(running_version)"
  echo "db_revision=$(db_revision)"
  for svc in "${SERVICES[@]}"; do echo "service.$svc=$(service_state "$svc")"; done
  if [ -f "$STATE_FILE" ]; then
    # Nur bekannte Schlüssel durchlassen — die Datei wird vom Skript selbst geschrieben.
    grep -E '^(started|prev_version|prev_setting|prev_db_revision|target)=' "$STATE_FILE" | sed 's/^/last_update./'
  fi
}

cmd_status() {
  require_release_mode
  protect_env
  echo "Image-Repo:        $(env_get OPNEXUS_IMAGE_REPO)"
  echo "OPNEXUS_VERSION:   $(env_get OPNEXUS_VERSION)"
  echo "Laufende Version:  $(running_version)"
  echo "DB-Revision:       $(db_revision)"
  for svc in "${SERVICES[@]}"; do echo "$svc: $(service_state "$svc")"; done
  [ -f "$STATE_FILE" ] && { echo; echo "Letztes Update (für 'rollback'):"; sed 's/^/  /' "$STATE_FILE"; }
  return 0
}

cmd_backup() {
  local name="manual-$(date -u +%Y%m%d-%H%M%S).dump"
  pgtool pg_dump -h postgres -U "$DB_USER" -d "$DB_NAME" -F c \
    --exclude-table-data=firewall_log_entries --exclude-table-data=host_resource_samples \
    -f "/backups/$name"
  say "Backup geschrieben: /backups/$name (Volume postgres_backups)"
}

# Passphrase für Vollsicherungen: aus OPNEXUS_BACKUP_PASSPHRASE oder vom Terminal (ohne Echo).
ask_passphrase() {
  if [ -n "${OPNEXUS_BACKUP_PASSPHRASE:-}" ]; then printf '%s' "$OPNEXUS_BACKUP_PASSPHRASE"; return; fi
  [ -r /dev/tty ] || die "Keine Passphrase: OPNEXUS_BACKUP_PASSPHRASE setzen oder an einem Terminal ausführen."
  local one two
  read -rsp "Passphrase (mind. 12 Zeichen): " one </dev/tty; echo >&2
  if [ "${1:-}" = confirm ]; then
    read -rsp "Passphrase wiederholen: " two </dev/tty; echo >&2
    [ "$one" = "$two" ] || die "Die Passphrasen stimmen nicht überein."
  fi
  printf '%s' "$one"
}

cmd_backup_full() {
  local out="${1:-./opnexus-full-$(date -u +%Y%m%d-%H%M%S).opxfull}" pass
  [ ! -e "$out" ] || die "$out existiert schon."
  c exec -T backend true >/dev/null 2>&1 || die "Das Backend läuft nicht — erst starten (docker compose up -d)."
  pass="$(ask_passphrase confirm)"
  ( umask 077
    FULL_BACKUP_PASSPHRASE="$pass" c exec -T -e FULL_BACKUP_PASSPHRASE backend python -m app.full_backup create > "$out.partial" ) \
    || { rm -f "$out.partial"; die "Vollsicherung fehlgeschlagen."; }
  mv "$out.partial" "$out"; chmod 600 "$out"
  say "Vollsicherung geschrieben: $out ($(du -h "$out" | cut -f1))"
  say "Ohne die Passphrase ist die Datei wertlos. Beides getrennt vom Server aufbewahren."
}

cmd_restore() {
  local file="" yes=0 pass arg
  for arg in "$@"; do case "$arg" in --yes|-y) yes=1 ;; *) file="$arg" ;; esac; done
  [ -n "$file" ] && [ -f "$file" ] || die "Aufruf: opnexus restore DATEI.opxfull [--yes]"
  pass="$(ask_passphrase)"
  say "Prüfe die Datei …"
  FULL_BACKUP_PASSPHRASE="$pass" c run --rm --no-deps -T -e FULL_BACKUP_PASSPHRASE backend python -m app.full_backup info < "$file" \
    || die "Die Datei lässt sich nicht öffnen (falsche Passphrase oder keine Vollsicherung)."
  if [ "$yes" != 1 ]; then
    read -rp "Das überschreibt Datenbank und Geheimnisse dieser Installation. Fortfahren? [j/N] " ans </dev/tty
    [[ "$ans" =~ ^[jJyY]$ ]] || die "Abgebrochen."
  fi
  c up -d postgres >/dev/null
  say "Stoppe Backend, Frontend und Proxy …"
  c stop proxy frontend backend >/dev/null 2>&1 || true
  say "Sicherheitskopie des jetzigen Zustands …"
  cmd_backup || say "(Sicherheitskopie nicht möglich — fahre trotzdem fort, weil die Installation leer sein kann)"
  say "Spiele zurück …"
  FULL_BACKUP_PASSPHRASE="$pass" c run --rm --no-deps -T -e FULL_BACKUP_PASSPHRASE backend python -m app.full_backup restore < "$file" \
    || die "Zurückspielen fehlgeschlagen. Der vorherige Stand liegt als Dump in /backups; Dienste sind gestoppt."
  say "Starte die Dienste …"
  c up -d backend frontend proxy >/dev/null
  wait_healthy || die "Nicht alle Dienste sind gesund — 'docker compose logs' prüfen."
  say "Wiederhergestellt."
}

# Prüft die Signatur der gezogenen Images (cosign), bevor irgendetwas gewechselt wird. Nur wenn `cosign`
# installiert ist und cosign.pub neben der docker-compose.yml liegt; sonst wird das gesagt und übersprungen.
verify_signatures() {
  local target="$1"; shift
  local repo; repo=$(env_get OPNEXUS_IMAGE_REPO)
  if ! command -v cosign >/dev/null 2>&1 || [ ! -f cosign.pub ]; then
    say "Signaturprüfung übersprungen (cosign oder cosign.pub fehlt — siehe Handbuch, „Images prüfen“)."
    return 0
  fi
  local svc
  for svc in "$@"; do
    cosign verify --key cosign.pub --insecure-ignore-tlog=true "$repo/opnexus-$svc:$target" >/dev/null 2>&1 \
      || die "Signatur von $repo/opnexus-$svc:$target ist ungültig oder fehlt — nichts wurde verändert."
  done
  say "Signaturen geprüft."
}

# Prüft VOR dem Wechsel, dass der Proxy des neuen Images mit der Konfiguration DIESER Installation startet
# (Caddy validate mit den Umgebungsvariablen, die Compose dem Proxy gibt). Hintergrund: 1.27.0 lieferte eine
# Compose-Datei, die OPNEXUS_GUI_SITES leer weitergab; der Proxy fiel in eine Neustart-Schleife, OPNexus war weg.
# Genau dieser Fall wird hier selbst repariert (Platzhalter in die .env), alles andere bricht VOR dem Wechsel ab.
preflight_proxy_config() {
  local target="$1" repo json args=() kv
  repo=$(env_get OPNEXUS_IMAGE_REPO)
  command -v python3 >/dev/null 2>&1 || { say "Proxy-Vorprüfung übersprungen (python3 fehlt)."; return 0; }
  proxy_env_json() { OPNEXUS_VERSION="$target" c config --format json 2>/dev/null \
    | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin)["services"]["proxy"].get("environment") or {}))'; }
  json=$(proxy_env_json) || { say "Proxy-Vorprüfung übersprungen (Compose-Konfiguration nicht lesbar)."; return 0; }
  if python3 -c 'import json,sys; e=json.loads(sys.argv[1]); sys.exit(0 if e.get("OPNEXUS_GUI_SITES", "x") == "" else 1)' "$json"; then
    say "OPNEXUS_GUI_SITES ist leer (Compose-Datei aus 1.27.0): setze den Platzhalter https://localhost:9441 in $ENV_FILE."
    env_set OPNEXUS_GUI_SITES "https://localhost:9441"
    json=$(proxy_env_json) || return 0
  fi
  while IFS= read -r kv; do args+=(-e "$kv"); done < <(python3 -c 'import json,sys; [print("%s=%s" % i) for i in json.loads(sys.argv[1]).items()]' "$json")
  if ! docker run --rm "${args[@]}" "$repo/opnexus-proxy:$target" caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1; then
    die "Die Proxy-Konfiguration des neuen Images ist mit dieser Installation ungültig — nichts wurde verändert. Prüfen: docker run --rm $(printf '%s ' "${args[@]}") $repo/opnexus-proxy:$target caddy validate --config /etc/caddy/Caddyfile"
  fi
  say "Proxy-Konfiguration geprüft."
}

# B8 (1.30): Die CLI, die ein Update ausführt, ist die ALTE (im Updater-Image eingebaut bzw. im Bundle). Korrekturen an
# Vorprüfungen und Ablauf aus dem Zielrelease griffen deshalb erst beim übernächsten Update. Jetzt holt sie sich die CLI
# aus dem Updater-Image des Ziels und startet sich damit neu, aber nur unter diesen Bedingungen (Review: die CLI läuft
# mit Zugriff auf den Docker-Socket, also wie root): cosign und cosign.pub sind da, das Image wurde in DIESEM Lauf
# gezogen, seine Signatur wurde nach DIGEST geprüft (nicht nach Tag) und genau dieser Digest wird ausgeführt, und die
# Ziel-CLI kennt selbst refresh_cli (so wird nie eine alte, bekannt fehlerhafte CLI geholt). Jeder andere Fehler heißt
# nur: mit der vorhandenen CLI weitermachen. OPNEXUS_NO_CLI_REFRESH=1 schaltet es ab. Der Neustart-Schutz ist eine
# Shell-Variable plus internes Argument, keine Umgebungsvariable, die ein Aufrufer vererben könnte.
CLI_REFRESHED=0
refresh_cli() {
  local target="$1" repo ref digest tmp keep=0
  [ "$CLI_REFRESHED" != 1 ] && [ "${OPNEXUS_NO_CLI_REFRESH:-}" != 1 ] || return 0
  repo=$(env_get OPNEXUS_IMAGE_REPO)
  [ -n "$repo" ] || return 0
  if ! command -v cosign >/dev/null 2>&1 || [ ! -f cosign.pub ]; then
    say "CLI-Aktualisierung übersprungen (ohne cosign und cosign.pub wird keine CLI aus einem Image ausgeführt)."; return 0
  fi
  ref="$repo/opnexus-updater:$target"
  if updater_enabled; then keep=1; fi
  if ! docker pull -q "$ref" >/dev/null 2>&1; then say "CLI-Aktualisierung übersprungen (Updater-Image des Ziels nicht ladbar)."; return 0; fi
  digest=$(docker image inspect --format '{{index .RepoDigests 0}}' "$ref" 2>/dev/null || true)
  if [[ "$digest" != *@sha256:* ]]; then
    [ "$keep" = 1 ] || docker rmi "$ref" >/dev/null 2>&1 || true
    say "CLI-Aktualisierung übersprungen (Digest des Updater-Images unbekannt)."; return 0
  fi
  cosign verify --key cosign.pub --insecure-ignore-tlog=true "$digest" >/dev/null 2>&1 \
    || die "Signatur des Updater-Images $ref ist ungültig oder fehlt — nichts wurde verändert."
  tmp=$(mktemp "${TMPDIR:-/tmp}/opnexus-cli.XXXXXX") || return 0
  if ! docker run --rm --pull never --entrypoint cat "$digest" /usr/local/bin/opnexus > "$tmp" 2>/dev/null \
      || [ ! -s "$tmp" ] || ! bash -n "$tmp" 2>/dev/null || ! grep -q '^refresh_cli() {' "$tmp"; then
    rm -f "$tmp"; [ "$keep" = 1 ] || docker rmi "$ref" >/dev/null 2>&1 || true
    say "CLI-Aktualisierung übersprungen (CLI des Zielreleases nicht lesbar oder zu alt) — nutze die vorhandene."; return 0
  fi
  [ "$keep" = 1 ] || docker rmi "$ref" >/dev/null 2>&1 || true
  if cmp -s "$tmp" "$(readlink -f "$0")"; then rm -f "$tmp"; return 0; fi
  say "Die CLI des Zielreleases (Signatur geprüft) ist neuer — starte sie."
  chmod 0700 "$tmp"
  export OPNEXUS_DIR="$PWD"
  exec bash "$tmp" __refreshed update "$target"
}

cmd_update() {
  require_release_mode
  protect_env
  local target="${1:-$(env_get OPNEXUS_VERSION)}"
  target="${target:-latest}"
  # Landet in .env und in Image-Tags: nur Zeichen, die ein Tag enthalten darf.
  [[ "$target" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || die "Ungültige Version '$target' (erlaubt: Buchstaben, Ziffern, . _ -)."
  local prev_setting prev_version prev_rev started
  prev_setting=$(env_get OPNEXUS_VERSION)
  prev_version=$(running_version)
  [ -n "$prev_version" ] || die "Backend läuft nicht oder antwortet nicht — erst reparieren, dann updaten."
  prev_rev=$(db_revision)

  say "Aktuell: $prev_version (DB-Revision ${prev_rev:-keine}) — Ziel: $target"
  say "Ziehe neue Images (am laufenden System ändert sich dabei nichts) …"
  phase pulling
  local pull_services=("${SERVICES[@]}")
  if updater_enabled; then pull_services+=(updater); fi
  OPNEXUS_VERSION="$target" c pull "${pull_services[@]}" || die "Pull fehlgeschlagen — nichts wurde verändert."
  verify_signatures "$target" "${pull_services[@]}"
  refresh_cli "$target"
  preflight_proxy_config "$target"

  started=$(date -u +%Y%m%dT%H%M%SZ)
  cat > "$STATE_FILE" <<STATE
started=$started
prev_version=$prev_version
prev_setting=$prev_setting
prev_db_revision=$prev_rev
target=$target
STATE

  env_set OPNEXUS_VERSION "$target"
  say "Wechsle auf $target (das Backend sichert die Datenbank selbst vor einer Migration) …"
  phase switching
  local targets=()
  mapfile -t targets < <(up_targets)
  # `up` selbst scheitert, sobald das Backend nicht gesund wird (depends_on) —
  # das ist der Fehlerfall, nicht das Ende des Skripts.
  if c up -d --no-build "${targets[@]}" && { phase waiting; wait_healthy; }; then
    say "Fertig: läuft jetzt auf $(running_version) (DB-Revision $(db_revision))."
    phase done
    return 0
  fi

  printf '\033[31mUpdate nicht gesund geworden.\033[0m Backend-Log:\n' >&2
  c logs --tail 25 backend >&2 || true
  if [ "${OPNEXUS_NO_AUTO_ROLLBACK:-}" = 1 ]; then
    die "Automatisches Zurückrollen ist aus. Manuell: opnexus rollback"
  fi
  say "Rolle automatisch zurück …"
  cmd_rollback
  phase rolled_back
  printf '\033[31mFehler:\033[0m Update auf %s fehlgeschlagen und zurückgerollt auf %s.\n' "$target" "$prev_version" >&2
  exit 3
}

latest_pre_upgrade_dump() {
  c exec -T postgres-backup sh -c 'ls -1t /backups/pre-upgrade-*.dump 2>/dev/null | head -n1' | tr -d '\r'
}

cmd_rollback() {
  require_release_mode
  [ -f "$STATE_FILE" ] || die "Kein Update-Zustand ($STATE_FILE) — es gibt nichts zurückzurollen."
  local started prev_version prev_setting prev_db_revision target
  read_update_state
  [ -n "${prev_version:-}" ] || die "$STATE_FILE ist unvollständig."

  # Rollback target = the exact image tag that was configured before the update.
  # The version the backend REPORTS only matches the tag for final releases: an
  # rc image reports its final version (rc1 images say 1.11.0), so rolling back
  # to that would land on a different, newer image. Floating tags ("latest",
  # "1.11") cannot be restored by name, so those fall back to the reported version.
  if [[ "${prev_setting:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then
    prev_version="$prev_setting"
  fi
  say "Rolle zurück auf $prev_version …"
  phase stopping
  c stop "${SERVICES[@]}"

  local now_rev dump
  now_rev=$(db_revision)
  if [ "$now_rev" != "$prev_db_revision" ]; then
    dump=$(latest_pre_upgrade_dump)
    [ -n "$dump" ] || die "Datenbank wurde migriert ($prev_db_revision → $now_rev), aber es gibt kein pre-upgrade-Backup. Backend bleibt gestoppt."
    # Nur ein Backup, das nach dem Start DIESES Updates entstanden ist, darf
    # zurückgespielt werden — ein älteres würde stillschweigend Daten verwerfen.
    dump_ts=$(echo "$dump" | sed -E 's/.*-([0-9]{8})-([0-9]{6})\.dump$/\1\2/')
    [ "$dump_ts" -ge "${started//[-:TZ]/}" ] 2>/dev/null \
      || die "Neuestes pre-upgrade-Backup ($dump) ist älter als dieses Update (SKIP_MIGRATION_BACKUP gesetzt?). Backend bleibt gestoppt — manuell eingreifen."
    say "Datenbank wurde migriert ($prev_db_revision → $now_rev): spiele $dump zurück."
    phase restoring
    say "Hinweis: Log-Historie und Ressourcen-Messwerte sind im Backup nicht enthalten und danach leer; alles andere steht auf dem Stand vor dem Update."
    # Erst prüfen, dass das Backup lesbar ist — BEVOR irgendetwas gelöscht wird.
    pgtool pg_restore --list "$dump" > /dev/null \
      || die "Backup $dump ist nicht lesbar. Datenbank unverändert, Backend bleibt gestoppt."
    local safety="pre-rollback-$(date -u +%Y%m%d-%H%M%S).dump"
    pgtool pg_dump -h postgres -U "$DB_USER" -d "$DB_NAME" -F c \
      --exclude-table-data=firewall_log_entries --exclude-table-data=host_resource_samples \
      -f "/backups/$safety"
    say "Sicherheitskopie des jetzigen Zustands: /backups/$safety"
    reset_schema() {
      pgtool psql -h postgres -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -q \
        -c 'DROP SCHEMA public CASCADE' -c 'CREATE SCHEMA public' 2>&1 | grep -v '^NOTICE\|^DETAIL\|^drop cascades' || true
    }
    reset_schema
    if ! pgtool pg_restore -h postgres -U "$DB_USER" -d "$DB_NAME" --no-owner --exit-on-error "$dump"; then
      say "Zurückspielen fehlgeschlagen — stelle den Zustand vor dem Rollback aus /backups/$safety wieder her …"
      reset_schema
      pgtool pg_restore -h postgres -U "$DB_USER" -d "$DB_NAME" --no-owner --exit-on-error "/backups/$safety" \
        || die "Auch die Wiederherstellung schlug fehl. Backups liegen in /backups ($dump, $safety). Backend bleibt gestoppt."
      die "Rollback der Datenbank fehlgeschlagen; Zustand vor dem Rollback wiederhergestellt. Backend bleibt gestoppt."
    fi
  else
    say "Datenbank-Revision unverändert ($now_rev) — kein Zurückspielen nötig."
  fi

  env_set OPNEXUS_VERSION "$prev_version"
  phase switching
  local rb_targets=()
  mapfile -t rb_targets < <(up_targets)
  c up -d --no-build "${rb_targets[@]}" || true
  phase waiting
  wait_healthy || die "Nach dem Rollback sind nicht alle Dienste gesund — 'docker compose logs' prüfen."
  say "Fertig: läuft wieder auf $(running_version) (DB-Revision $(db_revision)). OPNEXUS_VERSION in $ENV_FILE ist jetzt fest auf $prev_version gesetzt."
  rm -f "$STATE_FILE"
  phase done
}

# Setzt ein neues Zufallspasswort und gibt es einmalig aus. Gedacht für die
# frische Installation (das beim ersten Start erzeugte Passwort steht nur
# einmal im Log) und für ein vergessenes Admin-Passwort. Alle Sitzungen des
# Kontos enden. Rückfrage nur, wenn sich schon jemand angemeldet hat.
cmd_password() {
  local user="" yes=0 arg
  for arg in "$@"; do
    case "$arg" in
      --yes|-y) yes=1 ;;
      -*) die "Unbekannte Option: $arg" ;;
      *) [ -z "$user" ] || die "Nur ein Benutzername erlaubt."; user="$arg" ;;
    esac
  done
  c ps --status running --services 2>/dev/null | grep -qx backend \
    || die "Das Backend läuft nicht. Erst starten: docker compose up -d"
  if [ "$yes" != 1 ]; then
    local logins; logins="$(psql_db -c 'SELECT count(*) FROM admin_users WHERE last_login_at IS NOT NULL' 2>/dev/null || echo 1)"
    if [ "$logins" != 0 ]; then
      [ -t 0 ] || die "Ohne Terminal nur mit --yes."
      read -r -p "Passwort von ${user:-dem Admin} neu setzen? Alle Sitzungen enden. [j/N] " answer
      case "$answer" in j|J|y|Y) ;; *) die "Abgebrochen." ;; esac
    fi
  fi
  c exec -T backend python -m app.reset_password ${user:+"$user"}
}

# Konfiguration als Code (1.32). Die Datei geht über stdin ins Backend (es öffnet nie selbst eine Datei).
from_here() {
  local p
  case "$1" in /*) p="$1" ;; .) p="$START_DIR" ;; *) p="$START_DIR/${1#./}" ;; esac
  printf '%s' "${p%/}"
}

cmd_export() {
  local out="$START_DIR" kinds="" arg
  while [ $# -gt 0 ]; do
    case "$1" in
      --out) [ $# -ge 2 ] || die "--out braucht ein Verzeichnis."; out="$(from_here "$2")"; shift ;;
      --kinds) [ $# -ge 2 ] || die "--kinds braucht eine Liste."; kinds="$2"; shift ;;
      *) die "Unbekannte Option: $1" ;;
    esac
    shift
  done
  c ps --status running --services 2>/dev/null | grep -qx backend \
    || die "Das Backend läuft nicht. Erst starten: docker compose up -d"
  mkdir -p "$out"
  local target="$out/opnexus-config.yaml"
  ( umask 077
    c exec -T backend python -m app.config_code export ${kinds:+--kinds "$kinds"} > "$target.partial" ) \
    || { rm -f "$target.partial"; die "Export fehlgeschlagen."; }
  mv "$target.partial" "$target"
  say "Geschrieben: $target"
}

cmd_apply() {
  local file="" dry=0 prune=0 yes=0 json=0 arg
  for arg in "$@"; do
    case "$arg" in
      --dry-run) dry=1 ;; --prune) prune=1 ;; --yes|-y) yes=1 ;; --json) json=1 ;;
      -*) die "Unbekannte Option: $arg" ;;
      *) [ -z "$file" ] || die "Nur eine Datei erlaubt."; file="$(from_here "$arg")" ;;
    esac
  done
  [ -n "$file" ] && [ -f "$file" ] || die "Aufruf: opnexus apply DATEI [--dry-run] [--prune] [--yes] [--json]"
  c ps --status running --services 2>/dev/null | grep -qx backend \
    || die "Das Backend läuft nicht. Erst starten: docker compose up -d"
  local flags=(); [ "$prune" = 1 ] && flags+=(--prune)
  local out code
  set +e
  out="$(c exec -T backend python -m app.config_code plan "${flags[@]}" $([ "$json" = 1 ] && echo --json) < "$file")"
  code=$?
  set -e
  if [ "$json" = 1 ]; then
    [ "$dry" = 1 ] || die "--json geht nur zusammen mit --dry-run."
    printf '%s\n' "$out"
    exit "$code"
  fi
  # Der Plan trägt in seiner letzten Zeile das Token; die Anzeige davor.
  local token; token="$(printf '%s\n' "$out" | sed -n 's/^# plan-token: //p')"
  printf '%s\n' "$out" | grep -v '^# plan-token: '
  if [ "$code" = 3 ] || [ "$code" = 1 ] || [ "$code" = 4 ]; then
    [ "$code" != 4 ] || printf '\033[31mBlockiert:\033[0m Der Plan lässt sich nicht übernehmen.\n' >&2
    exit "$code"
  fi
  [ "$dry" = 1 ] && exit "$code"
  if [ "$code" = 0 ]; then say "Keine Unterschiede. Nichts zu übernehmen."; exit 0; fi
  if [ "$yes" != 1 ]; then
    [ -t 0 ] || die "Ohne Terminal nur mit --yes."
    [ "$prune" != 1 ] || printf '\033[33mAchtung:\033[0m --prune entfernt Objekte, die nicht in der Datei stehen.\n' >&2
    read -r -p "Diesen Plan in die Entwürfe übernehmen? Auf Firewalls wird nichts geschrieben. [j/N] " answer
    case "$answer" in j|J|y|Y) ;; *) die "Abgebrochen." ;; esac
  fi
  set +e
  c exec -T backend python -m app.config_code apply "${flags[@]}" --plan-token "$token" < "$file"
  code=$?
  set -e
  exit "$code"
}

# Interner Neustart durch refresh_cli: Marker setzen und die eigene Temp-Datei aufräumen (bash hat sie offen).
if [ "${1:-}" = __refreshed ]; then
  CLI_REFRESHED=1; shift
  case "$0" in */opnexus-cli.*) rm -f "$0" ;; esac
fi

case "${1:-}" in
  password) shift; cmd_password "$@" ;;
  export)   shift; cmd_export "$@" ;;
  apply)    shift; cmd_apply "$@" ;;
  status)   if [ "${2:-}" = "--porcelain" ]; then cmd_status_porcelain; else cmd_status; fi ;;
  update)   shift; cmd_update "${1:-}" ;;
  rollback) cmd_rollback ;;
  backup)   if [ "${2:-}" = "--full" ]; then shift 2; cmd_backup_full "$@"; else cmd_backup; fi ;;
  restore)  shift; cmd_restore "$@" ;;
  *) sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
esac
