#!/usr/bin/env bash
#
# CheckMK local check: Nextcloud
#
#   ohne Parameter : erzeugt <<<local:sep(0)>>> Output
#   --detect       : exit 0 = Plugin wird auf dieser Maschine benoetigt
#   --roots        : Debug - zeigt gefundene Webroots und Instanzen
#
# Benoetigt: bash, awk, sed, find, stat, php (CLI)
#
# Achtung: kein "set -e" - viele Aufrufe duerfen fehlschlagen, ohne dass das
# Plugin abbricht. Fehler werden pro Service in einen Status uebersetzt.
set -uo pipefail

########################### Konfiguration ##################################
OCC_TIMEOUT="${OCC_TIMEOUT:-120}"          # Timeout je occ-Aufruf
SEARCH_DEPTH="${SEARCH_DEPTH:-3}"          # Suchtiefe unterhalb eines Webroots

# Gleichzeitig laufende Checks insgesamt (nicht pro Instanz). 1 = seriell.
MAX_PARALLEL="${MAX_PARALLEL:-$(nproc 2>/dev/null || echo 4)}"

# Integritaetspruefung der installierten Apps. Kostet EINEN occ-Aufruf JE APP
# (occ kennt kein check-app --all), bei 30 Apps also gut eine halbe Minute.
# Der Core-Check laeuft immer.
NC_CHECK_APPS="${NC_CHECK_APPS:-0}"

# Nextcloud kennt keine Ausschlussliste fuer die Integritaetspruefung - jede
# fremde Datei im Installationsverzeichnis wird als EXTRA_FILE gemeldet.
# Panels legen dort aber regelmaessig eigene Verzeichnisse an (ISPConfig:
# stats/ mit AWStats-Ausgaben, error/, webdav/), ebenso NAS-Systeme (@eaDir,
# #recycle) und Editoren (.swp).
#
# Der Filter gilt AUSSCHLIESSLICH fuer EXTRA_FILE. Ein geaenderter Hash
# (INVALID_HASH) oder eine fehlende Datei bleibt immer kritisch - genau das
# ist der Zweck der Pruefung, und dort darf nichts ausgeblendet werden.
NC_INTEGRITY_IGNORE="${NC_INTEGRITY_IGNORE:-^(stats|awstats|webalizer|error|webdav|cgi-bin)/|^@eaDir/|^#recycle/|\.swp$|^\.well-known/}"

# Setup-Checks aus /settings/admin/overview ("Sicherheits- & Einrichtungs-
# warnungen"). Gibt es ab Nextcloud 28 als "occ setupchecks".
NC_SETUPCHECKS="${NC_SETUPCHECKS:-1}"
SETUPCHECK_TIMEOUT="${SETUPCHECK_TIMEOUT:-180}"

# Checks, die nicht bewertet werden: erweiterte regulaere Ausdruecke, mit "|"
# getrennt, gegen den Checknamen. Der Log-Teil der Admin-Uebersicht ist
# ausdruecklich ausgenommen - dort stehen Anwendungsfehler, die nichts ueber
# die Serverkonfiguration aussagen und im Monitoring nur Rauschen erzeugen.
NC_SETUPCHECK_IGNORE="${NC_SETUPCHECK_IGNORE:-[Ll]og|LogLevel|Errors in}"

# Status fuer Befunde der Stufe "error". Default 1 (WARN): gemeldet werden soll,
# dass Einrichtungswarnungen offen sind - der Dienst selbst laeuft ja. Auf 2
# setzen, wenn "error" als CRIT gelten soll.
NC_SETUPCHECK_ERROR_STATE="${NC_SETUPCHECK_ERROR_STATE:-1}"

# Wartungsmodus und ausstehendes DB-Upgrade als WARN melden. Wer regelmaessig
# in Wartungsfenstern arbeitet, setzt das auf 0.
NC_WARN_MAINTENANCE="${NC_WARN_MAINTENANCE:-1}"

# Zusaetzliche Verzeichnisse, die immer mitgeprueft werden (Leerzeichen-getrennt)
EXTRA_ROOTS="${EXTRA_ROOTS:-}"

# Caddy Admin-API (Live-Config). Bei abweichendem Listen-Endpoint anpassen.
CADDY_ADMIN="${CADDY_ADMIN:-localhost:2019}"

########################### Helfer #########################################

have() { command -v "$1" >/dev/null 2>&1; }

# emit <state> <servicename> <perfdata|-> <text>
emit() {
  local state="$1" name="$2" perf="${3:--}" text="$4"
  name="${name//\"/}"; name="${name//$'\t'/ }"
  text="${text//$'\n'/ }"; text="${text//$'\t'/ }"
  printf '%s "%s" %s %s\n' "$state" "$name" "$perf" "$text"
}

# Flaches JSON: json_val <key> < input
json_val() {
  grep -oE "\"$1\"[[:space:]]*:[[:space:]]*(\"[^\"]*\"|[^,}]*)" \
    | head -n1 | sed -E 's/^"[^"]*"[[:space:]]*:[[:space:]]*//; s/^"//; s/"$//' \
    | tr -d '[:space:]'
}

########################### Webroot-Discovery ##############################

# Identisch zum WordPress-Plugin: ausschliesslich die Config-Dump-Funktionen
# der Webserver, kein Grep ueber feste Verzeichnisse. Damit werden auch Panels
# wie CloudPanel/ISPConfig erfasst - und nur aktive Configs gelesen.

apache_bin() {
  local b
  for b in apache2ctl apachectl httpd; do
    have "$b" && { printf '%s\n' "$b"; return 0; }
  done
  return 1
}

