#!/bin/sh
# ─────────────────────────────────────────────────────────────────────────────
#  The-Y CRM — Datenbank aus einer Sicherung wiederherstellen. Läuft im Backup-Dienst (postgres:16-alpine, busybox-sh).
#  Anleitung Schritt für Schritt: SELF-HOSTING.md → „Wiederherstellen“. Im Installationsordner:
#     docker compose stop app worker
#     docker compose exec backup sh /usr/local/bin/restore.sh db-YYYYMMDD-HHMMSS.sql.gz
#     docker compose start app worker      (die App ergänzt beim Start fehlende Datenbank-Änderungen selbst)
#  Dateien (Anhänge, Belege, Aufnahmen) sind ein eigener Schritt: restore-files.sh.
#  Optionen: --ja (ohne Rückfrage, z. B. für restore-test.sh) · --ohne-sicherheitskopie (aktuellen Stand nicht vorher sichern)
#            --ohne-schluessel (weiter, obwohl der Schlüssel nicht zur Schlüssel-Sicherung passt — siehe 1b)
#  1.1.53: Liegt eine Schlüssel-Sicherung (schluessel.enc, schluessel.sh) bei den Sicherungen und fehlt der Schlüssel dieser
#  Installation oder passt er nicht dazu, bricht das Skript vor jeder Änderung ab (Code 3) und nennt den Weg, ihn mit der
#  Passphrase zurückzuholen (bash schluessel.sh --zurueckholen auf dem Server — dieser Dienst hat kein openssl und darf die .env
#  nicht ändern). Mit --ja (Automatik, restore-test.sh) oder --ohne-schluessel nur ein Hinweis.
#
#  1.1.52 (Review F14): vorher liefen Fehler durch (ON_ERROR_STOP=0), es hieß immer „abgeschlossen“, die Datei wurde nicht
#  geprüft und App/Worker schrieben währenddessen in halb gelöschte Tabellen. Jetzt — jeder Fehler bricht ab und ändert NICHTS:
#   1. Datei prüfen: gzip intakt, vollständiger pg_dump dieser App (Kopf- und Abschlusszeile, Tabellen _migrations/tenants/
#      users/contacts), Zeilenzahlen aus der Datei merken.
#   2. Abbrechen, solange App oder Worker laufen oder irgendein Programm mit der Datenbank verbunden ist (zweite App, offene
#      psql-Sitzung, laufende Sicherung …); greift trotzdem etwas WÄHREND des Einspielens zu, bricht die Prüfung vor dem
#      Festschreiben ab. Bewusst KEIN automatisches Stoppen: dieser Dienst hat keinen Zugriff auf Docker (und soll ihn nicht
#      bekommen); wer wiederherstellt, nimmt die Pause bewusst in Kauf und startet danach selbst.
#   3. Sicherheitskopie des aktuellen Stands nach /backups/vor-wiederherstellung-db-<Zeit>.sql.gz (wird nie automatisch gelöscht).
#   4. Einspielen in EINER Transaktion (psql --single-transaction, ON_ERROR_STOP=1): alten Inhalt von public leeren, Sicherung
#      einspielen, Zeilenzahlen gegen die Datei prüfen. Ein Wächter (aufgeschobener Fremdschlüssel) lässt das COMMIT scheitern,
#      falls die Datei unterwegs abreißt. Scheitert irgendetwas, rollt PostgreSQL alles zurück — der alte Stand bleibt.
# ─────────────────────────────────────────────────────────────────────────────
set -eu -o pipefail
DIR="${BACKUP_DIR:-/backups}"
DB_HOST="${PGHOST:-postgres}"; DB_USER="${POSTGRES_USER:-crm}"; DB_NAME="${POSTGRES_DB:-crm}"
export PGPASSWORD="${POSTGRES_PASSWORD:-}"
TS=$(date -u +%Y%m%d-%H%M%S)
KOPIE="$DIR/vor-wiederherstellung-db-$TS.sql.gz"
log(){ echo "[restore $(date -u +%H:%M:%S)] $*"; }
fail(){ log "FEHLER: $*"; exit 1; }
q(){ psql -X -q -At -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -c "$1"; }
trap 'rm -f "$KOPIE.part" /tmp/they-restore-$TS.rc' EXIT
usage(){
  echo "Nutzung: restore.sh [--ja] [--ohne-sicherheitskopie] [--ohne-schluessel] <db-YYYYMMDD-HHMMSS.sql.gz>"
  echo "Verfügbare Datenbank-Sicherungen in $DIR:"; ls -1 "$DIR"/db-*.sql.gz 2>/dev/null | sed 's#^.*/#  #' || echo "  (keine)"
}

