#!/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 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

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; }

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
  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."
}

cmd_update() {
  require_release_mode
  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[@]}"
  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."
  # shellcheck disable=SC1090
  . "./$STATE_FILE"
  [ -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"}
}

case "${1:-}" in
  password) shift; cmd_password "$@" ;;
  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,22p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
esac
