#!/usr/bin/env bash
#
# CheckMK local check: WordPress
#
#   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, curl, php (fuer wp-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 ##################################
WP_BIN="${WP_BIN:-/usr/local/bin/wp}"
WP_PHAR_URL="https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar"

SEARCH_DEPTH="${SEARCH_DEPTH:-3}"          # Suchtiefe unterhalb eines Webroots
WP_TIMEOUT="${WP_TIMEOUT:-120}"            # Timeout je wp-cli Aufruf
CURL_TIMEOUT="${CURL_TIMEOUT:-8}"          # Timeout fuer den xmlrpc-Test
UPDATE_WPCLI="${UPDATE_WPCLI:-1}"          # 1 = wp cli update --yes --stable
# Gleichzeitig laufende Checks insgesamt (nicht pro Instanz). 1 = seriell.
MAX_PARALLEL="${MAX_PARALLEL:-$(nproc 2>/dev/null || echo 4)}"

# Ohne diese Flags laedt jeder wp-cli-Aufruf saemtlichen Plugin- und Theme-Code,
# bevor er irgendetwas tut. Keiner unserer Checks braucht das: Listen und
# Pruefsummen werden aus Dateien und Transients gelesen, nicht aus laufendem
# Code. Auf "" setzen, falls eine Instanz ohne ihre Plugins nicht bootet.
WP_SKIP="${WP_SKIP:---skip-plugins --skip-themes --skip-packages}"

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

# Dateien, die im Webroot nichts verloren haben -> CRIT
EXPOSED_FILES=(info.php phpinfo.php test.php adminer.php)

# Ergebnisse je major.minor innerhalb eines Laufs merken - bei 20 Instanzen auf
# derselben PHP-Version bleibt es bei einem einzigen HTTP-Request.

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

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

# Grundsatz: es werden ausschliesslich die Config-Dump-Funktionen der
# Webserver benutzt, kein Grep ueber feste Verzeichnisse. Damit werden auch
# Panels wie CloudPanel/ISPConfig/Plesk erfasst, die ihre Vhosts ueber das
# System verteilen - und es wird nur die tatsaechlich aktive Config gelesen.

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

# Apache hat kein Aequivalent zu "nginx -T". DUMP_INCLUDES (>= 2.4.34) listet
# aber genau die Dateien, die der laufende Server tatsaechlich einliest -
# inklusive aller Includes an beliebigen Orten.
apache_active_configs() {
  local bin="$1"
  "$bin" -t -D DUMP_INCLUDES 2>/dev/null \
    | sed -nE 's/^[[:space:]]*\([^)]*\)[[:space:]]+(\/.*)$/\1/p'
  # Fallback fuer aeltere Versionen: Dateipfade aus der Vhost-Uebersicht
  "$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 -T dumpt die komplette effektive Config inkl. aller Includes.
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: bevorzugt die Live-Config aus der Admin-API (das ist die wirklich
# geladene), sonst "caddy adapt" auf das Caddyfile.
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
}

# Entfernt Pfade, die bereits unterhalb eines anderen Kandidaten liegen,
# damit derselbe Baum nicht mehrfach durchsucht wird.
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 und jeder
  # -d Test schlaegt fehl.
  { apache_roots; nginx_roots; caddy_roots; printf '%s\n' $EXTRA_ROOTS; } \
    | tr -d '\r' \
    | sed '/^$/d' \
    | sort -u \
    | prune_roots
}

# Sucht unterhalb der Webroots nach wp-includes/version.php und gibt die
# WordPress-Wurzelverzeichnisse aus.
find_wp_instances() {
  local root
  discover_webroots | while read -r root; do
    find "$root" -maxdepth "$SEARCH_DEPTH" -type f -path '*/wp-includes/version.php' \
         -print 2>/dev/null
  done | while read -r f; do
    dirname "$(dirname "$f")"
  done | sort -u
}

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

# Zeigt, was die Discovery findet - ohne wp-cli.
if [[ "${1:-}" == "--roots" ]]; then
  echo "== Webroots =="
  discover_webroots | cat -A | sed 's/\$$//'
  echo
  echo "== WordPress-Instanzen =="
  find_wp_instances
  exit 0