JA=""; OHNE_KOPIE=""; OHNE_SCHLUESSEL=""; F=""
for a in "$@"; do
  case "$a" in
    --ja|--yes) JA=1 ;;
    --ohne-sicherheitskopie) OHNE_KOPIE=1 ;;
    --ohne-schluessel) OHNE_SCHLUESSEL=1 ;;
    -h|--hilfe|--help) usage; exit 0 ;;
    -*) echo "Unbekannte Option: $a"; usage; exit 1 ;;
    *) [ -z "$F" ] || { echo "Bitte nur EINE Sicherungs-Datei angeben."; exit 1; }; F="$a" ;;
  esac
done
if [ -n "$F" ] && [ ! -f "$F" ] && [ -f "$DIR/$F" ]; then F="$DIR/$F"; fi
if [ -z "$F" ] || [ ! -f "$F" ]; then
  echo "Sicherungs-Datei nicht gefunden: ${F:-<keine angegeben>}"; usage; exit 1
fi

# 1) Datei prüfen — bevor irgendetwas an der Datenbank passiert.
log "Prüfe $F …"
gunzip -t "$F" 2>/dev/null || fail "Datei beschädigt (gzip-Prüfung fehlgeschlagen). Nichts geändert."
# Ein Durchlauf: Kopf-/Abschlusszeile von pg_dump, enthaltene Rechte (GRANT …), Zeilen der COPY-Blöcke für die Stichproben.
STATS=$(gunzip -c "$F" | awk '
  BEGIN { w["_migrations"]; w["tenants"]; w["users"]; w["contacts"] }
  cp != "" { if ($0 == "\\.") cp = ""; else if (cp in w) n[cp]++; next }
  NR <= 12 && $0 == "-- PostgreSQL database dump" { head = 1 }
  $0 == "-- PostgreSQL database dump complete" { done = 1 }
  /^(GRANT|REVOKE) / { acl = 1 }
  /^COPY / { t = $2; sub(/^public\./, "", t); cp = t; seen[t] = 1; next }
  END { printf "%d %d %d %d %d %d %d %d %d %d %d\n", head, done, acl, seen["_migrations"], n["_migrations"], seen["tenants"], n["tenants"], seen["users"], n["users"], seen["contacts"], n["contacts"] }') \
  || fail "Datei nicht lesbar. Nichts geändert."
# shellcheck disable=SC2086
set -- $STATS
HEAD=$1 DONE=$2 ACL=$3 S_MIG=$4 N_MIG=$5 S_TEN=$6 N_TEN=$7 S_USR=$8 N_USR=$9; shift 9; S_CON=$1 N_CON=$2
[ "$HEAD" = 1 ] || fail "Keine Datenbank-Sicherung (pg_dump-Kopfzeile fehlt). Nichts geändert."
[ "$DONE" = 1 ] || fail "Sicherung unvollständig (Abschlusszeile von pg_dump fehlt — abgebrochener Dump?). Nichts geändert."
[ "$S_MIG" = 1 ] && [ "$N_MIG" -gt 0 ] && [ "$S_TEN" = 1 ] && [ "$S_USR" = 1 ] && [ "$S_CON" = 1 ] \
  || fail "Die Datei ist keine Sicherung dieses CRM (Tabellen _migrations/tenants/users/contacts fehlen). Nichts geändert."
log "Datei in Ordnung: $N_MIG Datenbank-Änderungen, $N_TEN Konto/Konten, $N_USR Nutzer, $N_CON Kontakte."

# 1b) Schlüssel (1.1.53, nur wenn eine Schlüssel-Sicherung schluessel.enc daneben liegt): Ohne den passenden Schlüssel wären gespeicherte
#     Zugangsdaten (Postfächer, Schnittstellen) und die Zwei-Faktor-Anmeldung nach dem Zurückspielen unlesbar. Die Prüfzahl im Kopf
#     der Datei (4 Zeichen) erkennt „passt nicht“, ohne den Schlüssel zu verraten.
KF="$DIR/schluessel.enc"; [ -f "$KF" ] || KF="$(dirname "$F")/schluessel.enc"
if [ -f "$KF" ] && grep -qx 'THEY-SCHLUESSEL-V1' "$KF"; then
  KPZ=$(sed -n 's/^pruefzahl=\([0-9a-f][0-9a-f][0-9a-f][0-9a-f]\)$/\1/p' "$KF" | head -n 1 || true)
  CUR="${APP_ENCRYPTION_KEY:-${ENCRYPTION_KEY:-}}"; CPZ=""
  [ -z "$CUR" ] || CPZ=$(printf '%s' "they-schluessel:$CUR" | sha256sum | cut -c1-4)
  if [ -n "$KPZ" ] && [ "$CPZ" != "$KPZ" ]; then
    if [ -z "$CUR" ]; then log "ACHTUNG: Dieser Installation fehlt der Schlüssel (APP_ENCRYPTION_KEY in der .env)."
    else log "ACHTUNG: Der Schlüssel dieser Installation passt nicht zur Schlüssel-Sicherung ${KF##*/} (z. B. neuer Server)."; fi
    echo "   Ohne den passenden Schlüssel sind gespeicherte Zugangsdaten und die Zwei-Faktor-Anmeldung nach dem Zurückspielen unlesbar."
    echo "   Schlüssel mit deiner Passphrase zurückholen — im Installationsordner auf dem Server:"
    echo "      bash schluessel.sh --zurueckholen   dann   docker compose up -d backup   und dieses Skript erneut starten."
    if [ -z "$JA" ] && [ -z "$OHNE_SCHLUESSEL" ]; then
      echo "   Bewusst ohne passenden Schlüssel weiter: mit --ohne-schluessel erneut starten. Es wurde nichts geändert."
      exit 3
    fi
    log "Weiter ohne passenden Schlüssel (${OHNE_SCHLUESSEL:+--ohne-schluessel}${JA:+ --ja}) — erst mit dem richtigen Schlüssel in der .env sind diese Daten wieder lesbar."
  fi
fi

# 2) App/Worker gestoppt, Datenbank erreichbar und unbenutzt?
#    a) Docker löst Dienstnamen nur für LAUFENDE Container auf — erkennt App/Worker auch dann, wenn ihr Datenbank-Pool gerade
#       keine Verbindung offen hat (ungenutzte Verbindungen schließt er nach 10 s). RESTORE_STOP_SERVICES= (leer) schaltet das ab.
RUN=""
for s in ${RESTORE_STOP_SERVICES-app worker}; do nslookup "$s" >/dev/null 2>&1 && RUN="$RUN $s"; done
if [ -n "$RUN" ]; then
  log "ABBRUCH: Diese Dienste laufen noch:$RUN"
  echo "Zuerst im Installationsordner stoppen:   docker compose stop app worker"
  echo "Danach dieses Skript erneut starten. Es wurde nichts geändert."
  exit 2
fi
#    b) Sonst noch etwas mit der Datenbank verbunden (zweite App, offene psql-Sitzung, laufende Sicherung …)?
q "select 1" >/dev/null 2>&1 || fail "Datenbank $DB_NAME auf $DB_HOST nicht erreichbar (läuft der Dienst postgres?). Nichts geändert."
OTHERS="select count(*) from pg_stat_activity where datname = current_database() and pid <> pg_backend_pid() and backend_type = 'client backend'"
BUSY=$(q "$OTHERS")
if [ "$BUSY" != "0" ]; then
  log "ABBRUCH: Die Datenbank wird gerade benutzt ($BUSY Verbindung(en)) — vermutlich laufen App und Worker noch."
  q "select '   - ' || coalesce(nullif(application_name, ''), 'Programm') || ' (' || coalesce(host(client_addr), 'lokal') || ', ' || coalesce(state, '?') || ')' from pg_stat_activity where datname = current_database() and pid <> pg_backend_pid() and backend_type = 'client backend'" || true
  echo "Zuerst im Installationsordner stoppen:   docker compose stop app worker"
  echo "Danach dieses Skript erneut starten. Es wurde nichts geändert."
  exit 2
fi

# Rückfrage (Terminal) bzw. --ja (Automatik).
echo "!! ACHTUNG: Die AKTUELLE Datenbank wird vollständig durch den Stand dieser Sicherung ersetzt:"
echo "   $F"
if [ -z "$JA" ]; then
  if [ -t 0 ]; then
    printf 'Zum Fortfahren WIEDERHERSTELLEN eintippen (alles andere bricht ab): '
    read -r ANTWORT || ANTWORT=""
    [ "$ANTWORT" = "WIEDERHERSTELLEN" ] || { log "Abgebrochen — nichts geändert."; exit 1; }
  else
    fail "Ohne Terminal keine Rückfrage möglich — mit --ja bestätigen. Nichts geändert."
  fi
fi

# 3) Sicherheitskopie des aktuellen Stands (falls die falsche Datei gewählt wurde).
if [ -z "$OHNE_KOPIE" ]; then
  log "Sichere den aktuellen Stand …"
  if pg_dump -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" --no-owner --clean --if-exists 2>/dev/null | gzip > "$KOPIE.part"; then
    KTAIL=$(gunzip -c "$KOPIE.part" | tail -n 12) || KTAIL=""
  else
    KTAIL=""
  fi
  case "$KTAIL" in
    *"PostgreSQL database dump complete"*) mv "$KOPIE.part" "$KOPIE"; log "Sicherheitskopie: $KOPIE" ;;
    *) fail "Sicherheitskopie des aktuellen Stands fehlgeschlagen (Platz auf $DIR?). Nichts geändert. Ist die aktuelle Datenbank ohnehin leer oder kaputt: mit --ohne-sicherheitskopie erneut starten." ;;
  esac