apache_active_configs() {
  local bin="$1"
  "$bin" -t -D DUMP_INCLUDES 2>/dev/null \
    | sed -nE 's/^[[:space:]]*\([^)]*\)[[:space:]]+(\/.*)$/\1/p'
  "$bin" -S 2>/dev/null \
    | grep -oE '\(/[^:()]+:[0-9]+\)' \
    | sed -E 's/^\(//; s/:[0-9]+\)$//'
}

apache_roots() {
  local bin f
  bin="$(apache_bin)" || return 0
  apache_active_configs "$bin" | sort -u | while read -r f; do
    [[ -f "$f" ]] || continue
    grep -hiE '^[[:space:]]*DocumentRoot[[:space:]]+' "$f" 2>/dev/null
  done | sed -E 's/^[[:space:]]*[Dd]ocument[Rr]oot[[:space:]]+//; s/"//g; s/[[:space:]]*$//'
}

nginx_roots() {
  have nginx || return 0
  nginx -T 2>/dev/null | awk '
    { sub(/#.*/, "") }
    {
      for (i = 1; i < NF; i++) {
        if ($i == "root" || $i == "alias") {
          v = $(i+1); gsub(/[;"{}]/, "", v)
          if (v ~ /^\//) print v
        }
      }
    }
  '
}

caddy_roots() {
  have caddy || return 0
  local json="" cf
  if have curl; then
    json="$(curl -s --max-time 5 "http://${CADDY_ADMIN}/config/" 2>/dev/null)"
  fi
  if [[ -z "$json" || "$json" == "null" ]]; then
    for cf in /etc/caddy/Caddyfile /usr/local/etc/caddy/Caddyfile; do
      [[ -f "$cf" ]] || continue
      json="$(caddy adapt --config "$cf" --adapter caddyfile 2>/dev/null)"
      break
    done
  fi
  [[ -n "$json" ]] || return 0
  if have jq; then
    printf '%s' "$json" | jq -r '.. | objects | select(has("root")) | .root' 2>/dev/null
  else
    printf '%s' "$json" \
      | grep -oE '"root"[[:space:]]*:[[:space:]]*"[^"]+"' \
      | sed -E 's/.*"root"[[:space:]]*:[[:space:]]*"//; s/"$//'
  fi
}

prune_roots() {
  local -a in=() out=()
  local r p keep
  mapfile -t in
  for r in "${in[@]}"; do
    r="${r%/}"
    [[ -n "$r" && "$r" != "/" && -d "$r" ]] || continue
    keep=1
    for p in "${in[@]}"; do
      p="${p%/}"
      [[ -z "$p" || "$p" == "$r" ]] && continue
      if [[ "$r" == "$p"/* ]]; then keep=0; break; fi
    done
    (( keep )) && out+=("$r")
  done
  (( ${#out[@]} )) && printf '%s\n' "${out[@]}" | sort -u
  return 0
}

discover_webroots() {
  # tr -d '\r': CloudPanel & Co. schreiben Vhosts teilweise mit CRLF. awk/sed
  # zaehlen \r nicht als Whitespace, es haengt sonst am Pfadende.
  { apache_roots; nginx_roots; caddy_roots; printf '%s\n' $EXTRA_ROOTS; } \
    | tr -d '\r' \
    | sed '/^$/d' \
    | sort -u \
    | prune_roots
}

# Marker: eine Datei "occ" plus ein version.php mit $vendor = 'nextcloud'.
# Das grenzt sauber gegen ownCloud ab, das dieselbe Struktur nutzt, aber ein
# anderes occ mit abweichenden Subkommandos hat.
is_nextcloud() {
  local d="$1"
  [[ -f "$d/occ" && -f "$d/version.php" && -d "$d/lib" ]] || return 1
  grep -qi "'nextcloud'" "$d/version.php" 2>/dev/null
}

find_nc_instances() {
  local root f d
  discover_webroots | while read -r root; do
    find "$root" -maxdepth "$SEARCH_DEPTH" -type f -name occ -print 2>/dev/null
  done | while read -r f; do
    d="$(dirname "$f")"
    is_nextcloud "$d" && printf '%s\n' "$d"
  done | sort -u
}

########################### debug mode #####################################

if [[ "${1:-}" == "--roots" ]]; then
  echo "== Webroots =="
  discover_webroots | cat -A | sed 's/\$$//'
  echo
  echo "== Nextcloud-Instanzen =="
  find_nc_instances
  exit 0
fi

########################### detect mode ####################################

if [[ "${1:-}" == "--detect" ]]; then
  have apache2ctl || have apachectl || have httpd || have nginx || have caddy || exit 1
  have php || exit 1
  [[ -n "$(find_nc_instances | head -n1)" ]] && exit 0
  exit 1
fi

########################### occ ############################################

# Panels betreiben mehrere PHP-Versionen parallel. occ mit der falschen CLI-
# Version bricht mit "This version of Nextcloud requires PHP >= x" ab, deshalb
# wenn moeglich die Version nehmen, unter der die Instanz auch im Web laeuft.
nc_php_bin() {  # $1 = fpm-version ("8.3") oder leer
  local v="$1"
  [[ -n "$v" ]] && have "php$v" && { printf 'php%s\n' "$v"; return 0; }
  [[ -n "$v" && -x "/usr/bin/php$v" ]] && { printf '/usr/bin/php%s\n' "$v"; return 0; }
  printf 'php\n'
}

# occ verweigert den Dienst als root ("Console has to be executed with the user
# that owns the file config/config.php"), daher immer im Owner-Kontext.
nc_run() {  # php owner path <args...>
  local php="$1" owner="$2" path="$3"; shift 3
  local home
  if [[ "$(id -u)" != "0" ]]; then
    timeout "$OCC_TIMEOUT" "$php" "$path/occ" \
      --no-ansi --no-interaction --no-warnings "$@" 2>/dev/null
  else
    home="$(getent passwd "$owner" | cut -d: -f6)"
    [[ -d "${home:-}" ]] || home="/tmp"
    timeout "$OCC_TIMEOUT" sudo -n -u "$owner" env HOME="$home" \
      "$php" "$path/occ" --no-ansi --no-interaction --no-warnings "$@" 2>/dev/null
  fi
}

# occ meldet das, solange ein Upgrade aussteht oder Wartungsmodus aktiv ist -
# dann liefern update:check und integrity:check-core keine belastbaren Daten.
nc_blocked() {
  grep -qiE 'require upgrade|maintenance mode|not installed' <<< "$1"
}

########################### PHP EOL ########################################

# PHP-Version, unter der DIESE Site laeuft - FPM-Pool oder mod_php.
# Rueckgabe: "version|quelle"
#
# Fuenf Stufen, weil die Zuordnung je nach Setup woanders steht:
#   1. Pool-Datei, die den Webroot-Pfad enthaelt        (ISPConfig)
#   2. Pool-Datei, deren Pool-Name/user der Owner ist   (CloudPanel)
#   3. Socket aus dem Vhost -> Pool ueber "listen" bzw. Symlinkziel
#   4. Vhost mit eindeutiger FPM-Version im Text
#   5. Apache-Vhost-Block mit FPM-Handler               (mehrere Vhosts je Datei)
#   6. mod_php: kein FPM im Spiel, PHP laeuft im Apache-Prozess
#
# Stufe 5 zuletzt: mod_php gilt fuer den gesamten Apache. Sobald ein Vhost
# einen eigenen FPM-Handler hat, gewinnt der - deshalb erst pruefen, ob
# irgendwo eine FPM-Zuordnung existiert.
apache_modphp_version() {
  local f v
  # Debian: /etc/apache2/mods-enabled/php8.3.load mit
  #   LoadModule php_module /usr/lib/apache2/modules/libphp8.3.so
  for f in /etc/apache2/mods-enabled/php*.load /etc/httpd/conf.modules.d/*php*.conf; do
    [[ -f "$f" ]] || continue
    v="$(grep -oE 'libphp[0-9]+\.[0-9]+\.so' "$f" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -n1)"
    [[ -n "$v" ]] || v="$(basename "$f" | grep -oE '[0-9]+\.[0-9]+' | head -n1)"
    [[ -n "$v" ]] && { printf '%s\n' "$v"; return 0; }
  done
  # Modul geladen, aber ohne Version im Namen (libphp7.so, libphp.so):
  # dann die Version des Moduls ueber die geladene Bibliothek bestimmen.
  for f in /usr/lib/apache2/modules/libphp*.so; do
    [[ -f "$f" ]] || continue
    v="$(basename "$f" | grep -oE '[0-9]+\.[0-9]+' | head -n1)"
    [[ -n "$v" ]] && { printf '%s\n' "$v"; return 0; }
  done
  return 1
}

# Laeuft mod_php im Apache ueberhaupt?
apache_has_modphp() {
  local b
  for b in apache2ctl apachectl httpd; do
    have "$b" || continue
    "$b" -M 2>/dev/null | grep -qiE '^[[:space:]]*php[0-9_]*_module' && return 0
  done
  compgen -G "/etc/apache2/mods-enabled/php*.load" >/dev/null && return 0
  return 1
}

# Socket-Pfad, den der Vhost dieser Site benutzt.
# nginx:  fastcgi_pass unix:/run/php/xyz.sock;
# apache: SetHandler "proxy:unix:/run/php/xyz.sock|fcgi://localhost"
vhost_socket_for_root() {  # $1 = pfad -> socketpfad
  local path="$1" d f sock
  for d in /etc/nginx /etc/apache2 /etc/httpd; do
    [[ -d "$d" ]] || continue
    while IFS= read -r f; do
      [[ -f "$f" ]] || continue
      sock="$(sed -E 's/^[[:space:]]*#.*//' "$f" \
              | grep -oE '(unix:)?/[^[:space:]"'"'"';|]*\.sock' \
              | sed 's/^unix://' | head -n1)"
      [[ -n "$sock" ]] && { printf '%s\n' "$sock"; return 0; }
    done < <(grep -rlF -- "$path" "$d" 2>/dev/null | head -n10)
  done
  return 1
}

# PHP-Version zu einem Socket. Zwei Wege, weil der Socket im Vhost heute oft
# generisch benannt und nur ein Symlink auf die versionierte Datei ist:
#   a) den Pool finden, dessen "listen" auf genau diesen Socket zeigt -
#      unabhaengig von jeder Namenskonvention
#   b) den Symlink aufloesen und die Version aus dem Zielpfad lesen
php_version_from_socket() {  # $1 = socketpfad -> "version|quelle"
  local sock="$1" real d f v
  real="$(readlink -f -- "$sock" 2>/dev/null)"; real="${real:-$sock}"

  for d in /etc/php/*/fpm/pool.d /etc/php*/fpm/pool.d; do
    [[ -d "$d" ]] || continue
    for f in "$d"/*.conf; do
      [[ -f "$f" ]] || continue
      awk -v s1="$sock" -v s2="$real" '
        /^[[:space:]]*listen[[:space:]]*=/ {
          v = $0
          sub(/^[[:space:]]*listen[[:space:]]*=[[:space:]]*/, "", v)
          sub(/[[:space:]]+$/, "", v)
          if (v == s1 || v == s2) found = 1
        }
        END { exit !found }' "$f" || continue
      v="$(sed -nE 's|.*/php/?([0-9]+\.[0-9]+)/fpm/.*|\1|p' <<< "$f")"
      [[ -n "$v" ]] && { printf '%s|FPM-Pool %s (via Socket)\n' "$v" "${f##*/}"; return 0; }
    done
  done

  # Symlink aufgeloest: /run/php/php8.3-fpm-web1.sock
  v="$(grep -oE 'php[0-9]+\.[0-9]+' <<< "$real" | grep -oE '[0-9]+\.[0-9]+' | head -n1)"
  if [[ -n "$v" ]]; then
    if [[ "$real" != "$sock" ]]; then
      printf '%s|Socket %s -> %s\n' "$v" "${sock##*/}" "${real##*/}"
    else
      printf '%s|Socket %s\n' "$v" "${sock##*/}"
    fi
    return 0
  fi
  return 1
}

site_php_version() {  # $1 = pfad, $2 = owner (optional)
  local path="$1" owner="${2:-}" f v d vers n

  # --- 1. Pool referenziert den Webroot direkt ---
  for d in /etc/php/*/fpm/pool.d /etc/php*/fpm/pool.d; do
    [[ -d "$d" ]] || continue
    f="$(grep -rlF -- "$path" "$d" 2>/dev/null | head -n1)"
    [[ -n "$f" ]] || continue
    v="$(sed -nE 's|.*/php/?([0-9]+\.[0-9]+)/fpm/.*|\1|p' <<< "$f")"
    [[ -n "$v" ]] && { printf '%s|FPM-Pool %s\n' "$v" "${f##*/}"; return 0; }
  done

  # --- 2. Pool gehoert dem Owner (CloudPanel: ein Pool je Site-Benutzer) ---
  if [[ -n "$owner" ]]; then
    for d in /etc/php/*/fpm/pool.d /etc/php*/fpm/pool.d; do
      [[ -d "$d" ]] || continue
      f="$(grep -rlE "^[[:space:]]*(\[${owner}\]|user[[:space:]]*=[[:space:]]*${owner})[[:space:]]*$" \
           "$d" 2>/dev/null | head -n1)"
      [[ -n "$f" ]] || continue
      v="$(sed -nE 's|.*/php/?([0-9]+\.[0-9]+)/fpm/.*|\1|p' <<< "$f")"
      [[ -n "$v" ]] && { printf '%s|FPM-Pool %s (Owner)\n' "$v" "${f##*/}"; return 0; }
    done
  fi

  # --- 3. Socket aus dem Vhost: praeziser als jede Namenssuche, weil er
  #        ueber "listen" direkt auf den Pool zeigt bzw. per Symlink auf die
  #        versionierte Datei ---
  local sock
  if sock="$(vhost_socket_for_root "$path")"; then
    v="$(php_version_from_socket "$sock")" && [[ -n "$v" ]] && { printf '%s\n' "$v"; return 0; }
  fi

  # --- 4. Vhost-Datei mit dem Webroot, wenn eindeutig ---
  for d in /etc/nginx /etc/apache2 /etc/httpd /etc/caddy; do
    [[ -d "$d" ]] || continue
    while IFS= read -r f; do
      [[ -f "$f" ]] || continue
      vers="$(sed -E 's/^[[:space:]]*#.*//' "$f" \
              | grep -oE 'php[0-9]+\.[0-9]+|/php/[0-9]+\.[0-9]+/' \
              | grep -oE '[0-9]+\.[0-9]+' | sort -u)"
      n="$(grep -c . <<< "$vers")"
      if [[ -n "$vers" ]] && (( n == 1 )); then
        printf '%s|Vhost %s\n' "$vers" "${f##*/}"; return 0
      fi
    done < <(grep -rlF -- "$path" "$d" 2>/dev/null | head -n10)
  done

  # --- 5. Apache: Block um den DocumentRoot ---
  for d in /etc/apache2 /etc/httpd; do
    [[ -d "$d" ]] || continue
    while IFS= read -r f; do
      [[ -f "$f" ]] || continue
      v="$(awk -v p="$path" '
        { sub(/^[[:space:]]*#.*/, "") }
        /<VirtualHost/ { blk=""; hit=0 }
        { blk = blk "\n" $0 }
        index($0, p) > 0 { hit=1 }
        /<\/VirtualHost>/ { if (hit) { print blk; hit=0 } blk="" }' "$f" \
        | grep -oE 'php[0-9]+\.[0-9]+|/php/[0-9]+\.[0-9]+/' \
        | grep -oE '[0-9]+\.[0-9]+' | head -n1)"
      [[ -n "$v" ]] && { printf '%s|Vhost-Block %s\n' "$v" "${f##*/}"; return 0; }
    done < <(grep -rlF -- "$path" "$d" 2>/dev/null | head -n10)
  done

  # --- 6. mod_php: PHP laeuft im Apache-Prozess, kein FPM ---
  if apache_has_modphp; then
    v="$(apache_modphp_version)" && [[ -n "$v" ]] \
      && { printf '%s|mod_php im Apache\n' "$v"; return 0; }
  fi

  return 0
}

# Alter Name, damit bestehende Aufrufe weiter funktionieren.
fpm_version_for_root() { site_php_version "$@"; }

########################### Job-Pool #######################################

# Globaler Semaphor ueber eine FIFO: jedes Token ist ein Byte. Kinder erben den
# Filedeskriptor und geben ihr Token selbst zurueck, dadurch gilt MAX_PARALLEL
# fuer den gesamten Lauf und nicht pro Instanz.
SEM_FD=""

pool_init() {
  local fifo i
  (( MAX_PARALLEL > 1 )) || return 1
  fifo="$(mktemp -u)" || return 1
  mkfifo "$fifo" 2>/dev/null || return 1
  exec {SEM_FD}<>"$fifo" || { rm -f "$fifo"; SEM_FD=""; return 1; }
  rm -f "$fifo"
  for (( i = 0; i < MAX_PARALLEL; i++ )); do printf '.' >&"$SEM_FD"; done
  return 0
}

pool_run() {  # <ausgabedatei> <kommando...>
  local out="$1"; shift
  if [[ -z "$SEM_FD" ]]; then
    "$@" > "$out" 2>/dev/null
    return 0
  fi
  local tok
  IFS= read -r -n1 -u "$SEM_FD" tok
  { "$@" > "$out" 2>/dev/null; printf '.' >&"$SEM_FD"; } &
}

########################### Einzelchecks ###################################

# Ein occ-Aufruf liefert Version, Wartungsmodus und ausstehendes DB-Upgrade.
# Feldtrenner ist \037 (Unit Separator), NICHT Tab: Tab ist ein
# IFS-Whitespace-Zeichen, dadurch kollabieren aufeinanderfolgende Trenner und
# ein leeres Feld (z.B. nicht ermittelbare FPM-Version) verschiebt alle
# folgenden Werte nach links.
nc_bootstrap() {  # $1 = path -> "owner|php|fpm|version|maint|dbupg|id" (\037-getrennt)
  local path="$1" owner php_bin fpm_ver php_ver status ver maint dbupg id url
  owner="$(stat -c '%U' "$path/config/config.php" 2>/dev/null)"
  [[ -n "$owner" ]] || owner="$(stat -c '%U' "$path/occ" 2>/dev/null)"
  [[ -n "$owner" ]] || owner="root"

  # FPM-Version der Instanz zuerst - occ muss mit derselben Version laufen,
  # sonst bricht es bei abweichender Anforderung ab.
  fpm_ver="$(site_php_version "$path" "$owner")"   # "version|quelle"
  php_bin="$(nc_php_bin "${fpm_ver%%|*}")"
  php_ver="$("$php_bin" -r 'echo PHP_VERSION;' 2>/dev/null | tr -d '[:space:]')"

  status="$(nc_run "$php_bin" "$owner" "$path" status --output=json)"
  ver="$(printf   '%s' "$status" | json_val versionstring)"
  maint="$(printf '%s' "$status" | json_val maintenance)"
  dbupg="$(printf '%s' "$status" | json_val needsDbUpgrade)"

  # Kein occ-Aufruf noetig: die URL steht in der config.php.
  url="$(sed -nE "s/.*'overwrite\.cli\.url'[^']*'(https?:\/\/[^']*)'.*/\\1/p" \
           "$path/config/config.php" 2>/dev/null | head -n1)"
  id="${url#*://}"; id="${id%%/*}"
  [[ -n "$id" ]] || id="$(basename "$path")"

  printf '%s\037%s\037%s\037%s\037%s\037%s\037%s\n' \
    "$owner" "$php_bin" "$fpm_ver" "$ver" "$maint" "$dbupg" "$id"
}

# update:check liefert Server- UND App-Updates in einem Aufruf, deshalb
# erzeugt dieser eine Job beide Services.
nc_check_updates() {  # svc php owner path installed_ver
  local svc="$1" php="$2" owner="$3" path="$4" cur="$5"
  local out core_new apps acount

  out="$(nc_run "$php" "$owner" "$path" update:check)"

  if [[ -z "$out" ]]; then
    emit 3 "$svc Core Update" "-" \
      "UNKNOWN - update:check ohne Ausgabe (App 'updatenotification' aktiv?)"
    emit 3 "$svc App Updates" "-" "UNKNOWN - update:check ohne Ausgabe"
    return 0
  fi
  if nc_blocked "$out"; then
    emit 3 "$svc Core Update" "-" "UNKNOWN - occ blockiert: ${out%%$'\n'*}"
    emit 3 "$svc App Updates" "-" "UNKNOWN - occ blockiert"
    return 0
  fi

  core_new="$(printf '%s\n' "$out" | sed -nE 's/^Nextcloud ([0-9][^ ]*) is available.*/\1/p' | head -n1)"
  if [[ -n "$core_new" ]]; then
    emit 1 "$svc Core Update" "core_updates=1;1;;0" \
      "WARN - Nextcloud $core_new verfuegbar (installiert: $cur)"
  else
    emit 0 "$svc Core Update" "core_updates=0;1;;0" "OK - Nextcloud $cur ist aktuell"
  fi

  apps="$(printf '%s\n' "$out" \
          | sed -nE 's/^Update for (.+) to version (.+) is available\.?$/\1 (\2)/p' \
          | paste -sd, -)"
  acount=$(printf '%s' "$apps" | awk -F, 'NF{print NF}'); acount="${acount:-0}"
  if (( acount > 0 )); then
    emit 1 "$svc App Updates" "app_updates=$acount;1;;0" \
      "WARN - $acount App-Update(s): $apps"
  else
    emit 0 "$svc App Updates" "app_updates=0;1;;0" "OK - alle Apps aktuell"
  fi
}

# integrity:check-core gibt bei Erfolg NICHTS aus und liefert auch im
# Fehlerfall Exit-Code 0 - der Status haengt allein an der Ausgabe.
nc_check_integrity() {  # svc php owner path
  local svc="$1" php="$2" owner="$3" path="$4"
  local raw parsed prc apps app aout line cat file
  local -a hard=() extra=() ignored=() bad=()

  # --output=json wird nicht von jeder Nextcloud-Version fuer dieses Kommando
  # unterstuetzt. Erst JSON versuchen, sonst die Textausgabe parsen.
  raw="$(nc_run "$php" "$owner" "$path" integrity:check-core --output=json)"
  if [[ "$raw" == *"does not exist"* || "$raw" == *"not defined"* ]] \
     || ! grep -q '{' <<< "$raw"; then
    raw="$(nc_run "$php" "$owner" "$path" integrity:check-core)"
  fi

  if grep -qi 'disabled' <<< "$raw"; then
    emit 1 "$svc Integrity" "-" \
      "WARN - Integritaetspruefung ist deaktiviert (integrity.check.disabled), Code wird nicht verifiziert"
    return 0
  fi
  if nc_blocked "$raw"; then
    emit 3 "$svc Integrity" "-" "UNKNOWN - occ blockiert: ${raw%%$'\n'*}"
    return 0
  fi
  # Bei intakter Installation gibt das Kommando gar nichts aus.
  if [[ -z "${raw//[[:space:]]/}" ]]; then
    emit 0 "$svc Integrity" "integrity_issues=0;;1;0 integrity_ignored=0" \
      "OK - Core-Integritaet in Ordnung"
    return 0
  fi

  # JSON: {"core":{"EXTRA_FILE":{"stats/index.html":{...}},"INVALID_HASH":{...}}}
  # Geparst mit PHP - das Binary ist ohnehin da, und nur so lassen sich
  # Kategorie und Dateiname sauber zuordnen.
  parsed="$(printf '%s' "$raw" | timeout 30 "$php" -r '
    $in = stream_get_contents(STDIN);
    // Beginn des JSON suchen: occ stellt gelegentlich Textwarnungen voran.
    // Es kann ein Objekt {...} ODER ein Array [] sein - "keine Befunde"
    // liefert genau [], und nur nach "{" zu suchen macht daraus faelschlich
    // einen Parse-Fehler.
    $p = strcspn($in, "{[");
    if ($p >= strlen($in)) { exit(1); }
    $j = json_decode(substr($in, $p), true);
    if (!is_array($j)) { exit(1); }
    foreach ($j as $scope => $cats) {
      if (!is_array($cats)) { continue; }
      foreach ($cats as $cat => $files) {
        if (!is_array($files)) { continue; }
        foreach ($files as $file => $_) {
          echo $cat, "\t", $file, "\n";
        }
      }
    }
  ' 2>/dev/null)"; prc=$?

  # Kein JSON verwertbar - Textausgabe parsen. Sie ist hierarchisch:
  #   - core                  <- Scope
  #     - EXTRA_FILE          <- Kategorie
  #       - stats/index.html  <- Datei
  #         - expected:       <- Detail
  # Die Zuordnung laeuft ueber die Einrichtungstiefe: alles, was tiefer
  # eingerueckt ist als die Kategoriezeile, ist eine Datei dieser Kategorie.
  if (( prc != 0 )); then
    parsed="$(awk '
      function indent(l,   i) { match(l, /[^ \t]/); return RSTART - 1 }
      {
        line = $0
        sub(/\r$/, "", line)
        if (line ~ /^[[:space:]]*$/) next
        ind = indent(line)
        # Detailzeilen ZUERST aussortieren - nach dem Entfernen des
        # Doppelpunkts unten waeren sie nicht mehr als solche erkennbar.
        if (line ~ /-[[:space:]]*(expected|current)[[:space:]]*:/) next
        txt = line
        sub(/^[[:space:]]*-[[:space:]]*/, "", txt)
        sub(/[[:space:]]*:[[:space:]]*$/, "", txt)

        if (txt ~ /^(INVALID_HASH|EXTRA_FILE|MISSING_FILE|EXCEPTION)$/) {
          cat = txt; catind = ind; next
        }
        if (cat != "" && ind <= catind) { cat = ""; next }
        if (cat != "" && txt != "") print cat "\t" txt
      }' <<< "$raw")"
    prc=0
    if [[ -z "${parsed//[[:space:]]/}" ]]; then
      emit 2 "$svc Integrity" "integrity_issues=1;;1;0" \
        "CRIT - Integritaetspruefung meldet Abweichungen, Ausgabe nicht auswertbar: $(tr '\n' ' ' <<< "$raw" | cut -c1-250)"
      return 0
    fi
  fi

  while IFS=$'\t' read -r cat file; do
    [[ -n "$cat" ]] || continue
    if [[ "$cat" == "EXTRA_FILE" ]] && [[ -n "$NC_INTEGRITY_IGNORE" ]] \
       && grep -qE "$NC_INTEGRITY_IGNORE" <<< "$file"; then
      ignored+=("$file"); continue
    fi
    case "$cat" in
      EXTRA_FILE) extra+=("$file") ;;
      *)          hard+=("$cat:$file") ;;
    esac
  done <<< "$parsed"

  local total=$(( ${#hard[@]} + ${#extra[@]} ))
  local perf="integrity_issues=$total;;1;0 integrity_ignored=${#ignored[@]}"
  local note=""
  (( ${#ignored[@]} > 0 )) && note=" (${#ignored[@]} EXTRA_FILE ignoriert: $(printf '%s ' "${ignored[@]:0:5}" | cut -c1-120)…)"

  if (( ${#hard[@]} > 0 )); then
    emit 2 "$svc Integrity" "$perf" \
      "CRIT - Core-Integritaet verletzt: ${#hard[@]} geaenderte/fehlende Datei(en): $(printf '%s | ' "${hard[@]:0:6}" | cut -c1-350)$note"
  elif (( ${#extra[@]} > 0 )); then
    # Zusaetzliche Dateien sind kein Codeeingriff - der Kern ist unveraendert.
    emit 1 "$svc Integrity" "$perf" \
      "WARN - ${#extra[@]} zusaetzliche Datei(en) im Installationsverzeichnis, Core selbst unveraendert: $(printf '%s ' "${extra[@]:0:8}" | cut -c1-300)$note"
  else
    emit 0 "$svc Integrity" "$perf" "OK - Core-Integritaet in Ordnung$note"
  fi

  [[ "$NC_CHECK_APPS" == "1" ]] || return 0

  # Optional: jede App einzeln. occ hat kein check-app --all.
  apps="$(nc_run "$php" "$owner" "$path" app:list --output=json 2>/dev/null \
          | grep -oE '"[a-z0-9_]+":"[0-9]' | cut -d'"' -f2 | sort -u)"
  while read -r app; do
    [[ -n "$app" ]] || continue
    aout="$(nc_run "$php" "$owner" "$path" integrity:check-app "$app")"
    [[ -n "${aout//[[:space:]]/}" ]] && bad+=("$app")
  done <<< "$apps"

  if (( ${#bad[@]} > 0 )); then
    emit 2 "$svc App Integrity" "app_integrity_issues=${#bad[@]};;1;0" \
      "CRIT - Signaturfehler in: ${bad[*]}"
  else
    emit 0 "$svc App Integrity" "app_integrity_issues=0;;1;0" "OK - alle App-Signaturen gueltig"
  fi
}

# Wertet "occ setupchecks --output=json" aus. Geparst wird mit PHP statt jq:
# das Binary ist ohnehin da, und die Struktur ist verschachtelt
# (Kategorie -> Checkname -> {severity, description}).
nc_check_setup() {  # svc php owner path
  local svc="$1" php="$2" owner="$3" path="$4"
  local raw parsed prc sev name desc
  local -a errors=() warnings=() ignored=()

  if [[ "$NC_SETUPCHECKS" != "1" ]]; then
    emit 0 "$svc Setup Checks" "-" "OK - Setup-Checks deaktiviert (NC_SETUPCHECKS=0)"
    return 0
  fi

  raw="$(OCC_TIMEOUT="$SETUPCHECK_TIMEOUT" nc_run "$php" "$owner" "$path" setupchecks --output=json)"

  if [[ -z "${raw//[[:space:]]/}" ]]; then
    emit 3 "$svc Setup Checks" "-" \
      "UNKNOWN - setupchecks lieferte keine Ausgabe (Nextcloud aelter als 28?)"
    return 0
  fi
  if nc_blocked "$raw"; then
    emit 3 "$svc Setup Checks" "-" "UNKNOWN - occ blockiert: ${raw%%$'\n'*}"
    return 0
  fi

  # occ stellt dem JSON gelegentlich Textwarnungen voran ("Nextcloud or one of
  # the apps require upgrade ..."). Das Abschneiden ab der ersten geschweiften
  # Klammer erledigt das PHP-Skript selbst.
  parsed="$(printf '%s' "$raw" | timeout 30 "$php" -r '
    $in = stream_get_contents(STDIN);
    // Beginn des JSON suchen: occ stellt gelegentlich Textwarnungen voran.
    // Es kann ein Objekt {...} ODER ein Array [] sein - "keine Befunde"
    // liefert genau [], und nur nach "{" zu suchen macht daraus faelschlich
    // einen Parse-Fehler.
    $p = strcspn($in, "{[");
    if ($p >= strlen($in)) { exit(1); }
    $j = json_decode(substr($in, $p), true);
    if (!is_array($j)) { exit(1); }
    foreach ($j as $cat => $checks) {
      if (!is_array($checks)) { continue; }
      foreach ($checks as $name => $r) {
        $sev  = is_array($r) ? ($r["severity"] ?? "") : "";
        $desc = is_array($r) ? ($r["description"] ?? "") : "";
        $desc = trim(preg_replace("/\s+/", " ", strip_tags((string)$desc)));
        if ($sev === "" || $sev === "success" || $sev === "info") { continue; }
        echo $sev, "\t", $name, "\t", mb_substr($desc, 0, 160), "\n";
      }
    }
  ' 2>/dev/null)"; prc=$?

  # Leere Ausgabe ist zweideutig: entweder keine offenen Punkte oder das
  # Parsen ist gescheitert. Ohne diese Unterscheidung meldet der Check bei
  # kaputtem PHP oder geaendertem JSON-Format stillschweigend OK.
  if (( prc != 0 )); then
    emit 3 "$svc Setup Checks" "-" \
      "UNKNOWN - Ausgabe von setupchecks nicht auswertbar (PHP-Exit $prc, unerwartetes JSON-Format?)"
    return 0
  fi
  if [[ -z "${parsed//[[:space:]]/}" ]]; then
    emit 0 "$svc Setup Checks" "setup_errors=0;;1;0 setup_warnings=0;1;;0" \
      "OK - keine offenen Einrichtungs- oder Sicherheitswarnungen"
    return 0
  fi

  while IFS=$'\t' read -r sev name desc; do
    [[ -n "$sev" ]] || continue
    if [[ -n "$NC_SETUPCHECK_IGNORE" ]] && grep -qE "$NC_SETUPCHECK_IGNORE" <<< "$name"; then
      ignored+=("$name"); continue
    fi
    case "$sev" in
      error)   errors+=("$name: $desc") ;;
      warning) warnings+=("$name: $desc") ;;
    esac
  done <<< "$parsed"

  local perf="setup_errors=${#errors[@]};;1;0 setup_warnings=${#warnings[@]};1;;0"
  local note=""
  (( ${#ignored[@]} > 0 )) && note=" (${#ignored[@]} ignoriert: ${ignored[*]})"

  if (( ${#errors[@]} > 0 )); then
    local est="${NC_SETUPCHECK_ERROR_STATE:-1}" etxt=(OK WARN CRIT UNKNOWN)
    emit "$est" "$svc Setup Checks" "$perf" \
      "${etxt[$est]} - ${#errors[@]} Fehler, ${#warnings[@]} Warnung(en) unter /settings/admin/overview: $(printf '%s | ' "${errors[@]}" | cut -c1-500)$note"
  elif (( ${#warnings[@]} > 0 )); then
    emit 1 "$svc Setup Checks" "$perf" \
      "WARN - ${#warnings[@]} Einrichtungswarnung(en) unter /settings/admin/overview: $(printf '%s | ' "${warnings[@]}" | cut -c1-500)$note"
  else
    emit 0 "$svc Setup Checks" "$perf" \
      "OK - keine offenen Einrichtungs- oder Sicherheitswarnungen$note"
  fi
}

########################### Ausgabe sammeln ################################

collect() {
  echo "<<<local:sep(0)>>>"

  if ! have php; then
    emit 2 "Nextcloud" "-" "CRIT - kein PHP-CLI vorhanden, occ nicht lauffaehig"
    return 0
  fi

  local -a instances
  mapfile -t instances < <(find_nc_instances)

  emit 0 "Nextcloud Instances" "instances=${#instances[@]}" \
       "OK - ${#instances[@]} Nextcloud-Instanz(en) gefunden"
  (( ${#instances[@]} )) || return 0

  local tmpdir
  tmpdir="$(mktemp -d)" || return 0
  trap 'rm -rf "$tmpdir"' RETURN

  pool_init || SEM_FD=""

  # Phase 1: ein occ-Aufruf je Instanz, parallel.
  local i n path
  for i in "${!instances[@]}"; do
    pool_run "$(printf '%s/%03d.boot' "$tmpdir" "$i")" nc_bootstrap "${instances[$i]}"
  done
  wait

  # Zwischenschritt seriell: EOL-Datum je PHP-Version aufloesen. Muss vor dem
  # Forken passieren, sonst waere das Memo in jedem Kind leer und jede Instanz
  # wuerde endoflife.date einzeln befragen.
  local -a owner=() phpbin=() fpm=() ver=() maint=() dbupg=() id=()
  local line
  for i in "${!instances[@]}"; do
    line="$(cat "$(printf '%s/%03d.boot' "$tmpdir" "$i")" 2>/dev/null)"
    IFS=$'\037' read -r owner[i] phpbin[i] fpm[i] ver[i] maint[i] dbupg[i] id[i] <<< "$line"
  done

  # Phase 2: alle uebrigen Checks aller Instanzen in einem gemeinsamen Pool.
  # Bewusst flach - ein Instanz-Pool plus innerer Check-Pool wuerde bei kleinem
  # MAX_PARALLEL blockieren, weil aeussere Jobs Tokens halten.
  local svc out st txt fver fsrc
  for i in "${!instances[@]}"; do
    path="${instances[$i]}"
    svc="Nextcloud ${id[i]:-$(basename "$path")}"

    out="$(printf '%s/%03d-00.out' "$tmpdir" "$i")"
    if [[ -z "${ver[i]:-}" ]]; then
      emit 3 "$svc Status" "-" \
        "UNKNOWN - occ status lieferte keine Version fuer $path (Owner: ${owner[i]:-?}, PHP: ${phpbin[i]:-?})" > "$out"
      continue
    fi

    st=0; txt="OK - Nextcloud ${ver[i]} in $path (Owner: ${owner[i]:-?})"
    if [[ "${maint[i]:-false}" == "true" ]]; then
      txt="Wartungsmodus AKTIV - Nextcloud ${ver[i]} in $path"
      [[ "$NC_WARN_MAINTENANCE" == "1" ]] && st=1
    elif [[ "${dbupg[i]:-false}" == "true" ]]; then
      txt="DB-Upgrade steht aus (occ upgrade) - Nextcloud ${ver[i]} in $path"
      [[ "$NC_WARN_MAINTENANCE" == "1" ]] && st=1
    fi
    fver="${fpm[i]:-}"; fsrc="${fver#*|}"; fver="${fver%%|*}"
    [[ "$fsrc" == "$fver" ]] && fsrc=""
    txt="$txt (PHP ${fver:-?} via ${phpbin[i]:-php}${fsrc:+, $fsrc})"
    emit "$st" "$svc Status" "-" "$txt" > "$out"

    pool_run "$(printf '%s/%03d-01.out' "$tmpdir" "$i")" \
             nc_check_updates   "$svc" "${phpbin[i]:-php}" "${owner[i]:-root}" "$path" "${ver[i]}"
    pool_run "$(printf '%s/%03d-03.out' "$tmpdir" "$i")" \
             nc_check_integrity "$svc" "${phpbin[i]:-php}" "${owner[i]:-root}" "$path"
    pool_run "$(printf '%s/%03d-04.out' "$tmpdir" "$i")" \
             nc_check_setup     "$svc" "${phpbin[i]:-php}" "${owner[i]:-root}" "$path"

    # Die PHP-Version wird nicht mehr hier bewertet - dafuer gibt es ein
    # eigenes Plugin (php-versions).
  done
  wait

  for n in "$tmpdir"/*.out; do
    [[ -s "$n" ]] && cat "$n"
  done
  return 0
}

########################### Main ###########################################

collect

exit 0