fi

# Zeigt fuer eine Instanz, was bei der Ermittlung passiert - ohne Umleitung
# nach /dev/null, damit die Fehlermeldung von wp-cli sichtbar wird.
if [[ "${1:-}" == "--detect" ]]; then
  have apache2ctl || have apachectl || have httpd || have nginx || have caddy || exit 1
  [[ -n "$(find_wp_instances | head -n1)" ]] && exit 0
  exit 1
fi

########################### wp-cli bereitstellen ###########################

install_wpcli() {
  local tmp
  tmp="$(mktemp)" || return 1
  if have curl; then
    curl -fsSL --max-time 60 -o "$tmp" "$WP_PHAR_URL" || { rm -f "$tmp"; return 1; }
  elif have wget; then
    wget -qO "$tmp" "$WP_PHAR_URL" || { rm -f "$tmp"; return 1; }
  else
    rm -f "$tmp"; return 1
  fi
  # Plausibilitaet: muss ein PHP-Phar sein. (head -c 5 lieferte "#!/us" und
  # konnte gegen '^#!/usr' nie matchen -> Installation schlug immer fehl.)
  head -c 64 "$tmp" | grep -q '^#!/usr/bin/env php' || { rm -f "$tmp"; return 1; }
  (( $(stat -c %s "$tmp" 2>/dev/null || echo 0) > 1000000 )) || { rm -f "$tmp"; return 1; }
  chmod 0755 "$tmp"
  mv -f "$tmp" "$WP_BIN" || { rm -f "$tmp"; return 1; }
  return 0
}

# wp-cli im root-Kontext (Selbstverwaltung)
wp_root() { timeout "$WP_TIMEOUT" "$WP_BIN" --allow-root --skip-packages "$@" 2>/dev/null; }

# Panels halten mehrere PHP-Versionen parallel. wp-cli muss mit DERSELBEN
# Version laufen wie die Site: sonst laufen Plugin-Hooks unter einer anderen
# Laufzeit als im Web, und bei grossen Versionsspruengen bricht der Bootstrap
# mit Parse-Fehlern ab. Faellt die passende Version aus, wird das Default-php
# genommen - besser eine Messung mit Hinweis als gar keine.
wp_php_bin() {  # $1 = FPM-Version ("8.3") oder leer
  local v="$1" p
  if [[ -n "$v" ]]; then
    # Absoluter Pfad: unter "sudo -u" gilt secure_path, ein blosser
    # Kommandoname kann dort ins Leere laufen.
    p="$(command -v "php$v" 2>/dev/null)"
    [[ -n "$p" ]] && { printf '%s\n' "$p"; return 0; }
    for p in "/usr/bin/php$v" "/usr/local/bin/php$v" "/opt/php$v/bin/php"; do
      [[ -x "$p" ]] && { printf '%s\n' "$p"; return 0; }
    done
  fi
  p="$(command -v php 2>/dev/null)"
  printf '%s\n' "${p:-php}"
}

# wp-cli im Kontext des Owners einer Instanz
run_wp() {  # $1 = php-Binary, $2 = owner, $3 = pfad, danach wp-cli-Argumente
  local php="$1" owner="$2" path="$3"; shift 3
  local home
  local -a skip=(); [[ -n "$WP_SKIP" ]] && read -r -a skip <<< "$WP_SKIP"
  if [[ "$owner" == "root" || "$(id -u)" != "0" ]]; then
    timeout "$WP_TIMEOUT" "$php" "$WP_BIN" --allow-root --path="$path" "${skip[@]}" "$@" 2>/dev/null
  else
    home="$(getent passwd "$owner" | cut -d: -f6)"
    [[ -d "${home:-}" ]] || home="/tmp"
    timeout "$WP_TIMEOUT" sudo -n -u "$owner" \
      env HOME="$home" WP_CLI_CACHE_DIR="$home/.wp-cli/cache" \
      "$php" "$WP_BIN" --path="$path" "${skip[@]}" "$@" 2>/dev/null
  fi
}