fi

# 4) Einspielen — alles in EINER Transaktion.
PRE=$(cat <<'SQL'
-- The-Y restore.sh (1.1.52): Wächter — festgeschrieben wird nur, wenn die Datei bis zum Ende gelesen und geprüft wurde.
SET client_min_messages = warning;
CREATE TEMP TABLE they_restore_done (id int PRIMARY KEY);
CREATE TEMP TABLE they_restore_guard (id int REFERENCES pg_temp.they_restore_done DEFERRABLE INITIALLY DEFERRED);
INSERT INTO pg_temp.they_restore_guard VALUES (1);
-- App-Rolle wie Migration 0005 (in jeder migrierten Datenbank vorhanden; Sicherungen ab 1.1.52 enthalten ihre Rechte).
DO $$ BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'crm_app') THEN
    CREATE ROLE crm_app WITH LOGIN NOSUPERUSER NOBYPASSRLS NOREPLICATION;
  END IF;
END $$;
-- Genau den Stand der Sicherung herstellen: alter Inhalt weg (auch Tabellen aus neueren Versionen), Schema wie frisch angelegt.
DROP SCHEMA public CASCADE;
DO $$ BEGIN
  IF pg_catalog.current_setting('server_version_num')::int >= 150000 THEN
    EXECUTE 'CREATE SCHEMA public AUTHORIZATION pg_database_owner';
  ELSE
    EXECUTE 'CREATE SCHEMA public';
  END IF;
