<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * HtpLink - der Fernverwaltungs-Connector ("htprotect.app").
 *
 * ZERO-TRUST-AGENT. Grundprinzip (siehe docs/PROTOCOL.md, link-proto/1):
 * Ein vollstaendig kompromittierter htprotect.app-Server kann KEINE Site kapern.
 * Der Server haelt keinen Signierschluessel; der Agent fuehrt NUR Befehle aus, die
 * eine gueltige Ed25519-Signatur des beim Pairing GEPINNTEN Workspace-Schluessels
 * tragen (fail-CLOSED - kein libsodium, kein Befehl) UND deren Typ in einer festen,
 * konservativen Allow-Liste steht. Alles ausserhalb existiert im Dispatcher nicht.
 *
 * Fuer Einzelnutzer ist der Connector KOMPLETT INERT: solange $cfg['link_paired']
 * nicht gesetzt ist, laeuft hier nichts (kein Heartbeat, kein Poll, kein Netz).
 *
 * Alle Secrets/Volatiles liegen im geschuetzten State (htprotect_link.json, 0600,
 * nicht web-erreichbar) - NICHT in der DB-Config.
 */

defined('_JEXEC') or die;

class HtpLink
{
    const PROTO       = 'link-proto/1';
    /**
     * Adresse des Fernverwaltungs-Dashboards. FESTE Konstante, bewusst nicht konfigurierbar
     * und vor allem NICHT aus einem Formular gelesen.
     *
     * Frueher reiste dieser Wert als verstecktes Feld `fv_relay` durch den Browser (ui.php) und
     * wurde in app.php ungeprueft aus dem POST-Body uebernommen. Zwei Probleme auf einmal:
     *  1) Eine vollstaendige fremde URL in einem POST-Parameter ist der klassische Ausloeser der
     *     OWASP-Regel 931130 („Off-Domain Reference/Link"). Viele Hoster mit mod_security
     *     beantworten das mit HTTP 406 - die Kopplung scheiterte, bevor HTProtect ueberhaupt lief.
     *  2) Der Wert bestimmt, WEM die Website ihr Schluesselmaterial anvertraut. Er gehoert damit
     *     erst recht nicht in ein Formular, auch wenn ein CSRF-Token davor liegt.
     */
    const RELAY       = 'https://htprotect.app';
    const STATE_FILE  = 'htprotect_link.json';
    const LOCK_FILE   = 'htprotect_link.lock';
    const INSTANCE_FILE       = 'htprotect_instance.id';         // stabile Installations-Kennung (Enroll-Idempotenz)
    const ENROLL_PENDING_FILE = 'htprotect_enroll_pending.json'; // vorgemerkter Auto-Enroll (postflight -> Waechter-Retry)

    const TIMEOUT     = 12;      // Sekunden je Relay-Request
    const HB_MIN      = 30;      // Default-Heartbeat (Minuten), vom Relay ueberschreibbar
    const POLL_MIN    = 30;      // Default-Command-Poll-FALLBACK (Minuten); Leerlauf-Poll wird gefaltet (s. tick)
    const WAKE_HB_SEC = 2;       // Erzwungener Wake: Mindestabstand (Sekunden) zwischen Sofort-Heartbeats
    const SKEW        = 120;     // erlaubter Vorlauf issued_at (Sek.)
    const RING        = 256;     // Ringpuffer-Groesse (nonces / erledigte cmd_ids)
    const JOB_BUDGET  = 15.0;    // Wall-Clock je Tick fuer resumierbare Jobs (Scan/Integritaet)
    // Zwei getrennte Signale, weil "laeuft lange" und "haengt" grundverschieden sind:
    //
    //  JOB_STALL = kein FORTSCHRITT mehr. Das ist das eigentliche Hänger-Signal und darf kurz
    //  sein: ein gesunder Job kommt in jedem Request ein Stueck weit voran (JOB_BUDGET 15 s,
    //  Backup inline bis BACKUP_INLINE_BUDGET 150 s). Bewegt sich 10 Minuten lang nichts,
    //  obwohl gearbeitet werden koennte, ist der Vorgang tot - nicht langsam.
    //
    //  JOB_MAX = absolute Notbremse fuer einen Job, der zwar Fortschritt meldet, aber nie
    //  fertig wird (Endlosschleife). BEWUSST grosszuegig: ein Backup einer sehr grossen
    //  Website darf Stunden dauern, solange es vorankommt - es hier zu killen wuerde echte
    //  Arbeit zerstoeren. Frueher stand hier 7200; das war fuer einen Haenger VIEL zu lang
    //  (Nutzer sassen stundenlang auf 'busy') und fuer ein Riesen-Backup zu kurz.
    const JOB_STALL       = 600;    // Sek. ohne Fortschritt (zusammen mit JOB_STALL_TRIES)
    const JOB_STALL_TRIES = 3;      // so viele ERFOLGLOSE Anlaeufe -> haengt wirklich
    const JOB_STALL_HARD  = 2700;   // Sek. ohne Fortschritt: nur im Bedien-Pfad (siehe unten)
    const JOB_MAX         = 21600;  // Sek. Gesamt-Cap (6 h) - nur noch Notbremse
    // Selbst-Zeitlimit je Job-Schritt: bei Hosts mit max_execution_time=0 wuerde ein haengender
    // Schritt sonst EWIG laufen und den flock ewig halten -> kein Tick kommt mehr durch, der
    // Watchdog wird nie erreicht (genau der literatopia.de-Vorfall). PHPs Limit killt zwar keine
    // reine I/O-Wartezeit (das erledigt der FPM/FastCGI-IO-Timeout des Hosters), aber CPU-Haenger
    // und - wichtiger - es macht die Absicht explizit statt "unendlich". Grosszuegig, damit ein
    // legitimer langer Schritt (z. B. Paket-Download beim Kern-Update) nicht faelschlich stirbt.
    const JOB_STEP_MAX    = 300;    // Sek. hartes Limit je EINZELNEM advanceJob-Schritt
    // Crash-fester Haenger-Zaehler: 'stab' wird VOR dem riskanten Schritt hochgezaehlt+persistiert
    // (bei echtem Fortschritt zurueckgesetzt). Anders als 'tries' (erst NACH dem Schritt) ueberlebt
    // er einen HARTEN Abbruch (OOM/Kill) - ein Job, dessen Schritt jedes Mal hart stirbt, wird so
    // nach wenigen Anlaeufen sauber beendet, statt bis zur 6-h-Notbremse zu blockieren.
    const JOB_CRASH_TRIES = 5;      // so viele HART abgebrochene Anlaeufe -> Job aufgeben
    const BACKUP_INLINE_BUDGET = 150; // FFR-inline-Budget (Sek.) je Request fuers Backup (unter FPM-Kill)
    const LOCK_STALE  = 180;     // (Legacy) - der flock-Lock braucht keine Stale-Erkennung mehr