# wp-cli schreibt "Success:"/"Warning:" nach STDOUT, nicht STDERR. Bei einer
# Wertabfrage wie --field=version wird daraus sonst ein vermeintlicher Wert
# ("Success: WordPress is at the latest version." = angebliche Update-Version).
# Diese Varianten sind fuer Wertabfragen; wp_root/run_wp bleiben unveraendert,
# weil dort teils der Exit-Code ausgewertet wird.
wpcli_msgs() { grep -vE '^[[:space:]]*(Success|Error|Warning|Notice|Debug):'; }

wp_root_val() { wp_root "$@" | wpcli_msgs; return 0; }
run_wp_val()  { run_wp  "$@" | wpcli_msgs; return 0; }   # $1 = php-Binary

########################### 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...>
# Ohne Pool wird synchron ausgefuehrt - gleiche Ausgabe, nur langsamer.
pool_run() {
  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 ###################################

# Sammelt alles, was fuer die restlichen Checks gebraucht wird, in EINEM
# wp-cli-Bootstrap. Jeder wp-Aufruf kostet gut eine Sekunde, deshalb hier
# siteurl, Core- und PHP-Version zusammen abfragen.
# Feldtrenner ist \037 (Unit Separator), NICHT Tab: Tab ist ein
# IFS-Whitespace-Zeichen, dadurch kollabieren aufeinanderfolgende Trenner und
# ein leeres Feld (z.B. fehlende siteurl) verschiebt alle folgenden Werte.
bootstrap_instance() {  # $1 = path -> "owner|siteurl|core|php|fpm|phpbin|hinweis" (\037-getrennt)
  local path="$1" owner info siteurl core_ver php_ver fpm_ver fpm_only php_bin
  owner="$(stat -c '%U' "$path/wp-includes/version.php" 2>/dev/null)"
  [[ -n "$owner" ]] || owner="root"

  # Zuerst die FPM-Version der Site bestimmen - erst danach steht fest, mit
  # welchem PHP wp-cli aufgerufen werden muss.
  fpm_ver="$(site_php_version "$path" "$owner")"   # "version|quelle"
  fpm_only="${fpm_ver%%|*}"
  php_bin="$(wp_php_bin "$fpm_only")"

  info="$(run_wp_val "$php_bin" "$owner" "$path" eval \
          'echo get_option("siteurl"),"|",get_bloginfo("version"),"|",PHP_VERSION;' | head -n1)"

  # Kommt nichts zurueck, kann das an der gewaehlten PHP-Version liegen (fehlende
  # CLI-SAPI, fehlende Extension, open_basedir). Dann einmal mit dem Default-PHP
  # nachfassen, statt die Instanz komplett als UNKNOWN zu melden.
  local fallback=""
  if [[ -z "${info//[[:space:]]/}" ]]; then
    local def; def="$(command -v php 2>/dev/null)"
    if [[ -n "$def" && "$def" != "$php_bin" ]]; then
      info="$(run_wp_val "$def" "$owner" "$path" eval \
              'echo get_option("siteurl"),"|",get_bloginfo("version"),"|",PHP_VERSION;' | head -n1)"
      [[ -n "${info//[[:space:]]/}" ]] && { php_bin="$def"; fallback=" (Rueckfall auf Default-PHP)"; }
    fi
  fi
  siteurl="$(printf  '%s' "$info" | cut -d'|' -f1)"
  core_ver="$(printf '%s' "$info" | cut -d'|' -f2)"
  php_ver="$(printf  '%s' "$info" | cut -d'|' -f3 | tr -d '[:space:]')"
  printf '%s\037%s\037%s\037%s\037%s\037%s\037%s\n' \
    "$owner" "$siteurl" "$core_ver" "$php_ver" "$fpm_ver" "${php_bin}" "$fallback"
}

check_core_update() {  # svc php owner path core_ver
  local svc="$1" php="$2" owner="$3" path="$4" core_ver="$5" core_new
  core_new="$(run_wp_val "$php" "$owner" "$path" core check-update --field=version | head -n1)"
  if [[ -n "$core_new" ]]; then
    emit 1 "$svc Core Update" "core_updates=1;1;;0" \
         "WARN - Core-Update auf $core_new verfuegbar (installiert: $core_ver)"
  else
    emit 0 "$svc Core Update" "core_updates=0;1;;0" "OK - Core $core_ver ist aktuell"
  fi
}

# Gemeinsam fuer plugin/theme - der Ablauf war zweimal identisch im Skript.
check_component_updates() {  # svc php owner path <plugin|theme>
  local svc="$1" php="$2" owner="$3" path="$4" kind="$5" list count label
  list="$(run_wp_val "$php" "$owner" "$path" "$kind" list --update=available --field=name \
          | sed '/^$/d' | paste -sd, -)"
  count=$(printf '%s' "$list" | awk -F, 'NF{print NF}'); count="${count:-0}"
  label="Plugin"; [[ "$kind" == "theme" ]] && label="Theme"
  if (( count > 0 )); then
    emit 1 "$svc $label Updates" "${kind}_updates=$count;1;;0" \
         "WARN - $count $label-Update(s): $list"
  else
    emit 0 "$svc $label Updates" "${kind}_updates=0;1;;0" "OK - alle ${label}s aktuell"
  fi
}

check_checksums() {  # svc php owner path <core|plugin>
  local svc="$1" php="$2" owner="$3" path="$4" kind="$5" out rc label="Core"
  [[ "$kind" == "plugin" ]] && label="Plugin"
  if [[ "$kind" == "core" ]]; then
    out="$(run_wp "$php" "$owner" "$path" core verify-checksums)"; rc=$?
  else
    out="$(run_wp "$php" "$owner" "$path" plugin verify-checksums --all)"; rc=$?
  fi
  if (( rc == 0 )); then
    emit 0 "$svc $label Checksums" "-" "OK - ${label}-Pruefsummen stimmen"
  else
    emit 2 "$svc $label Checksums" "-" \
         "CRIT - ${label}-Pruefsummen weichen ab: ${out:-siehe wp $kind verify-checksums}"
  fi
}

check_exposed_files() {  # svc path
  local svc="$1" path="$2" f
  local -a found=()
  for f in "${EXPOSED_FILES[@]}"; do
    [[ -f "$path/$f" ]] && found+=("$f")
  done
  if (( ${#found[@]} > 0 )); then
    emit 2 "$svc Exposed Files" "exposed_files=${#found[@]};;1;0" \
         "CRIT - Datei(en) im Webroot: ${found[*]}"
  else
    emit 0 "$svc Exposed Files" "exposed_files=0;;1;0" "OK - keine info.php/phpinfo.php im Webroot"
  fi
}

check_xmlrpc() {  # svc path siteurl
  local svc="$1" path="$2" siteurl="$3" body code hostonly
  if ! have curl; then
    emit 3 "$svc XML-RPC" "-" "UNKNOWN - curl nicht vorhanden"; return 0
  elif [[ -z "$siteurl" ]]; then
    emit 3 "$svc XML-RPC" "-" "UNKNOWN - siteurl nicht ermittelbar"; return 0
  elif [[ ! -f "$path/xmlrpc.php" ]]; then
    emit 0 "$svc XML-RPC" "-" "OK - xmlrpc.php nicht vorhanden"; return 0
  fi

  hostonly="${siteurl#*://}"; hostonly="${hostonly%%/*}"; hostonly="${hostonly%%:*}"
  body="$(xmlrpc_probe "$siteurl")"
  code="$(printf '%s' "$body" | tail -n1)"

  # HTTP 000 = curl kam gar nicht durch (oft: Domain zeigt auf eine externe IP,
  # die der Host selbst nicht erreicht). Dann nochmal gegen 127.0.0.1 mit
  # korrektem SNI/Host-Header, sonst wuerde ungeprueft OK gemeldet.
  if [[ -z "$code" || "$code" == "000" ]]; then
    body="$(xmlrpc_probe "$siteurl" "$hostonly")"
    code="$(printf '%s' "$body" | tail -n1)"
  fi

  if printf '%s' "$body" | grep -q 'methodResponse'; then
    emit 2 "$svc XML-RPC" "-" "CRIT - xmlrpc.php ist oeffentlich erreichbar und antwortet (HTTP $code)"
  elif [[ -z "$code" || "$code" == "000" ]]; then
    emit 3 "$svc XML-RPC" "-" "UNKNOWN - $siteurl vom Host aus nicht erreichbar, xmlrpc.php NICHT geprueft"
  else
    emit 0 "$svc XML-RPC" "-" "OK - xmlrpc.php nicht nutzbar (HTTP $code)"
  fi
}

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

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

  ### --- wp-cli selbst ---------------------------------------------------
  if [[ ! -x "$WP_BIN" ]] && ! have wp; then
    if install_wpcli; then
      emit 0 "WP-CLI" "-" "OK - wp-cli nach $WP_BIN installiert"
    else
      emit 2 "WP-CLI" "-" "CRIT - wp-cli fehlt und konnte nicht installiert werden"
      return 0
    fi
  fi
  [[ -x "$WP_BIN" ]] || WP_BIN="$(command -v wp)"

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

  local wpcli_ver new_ver
  wpcli_ver="$(wp_root_val cli version | awk '{print $2}')"
  new_ver="$(wp_root_val cli check-update --field=version | head -n1)"

  if [[ -n "$new_ver" ]]; then
    if [[ "$UPDATE_WPCLI" == "1" ]] && wp_root cli update --yes --stable >/dev/null; then
      wpcli_ver="$(wp_root_val cli version | awk '{print $2}')"
      emit 0 "WP-CLI" "-" "OK - wp-cli auf ${wpcli_ver:-?} aktualisiert"
    else
      emit 1 "WP-CLI" "-" "WARN - Update auf $new_ver verfuegbar (aktuell ${wpcli_ver:-?})"
    fi
  else
    emit 0 "WP-CLI" "-" "OK - wp-cli ${wpcli_ver:-?} ist aktuell"
  fi

  ### --- Instanzen -------------------------------------------------------
  local -a instances
  mapfile -t instances < <(find_wp_instances)

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

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

  pool_init || SEM_FD=""

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

  # Zwischenschritt seriell im Elternprozess: EOL-Datum je verwendeter
  # PHP-Version aufloesen. Das fuellt Memo und Datei-Cache genau einmal -
  # in den Kindprozessen waere das Memo verloren und jede Instanz wuerde
  # endoflife.date erneut befragen.
  local -a owner=() siteurl=() core=() php=() fpm=() phpbin=() note=()
  local line fv fver fsrc
  for i in "${!instances[@]}"; do
    line="$(cat "$(printf '%s/%03d.boot' "$tmpdir" "$i")" 2>/dev/null)"
    IFS=$'\037' read -r owner[i] siteurl[i] core[i] php[i] fpm[i] phpbin[i] note[i] <<< "$line"
  done

  # Phase 2: alle uebrigen Checks aller Instanzen in einem gemeinsamen Pool.
  # Bewusst nicht geschachtelt (Instanzen-Pool + Check-Pool), das wuerde bei
  # kleinem MAX_PARALLEL blockieren, weil aeussere Jobs Tokens halten.
  local svc id host out
  for i in "${!instances[@]}"; do
    path="${instances[$i]}"
    if [[ -n "${siteurl[i]:-}" ]]; then
      host="${siteurl[i]#*://}"; id="${host%%/}"
    else
      id="$(basename "$path")"
    fi
    svc="WordPress $id"

    out="$(printf '%s/%03d-00.out' "$tmpdir" "$i")"
    if [[ -z "${core[i]:-}" ]]; then
      emit 3 "$svc Status" "-" \
        "UNKNOWN - wp-cli lieferte keine Ausgabe fuer $path (Owner: ${owner[i]:-?}, versucht mit ${phpbin[i]:-php}) - Diagnose: $0 --wp-debug $path" > "$out"
      continue
    fi
    fver="${fpm[i]:-}"; fsrc="${fver#*|}"; fver="${fver%%|*}"
    [[ "$fsrc" == "$fver" ]] && fsrc=""
    emit 0 "$svc Status" "-" \
      "OK - WordPress ${core[i]} in $path (Owner: ${owner[i]:-?}, PHP ${fver:-?} via ${phpbin[i]:-php}${fsrc:+, $fsrc})${note[i]:-}" > "$out"

    local pb="${phpbin[i]:-php}"
    pool_run "$(printf '%s/%03d-01.out' "$tmpdir" "$i")" \
             check_core_update       "$svc" "$pb" "${owner[i]:-root}" "$path" "${core[i]}"
    pool_run "$(printf '%s/%03d-02.out' "$tmpdir" "$i")" \
             check_component_updates "$svc" "$pb" "${owner[i]:-root}" "$path" plugin
    pool_run "$(printf '%s/%03d-03.out' "$tmpdir" "$i")" \
             check_component_updates "$svc" "$pb" "${owner[i]:-root}" "$path" theme
    pool_run "$(printf '%s/%03d-04.out' "$tmpdir" "$i")" \
             check_checksums         "$svc" "$pb" "${owner[i]:-root}" "$path" core
    pool_run "$(printf '%s/%03d-05.out' "$tmpdir" "$i")" \
             check_checksums         "$svc" "$pb" "${owner[i]:-root}" "$path" plugin
    pool_run "$(printf '%s/%03d-06.out' "$tmpdir" "$i")" \
             check_exposed_files     "$svc" "$path"
    pool_run "$(printf '%s/%03d-07.out' "$tmpdir" "$i")" \
             check_xmlrpc            "$svc" "$path" "${siteurl[i]:-}"

    # Die PHP-Version wird nicht mehr hier bewertet - dafuer gibt es ein
    # eigenes Plugin (php-versions), das FPM-Pools und CLI getrennt prueft und
    # nicht fuer jede Instanz im selben Pool denselben Service erzeugt.
  done
  wait

  # Stabile Reihenfolge: %03d-%02d sortiert lexikografisch korrekt.
  for n in "$tmpdir"/*.out; do
    [[ -s "$n" ]] && cat "$n"
  done
  return 0
}

# PHP-Version des FPM-Pools, der diesen Pfad bedient.
#
# Ein blosses Grep ueber /etc/apache2 liefert falsche Ergebnisse: Panels wie
# ISPConfig halten neben sites-enabled/ auch sites-available/ vor, dazu
# *.vhost.err aus fehlgeschlagenen Aenderungen und Backups. Nach einem Wechsel
# von 7.3 auf 8.3 steht die alte Version dort weiterhin - und der erste
# Treffer gewinnt. Deshalb:
#   1. nur Dateien, die der laufende Webserver tatsaechlich einliest
#   2. alle gefundenen Versionen sammeln, nicht die erste nehmen
#   3. nur Versionen behalten, deren FPM-Dienst laeuft bzw. deren Socket es gibt
#   4. bleibt es mehrdeutig, gewinnt die hoechste Version
fpm_is_live() {  # $1 = "8.3"
  local v="$1"
  [[ "$(systemctl is-active "php${v}-fpm.service" 2>/dev/null)" == "active" ]] && return 0
  compgen -G "/run/php/php${v}-fpm*.sock" >/dev/null 2>&1 && return 0
  compgen -G "/var/lib/php${v}-fpm/*.sock"  >/dev/null 2>&1 && return 0
  [[ -d "/etc/php/$v/fpm" ]] && [[ -z "$(systemctl list-unit-files "php*-fpm.service" 2>/dev/null)" ]] && return 0
  return 1
}

# 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 "$@"; }

# $1 = siteurl, $2 = optional Host fuer --resolve auf 127.0.0.1
xmlrpc_probe() {
  local url="$1" host="${2:-}"
  local -a extra=()
  [[ -n "$host" ]] && extra=(--resolve "${host}:443:127.0.0.1" --resolve "${host}:80:127.0.0.1")
  curl -sk --max-time "$CURL_TIMEOUT" -X POST \
    "${extra[@]}" \
    -H 'Content-Type: text/xml' \
    --data '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName><params></params></methodCall>' \
    -w '\n%{http_code}' "${url%/}/xmlrpc.php" 2>/dev/null
}

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

########################### Debug ##########################################
# Steht bewusst am Dateiende: der Block ruft fpm_version_for_root und
# wp_php_bin auf, und bash kennt Funktionen erst nach ihrer Definition.
if [[ "${1:-}" == "--wp-debug" ]]; then
  dbg_path="${2:-}"
  if [[ -z "$dbg_path" ]]; then
    echo "Aufruf: $0 --wp-debug /pfad/zur/instanz"
    echo "Gefundene Instanzen:"; find_wp_instances | sed 's/^/  /'
    exit 2
  fi
  dbg_owner="$(stat -c '%U' "$dbg_path/wp-includes/version.php" 2>/dev/null)"
  printf 'Pfad:   %s\nOwner:  %s\n\n' "$dbg_path" "${dbg_owner:-<nicht ermittelbar>}"

  echo '=== Stufe 1: Pool-Datei, die den Webroot-Pfad enthaelt ==='
  for d in /etc/php/*/fpm/pool.d /etc/php*/fpm/pool.d; do
    [[ -d "$d" ]] || continue
    printf '  %-32s %s\n' "$d" "$(grep -rlF -- "$dbg_path" "$d" 2>/dev/null | paste -sd' ' -)"
  done
  compgen -G "/etc/php/*/fpm/pool.d" >/dev/null || echo '  KEIN /etc/php/*/fpm/pool.d vorhanden'

  echo '=== Stufe 2: Pool-Datei, die dem Owner gehoert ==='
  if [[ -n "$dbg_owner" ]]; then
    for d in /etc/php/*/fpm/pool.d /etc/php*/fpm/pool.d; do
      [[ -d "$d" ]] || continue
      printf '  %-32s %s\n' "$d" \
        "$(grep -rlE "^[[:space:]]*(\\[${dbg_owner}\\]|user[[:space:]]*=[[:space:]]*${dbg_owner})[[:space:]]*$" "$d" 2>/dev/null | paste -sd' ' -)"
    done
  fi
  echo '  --- alle Pool-Dateien ---'
  find /etc/php -maxdepth 4 -name '*.conf' -path '*pool.d*' 2>/dev/null | head -20 | sed 's/^/    /'

  echo '=== Stufe 3/4: Vhost-Dateien mit diesem Pfad ==='
  for d in /etc/nginx /etc/apache2 /etc/httpd /etc/caddy; do
    [[ -d "$d" ]] || continue
    while IFS= read -r f; do
      [[ -f "$f" ]] || continue
      printf '  %s\n    Versionen darin: [%s]\n' "$f" \
        "$(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 | paste -sd' ' -)"
    done < <(grep -rlF -- "$dbg_path" "$d" 2>/dev/null | head -5)
  done

  echo '=== Stufe 5: mod_php im Apache ==='
  printf '  mod_php geladen: %s\n' "$(apache_has_modphp && echo ja || echo nein)"
  printf '  Modulversion:    %s\n' "$(apache_modphp_version || echo '<keine>')"
  ls -1 /etc/apache2/mods-enabled/php*.load 2>/dev/null | sed 's/^/    /'

  dbg_fpm="$(site_php_version "$dbg_path" "$dbg_owner")"
  dbg_bin="$(wp_php_bin "${dbg_fpm%%|*}")"
  printf '\nERGEBNIS: fpm=[%s]  php-binary=[%s]\n' "${dbg_fpm:-<leer>}" "$dbg_bin"
  echo '=== Testaufruf (stderr sichtbar) ==='
  if [[ "$(id -u)" == "0" && "${dbg_owner:-root}" != "root" ]]; then
    dbg_home="$(getent passwd "$dbg_owner" | cut -d: -f6)"
    set -x
    sudo -n -u "$dbg_owner" env HOME="${dbg_home:-/tmp}" \
      "$dbg_bin" "$WP_BIN" --path="$dbg_path" core version
    set +x
  else
    set -x
    "$dbg_bin" "$WP_BIN" --allow-root --path="$dbg_path" core version
    set +x
  fi
  exit 0
fi

collect

exit 0