END $$;
GRANT USAGE ON SCHEMA public TO PUBLIC;
SQL
)
# Sicherungen von vor 1.1.52 (pg_dump --no-privileges) bringen keine Rechte mit → Grundrechte der App-Rolle wie Migration 0005
# (so verhielt sich auch die frühere Wiederherstellung). Ab 1.1.52 stellt die Sicherung die Rechte genau wieder her.
GRANTS=""
if [ "$ACL" != 1 ]; then
  log "Hinweis: Sicherung ohne Rechte-Angaben (vor 1.1.52) — die App-Rolle crm_app bekommt die Grundrechte wie bei der Installation."
  GRANTS="GRANT USAGE ON SCHEMA public TO crm_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO crm_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO crm_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO crm_app;"
fi
POST=$(cat <<SQL
-- The-Y restore.sh: Stichprobe VOR dem Festschreiben — Zeilenzahlen müssen der Datei entsprechen.
SET client_min_messages = warning;
$GRANTS
DO \$\$
DECLARE n bigint;
BEGIN
  SELECT pg_catalog.count(*) INTO n FROM public._migrations; IF n <> $N_MIG THEN RAISE EXCEPTION 'Zeilenzahl _migrations: % statt $N_MIG', n; END IF;
  SELECT pg_catalog.count(*) INTO n FROM public.tenants;     IF n <> $N_TEN THEN RAISE EXCEPTION 'Zeilenzahl tenants: % statt $N_TEN', n; END IF;
  SELECT pg_catalog.count(*) INTO n FROM public.users;       IF n <> $N_USR THEN RAISE EXCEPTION 'Zeilenzahl users: % statt $N_USR', n; END IF;
  SELECT pg_catalog.count(*) INTO n FROM public.contacts;    IF n <> $N_CON THEN RAISE EXCEPTION 'Zeilenzahl contacts: % statt $N_CON', n; END IF;
  -- Hat während des Einspielens ein anderes Programm (App/Worker) auf die Datenbank zugegriffen, wartet es auf uns → abbrechen.
  IF EXISTS (SELECT 1 FROM pg_catalog.pg_stat_activity a WHERE a.pid <> pg_catalog.pg_backend_pid()
             AND pg_catalog.pg_backend_pid() = ANY (pg_catalog.pg_blocking_pids(a.pid))) THEN
    RAISE EXCEPTION 'Während der Wiederherstellung wollte ein anderes Programm die Datenbank benutzen (App/Worker gestartet?)';
  END IF;
END \$\$;
INSERT INTO pg_temp.they_restore_done VALUES (1);
SQL
)
LOG="$DIR/restore.log"; touch "$LOG" 2>/dev/null || LOG="/tmp/restore.log"   # /backups nur lesend (restore-test.sh) → /tmp
echo "=== restore $TS: $F ===" >> "$LOG"
log "Spiele ein (eine Transaktion; bei großen Datenbanken einige Minuten) …"
set +e
{
  printf '%s\n' "$PRE"
  gunzip -c "$F" || exit 1
  printf '%s\n' "$POST"
} | { psql -X -q -v ON_ERROR_STOP=1 --single-transaction -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" >> "$LOG" 2>&1; echo $? > "/tmp/they-restore-$TS.rc"; }
STREAM_RC=$?
set -e
PSQL_RC=$(cat "/tmp/they-restore-$TS.rc" 2>/dev/null || echo 99)
if [ "$PSQL_RC" != 0 ]; then
  log "FEHLER beim Einspielen — PostgreSQL hat ALLES zurückgerollt, die Datenbank ist unverändert. Letzte Meldungen ($LOG):"
  grep -E "ERROR|FEHLER|FATAL|psql:" "$LOG" | tail -n 5 | sed 's/^/   /'
  exit 1
fi
[ "$STREAM_RC" = 0 ] || fail "Datei konnte nicht vollständig gelesen werden, die Datenbank meldet aber Erfolg — bitte Stand prüfen ($LOG)."

# Zusammenfassung (die Zahlen wurden schon vor dem Festschreiben geprüft).
log "Wiederherstellung abgeschlossen und geprüft: $(q 'select count(*) from public._migrations') Datenbank-Änderungen, $(q 'select count(*) from public.tenants') Konto/Konten, $(q 'select count(*) from public.users') Nutzer, $(q 'select count(*) from public.contacts') Kontakte."
[ -n "$OHNE_KOPIE" ] || echo "Vorheriger Stand liegt in: $KOPIE (bei Bedarf genauso zurückspielen; wird nicht automatisch gelöscht)."
echo "Jetzt im Installationsordner:  docker compose start app worker   (die App ergänzt beim Start fehlende Datenbank-Änderungen)"
FTS=$(basename "$F" | sed -n 's/^db-\([0-9]\{8\}-[0-9]\{6\}\)\.sql\.gz$/\1/p')
if [ -n "$FTS" ] && [ -f "$DIR/files-$FTS.tar.gz" ]; then
  echo "Dateien vom selben Zeitpunkt zurückspielen (vor dem Start, MinIO gestoppt):  docker compose run --rm restore-files files-$FTS.tar.gz"
else
  echo "Dateien (Anhänge, Belege, Aufnahmen) separat zurückspielen:  docker compose run --rm restore-files <files-….tar.gz>"
fi