    /**
     * Die feste, konservative Befehls-Allow-Liste. Der Dispatcher kennt NUR diese
     * Typen; jeder andere - selbst gueltig signiert - ist ein No-Op. Wird auch im
     * Heartbeat als 'caps' gemeldet, damit das Dashboard nur Erlaubtes anbietet.
     * BEWUSST NICHT enthalten (nur lokal, nie remote): Schild ausschalten,
     * Backend-Zugangsschutz aendern, Datei-Editor/-Schreiben, Deinstallation,
     * Malware-Auto-Loeschen. Siehe docs/PROTOCOL.md Abschnitt 6.
     */
    public static function caps()
    {
        $caps = array(
            'status.refresh',
            'update.run',
            'update.policy',
            'scan.run',
            'scan.schedule',
            'integrity.check',
            'shield.selfheal',
            // Betreiber quittiert eine KONTO-Aenderung (PW/E-Mail/neues Super-User-Konto)
            // als "bekannt" -> Konten-Baseline uebernehmen. BEWUSST nur diese Kategorie:
            // Rogue-Admin-Muster & Malware sind NICHT remote quittierbar (manuelle Pruefung).
            'threat.ack',
            // Backup-Feature (docs/BACKUP-DESIGN.md). EHRLICHE Abgrenzung: die Zero-Knowledge-
            // Grenze (PROTOCOL.md 8) gilt hier NICHT vollstaendig. Die dauerhafte Ausschluss-Liste
            // (Verzeichnisse/Tabellennamen) geht im KLARTEXT an den Relay - anders koennte das
            // Dashboard die gewaehlten Eintraege nicht anzeigen/vorhaken; 'backup.analyze' liefert
            // dieselben Namen ohnehin als Befehlsergebnis ueber den Relay. Was der Relay NIE sieht:
            // den Backup-INHALT (Download laeuft signiert direkt Site->Browser, ohne Relay) sowie
            // Fund-/Abweichungs-Pfade aus Scan/Integritaet. BEWUSST enthalten (Datensicherung +
            // kontrollierter Restore); Schild-Aus/Editor/Deinstall bleiben ausgeschlossen.
            'backup.analyze',
            'backup.run',
            'backup.schedule',
            'backup.exclude',
            'backup.download',
            'backup.delete',
            'backup.restore',
            // Joomla-Kern-Update (Engine in HtpCoreUpdate, core/coreupdate*.php). NUR gleiche
            // Major (5.x/6.x, kein Cross-Major, kein EOL-J4/J3), kopflos ueber Joomlas eigene
            // com_joomlaupdate-API + nativen Entpacker. Zero-Knowledge-Fortschritt reitet auf
            // dem vorhandenen Job-Heartbeat (kein eigenes Status-Command noetig). Ein optionales
            // Backup davor ist reine Dashboard-Orchestrierung (backup.run dann core.update).
            'core.update',
            // Automatische Joomla-Kern-Updates fern an-/ausschalten (per-Site; angewendet 1x/Tag im
            // Job-Motor bei anstehendem same-major-Update >= 5.4). Reiner Config-Schalter, kein Geheimnis.
            'core.auto',
            // KI-Steuerung (zentraler MCP-Orchestrator, Schritt 1: API-Modus). BEWUSST enthalten:
            // legt einen MINIMAL-berechtigten Dienst-User + API-Token an (KEIN Super-User -> ein Leak
            // ist Defacement, kein RCE) bzw. nimmt ihn zurueck. Das Token reist verschluesselt-
            // serverseitig ueber ai/report (HMAC), NIE durch den Browser/das Result-Log. Schild-Aus/
            // Editor/Deinstall/Malware-Loeschen bleiben - wie immer - ausgeschlossen.
            'ai.provision',
            'ai.revoke',
            'ai.status',
            // Backend-Passwortschutz als Dashboard-Massenaktion (neu setzen / ergaenzen / entfernen).
            // Passwort bzw. Anhaengsel kommt E2E-versiegelt (Sealed-Box an site_pub) - der Relay sieht
            // nie Klartext; die Site oeffnet mit site_priv und wendet es via HtpGuard an (Login-Selbsttest
            // + Rollback, kein Lockout). In caps() -> das Dashboard erkennt so, welche Sites es koennen.
            'guard.batch',
            // Notfall-Modus / Wartung fern schalten (2.6.10): die ganze Website per 503 sperren
            // (on) bzw. wieder freigeben (off) und den geheimen Zugangs-Link rotieren (rekey).
            // KEINE der bewusst ausgeschlossenen Aktionen (Schild-Aus/Editor/Deinstall/Malware-
            // Loeschen): 'on' sperrt MEHR (reversibel), 'off' stellt Zugang wieder her. Das
            // zuverlaessige Fern-'off' bei GESPERRTER Seite laeuft zusaetzlich ueber den
            // ausgenommenen rescue.php-Push (der Poll verhungert ohne Traffic); der geheime
            // Zugangs-Link reist E2E-versiegelt an den Operator im Heartbeat (emergencyBeat).
            'emergency.on',
            'emergency.off',
            'emergency.rekey',
            // Werkzeug-Ansichten (2.6.5): sysfacts on-demand neu erfassen (facts.refresh);
            // ALLE offenen Post-Install-Meldungen ausblenden (postinstall.hide); Modul-
            // Versionierung einschalten (module.versioning, wirkt NUR auf Joomla 6+ - der
            // Dispatcher gibt sonst j6_only zurueck; das Dashboard bietet die Aktion ohnehin
            // nur fuer Sites an, deren sysfacts.modver vorliegt). Kein Geheimnis, keine
            // Sealed-Box -> einfacher als guard.batch.
            'facts.refresh',
            'postinstall.hide',
            'module.versioning',
            // Fehleranzeige/Debug fern setzen (debug.set, params {level}) und Backward-Compatibility-
            // Plugin(e) reaktivieren (bc.enable). Beides OHNE Geheimnis. Besonders wertvoll im
            // Fehlerzustand: debug.set 'maximum' macht die Ursache sichtbar, bc.enable heilt einen
            // durch ein deaktiviertes B/C-Plugin ausgeloesten Fatal. debug.set schaltet bei sichtbaren
            // Stufen (simple/maximum/development) einen 30-Min-Auto-Revert auf 'none' scharf. Beide
            // Aktionen sind ZUSAETZLICH ueber den crash-festen, Ed25519-signierten Rescue-Endpunkt
            // (plugins/system/htprotectwatch/rescue.php) erreichbar - selbe Signatur, ohne Joomla-Boot.
            'debug.set',
            'bc.enable',
            // Neuanmeldung (2.6.8): Registrierung fern an-/ausschalten (reg.set, reiner com_users-
            // Params-Schalter, kein Geheimnis) und - streng gegated - Spam-Konten aufraeumen
            // (reg.cleanup: mode=preview liefert Anzahl+maskierte Beispiele+Kriterien-Hash; mode=run
            // startet den resumierbaren Batch-Delete NUR mit Confirm-Hash + Count-Lock, hartem Deps-Gate,
            // ACL-Fail-Safe und Undo-Journal; mode=restore spielt ein Journal zurueck). reg.cleanup.cancel
            // bricht einen laufenden Lauf ab. BEWUSST enthalten, WEIL streng abgesichert; das Loeschen
            // trifft nur beweisbar inerte Bot-Konten (nie eingeloggt, kein Admin, kein Autor).
            'reg.set',
            'reg.cleanup',
            'reg.cleanup.cancel',
            // Flotten-Inhalts-Suche (HtpSearch). NUR LESEND, synchron: durchsucht die
            // Content-Tabellen-Allow-Liste (Artikel introtext/fulltext, Custom-HTML-Module,
            // eigene Felder) nach einem Text/Muster und gibt Zaehler + gedeckelte, entschaerfte
            // Beispiel-SNIPPETS im log-Feld zurueck. Zero-Knowledge-Ausnahme (wie backup-excludes/
            // reg.cleanup): begrenzter Content erreicht den Operator - bewusst, weil er die Site
            // per 1-Klick-Login ohnehin voll einsehen kann; der Relay bleibt blind fuer Secrets,
            // CommandResult.log ist transient + 2000-gedeckelt. Serialisierte params-Spalten sind
            // ausgeschlossen; kein Schreibzugriff.
            'search.run',
        );
        // HINWEIS Feature-Flags (KEINE Befehle): 'graceext' (per-ext-Karenz), 'autohonest' (Bit 8
        // auto_effective + enforce) und 'ausync' (Zwei-Wege-Sync) sind KEINE Queue-Befehle und stehen
        // daher NICHT in dieser Allow-Liste. Sie werden erst beim Heartbeat-Bau an die gemeldeten caps
        // angehaengt (s. unten, ~Z.1024) - die Allow-Liste bleibt die reine Befehls-Whitelist.
        // HINWEIS 1-Klick-Login (admin.sso): BEWUSST NICHT hier - es ist KEIN Queue-Befehl,
        // sondern ein DIREKTER, relay-blind signierter Endpunkt (onAjaxHtplinksso, wie der
        // Backup-Download). Die SSO-Faehigkeit signalisiert der Heartbeat ueber 'super_users'.

        // Kern-Update-Quelle setzen (J3): die Update-Server-URL von com_joomlaupdate fern
        // setzen (z. B. die JoomlaWorks-UTD-Collection oder eine eigene Custom-URL). NUR auf
        // Joomla 3 sinnvoll -> als J3-only-cap sieht das Dashboard die Batch-Aktion automatisch
        // nur bei geeigneten J3-Sites. Reiner Config/DB-Schalter, kein Geheimnis.
        if (class_exists('HtpAutoUpdateLegacy') && HtpAutoUpdateLegacy::isJ3()) {
            $caps[] = 'core.updatesource';
        }

        // JoomShaper-J3-Patch (Helix Ultimate/Helix3): das offizielle Vendor-J3-Security-Paket
        // manuell ausloesen/zuruecknehmen (der Auto-Ausloeser haengt an autoupdate_ext; dieser
        // Befehl deckt den Fall ab, dass Sicherheitsupdates bewusst AUS sind). NUR auf Joomla 3
        // sinnvoll -> J3-only-cap: das Dashboard zeigt die Aktion automatisch nur bei J3-Sites.
        if (class_exists('HtpJoomShaperPatch') && HtpJoomShaperPatch::isJoomla310()) {
            $caps[] = 'joomshaper.patch';
        }

        // Dateimanager (2.7.2): STANDARDMAESSIG AUS, je Site einmal signiert freizuschalten. Die
        // Enable/Disable-Schalter sind IMMER da; die eigentlichen Datei-Operationen laufen NICHT ueber
        // diese Queue (2000-Byte-Ergebnis-Cap zu klein), sondern ueber den direkten, synchronen, voll
        // E2E-versiegelten Endpunkt (onAjaxHtpfiles -> HtpLink::handleFilesRequest). Der Marker 'files'
        // meldet dem Dashboard NUR, dass freigeschaltet ist (dann ist der Endpunkt scharf). BEWUSSTE
        // Ausnahme von der Grenze "nie Fern-Schreibzugriff" (Z. ~89) - daher opt-in je Site.
        if (class_exists('HtpFiles')) {
            $caps[] = 'files.enable';
            $caps[] = 'files.disable';

            if (HtpFiles::isEnabled()) {
                $caps[] = 'files';
            }
        }

        // DB-Verwaltung (Weg A): analog zum Dateimanager. STANDARDMAESSIG AUS, je Site einmal signiert
        // freigeschaltet. Enable/Disable IMMER da; die DB-Operationen laufen ueber den direkten, voll
        // E2E-versiegelten Endpunkt (onAjaxHtpdb -> HtpLink::handleDbRequest), NICHT ueber diese Queue.
        // Marker 'db' meldet dem Dashboard nur, dass freigeschaltet ist (dann ist der Endpunkt scharf).
        if (class_exists('HtpDb')) {
            $caps[] = 'db.enable';
            $caps[] = 'db.disable';

            if (HtpDb::isEnabled()) {
                $caps[] = 'db';
            }
        }

        return $caps;
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Zustand (geschuetzter data/-Ordner, 0600)
    // ─────────────────────────────────────────────────────────────────────────

    /** Pfad einer Datei im geschuetzten data/-Ordner (0600-Zone), '' wenn nicht ermittelbar. */
    private static function dataFile($name)
    {
        return function_exists('htp_state_file') ? htp_state_file($name) : '';
    }

    private static function statePath()
    {
        return self::dataFile(self::STATE_FILE);
    }

    /**
     * Stabile, dauerhafte Installations-Kennung (32 Hex). Ueberlebt Updates (liegt im data/-Ordner)
     * und dient der IDEMPOTENTEN Wiedererkennung beim Auto-Enroll: das Site-Keypair wird bei jedem
     * Kopplungs-Versuch neu erzeugt und ist daher KEIN stabiler Schluessel. Einmal erzeugt, nie geaendert.
     */
    private static function instanceId()
    {
        $file = self::dataFile(self::INSTANCE_FILE);

        if ($file !== '' && is_file($file)) {
            $v = trim((string) @file_get_contents($file));

            if (preg_match('~^[a-f0-9]{32}$~', $v)) {
                return $v;
            }
        }

        $id = self::randHex(16); // 32 Hex

        if ($file !== '' && @file_put_contents($file, $id, LOCK_EX) !== false) {
            @chmod($file, 0600);
        }

        return $id;
    }

    public static function loadState()
    {
        $file = self::statePath();

        if ($file === '' || !is_file($file)) {
            return array();
        }

        $json = json_decode((string) @file_get_contents($file), true);

        return is_array($json) ? $json : array();
    }

    private static function saveState($state)
    {
        $file = self::statePath();

        if ($file === '') {
            return;
        }

        $json = json_encode($state);

        if ($json !== false && @file_put_contents($file, $json, LOCK_EX) !== false) {
            @chmod($file, 0600);
        }
    }

    /** Ist die Site gekoppelt? Billiges Gate (nur Config, keine Datei). */
    public static function isPaired($cfg)
    {
        return !empty($cfg['link_paired']);
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Empfaenger-Bindung (Ziel-Host) fuer signierte Direkt-Autorisierungen
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Host-Kanonisierung fuer den Empfaenger-Vergleich ('aud'). MUSS byte-genau dem
     * entsprechen, was der Operator-Browser signiert: nur der Host, kleingeschrieben,
     * OHNE Schema/Port/Pfad und ohne fuehrendes "www.". Damit zerschiessen www/non-www
     * und http/https keinen funktionierenden Login. Akzeptiert sowohl eine volle URL
     * als auch einen blanken Host. Leerer Rueckgabewert = unbrauchbar (-> fail-closed).
     *
     * @param   string  $v  Host oder URL
     *
     * @return string
     */
    private static function hostNorm($v)
    {
        $v = trim((string) $v);

        if ($v === '') {
            return '';
        }

        if (strpos($v, '//') !== false) {
            // Volle URL (oder schema-relativ): nur den Host herausziehen.
            $h = @parse_url($v, PHP_URL_HOST);
            $v = is_string($h) ? $h : '';
        } else {
            // Blanker Host: evtl. angehaengten Pfad/Query abschneiden ...
            $v = preg_replace('~[/?#].*$~', '', $v);

            // ... und den Port (IPv6 in eckigen Klammern dabei nicht zerlegen).
            if (strpos($v, ']') === false) {
                $p = strpos($v, ':');

                if ($p !== false) {
                    $v = substr($v, 0, $p);
                }
            }
        }

        $v = rtrim(strtolower(trim((string) $v)), '.');

        if (strpos($v, 'www.') === 0) {
            $v = substr($v, 4);
        }

        return preg_match('~^[a-z0-9._\-\[\]:]{1,253}$~', $v) ? $v : '';
    }

    /**
     * Der eigene, kanonische Host - Vergleichsbasis fuer die Empfaenger-Bindung ('aud')
     * signierter SSO-/Download-Autorisierungen.
     *
     * 1) Beim Pairing GEPINNT (Config 'site_host'; im Backend erfasst, dort stammt der Host
     *    aus dem Browser des Betreibers) -> unveraenderlich, das ist der Normalfall.
     * 2) Uebergang fuer Bestandsinstallationen ohne Pin: die vertrauenswuerdige Basis
     *    (live_site bzw. die im Backend erfasste Site-Adresse).
     *
     * NIE aus dem Request abgeleitet (weder $_SERVER['HTTP_HOST'] noch JUri::root() ohne
     * live_site - Joomla baut die Wurzel dann genau aus diesem Header). Sonst koennte ein
     * Angreifer beim Wiedereinspielen den Vergleich mit dem Host-Header selbst bestimmen und
     * die Empfaenger-Bindung waere wirkungslos. Ist nichts Vertrauenswuerdiges bekannt, wird
     * '' geliefert -> der Aufrufer weist fail-closed ab, bis der Betreiber das Backend besucht
     * (dort pinnt maybePinSiteHost() den Host einmalig nach).
     *
     * @return string  kanonischer Host oder '' (dann fail-closed beim Aufrufer)
     */
    public static function siteHostPinned()
    {
        $cfg = class_exists('HtpConfig') ? HtpConfig::load() : array();
        $cfg = is_array($cfg) ? $cfg : array();

        $pin = isset($cfg['site_host']) ? self::hostNorm($cfg['site_host']) : '';

        if ($pin !== '') {
            return $pin;
        }

        $base = function_exists('htp_trusted_base')
            ? (string) htp_trusted_base(isset($cfg['unsub_base']) ? $cfg['unsub_base'] : '', true)
            : '';
        return self::hostNorm($base);
    }

    /**
     * Bestandsinstallationen (schon gekoppelt, aber ohne gepinnten Host) einmalig nachpinnen.
     * STRENG nur aus einem authentifizierten Backend-Request eines berechtigten Betreibers -
     * dort stammt der Host aus dessen Browser und ist vertrauenswuerdig; aus einem beliebigen
     * Frontend-Request waere er faelschbar. Ist bereits gepinnt: unveraenderlich, kein Update.
     * Strikt fail-open (schlaegt es fehl, greift weiter der Uebergangs-Vergleich).
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return void
     */
    private static function maybePinSiteHost($cfg)
    {
        try {
            if (!class_exists('HtpConfig')) {
                return;
            }

            if (is_array($cfg) && isset($cfg['site_host']) && self::hostNorm($cfg['site_host']) !== '') {
                return; // schon gepinnt -> unveraenderlich
            }

            $app = function_exists('htp_app') ? htp_app() : null;

            if (!is_object($app)) {
                return;
            }

            // Nur Backend (J4+ isClient, J2.5/J3 isAdmin).
            if (method_exists($app, 'isClient')) {
                if (!$app->isClient('administrator')) {
                    return;
                }
            } elseif (method_exists($app, 'isAdmin')) {
                if (!$app->isAdmin()) {
                    return;
                }
            } else {
                return;
            }

            $user = function_exists('htp_user') ? htp_user() : null;

            if (!is_object($user) || !empty($user->guest) || !method_exists($user, 'authorise')
                || !$user->authorise('core.admin', 'com_htprotect')) {
                return; // nur ein angemeldeter Betreiber mit voller Berechtigung (wie pair())
            }

            $host = self::hostNorm(function_exists('htp_site_url') ? htp_site_url() : '');

            if ($host === '') {
                return;
            }

            // saveKeys() (NICHT save()): save() wuerde die gesamte Schild-Konfiguration ersetzen.
            HtpConfig::saveKeys(array('site_host' => $host));
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open
        }
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Scheduler-Einstieg (aus htpRun() [Traffic] und runStep() [Cron])
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Selbst-gedrosselter Herzschlag: Heartbeat (alle hb_min) + Command-Poll
     * (alle poll_min oder sofort, wenn das Relay poll=true meldet) + Fortschritt
     * laufender Jobs. Strikt fail-open; wird gefahrlos aus BEIDEN Treiberpfaden
     * aufgerufen (der jeweils zuerst feuernde gewinnt, der andere no-op't per Lock).
     */
    public static function tick($cfg = null, $forcePoll = false)
    {
        try {
            if ($cfg === null && class_exists('HtpConfig')) {
                $cfg = HtpConfig::load();
            }

            if (empty($cfg['link_paired'])) {
                return; // INERT - Einzelnutzer/ungekoppelt: hier passiert nie etwas.
            }

            if (!defined('HTP_ENGINE_OK') || !HTP_ENGINE_OK) {
                return;
            }

            if (!class_exists('HtpProbe') || !HtpProbe::available()) {
                return; // ohne ausgehende HTTP-Faehigkeit kein Sinn
            }

            // Bestandsinstallationen: den eigenen Host einmalig nachpinnen (No-Op, sobald gepinnt,
            // und No-Op ausserhalb eines authentifizierten Backend-Requests).
            self::maybePinSiteHost($cfg);

            $st = self::loadState();

            if (empty($st['paired']) || empty($st['relay']) || empty($st['site_id']) || empty($st['transport_secret'])) {
                return; // Config sagt gekoppelt, State fehlt/inkonsistent -> nichts tun
            }

            // Re-Entrancy: kurzer Lock, damit sich Traffic- und Cron-Tick nicht ueberlappen.
            $lock = self::acquireLock();

            if ($lock === false) {
                return;
            }

            try {
                $now = time();

                // Offene Befehls-Ergebnisse durabel nachsenden (No-Op, wenn keine anstehen) - so geht
                // ein durch einen verlorenen POST nicht bestaetigtes Ergebnis nie dauerhaft verloren.
                self::flushResults($st);

                // 0) laufenden Job (Scan/Integritaet) voranbringen
                if (!empty($st['job'])) {
                    self::advanceJob($st, $cfg);
                }

                // 1) geplanten Scan bzw. geplantes Backup (Kadenz) ggf. starten
                self::maybeScheduledScan($st, $cfg, $now);
                self::maybeScheduledBackup($st, $cfg, $now);

                // 2) Heartbeat, wenn faellig. Regulaer alle hb_min. Beim ERZWUNGENEN Wake (das
                //    Dashboard bittet aktiv um frischen Status - z. B. weil der Betreiber gerade
                //    ein rotes/gelbes Problem behoben hat oder eine scheinbar tote Site pruefen
                //    will) darf der Heartbeat deutlich frueher feuern, damit die neue Bewertung
                //    binnen Sekunden statt erst nach bis zu hb_min sichtbar wird. Eigene kurze
                //    Drossel (WAKE_HB_SEC) verhindert Heartbeat-Fluten bei haeufigen Wakes;
                //    statusPayload() wird bei JEDEM Heartbeat frisch berechnet -> aktueller Stand.
                $hbMin = max(5, (int) (!empty($st['hb_min']) ? $st['hb_min'] : self::HB_MIN));
                $poll  = false;

                $hbGate = $hbMin * 60;

                // Erzwungener Wake = das Dashboard bittet aktiv um frischen Status, i. d. R. direkt
                // nach einer rescue.php-Aktion (Debug / Notfall / B/C). Dann den Heartbeat mit einer
                // SEKUNDEN-Drossel statt hb_min zulassen, damit schnelles Hin-und-Her binnen weniger
                // Sekunden im Dashboard steht. Der Wake-Endpunkt ist selbst auf ~1/s gedrosselt
                // (Marker-Datei) -> das begrenzt die Heartbeat-Rate hart, ein Fluten ist so ausgeschlossen.
                if ($forcePoll) {
                    $hbGate = self::WAKE_HB_SEC;
                }

                if (($now - (int) (isset($st['last_beacon']) ? $st['last_beacon'] : 0)) >= $hbGate) {
                    $poll = self::heartbeat($st, $cfg);
                }

                // 3) Command-Poll, wenn vom Relay angefordert (poll=true, es warten Befehle) ODER
                //    ERZWUNGEN (forcePoll: Nudge/Wake-Endpunkt, weil ein Befehl wartet -> sofort
                //    abholen, Drossel umgangen; Besucher-Pfad unberuehrt) ODER periodisch als Netz.
                //
                //    SERVERSCHONUNG (2.6.13, Leerlauf-Poll gefaltet): Der Heartbeat beantwortet
                //    bereits „gibt es Befehle?" (poll-Flag, s. o.) und feuert alle hb_min. Solange
                //    ein FRISCHER Heartbeat vorliegt (< hb_min alt), ist ein separater periodischer
                //    Leerlauf-Poll ueberfluessig -> er entfaellt. Wartende Befehle kommen weiterhin
                //    SOFORT per Nudge/rescue.php bzw. spaetestens beim naechsten Heartbeat (<= hb_min).
                //    Der periodische Poll bleibt nur als Sicherheitsnetz, falls der Heartbeat-Kanal
                //    laenger als hb_min still ist (z. B. nach Ausfall + Wiederkehr).
                $pollMin     = max(1, (int) (!empty($st['poll_min']) ? $st['poll_min'] : self::POLL_MIN));
                $hbFresh     = ($now - (int) (isset($st['last_beacon']) ? $st['last_beacon'] : 0)) < $hbMin * 60;
                $periodicDue = !$hbFresh && ($now - (int) (isset($st['last_poll']) ? $st['last_poll'] : 0)) >= $pollMin * 60;

                if ($poll || $forcePoll || $periodicDue) {
                    self::poll($st, $cfg);
                }

                // Laeuft ein langer Job (Scan/Integritaet/Backup)? Nach dem Response FFR-inline zu
                // Ende rechnen (Stufe 3) - sonst kaeme er auf traffic-armen Seiten nicht voran.
                // Im gehaltenen Lock (kein Doppellauf).
                if (self::jobActive($st)) {
                    self::jobInline($st, $cfg);
                }

                self::saveState($st);
            } catch (Exception $e) {
                // fail-open
            } catch (Throwable $e) {
                // fail-open
            }

            self::releaseLock($lock);

            // Reichte das Inline-Budget nicht (riesige Site)? -> frischen Wake feuern, der NACH der
            // Lock-Freigabe weiterrechnet (erst jetzt, sonst faende der Wake den Lock noch belegt).
            if (self::jobActive($st)) {
                self::fireJobWake($st);
            }
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open
        }
    }

    private static function acquireLock()
    {
        $dir = function_exists('htp_state_dir') ? htp_state_dir() : '';

        if ($dir === '') {
            return null; // ohne State-Dir ohne Lock weiterlaufen (Best effort)
        }

        $path = rtrim(str_replace('\\', '/', $dir), '/') . '/' . self::LOCK_FILE;

        // flock statt unlink+fopen('x'): race-frei (kein TOCTOU beim Aufraeumen verwaister
        // Locks) und selbst-freigebend, wenn ein Tick abstuerzt (OS gibt den flock beim
        // Prozessende frei). LOCK_NB -> nicht warten, sondern sofort abbrechen.
        $fh = @fopen($path, 'c');

        if ($fh === false) {
            return null; // Lock-Datei nicht oeffenbar -> best effort ohne Lock
        }

        if (!@flock($fh, LOCK_EX | LOCK_NB)) {
            @fclose($fh);

            return false; // ein anderer Tick haelt den Lock
        }

        return array('fh' => $fh, 'path' => $path);
    }

    private static function releaseLock($lock)
    {
        if (is_array($lock) && isset($lock['fh'])) {
            @flock($lock['fh'], LOCK_UN);
            @fclose($lock['fh']); // Datei bleibt liegen (fester Name); der flock ist frei
        }
    }

    /** Laeuft gerade ein (langer, resumierbarer) Job? (Scan / Integritaet / Backup) */
    private static function jobActive($st)
    {
        return !empty($st['job']);
    }

    /**
     * Haengt der eingetragene Job laenger als erlaubt (JOB_MAX)?
     *
     * HINTERGRUND (echter Vorfall): Die Verfallszeit wurde bisher AUSSCHLIESSLICH in
     * advanceJob() geprueft - und advanceJob laeuft nur im Tick, der zuvor die Lock-Datei
     * braucht. Haelt ein abgestuerzter/haengender Prozess diesen Lock, kommt der Tick nie
     * durch, der Job wird nie vorangebracht und laeuft deshalb auch NIE in seine
     * Verfallszeit. Folge: jeder weitere Scan/Integritaetslauf wurde dauerhaft mit 'busy'
     * abgewiesen - auf einer betroffenen Website ueber 14 Stunden lang.
     *
     * Diese Pruefung braucht weder Lock noch Tick und macht den Zustand selbstheilend.
     *
     * @param   array  $st  Zustand
     *
     * @return  boolean
     */
    private static function jobStale($st, $bedienpfad = false)
    {
        if (empty($st['job']) || !is_array($st['job'])) {
            return false;
        }

        $job     = $st['job'];
        $now     = time();
        $started = isset($job['started']) ? (int) $job['started'] : 0;

        // Fehlender/unsinniger Startzeitpunkt: kann nie regulaer enden -> sofort raeumen.
        if ($started <= 0) {
            return true;
        }

        // Notbremse: laeuft absurd lange, auch wenn er Fortschritt meldet.
        if (($now - $started) > self::JOB_MAX) {
            return true;
        }

        // Uhr-Sprung (Zeitumstellung, NTP-Korrektur, falsch gestellter Server): ein in der
        // ZUKUNFT startender Job wuerde sonst nie ablaufen. Als haengend behandeln.
        if ($started > $now + 300) {
            return true;
        }

        // Das eigentliche Haenger-Signal: seit wann kein Fortschritt mehr? Ist 'advanced'
        // nicht gesetzt (Job von einer aelteren Fassung angelegt), gilt der Start als
        // letzter Fortschritt - dann greift dieselbe Regel.
        $letzter = isset($job['advanced']) ? (int) $job['advanced'] : $started;

        if ($letzter <= 0 || $letzter > $now + 300) {
            return true;
        }

        $ohneFortschritt = $now - $letzter;

        // WICHTIG - reine Wanduhr reicht hier NICHT: Jobs kommen nur voran, wenn die Website
        // Besucher hat (verkehrsgetriebener Scheduler, ~150 s Arbeit je Request). Auf einer
        // ruhigen Seite bedeutet "seit 10 Minuten kein Fortschritt" nur "es war niemand da" -
        // ein gesundes Backup wuerde sonst reihenweise abgeschossen.
        //
        // Massgeblich ist deshalb: Wir hatten die GELEGENHEIT zu arbeiten und es bewegte sich
        // trotzdem nichts. Genau das zaehlt 'tries' (in advanceJob hochgezaehlt, bei echtem
        // Fortschritt zurueckgesetzt) - unabhaengig vom Besucheraufkommen.
        // Crash-fest + traffic-unabhaengig: so viele HART abgebrochene Anlaeufe (der Schritt starb
        // - OOM/Timeout-Kill - VOR dem Fortschritts-Stempel; 'stab' wird VOR dem Schritt hoch-
        // gezaehlt+persistiert). Faengt genau die Schleifen, die 'tries' (erst NACH dem Schritt)
        // nie zu sehen bekommt - ohne 6 h auf JOB_MAX zu warten.
        if ((int) (isset($job['stab']) ? $job['stab'] : 0) >= self::JOB_CRASH_TRIES) {
            return true;
        }

        $versuche = isset($job['tries']) ? (int) $job['tries'] : 0;

        if ($versuche >= self::JOB_STALL_TRIES && $ohneFortschritt > self::JOB_STALL) {
            return true;
        }

        // Bedien-Pfad (der Betreiber startet gerade selbst etwas): Hier gilt zusaetzlich eine
        // reine Zeitgrenze. Grund: Klemmt die Tick-Sperre, laeuft advanceJob NIE und 'tries'
        // waechst nie - genau der Vorfall, bei dem eine Website 14 Stunden blockiert war. Der
        // Klick des Betreibers beweist, dass die Seite Requests bedient; ein Vorgang, der seit
        // 45 Minuten steht, ist dann nichts mehr, worauf man warten sollte.
        //
        // BEWUSST nur hier und nicht im Tick: Im Hintergrund wuerde dieselbe Regel ein bloss
        // ausgehungertes (statt totes) Backup stillschweigend verwerfen. Im Bedien-Pfad ist der
        // Betreiber anwesend, will jetzt etwas starten und bekommt eine Rueckmeldung.
        return $bedienpfad && $ohneFortschritt > self::JOB_STALL_HARD;
    }

    /**
     * Raeumt einen abgelaufenen Job weg, damit ein neuer starten kann. Gibt true zurueck,
     * wenn danach kein Job mehr eingetragen ist.
     *
     * @param   array  $st   Zustand
     * @param   array  $cfg  Konfiguration
     *
     * @return  boolean
     */
    private static function clearStaleJob(&$st, $cfg)
    {
        if (empty($st['job'])) {
            return true;
        }

        // true = Bedien-Pfad: clearStaleJob wird ausschliesslich beim manuellen Start gerufen,
        // dort gilt zusaetzlich die harte Zeitgrenze (siehe jobStale).
        if (!self::jobStale($st, true)) {
            return false; // laeuft wirklich noch -> 'busy' ist korrekt
        }

        self::finishJob($st, $cfg, false, 'job_timeout', 0, array());

        return empty($st['job']);
    }

    /**
     * Live-Fortschritt eines laufenden Jobs FUER DEN HEARTBEAT - Zero-Knowledge: nur Typ/Phase/
     * Prozent, nie Pfade/Namen. So zeigt das Dashboard einen echten Balken statt nur Spinner.
     *
     * @param   array  $st  Link-State
     *
     * @return array|null
     */
    private static function jobProgressPublic($st)
    {
        if (empty($st['job']) || !is_array($st['job'])) {
            return null;
        }

        $j = $st['job'];

        return array(
            'type'  => (string) (isset($j['type']) ? $j['type'] : ''),
            // Vorgang innerhalb des Typs: '' = normales Backup, 'restore' = Wiederherstellung.
            // Ohne diese Angabe koennte das Dashboard beide nicht unterscheiden und muesste den
            // Restore ohne Fortschrittsanzeige laufen lassen (Blindflug).
            'op'    => (string) (isset($j['op']) ? $j['op'] : ''),
            'phase' => (string) (isset($j['phase']) ? $j['phase'] : ''),
            'pct'   => max(0, min(100, (int) round(100 * (float) (isset($j['progress']) ? $j['progress'] : 0)))),
        );
    }

    /**
     * Einen laufenden Job NACH dem Response FFR-inline zu Ende rechnen (bis fertig ODER Budget).
     * Gilt fuer ALLE langen Jobs (Scan/Integritaet/Backup, Self-Trigger-Verkettung, Stufe 3): kein
     * Job darf am naechsten Traffic-Tick haengen. Der Besucher ist bedient, die Verbindung wird
     * getrennt, dann laeuft der Job im selben, voll gebooteten Request weiter. Reicht das Budget
     * nicht (riesige Site), zieht ein frischer Wake-Self-Call nach. Laeuft im gehaltenen tick-Lock.
     *
     * @param   array  &$st  Link-State
     * @param   array  $cfg  Konfiguration
     *
     * @return void
     */
    private static function jobInline(&$st, $cfg)
    {
        if (function_exists('fastcgi_finish_request')) {
            @fastcgi_finish_request(); // Besucher/Relay sofort trennen (idempotent, ggf. schon im Wake)
        }

        if (function_exists('ignore_user_abort')) {
            @ignore_user_abort(true);
        }

        // Gedeckelt statt unendlich: advanceJob RESETtet das Limit ohnehin je Schritt (JOB_STEP_MAX),
        // hier nur der konsistente Startwert - so haelt ein haengender Schritt den flock nicht ewig.
        @set_time_limit(self::JOB_STEP_MAX);

        $deadline = time() + self::BACKUP_INLINE_BUDGET;
        $lastHb   = 0;

        while (self::jobActive($st) && time() < $deadline) {
            self::advanceJob($st, $cfg); // ein Schritt (bis JOB_BUDGET); bei Abschluss finishJob -> job weg
            self::saveState($st);

            // Live-Fortschritt: alle ~5 s einen Heartbeat mit Job-Progress senden (nur solange der
            // Job noch laeuft; der Abschluss meldet finishJob ohnehin sofort). Outbound an den
            // Relay - KEIN Self-HTTP, also keine IP-/Rate-Limit-Beruehrung.
            if (self::jobActive($st) && (time() - $lastHb) >= 5) {
                self::heartbeat($st, $cfg);
                $lastHb = time();
            }
        }
    }

    /**
     * Frischen Wake-Self-Call feuern, damit ein NEUER Request den Job weiterrechnet (fire-and-
     * forget; der Handler laeuft serverseitig via ignore_user_abort weiter). Nur wenn das Inline-
     * Budget nicht reichte. Host-poisoning-feste Basis (triggerBase). Best effort.
     *
     * @param   array  $st  Link-State
     *
     * @return void
     */
    private static function fireJobWake($st)
    {
        $base = (class_exists('HtpAutoUpdate') && method_exists('HtpAutoUpdate', 'triggerBase'))
            ? (string) HtpAutoUpdate::triggerBase()
            : '';

        if (!preg_match('~^https?://~i', $base) || !function_exists('curl_init')) {
            return;
        }

        $url = rtrim($base, '/') . '/index.php?option=com_ajax&plugin=htplinkwake&group=system&format=raw';

        // Ein Hop. Auf mehrsprachigen/SEF-Sites leitet der Language-Filter die sprach-unpraefixierte
        // URL per 301 auf /<lang>/ um -> der Wake-Handler liefe sonst nie (Backup-Job wird erst beim
        // naechsten Traffic weitergerechnet). Wir folgen dem Redirect NICHT automatisch (das koennte
        // die SSRF-Haertung aushebeln bzw. den POST auf eine fremde Adresse tragen), sondern pruefen
        // ihn selbst und folgen GENAU EINEM Hop - aber nur SAME-ORIGIN (Schema+Host+Port). Der Wake
        // traegt kein Geheimnis, daher ist das Folgen ohne Nebenwirkung.
        $loc = self::wakePost($url, true);

        if ($loc !== '' && self::sameOrigin($url, $loc)) {
            self::wakePost($loc, false);
        }
    }

    /**
     * Einen Backup-Wake-POST absetzen (fire-and-forget: kurzer Timeout, der Handler laeuft via
     * ignore_user_abort weiter; TLS verifiziert; KEIN Auto-Follow). Liefert bei $wantRedirect und
     * einem 3xx die von cURL aufgeloeste Redirect-Ziel-URL (zum EINMALIGEN same-origin-Folgen),
     * sonst ''.
     *
     * @param   string   $url           Wake-URL
     * @param   boolean  $wantRedirect  true -> bei 3xx die Ziel-URL zurueckgeben
     *
     * @return string
     */
    private static function wakePost($url, $wantRedirect)
    {
        $loc = '';

        try {
            $ch = curl_init($url);

            if ($ch === false) {
                return '';
            }

            @curl_setopt_array($ch, array(
                CURLOPT_POST           => true,
                CURLOPT_POSTFIELDS     => 'htp_backup_wake=1',
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT        => 2,   // fire-and-forget (Handler laeuft weiter)
                CURLOPT_CONNECTTIMEOUT => 3,
                CURLOPT_SSL_VERIFYPEER => true,
                CURLOPT_SSL_VERIFYHOST => 2,
                CURLOPT_FOLLOWLOCATION => false,
            ));
            @curl_exec($ch);

            if ($wantRedirect) {
                $code = (int) @curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

                if ($code >= 300 && $code < 400) {
                    // Von cURL bereits absolut aufgeloeste Redirect-Ziel-URL (behandelt relative +
                    // protokoll-relative Location korrekt); Same-Origin prueft der Aufrufer.
                    $loc = (string) @curl_getinfo($ch, CURLINFO_REDIRECT_URL);
                }
            }

            @curl_close($ch);
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return $loc;
    }

    /**
     * Exakt derselbe Origin (Schema+Host+Port, Default-Port normalisiert)? Nur http(s). Verhindert
     * beim Folgen eines Wake-Redirects SSRF (Bounce auf interne Adresse), Open-Redirects (fremde
     * Site) und Port-Wechsel auf denselben Host.
     *
     * @param   string  $a  URL A
     * @param   string  $b  URL B
     *
     * @return boolean
     */
    private static function sameOrigin($a, $b)
    {
        $na = self::originStr($a);
        $nb = self::originStr($b);

        return $na !== '' && $na === $nb;
    }

    /** Normalisierter Origin "schema://host:port" oder '' bei ungueltig/Nicht-http(s). */
    private static function originStr($url)
    {
        $p = @parse_url((string) $url);

        if (!is_array($p) || empty($p['host'])) {
            return '';
        }

        $scheme = isset($p['scheme']) ? strtolower((string) $p['scheme']) : '';

        if ($scheme !== 'http' && $scheme !== 'https') {
            return '';
        }

        $host = strtolower(trim((string) $p['host'], '[]'));
        $port = isset($p['port']) ? (int) $p['port'] : ($scheme === 'http' ? 80 : 443);

        return $scheme . '://' . $host . ':' . $port;
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Pairing (versteckter Einstieg; siehe view=link)
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Koppelt die Site an ein htprotect.app-Dashboard. Wird ausschliesslich lokal
     * (Backend, versteckte Ansicht) angestossen - nie remote.
     *
     * @return array  ['ok'=>bool, ...] fuer die UI (nie Exceptions nach aussen).
     */
    public static function pair($relay, $code, $cfg = null)
    {
        try {
            $relay = rtrim(trim((string) $relay), '/');
            $code  = trim((string) $code);

            if (!self::relayValid($relay)) {
                return array('ok' => false, 'error' => 'relay_invalid');
            }

            if (!preg_match('~^[A-Za-z0-9\-]{6,64}$~', $code)) {
                return array('ok' => false, 'error' => 'code_invalid');
            }

            return self::claimAndStore($relay, '/api/v1/pair/claim', array('code' => $code), array(), $cfg);
        } catch (Exception $e) {
            return array('ok' => false, 'error' => 'exception');
        } catch (Throwable $e) {
            return array('ok' => false, 'error' => 'exception');
        }
    }

    /**
     * AUTOMATISCHE Selbst-Registrierung (Masseninstallation): koppelt OHNE Backend-Formular ueber ein
     * workspace-weites, widerrufbares Enroll-Token. Stellt DENSELBEN Endzustand her wie pair() (Claim
     * -> geschuetzte State-Datei + link_paired=1 + erstes Lebenszeichen). Idempotent ueber die stabile
     * instance_id -> das Dashboard erkennt eine erneute Registrierung wieder (Re-Point statt Duplikat).
     * Wird ausschliesslich lokal aus enrollRetry() angestossen (Waechter-Tick / Backend-Aufruf), nie
     * aus einem Formular oder remote.
     */
    public static function enroll($relay, $token, $cfg = null)
    {
        try {
            $relay = rtrim(trim((string) $relay), '/');
            $token = trim((string) $token);

            if (!self::relayValid($relay)) {
                return array('ok' => false, 'error' => 'relay_invalid');
            }

            // Enroll-Token: laenger/zufaelliger als ein Pair-Code (workspace-weit, widerrufbar).
            if (!preg_match('~^[A-Za-z0-9_\-]{16,200}$~', $token)) {
                return array('ok' => false, 'error' => 'token_invalid');
            }

            $iid = self::instanceId();

            return self::claimAndStore(
                $relay,
                '/api/v1/pair/enroll',
                array('token' => $token, 'instance_id' => $iid),
                array('instance_id' => $iid),
                $cfg
            );
        } catch (Exception $e) {
            return array('ok' => false, 'error' => 'exception');
        } catch (Throwable $e) {
            return array('ok' => false, 'error' => 'exception');
        }
    }

    /** Gemeinsame Relay-URL-Pruefung (nur https; der Wert stammt NIE aus einem Formular). */
    private static function relayValid($relay)
    {
        return (bool) preg_match('~^https://[a-z0-9.\-]+(?::\d+)?(?:/[\w\-./]*)?$~i', (string) $relay);
    }

    /**
     * Gemeinsamer Kern von pair() (Pair-Code) und enroll() (Workspace-Token): POSTet an $endpoint,
     * validiert die Antwort und stellt den vollen Kopplungs-Endzustand her (State-Datei + link_paired=1
     * + erstes Lebenszeichen). $authFields = endpunkt-spezifische Auth-Felder (code bzw. token+instance_id);
     * $extraState = zusaetzlich dauerhaft im State zu merkende Felder (z. B. instance_id).
     */
    private static function claimAndStore($relay, $endpoint, $authFields, $extraState, $cfg)
    {
        if ($cfg === null && class_exists('HtpConfig')) {
            $cfg = HtpConfig::load();
        }

        if (!class_exists('HtpProbe') || !HtpProbe::available()) {
            return array('ok' => false, 'error' => 'no_http');
        }

        // Optionales Site-Identity-Keypair (signiert kuenftig Heartbeats/Ergebnisse).
        $sitePub  = '';
        $sitePriv = '';

        if (function_exists('sodium_crypto_sign_keypair')) {
            try {
                $kp       = sodium_crypto_sign_keypair();
                $sitePriv = base64_encode(sodium_crypto_sign_secretkey($kp));
                $sitePub  = base64_encode(sodium_crypto_sign_publickey($kp));
            } catch (Exception $e) {
                $sitePub  = '';
                $sitePriv = '';
            } catch (Throwable $e) {
                $sitePub  = '';
                $sitePriv = '';
            }
        }

        $site = array(
            'url'       => self::frontendUrl($cfg),
            'admin_url' => self::adminUrl($cfg),
            'name'      => self::siteName($cfg),
            'joomla'    => self::joomlaVersion(),
            'php'       => PHP_VERSION,
            'htp'       => defined('HTP_VERSION') ? HTP_VERSION : '',
        );

        $fields = array_merge(
            array('pv' => self::PROTO),
            (array) $authFields,
            array(
                'site'     => json_encode($site),
                'site_pub' => $sitePub,
                'nonce'    => self::randHex(8),
            )
        );

        $res    = htp_http_post_multipart($relay . $endpoint, $fields, array(), self::TIMEOUT);
        $status = (int) (isset($res['status']) ? $res['status'] : 0);

        if ($status < 200 || $status >= 300) {
            return array('ok' => false, 'error' => 'relay_status', 'status' => $status);
        }

        $data = json_decode((string) (isset($res['body']) ? $res['body'] : ''), true);

        if (!is_array($data) || empty($data['site_id']) || empty($data['ws_root_pub']) || empty($data['transport_secret'])) {
            return array('ok' => false, 'error' => 'relay_reply');
        }

        $wsPub = base64_decode((string) $data['ws_root_pub'], true);

        if ($wsPub === false || strlen($wsPub) !== 32) {
            return array('ok' => false, 'error' => 'ws_key');
        }

        $st = array(
            'paired'           => 1,
            'relay'            => $relay,
            'site_id'          => (string) $data['site_id'],
            'ws_pub'           => (string) $data['ws_root_pub'],   // base64, wie geliefert (gepinnt)
            'ws_fpr'           => self::fingerprint((string) $data['ws_root_pub']),
            'relay_fpr'        => isset($data['relay_pub_fpr']) ? (string) $data['relay_pub_fpr'] : '',
            'transport_secret' => (string) $data['transport_secret'],
            'site_pub'         => $sitePub,
            'site_priv'        => $sitePriv,
            'hb_min'           => isset($data['hb_min']) ? max(5, (int) $data['hb_min']) : self::HB_MIN,
            'poll_min'         => isset($data['poll_min']) ? max(1, (int) $data['poll_min']) : self::POLL_MIN,
            'seq'              => 0,
            'last_beacon'      => 0,
            'last_poll'        => 0,
            'seen_nonces'      => array(),
            'done_cmds'        => array(),
            'pending_results'  => array(), // durabel: noch nicht bestaetigte Befehls-Ergebnisse
            'scan_cadence'     => 'off',
            'scan_last'        => 0,
            'paired_at'        => time(),
        );

        foreach ((array) $extraState as $k => $v) {
            $st[$k] = $v;
        }

        // Opake Site-Kennung, falls der Relay sie schon beim Koppeln mitliefert -> sofort pinnen.
        // Liefert er sie erst mit der ersten Heartbeat-Antwort, pinnt sie der Heartbeat unten einmalig;
        // danach ist sie in beiden Faellen unveraenderlich.
        if (isset($data['sid']) && preg_match('~^[A-Za-z0-9]{4,16}$~', (string) $data['sid'])) {
            $st['public_id']        = (string) $data['sid'];
            $st['public_id_pinned'] = 1;
        }

        self::saveState($st);

        if (class_exists('HtpConfig')) {
            // Eigenen Host pinnen (bindet kuenftige signierte SSO-/Download-Autorisierungen an diese
            // Adresse). saveKeys() (NICHT save()): save() wuerde die gesamte Schild-Konfiguration ersetzen.
            $keys = array('link_paired' => 1);
            $host = self::hostNorm(function_exists('htp_site_url') ? htp_site_url() : '');

            if ($host !== '') {
                $keys['site_host'] = $host;
            }

            HtpConfig::saveKeys($keys);
        }

        // Sofort ein erstes Lebenszeichen senden, damit das Dashboard sofort aufleuchtet.
        try {
            self::heartbeat($st, $cfg);
            self::saveState($st);
        } catch (Exception $e) {
            // egal - der Scheduler holt es nach
        } catch (Throwable $e) {
            // egal
        }

        return array(
            'ok'        => true,
            'site_id'   => $st['site_id'],
            'ws_fpr'    => $st['ws_fpr'],
            'relay_fpr' => $st['relay_fpr'],
            'relay'     => $relay,
        );
    }

    /**
     * Deferred Auto-Enroll: nachholen, falls ein vorgemerkter Enroll aussteht (Marker-Datei im
     * data/-Ordner, geschrieben vom Installer-postflight). Aufgerufen aus dem Hintergrund-Waechter-Tick
     * UND beim Backend-Aufruf. Erfolg -> Marker loeschen (keine Wiederholung). Fehlschlag (Netz/Token)
     * -> Marker bleibt, der naechste Tick versucht erneut. BEWUSST ohne link_paired-Gate: das
     * (Re-)Pointing auf den Token-Workspace soll auch dann greifen, wenn die Site bereits gekoppelt ist.
     */
    public static function enrollRetry($cfg = null)
    {
        try {
            $file = self::dataFile(self::ENROLL_PENDING_FILE);

            if ($file === '' || !is_file($file)) {
                return;
            }

            $m = json_decode((string) @file_get_contents($file), true);

            if (!is_array($m) || empty($m['token'])) {
                @unlink($file); // kaputter/leerer Marker -> weg
                return;
            }

            // Relay IMMER die feste Konstante - NIE aus dem (unsignierten) Marker/der Enroll-Datei.
            // Sonst koennte eine eingeschleuste Enroll-Datei die Site auf einen fremden Relay umlenken
            // und dessen ws_root_pub pinnen -> vollstaendige Fernuebernahme (auch bereits gekoppelter Sites).
            $res = self::enroll(self::RELAY, (string) $m['token'], $cfg);

            if (is_array($res) && !empty($res['ok'])) {
                @unlink($file); // erfolgreich eingetragen -> Marker weg
            }
        } catch (Exception $e) {
            // still - naechster Tick versucht erneut
        } catch (Throwable $e) {
            // still
        }
    }

    /** Entkoppelt die Site (lokal, nie remote). Macht den Connector wieder inert. */
    public static function unpair($cfg = null)
    {
        try {
            $file = self::statePath();

            if ($file !== '' && is_file($file)) {
                @unlink($file);
            }

            if (class_exists('HtpConfig')) {
                // site_host mit leeren: der Pin gehoert zur Kopplung. Bliebe ein (evtl. falsch)
                // gepinnter Host stehen, wuerde er nach dem Neukoppeln unter einer anderen Adresse
                // jede signierte Autorisierung dauerhaft abweisen.
                HtpConfig::saveKeys(array('link_paired' => 0, 'site_host' => ''));
            }

            return array('ok' => true);
        } catch (Exception $e) {
            return array('ok' => false);
        } catch (Throwable $e) {
            return array('ok' => false);
        }
    }

    /** Anzeige-Status fuer die versteckte Fernverwaltungs-Ansicht. */
    public static function status()
    {
        $st = self::loadState();

        return array(
            'paired'       => !empty($st['paired']),
            'relay'        => isset($st['relay']) ? (string) $st['relay'] : '',
            'site_id'      => isset($st['site_id']) ? (string) $st['site_id'] : '',
            'ws_fpr'       => isset($st['ws_fpr']) ? (string) $st['ws_fpr'] : '',
            'relay_fpr'    => isset($st['relay_fpr']) ? (string) $st['relay_fpr'] : '',
            'hb_min'       => isset($st['hb_min']) ? (int) $st['hb_min'] : self::HB_MIN,
            'poll_min'     => isset($st['poll_min']) ? (int) $st['poll_min'] : self::POLL_MIN,
            'last_beacon'  => isset($st['last_beacon']) ? (int) $st['last_beacon'] : 0,
            'scan_cadence' => isset($st['scan_cadence']) ? (string) $st['scan_cadence'] : 'off',
        );
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Heartbeat
    // ─────────────────────────────────────────────────────────────────────────

    /** @return bool  poll-Flag des Relays (true = sofort nachpollen) */
    private static function heartbeat(&$st, $cfg)
    {
        $now = time();
        $st['seq'] = (int) (isset($st['seq']) ? $st['seq'] : 0) + 1;

        // Rueckwaerts-Backfill des Operator-Fingerabdrucks: Sites, die unter einem Connector VOR diesem
        // Feld gekoppelt wurden, haben zwar den gepinnten Operator-Pub (ws_pub) im State, aber noch
        // keinen ws_fpr. Einmalig aus dem bereits gepinnten Pub ableiten, damit die Reconnect-Erkennung
        // des Dashboards auch fuer BESTANDSSITES greift - ohne Neu-Koppeln. Rein lokal, idempotent; der
        // Aufrufer persistiert $st ohnehin nach dem Heartbeat (seq/last_beacon).
        if (empty($st['ws_fpr']) && !empty($st['ws_pub'])) {
            $st['ws_fpr'] = self::fingerprint((string) $st['ws_pub']);
        }

        // Notaus erkannt? (rescue.php-Push oder FTP) -> Sperre sauber finalisieren, dann mit
        // frischem Config-Stand weiter, sonst meldete der Beat noch lock=1. Billig: der is_file-
        // Check laeuft nur, wenn ueberhaupt gesperrt ist.
        if (class_exists('HtpApp') && !empty($cfg['emergency_lock']) && HtpApp::emergencyFinalizeIfFlagged()) {
            $cfg = HtpConfig::load();
        }

        // Inventar (Delta-Sync, PROTOCOL.md §4): IMMER nur den 12-Hex-Hash mitschicken; die volle
        // Liste NUR, wenn sie sich seit dem letzten erfolgreichen Upload geaendert hat ODER der Relay
        // sie angefordert hat (want_inv). Spart Datenverkehr drastisch, bleibt via Nudge reaktiv.
        $inv = class_exists('HtpAutoUpdate')
            ? HtpAutoUpdate::inventory($cfg)
            : array('hash' => '', 'rows' => array(), 'pending' => 0);

        $sendInv = ((string) $inv['hash'] !== (string) (isset($st['inv_hash']) ? $st['inv_hash'] : ''))
            || !empty($st['want_inv']);

        // Auto-Update Zwei-Wege-Sync (sparsam): immer nur die rev; geaenderte Eintraege NUR, solange
        // die App sie noch nicht bestaetigt hat (au_acked_rev aus der letzten Antwort). Feld fehlt, wenn
        // HtpAuIntent nicht geladen ist -> die App faellt sauber aufs heutige Verhalten zurueck.
        $auRev   = null;
        $auDelta = null;
        $auHash  = null;
        if (class_exists('HtpAuIntent')) {
            $auIntent = HtpAuIntent::ensure($cfg, $now);
            $auRev    = HtpAuIntent::rev($auIntent);
            // Kanonischer Auswahl-Hash (wie inv_hash): das Dashboard vergleicht ihn mit seinem eigenen
            // und fordert bei Abweichung EINMALIG den vollen Intent an (want_au) -> Selbstheilung ohne
            // Dauer-Datenverkehr. Alt-Divergenzen (bereits geackte, nie wieder im Delta) heilen so nach.
            // NUR INSTALLIERTE Erweiterungen in den Hash (symmetrisch zur App-Seite, die auf ihr Inventar
            // filtert): ein alt-deinstallierter Intent-Eintrag wuerde sonst die Hashes dauerhaft trennen.
            $auFlat = HtpAuIntent::deriveFlat($auIntent);
            $auInst = array();
            foreach ((isset($inv['rows']) && is_array($inv['rows']) ? $inv['rows'] : array()) as $r) {
                if (isset($r[0]) && $r[0] !== '') {
                    $auInst[strtolower(trim((string) $r[0]))] = true;
                }
            }
            $auKeep = function ($arr) use ($auInst) {
                $out = array();
                foreach ((array) $arr as $k) {
                    if (isset($auInst[strtolower(trim((string) $k))])) {
                        $out[] = $k;
                    }
                }
                return $out;
            };
            // never-Liste additiv (ab 2.7.2): flatHash haengt sie nur an, wenn nicht leer -> fuer Sites
            // ohne "Nie"-Eintrag byte-gleich zu v1 (kein Rollout-Rauschen). Ebenfalls auf Installierte gefiltert.
            $auNever = isset($auFlat['autoupdate_never']) ? $auFlat['autoupdate_never'] : array();
            $auHash  = HtpAuIntent::flatHash(
                $auKeep($auFlat['autoupdate_include']),
                $auKeep($auFlat['autoupdate_exclude']),
                $auFlat['autoupdate_default'],
                $auKeep($auNever)
            );
            // want_au (aus der letzten Antwort) -> den VOLLEN Intent senden (ackedRev=0 = alle Eintraege)
            // + als 'full' markieren, damit die App den Voll-Abgleich faehrt statt des Delta-Wegs.
            if (!empty($st['want_au'])) {
                $auDelta = HtpAuIntent::delta($auIntent, 0);
                $auDelta['full'] = 1;
            } else {
                $auDelta = HtpAuIntent::delta($auIntent, isset($st['au_acked_rev']) ? (int) $st['au_acked_rev'] : 0);
            }
        }

        // System-Fakten (Delta-Sync wie inv_hash): facts_hash reist IMMER mit; das volle
        // Buendel NUR bei Aenderung oder want_facts. sysfactsBundle() ist billig + fail-soft.
        $sf        = self::sysfactsBundle($cfg);
        $sendFacts = ((string) $sf['hash'] !== (string) (isset($st['facts_hash']) ? $st['facts_hash'] : ''))
            || !empty($st['want_facts']);

        $body = array(
            'pv'           => self::PROTO,
            'site_id'      => (string) $st['site_id'],
            'ts'           => $now,
            'seq'          => (int) $st['seq'],
            'status'       => self::statusPayload($cfg),
            // Notfall-Modus (2.6.10): {lock:0} oder {lock:1, link_sealed:<E2E an Operator>}. Das
            // Dashboard zeigt bei lock=1 den entsiegelten Zugangs-Link + Aktivieren/Deaktivieren.
            'emergency'    => self::emergencyBeat($st, $cfg),
            // Ueber HTProtect (Joomla-Backend ODER Dashboard) eingerichteter Backend-Zugangs-String
            // (Cookie-Gate, Option 2). Reist E2E-VERSIEGELT an den Operator - GENAU wie der Notfall-
            // Link, nur fuer den Backend-Zugang: der Relay sieht ihn nie im Klartext. Das Dashboard
            // entsiegelt ihn im Tresor und fuellt damit automatisch das 1-Klick-Login-Feld, sodass
            // der Operator ihn nicht von Hand uebertragen muss. '' wenn kein Gate aktiv / kein Sodium
            // / kein gepinnter Operator-Pub -> dann reist gar nichts (nie Klartext, nie Ueberschreiben).
            'guard_string_sealed' => self::guardStringSealed($st, $cfg),
            'versions'     => array(
                'htp'    => defined('HTP_VERSION') ? HTP_VERSION : '',
                'joomla' => self::joomlaVersion(),
                'php'    => PHP_VERSION,
            ),
            'frontend_url' => self::frontendUrl($cfg),
            'admin_url'    => self::adminUrl($cfg),
            // Aktueller Joomla-Site-Title. Beim Pairing einmalig gemeldet; hier bei JEDEM Heartbeat
            // mitgeschickt, damit eine spaetere Umbenennung im Dashboard ankommt (das Dashboard
            // uebernimmt ihn nur bei echter Aenderung). Kurzer String -> vernachlaessigbarer Overhead;
            // per "Status aktualisieren" (loest heartbeat() aus) sofort, sonst beim naechsten Beat.
            'name'         => self::siteName($cfg),
            // Fingerabdruck des aktuell GEPINNTEN Operator-Schluessels (dieser Connector vertraut ihm).
            // Weicht er dashboard-seitig vom aktuellen ws_root_pub des Workspace ab (Schluessel
            // zurueckgesetzt/rotiert, Site anderweitig gekoppelt, Verbindung schlicht verloren), erkennt
            // das Dashboard PASSIV "Neu koppeln noetig" - ueber den ohnehin laufenden Heartbeat, ganz
            // ohne Zusatz-Request. Winziger String, daher IMMER mitgeschickt; alte Dashboards ignorieren
            // ein unbekanntes Feld. Leer, solange (noch) nichts gepinnt ist.
            'ws_fpr'       => (string) (isset($st['ws_fpr']) ? $st['ws_fpr'] : ''),
            'updates'      => array('pending' => (int) $inv['pending']),
            'inv_hash'     => (string) $inv['hash'],
            // System-Fakten-Hash (Werkzeug-Ansichten) - winzig, daher IMMER mit.
            'facts_hash'   => (string) $sf['hash'],
            // Faehigkeiten fuer das Dashboard = Befehls-Allowlist + Feature-Flags. Flags sind KEINE
            // Queue-Befehle -> bewusst NICHT in caps() (dort bliebe die Befehls-Allowlist rein), sondern
            // nur hier angehaengt: 'graceext' (per-ext-Karenz) und 'autohonest' (Bit 8 auto_effective +
            // enforce) IMMER (stabile Anzeige), 'ausync' (Zwei-Wege-Sync) nur bei vorliegendem Delta.
            'caps'         => array_merge(
                self::caps(),
                array('graceext', 'autohonest'),
                ($auDelta !== null) ? array('ausync') : array()
            ),
            // Super-Admins fuer die 1-Klick-Login-Zielauswahl im Dashboard (Zero-Knowledge:
            // nur id + Anzeigename/Username). Leer, solange HtpSso fehlt.
            'super_users'  => class_exists('HtpSso') ? HtpSso::superUsers() : array(),
            'job'          => self::jobSummary($st),
            'last_scan'    => self::scanReportPublic($st),
            'last_integrity' => self::integrityReportPublic($st),
            'last_backup'  => class_exists('HtpBackup') ? HtpBackup::reportPublic($st) : null,
            // Liste vorhandener Backups (Zero-Knowledge: nur ref/ts/ok/size/kind) - der Operator
            // kann so auch AELTERE Backups signiert direkt herunterladen.
            'backups'      => class_exists('HtpBackup') ? HtpBackup::listBackups() : array(),
            // Aktueller Backup-Zeitplan + Retention (damit das Dashboard den gesetzten Stand zeigt).
            'backup_sched' => array(
                'full' => isset($st['bk_full_cad']) ? (string) $st['bk_full_cad']
                    : (isset($st['backup_cadence']) ? (string) $st['backup_cadence'] : 'off'),
                'db'   => isset($st['bk_db_cad']) ? (string) $st['bk_db_cad'] : 'off',
                'keep_full' => isset($st['bk_keep_full']) ? (int) $st['bk_keep_full']
                    : (isset($st['bk_keep']) ? (int) $st['bk_keep'] : 3),
                'keep_db'   => isset($st['bk_keep_db']) ? (int) $st['bk_keep_db']
                    : (isset($st['bk_keep']) ? (int) $st['bk_keep'] : 14),
                'thin' => !empty($st['bk_thin']) ? 1 : 0,
                'time' => sprintf('%02d:%02d', isset($st['bk_hour']) ? (int) $st['bk_hour'] : 3, isset($st['bk_min']) ? (int) $st['bk_min'] : 0),
                // Dauerhafte Ausschluss-Liste (grosse Verzeichnisse/Tabellen) - damit die UI den
                // Ist-Stand ohne Neu-Analyse zeigt + vorhaeken kann. Auf 100 gedeckelt.
                'excl_dirs'     => array_slice((isset($st['bk_excl_dirs']) && is_array($st['bk_excl_dirs'])) ? array_values($st['bk_excl_dirs']) : array(), 0, 100),
                'excl_tables'   => array_slice((isset($st['bk_excl_tables']) && is_array($st['bk_excl_tables'])) ? array_values($st['bk_excl_tables']) : array(), 0, 100),
                'excl_bigfiles' => !empty($st['bk_excl_bigfiles']) ? 1 : 0,
            ),
            'job'          => self::jobProgressPublic($st),
        );

        if ($sendInv) {
            $body['inventory'] = $inv['rows'];
        }

        if ($sendFacts) {
            $body['sysfacts'] = $sf['bundle'];
        }

        if ($auDelta !== null) {
            // Schluessel MUSS 'autoupdate' heissen - die App liest exakt $data['autoupdate']
            // (HeartbeatController). Ein abweichender Name (frueher 'au') macht den Backend->App-Sync tot.
            $body['autoupdate'] = $auDelta;
        }

        // Auswahl-Hash IMMER mitschicken (winzig), damit das Dashboard Divergenz erkennen kann.
        if ($auHash !== null) {
            $body['au_hash'] = $auHash;
        }

        $canon = self::canonical($body);

        $fields = array(
            'pv'   => self::PROTO,
            'site' => (string) $st['site_id'],
            'body' => $canon,
            'auth' => self::hmac($st, $canon),
        );

        // seq crash-fest persistieren - VOR dem Senden. Der Zaehler wurde oben (heartbeat-Start)
        // erhoeht; wuerde er erst NACH erfolgreichem Upload gespeichert, koennte ein Crash zwischen
        // "Dashboard akzeptiert seq N" und "Aufrufer speichert" den lokalen Stand auf N-1 zuruecklassen
        // -> jeder Folge-Beacon kaeme als stale_seq (409) und die Site friert im Dashboard ein (genau
        // der literatopia.de-Reconnect-Fall). Ein evtl. "verbrannter" seq (Sendefehler) ist harmlos:
        // der Zaehler muss nur streng steigen, Luecken sind erlaubt.
        self::saveState($st);

        $res    = htp_http_post_multipart(rtrim($st['relay'], '/') . '/api/v1/heartbeat', $fields, array(), self::TIMEOUT);
        $status = (int) (isset($res['status']) ? $res['status'] : 0);

        $st['last_beacon'] = $now;

        // Selbstheilung bei Anti-Replay-seq-Regression: Wurde der lokale Zaehler (Neu-Pairing bzw.
        // zurueckgesetzter State) UNTER die dashboard-seitige High-Water-Mark gesetzt, weist der Relay
        // jeden Heartbeat als stale_seq (409) ab -> der Status im Dashboard friert ein (kriecht sonst nur
        // 1/Beat langsam wieder darueber). Der Relay teilt bei 409 seine aktuelle Marke mit; wir heben
        // unseren Zaehler EINMALIG knapp darueber, sodass der naechste Heartbeat akzeptiert wird. Nur
        // vorwaerts (nie zurueck) und der Request war bereits HMAC-authentisiert -> kein Replay-Loch.
        if ($status === 409) {
            $rj = json_decode((string) (isset($res['body']) ? $res['body'] : ''), true);

            if (is_array($rj) && isset($rj['error'], $rj['seq']) && $rj['error'] === 'stale_seq') {
                $far = (int) $rj['seq'];

                if ($far >= (int) $st['seq']) {
                    $st['seq'] = $far + 1;
                }

                // SOFORT mit korrigiertem seq neu senden - nicht bis zum naechsten faelligen Heartbeat
                // (bis zu hb_min = 30 Min) warten. Ein einziger Nachschuss genuegt: der korrigierte seq
                // liegt jetzt ueber der Dashboard-Marke, der Request bleibt HMAC-authentisiert. Erst den
                // korrigierten seq persistieren (crash-fest), dann Body+HMAC neu bilden und senden.
                self::saveState($st);
                $body['seq']    = (int) $st['seq'];
                $canon          = self::canonical($body);
                $fields['body'] = $canon;
                $fields['auth'] = self::hmac($st, $canon);

                $res    = htp_http_post_multipart(rtrim($st['relay'], '/') . '/api/v1/heartbeat', $fields, array(), self::TIMEOUT);
                $status = (int) (isset($res['status']) ? $res['status'] : 0);
                $st['last_beacon'] = time();
            }
        }

        if ($status < 200 || $status >= 300) {
            return false;
        }

        // Erst NACH erfolgreichem Upload den Inventar-Hash festschreiben (sonst wuerde ein
        // fehlgeschlagener Heartbeat den Delta-Zustand verlieren und beim naechsten Mal erneut senden).
        if ($sendInv) {
            $st['inv_hash'] = (string) $inv['hash'];
            unset($st['want_inv']);
        }

        // Erst NACH erfolgreichem Upload den Fakten-Hash festschreiben (analog inv_hash) -
        // ein fehlgeschlagener Heartbeat sendet das Buendel beim naechsten Mal erneut.
        if ($sendFacts) {
            $st['facts_hash'] = (string) $sf['hash'];
            unset($st['want_facts']);
        }

        // War want_au gesetzt, hat DIESER Heartbeat den vollen Intent (full) mitgeschickt -> Anforderung
        // erfuellt, zuruecksetzen. Ist das Dashboard danach noch divergent, fordert es unten erneut an.
        unset($st['want_au']);

        $data = json_decode((string) (isset($res['body']) ? $res['body'] : ''), true);

        if (is_array($data)) {
            if (isset($data['hb_min'])) {
                $st['hb_min'] = max(5, (int) $data['hb_min']);
            }

            if (isset($data['poll_min'])) {
                $st['poll_min'] = max(1, (int) $data['poll_min']);
            }

            // Opaker Site-Bezeichner (public_id) vom Relay: wird ZUSAETZLICH zur (numerischen)
            // Transport-site_id akzeptiert (Befehle/Downloads koennen dann darauf lauten). Transport-
            // Auth (Poll/Result/Heartbeat) bleibt numerisch - der Relay rekonstruiert die HMAC damit.
            //
            // WICHTIG: Der Heartbeat-REQUEST ist HMAC-authentisiert, die ANTWORT NICHT. Die
            // Kennung wird deshalb wie ws_pub EINMALIG gepinnt (beim Koppeln bzw. - fuer
            // Bestandsinstallationen - beim ersten Mal, an dem der Relay sie liefert). Eine
            // spaeter ABWEICHENDE Kennung wird STILL ignoriert: sonst koennte ein uebernommener
            // Relay Operator-Signaturen auf eine andere Site desselben Arbeitsbereichs umlenken.
            if (isset($data['sid']) && preg_match('~^[A-Za-z0-9]{4,16}$~', (string) $data['sid'])) {
                $sid = (string) $data['sid'];

                if (empty($st['public_id'])) {
                    $st['public_id']        = $sid;
                    $st['public_id_pinned'] = 1;
                } elseif ($sid !== (string) $st['public_id']) {
                    // Nur intern vermerken (kein Fehler, kein Abbruch - der Betrieb laeuft weiter).
                    $st['sid_mismatch_at'] = $now;
                }
            }

            // Relay fordert das Voll-Inventar an (Hash-Mismatch ohne mitgeliefertes inventory,
            // z. B. nach Relay-Restore) -> naechster Heartbeat schickt die komplette Liste.
            if (!empty($data['want_inv'])) {
                $st['want_inv'] = 1;
            }

            // Relay/Dashboard fordert das volle sysfacts-Buendel an (Hash-Mismatch ohne
            // mitgeliefertes sysfacts, z. B. nach Relay-Restore) -> naechster Heartbeat sendet es.
            if (!empty($data['want_facts'])) {
                $st['want_facts'] = 1;
            }

            // Dashboard fordert den VOLLEN Intent an (Auswahl-Hash weicht ab) -> naechster Heartbeat
            // schickt den kompletten Intent ('full') fuer den LWW-Voll-Abgleich (Selbstheilung).
            if (!empty($data['want_au'])) {
                $st['want_au'] = 1;
            }

            // Auto-Update-Sync-Ack: bis zu welcher rev die App die Absicht bestaetigt hat. Auf [0, rev]
            // geklemmt (die Antwort ist unauthentisiert; eine kuenftige Aenderung mit rev+1 wird damit
            // IMMER wieder gesendet - ein manipulierter Ack kann den Sync hoechstens verzoegern, nie
            // Daten loeschen). Steuert nur die Sparsamkeit des Deltas.
            if ($auRev !== null && isset($data['au_ack'])) {
                $st['au_acked_rev'] = max(0, min((int) $data['au_ack'], (int) $auRev));
            }

            return !empty($data['poll']);
        }

        return false;
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Command-Poll + Verifikation + Dispatch
    // ─────────────────────────────────────────────────────────────────────────

    private static function poll(&$st, $cfg)
    {
        $now = time();
        $auth = self::hmac($st, 'pending|' . $st['site_id'] . '|' . $now);

        $headers = array(
            'X-Htp-Site: ' . $st['site_id'],
            'X-Htp-Ts: ' . $now,
            'X-Htp-Auth: ' . $auth,
        );

        $resp = HtpProbe::fetch(rtrim($st['relay'], '/') . '/api/v1/commands/pending', true, null, self::TIMEOUT, $headers);
        $st['last_poll'] = $now;

        $status = (int) (isset($resp['status']) ? $resp['status'] : 0);

        if ($status < 200 || $status >= 300) {
            return;
        }

        $data = json_decode((string) (isset($resp['body']) ? $resp['body'] : ''), true);

        if (!is_array($data) || empty($data['cmds']) || !is_array($data['cmds'])) {
            return;
        }

        foreach ($data['cmds'] as $item) {
            if (!is_array($item) || !isset($item['cmd'])) {
                continue;
            }

            self::handleCommand($item['cmd'], isset($item['sig']) ? $item['sig'] : '', $st, $cfg);

            // Nur EINEN neuen Langlauf-Job je Poll starten (Scan/Integritaet laufen exklusiv).
            if (!empty($st['job']) && empty($st['job']['reported'])) {
                // ein Job wurde gerade gestartet - Rest bleibt in der Queue, naechster Poll holt ihn.
            }
        }
    }

    private static function handleCommand($cmd, $sig, &$st, $cfg)
    {
        $reason = self::verifyCommand($cmd, $sig, $st);
        $cid    = is_array($cmd) && isset($cmd['cmd_id']) ? (string) $cmd['cmd_id'] : '';

        if ($reason === 'done') {
            // Idempotenz: schon erledigt -> nur nochmal bestaetigen, nicht erneut ausfuehren.
            self::reportResult($st, $cid, true, 'dup', '', self::level($cfg));
            return;
        }

        if ($reason !== '') {
            // Verworfen (bad_sig/expired/replay/unknown_type/...). Transparent melden, NICHT ausfuehren.
            if ($cid !== '') {
                self::reportResult($st, $cid, false, 'rejected:' . $reason, '', self::level($cfg));
            }

            return;
        }

        // Gueltig: Nonce + cmd_id sofort verbuchen (Replay-/Doppelzustellungs-Schutz),
        // BEVOR ausgefuehrt wird - so kann ein Langlauf-Job nicht neu gestartet werden.
        self::ringPush($st, 'seen_nonces', (string) $cmd['nonce']);
        self::ringPush($st, 'done_cmds', $cid);

        $type   = (string) $cmd['type'];
        $params = isset($cmd['params']) && is_array($cmd['params']) ? $cmd['params'] : array();

        $out = self::dispatch($type, $params, $st, $cfg, $cid);

        if (empty($out['async'])) {
            self::reportResult(
                $st,
                $cid,
                !empty($out['ok']),
                isset($out['code']) ? $out['code'] : ($out['ok'] ? 'ok' : 'fail'),
                isset($out['log']) ? $out['log'] : '',
                self::level($cfg)
            );
        }
        // async (Scan/Integritaet): Ergebnis meldet advanceJob bei Abschluss.
    }

    /**
     * Vollstaendige Befehlspruefung. Rueckgabe '' = gueltig; sonst der Ablehnungsgrund.
     * Reihenfolge/Regeln V1..V7 exakt nach docs/PROTOCOL.md 5.2 (fail-CLOSED).
     */
    private static function verifyCommand($cmd, $sig, $st)
    {
        if (!is_array($cmd)) {
            return 'bad_cmd';
        }

        if ((isset($cmd['pv']) ? $cmd['pv'] : '') !== self::PROTO) {
            return 'bad_proto';
        }

        // Site-Bindung: der signierte cmd.site_id muss die eigene Site treffen - das opake public_id
        // ODER (Uebergang, bis das Frontend umgestellt ist) die alte numerische Transport-site_id.
        $cmdSite = (string) (isset($cmd['site_id']) ? $cmd['site_id'] : '');

        if ($cmdSite === ''
            || ($cmdSite !== (string) $st['site_id']
                && !(!empty($st['public_id']) && $cmdSite === (string) $st['public_id']))) {
            return 'wrong_site';
        }

        $type = (string) (isset($cmd['type']) ? $cmd['type'] : '');

        if (!in_array($type, self::caps(), true)) {
            return 'unknown_type';
        }

        $now = time();
        $exp = (int) (isset($cmd['expires_at']) ? $cmd['expires_at'] : 0);
        $iss = (int) (isset($cmd['issued_at']) ? $cmd['issued_at'] : 0);

        if ($exp <= 0 || $now > $exp) {
            return 'expired';
        }

        if ($iss > $now + self::SKEW) {
            return 'future';
        }

        $nonce = (string) (isset($cmd['nonce']) ? $cmd['nonce'] : '');

        if ($nonce === '' || self::ringHas($st, 'seen_nonces', $nonce)) {
            return 'replay';
        }

        $cid = (string) (isset($cmd['cmd_id']) ? $cmd['cmd_id'] : '');

        if ($cid === '') {
            return 'no_id';
        }

        if (self::ringHas($st, 'done_cmds', $cid)) {
            return 'done';
        }

        // Signatur - fail-CLOSED: ohne libsodium wird KEIN Befehl ausgefuehrt.
        if (!function_exists('sodium_crypto_sign_verify_detached')) {
            return 'no_crypto';
        }

        $sigRaw = base64_decode((string) $sig, true);
        $pubRaw = base64_decode((string) (isset($st['ws_pub']) ? $st['ws_pub'] : ''), true);

        if ($sigRaw === false || $pubRaw === false || strlen($sigRaw) !== 64 || strlen($pubRaw) !== 32) {
            return 'bad_key';
        }

        $ok = false;

        try {
            $ok = sodium_crypto_sign_verify_detached($sigRaw, self::canonical($cmd), $pubRaw);
        } catch (Exception $e) {
            $ok = false;
        } catch (Throwable $e) {
            $ok = false;
        }

        if (!$ok) {
            return 'bad_sig';
        }

        return '';
    }

    /**
     * Verifiziert eine signierte Direkt-DOWNLOAD-Autorisierung (Backup-Abholung Site->Browser,
     * OHNE Relay = Zero-Knowledge). Der Operator-Browser signiert mit WS_root das Objekt
     * {pv, site_id, t:'dl', aud, ref, part, exp}; hier wird dasselbe Objekt SERVERSEITIG
     * rekonstruiert (pv/site_id/t aus Konstante+State, NICHT aus der URL -> nicht faelschbar) und
     * die Ed25519-Signatur ueber das kanonische JSON geprueft. Strikt: gueltiges kurzes
     * Zeitfenster, Teil aus {files,db}, ref = 24-hex. Fail-CLOSED (ohne libsodium/Key/Signatur
     * -> false).
     *
     * EMPFAENGER-BINDUNG: 'aud' ist der Ziel-Host, fuer den der Browser signiert hat. Er MUSS
     * dem eigenen, gepinnten Host entsprechen - sonst koennte ein uebernommener Relay den
     * Browser fuer die echte Site signieren lassen, die Autorisierung selbst entgegennehmen und
     * bei der echten Site wieder einspielen. Fehlt 'aud', wird ABGELEHNT: diese Endpunkte gibt
     * es erst seit 2.5.0, es existiert kein Alt-Client, auf den Ruecksicht zu nehmen waere.
     *
     * @param   string  $ref    Backup-Referenz (24-hex)
     * @param   string  $part   'files' oder 'db'
     * @param   int     $exp    Ablauf (Unix)
     * @param   string  $nonce  Einmal-Nonce (16-64 hex)
     * @param   string  $sig    Ed25519-Signatur (base64, url-safe erlaubt)
     * @param   string  $aud    Ziel-Host, wie vom Browser signiert (ohne Schema/Port)
     *
     * @return bool
     */
    public static function verifyDownloadAuth($ref, $part, $exp, $nonce, $sig, $aud = '')
    {
        $exp = (int) $exp;
        $now = time();

        // Kurzes Fenster: gueltig, nicht abgelaufen, hoechstens 5 Min in der Zukunft (deckt sich
        // mit dem Browser-Fenster - kein 1h-"Zeitbomben"-Vorlauf fuer geloggte Tokens).
        if ($exp <= 0 || $now > $exp || $exp > $now + 300) {
            return false;
        }

        if (!in_array($part, array('files', 'db'), true)) {
            return false;
        }

        if (!preg_match('~^[a-f0-9]{24}$~', (string) $ref)) {
            return false;
        }

        if (!preg_match('~^[a-f0-9]{16,64}$~', (string) $nonce)) {
            return false;
        }

        // Empfaenger-Bindung: nur der Host wird verglichen (normalisiert, ohne Gross-/Klein-
        // schreibung, ohne "www."), damit www/non-www und http/https nichts zerschiessen.
        // Fehlender oder abweichender Ziel-Host -> fail-closed.
        $audNorm = self::hostNorm($aud);
        $ownHost = self::siteHostPinned();

        if ($audNorm === '' || $ownHost === '' || !hash_equals($ownHost, $audNorm)) {
            return false;
        }

        if (!function_exists('sodium_crypto_sign_verify_detached')) {
            return false;
        }

        $st = self::loadState();

        if (!is_array($st) || empty($st['ws_pub']) || empty($st['site_id'])) {
            return false;
        }

        // Single-Use (Replay-Schutz, analog verifyCommand): eine bereits verwendete Autorisierung
        // wird abgelehnt. So ist ein abgefangener/geloggter Token nach der ersten Auslieferung
        // wertlos - selbst innerhalb des Zeitfensters.
        if (self::ringHas($st, 'seen_dl', (string) $nonce)) {
            return false;
        }

        // URL-sicheres base64 zulassen (-_ -> +/), Padding wieder auffuellen.
        $s   = strtr((string) $sig, '-_', '+/');
        $pad = strlen($s) % 4;

        if ($pad > 0) {
            $s .= str_repeat('=', 4 - $pad);
        }

        $sigRaw = base64_decode($s, true);
        $pubRaw = base64_decode((string) $st['ws_pub'], true);

        if ($sigRaw === false || $pubRaw === false || strlen($sigRaw) !== 64 || strlen($pubRaw) !== 32) {
            return false;
        }

        // Objekt SERVERSEITIG bauen: pv/site_id/t sind fix, nur aud/ref/part/exp/nonce stammen
        // (signaturgedeckt) aus dem Request. 'aud' geht dabei UNVERAENDERT in das Objekt (die
        // Signatur deckt exakt die gesendeten Bytes); geprueft wurde er oben normalisiert.
        // Die signierte site_id darf das opake public_id (bevorzugt) ODER die legacy-numerische
        // sein (Uebergang, bis das Frontend umstellt).
        $sids = array();

        if (!empty($st['public_id'])) {
            $sids[] = (string) $st['public_id'];
        }

        $sids[] = (string) $st['site_id'];
        $ok = false;

        foreach ($sids as $sid) {
            $obj = array(
                'pv'      => self::PROTO,
                'site_id' => $sid,
                't'       => 'dl',
                'aud'     => (string) $aud,
                'ref'     => (string) $ref,
                'part'    => (string) $part,
                'exp'     => $exp,
                'nonce'   => (string) $nonce,
            );

            try {
                if (sodium_crypto_sign_verify_detached($sigRaw, self::canonical($obj), $pubRaw)) {
                    $ok = true;
                    break;
                }
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }
        }

        if (!$ok) {
            return false;
        }

        // Nonce verbrauchen (Single-Use) + persistieren -> kein zweiter Download mit demselben Token.
        self::ringPush($st, 'seen_dl', (string) $nonce);
        self::saveState($st);

        return true;
    }

    /**
     * SSO-Autorisierung pruefen (1-Klick-Login, docs/SSO-DESIGN). ANALOG verifyDownloadAuth:
     * das signierte Objekt {pv, site_id, t:'sso', aud, uid, exp, nonce} wird SERVERSEITIG
     * rekonstruiert (pv/site_id/t fix aus Konstante+State, NICHT aus dem Request -> nicht
     * faelschbar) und die Ed25519-Signatur ueber das kanonische JSON geprueft. Single-use
     * (seen_sso-Ring), kurzes Zeitfenster, fail-CLOSED (ohne libsodium/Key/gueltige Signatur
     * -> false). Dass der Ziel-User ein aktiver Super-Admin ist, prueft der Aufrufer
     * zusaetzlich (HtpSso::isSuperUser).
     *
     * EMPFAENGER-BINDUNG: 'aud' ist der Ziel-Host, fuer den der Browser signiert hat, und MUSS
     * dem eigenen, gepinnten Host entsprechen. Ohne diese Bindung koennte ein uebernommener
     * Relay den Browser fuer die echte Site signieren lassen, die Autorisierung selbst
     * entgegennehmen und bei der echten Site einspielen -> fremder Super-User-Login. Fehlt
     * 'aud', wird ABGELEHNT (fail-closed; der Endpunkt existiert erst seit 2.5.0).
     *
     * @param   int     $uid    Ziel-User-ID
     * @param   int     $exp    Ablauf (Unix)
     * @param   string  $nonce  Einmal-Nonce (16-64 hex)
     * @param   string  $sig    Ed25519-Signatur (base64, url-safe erlaubt)
     * @param   string  $aud    Ziel-Host, wie vom Browser signiert (ohne Schema/Port)
     *
     * @return bool
     */
    public static function verifySsoAuth($uid, $exp, $nonce, $sig, $aud = '')
    {
        $uid = (int) $uid;
        $exp = (int) $exp;
        $now = time();

        if ($uid <= 0) {
            return false;
        }

        // Kurzes Fenster: gueltig, nicht abgelaufen, hoechstens 5 Min in der Zukunft.
        if ($exp <= 0 || $now > $exp || $exp > $now + 300) {
            return false;
        }

        if (!preg_match('~^[a-f0-9]{16,64}$~', (string) $nonce)) {
            return false;
        }

        // Empfaenger-Bindung (siehe verifyDownloadAuth): nur der Host, normalisiert, ohne
        // Gross-/Kleinschreibung und ohne "www.". Fehlt/weicht ab -> fail-closed.
        $audNorm = self::hostNorm($aud);
        $ownHost = self::siteHostPinned();

        if ($audNorm === '' || $ownHost === '' || !hash_equals($ownHost, $audNorm)) {
            return false;
        }

        if (!function_exists('sodium_crypto_sign_verify_detached')) {
            return false;
        }

        $st = self::loadState();

        if (!is_array($st) || empty($st['ws_pub']) || empty($st['site_id'])) {
            return false;
        }

        // Single-Use (Replay-Schutz): ein bereits verwendeter Nonce wird abgelehnt.
        if (self::ringHas($st, 'seen_sso', (string) $nonce)) {
            return false;
        }

        $s   = strtr((string) $sig, '-_', '+/');
        $pad = strlen($s) % 4;

        if ($pad > 0) {
            $s .= str_repeat('=', 4 - $pad);
        }

        $sigRaw = base64_decode($s, true);
        $pubRaw = base64_decode((string) $st['ws_pub'], true);

        if ($sigRaw === false || $pubRaw === false || strlen($sigRaw) !== 64 || strlen($pubRaw) !== 32) {
            return false;
        }

        // site_id signaturgedeckt: opakes public_id (bevorzugt) ODER legacy-numerisch.
        $sids = array();

        if (!empty($st['public_id'])) {
            $sids[] = (string) $st['public_id'];
        }

        $sids[] = (string) $st['site_id'];
        $ok = false;

        foreach ($sids as $sid) {
            $obj = array(
                'pv'      => self::PROTO,
                'site_id' => $sid,
                't'       => 'sso',
                // unveraendert wie gesendet - die Signatur deckt exakt diese Bytes.
                'aud'     => (string) $aud,
                'uid'     => $uid,
                'exp'     => $exp,
                'nonce'   => (string) $nonce,
            );

            try {
                if (sodium_crypto_sign_verify_detached($sigRaw, self::canonical($obj), $pubRaw)) {
                    $ok = true;
                    break;
                }
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }
        }

        if (!$ok) {
            return false;
        }

        // Nonce verbrauchen (Single-Use) + persistieren.
        self::ringPush($st, 'seen_sso', (string) $nonce);
        self::saveState($st);

        return true;
    }

    /**
     * Der feste Allow-Listen-Dispatcher. default = No-Op (kann nie erreicht werden,
     * da verifyCommand unbekannte Typen bereits verwirft - Guertel und Hosentraeger).
     */
    private static function dispatch($type, $params, &$st, $cfg, $cid)
    {
        switch ($type) {
            case 'status.refresh':
                // ERGAENZUNG zum 6h-Automatik-Pull (der bleibt unveraendert - Waechter-Tick
                // refresh(false) + REFRESH_INTERVAL): der MANUELLE Dashboard-Refresh erzwingt
                // zusaetzlich einen sofortigen Defs-Feed-Pull (refresh(true) umgeht die 6h-Sperre)
                // und bewertet die Erweiterungen neu, damit eine frische/korrigierte Feed-Version
                // umgehend in die Ampel einfliesst statt bis zu 6h zu warten. Bewusst NUR hier,
                // nicht im Heartbeat/Automatik-Tick. Abruf ist konditional (ETag) -> meist nur ein
                // billiges 304, daher auch bei wiederholtem Klicken keine Dauerlast.
                if (class_exists('HtpDefs')) {
                    try {
                        HtpDefs::refresh(true);
                        $vuln = HtpDefs::checkExtensions();
                        if (class_exists('HtpAutoUpdate')) {
                            HtpAutoUpdate::surfaceVulnerableUpdates(is_array($vuln) ? $vuln : array());
                        }
                    } catch (Exception $e) {
                    } catch (Throwable $e) {
                    }
                }
                // Der naechste Heartbeat traegt ohnehin frischen Status; hier nur bestaetigen.
                self::heartbeat($st, $cfg);
                return array('ok' => true, 'code' => 'refreshed');

            case 'update.run':
                if (class_exists('HtpDefs') && class_exists('HtpAutoUpdate')) {
                    try {
                        $vuln = HtpDefs::checkExtensions();
                        HtpAutoUpdate::surfaceVulnerableUpdates(is_array($vuln) ? $vuln : array());
                    } catch (Exception $e) {
                    } catch (Throwable $e) {
                    }

                    // Gezielter Einzel-Install, wenn ein Schluessel mitkommt (params.key =
                    // ext_key type|element|folder); sonst der volle Auto-Update-Lauf.
                    $key = isset($params['key']) ? (string) $params['key'] : '';

                    if ($key !== '') {
                        return HtpAutoUpdate::runOne($key);
                    }

                    HtpAutoUpdate::runStep('link');
                    return array('ok' => true, 'code' => 'update_ran');
                }
                return array('ok' => false, 'code' => 'no_engine');

            case 'update.policy':
                return self::applyUpdatePolicy($params);

            case 'scan.run':
                return self::startScan($st, $cid);

            case 'scan.schedule':
                $cad = isset($params['cadence']) ? (string) $params['cadence'] : 'off';
                if (!in_array($cad, array('off', 'daily', '3d', 'weekly', 'monthly'), true)) {
                    $cad = 'off';
                }
                $st['scan_cadence'] = $cad;
                return array('ok' => true, 'code' => 'schedule:' . $cad);

            case 'integrity.check':
                return self::startIntegrity($st, $cid);

            case 'shield.selfheal':
                // NUTZER-getriggert: FORCE-schreiben (erstellt eine fehlende/geloeschte .htaccess NEU) und
                // EHRLICHEN Status zurueckgeben. healShieldNow ist neu (2.7.4) - Fallback auf das alte,
                // konservative selfHealShield fuer den Fall eines noch aelteren Kerns (B/C).
                if (class_exists('HtpApp') && method_exists('HtpApp', 'healShieldNow')) {
                    return HtpApp::healShieldNow();
                }
                if (class_exists('HtpApp')) {
                    HtpApp::selfHealShield();
                    return array('ok' => true, 'code' => 'shield_healed');
                }
                return array('ok' => false, 'code' => 'no_engine');

            case 'threat.ack':
                // Betreiber quittiert eine KONTO-Aenderung als "bekannt". BEWUSST nur
                // htp-a-accounts (PW/E-Mail/neues Super-User-Konto) - Rogue-Admin-Muster &
                // Malware sind NIE remote quittierbar (manuelle Pruefung im Backend).
                $kind = isset($params['kind']) ? (string) $params['kind'] : '';
                if ($kind !== 'htp-a-accounts' && $kind !== 'accounts') {
                    return array('ok' => false, 'code' => 'ack_unsupported:' . $kind);
                }
                if (!class_exists('HtpAccounts') || !class_exists('HtpConfig')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }
                // Aktuellen Konten-Stand als neue Baseline uebernehmen ("alles bekannt");
                // nur diesen einen Schluessel persistieren (kein Voll-Config-Ueberschreiben).
                HtpConfig::saveKeys(array('account_baseline' => HtpAccounts::snapshot()));
                // Snapshot mit frischer Baseline neu bauen -> Konto-Bedrohung faellt weg,
                // andere Bedrohungen bleiben -> sofortiger, ehrlicher Heartbeat.
                $c = HtpConfig::load();
                self::refreshThreatSnapshot($c);
                self::heartbeat($st, $c);
                return array('ok' => true, 'code' => 'ack:accounts');

            // ── KI-Steuerung (Engine in HtpAi, core/ai.php) ──
            // Legt einen Minimal-ACL-Dienst-User + API-Token an (provision), nimmt ihn zurueck
            // (revoke) oder meldet den Erreichbarkeits-/Freischalt-Status (status). Das Ergebnis geht
            // zusaetzlich ueber den eigenen Report-Kanal an den Relay (Token verschluesselt-serverseitig).
            case 'ai.provision':
                if (!class_exists('HtpAi')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }
                return self::runAi($st, $cid, 'provision', $params);

            case 'ai.revoke':
                if (!class_exists('HtpAi')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }
                return self::runAi($st, $cid, 'revoke', $params);

            case 'ai.status':
                if (!class_exists('HtpAi')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }
                return self::runAi($st, $cid, 'status', $params);

            // ── Backup-Feature (Engine in HtpBackup, core/backup.php) ──
            case 'backup.analyze':
                return class_exists('HtpBackup')
                    ? HtpBackup::analyze($st, $cid, $params)
                    : array('ok' => false, 'code' => 'no_engine');

            case 'backup.run':
                if (!class_exists('HtpBackup')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }
                // Nur seed'en + schnell zurueck; jobInline() (Tick-Ende, nach FFR) rechnet das
                // Backup dann detached zu Ende -> der Poll-Response bleibt schnell.
                return HtpBackup::start($st, $cid, $params);

            case 'backup.schedule':
                // ZWEI unabhaengige Zeitplaene mit je eigener Kadenz + eigener Aufbewahrung:
                //   Voll-Backup (Dateien + DB)  -> bk_full_cad / bk_keep_full
                //   Datenbank-Backup (nur DB)   -> bk_db_cad   / bk_keep_db
                // Inkrement-Backups sind entfallen (fehleranfaellig: Ketten + keine DB) -> stillgelegt.
                // Abwaertskompatibel zum alten 'cadence' (=Voll) und 'keep' (=beide Pools).
                $fullCad = isset($params['full']) ? (string) $params['full']
                    : (isset($params['cadence']) ? (string) $params['cadence'] : 'off');
                $dbCad   = isset($params['db']) ? (string) $params['db'] : 'off';

                $st['bk_full_cad'] = in_array($fullCad, array('off', 'daily', 'weekly', 'monthly'), true) ? $fullCad : 'off';
                $st['bk_db_cad']   = in_array($dbCad, array('off', 'daily', 'weekly', 'monthly'), true) ? $dbCad : 'off';
                $st['bk_incr_cad'] = 'off'; // Alt-Zeitplan endgueltig aus

                // Uhrzeit "HH:MM" (lokale Zeit der Website) fuer die geplanten Backups.
                if (isset($params['time']) && preg_match('~^([01][0-9]|2[0-3]):([0-5][0-9])$~', (string) $params['time'], $m)) {
                    $st['bk_hour'] = (int) $m[1];
                    $st['bk_min']  = (int) $m[2];
                }

                // Getrennte Aufbewahrung; 'keep' (alt, gemeinsam) als Fallback fuer beide.
                $legacyKeep = isset($params['keep']) ? max(1, min(400, (int) $params['keep'])) : null;

                if (isset($params['keep_full'])) {
                    $st['bk_keep_full'] = max(1, min(400, (int) $params['keep_full']));
                } elseif ($legacyKeep !== null) {
                    $st['bk_keep_full'] = $legacyKeep;
                }

                if (isset($params['keep_db'])) {
                    $st['bk_keep_db'] = max(1, min(400, (int) $params['keep_db']));
                } elseif ($legacyKeep !== null) {
                    $st['bk_keep_db'] = $legacyKeep;
                }

                if (array_key_exists('thin', $params)) {
                    $st['bk_thin'] = !empty($params['thin']) ? 1 : 0;
                }

                return array('ok' => true, 'code' => 'backup_schedule');

            case 'backup.exclude':
                // Dauerhafte Ausschluss-Liste (grosse Verzeichnisse / DB-Tabellen); gilt fuer ALLE
                // kuenftigen Backups (geplant + manuell). Sanitisierung in der Engine.
                if (!class_exists('HtpBackup')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }
                return HtpBackup::setExcludes($st, $params);

            case 'backup.restore':
                if (!class_exists('HtpBackup')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }
                // Nur seed'en + schnell zurueck; jobInline/tick fahren den Restore detached zu Ende.
                return HtpBackup::startRestore($st, $cid, $params);

            case 'backup.delete':
                if (!class_exists('HtpBackup')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }
                // Ein Backup (+ abhaengige Inkremente) endgueltig loeschen. Der naechste Heartbeat
                // meldet die aktualisierte Liste (Relay spiegelt sie).
                return HtpBackup::deleteBackup(isset($params['ref']) ? $params['ref'] : '');

            case 'backup.download':
                // Download laeuft ueber den signierten Direkt-Endpunkt (onAjaxHtplinkdl), NICHT als Befehl.
                return array('ok' => false, 'code' => 'backup_not_ready');

            // ── Joomla-Kern-Update (Engine in HtpCoreUpdate, core/coreupdate*.php) ──
            case 'core.update':
                if (!class_exists('HtpCoreUpdate')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }
                // Nur seed'en + schnell zurueck; jobInline/tick fahren das Kern-Update dann
                // resumierbar zu Ende (check -> download -> extract -> finalise -> verify).
                return HtpCoreUpdate::start($st, $cid, $params);

            // ── Automatische Kern-Updates: schaltet JOOMLAS Bordmittel (2.6.6) ──
            case 'core.auto':
                // /core-auto steuert Joomlas EIGENE „Automated Updates" (com_joomlaupdate ->
                // params.autoupdate). params.on = an/aus; beim Einschalten wird - wie Joomlas UI -
                // ein Update-Token erzeugt, falls keiner existiert. HTProtect wendet KEINE Kern-
                // Updates mehr selbst automatisch an (nur EIN Gleis: Joomlas Bordmittel).
                $onj = !empty($params['on']) ? 1 : 0;
                $rj  = self::setJoomlaAutoUpdate($onj);
                $st['want_facts'] = 1;               // sysfacts.core_auto (nativer Zustand) sofort spiegeln
                self::heartbeat($st, $cfg);
                return $rj
                    ? array('ok' => true, 'code' => 'core_auto:' . $onj)
                    : array('ok' => false, 'code' => 'core_auto_failed');

            // ── Kern-Update-Quelle setzen (J3): com_joomlaupdate-Update-Server fern setzen ──
            case 'core.updatesource':
                if (!class_exists('HtpCoreUpdateJ3')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }
                // params.url = Ziel-Collection-URL ('' -> JoomlaWorks-UTD-Vorgabe). Setzt die
                // com_joomlaupdate-Params (updatesource=custom + customurl) UND die Location der
                // Kern-Update-Site. Nur HTTPS; idempotent.
                $suRes = HtpCoreUpdateJ3::setUpdateSource(isset($params['url']) ? (string) $params['url'] : '');
                $st['want_facts'] = 1;
                return !empty($suRes['ok'])
                    ? array('ok' => true, 'code' => 'core_updatesource:' . (int) (isset($suRes['changed']) ? $suRes['changed'] : 0))
                    : array('ok' => false, 'code' => 'core_updatesource_failed:' . (isset($suRes['error']) ? $suRes['error'] : '?'));

            // ── JoomShaper-J3-Patch (Helix Ultimate/Helix3) manuell ausloesen/zuruecknehmen ──
            // Deckt den Fall ab, dass Sicherheitsupdates bewusst AUS sind (sonst laeuft der
            // Auto-Ausloeser). params.action = apply|revert|status (Default apply); params.ext =
            // helixultimate|helix3 (optional; ohne = alle anwendbaren). Engine ist fail-closed +
            // reversibel; hier nur Delegation + Ergebnis-Rollup.
            case 'joomshaper.patch':
                if (!class_exists('HtpJoomShaperPatch')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }

                $jpAction = isset($params['action']) ? strtolower((string) $params['action']) : 'apply';
                $jpExt    = isset($params['ext']) ? strtolower((string) $params['ext']) : '';
                $jpExt    = ($jpExt === 'helixultimate' || $jpExt === 'helix3') ? $jpExt : null;

                if ($jpAction === 'status') {
                    return array('ok' => true, 'code' => 'jspatch_status', 'log' => json_encode(HtpJoomShaperPatch::getStatus()));
                }

                $jpRes = ($jpAction === 'revert')
                    ? HtpJoomShaperPatch::revert($jpExt)
                    : HtpJoomShaperPatch::apply($jpExt);

                $st['want_facts'] = 1; // Inventar/Radar sofort neu spiegeln (Warnung faellt bei Erfolg)

                $jpOk    = true;
                $jpCodes = array();

                foreach ((array) $jpRes as $jpKey => $jpOne) {
                    $jpCodes[] = $jpKey . ':' . (isset($jpOne['code']) ? $jpOne['code'] : '?');

                    if (isset($jpOne['ok']) && $jpOne['ok'] === false) {
                        $jpOk = false;
                    }
                }

                return array('ok' => $jpOk, 'code' => 'jspatch:' . implode(',', $jpCodes), 'log' => json_encode($jpRes));

            // ── Dateimanager fern FREISCHALTEN / abschalten (2.7.2) ──
            // Nur der Schalter laeuft ueber die Queue (kleiner Config-Zustand, dateibasiert, DB-
            // unabhaengig -> auch im Rescue lesbar). Die eigentlichen Datei-Operationen laufen NIE hier,
            // sondern ueber den direkten E2E-Endpunkt (HtpLink::handleFilesRequest), der isEnabled prueft.
            case 'files.enable':
                if (!class_exists('HtpFiles')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }

                $st['want_facts'] = 1; // caps sofort neu melden (Dashboard sieht die Freischaltung)

                return HtpFiles::enable();

            case 'files.disable':
                if (!class_exists('HtpFiles')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }

                $st['want_facts'] = 1;

                return HtpFiles::disable();

            // ── DB-Verwaltung: nur Freischalten/Abschalten laeuft ueber die Queue (off-by-default). Die
            //    DB-Operationen selbst laufen NIE hier, sondern ueber den versiegelten HtpLink::handleDbRequest.
            case 'db.enable':
                if (!class_exists('HtpDb')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }

                $st['want_facts'] = 1; // caps sofort neu melden (Dashboard sieht die Freischaltung)

                return HtpDb::enable((int) (isset($params['ttl']) ? $params['ttl'] : 0));

            case 'db.disable':
                if (!class_exists('HtpDb')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }

                $st['want_facts'] = 1;

                return HtpDb::disable();

            // ── Backend-Passwortschutz als Batch (Dashboard: neu setzen / ergaenzen / entfernen) ──
            case 'guard.batch':
                return self::runGuardBatch($st, $params);

            // ── Notfall-Modus / Wartung fern schalten (2.6.10; Engine in HtpApp::remoteEmergency) ──
            // Autoritaet = die bereits gepruefte Ed25519-Signatur (verifyCommand), NICHT die UI-
            // core.admin-Pruefung. 'on' schreibt die 503-Sperre + Selbsttest; 'off' hebt sie sauber
            // auf; 'rekey' rotiert das Zugangs-Token. Das crash-feste 'off' bei voll gesperrter Seite
            // kommt zusaetzlich ueber rescue.php (siehe caps-Kommentar).
            case 'emergency.on':
            case 'emergency.off':
            case 'emergency.rekey':
                if (!class_exists('HtpApp')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }

                $emRes = HtpApp::remoteEmergency(substr($type, 10)); // 'emergency.' abschneiden -> on|off|rekey

                // ENTSCHEIDEND: sofort den neuen Zustand melden. Bei 'on' ist die Site danach 503-
                // gesperrt -> ein spaeterer Heartbeat verhungert mangels Traffic; NUR dieser Push
                // liefert dem Dashboard lock=1 + den E2E-versiegelten Zugangs-Link (sonst bleibt die
                // Notfall-Karte leer). Frischer cfg-Stand (Lock/Key wurden gerade geaendert).
                if (!empty($emRes['ok']) && class_exists('HtpConfig')) {
                    self::heartbeat($st, HtpConfig::load());
                }

                return $emRes;

            // ── Werkzeug-Ansichten (2.6.5) ──
            case 'facts.refresh':
                // On-Abruf: teure Proben (SMTP-Connect) sofort neu fahren statt aufs 24h-Fenster
                // zu warten, und das volle sysfacts-Buendel im naechsten Heartbeat erzwingen.
                if (class_exists('HtpConfig')) {
                    self::runMailHealth($cfg);
                    $cfg = HtpConfig::load();
                }
                $st['want_facts'] = 1;
                self::heartbeat($st, $cfg);
                return array('ok' => true, 'code' => 'facts_refreshed');

            case 'postinstall.hide':
                // ALLE offenen Post-Install-Meldungen ausblenden (published=0, reversibel; kein Loeschen).
                return self::runPostinstallHide($st, $cfg);

            case 'module.versioning':
                // Modul-Versionierung einschalten (nur Joomla 6+); params.revisions = History-Limit.
                return self::runModuleVersioning($st, $cfg, $params);

            // ── Fehleranzeige/Debug + Backward-Compatibility (2.6.6) ──
            case 'debug.set':
                // params.level in {none,default,simple,maximum,development}. Sichtbare Stufen
                // schalten einen 30-Min-Auto-Revert auf 'none' scharf (Waechter).
                return self::runDebugSet($st, $cfg, $params);

            case 'bc.enable':
                // Backward-Compatibility-Plugin(e) wieder aktivieren (heilt B/C-Fatals). Idempotent.
                return self::runBcEnable($st, $cfg);

            // ── Neuanmeldung (Engine in HtpRegClean, core/regclean.php) ──
            case 'reg.set':
                // Registrierung an/aus (com_users allowUserRegistration). Reiner Params-Schalter.
                if (!class_exists('HtpRegClean')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }
                $regOn = !empty($params['on']) ? 1 : 0;
                $rok   = HtpRegClean::setRegistration($regOn);
                $st['want_facts'] = 1;                 // sysfacts.reg sofort spiegeln
                self::heartbeat($st, $cfg);
                return $rok
                    ? array('ok' => true, 'code' => 'reg_set:' . $regOn)
                    : array('ok' => false, 'code' => 'reg_set_failed');

            case 'reg.cleanup':
                if (!class_exists('HtpRegClean')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }
                $mode = isset($params['mode']) ? (string) $params['mode'] : 'preview';
                if ($mode === 'preview') {
                    // Synchron: Anzahl + maskierte Beispiele + Kriterien-Hash (fuehrt NICHTS aus). Das
                    // reichhaltige Ergebnis reist ueber das log-Feld (nur ok/code/log ueberleben bis zum
                    // CommandResult) - das Dashboard liest es dort und parst es (GET .../result).
                    $pv = HtpRegClean::preview($params);
                    if (is_array($pv)) {
                        $pv['log'] = (string) json_encode($pv);
                    }
                    return $pv;
                }
                if ($mode === 'list') {
                    // Synchron: Verlauf der Aufraeum-Aktionen (fuer den Rueckgaengig-Dialog).
                    $pl = HtpRegClean::journalList();
                    if (is_array($pl)) {
                        $pl['log'] = (string) json_encode($pl);
                    }
                    return $pl;
                }
                if ($mode === 'restore') {
                    // Journal zurueckspielen (async, resumierbar).
                    return HtpRegClean::startRestore($st, $cid, $params);
                }
                // mode = run: Batch-Delete anstossen (async; Confirm-Hash + Count-Lock in start()).
                return HtpRegClean::start($st, $cid, $params);

            case 'reg.cleanup.cancel':
                if (!class_exists('HtpRegClean')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }
                return HtpRegClean::cancel($st);

            case 'search.run':
                // Synchron + NUR LESEND: Zaehler + gedeckelte Beispiel-Snippets als JSON im
                // log-Feld (Praezedenz reg.cleanup preview). HtpSearch::run deckelt das JSON
                // bereits unter das 2000-Zeichen-Log-Budget; reportResult kappt zusaetzlich.
                if (!class_exists('HtpSearch')) {
                    return array('ok' => false, 'code' => 'no_engine');
                }
                $sr = HtpSearch::run($params);
                if (is_array($sr)) {
                    $sr['log'] = (string) json_encode($sr);
                }
                return $sr;
        }

        return array('ok' => false, 'code' => 'unknown_type');
    }

    /**
     * Backend-Passwortschutz als Batch (Dashboard-Massenaktion). op = set|add|remove.
     *   - set    : genau EINE neue Kombi, alle bestehenden HTProtect-Zugaenge weg (replaceAll).
     *   - add    : eine weitere Kombi ergaenzen - NUR wenn HTProtect den Schutz schon selbst fuehrt.
     *   - remove : Passwortschutz komplett entfernen (alle User).
     * Das Passwort (mode=same) bzw. das Anhaengsel (mode=domain) kommt E2E-VERSIEGELT (Sealed-Box an
     * site_pub): nur DIESE Site oeffnet es mit ihrem site_priv - der Relay sieht nie Klartext. Bei
     * mode=domain leitet die Site das Passwort selbst ab: Domain-Kern (aus der eigenen URL, IDN-korrekt)
     * + Anhaengsel. Alles laeuft ueber HtpGuard mit dessen Login-Selbsttest + Rollback (kein Lockout).
     *
     * @return array{ok:bool,code:string,detail?:string}
     */
    private static function runGuardBatch(&$st, $params)
    {
        if (!class_exists('HtpGuard')) {
            return array('ok' => false, 'code' => 'guard_unavailable');
        }

        $op = isset($params['op']) ? (string) $params['op'] : '';

        if ($op === 'remove') {
            $r = HtpGuard::remove();
            // Bewusst entfernt -> Backend-Schutz als "uebersprungen" markieren (identisch zum X auf der
            // Onboarding-Karte): Self-Check-Ampel wird gruen + die "Schutz einrichten"-Karte verschwindet,
            // der Betreiber wird nicht sofort wieder zum Einrichten genudged. saveKeys (nicht save) - nur
            // diesen einen Schluessel race-sicher setzen. Nur bei Erfolg.
            if (is_array($r) && !empty($r['success']) && class_exists('HtpConfig')) {
                HtpConfig::saveKeys(array('guard_dismissed' => 1));
            }
            return self::guardResult($r, 'guard_removed');
        }

        // Option 2: Cookie-Gate ueber einen geheimen Zugangs-String aktivieren (Alternative zum
        // Passwortschutz). Der String ist ein URL-Parameter (kein Passwort), reist im signierten
        // Befehl mit; lockout-sicher (Betreiber kennt ihn, 1-Klick-Login traegt ihn ohnehin).
        if ($op === 'string') {
            $secret = isset($params['gate']) ? trim((string) $params['gate']) : '';

            if ($secret === '') {
                return array('ok' => false, 'code' => 'guard_missing');
            }

            $r = HtpGuard::setupString($secret);

            // Neuen Modus sofort ans Dashboard (want_facts + Heartbeat) - so weiss die UI, dass
            // Option 2 aktiv ist und das 1-Klick-Login den String traegt.
            if (is_array($r) && !empty($r['success']) && class_exists('HtpConfig')) {
                $st['want_facts'] = 1;
                self::heartbeat($st, HtpConfig::load());
            }

            return self::guardResult($r, 'guard_string_set');
        }

        if ($op !== 'set' && $op !== 'add') {
            return array('ok' => false, 'code' => 'guard_bad_op');
        }

        $user   = isset($params['user']) ? trim((string) $params['user']) : '';
        $mode   = isset($params['mode']) ? (string) $params['mode'] : 'same';
        $sealed = isset($params['pw_sealed']) ? (string) $params['pw_sealed'] : '';

        if ($user === '' || $sealed === '') {
            return array('ok' => false, 'code' => 'guard_missing');
        }

        $secret = self::openSealedForSite($st, $sealed);

        if ($secret === null || $secret === '') {
            return array('ok' => false, 'code' => 'guard_decrypt');
        }

        if ($mode === 'domain') {
            $core = self::domainCore(function_exists('htp_site_url') ? htp_site_url() : '');

            if ($core === '') {
                return array('ok' => false, 'code' => 'guard_no_domain');
            }

            $password = $core . $secret;
        } else {
            $password = $secret;
        }

        if ($op === 'add') {
            if (!HtpGuard::isOwnActive()) {
                return array('ok' => false, 'code' => 'guard_not_protected'); // „ergaenzen" nur bei bestehendem Schutz
            }

            return self::guardResult(HtpGuard::addUser($user, $password), 'guard_added');
        }

        // op === 'set': genau EINE Kombi, alle bestehenden weg (replaceAll). setup() testet den Login
        // und rollt bei Fehlschlag auf den urspruenglichen Stand zurueck -> nie ungeschuetzt.
        return self::guardResult(HtpGuard::setup($user, $password, '', true), 'guard_set');
    }

    /**
     * Oeffnet eine im Operator-Browser erzeugte Sealed-Box (crypto.js `sealTo`, Empfaenger =
     * Ed25519-site_pub ueber Montgomery-Wandlung) mit dem eigenen site_priv. Format byte-identisch
     * zu crypto.js: ephPub(32) || nonce(24) || XChaCha20-Poly1305-IETF-Chiffrat; Schluessel =
     * sha512(shared || ephPub || myXpub)[:32]. Gibt Klartext oder null zurueck (nie werfen).
     */
    private static function openSealedForSite($st, $blobB64)
    {
        try {
            if (!function_exists('sodium_crypto_sign_ed25519_sk_to_curve25519')
                || !function_exists('sodium_crypto_aead_xchacha20poly1305_ietf_decrypt')) {
                return null;
            }

            $sk = base64_decode((string) (isset($st['site_priv']) ? $st['site_priv'] : ''), true);

            if ($sk === false || strlen($sk) !== 64) {
                return null; // kein/kaputtes Site-Keypair -> nicht entschluesselbar
            }

            $xPriv = sodium_crypto_sign_ed25519_sk_to_curve25519($sk);
            $xPub  = sodium_crypto_scalarmult_base($xPriv);
            $blob  = base64_decode((string) $blobB64, true);

            if ($blob === false || strlen($blob) < 32 + 24 + 16) {
                return null;
            }

            $eph    = substr($blob, 0, 32);
            $nonce  = substr($blob, 32, 24);
            $ct     = substr($blob, 56);
            $shared = sodium_crypto_scalarmult($xPriv, $eph);
            $key    = substr(hash('sha512', $shared . $eph . $xPub, true), 0, 32);
            $pt     = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ct, '', $nonce, $key);

            return ($pt === false) ? null : $pt;
        } catch (\Throwable $e) {
            return null;
        }
    }

    /**
     * Notfall-Status fuer den Heartbeat. Bei aktiver Sperre reist der geheime Zugangs-Link
     * E2E-VERSIEGELT an den Operator (sealToOperator) - der Relay sieht ihn nie im Klartext, nur
     * der Operator-Browser oeffnet ihn (wie die Guard-Passwoerter, nur andere Richtung). Ohne
     * Sperre nur {lock:0}. Faellt Sodium/der Operator-Pub weg, bleibt der Link weg (kein Klartext).
     *
     * @return array
     */
    private static function emergencyBeat($st, $cfg)
    {
        if (empty($cfg['emergency_lock'])) {
            return array('lock' => 0);
        }

        $out = array('lock' => 1);
        $key = isset($cfg['emergency_key']) ? (string) $cfg['emergency_key'] : '';

        if ($key !== '' && function_exists('htp_trusted_base')) {
            $base = rtrim((string) htp_trusted_base(isset($cfg['unsub_base']) ? $cfg['unsub_base'] : ''), '/');

            if ($base !== '' && $base !== '/') {
                $sealed = self::sealToOperator($st, $base . '/?htp_' . $key);

                if ($sealed !== '') {
                    $out['link_sealed'] = $sealed;
                }
            }
        }

        return $out;
    }

    /**
     * Der ueber HTProtect eingerichtete Backend-Zugangs-String (Cookie-Gate, Option 2) - E2E an den
     * Operator versiegelt, damit das Dashboard das 1-Klick-Login-Feld automatisch fuellt. Nur wenn
     * der Gate-Modus GENAU JETZT aktiv ist (Live-status(), nicht bloss die Config) UND wir den String
     * kennen. Format wie das Admin-URL-Feld: fuehrendes '?'. '' -> es reist nichts (kein Klartext).
     *
     * @return string  base64 der Sealed-Box oder ''
     */
    private static function guardStringSealed($st, $cfg)
    {
        if (!class_exists('HtpGuard')) {
            return '';
        }

        try {
            $g = HtpGuard::status();
        } catch (\Throwable $e) {
            return '';
        }

        if (empty($g['active']) || (isset($g['mode']) ? (string) $g['mode'] : '') !== 'string') {
            return '';
        }

        $str = isset($cfg['guard_string']) ? (string) $cfg['guard_string'] : '';

        if ($str === '') {
            return '';
        }

        return self::sealToOperator($st, '?' . $str);
    }

    /**
     * Klartext E2E an den GEPINNTEN Operator-Schluessel (ws_pub, Ed25519 -> Curve25519) versiegeln -
     * BYTE-IDENTISCH zum Format, das openSealedForSite oeffnet: ephPub(32) || nonce(24) ||
     * XChaCha20-Poly1305-IETF-Chiffrat; Schluessel = sha512(shared || ephPub || recipientXpub)[:32].
     * Der Operator oeffnet mit seinem Privatschluessel (Browser-Tresor, crypto.js). '' wenn nicht
     * moeglich (kein Sodium / kein gepinnter Operator-Pub) -> dann reist gar kein Link.
     *
     * @return string  base64 der Sealed-Box oder ''
     */
    private static function sealToOperator($st, $plaintext)
    {
        try {
            if (!function_exists('sodium_crypto_sign_ed25519_pk_to_curve25519')
                || !function_exists('sodium_crypto_aead_xchacha20poly1305_ietf_encrypt')
                || !function_exists('sodium_crypto_scalarmult')
                || !function_exists('sodium_crypto_scalarmult_base')) {
                return '';
            }

            $edPub = base64_decode((string) (isset($st['ws_pub']) ? $st['ws_pub'] : ''), true);

            if ($edPub === false || strlen($edPub) !== 32) {
                return '';
            }

            $xPub    = sodium_crypto_sign_ed25519_pk_to_curve25519($edPub);   // Empfaenger-Curve-Pub
            $ephPriv = function_exists('htp_random_bytes') ? (string) htp_random_bytes(32) : random_bytes(32);
            $ephPub  = sodium_crypto_scalarmult_base($ephPriv);
            $shared  = sodium_crypto_scalarmult($ephPriv, $xPub);
            $key     = substr(hash('sha512', $shared . $ephPub . $xPub, true), 0, 32);
            $nonce   = function_exists('htp_random_bytes') ? (string) htp_random_bytes(24) : random_bytes(24);
            $ct      = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, '', $nonce, $key);

            return base64_encode($ephPub . $nonce . $ct);
        } catch (\Throwable $e) {
            return '';
        } catch (\Exception $e) {
            return '';
        }
    }

    /**
     * Direkter, SYNCHRONER, voll E2E-versiegelter Dateimanager-Endpunkt (2.7.2). Aufgerufen vom
     * Watch-Plugin (onAjaxHtpfiles). Der Operator-Browser sendet {sealed}; 'sealed' ist eine Sealed-Box
     * an site_pub, deren Klartext {cmd, sig} ist (cmd = derselbe kanonische, Ed25519-signierte Befehl
     * wie im Queue-Kanal, hier aber files.*). Ablauf: entsiegeln -> Signatur/Frische/Replay/Typ pruefen
     * -> isEnabled -> an HtpFiles verteilen -> Ergebnis an den Operator versiegeln. Der Relay sieht nur
     * Routing + Chiffrat (Autoritaet UND Inhalt UND Metadaten blind). Bewusst NICHT der Queue-Kanal
     * (dessen Ergebnis-log ist 2000-Byte-gedeckelt).
     *
     * @param   string  $rawBody  roher POST-Body (JSON)
     *
     * @return  string  JSON, das der Endpunkt 1:1 ausgibt
     */
    public static function handleFilesRequest($rawBody)
    {
        if (!class_exists('HtpFiles')) {
            return json_encode(array('ok' => false, 'code' => 'no_engine'));
        }

        if (!function_exists('sodium_crypto_sign_verify_detached')) {
            return json_encode(array('ok' => false, 'code' => 'no_crypto'));
        }

        $in = json_decode((string) $rawBody, true);

        if (!is_array($in) || !isset($in['sealed']) || !is_string($in['sealed'])) {
            return json_encode(array('ok' => false, 'code' => 'bad_body'));
        }

        $st = self::loadState();

        if (!is_array($st) || empty($st['ws_pub']) || empty($st['site_id'])) {
            return json_encode(array('ok' => false, 'code' => 'unpaired'));
        }

        // Freischaltung wird TYP-ABHAENGIG geprueft (siehe unten, nach der Signatur): die Steuer-Ops
        // arm/disarm/status muessen auch bei GESCHLOSSENEM Fenster durchkommen (sonst koennte man nie
        // freischalten - Henne/Ei); die Datenoperationen bleiben strikt hinter dem offenen Fenster.

        // (1) Entsiegeln: Operator -> Site (site_priv). Der Relay sieht den Klartext nie.
        $plain = self::openSealedForSite($st, (string) $in['sealed']);

        if ($plain === null) {
            return json_encode(array('ok' => false, 'code' => 'unseal_failed'));
        }

        $env = json_decode((string) $plain, true);

        if (!is_array($env) || !isset($env['cmd']) || !is_array($env['cmd']) || !isset($env['sig'])) {
            return json_encode(array('ok' => false, 'code' => 'bad_env'));
        }

        $cmd = $env['cmd'];
        $sig = (string) $env['sig'];

        // (2) Signatur + Frische + Replay + Typ (fail-closed).
        $why = self::verifyFilesCmd($cmd, $sig, $st);

        if ($why !== '') {
            return json_encode(array('ok' => false, 'code' => $why));
        }

        $type   = (string) $cmd['type'];
        $params = (isset($cmd['params']) && is_array($cmd['params'])) ? $cmd['params'] : array();
        $isCtl  = ($type === 'files.arm' || $type === 'files.disarm' || $type === 'files.status');

        // (3) Opt-in-Gate NUR fuer Datenoperationen: ohne offenes Fenster ist der Daten-Endpunkt inert.
        // arm/disarm/status sind die Steuerebene und laufen unabhaengig davon (arm OEFFNET das Fenster).
        if (!$isCtl && !HtpFiles::isEnabled()) {
            return json_encode(array('ok' => false, 'code' => 'files_off'));
        }

        // (4) Nonce/cmd_id verbrauchen (eigener Ring, keine Kollision mit der Queue).
        self::ringPush($st, 'seen_files', (string) $cmd['nonce']);
        self::ringPush($st, 'done_files', (string) $cmd['cmd_id']);
        self::saveState($st);

        // (5) An HtpFiles verteilen (die eigentliche Sicherheit liegt dort: Jail/Deny/Scan/Snapshot).
        $res = self::dispatchFiles($type, $params);

        // (6) Festes Fenster: HtpFiles::touch() ist bewusst ein No-op - das 1h-Fenster laeuft ab
        // Freischaltung fix und wird durch Nutzung NICHT verlaengert (nach Ablauf per Knopf neu
        // freischalten). Aufruf bleibt als Struktur-/B-C-Anker erhalten.
        if (!$isCtl && is_array($res) && !empty($res['ok'])) {
            HtpFiles::touch();
        }

        // (5) Ergebnis an den Operator versiegeln (Site -> Operator, ws_pub).
        $sealed = self::sealToOperator($st, (string) json_encode($res));

        if ($sealed === '') {
            return json_encode(array('ok' => false, 'code' => 'seal_failed'));
        }

        return json_encode(array('ok' => true, 'sealed' => $sealed));
    }

    /**
     * Signaturpruefung fuer files.*-Befehle - analog verifyCommand, aber mit EIGENER Typ-Allow-Liste
     * (nicht caps() -> die Datei-Operationen sind bewusst NICHT ueber die Queue erreichbar), kurzem
     * TTL (Kanaltrennung) und eigenem Nonce-/cmd_id-Ring. '' = gueltig; sonst der Ablehnungsgrund.
     */
    private static function verifyFilesCmd($cmd, $sig, $st)
    {
        static $types = array(
            // Steuerebene (auch bei geschlossenem Fenster erlaubt): freischalten/abschalten/status.
            'files.arm', 'files.disarm', 'files.status',
            // Datenebene (nur bei offenem Fenster):
            'files.browse', 'files.read', 'files.write', 'files.mkdir', 'files.delete', 'files.rename',
            'files.chmod', 'files.copy', 'files.search', 'files.ziplist', 'files.extract', 'files.restore',
            'files.disk', 'files.write_begin', 'files.write_chunk', 'files.write_commit',
        );

        if (!is_array($cmd)) {
            return 'bad_cmd';
        }

        if ((isset($cmd['pv']) ? $cmd['pv'] : '') !== self::PROTO) {
            return 'bad_proto';
        }

        $cmdSite = (string) (isset($cmd['site_id']) ? $cmd['site_id'] : '');

        if ($cmdSite === ''
            || ($cmdSite !== (string) $st['site_id']
                && !(!empty($st['public_id']) && $cmdSite === (string) $st['public_id']))) {
            return 'wrong_site';
        }

        if (!in_array((string) (isset($cmd['type']) ? $cmd['type'] : ''), $types, true)) {
            return 'unknown_type';
        }

        $now = time();
        $exp = (int) (isset($cmd['expires_at']) ? $cmd['expires_at'] : 0);
        $iss = (int) (isset($cmd['issued_at']) ? $cmd['issued_at'] : 0);

        if ($exp <= 0 || $now > $exp) {
            return 'expired';
        }

        // Kanaltrennung: der Files-Kanal akzeptiert NUR kurzlebige Befehle (<= 5 Min) -> ein fuer die
        // Queue signierter (bis 1 h gueltiger) Befehl kann hier nicht wiedereingespielt werden.
        if ($exp - $now > 300) {
            return 'ttl_too_long';
        }

        if ($iss > $now + self::SKEW) {
            return 'future';
        }

        $nonce = (string) (isset($cmd['nonce']) ? $cmd['nonce'] : '');

        if ($nonce === '' || self::ringHas($st, 'seen_files', $nonce)) {
            return 'replay';
        }

        $cid = (string) (isset($cmd['cmd_id']) ? $cmd['cmd_id'] : '');

        if ($cid === '' || self::ringHas($st, 'done_files', $cid)) {
            return 'done';
        }

        $sigRaw = base64_decode((string) $sig, true);
        $pubRaw = base64_decode((string) $st['ws_pub'], true);

        if ($sigRaw === false || $pubRaw === false || strlen($sigRaw) !== 64 || strlen($pubRaw) !== 32) {
            return 'bad_key';
        }

        $ok = false;

        try {
            $ok = sodium_crypto_sign_verify_detached($sigRaw, self::canonical($cmd), $pubRaw);
        } catch (Exception $e) {
            $ok = false;
        } catch (Throwable $e) {
            $ok = false;
        }

        return $ok ? '' : 'bad_sig';
    }

    /**
     * files.* -> HtpFiles. Reine Zuordnung; die Sicherheit (Jail/Deny/Scan/Snapshot) liegt in HtpFiles.
     * Unbekannte Typen sind hier nicht erreichbar (verifyFilesCmd hat sie bereits verworfen).
     */
    private static function dispatchFiles($type, $params)
    {
        $p = function ($k, $d = '') use ($params) {
            return isset($params[$k]) ? $params[$k] : $d;
        };
        $b = function ($k) use ($params) {
            return !empty($params[$k]);
        };

        switch ($type) {
            // Steuerebene (Sofort-Freischaltung ueber den synchronen Kanal): kein Queue-/Heartbeat-Umweg.
            case 'files.arm':
                return HtpFiles::enable((int) $p('ttl', 0));

            case 'files.disarm':
                return HtpFiles::disable();

            case 'files.status':
                return HtpFiles::status();

            case 'files.browse':
                return HtpFiles::browse((string) $p('path', ''), (string) $p('sort', 'folders'), (int) $p('offset', 0));

            case 'files.read':
                $rd = HtpFiles::read((string) $p('path', ''), (int) $p('off', 0), (int) $p('len', 0));

                // Inhalt binaersicher + JSON-tauglich als base64 zurueck (der Operator-Browser dekodiert);
                // die Antwort ist ohnehin voll an den Operator versiegelt.
                if (!empty($rd['ok']) && isset($rd['content'])) {
                    $rd['content_b64'] = base64_encode((string) $rd['content']);
                    unset($rd['content']);
                }

                return $rd;

            case 'files.write':
                // content reist als base64 (binaersicher, JSON-tauglich) -> hier zu Rohbytes dekodieren.
                return HtpFiles::write((string) $p('path', ''), (string) base64_decode((string) $p('content', '')), (string) $p('expect_md5', ''), $b('confirm'));

            case 'files.mkdir':
                return HtpFiles::mkdir((string) $p('path', ''));

            case 'files.delete':
                return HtpFiles::delete((string) $p('path', ''), $b('confirm'));

            case 'files.rename':
                return HtpFiles::rename((string) $p('path', ''), (string) $p('dest', ''));

            case 'files.chmod':
                return HtpFiles::chmod((string) $p('path', ''), (string) $p('mode', ''));

            case 'files.copy':
                return HtpFiles::copy((string) $p('path', ''), (string) $p('dest', ''), $b('recursive'));

            case 'files.search':
                return HtpFiles::search((string) $p('path', ''), (string) $p('query', ''), (string) $p('mode', 'name'));

            case 'files.ziplist':
                return HtpFiles::ziplist((string) $p('path', ''));

            case 'files.extract':
                return HtpFiles::extract((string) $p('path', ''), (string) $p('dest', ''), $b('confirm'));

            case 'files.restore':
                return HtpFiles::restore((string) $p('snap', ''), $b('confirm'));

            case 'files.disk':
                return HtpFiles::disk();

            case 'files.write_begin':
                return HtpFiles::writeBegin((string) $p('path', ''));

            case 'files.write_chunk':
                return HtpFiles::writeChunk((string) $p('xfer', ''), (int) $p('seq', 0), (string) base64_decode((string) $p('bytes', '')));

            case 'files.write_commit':
                return HtpFiles::writeCommit((string) $p('path', ''), (string) $p('xfer', ''), (string) $p('expect_md5', ''), $b('confirm'));
        }

        return array('ok' => false, 'code' => 'unknown_type');
    }

    /**
     * Direkter, SYNCHRONER, voll E2E-versiegelter DB-Verwaltungs-Endpunkt (Weg A). Aufgerufen vom
     * Watch-Plugin (onAjaxHtpdb). Ablauf EXAKT wie handleFilesRequest: entsiegeln (Operator->Site) ->
     * Signatur/Frische/Replay/Typ pruefen -> isEnabled -> an HtpDb verteilen -> Ergebnis an den Operator
     * versiegeln. Der Relay sieht nur Routing + Chiffrat (Autoritaet UND Inhalt UND Metadaten blind).
     *
     * @param   string  $rawBody  roher POST-Body (JSON)
     *
     * @return  string  JSON, das der Endpunkt 1:1 ausgibt
     */
    public static function handleDbRequest($rawBody)
    {
        if (!class_exists('HtpDb')) {
            return json_encode(array('ok' => false, 'code' => 'no_engine'));
        }

        if (!function_exists('sodium_crypto_sign_verify_detached')) {
            return json_encode(array('ok' => false, 'code' => 'no_crypto'));
        }

        $in = json_decode((string) $rawBody, true);

        if (!is_array($in) || !isset($in['sealed']) || !is_string($in['sealed'])) {
            return json_encode(array('ok' => false, 'code' => 'bad_body'));
        }

        $st = self::loadState();

        if (!is_array($st) || empty($st['ws_pub']) || empty($st['site_id'])) {
            return json_encode(array('ok' => false, 'code' => 'unpaired'));
        }

        // Freischaltung wird TYP-ABHAENGIG geprueft (nach der Signatur): die Steuer-Ops arm/disarm/status
        // muessen auch bei GESCHLOSSENEM Fenster durchkommen (arm OEFFNET es - sonst Henne/Ei); die
        // Datenoperationen bleiben strikt hinter dem offenen Fenster.

        // (1) Entsiegeln: Operator -> Site (site_priv). Der Relay sieht den Klartext nie.
        $plain = self::openSealedForSite($st, (string) $in['sealed']);

        if ($plain === null) {
            return json_encode(array('ok' => false, 'code' => 'unseal_failed'));
        }

        $env = json_decode((string) $plain, true);

        if (!is_array($env) || !isset($env['cmd']) || !is_array($env['cmd']) || !isset($env['sig'])) {
            return json_encode(array('ok' => false, 'code' => 'bad_env'));
        }

        $cmd = $env['cmd'];
        $sig = (string) $env['sig'];

        // (2) Signatur + Frische + Replay + Typ (fail-closed).
        $why = self::verifyDbCmd($cmd, $sig, $st);

        if ($why !== '') {
            return json_encode(array('ok' => false, 'code' => $why));
        }

        $type   = (string) $cmd['type'];
        $params = (isset($cmd['params']) && is_array($cmd['params'])) ? $cmd['params'] : array();
        $isCtl  = ($type === 'db.arm' || $type === 'db.disarm' || $type === 'db.status');

        // (3) Opt-in-Gate NUR fuer Datenoperationen. arm/disarm/status sind die Steuerebene.
        if (!$isCtl && !HtpDb::isEnabled()) {
            return json_encode(array('ok' => false, 'code' => 'db_off'));
        }

        // (4) Nonce/cmd_id verbrauchen (eigener Ring, keine Kollision mit Queue/Files).
        self::ringPush($st, 'seen_db', (string) $cmd['nonce']);
        self::ringPush($st, 'done_db', (string) $cmd['cmd_id']);
        self::saveState($st);

        // (5) An HtpDb verteilen (die eigentliche DB-Logik + Sicherheits-Deckel liegen dort).
        $res = self::dispatchDb($type, $params);

        // (6) Festes Fenster: HtpDb::touch() ist bewusst ein No-op - das 1h-Fenster laeuft ab
        // Freischaltung fix und wird durch Nutzung NICHT verlaengert (nach Ablauf per Knopf neu
        // freischalten). Aufruf bleibt als Struktur-/B-C-Anker erhalten.
        if (!$isCtl && is_array($res) && !empty($res['ok'])) {
            HtpDb::touch();
        }

        // (5) Ergebnis an den Operator versiegeln (Site -> Operator, ws_pub).
        $sealed = self::sealToOperator($st, (string) json_encode($res));

        if ($sealed === '') {
            return json_encode(array('ok' => false, 'code' => 'seal_failed'));
        }

        return json_encode(array('ok' => true, 'sealed' => $sealed));
    }

    /**
     * Signaturpruefung fuer db.*-Befehle - analog verifyFilesCmd: EIGENE Typ-Allow-Liste (NICHT ueber die
     * Queue erreichbar), kurzes TTL (Kanaltrennung), eigener Nonce-/cmd_id-Ring. '' = gueltig; sonst Grund.
     */
    private static function verifyDbCmd($cmd, $sig, $st)
    {
        static $types = array(
            // Steuerebene (auch bei geschlossenem Fenster erlaubt):
            'db.arm', 'db.disarm', 'db.status',
            // Datenebene (nur bei offenem Fenster):
            'db.tables', 'db.structure', 'db.browse', 'db.sql', 'db.saverow', 'db.delrows',
            'db.truncate', 'db.droptable', 'db.exportsql', 'db.exportcsv', 'db.import', 'db.search',
        );

        if (!is_array($cmd)) {
            return 'bad_cmd';
        }

        if ((isset($cmd['pv']) ? $cmd['pv'] : '') !== self::PROTO) {
            return 'bad_proto';
        }

        $cmdSite = (string) (isset($cmd['site_id']) ? $cmd['site_id'] : '');

        if ($cmdSite === ''
            || ($cmdSite !== (string) $st['site_id']
                && !(!empty($st['public_id']) && $cmdSite === (string) $st['public_id']))) {
            return 'wrong_site';
        }

        if (!in_array((string) (isset($cmd['type']) ? $cmd['type'] : ''), $types, true)) {
            return 'unknown_type';
        }

        $now = time();
        $exp = (int) (isset($cmd['expires_at']) ? $cmd['expires_at'] : 0);
        $iss = (int) (isset($cmd['issued_at']) ? $cmd['issued_at'] : 0);

        if ($exp <= 0 || $now > $exp) {
            return 'expired';
        }

        // Kanaltrennung: nur kurzlebige Befehle (<= 5 Min) -> ein fuer die Queue signierter Befehl kann
        // hier nicht wiedereingespielt werden.
        if ($exp - $now > 300) {
            return 'ttl_too_long';
        }

        if ($iss > $now + self::SKEW) {
            return 'future';
        }

        $nonce = (string) (isset($cmd['nonce']) ? $cmd['nonce'] : '');

        if ($nonce === '' || self::ringHas($st, 'seen_db', $nonce)) {
            return 'replay';
        }

        $cid = (string) (isset($cmd['cmd_id']) ? $cmd['cmd_id'] : '');

        if ($cid === '' || self::ringHas($st, 'done_db', $cid)) {
            return 'done';
        }

        $sigRaw = base64_decode((string) $sig, true);
        $pubRaw = base64_decode((string) $st['ws_pub'], true);

        if ($sigRaw === false || $pubRaw === false || strlen($sigRaw) !== 64 || strlen($pubRaw) !== 32) {
            return 'bad_key';
        }

        $ok = false;

        try {
            $ok = sodium_crypto_sign_verify_detached($sigRaw, self::canonical($cmd), $pubRaw);
        } catch (Exception $e) {
            $ok = false;
        } catch (Throwable $e) {
            $ok = false;
        }

        return $ok ? '' : 'bad_sig';
    }

    /**
     * db.* -> HtpDb. Reine Zuordnung; die DB-Logik + Sicherheits-Deckel liegen in HtpDb. Unbekannte Typen
     * sind hier nicht erreichbar (verifyDbCmd hat sie bereits verworfen).
     */
    private static function dispatchDb($type, $params)
    {
        $s = function ($k) use ($params) {
            return (string) (isset($params[$k]) ? $params[$k] : '');
        };
        $a = function ($k) use ($params) {
            return (isset($params[$k]) && is_array($params[$k])) ? $params[$k] : array();
        };

        switch ($type) {
            // Steuerebene (Sofort-Freischaltung ueber den synchronen Kanal): kein Queue-/Heartbeat-Umweg.
            // MUSS zu den in verifyDbCmd erlaubten Typen db.arm/db.disarm/db.status passen (sonst faellt
            // ein erlaubter Typ hier faelschlich in den unknown_type-Default) - Parity zu dispatchFiles.
            case 'db.arm':
                return HtpDb::enable((int) (isset($params['ttl']) ? $params['ttl'] : 0));

            case 'db.disarm':
                return HtpDb::disable();

            case 'db.status':
                return HtpDb::status();

            case 'db.tables':
                return HtpDb::tables();

            case 'db.structure':
                return HtpDb::structure($s('table'));

            case 'db.browse':
                return HtpDb::browse($s('table'), $a('opts'));

            case 'db.sql':
                return HtpDb::runSql($s('sql'));

            case 'db.saverow':
                return HtpDb::saveRow($s('table'), $a('set'), $a('where'), !empty($params['is_new']));

            case 'db.delrows':
                return HtpDb::deleteRows($s('table'), $a('rows'));

            case 'db.truncate':
                return HtpDb::truncateTable($s('table'));

            case 'db.droptable':
                return HtpDb::dropTable($s('table'));

            case 'db.exportsql':
                return HtpDb::exportSql($a('tables'), !empty($params['gz']));

            case 'db.exportcsv':
                return HtpDb::exportCsv($s('table'), $a('opts'));

            case 'db.import':
                return HtpDb::importSql($s('sql'), !empty($params['gz']));

            case 'db.search':
                return HtpDb::search($s('needle'), $a('tables'));
        }

        return array('ok' => false, 'code' => 'unknown_type');
    }

    /**
     * Domain-Kern aus einer URL: ohne Schema/Pfad, ohne fuehrendes www, ohne Subdomain, ohne TLD;
     * IDN/Punycode wird zu echten Umlauten dekodiert. z. B. https://www.sub.muenchen-shop.de/x -> „muenchen-shop".
     * (Bei mehrteiligen TLDs wie co.uk ist der Kern die vorletzte Marke - fuer den DACH-Markt korrekt.)
     */
    private static function domainCore($url)
    {
        $host = (string) @parse_url((string) $url, PHP_URL_HOST);

        if ($host === '') {
            return '';
        }

        $host = strtolower(trim($host, '.'));

        if (strpos($host, 'xn--') !== false && function_exists('idn_to_utf8')) {
            $u = @idn_to_utf8($host, 0, defined('INTL_IDNA_VARIANT_UTS46') ? INTL_IDNA_VARIANT_UTS46 : 0);

            if (is_string($u) && $u !== '') {
                $host = $u;
            }
        }

        $labels = explode('.', $host);

        if (count($labels) > 1 && $labels[0] === 'www') {
            array_shift($labels);
        }

        $n = count($labels);

        if ($n <= 1) {
            return $labels[0]; // Hostname ohne Punkt
        }

        return $labels[$n - 2]; // SLD = Marke direkt vor der TLD
    }

    /** HtpGuard-Ergebnis ({success,message}) in das Befehls-Ergebnisformat uebersetzen. */
    private static function guardResult($r, $okCode)
    {
        $ok  = is_array($r) && !empty($r['success']);
        $msg = (is_array($r) && isset($r['message'])) ? (string) $r['message'] : '';

        return array('ok' => $ok, 'code' => $ok ? $okCode : 'guard_failed', 'detail' => $msg);
    }

    /**
     * Bedrohungs-Snapshot ausserhalb der UI neu bauen - dieselbe rote Sicht wie der
     * Hintergrund-Waechter (htprotectwatch). Aufgerufen nach threat.ack, damit der
     * sofortige Heartbeat den bereinigten Stand meldet, ohne auf den 15-Minuten-
     * Waechter zu warten. Fail-open: bei jedem Fehler bleibt der alte Snapshot.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return  void
     */
    private static function refreshThreatSnapshot($cfg)
    {
        if (!class_exists('HtpStatus')) {
            return;
        }

        try {
            $vuln = (class_exists('HtpDefs') && method_exists('HtpDefs', 'checkExtensions'))
                ? HtpDefs::checkExtensions()
                : array();
            $vuln = is_array($vuln) ? $vuln : array();

            $vulnActive = (class_exists('HtpDefs') && method_exists('HtpDefs', 'vulnPartition'))
                ? HtpDefs::vulnPartition($vuln, isset($cfg['vuln_ack']) ? $cfg['vuln_ack'] : array())
                : array('active' => $vuln);

            $rogue = class_exists('HtpAccounts')
                ? HtpAccounts::suspiciousAdmins(isset($cfg['admin_whitelist']) ? $cfg['admin_whitelist'] : array())
                : array();

            $acc = class_exists('HtpAccounts') ? HtpAccounts::evaluate($cfg) : array();

            // Gecachter SEO-/Defacement-Befund (wie der Waechter) - kein teurer Live-Scan.
            $seo = (isset($cfg['seo_overview_finding']) && is_array($cfg['seo_overview_finding']))
                ? $cfg['seo_overview_finding']
                : array();

            HtpStatus::writeSnapshot($cfg, array(
                'suspicious_admins' => is_array($rogue) ? $rogue : array(),
                'vuln'              => $vuln,
                'vuln_active'       => isset($vulnActive['active']) ? $vulnActive['active'] : $vuln,
                'account_changes'   => is_array($acc) ? $acc : array(),
                'jce_profiles'      => (class_exists('HtpDefs') && method_exists('HtpDefs', 'checkJceProfiles')) ? HtpDefs::checkJceProfiles() : array(),
                'mal_htaccess'      => (class_exists('HtpStorage') && method_exists('HtpStorage', 'scanMaliciousHtaccess')) ? HtpStorage::scanMaliciousHtaccess() : array(),
                'seo_finding'       => $seo,
            ));
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    private static function applyUpdatePolicy($params)
    {
        if (!class_exists('HtpConfig')) {
            return array('ok' => false, 'code' => 'no_engine');
        }

        $now       = time();
        $useIntent = class_exists('HtpAuIntent'); // Sync-faehig? (Datei vorhanden)
        $keys      = array();                     // sonstige (nicht per-Erweiterung) Flach-Keys

        // ── Zwei-Wege-Sync-Pfad: per-Erweiterung-ABSICHT (LWW), sparsam per-Key ──
        if ($useIntent) {
            $cfg           = HtpConfig::load();
            $intent        = HtpAuIntent::ensure($cfg, $now);
            $touched       = false;
            $defaultTouched = false;

            // v2: per-Erweiterung-Deltas. Der Befehl liefert die ts (Zeitpunkt des Operator-Klicks, fuer
            // LWW); die seq vergibt der CONNECTOR selbst (rev+1, monoton) - so bleibt sie lokal luecken-
            // frei fuers sparsame Delta, und ein verspaeteter Befehl verliert korrekt ueber die aeltere ts.
            if (isset($params['changes']) && is_array($params['changes'])) {
                foreach ($params['changes'] as $ch) {
                    if (!is_array($ch)) {
                        continue;
                    }
                    $ext = isset($ch['ext']) ? (string) $ch['ext'] : '';
                    $val = isset($ch['val']) ? (string) $ch['val'] : '';
                    $ts  = isset($ch['ts'])  ? (int) $ch['ts']  : $now;
                    if ($ext !== '' && $val !== '' && HtpAuIntent::applyExt($intent, $ext, $val, HtpAuIntent::rev($intent) + 1, $ts, 'app')) {
                        $touched = true;
                    }
                    // Per-Erweiterung-Karenz (optional, app-autoritativ): eigener grace_ts-LWW, kein
                    // Einfluss auf val. Aeltere Connector kennen das Feld nicht -> ignorieren es still.
                    if ($ext !== '' && isset($ch['grace']) && HtpAuIntent::applyExtGrace($intent, $ext, (int) $ch['grace'], $ts)) {
                        $touched = true;
                    }
                }
            }

            // Fleet-Default: v2 als Objekt {val,ts} ODER v1 als String (Legacy). seq wiederum connector-lokal.
            if (isset($params['default']) && is_array($params['default'])) {
                $dv = isset($params['default']['val']) ? (string) $params['default']['val'] : '';
                $dt = isset($params['default']['ts'])  ? (int) $params['default']['ts']  : $now;
                if (HtpAuIntent::applyDefault($intent, $dv, HtpAuIntent::rev($intent) + 1, $dt, 'app')) {
                    $touched = $defaultTouched = true;
                }
            } else {
                $dv = isset($params['default']) ? (string) $params['default']
                    : (isset($params['mode']) ? (string) $params['mode'] : '');
                if (in_array($dv, array('security', 'all', 'off', 'never'), true)) {
                    // v1 hat kein Versionsfeld -> als naechste seq einfalten (origin app).
                    if (HtpAuIntent::applyDefault($intent, $dv, HtpAuIntent::rev($intent) + 1, $now, 'app')) {
                        $touched = $defaultTouched = true;
                    }
                }
            }

            // v1-Altbefehl: ganze Include-/Exclude-Listen -> per-Key in den intent falten (Transient,
            // nur falls ein alter Befehl einen frisch aktualisierten Connector erreicht).
            if (isset($params['includes']) && is_array($params['includes'])) {
                $seqN = HtpAuIntent::rev($intent) + 1;
                foreach (self::sanitizeKeys($params['includes']) as $k) {
                    if (HtpAuIntent::applyExt($intent, $k, 'on', $seqN, $now, 'app')) {
                        $touched = true;
                    }
                }
            }
            if (isset($params['excludes']) && is_array($params['excludes'])) {
                $seqN = HtpAuIntent::rev($intent) + 1;
                foreach (self::sanitizeKeys($params['excludes']) as $k) {
                    if (HtpAuIntent::applyExt($intent, $k, 'off', $seqN, $now, 'app')) {
                        $touched = true;
                    }
                }
            }
            if (isset($params['nevers']) && is_array($params['nevers'])) {
                // v1-Altbefehl mit "Nie"-Liste (ab 2.7.2). Per-Key als 'never' in den intent falten.
                $seqN = HtpAuIntent::rev($intent) + 1;
                foreach (self::sanitizeKeys($params['nevers']) as $k) {
                    if (HtpAuIntent::applyExt($intent, $k, 'never', $seqN, $now, 'app')) {
                        $touched = true;
                    }
                }
            }

            if ($touched) {
                // intent = Wahrheit; Flach-Keys DARAUS ableiten, damit autoupdate.php unveraendert laeuft.
                $flat = HtpAuIntent::deriveFlat($intent);
                $keys['autoupdate_intent']  = $intent;
                $keys['autoupdate_include'] = $flat['autoupdate_include'];
                $keys['autoupdate_exclude'] = $flat['autoupdate_exclude'];
                $keys['autoupdate_never']   = $flat['autoupdate_never'];
                $keys['autoupdate_default'] = $flat['autoupdate_default'];

                // Master-Schalter nur bei Default-Aenderung nachziehen (Original-Kopplung bewahrt).
                // Legacy 'off' schaltet die Automatik ganz ab (master+all=0). 'never' ist NICHT 'off':
                // es vergibt nur fuer UNGELISTETE nichts (steckt in der per-Erweiterung-Aufloesung,
                // auDefault==='never' -> never) - der Security-Master bleibt AN, damit explizite
                // "Nur Sicherheit"/"Immer"-Overrides + der Boden weiter laufen. 'all' oeffnet zusaetzlich
                // das Gate. 'never'/'security' fassen autoupdate_all nur ueber die Include-Kopplung an.
                if ($defaultTouched) {
                    $def       = (string) $flat['autoupdate_default'];
                    $legacyOff = ($def === 'off');
                    $keys['autoupdate_ext'] = $legacyOff ? 0 : 1;

                    if ($legacyOff) {
                        $keys['autoupdate_all'] = 0;
                    } elseif ($def === 'all' || !empty($flat['autoupdate_include'])) {
                        $keys['autoupdate_all'] = 1;
                    }
                } elseif ($flat['autoupdate_default'] !== 'off'
                    && (!empty($flat['autoupdate_include']) || $flat['autoupdate_default'] === 'all')) {
                    // GATE-KOPPLUNG (Schalter 2 = autoupdate_all): eine reine per-Erweiterung-Auswahl
                    // (Dashboard-v2-Delta) traegt KEIN 'default' -> ohne dies bliebe autoupdate_all evtl. 0
                    // und die Auswahl waere GESPEICHERT-ABER-WIRKUNGSLOS (Gate zu; execPlan laeuft dann
                    // vuln-only). Sobald die Fleet-Politik ueberhaupt ein Nicht-Security-Update WILL
                    // (nicht-leere Auswahlliste ODER default 'all'), das Gate oeffnen. Nur hier erreichbar
                    // (applyUpdatePolicy = Dashboard-Befehl => gemanagte Site, Dashboard ist autoritativ);
                    // Schalter 1 (Security-Master) bleibt unberuehrt.
                    $keys['autoupdate_all'] = 1;
                }
            }
        } else {
            // ── Fallback ohne HtpAuIntent (Datei fehlt): EXAKT das Alt-Verhalten (+ never) ──
            $default = isset($params['default']) ? (string) $params['default']
                : (isset($params['mode']) ? (string) $params['mode'] : '');
            if (in_array($default, array('security', 'all', 'off', 'never'), true)) {
                $keys['autoupdate_default'] = $default;
                // Legacy 'off' schaltet die Automatik ganz ab; 'never'/'security'/'all' lassen den
                // Security-Master AN (global 'never' vergibt nur fuer Ungelistete nichts, ueber den
                // Default selbst - nicht ueber den Master). 'all' oeffnet zusaetzlich das Gate.
                $keys['autoupdate_ext'] = ($default === 'off') ? 0 : 1;
                if ($default === 'off') {
                    $keys['autoupdate_all'] = 0;
                } elseif ($default === 'all') {
                    $keys['autoupdate_all'] = 1;
                }
            }
            if (isset($params['includes']) && is_array($params['includes'])) {
                $keys['autoupdate_include'] = self::sanitizeKeys($params['includes']);
            }
            if (isset($params['excludes']) && is_array($params['excludes'])) {
                $keys['autoupdate_exclude'] = self::sanitizeKeys($params['excludes']);
            }
            if (isset($params['nevers']) && is_array($params['nevers'])) {
                $keys['autoupdate_never'] = self::sanitizeKeys($params['nevers']);
            }
        }

        // Fleet-weite Zeitplan-/Sicherheits-Parameter (nicht per-Erweiterung, daher direkt als Flach-Keys).
        if (isset($params['skip_major'])) {
            $keys['autoupdate_skip_major'] = !empty($params['skip_major']) ? 1 : 0;
        }
        if (isset($params['schedule']) && in_array($params['schedule'], array('instant', 'daily', 'weekly'), true)) {
            $keys['autoupdate_schedule'] = (string) $params['schedule'];
        }
        if (isset($params['hour'])) {
            $keys['autoupdate_hour'] = max(0, min(23, (int) $params['hour']));
        }
        if (isset($params['weekday'])) {
            $keys['autoupdate_weekday'] = max(1, min(7, (int) $params['weekday']));
        }
        if (isset($params['grace'])) {
            $g = (int) $params['grace'];
            $keys['autoupdate_grace'] = in_array($g, array(0, 1, 3, 7), true) ? $g : 3;
        }
        // Fleet-Schalter: auch bei JEDEM erfolgreichen Auto-Update mailen (Default AUS). Fehlschlag/
        // Rollback/Host-Wechsel/Security werden ohnehin gemeldet - das hier ist die Routine-Erfolgsmail.
        if (isset($params['mail_success'])) {
            $keys['autoupdate_mail_success'] = !empty($params['mail_success']) ? 1 : 0;
        }

        // DURCHSETZEN: der Operator hat im Dashboard eine Divergenz aufgeloest ("Auswahl 'Auto', aber am
        // Connector wirkungslos, weil ein Gate zu ist"). Beide Master-Gates aufziehen, damit die
        // vorhandene Auswahl wirklich greift. Bewusst DIREKT (kein LWW/Default-Umweg): es ist eine
        // ausdrueckliche, aktuelle Operator-Aktion und darf keinen aelteren Backend-Default reaktivieren.
        // Kommt ganz zuletzt -> ueberstimmt eine etwaige Default-Kopplung desselben Befehls.
        if (!empty($params['enforce'])) {
            $keys['autoupdate_ext'] = 1;
            $keys['autoupdate_all'] = 1;
        }

        if (empty($keys)) {
            return array('ok' => false, 'code' => 'policy_noop');
        }

        // HTProtect selbst gehoert NICHT in die per-Erweiterung-Auswahl (Self-Update laeuft ueber
        // einen eigenen Pfad). Auch auf dem Dashboard-/Fleet-Weg einen ggf. mitgeschickten oder aus
        // dem Intent re-derivten com_htprotect-Eintrag entfernen - symmetrisch zu HtpAutoUpdate::taskSave.
        if (class_exists('HtpAutoUpdate') && method_exists('HtpAutoUpdate', 'stripSelfInclude')) {
            HtpAutoUpdate::stripSelfInclude($keys);
        }

        HtpConfig::saveKeys($keys);

        return array('ok' => true, 'code' => 'policy_set');
    }

    /** ext_key-Liste saeubern: nur Strings, getrimmt + lowercase, eindeutig, gedeckelt. */
    private static function sanitizeKeys($arr)
    {
        $out = array();

        foreach ((array) $arr as $k) {
            if (!is_string($k) && !is_numeric($k)) {
                continue;
            }

            $k = strtolower(trim((string) $k));

            if ($k !== '' && strlen($k) <= 190) {
                $out[$k] = true;
            }
        }

        return array_slice(array_keys($out), 0, 500);
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Resumierbare Jobs (Malware-Scan / Kern-Integritaet)
    // ─────────────────────────────────────────────────────────────────────────

    private static function startScan(&$st, $cid)
    {
        // Abgelaufenen Job zuerst wegraeumen (siehe jobStale) - sonst blockiert ein einmal
        // haengengebliebener Job dauerhaft jeden weiteren Scan.
        $cfgTmp = class_exists('HtpConfig') ? HtpConfig::load() : array();

        if (!empty($st['job']) && !self::clearStaleJob($st, $cfgTmp)) {
            return array('ok' => false, 'code' => 'busy'); // ein Job laeuft wirklich
        }

        if (!class_exists('HtpStorage') || !method_exists('HtpStorage', 'scanMalwareChunk')) {
            return array('ok' => false, 'code' => 'no_scanner');
        }

        $id = HtpStorage::scanStateNewId();
        $state = array();

        $cfg = class_exists('HtpConfig') ? HtpConfig::load() : array();
        if (!empty($cfg['mal_whitelist']) && is_array($cfg['mal_whitelist'])) {
            $state['wl'] = $cfg['mal_whitelist'];
        }

        HtpStorage::scanStateWrite($id, $state);

        $st['job'] = array(
            'type'     => 'scan',
            'cmd_id'   => (string) $cid,
            'scan_id'  => $id,
            'started'  => time(),
            // Fortschritts-Zeitstempel: Basis der Haenger-Erkennung (siehe jobStale).
            'advanced' => time(),
            'progress' => 0.0,
            'hits'     => 0,
            'sample'   => array(),
        );

        // Sofort ein Stueck weit rechnen (Responsivitaet).
        self::advanceJob($st, $cfg);

        return array('ok' => true, 'code' => 'scan_started', 'async' => true);
    }

    private static function startIntegrity(&$st, $cid)
    {
        // Gleiche Selbstheilung wie beim Scan: abgelaufener Job blockiert nicht dauerhaft.
        $cfgTmp = class_exists('HtpConfig') ? HtpConfig::load() : array();

        if (!empty($st['job']) && !self::clearStaleJob($st, $cfgTmp)) {
            return array('ok' => false, 'code' => 'busy');
        }

        if (!class_exists('HtpCoreScan') || !method_exists('HtpCoreScan', 'begin')) {
            return array('ok' => false, 'code' => 'no_corescan');
        }

        $id = HtpCoreScan::newId();
        $state = HtpCoreScan::begin();
        HtpCoreScan::stateWrite($id, $state);

        $st['job'] = array(
            'type'     => 'integrity',
            'cmd_id'   => (string) $cid,
            'scan_id'  => $id,
            'started'  => time(),
            // Fortschritts-Zeitstempel: Basis der Haenger-Erkennung (siehe jobStale).
            'advanced' => time(),
            'progress' => 0.0,
        );

        $cfg = class_exists('HtpConfig') ? HtpConfig::load() : array();
        self::advanceJob($st, $cfg);

        return array('ok' => true, 'code' => 'integrity_started', 'async' => true);
    }

    /** Bringt den laufenden Job im Wall-Clock-Budget voran; meldet bei Abschluss. */
    private static function advanceJob(&$st, $cfg)
    {
        // Selbst-Zeitlimit je Schritt: verhindert, dass ein haengender Schritt bei Hosts mit
        // max_execution_time=0 ewig laeuft und den flock ewig haelt. set_time_limit RESETtet den
        // Timer bei JEDEM Aufruf -> ein normaler, gestepter Job (viele Schritte) laeuft weiter,
        // nur EIN einzelner Schritt ist gedeckelt. (Reine I/O-Wartezeit deckelt der FPM-IO-Timeout.)
        @set_time_limit(self::JOB_STEP_MAX);

        $job = $st['job'];

        // Watchdog: haengender Job (kein Fortschritt) ODER absurd lange Gesamtlaufzeit ->
        // sauber beenden und als Fehler melden, statt Befehl und Job-Slot ewig zu blockieren.
        // Die genaue Regel steckt in jobStale(); hier NUR noch der Aufruf, damit Start-Pfad
        // und Tick garantiert dieselbe Entscheidung treffen.
        if (self::jobStale($st)) {
            self::finishJob($st, $cfg, false, 'job_stalled', 0, array());

            return;
        }

        // Crash-fester Anlauf-Marker: VOR dem riskanten Schritt hochzaehlen UND persistieren.
        // Stirbt der Schritt gleich hart (OOM/Timeout-Kill), ueberlebt dieser Zaehler den Crash
        // (anders als 'tries', das erst NACH dem Schritt zaehlt) -> jobStale gibt nach
        // JOB_CRASH_TRIES Anlaeufen auf, statt bis zur 6-h-Notbremse zu blockieren. Bei echtem
        // Fortschritt wird er unten wieder auf 0 gesetzt.
        $st['job']['stab'] = (int) (isset($st['job']['stab']) ? $st['job']['stab'] : 0) + 1;
        self::saveState($st);
        $job = $st['job'];

        // Fingerabdruck des Fortschritts VOR der Arbeit. Der Zeitstempel 'advanced' wird
        // anschliessend NUR aufgefrischt, wenn sich davon etwas geaendert hat.
        //
        // Bewusst so herum: Wuerde man ihn schon beim blossen Versuch setzen, wuerde ein Job,
        // dessen Chunk bei JEDEM Durchlauf stirbt, dauerhaft als "lebendig" gelten und den
        // Slot fuer immer blockieren - genau der Fehler, den diese Erkennung beheben soll.
        // Massgeblich ist also echter Fortschritt, nicht der gute Wille.
        $vorher = self::jobFingerprint($st);

        $deadline = microtime(true) + self::JOB_BUDGET;

        try {
            if ($job['type'] === 'scan') {
                $state = HtpStorage::scanStateRead($job['scan_id']);

                if (!is_array($state)) {
                    self::finishJob($st, $cfg, false, 'scan_lost', 0, array());
                    return;
                }

                $done = false;

                do {
                    $r = HtpStorage::scanMalwareChunk($state, 5.0, 300);

                    if (!is_array($r)) {
                        // Soft-Fehler der Scan-Engine -> Job sauber beenden + melden (nicht haengen).
                        HtpStorage::scanStateWrite($job['scan_id'], $state);
                        self::finishJob($st, $cfg, false, 'scan_error', (int) $job['hits'], array());

                        return;
                    }

                    if (!empty($r['hits']) && is_array($r['hits'])) {
                        $job['hits'] = (int) $job['hits'] + count($r['hits']);
                        foreach ($r['hits'] as $h) {
                            if (count($job['sample']) < 20 && isset($h['path'])) {
                                $job['sample'][] = (string) $h['path'];
                            }
                        }
                    }

                    if (isset($r['quarantine'])) {
                        $job['quar'] = (int) $r['quarantine']; // kumulativ (Anzahl vom Hoster gesperrter Dateien)
                    }

                    $seen   = isset($state['seen']) ? (int) $state['seen'] : 0;
                    $queued = isset($state['queued']) ? (int) $state['queued'] : (isset($r['queued']) ? (int) $r['queued'] : 0);
                    $job['progress'] = ($seen + $queued) > 0 ? round($seen / ($seen + $queued), 3) : ($done ? 1.0 : $job['progress']);

                    $done = is_array($r) && !empty($r['done']);
                } while (!$done && microtime(true) < $deadline);

                HtpStorage::scanStateWrite($job['scan_id'], $state);
                $st['job'] = $job;
                self::saveState($st);

                if ($done) {
                    if (method_exists('HtpStorage', 'malwareBaselineWrite')) {
                        $ver = (defined('HTP_VERSION') ? HTP_VERSION : '') . '|' . (class_exists('HtpDefs') ? HtpDefs::malwareVersion() : '');
                        $paths = isset($state['hitpaths']) && is_array($state['hitpaths']) ? $state['hitpaths'] : array();
                        @HtpStorage::malwareBaselineWrite(isset($state['started']) ? $state['started'] : time(), $ver, $paths);
                    }
                    HtpStorage::scanStateDelete($job['scan_id']);
                    self::finishJob($st, $cfg, true, 'scan_done', (int) $job['hits'], $job['sample']);
                }
            } elseif ($job['type'] === 'integrity') {
                if (!class_exists('HtpCoreScan')) {
                    self::finishJob($st, $cfg, false, 'no_corescan', 0, array());
                    return;
                }

                $state = HtpCoreScan::stateRead($job['scan_id']);

                if (!is_array($state)) {
                    self::finishJob($st, $cfg, false, 'integrity_lost', 0, array());
                    return;
                }

                $changed = 0;

                do {
                    $r = HtpCoreScan::step($state);
                    if (is_array($r) && !empty($r['items']) && is_array($r['items'])) {
                        $changed += count($r['items']);
                    }
                    $job['progress'] = is_array($r) && isset($r['progress']) ? (float) $r['progress'] : $job['progress'];
                } while (!HtpCoreScan::isDone($state) && microtime(true) < $deadline);

                HtpCoreScan::stateWrite($job['scan_id'], $state);
                $st['job'] = $job;
                self::saveState($st);

                if (HtpCoreScan::isDone($state)) {
                    $counts = isset($state['counts']) && is_array($state['counts']) ? $state['counts'] : array();
                    $ch = (int) (isset($counts['changed']) ? $counts['changed'] : 0);
                    $mi = (int) (isset($counts['missing']) ? $counts['missing'] : 0);
                    HtpCoreScan::stateDelete($job['scan_id']);
                    if (method_exists('HtpCoreScan', 'cleanCache')) {
                        @HtpCoreScan::cleanCache(isset($state['version']) ? $state['version'] : '');
                    }
                    self::finishJob($st, $cfg, true, 'integrity_done:' . $ch . '/' . $mi, $ch + $mi, array());
                }
            } elseif ($job['type'] === 'backup') {
                // Voll-Backup (Dateien/DB) - die gesamte Logik liegt in HtpBackup (data/-sicher,
                // resumierbar). Hier nur den Job spiegeln + bei Abschluss die Zero-Knowledge-Zaehler
                // (last_backup) merken und den Operator-Command abschliessen.
                if (!class_exists('HtpBackup')) {
                    self::finishJob($st, $cfg, false, 'no_engine', 0, array());
                    return;
                }

                $r   = HtpBackup::advance($job, $deadline, $cfg);
                $job = (isset($r['job']) && is_array($r['job'])) ? $r['job'] : $job;
                $st['job'] = $job;
                self::saveState($st);

                if (!empty($r['done'])) {
                    if (isset($r['report']) && is_array($r['report'])) {
                        $st['backup_report'] = $r['report'];
                    }
                    self::finishJob($st, $cfg, !empty($r['ok']), isset($r['code']) ? (string) $r['code'] : 'backup_done', 0, array());
                }
            } elseif ($job['type'] === 'coreupdate') {
                // Joomla-Kern-Update - die gesamte Phasen-Logik liegt in HtpCoreUpdate
                // (resumierbar, kopflos ueber Joomlas com_joomlaupdate-API). Hier nur den
                // Job spiegeln + bei Abschluss den Operator-Command abschliessen. Der
                // Zero-Knowledge-Fortschritt (Phase/%) reist ohnehin im Heartbeat mit.
                if (!class_exists('HtpCoreUpdate')) {
                    self::finishJob($st, $cfg, false, 'no_engine', 0, array());
                    return;
                }

                $r   = HtpCoreUpdate::advance($job, $deadline, $cfg);
                $job = (isset($r['job']) && is_array($r['job'])) ? $r['job'] : $job;
                $st['job'] = $job;
                self::saveState($st);

                if (!empty($r['done'])) {
                    self::finishJob($st, $cfg, !empty($r['ok']), isset($r['code']) ? (string) $r['code'] : 'core_update_done', 0, array());
                }
            } elseif ($job['type'] === 'reg_cleanup') {
                // Spam-Konten aufraeumen bzw. ein Journal zuruueckspielen - die gesamte Logik (strenge
                // Kriterien, ACL-Fail-Safe, Journal) liegt in HtpRegClean. Hier nur Job spiegeln + bei
                // Abschluss den Operator-Command abschliessen. Der Fortschritt reist im Heartbeat mit.
                if (!class_exists('HtpRegClean')) {
                    self::finishJob($st, $cfg, false, 'no_engine', 0, array());
                    return;
                }

                $r   = ((isset($job['op']) ? $job['op'] : '') === 'restore')
                    ? HtpRegClean::advanceRestore($job, $deadline, $cfg)
                    : HtpRegClean::advance($job, $deadline, $cfg);
                $job = (isset($r['job']) && is_array($r['job'])) ? $r['job'] : $job;
                $st['job'] = $job;
                self::saveState($st);

                if (!empty($r['done'])) {
                    $cnt = (int) (isset($job['deleted']) ? $job['deleted'] : (isset($job['restored']) ? $job['restored'] : 0));
                    self::finishJob($st, $cfg, !empty($r['ok']), isset($r['code']) ? (string) $r['code'] : 'reg_cleanup_done', $cnt, array());
                }
            } else {
                // Unbekannter Job-Typ -> aufraeumen.
                unset($st['job']);
                self::saveState($st);
            }

            // Hat sich wirklich etwas bewegt? Laeuft der Job noch (finishJob hat ihn nicht
            // entfernt)? Dann Fortschritt stempeln und den Fehlversuchs-Zaehler zuruecksetzen.
            // Sonst: Fehlversuch zaehlen - DAS ist das traffic-unabhaengige Haenger-Signal.
            if (!empty($st['job'])) {
                if (self::jobFingerprint($st) !== $vorher) {
                    $st['job']['advanced'] = time();
                    $st['job']['tries']    = 0;
                    // Echter Fortschritt -> der crash-feste Anlauf-Zaehler wird zurueckgesetzt.
                    $st['job']['stab']     = 0;
                } else {
                    $st['job']['tries'] = (isset($st['job']['tries']) ? (int) $st['job']['tries'] : 0) + 1;
                }

                self::saveState($st);
            }
        } catch (Exception $e) {
            self::finishJob($st, $cfg, false, 'job_error', 0, array());
        } catch (Throwable $e) {
            self::finishJob($st, $cfg, false, 'job_error', 0, array());
        }
    }

    /**
     * Fingerabdruck des Job-Fortschritts. Aendert er sich, kam der Job voran.
     *
     * Bewusst mehrere Groessen: `progress` allein genuegt nicht - beim Backup steigt zeitweise
     * nur die Phase oder der Byte-Zaehler, waehrend der Prozentwert gerundet gleich bleibt.
     * Ein Job, der Bytes schreibt, ist lebendig, auch wenn die Anzeige noch 12 % zeigt.
     *
     * @param   array  $st  Zustand
     *
     * @return  string
     */
    private static function jobFingerprint($st)
    {
        if (empty($st['job']) || !is_array($st['job'])) {
            return '';
        }

        $j = $st['job'];

        $teile = array();

        foreach (array('phase', 'progress', 'hits', 'r_idx', 'r_zip', 'r_db_off', 'bytes', 'files', 'rows') as $k) {
            $teile[] = isset($j[$k]) ? (string) $j[$k] : '';
        }

        return implode('|', $teile);
    }

    private static function finishJob(&$st, $cfg, $ok, $code, $count, $sample)
    {
        $job = isset($st['job']) ? $st['job'] : array();
        $cid = isset($job['cmd_id']) ? (string) $job['cmd_id'] : '';

        // Fuer die Kachel/den Heartbeat den letzten Scan-Report merken (auch geplante Scans).
        // NUR bei erfolgreichem Lauf ($ok) - genau wie beim Integritaets-Zweig darunter: bricht ein
        // Scan ab (Exception -> 'job_error' mit $count=0), wuerde sonst hits=0 gemeldet und das
        // Dashboard zeigte "sauber" fuer eine moeglicherweise BEFALLENE Seite. Bei Abbruch bleibt
        // der letzte gueltige Report stehen - eine veraltete, aber ehrliche Angabe.
        if (isset($job['type']) && $job['type'] === 'scan' && $ok) {
            $st['scan_report'] = array('ts' => time(), 'hits' => (int) $count, 'quar' => (int) (isset($job['quar']) ? $job['quar'] : 0), 'sample' => $sample);
            $st['scan_last']   = time();
        } elseif (isset($job['type']) && $job['type'] === 'integrity' && $ok) {
            // Nur bei erfolgreicher Pruefung melden (ein Abbruch wie 'integrity_lost' wuerde sonst
            // faelschlich '0 Abweichungen' = sauber vortaeuschen). CH/MI aus 'integrity_done:ch/mi'.
            $ch = 0;
            $mi = 0;
            if (strpos((string) $code, 'integrity_done:') === 0) {
                $parts = explode('/', substr((string) $code, strlen('integrity_done:')));
                $ch = (int) (isset($parts[0]) ? $parts[0] : 0);
                $mi = (int) (isset($parts[1]) ? $parts[1] : 0);
            }
            $st['integrity_report'] = array('ts' => time(), 'dev' => (int) $count, 'changed' => $ch, 'missing' => $mi);
            $st['integrity_last']   = time();
        }

        unset($st['job']);
        self::saveState($st);

        // Operator-ausgeloeste Jobs melden ihr Ergebnis; geplante (cmd_id='') nicht.
        if ($cid !== '') {
            $log = $count > 0 ? ($count . ' Fund(e)') : 'sauber';
            self::reportResult($st, $cid, $ok, $code, $log, self::level($cfg));
            // reportResult/flushResults kann die Nachsende-Warteschlange veraendert haben (POST verloren
            // -> Ergebnis bleibt zum spaeteren Nachsenden liegen). Durabel sichern, bevor der Heartbeat
            // laeuft (der State oben bei 'unset job' ist noch OHNE die Warteschlange gespeichert).
            self::saveState($st);

            // Danach SOFORT einen frischen Heartbeat: sonst haengt die Dashboard-Kachel (letzter
            // Scan) + der Level bis zum naechsten hb_min-Heartbeat auf dem alten Stand, obwohl der
            // Job (den der Operator gerade angestossen hat) fertig ist. heartbeat() prueft hb_min
            // NICHT (die Drossel sitzt in tick()) -> sendet direkt. Fail-open (der advanceJob-
            // Aufrufer faengt Fehler); 0,0 Besucherlast (laeuft nach backgroundFlush im Hintergrund).
            self::heartbeat($st, $cfg);
        }
    }

    /**
     * Ergebnis eines LOKAL im Joomla-Backend angestossenen Malware-Scans dem Dashboard SOFORT
     * melden. Ohne das zeigt die Dashboard-Malware-Kachel den alten Stand, bis ein separater
     * Remote-Scan laeuft - verwirrend, wenn man gerade im Backend einen Fund beseitigt hat und der
     * erneute Backend-Scan schon "sauber" liefert. Nutzt exakt denselben Weg wie ein dashboard-
     * ausgeloester Scan (finishJob): scan_report setzen -> Sofort-Heartbeat, der last_scan traegt.
     *
     * Zero-Knowledge bleibt gewahrt: an den Relay gehen NUR ts+hits (scanReportPublic), nie Pfade.
     * Best-effort: nur bei gekoppelter Site, faengt alle Fehler ab (darf den Scan nie stoeren).
     *
     * @param   int    $hits  Aktuelle Trefferzahl des ABGESCHLOSSENEN Scans (0 = sauber)
     * @param   int    $quar  Anzahl vom Hoster gesperrter Dateien (chmod 000); reine Zahl
     * @param   mixed  $cfg   Optional bereits geladene Konfiguration; sonst wird sie geladen
     *
     * @return void
     */
    public static function reportScanResult($hits, $quar = 0, $cfg = null)
    {
        try {
            if ($cfg === null) {
                $cfg = class_exists('HtpConfig') ? HtpConfig::load() : array();
            }

            if (!self::isPaired($cfg)) {
                return; // nicht mit dem Dashboard gekoppelt -> nichts zu melden
            }

            $st = self::loadState();

            // scan_report exakt wie in finishJob (ts + hits). sample bleibt bewusst leer: die Pfade
            // gehen ohnehin nie an den Relay (scanReportPublic sendet nur ts+hits) und werden lokal
            // nirgends aus scan_report gelesen.
            $st['scan_report'] = array('ts' => time(), 'hits' => max(0, (int) $hits), 'quar' => max(0, (int) $quar), 'sample' => array());
            $st['scan_last']   = time();
            self::saveState($st);

            // Sofort-Heartbeat: traegt last_scan (nur ts+hits) ans Dashboard -> Kachel aktualisiert
            // sich direkt. heartbeat() prueft KEIN hb_min (die Drossel sitzt in tick()) -> sendet
            // sofort. Fail-open: der try/catch faengt jeden Fehler, der Scan laeuft ungestoert weiter.
            self::heartbeat($st, $cfg);
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    /**
     * Sofort-Statuspush bei einer AENDERUNG der Bedrohungslage: traegt den gerade geschriebenen
     * Threat-Snapshot (nur die verdichtete Status-Signatur - Level/Reason/Anker/Anzahl, NIE
     * Identitaeten) unverzueglich ans Dashboard, statt bis zum naechsten faelligen Heartbeat (hb_min,
     * bis zu 30 Min) zu warten. Wird vom Waechter-Tick NACH HtpStatus::writeSnapshot aufgerufen, damit
     * die Signatur den FRISCHEN Stand liest (der regulaere Heartbeat frueher im Tick sieht noch den
     * Vorrunden-Snapshot). Push NUR bei geaenderter Signatur (neuer / behobener / veraenderter Befund)
     * -> kein Heartbeat-Fluten bei anhaltendem Befund. Analog zu reportScanResult; heartbeat() prueft
     * KEIN hb_min. Zero-Knowledge (PROTOCOL.md 8): es geht ausschliesslich die Kategorie/Zahl raus,
     * nie die Rogue-Admin-Liste oder Pfade. Strikt fail-open; No-Op fuer ungekoppelte Einzelnutzer.
     *
     * @param   array|null  $cfg  Konfiguration (oder null -> laden)
     *
     * @return void
     */
    public static function reportThreatIfChanged($cfg = null)
    {
        try {
            if ($cfg === null) {
                $cfg = class_exists('HtpConfig') ? HtpConfig::load() : array();
            }

            if (!self::isPaired($cfg) || !class_exists('HtpStatus')) {
                return;
            }

            // Dieselben verdichteten Felder, die der Heartbeat ohnehin traegt (level|reason|anker|count).
            // 'kind' defensiv lesen - statusPayload liefert es im Ausnahme-Fallback evtl. nicht mit
            // (sonst "Undefined array key" auf Sites, die Warnungen zu ErrorException erheben).
            $p   = self::statusPayload($cfg);
            $sig = (string) $p['level'] . '|' . (string) $p['reason'] . '|'
                . (string) (isset($p['kind']) ? $p['kind'] : '') . '|' . (int) (isset($p['count']) ? $p['count'] : 0);

            $st   = self::loadState();
            $prev = array_key_exists('last_threat_sig', $st) ? (string) $st['last_threat_sig'] : null;

            if ($prev === $sig) {
                return; // unveraendert -> der regulaere Heartbeat (hb_min) genuegt, kein Sofort-Push
            }

            $st['last_threat_sig'] = $sig;

            // Erstlauf auf sauberem Stand: nur die Signatur merken, kein Push (nichts Neues zu melden).
            if ($prev === null && (string) $p['level'] === 'success') {
                self::saveState($st);

                return;
            }

            // Sofort-Heartbeat: eine neue Bedrohung (rot) ODER ihre Behebung (gruen) erreicht das
            // Dashboard in Sekunden (heartbeat() prueft KEIN hb_min). WICHTIG fuer die Sparsamkeit:
            // ERST NACH dem Heartbeat speichern - heartbeat() setzt last_beacon (per Referenz), das
            // hier persistiert wird und den naechsten regulaeren Heartbeat entsprechend nach hinten
            // schiebt. So ERSETZT der Aenderungs-Push einen regulaeren Heartbeat, statt ihn zu
            // verdoppeln -> kein zusaetzliches Grundrauschen im Steady State (dort greift bereits die
            // Signatur-Gleichheit oben und es geht gar kein Netz-Request raus).
            self::heartbeat($st, $cfg);
            self::saveState($st);
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    /**
     * Scan-Report FUER DEN HEARTBEAT - bewusst NUR unbedenkliche Zaehler (ts, hits), NIE die
     * Datei-Pfade (sample). Zero-Knowledge-Grenze (PROTOCOL.md 8): der Relay darf Pfade befallener
     * Dateien nie sehen/halten. Die Pfade bleiben lokal im 0600-State fuer eine evtl. lokale Ansicht.
     */
    private static function scanReportPublic($st)
    {
        if (empty($st['scan_report']) || !is_array($st['scan_report'])) {
            return null;
        }

        return array(
            'ts'   => isset($st['scan_report']['ts']) ? (int) $st['scan_report']['ts'] : 0,
            'hits' => isset($st['scan_report']['hits']) ? (int) $st['scan_report']['hits'] : 0,
            // Vom Hoster gesperrte Dateien (chmod 000) - reine Zahl (Zero-Knowledge, nie Pfade).
            'quar' => isset($st['scan_report']['quar']) ? (int) $st['scan_report']['quar'] : 0,
        );
    }

    /**
     * Integritaets-Report FUER DEN HEARTBEAT - wie scanReportPublic bewusst NUR unbedenkliche
     * Zaehler (ts, dev/changed/missing), NIE die betroffenen Pfade. Zero-Knowledge (PROTOCOL.md 8):
     * der Relay sieht nie, WELCHE Kerndateien abweichen - nur DASS es Abweichungen gibt.
     */
    private static function integrityReportPublic($st)
    {
        if (empty($st['integrity_report']) || !is_array($st['integrity_report'])) {
            return null;
        }

        return array(
            'ts'      => isset($st['integrity_report']['ts']) ? (int) $st['integrity_report']['ts'] : 0,
            'dev'     => isset($st['integrity_report']['dev']) ? (int) $st['integrity_report']['dev'] : 0,
            'changed' => isset($st['integrity_report']['changed']) ? (int) $st['integrity_report']['changed'] : 0,
            'missing' => isset($st['integrity_report']['missing']) ? (int) $st['integrity_report']['missing'] : 0,
        );
    }

    private static function jobSummary($st)
    {
        if (empty($st['job']) || !is_array($st['job'])) {
            return null;
        }

        return array(
            'type'     => isset($st['job']['type']) ? (string) $st['job']['type'] : '',
            'progress' => isset($st['job']['progress']) ? (float) $st['job']['progress'] : 0.0,
            'cmd_id'   => isset($st['job']['cmd_id']) ? (string) $st['job']['cmd_id'] : '',
        );
    }

    private static function maybeScheduledScan(&$st, $cfg, $now)
    {
        if (!empty($st['job'])) {
            return; // ein Job laeuft schon
        }

        $cad = isset($st['scan_cadence']) ? (string) $st['scan_cadence'] : 'off';
        $map = array('daily' => 86400, '3d' => 3 * 86400, 'weekly' => 7 * 86400, 'monthly' => 30 * 86400);

        if (!isset($map[$cad])) {
            return;
        }

        $last = (int) (isset($st['scan_last']) ? $st['scan_last'] : 0);

        if (($now - $last) < $map[$cad]) {
            return;
        }

        // Geplanter Scan ohne Operator-Befehl -> cmd_id '' (kein Ergebnis-POST, Kachel spiegelt Funde).
        self::startScan($st, '');
    }

    private static function maybeScheduledBackup(&$st, $cfg, $now)
    {
        if (!empty($st['job']) || !class_exists('HtpBackup')) {
            return;
        }

        $map = array('1h' => 3600, '2h' => 7200, '4h' => 14400, '8h' => 28800, '12h' => 43200,
            'daily' => 86400, 'weekly' => 604800, 'monthly' => 2592000);

        // Zwei unabhaengige Zeitplaene: Voll-Backup (Dateien+DB) und Datenbank-Backup (nur DB).
        $fullCad = isset($st['bk_full_cad']) ? (string) $st['bk_full_cad']
            : (isset($st['backup_cadence']) ? (string) $st['backup_cadence'] : 'off'); // alt-kompatibel
        $dbCad   = isset($st['bk_db_cad']) ? (string) $st['bk_db_cad'] : 'off';

        if (!isset($map[$fullCad]) && !isset($map[$dbCad])) {
            return; // nichts geplant
        }

        // Backoff: nach einem GEPLANTEN Versuch fruehestens in 1h erneut - sonst wuerde ein dauerhaft
        // scheiterndes Backup (Platte voll/DB-Fehler) bei jedem Traffic-Tick neu starten (Retry-Sturm).
        if (isset($st['bk_last_attempt']) && ($now - (int) $st['bk_last_attempt']) < 3600) {
            return;
        }

        // Anker an den ECHTEN, ERFOLGREICHEN Backups (manuell + geplant zaehlen beide). Ein
        // fehlgeschlagenes Backup (ok=false) erfuellt die Kadenz NICHT -> der Scheduler holt nach.
        //   $lastFull   = letztes Voll-Backup (fuer die Voll-Kadenz)
        //   $lastDbCap  = letzte DB-ERFASSUNG = neuestes full ODER db (fuer die DB-Kadenz; ein Voll
        //                 sichert die DB mit, also kein redundantes DB-Backup direkt danach)
        $list      = HtpBackup::listBackups(50); // neueste zuerst
        $lastFull  = 0;
        $lastDbCap = 0;

        foreach ($list as $b) {
            if (empty($b['ok'])) {
                continue;
            }

            $bk = isset($b['kind']) ? (string) $b['kind'] : '';

            if ($lastFull === 0 && $bk === 'full') {
                $lastFull = (int) $b['ts'];
            }

            if ($lastDbCap === 0 && ($bk === 'full' || $bk === 'db')) {
                $lastDbCap = (int) $b['ts'];
            }

            if ($lastFull !== 0 && $lastDbCap !== 0) {
                break;
            }
        }

        // Geplante Backups an der eingestellten Uhrzeit (lokale Zeit der Website). Default 03:00.
        $hour = isset($st['bk_hour']) ? (int) $st['bk_hour'] : 3;
        $min  = isset($st['bk_min']) ? (int) $st['bk_min'] : 0;

        // Geplante Backups ohne Operator-Befehl -> cmd_id ''. VOLL hat Vorrang (deckt die DB mit ab).
        // Vor jedem geplanten Start: Versuchszeit vermerken (Backoff) + fehlgeschlagene Ordner raeumen.
        if (isset($map[$fullCad]) && self::schedDue($lastFull, $map[$fullCad], $hour, $min, $now)) {
            $st['bk_last_attempt'] = $now;
            HtpBackup::cleanFailedBackups($now);
            HtpBackup::start($st, '', array('kind' => 'full'));
            return;
        }

        if (isset($map[$dbCad]) && self::schedDue($lastDbCap, $map[$dbCad], $hour, $min, $now)) {
            $st['bk_last_attempt'] = $now;
            HtpBackup::cleanFailedBackups($now);
            HtpBackup::start($st, '', array('kind' => 'db'));
            return;
        }
    }

    /**
     * Ist ein geplantes Backup faellig? Faellig, wenn die heutige Uhrzeit (lokale Zeit der Website)
     * erreicht ist UND seit dem letzten Backup rund das Intervall vergangen ist. Die 12h-Toleranz
     * faengt Tick-/Sommerzeit-Schwankungen ab, ohne dass ein taegliches Backup einen Tag ausfaellt.
     *
     * @return bool
     */
    private static function schedDue($lastTs, $interval, $hour, $min, $now)
    {
        $todayAtT = mktime((int) $hour, (int) $min, 0, (int) date('n', $now), (int) date('j', $now), (int) date('Y', $now));

        if ($now < $todayAtT) {
            return false; // heute noch vor der geplanten Uhrzeit
        }

        return ((int) $lastTs < $todayAtT) && (($now - (int) $lastTs) >= ((int) $interval - 43200));
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Ergebnis melden
    // ─────────────────────────────────────────────────────────────────────────

    private static function reportResult(&$st, $cid, $ok, $code, $log, $level)
    {
        if ($cid === '') {
            return; // geplante/No-Op-Aktionen melden nichts
        }

        // DURABEL: Ergebnis in eine persistente Warteschlange legen (ueberlebt einen verlorenen POST
        // + den naechsten Tick). Frueher fire-and-forget - ging der eine POST verloren (Netz-Haenger/
        // Timeout), blieb der Befehl im Dashboard FUER IMMER auf "zugestellt". Je cmd_id genau ein
        // Ergebnis (vorhandenes ersetzen), Cap gegen unbegrenztes Wachsen.
        if (!isset($st['pending_results']) || !is_array($st['pending_results'])) {
            $st['pending_results'] = array();
        }

        $keep = array();

        foreach ($st['pending_results'] as $p) {
            if (is_array($p) && isset($p['cid']) && (string) $p['cid'] !== (string) $cid) {
                $keep[] = $p;
            }
        }

        $keep[] = array(
            'cid'   => (string) $cid,
            'ok'    => $ok ? 1 : 0,
            'code'  => (string) $code,
            'log'   => substr((string) $log, 0, 2000),
            'level' => (string) $level,
            'ts'    => time(),
            'tries' => 0,
        );

        $st['pending_results'] = array_slice($keep, -20); // hoechstens 20 offene Ergebnisse

        self::flushResults($st);
    }

    /**
     * Offene Befehls-Ergebnisse an den Relay senden und die BESTAETIGTEN (HTTP 2xx-4xx = angekommen)
     * aus der Warteschlange entfernen; nur echte Netz-/5xx-Fehler bleiben fuer den naechsten Versuch.
     * Wird bei jedem neuen Ergebnis UND regelmaessig im tick() aufgerufen - so geht kein Ergebnis durch
     * einen verlorenen POST dauerhaft verloren (sonst haengt der Befehl ewig auf "zugestellt").
     */
    private static function flushResults(&$st)
    {
        if (empty($st['pending_results']) || !is_array($st['pending_results'])
            || empty($st['relay']) || empty($st['site_id'])) {
            return;
        }

        $now  = time();
        $keep = array();

        foreach ($st['pending_results'] as $p) {
            if (!is_array($p) || !isset($p['cid'])) {
                continue;
            }

            // Nach ~24 h oder sehr vielen Versuchen aufgeben (das Dashboard-Sicherheitsnetz raeumt den
            // Befehl ohnehin ab) - kein ewiges Nachsenden fuer einen laengst weg-expirten Befehl.
            $age = $now - (int) (isset($p['ts']) ? $p['ts'] : $now);

            if ($age > 86400 || (int) (isset($p['tries']) ? $p['tries'] : 0) > 200) {
                continue;
            }

            $cid    = (string) $p['cid'];
            $fields = array(
                'pv'     => self::PROTO,
                'site'   => (string) $st['site_id'],
                'cmd_id' => $cid,
                'ok'     => !empty($p['ok']) ? '1' : '0',
                'code'   => (string) (isset($p['code']) ? $p['code'] : ''),
                'log'    => substr((string) (isset($p['log']) ? $p['log'] : ''), 0, 2000),
                'level'  => (string) (isset($p['level']) ? $p['level'] : ''),
                'ts'     => $now,
                'auth'   => self::hmac($st, 'result|' . $st['site_id'] . '|' . $cid . '|' . $now),
            );

            $sent = false;

            try {
                $r    = htp_http_post_multipart(rtrim($st['relay'], '/') . '/api/v1/commands/result', $fields, array(), self::TIMEOUT);
                $code = is_array($r) && isset($r['status']) ? (int) $r['status'] : 0;
                // 2xx = verbucht; 4xx (Befehl unbekannt/laengst weg) bringt erneutes Senden nichts ->
                // ebenfalls als erledigt werten. NUR Netz (0) / 5xx erneut versuchen.
                $sent = ($code >= 200 && $code < 500);
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }

            if (!$sent) {
                $p['tries'] = (int) (isset($p['tries']) ? $p['tries'] : 0) + 1;
                $keep[]     = $p;
            }
        }

        $st['pending_results'] = $keep;
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  KI-Steuerung: Aktion ausfuehren + Ergebnis melden
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Fuehrt eine ai.*-Aktion in HtpAi aus, meldet das Ergebnis ueber den eigenen Report-Kanal an
     * den Relay (Token verschluesselt-serverseitig, NIE ueber das Result-Log) und liefert das
     * normale Befehls-Ergebnis (ok/code) zurueck. HtpAi liefert ['ok','code','ai'=>[...]].
     */
    private static function runAi(&$st, $cid, $method, $params)
    {
        $res = call_user_func(array('HtpAi', $method), is_array($params) ? $params : array());

        if (is_array($res) && isset($res['ai']) && is_array($res['ai'])) {
            self::sendAiReport($st, $cid, $res['ai']);
        }

        return array(
            'ok'   => is_array($res) && !empty($res['ok']),
            'code' => (is_array($res) && isset($res['code'])) ? (string) $res['code'] : 'ai',
        );
    }

    /**
     * Ergebnis einer ai.*-Aktion an den Relay melden (eigener Endpunkt /api/v1/ai/report, damit das
     * frisch geminzte Token NIE durch das Klartext-Result-Log laeuft). HMAC ueber das transport_secret;
     * Nachricht 'ai_report|site|cmd_id|action|ts' - byte-identisch zur Server-Pruefung. Best-effort.
     */
    private static function sendAiReport(&$st, $cid, $ai)
    {
        if (empty($st['relay']) || empty($st['site_id']) || !is_array($ai)) {
            return;
        }

        $action = isset($ai['action']) ? (string) $ai['action'] : 'error';
        $now    = time();

        $fields = array(
            'pv'     => self::PROTO,
            'site'   => (string) $st['site_id'],
            'cmd_id' => (string) $cid,
            'action' => $action,
            'ts'     => $now,
            'auth'   => self::hmac($st, 'ai_report|' . $st['site_id'] . '|' . $cid . '|' . $action . '|' . $now),
        );

        $fields['tier'] = (string) (int) (isset($ai['tier']) ? $ai['tier'] : 1);

        if ($action === 'enroll') {
            $fields['token']           = (string) (isset($ai['token']) ? $ai['token'] : '');
            $fields['api_base']        = (string) (isset($ai['api_base']) ? $ai['api_base'] : '');
            $fields['joomla_uid']      = (string) (int) (isset($ai['joomla_uid']) ? $ai['joomla_uid'] : 0);
            $fields['joomla_group_id'] = (string) (int) (isset($ai['joomla_group_id']) ? $ai['joomla_group_id'] : 0);

            // Stufe 2: das elevated Token + Konto zusaetzlich melden.
            if (isset($ai['admin_token']) && $ai['admin_token'] !== '') {
                $fields['admin_token']    = (string) $ai['admin_token'];
                $fields['admin_uid']      = (string) (int) (isset($ai['admin_uid']) ? $ai['admin_uid'] : 0);
                $fields['admin_group_id'] = (string) (int) (isset($ai['admin_group_id']) ? $ai['admin_group_id'] : 0);
            }
        } elseif ($action === 'error') {
            $fields['reason'] = substr((string) (isset($ai['reason']) ? $ai['reason'] : ''), 0, 190);
        } elseif ($action === 'status') {
            if (!empty($ai['api_base'])) {
                $fields['api_base'] = (string) $ai['api_base'];
            }
        } elseif ($action === 'revoke') {
            // Ergebnis der Zuruecknahme je Konto: 'deleted' (Zero-Footprint) | 'blocked' (besitzt Inhalte)
            // | 'absent'. Nur Anzeige im Dashboard. Stufe 1 = {user,admin}, Stufe 2 = nur der Admin-String.
            $rm = isset($ai['removed']) ? $ai['removed'] : null;

            if (is_array($rm)) {
                $fields['removed_user']  = (string) (isset($rm['user']) ? $rm['user'] : '');
                $fields['removed_admin'] = (string) (isset($rm['admin']) ? $rm['admin'] : '');
            } elseif (is_string($rm)) {
                $fields['removed_admin'] = $rm;
            }
        }

        try {
            htp_http_post_multipart(rtrim($st['relay'], '/') . '/api/v1/ai/report', $fields, array(), self::TIMEOUT);
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Nutzlast-Helfer
    // ─────────────────────────────────────────────────────────────────────────

    private static function statusPayload($cfg)
    {
        if (!class_exists('HtpStatus')) {
            return array('level' => 'success', 'reason' => 'ok', 'kind' => '', 'count' => 0);
        }

        try {
            $c = HtpStatus::card($cfg);

            return array(
                'level'  => isset($c['level']) ? (string) $c['level'] : 'success',
                'reason' => isset($c['reason']) ? (string) $c['reason'] : 'ok',
                // Spezifischer Bedrohungs-/Anker-Code (htp-a-accounts/-admins/-malfiles/…):
                // macht die generische Kategorie "attack" im Dashboard konkret. ZERO-KNOWLEDGE:
                // reine Kategorie, KEINE Pfade/Kontonamen/IDs.
                'kind'   => isset($c['anchor']) ? (string) $c['anchor'] : '',
                'count'  => isset($c['count']) ? (int) $c['count'] : 0,
            );
        } catch (Exception $e) {
            return array('level' => 'success', 'reason' => 'ok', 'kind' => '', 'count' => 0);
        } catch (Throwable $e) {
            return array('level' => 'success', 'reason' => 'ok', 'kind' => '', 'count' => 0);
        }
    }

    private static function level($cfg)
    {
        $p = self::statusPayload($cfg);

        return isset($p['level']) ? $p['level'] : 'success';
    }

    private static function pendingUpdates()
    {
        $out = array('pending' => 0, 'security' => array());

        try {
            $db = function_exists('htp_db') ? htp_db() : null;

            if ($db) {
                // NUR Updates fuer TATSAECHLICH INSTALLIERTE Erweiterungen zaehlen. Joomlas
                // #__updates enthaelt zusaetzlich ALLE vom "Accredited Translations"-Update-Server
                // angebotenen, NICHT installierten Sprachpakete (extension_id = 0, z. B. Afrikaans,
                // Bulgarisch ...) -> die blaehten die Zahl sonst auf 50+ auf, obwohl Joomlas EIGENE
                // Update-Ansicht sie gar nicht zeigt. Der INNER JOIN auf #__extensions laesst nur
                // Updates uebrig, die einer wirklich installierten Erweiterung zugeordnet sind
                // (Kern, Plugins, Templates, Pakete) -> die echte, handlungsrelevante Zahl.
                $q = $db->getQuery(true)
                    ->select('COUNT(*)')
                    ->from($db->quoteName('#__updates', 'u'))
                    ->join(
                        'INNER',
                        $db->quoteName('#__extensions', 'e')
                        . ' ON ' . $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('u.extension_id')
                    );
                $db->setQuery($q);
                $out['pending'] = (int) $db->loadResult();
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        try {
            if (class_exists('HtpDefs')) {
                $vuln = HtpDefs::checkExtensions();

                if (is_array($vuln)) {
                    foreach ($vuln as $v) {
                        $el = isset($v['element']) ? (string) $v['element'] : '';

                        if ($el !== '') {
                            $out['security'][] = $el;
                        }
                    }
                }
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return $out;
    }

    private static function frontendUrl($cfg)
    {
        $stored = is_array($cfg) && isset($cfg['unsub_base']) ? (string) $cfg['unsub_base'] : '';

        if (function_exists('htp_trusted_base')) {
            $b = htp_trusted_base($stored);

            if (is_string($b) && $b !== '') {
                return rtrim($b, '/') . '/';
            }
        }

        return function_exists('htp_site_url') ? htp_site_url() : '/';
    }

    private static function adminUrl($cfg)
    {
        return rtrim(self::frontendUrl($cfg), '/') . '/administrator/';
    }

    /**
     * Oeffentlicher, host-poisoning-fester Admin-Basis-URL fuer das 1-Klick-Login (SSO):
     * Basis ist die VERTRAUENSWUERDIGE frontendUrl (nicht der Request-Host), damit der
     * Redirect selbst bei manipuliertem Host-Header nie auf eine fremde Domain zeigt.
     *
     * @return string  z. B. "https://example.tld/administrator/"
     */
    public static function ssoAdminUrl()
    {
        return self::adminUrl(HtpConfig::load());
    }

    private static function siteName($cfg)
    {
        try {
            $app = function_exists('htp_app') ? htp_app() : null;

            if ($app && method_exists($app, 'get')) {
                $n = (string) $app->get('sitename', '');

                if ($n !== '') {
                    return $n;
                }
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        $host = parse_url(self::frontendUrl($cfg), PHP_URL_HOST);

        return is_string($host) && $host !== '' ? $host : 'Joomla';
    }

    private static function joomlaVersion()
    {
        // Installierte Version AUTHORITATIV vom Datentraeger (administrator/manifests/files/joomla.xml),
        // NICHT aus JVERSION: die Konstante wird beim Prozessstart eingefroren und bleibt nach einem
        // (Fern-)Kern-Update ALT - OPcache liefert die alte Version.php weiter, und opcache_reset() ist
        // auf gehaerteten Hostern oft ein No-op. Dadurch meldete "Status aktualisieren" die ALTE Version.
        // Ein reiner Datei-Read ist OPcache-immun (Bytecode-Cache != file_get_contents) und lokal (kein
        // Netz-Mehrverkehr; die Version reist ohnehin im vorhandenen Heartbeat-Feld). Spiegelt bewusst
        // HtpCoreScan::detectVersion(), bleibt aber self-contained (unabhaengig davon, ob dessen Klasse
        // im Heartbeat-Kontext geladen ist).
        $root = function_exists('htp_root') ? htp_root() : (defined('JPATH_ROOT') ? JPATH_ROOT : '');

        if ($root !== '') {
            $xml = rtrim(str_replace('\\', '/', (string) $root), '/') . '/administrator/manifests/files/joomla.xml';

            if (@is_file($xml)) {
                $raw = (string) @file_get_contents($xml, false, null, 0, 4096);

                if ($raw !== '' && preg_match('~<version>\s*([0-9]+\.[0-9]+\.[0-9]+)\s*</version>~i', $raw, $m)) {
                    return $m[1];
                }
            }
        }

        // Fallback (Manifest fehlt/unlesbar - z. B. sehr alte Joomla): eingefrorene Konstante bzw. Version-Klasse.
        if (defined('JVERSION')) {
            return JVERSION;
        }

        if (class_exists('Joomla\\CMS\\Version')) {
            try {
                $v = new \Joomla\CMS\Version();

                return $v->getShortVersion();
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }
        }

        return '';
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  System-Fakten (sysfacts) fuer die Dashboard-Werkzeug-Ansichten (2.6.5)
    //
    //  EIN kompaktes Zustands-Buendel, das der Heartbeat DELTA-SYNCT: der 12-Hex-
    //  facts_hash reist IMMER mit (winzig); das volle Buendel NUR bei Aenderung
    //  oder auf Anforderung (want_facts) - byte-fuer-byte dasselbe Muster wie
    //  inv_hash/want_inv. Alle Fakten sind Zustands-Kategorien/Zaehler (Zero-
    //  Knowledge: keine Pfade/Kontonamen/Geheimnisse). Bewusste Ausnahme (im
    //  PROTOCOL.md dokumentiert): host/ip = Hosting-Server-Metadatum, noetig fuer
    //  die Gruppierung nach Webserver.
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Baut das sysfacts-Buendel + seinen kanonischen 12-Hex-Hash. Der Hash geht NUR
     * ueber die stabilen Zustandsfelder (keine Zeitstempel), damit sich das Buendel
     * wirklich nur bei einer echten Aenderung neu hochlaedt. Alle Einzel-Checks sind
     * billig (Config-Reads, is_writable, ein indizierter COUNT) und fail-soft.
     *
     * @return array{hash:string,bundle:array}
     */
    private static function sysfactsBundle($cfg)
    {
        $app = function_exists('htp_app') ? htp_app() : null;

        // /administrator-Schutz: htp (von HTProtect) | foreign (fremde .htaccess-Auth) | none.
        // "none" heisst NUR "kein .htaccess-Schutz erkannt" - Panel-/vHost-/Proxy-Auth sieht
        // der lokale Read nicht (das Dashboard bietet dafuer einen On-Abruf-Tiefencheck).
        $guard     = 'none';
        $guardMode = '';   // bei guard='htp': 'password' (Option 1) | 'string' (Option 2, Cookie-Gate)
        if (class_exists('HtpGuard')) {
            try {
                $g = HtpGuard::status();
                if (!empty($g['active'])) {
                    $guard     = 'htp';
                    $guardMode = isset($g['mode']) ? (string) $g['mode'] : '';
                } elseif (!empty($g['foreign_auth'])) {
                    $guard = 'foreign';
                }
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }
        }

        // Echter Hosting-Server (nicht Kundendomain/CDN) fuer die Server-Gruppierung.
        $host = '';
        if (function_exists('gethostname')) {
            $h = @gethostname();
            if (is_string($h)) {
                $host = substr($h, 0, 120);
            }
        }
        $ip = isset($_SERVER['SERVER_ADDR']) ? substr((string) $_SERVER['SERVER_ADDR'], 0, 64) : '';

        // tmp/log-Schreibbarkeit (spiegelt Joomlas Systeminfo-Verzeichnisrechte).
        $tmpW = 1;
        $logW = 1;
        if ($app && method_exists($app, 'get')) {
            $tp = (string) $app->get('tmp_path', '');
            $lp = (string) $app->get('log_path', '');
            $tmpW = ($tp !== '' && @is_writable($tp)) ? 1 : 0;
            $logW = ($lp !== '' && @is_writable($lp)) ? 1 : 0;
        }

        // Fehleranzeige/Debug (im Live-Betrieb ist alles ausser "default" nicht empfehlenswert).
        $err   = 'default';
        $debug = 0;
        if ($app && method_exists($app, 'get')) {
            $e = strtolower((string) $app->get('error_reporting', 'default'));
            if (in_array($e, array('default', 'none', 'simple', 'maximum', 'development'), true)) {
                $err = $e;
            }
            $debug = ((int) $app->get('debug', 0) === 1) ? 1 : 0;
        }

        // Offene Post-Install-Meldungen (nur Anzahl; -1 = nicht ermittelbar).
        $pim = self::postinstallCount();

        // Mail-Zustand (Config + gecachter 24h-Connect-Test + passiver Echt-Fehlschlag).
        $mail = self::mailFactState($cfg, $app);

        // Stabile Felder -> Hash. Feste Reihenfolge = stabiler Hash.
        $facts = array(
            'guard'   => $guard,
            'host'    => $host,
            'ip'      => $ip,
            'tmp_w'   => $tmpW,
            'log_w'   => $logW,
            'err'     => $err,
            'debug'   => $debug,
            'pim'     => $pim,
            'mailcfg' => $mail['cfg'],
            'mail'    => $mail['state'],
        );

        // Modul-Versionierung NUR bei Joomla 6+ (sonst wuerde ein sinnloses Feld den Hash
        // aller aelteren Sites fuellen). 0 = aus, 1 = an; bei nicht-ermittelbar weggelassen.
        if (self::joomlaMajor() >= 6) {
            $mv = self::moduleVersioningState();
            if ($mv !== null) {
                $facts['modver'] = $mv ? 1 : 0;
            }
        }

        // Backward-Compatibility-Plugin (2.6.6): NUR ab Joomla 5 relevant. -1 (nicht anwendbar/
        // kein Plugin) weglassen; sonst 0 = deaktiviert (handlungsbeduerftig), 1 = aktiv.
        if (self::joomlaMajor() >= 5 && class_exists('HtpCompat')) {
            $bc = HtpCompat::bcState();
            if ($bc === 0 || $bc === 1) {
                $facts['bc'] = $bc;
            }
        }

        // /core-auto (2.6.6): steuert/zeigt JOOMLAS EIGENE „Automated Updates" (Bordmittel,
        // com_joomlaupdate -> params.autoupdate). core_auto = nativer Zustand (1/0), core_elig =
        // geeignet (>= 5.4). WICHTIG: HTProtect wendet Kern-Updates NICHT mehr selbst automatisch an -
        // der frueher hier gefuehrte EIGENE Schalter (config core_auto + maybeScheduledCoreAuto) ist
        // entfallen. Es gibt nur EIN Gleis: Joomlas Bordmittel. (Manuelles core.update bleibt.)
        $facts['core_auto'] = (self::joomlaAutoUpdateState() === 1) ? 1 : 0;
        $facts['core_elig'] = (class_exists('HtpAutoUpdate') && HtpAutoUpdate::versionGateOk(self::joomlaVersion())) ? 1 : 0;

        // Backend-Schutz-Modus (nur wenn von HTProtect geschuetzt): 'password' (Option 1) oder
        // 'string' (Option 2, Cookie-Gate). Das Dashboard zeigt danach die richtige Option und weiss,
        // dass das 1-Klick-Login bei 'string' den Zugangs-String traegt. Nur bei aktivem Schutz gesetzt
        // (minimaler Hash-Churn; Sites ohne HTProtect-Schutz tragen das Feld nicht).
        if ($guardMode !== '') {
            $facts['guard_mode'] = $guardMode;
        }

        // Joomla 3: anstehende Kern-Update-Zielversion aus der KONFIGURIERTEN Quelle (z. B.
        // JoomlaWorks) melden, damit das Dashboard die KONKRETE Version zeigt (die offizielle
        // latest-Map kennt J3-Fork-Versionen nicht). Nur bei echtem Update gesetzt; im Hash, damit
        // ein Stand-Wechsel (Quelle gesetzt / neue Version / Seite aktualisiert) sofort gemeldet wird.
        if (class_exists('HtpCoreUpdateJ3')) {
            $j3t = HtpCoreUpdateJ3::pendingTarget();

            if ($j3t !== '') {
                $facts['j3core'] = $j3t;
            }
        }

        // Neuanmeldung an/aus (com_users allowUserRegistration). Stabil -> in den Hash. null (nicht
        // ermittelbar) weggelassen, damit es den Hash nicht fuellt. Details/Zaehler s. reg_stats (volatil).
        if (class_exists('HtpRegClean')) {
            $rg = HtpRegClean::state();
            if ($rg === 0 || $rg === 1) {
                $facts['reg'] = $rg;
            }
        }

        $hash = substr(hash('sha256', (string) json_encode($facts)), 0, 12);

        // Volatile Zusatzinfos NUR ins gesendete Buendel (NICHT in den Hash - sonst wuerde
        // jeder 24h-Mailtest einen Upload ausloesen). Das Dashboard zeigt sie als Detail;
        // Filter/Ampel richten sich nach den stabilen Feldern oben.
        $bundle = $facts;
        if (!empty($mail['at'])) {
            $bundle['mail_at'] = (int) $mail['at'];
        }
        if (!empty($mail['err'])) {
            $bundle['mail_err'] = substr((string) $mail['err'], 0, 160);
        }
        if (!empty($mail['fail_at'])) {
            $bundle['mail_fail_at'] = (int) $mail['fail_at'];
        }

        // Neuanmeldung-Kennzahlen (gecacht mit TTL, weil ueber #__users gerechnet) -> nur ins gesendete
        // Buendel, NIE in den Hash (sonst wuerde jede neue Anmeldung/jeder TTL-Lauf einen Upload ausloesen).
        // Das Dashboard bildet daraus die Verdict-Ampel (bedenkenlos/missbraucht/pruefen/gebraucht).
        if (class_exists('HtpRegClean')) {
            $rst = HtpRegClean::stats($cfg);
            if (is_array($rst)) {
                $bundle['reg_stats'] = $rst;
            }
        }

        return array('hash' => $hash, 'bundle' => $bundle);
    }

    /**
     * Mail-Zustand ehrlich ableiten:
     *   off       = Joomlas "E-Mail versenden" ist aus
     *   misconfig = SMTP ohne Host hinterlegt
     *   fail      = 24h-Connect-Test fehlgeschlagen ODER kuerzlich ein ECHTER Versand gescheitert
     *   ok        = SMTP-Connect ok und kein kuerzlicher Fehlschlag
     *   untested  = mail/sendmail (ohne Versand nicht pruefbar) und keine Fehl-Signale
     *
     * @return array{state:string,cfg:string,at:int,err:string,fail_at:int}
     */
    private static function mailFactState($cfg, $app)
    {
        $mailonline = ($app && method_exists($app, 'get')) ? (int) $app->get('mailonline', 1) : 1;
        $mailer     = ($app && method_exists($app, 'get')) ? strtolower((string) $app->get('mailer', 'mail')) : 'mail';
        $smtphost   = ($app && method_exists($app, 'get')) ? (string) $app->get('smtphost', '') : '';

        $mh     = (isset($cfg['mailhealth']) && is_array($cfg['mailhealth'])) ? $cfg['mailhealth'] : array();
        $mhOk   = isset($mh['ok']) ? (int) $mh['ok'] : -1;  // 1 ok, 0 fail, -1 ungetestet
        $mhAt   = isset($mh['at']) ? (int) $mh['at'] : 0;
        $mhErr  = isset($mh['err']) ? (string) $mh['err'] : '';
        $failAt = isset($cfg['mail_last_fail_at']) ? (int) $cfg['mail_last_fail_at'] : 0;
        $recentFail = ($failAt > 0 && (time() - $failAt) < 259200); // 3 Tage

        // Config-Kurzstatus (fuers Dashboard, unabhaengig vom Laufzeit-Test).
        if ($mailonline === 0) {
            $cfgState = 'off';
        } elseif ($mailer === 'smtp' && $smtphost === '') {
            $cfgState = 'incomplete';
        } elseif ($mailer === 'smtp') {
            $cfgState = 'smtp';
        } else {
            $cfgState = ($mailer === 'sendmail') ? 'sendmail' : 'mail';
        }

        if ($mailonline === 0) {
            $state = 'off';
        } elseif ($mailer === 'smtp' && $smtphost === '') {
            $state = 'misconfig';
        } elseif ($recentFail) {
            $state = 'fail';
        } elseif ($mailer === 'smtp') {
            $state = ($mhOk === 1) ? 'ok' : (($mhOk === 0) ? 'fail' : 'untested');
        } else {
            $state = 'untested';
        }

        return array('state' => $state, 'cfg' => $cfgState, 'at' => $mhAt, 'err' => $mhErr, 'fail_at' => $failAt);
    }

    /** Offene Post-Install-Meldungen (published=1); -1 = nicht ermittelbar. */
    private static function postinstallCount()
    {
        try {
            $db = function_exists('htp_db') ? htp_db() : null;
            if (!$db) {
                return -1;
            }
            // Joomla-Post-Install-Meldungen fuehren ihren Offen/Ausgeblendet-Zustand in der Spalte
            // `enabled` (1 = offen, 0 = ausgeblendet) - es gibt KEINE Spalte `published` (fruehere
            // Fassung fragte die ab -> SQL-Fehler -> -1 -> die Ansicht blieb leer). Zaehlt die nicht
            // ausgeblendeten Meldungen (kann geringfuegig ueber der cPanel-Zahl liegen, die zusaetzlich
            // Anzeige-Bedingungen je Meldung auswertet).
            $q = $db->getQuery(true)
                ->select('COUNT(*)')
                ->from($db->quoteName('#__postinstall_messages'))
                ->where($db->quoteName('enabled') . ' = 1');
            $db->setQuery($q);
            return (int) $db->loadResult();
        } catch (Exception $e) {
            return -1;
        } catch (Throwable $e) {
            return -1;
        }
    }

    /**
     * Joomlas EIGENE „Automated Updates" (com_joomlaupdate-Option): 1 = an, 0 = aus, null = nicht
     * ermittelbar. Liest params.autoupdate direkt aus #__extensions (versionsneutral, ohne
     * ComponentHelper). Getrennt von HTProtects core_auto - beschreibt, ob der Kern sich NATIV
     * automatisch aktualisiert (setzt einen externen Cron/Task voraus, den der Betreiber einrichtet).
     */
    private static function joomlaAutoUpdateState()
    {
        try {
            $db = function_exists('htp_db') ? htp_db() : null;
            if (!$db) {
                return null;
            }
            $q = $db->getQuery(true)
                ->select($db->quoteName('params'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_joomlaupdate'));
            $db->setQuery($q);
            $raw = (string) $db->loadResult();
            if ($raw === '') {
                return null;
            }
            $p = json_decode($raw, true);
            if (!is_array($p)) {
                return null;
            }
            return !empty($p['autoupdate']) ? 1 : 0;
        } catch (Exception $e) {
            return null;
        } catch (Throwable $e) {
            return null;
        }
    }

    /**
     * Joomlas EIGENE „Automated Updates" (Bordmittel) an-/ausschalten: setzt com_joomlaupdate
     * params.autoupdate = 1/0. Beim Einschalten wird - genau wie Joomlas UI-Schalter - ein
     * Update-Token erzeugt, falls keiner existiert (der Betreiber richtet Cron/Task selbst ein, wie
     * nativ). Reiner Params-Schreibvorgang in #__extensions (versionsneutral). true = ok.
     */
    private static function setJoomlaAutoUpdate($on)
    {
        try {
            $db = function_exists('htp_db') ? htp_db() : null;
            if (!$db) {
                return false;
            }

            $q = $db->getQuery(true)
                ->select($db->quoteName('params'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_joomlaupdate'));
            $db->setQuery($q);
            $raw = (string) $db->loadResult();

            $p = ($raw !== '') ? json_decode($raw, true) : array();
            if (!is_array($p)) {
                $p = array();
            }

            $p['autoupdate'] = $on ? 1 : 0;

            if ($on) {
                if (empty($p['update_token'])) {
                    $p['update_token'] = self::randToken(40);
                }
                $p['autoupdate_status'] = 2;
            } else {
                $p['autoupdate_status'] = 0;
            }

            $u = $db->getQuery(true)
                ->update($db->quoteName('#__extensions'))
                ->set($db->quoteName('params') . ' = ' . $db->quote((string) json_encode($p)))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_joomlaupdate'));
            $db->setQuery($u);
            $db->execute();

            return true;
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }
    }

    /** Zufalls-Token (alphanumerisch) fuer Joomlas Update-Token (bias-frei via random_int). */
    private static function randToken($len)
    {
        $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
        $out   = '';
        $max   = strlen($chars) - 1;
        for ($i = 0; $i < (int) $len; $i++) {
            $out .= $chars[random_int(0, $max)];
        }
        return $out;
    }

    /** Joomla-Hauptversion (Major) als int; 0 = unbekannt. */
    private static function joomlaMajor()
    {
        if (preg_match('~^(\d+)~', (string) self::joomlaVersion(), $m)) {
            return (int) $m[1];
        }
        return 0;
    }

    /**
     * Modul-Versionierung (Content-History fuer com_modules): 1 = an, 0 = aus, null =
     * nicht ermittelbar. Liest die Komponenten-Params direkt aus #__extensions
     * (versionsneutral, ohne ComponentHelper-Abhaengigkeit).
     */
    private static function moduleVersioningState()
    {
        try {
            $db = function_exists('htp_db') ? htp_db() : null;
            if (!$db) {
                return null;
            }
            $q = $db->getQuery(true)
                ->select($db->quoteName('params'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_modules'));
            $db->setQuery($q);
            $raw = (string) $db->loadResult();
            if ($raw === '') {
                return 0;
            }
            $p = json_decode($raw, true);
            if (!is_array($p)) {
                return 0;
            }
            return (isset($p['save_history']) && (int) $p['save_history'] === 1) ? 1 : 0;
        } catch (Exception $e) {
            return null;
        } catch (Throwable $e) {
            return null;
        }
    }

    /**
     * Mail-Health 1x/24h (Waechter) bzw. on-demand (facts.refresh): SMTP-Connect-Test OHNE
     * Versand, Ergebnis race-sicher in die Config cachen (mailhealth). Bei Erfolg einen zuvor
     * passiv gemerkten ECHT-Fehlschlag zuruecksetzen (Erholung). Public: der Waechter ruft es.
     *
     * @return array{ok:int,err:string}
     */
    public static function runMailHealth($cfg = null)
    {
        if (!function_exists('htp_mail_probe') || !class_exists('HtpConfig')) {
            return array('ok' => -1, 'err' => 'no_engine');
        }

        $res  = htp_mail_probe();
        $keys = array('mailhealth' => array(
            'ok'  => (int) $res['ok'],
            'err' => substr((string) $res['err'], 0, 200),
            'at'  => time(),
        ));

        if ((int) $res['ok'] === 1) {
            $keys['mail_last_fail_at'] = 0; // Erholung: passiven Fehlschlag zuruecksetzen
        }

        HtpConfig::saveKeys($keys);

        return $res;
    }

    /**
     * ALLE offenen Post-Install-Meldungen ausblenden (enabled=0). Reversibel (kein Loeschen),
     * idempotent, kein Geheimnis. Danach ein Heartbeat -> die Zahl faellt sofort im Dashboard.
     * WICHTIG: Joomla nutzt die Spalte `enabled` (1 = offen, 0 = ausgeblendet), NICHT `published`
     * (die gibt es in #__postinstall_messages nicht - eine fruehere Fassung fragte sie ab und
     * scheiterte damit still).
     */
    private static function runPostinstallHide(&$st, $cfg)
    {
        try {
            $db = function_exists('htp_db') ? htp_db() : null;
            if (!$db) {
                return array('ok' => false, 'code' => 'no_db');
            }

            $q = $db->getQuery(true)
                ->select('COUNT(*)')
                ->from($db->quoteName('#__postinstall_messages'))
                ->where($db->quoteName('enabled') . ' = 1');
            $db->setQuery($q);
            $n = (int) $db->loadResult();

            if ($n > 0) {
                $u = $db->getQuery(true)
                    ->update($db->quoteName('#__postinstall_messages'))
                    ->set($db->quoteName('enabled') . ' = 0')
                    ->where($db->quoteName('enabled') . ' = 1');
                $db->setQuery($u);
                $db->execute();
            }

            self::heartbeat($st, $cfg); // sysfacts.pim -> 0 sofort spiegeln
            return array('ok' => true, 'code' => 'postinstall_hidden', 'detail' => (string) $n);
        } catch (Exception $e) {
            return array('ok' => false, 'code' => 'error');
        } catch (Throwable $e) {
            return array('ok' => false, 'code' => 'error');
        }
    }

    /**
     * Modul-Versionierung (Content-History fuer com_modules) einschalten - NUR Joomla 6+.
     * Setzt save_history=1 + history_limit (params.revisions, Standard 10) in #__extensions.
     * Auf aelteren Joomla-Versionen bewusst j6_only (No-Op) - das Dashboard bietet die Aktion
     * ohnehin nur an, wenn sysfacts.modver vorliegt (also J6). Danach ein Heartbeat.
     */
    private static function runModuleVersioning(&$st, $cfg, $params)
    {
        if (self::joomlaMajor() < 6) {
            return array('ok' => false, 'code' => 'j6_only');
        }

        $rev = isset($params['revisions']) ? (int) $params['revisions'] : 10;
        if ($rev < 0) {
            $rev = 0;
        }
        if ($rev > 100) {
            $rev = 100;
        }

        try {
            $db = function_exists('htp_db') ? htp_db() : null;
            if (!$db) {
                return array('ok' => false, 'code' => 'no_db');
            }

            $q = $db->getQuery(true)
                ->select($db->quoteName('params'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_modules'));
            $db->setQuery($q);
            $raw = (string) $db->loadResult();

            $p = ($raw !== '') ? json_decode($raw, true) : array();
            if (!is_array($p)) {
                $p = array();
            }

            $p['save_history']  = 1;
            $p['history_limit'] = $rev;

            $u = $db->getQuery(true)
                ->update($db->quoteName('#__extensions'))
                ->set($db->quoteName('params') . ' = ' . $db->quote((string) json_encode($p)))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_modules'));
            $db->setQuery($u);
            $db->execute();

            self::heartbeat($st, $cfg); // sysfacts.modver -> 1 sofort spiegeln
            return array('ok' => true, 'code' => 'module_versioning_on', 'detail' => (string) $rev);
        } catch (Exception $e) {
            return array('ok' => false, 'code' => 'error');
        } catch (Throwable $e) {
            return array('ok' => false, 'code' => 'error');
        }
    }

    // ── Fehleranzeige/Debug fern setzen (debug.set, 2.6.6) ───────────────────
    // params.level in {none,default,simple,maximum,development}. Bei SICHTBAREN Stufen
    // (simple/maximum/development) wird ein 30-Min-Auto-Revert auf 'none' scharf geschaltet
    // (Waechter htpErrorReportingAutoRevert) - „damit man es nicht vergisst". Auf none/default
    // gesetzt bleibt es (kein Revert). Dieselbe Aktion ist crash-fest ueber rescue.php erreichbar.
    private static function runDebugSet(&$st, $cfg, $params)
    {
        if (!class_exists('HtpCompat')) {
            return array('ok' => false, 'code' => 'no_engine');
        }

        $level = isset($params['level']) ? strtolower((string) $params['level']) : '';

        if (!in_array($level, array('none', 'default', 'simple', 'maximum', 'development'), true)) {
            return array('ok' => false, 'code' => 'bad_level');
        }

        if (!HtpCompat::setJoomlaErrorReporting($level)) {
            return array('ok' => false, 'code' => 'write_failed');
        }

        if (HtpCompat::errLevelIsVisible($level)) {
            HtpCompat::errdbgArm(1800); // 30 Min Auto-Revert auf 'none'
        } else {
            HtpCompat::errdbgClear();
        }

        $st['want_facts'] = 1; // sysfacts.err sofort spiegeln
        self::heartbeat($st, $cfg);

        return array('ok' => true, 'code' => 'debug_set:' . $level);
    }

    // ── Backward-Compatibility-Plugin(e) reaktivieren (bc.enable, 2.6.6) ─────
    // Heilt einen durch ein deaktiviertes „Behaviour - Backward Compatibility"-Plugin ausgeloesten
    // Fatal. Idempotent (already/absent sind ok). Crash-fest zusaetzlich ueber rescue.php.
    private static function runBcEnable(&$st, $cfg)
    {
        if (!class_exists('HtpCompat')) {
            return array('ok' => false, 'code' => 'no_engine');
        }

        $r = HtpCompat::enableBcNow(); // 'enabled'|'already'|'absent'|'error'

        $st['want_facts'] = 1; // sysfacts.bc sofort spiegeln
        self::heartbeat($st, $cfg);

        if ($r === 'error') {
            return array('ok' => false, 'code' => 'bc_error');
        }

        return array('ok' => true, 'code' => 'bc_' . $r);
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Krypto/Transport-Helfer
    // ─────────────────────────────────────────────────────────────────────────

    private static function hmac($st, $msg)
    {
        return hash_hmac('sha256', (string) $msg, (string) (isset($st['transport_secret']) ? $st['transport_secret'] : ''));
    }

    /**
     * Kanonisches JSON: Objekt-Schluessel rekursiv sortiert, keine Zusatz-Leerzeichen,
     * Slashes/Unicode nicht escaped. Muss byte-identisch zur JS-Implementierung im
     * Dashboard sein (Signatur/HMAC laufen ueber genau diese Bytes).
     */
    public static function canonical($x)
    {
        $flags = 0;

        if (defined('JSON_UNESCAPED_SLASHES')) {
            $flags |= JSON_UNESCAPED_SLASHES;
        }

        if (defined('JSON_UNESCAPED_UNICODE')) {
            $flags |= JSON_UNESCAPED_UNICODE;
        }

        return json_encode(self::canonNorm($x), $flags);
    }

    private static function canonNorm($x)
    {
        if (!is_array($x)) {
            return $x;
        }

        $keys   = array_keys($x);
        $isList = ($keys === range(0, count($x) - 1));

        if ($isList) {
            $out = array();

            foreach ($x as $v) {
                $out[] = self::canonNorm($v);
            }

            return $out;
        }

        ksort($x);
        $out = array();

        foreach ($x as $k => $v) {
            $out[$k] = self::canonNorm($v);
        }

        return $out;
    }

    private static function fingerprint($b64)
    {
        $raw = base64_decode((string) $b64, true);

        if ($raw === false) {
            return '';
        }

        $hex = substr(hash('sha256', $raw), 0, 16);

        return strtoupper(implode(' ', str_split($hex, 4)));
    }

    private static function randHex($bytes)
    {
        if (function_exists('htp_random_bytes')) {
            return bin2hex(htp_random_bytes($bytes));
        }

        try {
            return bin2hex(random_bytes($bytes));
        } catch (Exception $e) {
            return bin2hex(md5(uniqid((string) mt_rand(), true), true));
        } catch (Throwable $e) {
            return bin2hex(md5(uniqid((string) mt_rand(), true), true));
        }
    }

    private static function ringPush(&$st, $key, $val)
    {
        if (!isset($st[$key]) || !is_array($st[$key])) {
            $st[$key] = array();
        }

        $st[$key][] = (string) $val;

        if (count($st[$key]) > self::RING) {
            $st[$key] = array_slice($st[$key], -self::RING);
        }
    }

    private static function ringHas($st, $key, $val)
    {
        return isset($st[$key]) && is_array($st[$key]) && in_array((string) $val, $st[$key], true);
    }
}

if (!function_exists('htp_site_host_pinned')) {
    /**
     * Der eigene, kanonische Host (kleingeschrieben, ohne Schema/Port/Pfad, ohne fuehrendes
     * "www.") - Vergleichsbasis fuer die Empfaenger-Bindung signierter Autorisierungen.
     * Duenner Aufsatz auf HtpLink::siteHostPinned(); '' = nicht ermittelbar (fail-closed).
     *
     * @return string
     */
    function htp_site_host_pinned()
    {
        return class_exists('HtpLink') ? HtpLink::siteHostPinned() : '';
    }
}
