<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Hintergrund-Waechter (Pseudo-Cron) fuer HTProtect. Prueft beim normalen
 * Seiten-Traffic in groesseren Abstaenden (Default 6 Stunden) die von der
 * Komponente verwalteten .htaccess-Dateien und die Erweiterungs-Warnliste,
 * laedt neue Exploit-Signaturen nach und meldet neue Probleme per E-Mail.
 *
 * Bewusst genuegsam: pro Seitenaufruf wird nur eine kleine State-Datei
 * gelesen; die eigentliche Pruefung laeuft erst, wenn das Intervall
 * abgelaufen ist, und ist gegen Parallel-Laeufe gesichert. Faellt nie hart
 * aus (alles in try/catch) - der Schutz darf die Site nie stoeren.
 */

defined('_JEXEC') or die;

// Basisklasse versionsneutral bestimmen (Joomla 2.5 bis 6).
if (!class_exists('HtpWatchBaseCMSPlugin')) {
    if (class_exists('Joomla\\CMS\\Plugin\\CMSPlugin')) {
        class_alias('Joomla\\CMS\\Plugin\\CMSPlugin', 'HtpWatchBaseCMSPlugin');
    } elseif (class_exists('JPlugin')) {
        class_alias('JPlugin', 'HtpWatchBaseCMSPlugin');
    }
}

if (class_exists('HtpWatchBaseCMSPlugin')) {

class PlgSystemHtprotectwatch extends HtpWatchBaseCMSPlugin
{
    /**
     * Mindestabstand zwischen zwei Pruefungen (Sekunden).
     */
    const INTERVAL = 900; // 15 Minuten

    /**
     * Kurzes Sub-Gate NUR fuer die BILLIGE Konten-Pruefung (ausgesperrte Eigentuemer
     * zurueckholen + eingeschleuste Admins stilllegen). Laeuft backend-eager, entkoppelt
     * vom teuren 15-Min-Sweep, damit eine reine DB-Manipulation an Admin-Rechten (die
     * kein Joomla-Ereignis feuert) schon beim naechsten Backend-Aufruf gefangen wird -
     * statt erst im naechsten vollen Tick.
     */
    const ACCTGUARD_INTERVAL = 60; // 60 s

    /** Cookie des 1-Klick-Login-Guard-Passes. BEWUSST hier gespiegelt (statt HtpSso::SSO_COOKIE):
     *  der Logout-Pfad laeuft ggf. bevor/ohne dass die Engine geladen ist. Muss identisch bleiben. */
    const SSO_COOKIE_NAME = 'htp_sso';

    /**
     * Eigenes (langes) Intervall fuer den Integritaets-/Anomalie-Check der
     * Einstiegspunkte - laeuft hoechstens alle 12 h, unabhaengig von INTERVAL.
     */
    const INTEGRITY_INTERVAL = 43200; // 12 Stunden

    /**
     * Taegliche tmp-Reinigung - hoechstens alle 24 h.
     */
    const TMPCLEAN_INTERVAL = 86400; // 24 Stunden

    /**
     * Defacement-/SEO-Spam-Sweep - hoechstens alle 6 h.
     */
    const SEO_INTERVAL = 21600; // 6 Stunden

    /** Verwaiste/partielle Auto-Update-Backups + alte Editier-Sicherungen aufraeumen: hoechstens 1x taeglich. */
    const BACKUP_SWEEP_INTERVAL = 86400; // 24 Stunden

    /** Mail-Health (SMTP-Connect-Test OHNE Versand) fuer die /email-Werkzeugansicht: hoechstens 1x taeglich. */
    const MAILHEALTH_INTERVAL = 86400; // 24 Stunden

    /**
     * Wie lange die Dashboard-Ampel ununterbrochen gruen sein muss, bevor der Gruen-Pfad
     * die EINMALIGE Willkommens-Mail ausloest ("alles erledigt" - nicht bloss installiert).
     */
    const WELCOME_GREEN_DELAY = 1800; // 30 Minuten

    /**
     * Wartezeit, bevor ein verwaister Selbst-Update-Marker als "Lauf tot" gilt und
     * die Notfall-Wiederherstellung greift (der normale Selbst-Update-Lauf ist
     * laengst fertig; ein noch frischer Marker = Lauf evtl. aktiv -> nicht stoeren).
     */
    const SELFHEAL_DELAY = 180; // 3 Minuten

    /**
     * Per-Request gecachter Datenordner (Laufzeitdateien).
     */
    private $statePathCache = null;

    /**
     * Allererste Stufe (laeuft VOR dem Laden der evtl. kaputten Komponente):
     * Notfall-Wiederherstellung von HTProtect, falls ein Selbst-Update mitten im
     * Lauf abgebrochen ist und die neue Version auf Platte kaputt waere. Komplett
     * self-contained (kein require der Engine), strikt fail-open. Im Normalfall
     * genau EIN is_file-stat, dann raus -> 0 Performance-Impact.
     *
     * @return void
     */
    public function onAfterInitialise()
    {
        try {
            $this->htpEarlyLinkWake();
            $this->htpSsoLanding();
            $this->htpSelfHeal();
            $this->htpCompatDebugAutoOff();
            $this->htpErrorReportingAutoRevert();
            $this->htpCompatBackend();
            $this->htpEnrollWake();
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still (PHP 7+) - der Schutz darf die Site nie stoeren.
        }
    }

    /**
     * Mehrsprachige/SEF-Robustheit: HTProtect bedient seinen EIGENEN Wake-Endpunkt (Sofort-Poll der
     * Fernverwaltung bzw. Backup-Job-Weiterlauf) bereits HIER - noch VOR dem Language-Filter, der die
     * sprach-unpraefixierte com_ajax-URL sonst per 301 auf /<lang>/ umleitet, wodurch der Handler nie
     * liefe (Dashboard-Aktionen blieben in der Warteschlange, Self-Wake tot).
     *
     * STRENG gekapselt + NEBENWIRKUNGSFREI: greift AUSSCHLIESSLICH bei einem FRONTEND-Request mit exakt
     * option=com_ajax & group=system & plugin=htplinkwake - jeder andere Request bleibt voellig
     * unberuehrt. Der Wake traegt selbst KEINE Autoritaet (stoesst nur den ohnehin signaturgeprueften
     * Poll an) und ist auf ungekoppelten Sites inert. Bewusst NUR dieser leichte Endpunkt (Tick/Poll)
     * - NICHT der schwere Auto-Update-Installer (der hat seinen eigenen Inline-Fallback), NICHT der
     * Download (dem folgt der Browser ohnehin selbst). Feuert der Language-Filter in einer Konfiguration
     * doch zuerst, laeuft dieser Pfad einfach nicht -> die caller-seitigen Same-Origin-Hops (Relay-Nudge,
     * fireJobWake) fangen den Fall dann ab. Strikt fail-open.
     *
     * @return void
     */
    private function htpEarlyLinkWake()
    {
        $app = $this->getApp();

        if (!$app) {
            return;
        }

        // Nur FRONTEND (Site): nur dort greift der Language-Redirect. Backend/CLI nie anfassen.
        // Versionsneutral (J4+ isClient, J2.5/J3 isSite - das Pendant zum isAdmin-Fallback der
        // uebrigen Methoden); ohne gueltigen Frontend-Nachweis -> raus (nie ungegatet durchlassen).
        if (method_exists($app, 'isClient')) {
            if (!$app->isClient('site')) {
                return;
            }
        } elseif (method_exists($app, 'isSite')) {
            if (!$app->isSite()) {
                return;
            }
        } else {
            return;
        }

        // Input VERSIONSNEUTRAL: J4+ getInput(), Joomla 2.5/3 die ->input-Eigenschaft (magisch).
        // NICHT auf method_exists($app,'getInput') gaten - das gibt es erst ab J4 und wuerde J3
        // (= genau die betroffene mehrsprachige Plattform) sofort rauswerfen; der Fruehwake liefe nie.
        $input = null;

        if (method_exists($app, 'getInput')) {
            $input = $app->getInput();
        } else {
            try {
                $input = $app->input;
            } catch (Exception $e) {
                $input = null;
            } catch (Throwable $e) {
                $input = null;
            }
        }

        // NUR is_object pruefen: getCmd/... sind bei Joomlas Input MAGISCHE Methoden (__call),
        // method_exists() liefert dafuer false -> ein Guard darauf wuerde die Interception immer
        // abbrechen. Der magische Aufruf $input->getCmd(...) unten funktioniert trotzdem.
        if (!is_object($input)) {
            return;
        }

        // Billiges, EXAKTES Gate (die option/plugin/group stehen als echte Query-Parameter in der
        // Wake-URL, sind hier also schon lesbar - vor dem SEF-/Language-Routing). Erster Vergleich
        // faellt fuer Normal-Requests sofort durch (kein com_ajax) -> near zero Kosten.
        if ($input->getCmd('option', '') !== 'com_ajax'
            || $input->getCmd('group', '') !== 'system'
            || $input->getCmd('plugin', '') !== 'htplinkwake') {
            return;
        }

        // Exakt denselben Handler aufrufen, den com_ajax spaeter aufriefe - nur jetzt, VOR einem
        // moeglichen Language-Redirect. Der Handler ist voll gekapselt (eigenes try/catch, eigenes
        // Drossel-Gate, backgroundFlush). Danach sauber beenden: kein Redirect, keine Doppelausfuehrung.
        $this->onAjaxHtplinkwake();

        if (function_exists('jexit')) {
            jexit('');
        }

        if (method_exists($app, 'close')) {
            $app->close();
        }
    }

    /**
     * 1-Klick-Login – ADMIN-LANDUNG. Traegt der Backend-Request ein gueltiges Einmal-Ticket
     * (?htpsso=), wird es EINGELOEST (single-use), die Admin-Session fuer den Ziel-User gesetzt
     * und OHNE Ticket sauber weitergeleitet. Der Frontend-Endpunkt (onAjaxHtplinksso) hat das
     * Ticket zuvor nur nach gueltiger, signierter SSO-Autorisierung ausgestellt. Billiges Gate:
     * ohne htpsso-Parameter im Backend sofort raus (0 Kosten). Strikt fail-open.
     *
     * @return void
     */
    /**
     * Erlaubte Ziele nach dem 1-Klick-Login. FESTE Zuordnung Schluesselwort -> Adresse; der Wert
     * aus dem Request wird NIE als Adresse verwendet, sondern nur nachgeschlagen. Unbekanntes
     * Schluesselwort ergibt '' (= Startseite). Damit kann ueber diesen Weg niemand auf ein
     * fremdes Ziel umgeleitet werden.
     *
     * @param   string  $key  Schluesselwort
     *
     * @return  string  Relative Backend-Adresse oder ''
     */
    private function htpSsoTarget($key)
    {
        $ziele = array(
            'scan'      => 'index.php?option=com_htprotect&sec=scan&autoscan=1#htp-a-malfiles',
            'integrity' => 'index.php?option=com_htprotect&sec=scan&autointegrity=1#htp-a-core',
            'htprotect' => 'index.php?option=com_htprotect',
            'updates'   => 'index.php?option=com_htprotect&sec=updates',
        );

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

        return isset($ziele[$key]) ? $ziele[$key] : '';
    }

    /**
     * Schluesselwort fuer das Anmelde-Ziel aus dem Request (nur [a-z], kurz). Leer, wenn nicht
     * gesetzt oder unplausibel.
     *
     * @return  string
     */
    private function htpSsoRetKey()
    {
        $app = $this->getApp();

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

        $input = method_exists($app, 'getInput') ? $app->getInput() : null;

        if (!is_object($input)) {
            try {
                $input = $app->input;
            } catch (Exception $e) {
                return '';
            } catch (Throwable $e) {
                return '';
            }
        }

        if (!is_object($input)) {
            return '';
        }

        $ret = (string) $input->getString('ret', '');

        return preg_match('~^[a-z]{2,16}$~', $ret) ? $ret : '';
    }

    private function htpSsoLanding()
    {
        $app = $this->getApp();

        if (!$app || !method_exists($app, 'redirect')) {
            return;
        }

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

        // Input versionsneutral (J4+ getInput, J3 ->input magisch).
        $input = null;

        if (method_exists($app, 'getInput')) {
            $input = $app->getInput();
        } else {
            try {
                $input = $app->input;
            } catch (Exception $e) {
                $input = null;
            } catch (Throwable $e) {
                $input = null;
            }
        }

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

        $ticket = (string) $input->getString('htpsso', '');

        // Normalfall (kein Ticket): sofort raus, KEIN Engine-Load -> 0 Kosten.
        if ($ticket === '' || !preg_match('~^[a-f0-9]{32,128}$~', $ticket)) {
            return;
        }

        if (!$this->loadEngine() || !class_exists('HtpSso')) {
            $app->redirect('index.php'); // Ticket aus der URL nehmen, sonst nichts
            return;
        }

        $uid = HtpSso::consumeTicket($ticket); // single-use + TTL

        if ($uid > 0) {
            HtpSso::establishSession($uid);
        }

        // Sauber weiter (Ticket raus aus der URL). Da die Session-Kennung beim Login NICHT gewechselt
        // wird (s. establishSession), traegt der Folge-Request dieselbe, bereits eingeloggte Kennung
        // -> ein einfacher, schneller Server-Redirect genuegt; keine Zwischenseite noetig. Ein Ziel-
        // Schluesselwort fuehrt zum Deeplink (z. B. Malware-Scan), sonst auf die Konsole. Bei
        // fehlgeschlagenem Login BEWUSST zur Anmeldung statt auf eine ohne Sitzung leere Seite.
        $ziel = '';

        if ($uid > 0) {
            $ziel = (string) $this->htpSsoTarget((string) $input->getString('htpret', ''));
        }

        // Auch dieser ZWEITE Sprung (Deeplink bzw. Konsole) muss den optionalen Admin-URL-Secret-
        // String tragen - er kam als 'adminqs' aus dem ersten Redirect mit (nie gespeichert). So
        // klappt der direkte Sprung in die Malware-Scan-Ansicht auch auf Sites mit Secret-URL-Schutz.
        $aqs2 = $this->htpAdminQs($input);

        // Zugangs-String-Gate: das Cookie ist bereits gesetzt (erster Sprung) -> das Secret NICHT
        // erneut an die URL haengen, sonst verwirft das Gate per QSD-Redirect das Ziel (Deeplink).
        // Sicherheitshalber das Gate-Cookie hier nochmals setzen (falls es beim ersten Sprung nicht
        // haften blieb), damit der Zielsprung garantiert durch das Gate kommt.
        $gateSecret2 = $this->htpGateSecret();

        if ($gateSecret2 !== '') {
            $this->setGateCookie($gateSecret2);

            if ($aqs2 === $gateSecret2) {
                $aqs2 = '';
            }
        }

        $final = $this->htpWithAdminQs($ziel !== '' ? $ziel : 'index.php', $aqs2);

        $app->redirect($final);
    }

    /**
     * 1-Klick-Login – FRONTEND-ENDPUNKT (com_ajax, relay-blind). Der entsperrte Operator-Browser
     * POSTet die WS_root-signierte SSO-Autorisierung {uid, exp, nonce, sig} DIREKT hierher (nicht
     * ueber den Relay). Bei gueltiger Signatur (single-use, fail-closed) + Ziel = aktiver
     * Super-Admin wird ein Einmal-Ticket ausgestellt und in den Admin-Bereich weitergeleitet,
     * wo htpSsoLanding es einloest und die Session setzt. Neutral abweisend bei Fehler.
     *
     * @return string  (immer '' - die Antwort ist ein Redirect via $app->redirect)
     */
    /**
     * Optionaler Admin-URL-Sicherheits-String (z. B. Akeeba Admin Tools "Secret URL parameter")
     * aus dem UNSIGNIERTEN SSO-POST-Feld 'adminqs'. Der Connector speichert ihn NIE - er reist bei
     * jedem 1-Klick-Login inline mit und wird nur an die Admin-Weiterleitung gehaengt (kein Sync).
     * Streng sanitisiert: fuehrendes '?', danach nur query-sichere Zeichen, Laengen-Cap; alles
     * andere -> '' (fail-closed).
     *
     * @param   object|null  $input
     *
     * @return  string  Query-Fragment OHNE fuehrendes '?' (z. B. "mysecret", "token=hiddensecret") oder ''
     */
    private function htpAdminQs($input)
    {
        if (!is_object($input)) {
            return '';
        }

        $raw = (string) $input->get('adminqs', '', 'RAW');

        return preg_match('#^\?([A-Za-z0-9_\-=&%.~]{1,120})$#', $raw, $m) ? $m[1] : '';
    }

    /**
     * Ein Query-Fragment (ohne fuehrendes '?') an eine URL anhaengen - mit '?' bzw. '&' je nachdem,
     * ob die URL schon eine Query hat. Leeres Fragment -> URL unveraendert.
     *
     * @param   string  $url
     * @param   string  $frag
     *
     * @return  string
     */
    private function htpWithAdminQs($url, $frag)
    {
        if ($frag === '') {
            return $url;
        }

        // Vor einem etwaigen #Anker einfuegen: ein Query-Param HINTER dem # landet im Fragment
        // (kommt nie beim Server an), der Secret-URL-Schutz griffe dann nicht. Die Deeplinks in
        // htpSsoTarget tragen genau solche Anker (z. B. ...autoscan=1#htp-a-malfiles).
        $hash = '';
        $h    = strpos($url, '#');

        if ($h !== false) {
            $hash = substr($url, $h);
            $url  = substr($url, 0, $h);
        }

        return $url . (strpos($url, '?') !== false ? '&' : '?') . $frag . $hash;
    }

    public function onAjaxHtplinksso()
    {
        try {
            $app = $this->getApp();

            if (!$app || !method_exists($app, 'redirect')) {
                return '';
            }

            $input = null;

            if (method_exists($app, 'getInput')) {
                $input = $app->getInput();
            } else {
                try {
                    $input = $app->input;
                } catch (Exception $e) {
                    $input = null;
                } catch (Throwable $e) {
                    $input = null;
                }
            }

            if (!is_object($input)) {
                return '';
            }

            $uid   = (int) $input->getInt('uid', 0);
            $exp   = (int) $input->getInt('exp', 0);
            $nonce = (string) $input->getString('nonce', '');
            $sig   = (string) $input->get('sig', '', 'RAW');
            // Empfaenger-Bindung: der Ziel-Host, fuer den der Browser signiert hat. HtpLink
            // vergleicht ihn mit dem eigenen, gepinnten Host (fehlt er, wird abgewiesen).
            $aud   = (string) $input->get('aud', '', 'RAW');

            if (!$this->loadEngine()
                || !class_exists('HtpLink') || !class_exists('HtpSso')
                || !method_exists('HtpLink', 'verifySsoAuth') || !method_exists('HtpLink', 'ssoAdminUrl')) {
                return '';
            }

            $adm = rtrim((string) HtpLink::ssoAdminUrl(), '/');

            if (!preg_match('~^https?://~i', $adm)) {
                return '';
            }

            // Optionaler Admin-URL-Sicherheits-String (z. B. Akeeba Admin Tools "Secret URL
            // parameter"): reist UNSIGNIERT im POST mit (adminqs) und wird NUR an diese eine
            // Weiterleitung gehaengt - der Connector speichert ihn NIE (kein laufender Sync). Streng
            // sanitisiert; '' wenn ungueltig. Da die Ziel-BASIS $adm die host-poisoning-feste
            // ssoAdminUrl bleibt, kann der Wert nur Query-Parameter am EIGENEN /administrator ergaenzen.
            $aqs = $this->htpAdminQs($input);

            // Zero-Trust: signierte Autorisierung pruefen (single-use, fail-closed) + Ziel-User
            // MUSS aktiver Super-Admin sein. Bei Fehler NEUTRAL abweisen (nur Admin-URL, kein Login).
            if (!HtpLink::verifySsoAuth($uid, $exp, $nonce, $sig, $aud) || !HtpSso::isSuperUser($uid)) {
                $app->redirect($this->htpWithAdminQs($adm . '/index.php', $aqs));

                return '';
            }

            $ticket = HtpSso::mintTicket($uid);

            if ($ticket === '') {
                $app->redirect($this->htpWithAdminQs($adm . '/index.php', $aqs));

                return '';
            }

            // Guard-Pass fuer geschuetzte /administrator (Basic-Auth): NUR wenn ein Guard aktiv ist,
            // legt openGuardPass einen markierten, hochentropischen Pass-Block in die guard-.htaccess
            // (Apache-2.4-RequireAny) und wir setzen das passende Session-Cookie, damit GENAU DIESE
            // Session an der Passwortabfrage vorbeikommt. Ohne Guard passiert NICHTS (Rueckgabe '').
            if (method_exists('HtpSso', 'openGuardPass')) {
                $gtoken = (string) HtpSso::openGuardPass();

                if ($gtoken !== '') {
                    $this->setSsoCookie($gtoken);
                }
            }

            // Zugangs-String-Gate (Option 2, Cookie-Gate): der 1-Klick-Login wuerde sonst am QSD-
            // Redirect des Gates scheitern - haengt man das Secret als Query an, setzt das Gate das
            // Cookie und VERWIRFT per QSD die ganze Query inkl. htpsso-Ticket -> nie eingeloggt.
            // Loesung: das Gate-Cookie hier SERVER-SEITIG setzen (der Connector kennt das Secret) und
            // das Secret NICHT an die URL haengen -> das Ticket bleibt erhalten, das Gate laesst die
            // /administrator-Anfrage per Cookie durch. Fremde Secret-URL-Schutze (Admin Tools) bleiben
            // unberuehrt (nur wenn adminqs GENAU unser Gate-Secret ist, wird es unterdrueckt).
            $gateSecret = $this->htpGateSecret();

            if ($gateSecret !== '') {
                $this->setGateCookie($gateSecret);

                if ($aqs === $gateSecret) {
                    $aqs = '';
                }
            }

            // Optionales Ziel NACH dem Anmelden ("Im Backend pruefen" fuehrt direkt auf den Befund
            // statt auf die Startseite). BEWUSST ein SCHLUESSELWORT, keine Adresse: die Zieladresse
            // baut ausschliesslich diese Seite (htpSsoTarget). Damit ist der Wert auch UNSIGNIERT
            // unbedenklich - im schlimmsten Fall landet der Betreiber auf einer anderen
            // HTProtect-Seite seines eigenen Backends. Eine mitgegebene ADRESSE waere dagegen ein
            // offenes Weiterleitungsziel gewesen. Unbekannt/leer -> Startseite wie bisher.
            $ret = (string) $this->htpSsoRetKey();

            // adminqs zusaetzlich als Parameter durchreichen, damit die Landeseite (htpSsoLanding)
            // beim ZWEITEN Sprung (Konsole bzw. Deeplink, z. B. Malware-Scan) den Secret-String
            // ebenfalls anhaengt - sonst liefe dieser zweite Aufruf ohne Secret auf /administrator
            // (404), falls Admin Tools ihn bei jedem Request verlangt bzw. den Session-Freischalt
            // beim Login verwirft. Der rohe Frag steht zusaetzlich in der URL (fuer Sprung 1 selbst).
            $url = $adm . '/index.php?htpsso=' . rawurlencode($ticket)
                . ($ret !== '' ? '&htpret=' . rawurlencode($ret) : '')
                . ($aqs !== '' ? '&adminqs=' . rawurlencode('?' . $aqs) : '');

            $app->redirect($this->htpWithAdminQs($url, $aqs));
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still
        }

        return '';
    }

    /**
     * Direkter, SYNCHRONER Dateimanager-Endpunkt (2.7.2). Nimmt den voll E2E-versiegelten Befehl
     * ({sealed}) aus dem POST-Body, laesst ihn von HtpLink::handleFilesRequest verifizieren + ausfuehren
     * (die eigentliche Sicherheit - Jail/Deny/Scan/Snapshot - liegt in HtpFiles) und gibt die versiegelte
     * Antwort als JSON aus. Der Endpunkt traegt selbst KEINE Autoritaet: ohne gueltige Signatur bzw. ohne
     * Freischaltung nur ein Fehler-JSON. Bewusst NICHT der Queue-Weg (dessen Ergebnis ist 2000-gedeckelt).
     *
     * @return void
     */
    public function onAjaxHtpfiles()
    {
        $out = '{"ok":false,"code":"error"}';

        try {
            $raw = (string) @file_get_contents('php://input');

            // Fallback: manche Relays posten form-kodiert mit einem 'body'-Feld statt Roh-JSON.
            if ((strlen($raw) === 0 || $raw[0] !== '{') && class_exists('Joomla\\CMS\\Factory')) {
                $app   = Joomla\CMS\Factory::getApplication();
                $input = ($app && method_exists($app, 'getInput')) ? $app->getInput() : null;

                if ($input && method_exists($input, 'get')) {
                    $post = isset($input->post) ? $input->post : $input;
                    $raw  = (string) $post->get('body', '', 'raw');
                }
            }

            $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
                . '/components/com_htprotect/core/bootstrap.php';

            if ($raw !== '' && is_file($bootstrap)) {
                require_once $bootstrap;

                if (class_exists('HtpLink') && method_exists('HtpLink', 'handleFilesRequest')) {
                    $r = HtpLink::handleFilesRequest($raw);

                    if (is_string($r) && $r !== '') {
                        $out = $r;
                    }
                }
            }
        } catch (Exception $e) {
            $out = '{"ok":false,"code":"error"}';
        } catch (Throwable $e) {
            $out = '{"ok":false,"code":"error"}';
        }

        while (function_exists('ob_get_level') && ob_get_level() > 0) {
            @ob_end_clean();
        }

        if (!headers_sent()) {
            header('Content-Type: application/json; charset=utf-8');
            header('X-Robots-Tag: noindex, nofollow');
            header('X-Content-Type-Options: nosniff');
            header('Cache-Control: private, no-store');
        }

        echo $out;
        jexit();
    }

    /**
     * Direkter, SYNCHRONER, voll E2E-versiegelter DB-Verwaltungs-Endpunkt (Weg A). Nimmt die versiegelte
     * Operation ({sealed}) aus dem POST-Body, laesst sie von HtpLink::handleDbRequest verifizieren +
     * ausfuehren (die DB-Logik + Deckel liegen in HtpDb) und gibt die versiegelte Antwort als JSON aus.
     * Ohne gueltige Signatur bzw. ohne Freischaltung nur ein Fehler-JSON. EXAKT wie onAjaxHtpfiles.
     *
     * @return void
     */
    public function onAjaxHtpdb()
    {
        $out = '{"ok":false,"code":"error"}';

        try {
            $raw = (string) @file_get_contents('php://input');

            if ((strlen($raw) === 0 || $raw[0] !== '{') && class_exists('Joomla\\CMS\\Factory')) {
                $app   = Joomla\CMS\Factory::getApplication();
                $input = ($app && method_exists($app, 'getInput')) ? $app->getInput() : null;

                if ($input && method_exists($input, 'get')) {
                    $post = isset($input->post) ? $input->post : $input;
                    $raw  = (string) $post->get('body', '', 'raw');
                }
            }

            $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
                . '/components/com_htprotect/core/bootstrap.php';

            if ($raw !== '' && is_file($bootstrap)) {
                require_once $bootstrap;

                if (class_exists('HtpLink') && method_exists('HtpLink', 'handleDbRequest')) {
                    $r = HtpLink::handleDbRequest($raw);

                    if (is_string($r) && $r !== '') {
                        $out = $r;
                    }
                }
            }
        } catch (Exception $e) {
            $out = '{"ok":false,"code":"error"}';
        } catch (Throwable $e) {
            $out = '{"ok":false,"code":"error"}';
        }

        while (function_exists('ob_get_level') && ob_get_level() > 0) {
            @ob_end_clean();
        }

        if (!headers_sent()) {
            header('Content-Type: application/json; charset=utf-8');
            header('X-Robots-Tag: noindex, nofollow');
            header('X-Content-Type-Options: nosniff');
            header('Cache-Control: private, no-store');
        }

        echo $out;
        jexit();
    }

    /**
     * SESSION-ENDE: meldet sich der Operator ab, wird sein Guard-Pass SOFORT aus der .htaccess
     * entfernt und das Cookie geloescht - der /administrator-Schutz ist damit augenblicklich wieder
     * lueckenlos. Faellt dieser Weg aus (aeltere Joomla-Versionen reichen onUserLogout nicht an
     * System-Plugins), greift der Waechter-Sweep: der entfernt jeden Pass, dessen Joomla-Session
     * nicht mehr existiert - spaetestens nach der TTL. Strikt fail-open.
     *
     * @return boolean
     */
    public function onUserLogout($user = null, $options = array())
    {
        try {
            $token = isset($_COOKIE[self::SSO_COOKIE_NAME]) ? (string) $_COOKIE[self::SSO_COOKIE_NAME] : '';

            if ($token !== '' && preg_match('~^[a-f0-9]{16,96}$~', $token)) {
                if ($this->loadEngine() && class_exists('HtpSso') && method_exists('HtpSso', 'closeGuardPassByToken')) {
                    HtpSso::closeGuardPassByToken($token);
                }

                $this->setSsoCookie('', true); // Cookie sofort entwerten
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return true;
    }

    /**
     * Guard-Pass-Cookie setzen (nur wenn ein Guard-Pass geoeffnet wurde). Session-Cookie (kein
     * Ablauf -> stirbt beim Browser-Schliessen), HttpOnly, Secure bei HTTPS, SameSite=Lax (das
     * initiale Redirect auf /administrator ist eine Top-Level-GET-Navigation -> Cookie faehrt mit),
     * Pfad /administrator/. Zusaetzlich raeumt der Logout bzw. der Waechter-Sweep auf.
     *
     * @param   string   $gtoken  Token; leer + $expire=true loescht das Cookie
     * @param   boolean  $expire  true = Cookie loeschen
     *
     * @return void
     */
    private function setSsoCookie($gtoken, $expire = false)
    {
        try {
            $name    = self::SSO_COOKIE_NAME;
            $expires = $expire ? (time() - 3600) : 0; // Vergangenheit = Browser loescht sofort

            $secure = (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off')
                || (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443)
                || (isset($_SERVER['HTTP_X_FORWARDED_PROTO'])
                    && strtolower((string) $_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https');

            if (PHP_VERSION_ID >= 70300) {
                @setcookie($name, $gtoken, array(
                    'expires'  => $expires,
                    'path'     => '/administrator/',
                    'secure'   => $secure,
                    'httponly' => true,
                    'samesite' => 'Lax',
                ));
            } else {
                // Aeltere PHP: SameSite ueber den Pfad-Parameter anhaengen (verbreiteter Kompat-Trick).
                @setcookie($name, $gtoken, $expires, '/administrator/; SameSite=Lax', '', $secure, true);
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    /**
     * Ist der Zugangs-String-Gate (Option 2, Cookie-Gate) GENAU JETZT aktiv, sein reines Secret
     * (guard_string, ohne fuehrendes '?'); sonst ''. Wird beim 1-Klick-Login genutzt, um das Gate-
     * Cookie server-seitig zu setzen und das Secret NICHT an die URL zu haengen (QSD-Falle).
     *
     * @return string
     */
    private function htpGateSecret()
    {
        try {
            if (!$this->loadEngine() || !class_exists('HtpConfig') || !class_exists('HtpGuard')) {
                return '';
            }

            $g = HtpGuard::status();

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

            $cfg = HtpConfig::load();

            return isset($cfg['guard_string']) ? (string) $cfg['guard_string'] : '';
        } catch (Exception $e) {
            return '';
        } catch (Throwable $e) {
            return '';
        }
    }

    /**
     * Das Zugangs-String-Gate-Cookie (htp_ag) server-seitig setzen - byte-identisch zur CO-Direktive
     * der Gate-.htaccess (Name htp_ag, Wert = reines Secret, Pfad '/', Secure bei HTTPS, HttpOnly,
     * lange Lebensdauer). So kommt die per 1-Klick angestossene /administrator-Anfrage durch das Gate,
     * OHNE dass das Secret in der URL steht (das wuerde per QSD-Redirect das htpsso-Ticket verwerfen).
     *
     * @param   string  $secret  reines Gate-Secret (ohne '?')
     *
     * @return void
     */
    private function setGateCookie($secret)
    {
        try {
            if ($secret === '') {
                return;
            }

            $secure = (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off')
                || (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443)
                || (isset($_SERVER['HTTP_X_FORWARDED_PROTO'])
                    && strtolower((string) $_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https');

            $expires = time() + 525600 * 60; // ~1 Jahr, wie die Gate-CO-Direktive (525600 Min)

            if (PHP_VERSION_ID >= 70300) {
                @setcookie('htp_ag', $secret, array(
                    'expires'  => $expires,
                    'path'     => '/',
                    'secure'   => $secure,
                    'httponly' => true,
                    'samesite' => 'Lax',
                ));
            } else {
                @setcookie('htp_ag', $secret, $expires, '/; SameSite=Lax', '', $secure, true);
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    /**
     * Sicherheitsnetz fuer die 1-Klick-Debug-Diagnose: hat HTProtect den Debug-Modus
     * aktiviert und ist er aelter als ~20 Min, schaltet er sich selbst wieder aus -
     * auch vom Frontend. Billig: nur is_file + (bei frischem Marker) ein Timestamp-Read,
     * KEIN Engine-Load -> 0 Kosten im Normalbetrieb, Engine erst beim Abschalten.
     *
     * @return void
     */
    private function htpCompatDebugAutoOff()
    {
        $flag = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
            . '/components/com_htprotect/data/htprotect_debug.flag';

        if (!is_file($flag)) {
            return; // Normalfall -> 0 Kosten
        }

        $since = (int) @file_get_contents($flag);

        if ($since > 0 && (time() - $since) <= 300) {
            return; // noch frisch (< 5 Min) -> Engine NICHT laden
        }

        if ($this->loadEngine() && class_exists('HtpCompat')) {
            HtpCompat::diagAutoOff();
        }
    }

    /**
     * Auto-Revert der Fehleranzeige (2.6.6): hat HTProtect error_reporting hochgesetzt
     * (debug.set / rescue.php auf simple|maximum|development), stellt der Waechter es nach
     * Ablauf der 30-Min-Frist selbst wieder auf 'none' - „damit man es nicht vergisst".
     * Die Marker-Datei traegt eine ABSOLUTE Deadline. Billig: nur is_file + ein Timestamp-Read,
     * KEIN Engine-Load, solange die Frist laeuft -> 0 Kosten im Normalbetrieb. Laeuft auch vom
     * Frontend (ein einziger Seitenaufruf nach Fristablauf genuegt, selbst wenn das Backend tot ist).
     *
     * @return void
     */
    private function htpErrorReportingAutoRevert()
    {
        $flag = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
            . '/components/com_htprotect/data/htprotect_errdbg.until';

        if (!is_file($flag)) {
            return; // Normalfall -> 0 Kosten
        }

        $until = (int) @file_get_contents($flag); // ABSOLUTE Deadline (Unix-ts)

        if ($until > 0 && time() < $until) {
            return; // Frist laeuft noch
        }

        // SELF-CONTAINED zuruecksetzen - BEWUSST OHNE loadEngine(): der Auto-Revert muss auch dann
        // greifen, wenn die HTProtect-Engine defekt ist (Selbst-Update/Manipulation) oder Joomla nur
        // teilweise bootet - genau die Lage, die den Debug-Modus ausgeloest hat. Der Regex-Patch von
        // configuration.php braucht nur die Datei (identisch zu HtpCompat::setJoomlaErrorReporting /
        // rescue.php). Spart zugleich den teuren Engine-Ladevorgang bei jedem Request.
        $reverted = $this->htpRevertErrorReportingInline();

        // Erfolgreich zurueckgestellt -> Marker weg. Sonst nur aufgeben, wenn die Frist absurd lange
        // her ist (Muster fehlt dauerhaft), damit ein Sonderfall nicht ewig bei jedem Request patcht.
        if ($reverted || (time() > $until + 86400)) {
            @unlink($flag);
        }
    }

    /**
     * error_reporting in configuration.php self-contained auf 'none' setzen (Regex-Patch, kein
     * Joomla/keine Engine). true = gesetzt (oder war schon none); false = Datei/Muster fehlt.
     *
     * @return boolean
     */
    private function htpRevertErrorReportingInline()
    {
        $root = defined('JPATH_ROOT') ? JPATH_ROOT : (defined('JPATH_SITE') ? JPATH_SITE : '');

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

        $file = $root . '/configuration.php';

        if (!@is_file($file)) {
            return false;
        }

        $src = @file_get_contents($file);

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

        $cnt = 0;
        $new = preg_replace(
            '/(public\s+\$error_reporting\s*=\s*)([\'"])(?:none|default|simple|maximum|development|[0-9]+|-1|E_ALL)\2(\s*;)/i',
            '${1}\'none\'${3}',
            $src,
            1,
            $cnt
        );

        if ($new === null || $cnt < 1) {
            return false; // Muster fehlt -> nichts anfassen
        }

        // Debug-Modus mit zuruecknehmen: ein Auto-Revert bedeutet immer „Fehleranzeige aus" ->
        // Debug aus (self-contained, ohne Engine). Format je Joomla-Generation bewahren
        // (J4-J6 Boolean false, J2.5/J3 gequotet '0').
        $new = preg_replace_callback(
            '/(public\s+\$debug\s*=\s*)([\'"]?)(?:true|false|0|1)\2(\s*;)/i',
            function ($m) {
                return $m[1] . $m[2] . ($m[2] === '' ? 'false' : '0') . $m[2] . $m[3];
            },
            $new,
            1
        );

        if ($new === null) {
            return false;
        }

        if ($new === $src) {
            return true; // schon none
        }

        $ro = !is_writable($file);

        if ($ro) {
            @chmod($file, 0644);
        }

        $ok = @file_put_contents($file, $new) !== false;

        if ($ro) {
            @chmod($file, 0444);
        }

        return $ok;
    }

    /**
     * Proaktive Backend-Warnung: ist das B/C-Plugin deaktiviert, im Plugin-Manager
     * (com_plugins) einmal pro Session einen Hinweis mit ?recompat-Tipp zeigen.
     * Nur J4+, nur Administrator, nur auf com_plugins -> sonst 0 Kosten.
     *
     * @return void
     */
    private function htpCompatBackend()
    {
        if (self::htpJMajor() < 5 || !class_exists('Joomla\\CMS\\Plugin\\CMSPlugin')) {
            return; // umschaltbares B/C-Plugin gibt es erst ab J5 -> darunter nichts zeigen
        }

        $app = $this->getApp();

        if (!$app || !method_exists($app, 'isClient') || !$app->isClient('administrator')) {
            return;
        }

        $option = '';
        $dbg    = '';

        try {
            if (method_exists($app, 'getInput')) {
                $option = (string) $app->getInput()->getCmd('option', '');
                $dbg    = (string) $app->getInput()->getCmd('htpcompatdebug', '');
            }
        } catch (Exception $e) {
            return;
        } catch (Throwable $e) {
            return;
        }

        // 1-Klick-Debug-Diagnose (Super-User + Token werden in HtpCompat geprueft).
        if ($dbg === 'on' || $dbg === 'off') {
            if ($this->loadEngine() && class_exists('HtpCompat')) {
                if ($dbg === 'on') {
                    HtpCompat::diagEnableDebug($app);
                } else {
                    HtpCompat::diagDisableDebug($app);
                }
            }

            return;
        }

        // Billiger Vorab-Check: Recovery-Events ODER laufende Debug-Diagnose ODER
        // Plugin-Manager -> nur dann Engine + Meldung (sonst 0 Kosten).
        $dir       = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
            . '/components/com_htprotect/data';
        $hasEvents = false;
        $sf        = $dir . '/htprotect_compat.json';

        if (is_file($sf)) {
            $raw = @file_get_contents($sf);

            if ($raw !== false && strpos((string) $raw, '"events"') !== false) {
                $hasEvents = true;
            }
        }

        $diagOn = is_file($dir . '/htprotect_debug.flag');

        if (!$hasEvents && !$diagOn && $option !== 'com_plugins') {
            return;
        }

        if ($this->loadEngine() && class_exists('HtpCompat')) {
            // Im Plugin-Manager koennte der Admin B/C gerade (wieder) einschalten ->
            // "eingerastet"-Status loesen, damit die Beobachtung wieder genau hinschaut.
            if ($option === 'com_plugins') {
                HtpCompat::unsettle();
            }

            HtpCompat::backendNotice($app);
        }
    }

    /**
     * Echtzeit-Schutz: nach dem Routing, aber BEVOR die (evtl. verwundbare)
     * Komponente ausgefuehrt wird. Zweite Schicht des Exploit-Schilds -
     * prueft GET UND POST und faengt so auch im Body geschmuggelte Aufrufe,
     * die eine .htaccess nie sieht. Fail-open: blockiert nur bei einem
     * eindeutigen, validierten Signaturtreffer.
     *
     * @return void
     */
    public function onAfterRoute()
    {
        // Doppelt absichern: catch(Exception) fuer PHP 5, catch(Throwable) fuer
        // PHP 7/8 (Error/TypeError). So kann ein Fehler die Site NIE mit 500
        // lahmlegen - der Schutz bleibt strikt fail-open.
        try {
            // Stealth: HTProtect vor ausgeblendeten Super-Usern verbergen. Ein Direktaufruf
            // von com_htprotect wird fuer sie still aufs Dashboard umgeleitet. KEINE
            // Sicherheitsgrenze (ein Super-User hat ohnehin Vollzugriff), rein Sicht/Komfort
            // fuer Agenturen -> strikt fail-open. Der einrichtende SU ist NIE ausgeblendet.
            $this->htpStealthGuard();
            // Uninstall-Interception (Backend): Klick auf "Deinstallieren" fuer eine der
            // 4 HTProtect-IDs -> Umleitung auf den Aufraeum-Assistenten VOR dem Uninstall.
            $this->htpUninstallIntercept();
            // Sichere Frontend-Abmeldung: default-AUS, behandelt + beendet die
            // Anfrage NUR bei einem formgueltigen htp_unsub-Token; sonst sofort raus.
            $this->htpUnsub();
            // Auto-Update-Feedback aus der Post-Update-Mail (rein meldend): default-AUS,
            // behandelt + beendet die Anfrage NUR bei formgueltigem htp_fb-Link.
            $this->htpFeedback();
            // Backend-Schutz-Wiederherstellung ueber den Mail-Link: default-AUS, behandelt +
            // beendet die Anfrage NUR bei formgueltigem htp_recover-Token. Entfernt den Schutz
            // erst nach Super-User-Login (Frontend-Session) + bestaetigtem POST + CSRF.
            $this->htpRecover();
            $this->htpWaf();
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still (PHP 7+)
        }
    }

    /**
     * Nach der Antwort an den Besucher (Ausgabe ist bereits raus).
     *
     * @return void
     */
    public function onAfterRespond()
    {
        // Auto-Update serverseitig antippen (statt Joomlas JS-Lazy-Scheduler): der
        // Waechter feuert einen frischen com_ajax-Self-Request, der den Install VOR der
        // Response im vollen Kontext ausfuehrt (onAfterRespond selbst taugt dafuer nicht).
        try {
            $this->htpAutoUpdateTick();
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still
        }

        // Aenderung: Der fruehere Gzip-Waechter, der Joomlas gzip in der
        // configuration.php lautlos abgeschaltet hat, ist ENTFERNT. HTProtect
        // fasst die Joomla-Konfiguration nicht mehr von sich aus an - es weist
        // im Backend nur noch darauf hin, entschieden wird vom Betreiber.

        try {
            $this->htpRun();
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still (PHP 7+) - der Waechter darf die Site nie beeintraechtigen.
        }

        // Backend-eager Konten-Schutz: der billige Restore-/Contain-Teil auf kurzem
        // Sub-Gate (ACCTGUARD_INTERVAL), NUR im Backend, detached. Faengt eine reine
        // DB-Manipulation an Admin-Rechten schon beim naechsten Backend-Aufruf, ohne den
        // teuren Sweep vorzuziehen. Kein Doppellauf: lief oben ein voller Tick, hat er das
        // Sub-Gate bereits gesetzt -> diese Methode kehrt am Marker sofort zurueck.
        try {
            $this->htpAccountGuardTick();
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still (PHP 7+).
        }
    }

    /**
     * Notfall-Modus-Erinnerung im FRONTEND: ein dezenter fixed-bottom-Balken, den NUR der
     * Inhaber des gueltigen Zugangs-Cookies sieht (normale Besucher werden ohnehin per
     * .htaccess mit 503 gesperrt und kommen nie hierher) - damit man den aktiven Notfall-
     * Modus nicht vergisst. Billiges Gate: ohne htp_em-Cookie sofort raus (kein Config-Read).
     *
     * @return void
     */
    private function htpEmergencyBanner()
    {
        $app = $this->getApp();

        if (!$app || !method_exists($app, 'getBody') || !method_exists($app, 'setBody')) {
            return;
        }

        // Banner erscheint im FRONTEND (fuer den Cookie-Inhaber) UND im BACKEND auf JEDER Admin-
        // Seite - inkl. ALLER HTProtect-Komponentenseiten -, damit man den aktiven Notfall-Modus
        // nirgends vergisst. isClient/isAdmin-Fallback: steuert Gate UND Banner-Text.
        $isAdmin = false;

        if (method_exists($app, 'isClient')) {
            $isAdmin = (bool) $app->isClient('administrator');
        } elseif (method_exists($app, 'isAdmin')) {
            $isAdmin = (bool) $app->isAdmin();
        }

        // Billiges Gate (vor dem Engine-Laden):
        // - BACKEND: die statische Wartungsseite existiert NUR waehrend des Notfall-Modus -> als
        //   Anwesenheits-Signal nutzen. Der Admin soll den "beenden"-Reminder IMMER sehen, egal ob
        //   er ueber den Zugangs-Link (Cookie) ODER die Notaus-Datei reingekommen ist (bei Dashboard-
        //   Aktivierung hat der Operator ja gar kein htp_em-Cookie). Backend = wenig Traffic -> ein
        //   is_file je Seite ist vernachlaessigbar.
        // - FRONTEND: nur der Cookie-Inhaber (Normalbetrieb: 0 Kosten, gar kein Config-Load; und ein
        //   Besucher, der ueber die Notaus-Datei auf die entsperrte Seite kommt, soll ihn NICHT sehen).
        if ($isAdmin) {
            $maintDir = defined('JPATH_PUBLIC') ? JPATH_PUBLIC : (defined('JPATH_ROOT') ? JPATH_ROOT : '');

            if ($maintDir === '' || !@is_file($maintDir . '/htp-maintenance.html')) {
                return;
            }
        } elseif (empty($_COOKIE['htp_em'])) {
            return;
        }

        $body = (string) $app->getBody();

        if (stripos($body, '</body>') === false) {
            return; // kein HTML-Dokument (JSON/AJAX/Feed)
        }

        // Engine laden + Config pruefen (nur im seltenen Fall: Cookie-Inhaber ODER Notfall-Backend).
        $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
            . '/components/com_htprotect/core/bootstrap.php';

        if (!is_file($bootstrap)) {
            return;
        }

        require_once $bootstrap;

        if (!class_exists('HtpConfig')) {
            return;
        }

        $cfg = HtpConfig::load();

        if (empty($cfg['emergency_lock'])) {
            return;
        }

        // Notaus-Datei = "wird gerade entsperrt": die Seite ist effektiv schon offen (die Engine
        // setzt lock=0 erst beim naechsten Lauf). Damit der Banner dem TATSAECHLICHEN Live-Stand
        // folgt - und nicht "aktiv" zeigt, waehrend die Seite bereits offen ist - hier NICHT zeigen.
        // Pfad deckungsgleich zu HtpApp::emergencyFlagPath ($_SERVER['DOCUMENT_ROOT'], JPATH_PUBLIC-Fallback).
        $emDr = isset($_SERVER['DOCUMENT_ROOT']) ? rtrim(str_replace('\\', '/', (string) $_SERVER['DOCUMENT_ROOT']), '/') : '';

        if ($emDr === '' && defined('JPATH_PUBLIC')) {
            $emDr = rtrim(str_replace('\\', '/', (string) JPATH_PUBLIC), '/');
        }

        if ($emDr !== '' && @is_file($emDr . '/htp-emergency-off')) {
            return;
        }

        // FRONTEND: KEINE erneute exakte Token-Pruefung mehr (frueher: Cookie === emergency_key).
        // Sie war (a) REDUNDANT - die Apache-503-Sperre laesst nur den Inhaber des zum LOCK passenden
        // Cookies ueberhaupt auf die NORMALE Seite; wer ein veraltetes/fremdes Cookie hat, sieht die
        // Wartungsseite (statisches Apache-HTML) und kommt nie bis hierher - und (b) FRAGIL: wich der
        // in der .htaccess aktive Sperr-Token mal vom in der Config gespeicherten emergency_key ab
        // (Fernstart/Rekey/Bestands-Fall), passte das (von Apache akzeptierte) Cookie nicht mehr zum
        // Config-Key -> der Operator sah die gesperrte Seite, aber KEIN Banner. Es genuegt: Notfall-
        // Modus aktiv (oben) + Zugangs-Cookie vorhanden (oben) + keine Notaus-Datei (oben). Damit
        // erscheint das Banner im Frontend zuverlaessig - egal ob per Backend oder Connector gestartet.

        if (function_exists('htp_load_lang')) {
            htp_load_lang();
        }

        $base     = class_exists('JUri') ? rtrim((string) JUri::root(true), '/') : '';
        $adminUrl = $base . '/administrator/index.php?option=com_htprotect&sec=guard#htp-emergency';
        $txtKey   = $isAdmin ? 'COM_HTPROTECT_EMERGENCY_FBANNER_BE' : 'COM_HTPROTECT_EMERGENCY_FBANNER';
        $txt      = function_exists('htp_t') ? htp_t($txtKey) : ($isAdmin ? 'Emergency mode active - your website is locked for visitors' : 'Emergency mode active - only you can see the site');
        $end      = function_exists('htp_t') ? htp_t('COM_HTPROTECT_EMERGENCY_FBANNER_END') : 'end';
        $esc      = function_exists('htp_esc')
            ? array('t' => htp_esc($txt), 'e' => htp_esc($end), 'u' => htp_esc($adminUrl))
            : array('t' => htmlspecialchars($txt, ENT_QUOTES, 'UTF-8'), 'e' => htmlspecialchars($end, ENT_QUOTES, 'UTF-8'), 'u' => htmlspecialchars($adminUrl, ENT_QUOTES, 'UTF-8'));

        $banner = '<div id="htp-emx" role="status" style="position:fixed;left:0;right:0;bottom:0;z-index:2147483000;'
            . 'background:#15264f;color:#fff;font:14px/1.45 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Arial,sans-serif;'
            . 'padding:9px 16px;display:flex;align-items:center;justify-content:center;gap:14px;box-shadow:0 -2px 14px rgba(0,0,0,.28)">'
            . '<span style="opacity:.96">&#128274; ' . $esc['t'] . '</span>'
            . '<a href="' . $esc['u'] . '" style="color:#8ec5ff;text-decoration:underline;font-weight:600;white-space:nowrap">' . $esc['e'] . '</a>'
            . '</div>';

        $app->setBody(str_ireplace('</body>', $banner . '</body>', $body));
    }

    /**
     * Auto-Update serverseitig antippen - unabhaengig von Joomlas JS-Lazy-Scheduler.
     * Billiges Gate (1 stat): laeuft ein Job, wird alle ~15 s EINE Phase getickt; idle
     * hoechstens alle ~200 s ein Pruef-Lauf (runStep prueft selbst die Faelligkeit). Im
     * Hintergrund via backgroundFlush (FPM-Verbindung beenden, Session OFFEN lassen),
     * also 0,0 Seitenlast und voller Install-Kontext. Nur wenn das Feature je lief
     * (State-Datei existiert). Strikt fail-open.
     *
     * @return void
     */
    private function htpAutoUpdateTick()
    {
        $dir = $this->statePath();

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

        $stateFile = $dir . '/htprotect_autoupdate.json';

        // Feature nie gelaufen (keine State-Datei) -> SOFORT raus (1 stat). Sites OHNE
        // Auto-Update (Default) zahlen damit praktisch nichts.
        if (!is_file($stateFile)) {
            return;
        }

        $now    = time();
        $marker = $dir . '/htprotect_autoupdate.tick';

        // Billiges Gate (1 stat): der Marker traegt den NAECHSTEN erlaubten Zeitpunkt
        // (mtime in der Zukunft). Nicht erreicht -> raus, OHNE State-Read/Engine. Der
        // Normalfall kostet pro Request also nur 2 stats - near zero.
        if (is_file($marker) && (int) @filemtime($marker) > $now) {
            return;
        }

        // Loop-Guard: der frische Self-Trigger-Request (com_ajax/htpautoupdate) darf nicht
        // erneut triggern.
        $app   = $this->getApp();
        $input = ($app && method_exists($app, 'getInput')) ? $app->getInput() : null;

        if ($input
            && $input->getCmd('option') === 'com_ajax'
            && $input->getCmd('plugin') === 'htpautoupdate') {
            return;
        }

        // Erst jetzt (faellig) den State lesen: laeuft ein Job -> alle 15 s eine Phase;
        // sonst hoechstens alle 14 Min ein Pruef-Anstoss. Den naechsten erlaubten Zeitpunkt
        // in den Marker schreiben. WICHTIG: dieser Idle-Takt (840 s) MUSS ueber
        // HtpAutoUpdate::INSTANT_MIN (600 s) bleiben - sonst wuerde der 'instant'-Zeitplan
        // verhungern (jeder Tick setzt last_run neu; INSTANT_MIN ist die scheduleDue-Schwelle).
        $st       = json_decode((string) @file_get_contents($stateFile), true);
        $active   = is_array($st) && !empty($st['job']);
        $throttle = $active ? 15 : 840;

        @touch($marker, $now + $throttle);

        // Trigger-Schluessel + Basis-URL holen (Engine kurz laden).
        $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
            . '/components/com_htprotect/core/bootstrap.php';

        if (!is_file($bootstrap)) {
            return;
        }

        require_once $bootstrap;

        if (!class_exists('HtpAutoUpdate') || !method_exists('HtpAutoUpdate', 'triggerToken')) {
            return;
        }

        // === FPM mit funktionierendem FFR: Schritt INLINE ausfuehren ===
        // Kein HTTP-Self-Call -> kein oeffentlicher Endpunkt, kein TLS, kein Token,
        // KEIN Loopback/Hairpinning noetig. Damit laeuft es auch auf Billo-Hosts
        // (Strato/IONOS), http-only oder mit kaputtem Zertifikat zuverlaessig. Der
        // Besucher ist am onAfterRespond bereits bedient; backgroundFlush() trennt die
        // Verbindung (fastcgi_finish_request), danach laeuft der Install im selben,
        // voll gebooteten Request (bewiesen: Installer::install() funktioniert dort).
        // Self-healing: stockt ein aktiver Job ueber mehrere FFR-Ticks -> dieser Host
        // schaltet dauerhaft auf den gehaerteten HTTP-Trigger zurueck.
        // Inline bevorzugen, wenn (a) dieser Host den HTTP-Self-Trigger schon einmal in einen
        // Redirect laufen sah (Multilingual/SEF - dann ist der HTTP-Weg dauerhaft aussichtslos)
        // ODER (b) FFR-inline regulaer zulaessig ist. httpRedirectSeen zuerst pruefen: ist es
        // gesetzt, wird ffrInlineDecide (mit seiner FFR-Selbstheilungs-Buchfuehrung) bewusst
        // uebersprungen - auf einem Redirect-Host bleiben wir immer inline.
        if (method_exists('HtpAutoUpdate', 'runInline')
            && ($this->httpRedirectSeen($dir) || $this->ffrInlineDecide($dir))) {
            @unlink($dir . '/htprotect_trigger_reach.warn'); // laeuft inline -> kein Trigger-Erreichbarkeitsproblem
            $this->backgroundFlush();
            HtpAutoUpdate::runInline();

            // Der Throttle oben wurde VOR dem Seeden gelesen (idle -> 14 Min). Hat dieser Lauf
            // gerade EINEN Job angestossen (haeufig auf J3, wo der Waechter-Herzschlag seedet),
            // darf die naechste Phase nicht bis zu 14 Min haengen -> Marker mit der aktiven
            // Kurz-Kadenz (15 s) nachstempeln, damit der naechste Request zuegig weitertickt.
            if (!$active) {
                $st2 = json_decode((string) @file_get_contents($stateFile), true);

                if (is_array($st2) && !empty($st2['job'])) {
                    @touch($marker, time() + 15);
                }
            }

            return;
        }

        // Kurzlebiges Token (NICHT der langlebige Schluessel) - wird gleich im POST-Body
        // gesendet, landet also in keinem Access-/Proxy-Log und ist nach ~2 Min wertlos.
        $token = HtpAutoUpdate::triggerToken();
        // Host-poisoning-feste Basis (live_site/gespeichert, sonst Fallback) - der Trigger
        // kann so selbst bei manipuliertem Host-Header nie auf eine fremde Domain zeigen.
        $base = method_exists('HtpAutoUpdate', 'triggerBase') ? (string) HtpAutoUpdate::triggerBase() : '';

        if ($token === '' || !preg_match('~^https?://~i', $base)) {
            // Weder FFR-Inline (oben) noch HTTP-Trigger moeglich, weil keine vertrauens-
            // wuerdige Basis-URL bekannt ist -> nicht STILL aufgeben, sondern (debounced)
            // den Cron-Hinweis setzen. Heilt beim ersten Backend-Besuch (Basis wird erfasst).
            if (!preg_match('~^https?://~i', $base)) {
                $this->noteTriggerNoBase();
            }

            return;
        }

        // Nur Routing-Parameter im URL (kein Geheimnis); das Token reist im POST-Body.
        $url = rtrim($base, '/') . '/index.php?option=com_ajax&plugin=htpautoupdate'
            . '&group=system&format=raw';

        // Besucher-Verbindung freigeben, dann den FRISCHEN Trigger-Request feuern
        // (kurzer Timeout, fire-and-forget; der Handler laeuft via ignore_user_abort
        // weiter und macht den Install im vollen Pre-Response-Kontext).
        $this->backgroundFlush();
        $this->fireTrigger($url, $token, $dir);
    }

    /**
     * Den Self-Trigger-Request feuern (fire-and-forget, kurzer Timeout - der Handler
     * laeuft serverseitig via ignore_user_abort weiter). Voll gekapselt.
     *
     * SICHERHEIT: Das Token reist im POST-Body (nicht im URL-Query) und ist kurzlebig.
     * TLS wird VERIFIZIERT; nur bei einem echten Zertifikatsfehler (z. B. kaputtes
     * CA-Bundle / Self-Signed auf manchen Hostings) wird EINMALIG unverifiziert
     * nachgefeuert UND eine Backend-Warnung gesetzt - statt still unsicher zu laufen.
     * Der Timeout-Abbruch des verifizierten Versuchs (Handler arbeitet noch) ist KEIN
     * Zertifikatsfehler und gilt als Erfolg (Warnung wird dann geloescht).
     *
     * @param   string  $url    Trigger-URL (nur Routing, kein Geheimnis)
     * @param   string  $token  kurzlebiges Trigger-Token (POST-Body)
     *
     * @return void
     */
    private function fireTrigger($url, $token, $dir = '')
    {
        try {
            if (function_exists('curl_init')) {
                $http  = 0;
                $errno = $this->curlTrigger($url, $token, true, $http); // verifiziert

                if ($errno !== 0 && $this->isCurlCertError($errno)) {
                    // Zertifikat nicht verifizierbar -> vermerken + unverifiziert nachfeuern
                    $this->setTlsWarning();
                    $errno = $this->curlTrigger($url, $token, false, $http); // finaler Versuch
                } else {
                    // verifiziert erreicht (oder Timeout, weil Handler laeuft) -> Warnung weg
                    $this->clearTlsWarning();
                }

                // Multilingual/SEF: die sprach-unpraefixierte com_ajax-Trigger-URL wird per 3xx auf
                // /<lang>/ umgeleitet -> der htpautoupdate-Handler laeuft NIE (ein 3xx ist fuer cURL
                // errno 0 und wuerde sonst faelschlich als "erreicht" gelten). Wir folgen dem Redirect
                // BEWUSST nicht (Token-Weiterreichung/Leak vermeiden), sondern holen den Schritt
                // in-process per Inline nach - ganz ohne HTTP/Token/Redirect, also sprach- und
                // routing-unabhaengig - und stellen diesen Host dauerhaft auf Inline.
                if ($http >= 300 && $http < 400) {
                    if ($dir !== '') {
                        $this->markHttpRedirect($dir);
                        @unlink($dir . '/htprotect_trigger_reach.warn'); // Inline uebernimmt -> kein Reach-Problem
                    }

                    if (method_exists('HtpAutoUpdate', 'runInline')) {
                        $this->backgroundFlush();
                        HtpAutoUpdate::runInline();
                    }

                    return;
                }

                // Erreichbarkeit des Self-Calls bewerten (Loopback/Hairpinning-Diagnose).
                $this->noteTriggerReach($errno);

                return;
            }

            if (class_exists('Joomla\\CMS\\Http\\HttpFactory')) {
                $http = Joomla\CMS\Http\HttpFactory::getHttp();

                if ($http) {
                    @$http->post($url, array('htpkey' => $token), array(), 3);
                }
            }
        } catch (Exception $e) {
            // egal
        } catch (Throwable $e) {
            // egal
        }
    }

    /**
     * Einen Trigger-POST absetzen. Liefert die cURL-Fehlernummer (0 = ok). Token im
     * Body, kurzer Timeout (fire-and-forget; der Handler laeuft serverseitig weiter).
     *
     * @param   string   $url     Trigger-URL
     * @param   string   $token   Trigger-Token
     * @param   boolean  $verify  TLS-Zertifikat pruefen?
     *
     * @return int  cURL-Fehlernummer
     */
    private function curlTrigger($url, $token, $verify, &$http = 0)
    {
        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, 3);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verify ? true : false);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $verify ? 2 : 0);
        // Bewusst KEIN FOLLOWLOCATION: ein 301/302 wuerde POST->GET wandeln (Token weg) bzw. mit
        // POSTREDIR das kurzlebige Trigger-Token ans Redirect-Ziel weiterreichen (Leak-Flaeche).
        // Einen Redirect behandeln wir stattdessen ueber den Inline-Pfad (siehe fireTrigger).
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(array('htpkey' => (string) $token)));
        curl_setopt($ch, CURLOPT_USERAGENT, 'HTProtect-Trigger');
        @curl_exec($ch);
        $errno = (int) @curl_errno($ch);
        $http  = (int) @curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        @curl_close($ch);

        return $errno;
    }

    /**
     * Ist die cURL-Fehlernummer ein ECHTER Zertifikatsfehler (dann unverifizierter
     * Fallback sinnvoll)? Bewusst eng: Timeout (28, Handler laeuft noch),
     * Connection-Refused, DNS etc. fallen NICHT darunter.
     *  51 = Peer-Zertifikat/Host stimmt nicht (legacy), 58 = lokales Client-Zertifikat,
     *  60 = CA/Peer-Verifikation fehlgeschlagen, 66 = SSL-Engine-Init,
     *  77 = CA-Bundle nicht lesbar, 83 = Issuer-Check.
     *
     * @param   int  $errno  cURL-Fehlernummer
     *
     * @return boolean
     */
    private function isCurlCertError($errno)
    {
        return in_array((int) $errno, array(51, 58, 60, 66, 77, 83), true);
    }

    /**
     * Darf der Schritt INLINE per FFR laufen (FPM)? Fuehrt zugleich den Self-Healing-
     * Waechter: macht ein AKTIVER Job ueber mehrere FFR-Ticks keinen Fortschritt, wird
     * FFR-inline fuer DIESEN Host dauerhaft abgeschaltet (Fallback auf den HTTP-Trigger).
     * Eigene kleine Status-Datei (kein Schreib-Race mit der Engine).
     *
     * @param   string  $dir  State-Ordner
     *
     * @return boolean  true = FFR-inline verwenden
     */
    private function ffrInlineDecide($dir)
    {
        if (!function_exists('fastcgi_finish_request')) {
            return false; // Nicht-FPM -> gehaerteter HTTP-Trigger
        }

        $sf = $dir . '/htprotect_trigger.state';
        $st = json_decode((string) @file_get_contents($sf), true);

        if (!is_array($st)) {
            $st = array();
        }

        if (!empty($st['ffr_disabled'])) {
            return false; // auf diesem Host als untauglich markiert
        }

        // Fortschritts-Signatur des Auto-Update-Jobs (read-only gelesen).
        $au        = json_decode((string) @file_get_contents($dir . '/htprotect_autoupdate.json'), true);
        $jobActive = is_array($au) && !empty($au['job']);
        $sig       = $jobActive
            ? ('job|' . (isset($au['job']['phase']) ? $au['job']['phase'] : '') . '|' . (isset($au['job']['idx']) ? (int) $au['job']['idx'] : 0))
            : ('idle|' . (isset($au['last_run']) ? (int) $au['last_run'] : 0));

        // Nur bei AKTIVEM Job ohne Fortschritt einen "miss" zaehlen (idle ist kein Stillstand).
        if ($jobActive && isset($st['ffr_sig']) && $st['ffr_sig'] === $sig) {
            $st['ffr_misses'] = (isset($st['ffr_misses']) ? (int) $st['ffr_misses'] : 0) + 1;

            if ($st['ffr_misses'] >= 4) {
                $st['ffr_disabled'] = 1; // kommt hier nicht voran -> kuenftig HTTP-Trigger
            }
        } else {
            $st['ffr_misses'] = 0;
        }

        $st['ffr_sig'] = $sig;
        @file_put_contents($sf, json_encode($st));

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

    /**
     * Merken, dass der HTTP-Self-Trigger auf diesem Host in einen Redirect lief (Multilingual/SEF):
     * kuenftige Ticks nehmen dann direkt den Inline-Pfad, statt erneut einen aussichtslosen HTTP-
     * Request zu feuern. Kleiner Read-Modify-Write auf die Trigger-Status-Datei (best-effort).
     *
     * @param   string  $dir  State-Ordner
     *
     * @return void
     */
    private function markHttpRedirect($dir)
    {
        if ($dir === '') {
            return;
        }

        $sf = $dir . '/htprotect_trigger.state';
        $st = json_decode((string) @file_get_contents($sf), true);

        if (!is_array($st)) {
            $st = array();
        }

        if (empty($st['http_redirect'])) {
            $st['http_redirect'] = 1;
            @file_put_contents($sf, json_encode($st));
        }
    }

    /**
     * Lief der HTTP-Self-Trigger auf diesem Host schon einmal in einen Redirect? -> Inline bevorzugen.
     *
     * @param   string  $dir  State-Ordner
     *
     * @return boolean
     */
    private function httpRedirectSeen($dir)
    {
        if ($dir === '') {
            return false;
        }

        $st = json_decode((string) @file_get_contents($dir . '/htprotect_trigger.state'), true);

        return is_array($st) && !empty($st['http_redirect']);
    }

    /**
     * Erreichbarkeits-Diagnose des HTTP-Self-Triggers: konnte der Self-Call die eigene
     * Seite ueberhaupt erreichen? errno 0/28 (28 = Timeout, Handler laeuft) = erreicht;
     * errno 6/7 (DNS/Connect) = NICHT erreichbar (z. B. blockiertes Loopback/Hairpinning
     * auf manchen Billo-Hosts). Nach mehrfachem Fehlschlag -> Backend-Hinweis (Cron noetig).
     *
     * @param   int  $errno  cURL-Fehlernummer des letzten Versuchs
     *
     * @return void
     */
    private function noteTriggerReach($errno)
    {
        $errno   = (int) $errno;
        $reached = ($errno === 0 || $errno === 28);
        $unreach = in_array($errno, array(6, 7), true);

        if (!$reached && !$unreach) {
            return; // unklar (transient) -> Status nicht veraendern
        }

        $dir = $this->statePath();

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

        $sf = $dir . '/htprotect_trigger.state';
        $st = json_decode((string) @file_get_contents($sf), true);

        if (!is_array($st)) {
            $st = array();
        }

        if ($reached) {
            $st['unreach'] = 0;
            @unlink($dir . '/htprotect_trigger_reach.warn');
        } else {
            $st['unreach'] = (isset($st['unreach']) ? (int) $st['unreach'] : 0) + 1;

            if ($st['unreach'] >= 3) {
                @file_put_contents($dir . '/htprotect_trigger_reach.warn', (string) time());
            }
        }

        @file_put_contents($sf, json_encode($st));
    }

    /**
     * Der HTTP-Self-Trigger konnte GAR NICHT gefeuert werden: non-FFR-Host UND keine
     * vertrauenswuerdige Basis-URL bekannt (z. B. frische Installation mit leerem
     * live_site, bevor ein Super-User das Backend geoeffnet hat - die SSRF-Haertung
     * unterdbrueckt bewusst die request-abgeleitete Basis). Statt STILL nichts zu tun wie
     * ein Reach-Fehlschlag debounced zaehlen und nach mehrfachem Auftreten denselben
     * Cron-Hinweis setzen. Heilt sich selbst: der erste Backend-Besuch erfasst die Basis,
     * danach greift der Trigger und noteTriggerReach() loescht die Warnung wieder. Cron
     * bleibt ohnehin die Garantie fuer unbesuchte Sites.
     *
     * @return void
     */
    private function noteTriggerNoBase()
    {
        $dir = $this->statePath();

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

        $sf = $dir . '/htprotect_trigger.state';
        $st = json_decode((string) @file_get_contents($sf), true);

        if (!is_array($st)) {
            $st = array();
        }

        $st['unreach'] = (isset($st['unreach']) ? (int) $st['unreach'] : 0) + 1;

        if ($st['unreach'] >= 3) {
            @file_put_contents($dir . '/htprotect_trigger_reach.warn', (string) time());
        }

        @file_put_contents($sf, json_encode($st));
    }

    /** Backend-Warnung "interner HTTPS-Trigger nicht verifizierbar" setzen (Marker-Datei). */
    private function setTlsWarning()
    {
        $dir = $this->statePath();

        if ($dir !== '') {
            @file_put_contents($dir . '/htprotect_trigger_tls.warn', (string) time());
        }
    }

    /** Die TLS-Warnung wieder aufheben (verifizierter Trigger hat geklappt). */
    private function clearTlsWarning()
    {
        $dir = $this->statePath();

        if ($dir !== '' && is_file($dir . '/htprotect_trigger_tls.warn')) {
            @unlink($dir . '/htprotect_trigger_tls.warn');
        }
    }

    /**
     * com_ajax-Einstieg fuer den Auto-Update-Self-Trigger. Vom Waechter per HTTP gefeuert
     * (mit Trigger-Schluessel); laeuft im FRISCHEN Request VOR der Response (voller
     * Kontext - dort funktioniert der Joomla-Install zuverlaessig, anders als in
     * onAfterRespond). Die eigentliche Arbeit + Schluesselpruefung macht
     * HtpAutoUpdate::handleTrigger (ignore_user_abort). Strikt fail-open.
     *
     * @return string
     */
    public function onAjaxHtpautoupdate()
    {
        try {
            $app = $this->getApp();

            // Token aus dem POST-Body (dorthin sendet der Waechter es - nicht im Query,
            // also in keinem Access-Log). Fallback auf den gemergten Input fuer Robustheit.
            $token = '';

            if ($app && method_exists($app, 'getInput')) {
                $input = $app->getInput();
                $token = (string) $input->post->get('htpkey', '', 'raw');

                if ($token === '') {
                    $token = (string) $input->get('htpkey', '', 'raw');
                }
            }

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

            $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
                . '/components/com_htprotect/core/bootstrap.php';

            if (!is_file($bootstrap)) {
                return '';
            }

            require_once $bootstrap;

            if (class_exists('HtpAutoUpdate') && method_exists('HtpAutoUpdate', 'handleTrigger')) {
                HtpAutoUpdate::handleTrigger($token);
            }
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still
        }

        return '';
    }

    /**
     * com_ajax-Wake fuer die Fernverwaltung: der htprotect.app-Relay ruft diesen Endpunkt an,
     * SOBALD ein Befehl wartet (Sofort-"Nudge"), und erzwingt damit einen SOFORTIGEN Command-Poll
     * (poll_min-Drossel umgangen) - so wird eine Aktion in Sekunden statt Minuten ausgefuehrt.
     *
     * PERFORMANCE: Besucher loesen das NIE aus (nur der Relay, nur bei wartendem Befehl). Die
     * Verbindung wird sofort getrennt (backgroundFlush) und der Poll laeuft im Hintergrund -> 0,0
     * Seitenlast. Anti-Spam/DoS: billiges 1-stat-Gate (hoechstens ~1x/s, OHNE Engine-Load) - bewusst
     * KURZ, damit schnell aufeinanderfolgende Dashboard-Aktionen (z. B. Ordner im Analyzer nacheinander
     * aufklappen) jeweils SOFORT ihren eigenen Poll ausloesen; eine grobe Drossel liess sonst den 2.
     * Klick bis zum naechsten regulaeren Poll (POLL_MIN) haengen. Ueberlappende Ticks verhindert der
     * flock-Lock ohnehin, das 1-stat-Gate spart nur den Engine-Load bei echtem Hammering.
     * Traegt KEINE Autoritaet: er stoesst nur den ohnehin signaturgeprueften Poll des Agenten an.
     * Auf ungekoppelten Sites ist HtpLink::tick inert (Config-Gate) -> komplett wirkungslos.
     *
     * @return string
     */
    public function onAjaxHtplinkwake()
    {
        try {
            $dir = $this->statePath();

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

            $now    = time();
            $marker = $dir . '/htprotect_link_wake.tick';

            // Billiges Gate (1 stat, VOR jedem Engine-Load): noch gedrosselt -> sofort raus.
            if (is_file($marker) && (int) @filemtime($marker) > $now) {
                return '';
            }

            @touch($marker, $now + 1);

            // Besucher/Relay-Verbindung sofort trennen, dann im Hintergrund den Poll erzwingen.
            $this->backgroundFlush();

            $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
                . '/components/com_htprotect/core/bootstrap.php';

            if (!is_file($bootstrap)) {
                return '';
            }

            require_once $bootstrap;

            if (class_exists('HtpLink')) {
                HtpLink::tick(null, true); // forcePoll = sofort abholen (poll_min umgangen)
            }
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still - der Wake darf die Site nie beeintraechtigen.
        }

        return '';
    }

    /**
     * com_ajax-Endpunkt fuer den signierten Direkt-DOWNLOAD eines Backups (Site->Browser, OHNE
     * Relay = Zero-Knowledge). Aufruf per Navigation aus dem entsperrten Operator-Browser:
     *   /index.php?option=com_ajax&plugin=htplinkdl&group=system&format=raw&ref=..&part=files|db&exp=..&sig=..
     * Die WS_root-Signatur wird von HtpLink::verifyDownloadAuth geprueft; nur bei Erfolg wird die
     * geschuetzte Backup-Datei mit Range/Resume gestreamt. Der Endpunkt traegt selbst KEINE
     * Autoritaet - ohne gueltige Signatur liefert er 403, sonst nichts.
     *
     * @return void
     */
    public function onAjaxHtplinkdl()
    {
        try {
            if (!class_exists('Joomla\\CMS\\Factory')) {
                $this->dlDeny(404);
            }

            $app   = Joomla\CMS\Factory::getApplication();
            $input = ($app && method_exists($app, 'getInput')) ? $app->getInput() : null;

            if (!$input) {
                $this->dlDeny(400);
            }

            // Credential aus dem POST-BODY (nicht der URL) lesen: so landet die Signatur nie in
            // Access-Logs / Browser-History / Referer. Nur POST wird akzeptiert.
            $post  = method_exists($input, 'get') && isset($input->post) ? $input->post : $input;
            $ref   = (string) $post->get('ref', '', 'raw');
            $part  = (string) $post->get('part', '', 'raw');
            $exp   = (int) $post->get('exp', 0, 'int');
            $nonce = (string) $post->get('nonce', '', 'raw');
            $sig   = (string) $post->get('sig', '', 'raw');
            // Empfaenger-Bindung (siehe SSO): Ziel-Host, wie vom Browser signiert.
            $aud   = (string) $post->get('aud', '', 'raw');

            $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
                . '/components/com_htprotect/core/bootstrap.php';

            if (!is_file($bootstrap)) {
                $this->dlDeny(404);
            }

            require_once $bootstrap;

            if (!class_exists('HtpLink') || !class_exists('HtpBackup')) {
                $this->dlDeny(404);
            }

            if (!HtpLink::verifyDownloadAuth($ref, $part, $exp, $nonce, $sig, $aud)) {
                $this->dlDeny(403);
            }

            $path = HtpBackup::backupFilePath($ref, $part);

            if ($path === '' || !is_file($path)) {
                $this->dlDeny(404);
            }

            // Download-Name: neue Backups tragen bereits Site + Datum im Dateinamen (aussagekraeftig)
            // -> direkt verwenden; aeltere haben generische Namen (files.zip/db.sql.gz) -> mit einem
            // kurzen Referenz-Kuerzel eindeutig machen.
            $bn = basename($path);
            $dl = preg_match('~-\d{8}-\d{6}-~', $bn) ? $bn : ('htprotect-' . substr((string) $ref, 0, 8) . '-' . $bn);
            $this->streamFile($path, $dl);
        } catch (Exception $e) {
            $this->dlDeny(404);
        } catch (Throwable $e) {
            $this->dlDeny(404);
        }

        jexit();
    }

    /** Schlanke Fehlerantwort fuer den dl-Endpunkt, dann Ende (nie Detailleck). */
    private function dlDeny($code)
    {
        while (function_exists('ob_get_level') && ob_get_level() > 0) {
            @ob_end_clean();
        }

        $map = array(400 => '400 Bad Request', 403 => '403 Forbidden', 404 => '404 Not Found');
        $msg = isset($map[$code]) ? $map[$code] : '400 Bad Request';

        if (!headers_sent()) {
            header('HTTP/1.1 ' . $msg);
            header('Content-Type: text/plain; charset=utf-8');
            header('X-Robots-Tag: noindex, nofollow');
            header('Cache-Control: private, no-store');
        }

        echo $msg;
        jexit();
    }

    /** Streamt eine Datei mit Range/Resume + Content-Disposition, speicherschonend, dann Ende. */
    private function streamFile($path, $downloadName)
    {
        while (function_exists('ob_get_level') && ob_get_level() > 0) {
            @ob_end_clean();
        }

        $size = @filesize($path);
        $fp   = @fopen($path, 'rb');

        if ($size === false || $size < 0 || !$fp) {
            $this->dlDeny(404);
        }

        $start  = 0;
        $end    = $size - 1;
        $status = 200;
        $range  = isset($_SERVER['HTTP_RANGE']) ? (string) $_SERVER['HTTP_RANGE'] : '';

        if ($range !== '' && preg_match('~^bytes=(\d*)-(\d*)$~', $range, $m)) {
            if ($m[1] === '' && $m[2] !== '') {
                $start = max(0, $size - (int) $m[2]); // Suffix: letzte N Bytes
                $end   = $size - 1;
            } else {
                $start = (int) $m[1];
                $end   = ($m[2] === '') ? ($size - 1) : min((int) $m[2], $size - 1);
            }

            if ($start > $end || $start >= $size) {
                @fclose($fp);

                if (!headers_sent()) {
                    header('HTTP/1.1 416 Range Not Satisfiable');
                    header('Content-Range: bytes */' . $size);
                }

                jexit();
            }

            $status = 206;
        }

        $len  = $end - $start + 1;
        $name = preg_replace('~[^A-Za-z0-9._-]~', '_', (string) $downloadName);

        if (!headers_sent()) {
            header('HTTP/1.1 ' . ($status === 206 ? '206 Partial Content' : '200 OK'));

            if ($status === 206) {
                header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
            }

            header('Content-Type: application/octet-stream');
            header('Content-Length: ' . $len);
            header('Content-Disposition: attachment; filename="' . $name . '"');
            header('Accept-Ranges: bytes');
            header('X-Content-Type-Options: nosniff');
            header('X-Robots-Tag: noindex, nofollow');
            header('Cache-Control: private, no-store');
        }

        @set_time_limit(0);

        if ($start > 0) {
            @fseek($fp, $start);
        }

        $remaining = $len;
        $bufSize   = 262144;

        while ($remaining > 0 && !feof($fp)) {
            $read = ($remaining > $bufSize) ? $bufSize : $remaining;
            $buf  = fread($fp, $read);

            if ($buf === false) {
                break;
            }

            echo $buf;
            $remaining -= strlen($buf);
            @flush();
        }

        @fclose($fp);
        jexit();
    }

    /**
     * Im Backend auf Joomlas Update-Ansicht (com_installer/update) einen auffaelligen
     * "Auto-Updates"-Button ans Ende der Toolbar setzen, der zur HTProtect-Auto-
     * Update-Verwaltung fuehrt. Nur >= Joomla 5.4 (dort existiert das Feature). Per
     * kleinem JS in die Toolbar gehaengt - robust ueber Joomla-Versionen, ohne
     * Toolbar-API-Eigenheiten. Strikt fail-open (darf die Update-Seite nie stoeren).
     *
     * @return void
     */
    public function onBeforeRender()
    {
        try {
            if (!class_exists('Joomla\\CMS\\Factory')) {
                return;
            }

            $app = Joomla\CMS\Factory::getApplication();

            if (!$app || !method_exists($app, 'isClient') || !$app->isClient('administrator')) {
                return;
            }

            // Cross-Hinweis in Joomlas Globaler Konfiguration (com_config): der Standard-"Offline"-Modus
            // sperrt nur Besucher aus, KEINEN Angreifer -> direkt am Feld auf den HTProtect-Notfallmodus
            // verlinken. Versionsunabhaengig (laeuft auch unter <5.4), rein additiv, self-contained,
            // strikt fail-open. Steht bewusst VOR der 5.4-Schranke der Auto-Update-Schaltflaeche.
            $this->injectConfigOfflineHint($app);

            // Nur dort, wo es das Auto-Update-Feature gibt (>= Joomla 5.4).
            if (!defined('JVERSION') || !version_compare(JVERSION, '5.4.0', '>=')) {
                return;
            }

            $input = method_exists($app, 'getInput') ? $app->getInput() : null;
            $view  = $input ? $input->getCmd('view') : '';

            // Joomlas Update-Ansicht UND die Erweiterungs-Verwaltung (dort verlinkt
            // "Auf Updates pruefen" auf view=update) - auf beiden den Button setzen.
            if (!$input
                || $input->getCmd('option') !== 'com_installer'
                || ($view !== 'update' && $view !== 'manage')) {
                return;
            }

            $doc = method_exists($app, 'getDocument') ? $app->getDocument() : null;

            if (!$doc || !method_exists($doc, 'addScriptDeclaration')) {
                return;
            }

            $url = 'index.php?option=com_htprotect&sec=backups#htp-autoupdate';
            $js  = "(function(){function a(){"
                . "var t=document.getElementById('toolbar')||document.querySelector('.toolbar');"
                . "if(!t||document.getElementById('htp-au-toolbtn')){return;}"
                . "var b=document.createElement('a');b.id='htp-au-toolbtn';"
                . "b.href='" . $url . "';b.className='btn btn-success';"
                // Kein eigenes margin: gleicher Wrapper-Typ wie der Nachbar -> die
                // Toolbar gibt den EINHEITLICHEN Abstand vor (auf beiden Views). Glow
                // ist box-shadow (kein Layout-Einfluss).
                . "b.style.cssText='font-weight:700;box-shadow:0 0 0 .18rem rgba(40,167,69,.35)';"
                . "b.innerHTML='<span class=\"icon-refresh\" aria-hidden=\"true\"></span> Auto-Updates';"
                // Update-View: rechts neben \"Erweiterungen verwalten\" (view=manage);
                // Manage-View: rechts neben \"Auf Updates pruefen\" (Link zu view=update).
                // Das aeusserste Toolbar-Item des Nachbarn finden, DANACH einfuegen und
                // den REAL gemessenen Abstand zum Vorgaenger uebernehmen -> ueberall gleich.
                . "var r=t.querySelector('[href*=\"view=manage\"]')||t.querySelector('[href*=\"view=update\"]');"
                . "if(r){var slot=r;while(slot.parentNode&&slot.parentNode!==t){slot=slot.parentNode;}"
                . "var gap=0,prev=slot.previousElementSibling;"
                . "if(prev){try{var d=slot.getBoundingClientRect().left-prev.getBoundingClientRect().right;if(d>0&&d<80){gap=Math.round(d);}}catch(e){}}"
                . "b.style.marginLeft=(gap||8)+'px';"
                . "slot.parentNode.insertBefore(b,slot.nextSibling);}else{t.appendChild(b);}}"
                . "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',a);}else{a();}"
                . "})();";

            $doc->addScriptDeclaration($js);
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open
        }
    }

    /**
     * Cross-Hinweis in Joomlas Globaler Konfiguration (com_config): direkt unter dem Feld
     * "Website offline" darauf hinweisen, dass der Standard-Offline-Modus nur Besucher aussperrt,
     * KEINEN Angreifer - mit Link zum HTProtect-Notfallmodus (sec=guard#htp-emergency). Rein additiv
     * (ein kleiner Hinweis-Kasten wird per JS ans Offline-Feld gehaengt), versionsunabhaengig,
     * strikt fail-open. Nur com_config, nur Backend (dort ohnehin nur Super-User).
     *
     * @param  object  $app
     *
     * @return void
     */
    private function injectConfigOfflineHint($app)
    {
        try {
            $input = method_exists($app, 'getInput') ? $app->getInput() : null;

            if (!$input || $input->getCmd('option') !== 'com_config') {
                return;
            }

            // Stealth respektieren: ist HTProtect fuer diesen Super-User ausgeblendet (hidden_su),
            // KEINEN HTProtect-Hinweis in der Konfiguration zeigen (sonst waere es doch sichtbar).
            if ($this->htpStealthHiddenCurrent($app)) {
                return;
            }

            $doc = method_exists($app, 'getDocument') ? $app->getDocument() : null;

            if (!$doc || !method_exists($doc, 'addScriptDeclaration')) {
                return;
            }

            $de = false;

            try {
                $l  = method_exists($app, 'getLanguage') ? $app->getLanguage() : null;
                $de = $l && stripos((string) $l->getTag(), 'de') === 0;
            } catch (Throwable $e) {
            }

            $msg  = $de
                ? 'Offline sperrt Besucher aus, aber keinen Angreifer. → '
                : 'Offline locks out visitors, but not an attacker. → ';
            $link = $de ? 'HTProtect Notfallmodus' : 'HTProtect emergency mode';
            $url  = 'index.php?option=com_htprotect&sec=guard#htp-emergency';

            // Rein additiver Hinweis-Kasten direkt am Offline-Feld (#jform_offline). Selektoren bewusst
            // robust + mit Fallback; doppelte Einfuegung verhindert, greift auf J3-J6.
            $js = "(function(){function h(){"
                . "var f=document.getElementById('jform_offline');"
                . "if(!f||document.getElementById('htp-offline-hint')){return;}"
                . "var box=(f.closest?(f.closest('.controls')||f.closest('.control-group')||f.closest('[class*=\"control-\"]')):null)||f.parentNode;"
                . "if(!box){return;}"
                . "var n=document.createElement('div');n.id='htp-offline-hint';"
                . "n.style.cssText='margin-top:.5rem;padding:.45rem .7rem;border-left:3px solid #d9534f;background:rgba(217,83,79,.09);border-radius:4px;font-size:.9rem;line-height:1.45';"
                . "n.innerHTML=" . json_encode($msg) . "+'<a href=\"'+" . json_encode($url) . "+'\" style=\"font-weight:700;color:#d9534f\">'+" . json_encode($link) . "+'</a>';"
                . "box.appendChild(n);}"
                . "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',h);}else{h();}"
                . "})();";

            $doc->addScriptDeclaration($js);
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open
        }
    }

    /**
     * Stealth-Zugriffssperre: Ist der aktuelle Super-User in der Ausblend-Liste
     * (hidden_su), wird ein Direktaufruf von com_htprotect im Backend still auf das
     * Dashboard umgeleitet - HTProtect ist damit fuer ihn unsichtbar UND unerreichbar.
     * Bewusst KEINE Sicherheitsgrenze (ein Super-User kann ohnehin alles am System),
     * sondern reine Sicht-/Komfortfunktion fuer Agenturen -> strikt fail-open.
     *
     * @return void
     */
    private function htpStealthGuard()
    {
        try {
            if (!class_exists('Joomla\\CMS\\Factory')) {
                return;
            }

            $app = Joomla\CMS\Factory::getApplication();

            if (!$app || !method_exists($app, 'isClient') || !$app->isClient('administrator')) {
                return; // nur Backend
            }

            $input = method_exists($app, 'getInput') ? $app->getInput() : null;

            if (!$input || $input->getCmd('option') !== 'com_htprotect') {
                return; // betrifft nur unsere Komponente
            }

            if (!$this->htpStealthHiddenCurrent($app)) {
                return; // niemand ausgeblendet ODER dieser Nutzer nicht
            }

            if (method_exists($app, 'redirect')) {
                $app->redirect('index.php'); // still aufs Dashboard (kein Hinweis)
            }
        } catch (Exception $e) {
            // fail-open: im Zweifel sichtbar
        } catch (Throwable $e) {
            // fail-open
        }
    }

    /**
     * Ist der aktuell angemeldete Backend-Benutzer in hidden_su? Ergebnis wird pro
     * Request gecacht (onAfterRoute + onAfterRender teilen es) -> hoechstens EIN
     * Config-Load je Admin-Request. Fuer Einzelnutzer (leere Liste) sofort false.
     *
     * @return boolean
     */
    private function htpStealthHiddenCurrent($app)
    {
        static $cache = null;

        if ($cache !== null) {
            return $cache;
        }

        $cache = false;

        try {
            if (!$this->loadEngine() || !class_exists('HtpConfig')) {
                return $cache;
            }

            $cfg    = HtpConfig::load();
            $hidden = (isset($cfg['hidden_su']) && is_array($cfg['hidden_su'])) ? $cfg['hidden_su'] : array();

            if (empty($hidden)) {
                return $cache; // Standard/Einzelnutzer: nichts ausgeblendet
            }

            $user = method_exists($app, 'getIdentity') ? $app->getIdentity() : null;
            $uid  = ($user && isset($user->id)) ? (int) $user->id : 0;
            $cache = $uid > 0 && in_array((string) $uid, array_map('strval', $hidden), true);
        } catch (Exception $e) {
            $cache = false;
        } catch (Throwable $e) {
            $cache = false;
        }

        return $cache;
    }

    /**
     * Menue-Ausblendung: entfernt fuer ausgeblendete Super-User die HTProtect-Eintraege
     * (Menue-Item + Komponenten-Liste) aus dem fertigen Backend-HTML. Rein kosmetisch +
     * fail-open; die eigentliche Sperre ist htpStealthGuard (Direktzugriff wird ohnehin
     * umgeleitet, selbst wenn ein Menue-Link stehen bliebe).
     *
     * @return void
     */
    public function onAfterRender()
    {
        try {
            // Frontend: dezenter Notfall-Modus-Erinnerungsbanner fuer den Zugangs-Cookie-
            // Inhaber (eigenes billiges Gate; ohne Cookie/im Backend praktisch 0 Kosten).
            $this->htpEmergencyBanner();

            if (!class_exists('Joomla\\CMS\\Factory')) {
                return;
            }

            $app = Joomla\CMS\Factory::getApplication();

            if (!$app || !method_exists($app, 'isClient') || !$app->isClient('administrator')
                || !method_exists($app, 'getBody') || !method_exists($app, 'setBody')) {
                return;
            }

            if (!$this->htpStealthHiddenCurrent($app)) {
                return;
            }

            $body = $app->getBody();

            if (!is_string($body) || $body === '' || stripos($body, 'com_htprotect') === false) {
                return;
            }

            // Ein <li>, dessen ERSTER Anker auf option=com_htprotect zeigt (Leaf-Eintrag;
            // keine Untermenues betroffen) -> entfernen. Ueber alle Vorkommen (Menue +
            // Komponenten-Liste). Beruehrt keine Skript-/Asset-Verweise.
            $clean = preg_replace(
                '~<li\b[^>]*>\s*<a\b[^>]*option=com_htprotect[^>]*>.*?</li>~is',
                '',
                $body
            );

            if (is_string($clean) && $clean !== '' && $clean !== $body) {
                $app->setBody($clean);
            }
        } catch (Exception $e) {
            // fail-open: dann bleibt der Eintrag sichtbar (Zugriff ist trotzdem gesperrt)
        } catch (Throwable $e) {
            // fail-open
        }
    }

    /**
     * EINZIGER, bewusst gekapselter Zugriff des Waechters auf die rohen PHP-
     * Request-Superglobale ($_GET/$_POST/$_FILES). Liefert das ungefilterte Bag
     * als Referenz. Eine WAF MUSS die echte, UNGEFILTERTE Eingabe sehen - sie
     * sucht exakt nach den Mustern (../, php://, serialisierte Objekte, im Body
     * geschmuggelte Aufrufe), die ein Eingabefilter wegputzen wuerde. Das Plugin
     * laeuft als ERSTE Schicht VOR dem Laden der Engine (kein compat.php), darum
     * dieser eigenstaendige, dokumentierte Zugriffspunkt statt verstreuter
     * Superglobal-Zugriffe. Verhalten unveraendert - exakt der bisherige
     * Direktzugriff, nur an dieser einen Stelle gebuendelt.
     *
     * Hinweis fuer Code-Pruefer (JED/PHPCS): fuer eine WAF unvermeidbar und
     * absichtlich auf diese eine Methode begrenzt.
     *
     * @param   string  $which  'get' | 'post' | 'files'
     *
     * @return array  Referenz auf die jeweilige Superglobale
     */
    private function &sg($which)
    {
        // phpcs:disable
        switch ($which) {
            case 'get':
                return $_GET;
            case 'files':
                return $_FILES;
            case 'post':
            default:
                return $_POST;
        }
        // phpcs:enable
    }

    /**
     * Sichere Frontend-Abmeldung von Alarm-Mails. SURFACE-MINIMAL: default-AUS;
     * nur wenn der dedizierte Parameter htp_unsub vorhanden UND formgueltig ist,
     * wird ueberhaupt etwas geladen. GET zeigt nur die Bestaetigungsseite
     * (aendert nichts) - erst der POST entfernt die EINE Adresse. Unfaelschbar
     * (HMAC), keine Adresse in der URL, generische Antwort bei Nicht-Treffer
     * (keine Enumeration). Beendet die Anfrage bei Behandlung; sonst return.
     *
     * @return void
     */
    private function htpUnsub()
    {
        // 1) Dedizierter Parameter da? (fast immer NEIN -> sofort raus, 0 Overhead)
        $token = '';
        $get   = &$this->sg('get');
        $post  = &$this->sg('post');

        if (isset($get['htp_unsub']) && is_string($get['htp_unsub'])) {
            $token = $get['htp_unsub'];
        } elseif (isset($post['htp_unsub']) && is_string($post['htp_unsub'])) {
            $token = $post['htp_unsub'];
        }

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

        // 2) Strenge Form ZUERST (vor jedem Laden/Reflektieren). Ungueltig ->
        //    so tun, als gaebe es den Parameter nicht (normale Seite laedt weiter).
        if (!preg_match('~^[A-Za-z0-9_-]{16,64}$~', $token)) {
            return;
        }

        // 3) Nur die Site-Anwendung (nie Backend/CLI/API).
        $app = $this->getApp();

        if (!$app) {
            return;
        }

        if (method_exists($app, 'isClient')) {
            if (!$app->isClient('site')) {
                return;
            }
        } elseif (method_exists($app, 'isAdmin') && $app->isAdmin()) {
            return;
        }

        // 4) Engine laden (nur in diesem seltenen, formgeprueften Pfad).
        $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
            . '/components/com_htprotect/core/bootstrap.php';

        if (!is_file($bootstrap)) {
            return;
        }

        require_once $bootstrap;

        if (!class_exists('HtpConfig') || !class_exists('HtpUnsub')) {
            return;
        }

        if (function_exists('htp_load_lang')) {
            htp_load_lang();
        }

        $cfg    = HtpConfig::load();
        $secret = isset($cfg['unsub_secret']) ? (string) $cfg['unsub_secret'] : '';
        $emails = HtpUnsub::normalizeList(isset($cfg['notify_email']) ? $cfg['notify_email'] : '');
        $base   = function_exists('htp_trusted_base') ? htp_trusted_base(isset($cfg['unsub_base']) ? $cfg['unsub_base'] : '') : '';
        $match  = ($secret !== '') ? HtpUnsub::match($token, $emails, $secret) : '';

        // 5) Kein Treffer -> generische Antwort (ungueltig / bereits abgemeldet).
        if ($match === '') {
            $this->unsubRender('invalid', '', $token, $base);

            return; // (unsubRender beendet via close()/exit; defensiv)
        }

        // 6) Bestaetigt (POST + Flag)? -> NUR diese eine Adresse entfernen.
        $isConfirm = (isset($_SERVER['REQUEST_METHOD']) && strtoupper((string) $_SERVER['REQUEST_METHOD']) === 'POST')
            && isset($post['htp_unsub_confirm']);

        if ($isConfirm) {
            $remaining = HtpUnsub::remove($match, $emails);
            HtpConfig::saveKeys(array('notify_email' => implode(', ', $remaining)));
            // Sicherheits-Bestaetigung an die abgemeldete Adresse: so erfaehrt der echte
            // Inhaber, dass seine Adresse aus den Sicherheitswarnungen entfernt wurde -
            // falls jemand anderes (mit Zugriff auf die Mail/den Link) abgemeldet hat.
            $this->sendUnsubConfirmMail($match, $base);
            $this->unsubRender('done', $match, $token, $base);
        } else {
            $this->unsubRender('confirm', $match, $token, $base);
        }
    }

    /**
     * Minimal-HTML der Abmelde-Seite ausgeben und die Anfrage beenden. Alles
     * Dynamische ist escaped; das Token ist bereits strikt formgeprueft.
     *
     * @param   string  $state  confirm|done|invalid
     * @param   string  $email  getroffene Adresse (nur Anzeige, wird maskiert)
     * @param   string  $token  formgeprueftes Token (nur fuer das Formular)
     *
     * @return void
     */
    private function unsubRender($state, $email, $token, $base = '')
    {
        while (ob_get_level() > 0) {
            @ob_end_clean();
        }

        if (!headers_sent()) {
            header('Content-Type: text/html; charset=utf-8');
            header('X-Robots-Tag: noindex, nofollow');
            header('Referrer-Policy: no-referrer');
        }

        $esc    = 'htp_esc';
        $masked = $esc(HtpUnsub::mask($email));

        if ($state === 'done') {
            $inner = '<h1>' . $esc(htp_t('COM_HTPROTECT_UNSUB_DONE_TITLE')) . '</h1>'
                . '<p>' . str_replace('%s', '<strong>' . $masked . '</strong>', $esc(htp_t('COM_HTPROTECT_UNSUB_DONE'))) . '</p>';
        } elseif ($state === 'invalid') {
            $inner = '<h1>' . $esc(htp_t('COM_HTPROTECT_UNSUB_INVALID_TITLE')) . '</h1>'
                . '<p>' . $esc(htp_t('COM_HTPROTECT_UNSUB_INVALID')) . '</p>';
        } else {
            $actionBase = ($base !== '' && $base !== '/') ? $base : htp_trusted_base();
            $action     = $esc(rtrim($actionBase, '/') . '/index.php');
            $inner  = '<h1>' . $esc(htp_t('COM_HTPROTECT_UNSUB_PAGE_TITLE')) . '</h1>'
                . '<p>' . str_replace('%s', '<strong>' . $masked . '</strong>', $esc(htp_t('COM_HTPROTECT_UNSUB_CONFIRM_Q'))) . '</p>'
                . '<form method="post" action="' . $action . '">'
                . '<input type="hidden" name="htp_unsub" value="' . $esc($token) . '">'
                . '<input type="hidden" name="htp_unsub_confirm" value="1">'
                . '<button type="submit" class="htpu-btn">' . $esc(htp_t('COM_HTPROTECT_UNSUB_CONFIRM_BTN')) . '</button>'
                . '</form>'
                . '<p class="htpu-dim">' . $esc(htp_t('COM_HTPROTECT_UNSUB_CONFIRM_INFO')) . '</p>';
        }

        $css = 'body{margin:0;background:#eef1f6;color:#1c2230;font:16px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif}'
            . '.htpu-wrap{max-width:520px;margin:8vh auto;padding:0 18px}'
            . '.htpu-card{background:#fff;border:1px solid #d9dee7;border-radius:14px;padding:26px 28px;box-shadow:0 1px 3px rgba(20,30,50,.08)}'
            . '.htpu-card h1{font-size:1.25rem;margin:0 0 12px}.htpu-card p{margin:0 0 14px}'
            . '.htpu-btn{display:inline-block;border:0;border-radius:8px;background:#1f9d57;color:#fff;font-weight:600;font-size:1rem;padding:11px 20px;cursor:pointer}'
            . '.htpu-btn:hover{background:#188048}.htpu-dim{color:#5b6473;font-size:.86rem}'
            . '.htpu-foot{color:#8b91a5;font-size:.8rem;text-align:center;margin-top:16px}';

        $html = '<!DOCTYPE html><html lang="' . $esc(htp_lang()) . '"><head><meta charset="utf-8">'
            . '<meta name="viewport" content="width=device-width,initial-scale=1">'
            . '<meta name="robots" content="noindex,nofollow">'
            . '<title>' . $esc(htp_t('COM_HTPROTECT_UNSUB_PAGE_TITLE')) . '</title><style>' . $css . '</style></head>'
            . '<body><div class="htpu-wrap"><div class="htpu-card">' . $inner . '</div>'
            . '<div class="htpu-foot">HTProtect &middot; website-bereinigung.de</div></div></body></html>';

        echo $html;

        $app = $this->getApp();

        if ($app && method_exists($app, 'close')) {
            $app->close();
        }

        exit;
    }

    /**
     * Sicherheits-Bestaetigung nach erfolgreicher Abmeldung: eine kurze Plain-Text-
     * Mail an die soeben abgemeldete Adresse, damit der echte Inhaber erfaehrt, dass
     * seine Adresse aus den Sicherheitswarnungen entfernt wurde (Schutz gegen ein
     * unbemerktes "Stilllegen" durch einen Dritten mit Zugriff auf die Mail/den Link).
     * Fail-soft: ein Mail-Problem darf die Abmelde-Antwort nie stoeren.
     *
     * @param   string  $email  abgemeldete Adresse (bereits per HMAC verifiziert)
     * @param   string  $base   vertrauenswuerdige Basis-URL (nur Anzeige)
     *
     * @return void
     */
    private function sendUnsubConfirmMail($email, $base)
    {
        if (!function_exists('htp_send_mail') || (string) $email === '') {
            return;
        }

        $site = ($base !== '' && $base !== '/') ? rtrim((string) $base, '/') : '';

        if ($site === '') {
            $app  = $this->getApp();
            $site = ($app && method_exists($app, 'get')) ? (string) $app->get('sitename', '') : '';
        }

        $lines   = array();
        $lines[] = htp_t('COM_HTPROTECT_UNSUB_MAIL_LINE1', $email, $site);
        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_UNSUB_MAIL_LINE2');
        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_MAIL_SIGNATURE'); // Plain-Alt; HtpMail::wrap setzt sie im HTML selbst

        // HTML-Variante (Signatur kommt aus dem Template-Footer -> hier ohne).
        $html = '';

        if (class_exists('HtpMail')) {
            $content = HtpMail::lineP(htp_t('COM_HTPROTECT_UNSUB_MAIL_LINE1', $email, $site))
                . HtpMail::muted(htp_t('COM_HTPROTECT_UNSUB_MAIL_LINE2'));
            $html    = HtpMail::wrap(htp_t('COM_HTPROTECT_UNSUB_DONE_TITLE'), $content);
        }

        $err = '';

        try {
            htp_send_mail($email, htp_t('COM_HTPROTECT_UNSUB_MAIL_SUBJECT'), implode("\n", $lines), '', $err, array(), $html !== '' ? $html : null);
        } catch (Exception $e) {
            // egal
        } catch (Throwable $e) {
            // egal
        }
    }

    /**
     * Backend-Schutz-WIEDERHERSTELLUNG ueber den Mail-Link (fuer den echten Aussperr-Fall:
     * die Basic-Auth vor /administrator vergessen). SURFACE-MINIMAL (default-AUS): nur bei
     * formgueltigem htp_recover-Token wird ueberhaupt etwas geladen. Sicherheit = ZWEI
     * unabhaengige Faktoren + CSRF:
     *   (a) 256-bit-Einmal-Token aus der Mail (gegen den gespeicherten SHA-256-Hash,
     *       timing-sicher; fail-closed),
     *   (b) eingeloggter SUPER-USER (core.admin) - ueber die FRONTEND-Session, weil
     *       /administrator ja gerade gesperrt ist,
     *   (c) das Entfernen NUR per bestaetigtem POST + gueltigem Joomla-CSRF-Token
     *       (GET zeigt nur die Seite -> schuetzt gegen Mail-Prefetch UND CSRF).
     * Danach Einmal-Token verbraucht + Warn-Mail (Zeit/IP). Beendet die Anfrage bei
     * Behandlung; sonst return. Fail-open (nie 500).
     *
     * @return void
     */
    private function htpRecover()
    {
        // 1) Dedizierter Parameter da? (fast immer NEIN -> sofort raus, 0 Overhead)
        $token = '';
        $get   = &$this->sg('get');
        $post  = &$this->sg('post');

        if (isset($get['htp_recover']) && is_string($get['htp_recover'])) {
            $token = $get['htp_recover'];
        } elseif (isset($post['htp_recover']) && is_string($post['htp_recover'])) {
            $token = $post['htp_recover'];
        }

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

        // 2) Strenge Form ZUERST (64 Hex). Ungueltig -> normale Seite laedt weiter.
        if (!preg_match('~^[a-f0-9]{64}$~i', $token)) {
            return;
        }

        // 3) Nur die Site-Anwendung (nie Backend/CLI/API). Der Link zeigt bewusst aufs
        //    FRONTEND, weil /administrator gesperrt ist.
        $app = $this->getApp();

        if (!$app) {
            return;
        }

        if (method_exists($app, 'isClient')) {
            if (!$app->isClient('site')) {
                return;
            }
        } elseif (method_exists($app, 'isAdmin') && $app->isAdmin()) {
            return;
        }

        // 4) Engine laden (nur in diesem seltenen, formgeprueften Pfad).
        $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
            . '/components/com_htprotect/core/bootstrap.php';

        if (!is_file($bootstrap)) {
            return;
        }

        require_once $bootstrap;

        if (!class_exists('HtpConfig') || !class_exists('HtpGuard')) {
            return;
        }

        if (function_exists('htp_load_lang')) {
            htp_load_lang();
        }

        $cfg  = HtpConfig::load();
        $hash = isset($cfg['guard_recover']) ? (string) $cfg['guard_recover'] : '';
        $base = function_exists('htp_trusted_base') ? htp_trusted_base(isset($cfg['unsub_base']) ? $cfg['unsub_base'] : '') : '';

        // 5) FAKTOR 1: Token gegen den gespeicherten Hash (timing-sicher, fail-closed).
        //    Kein aktiver Link ODER falsches Token -> generische "ungueltig"-Seite, NICHTS wird getan.
        if (!HtpGuard::recoverTokenValid($token, $hash)) {
            $this->recoverRender('invalid', $token, $base);

            return;
        }

        // 6) FAKTOR 2: eingeloggter SUPER-USER (core.admin, Frontend-Session).
        //    Kein Super-User -> Login-Hinweis-Seite; NICHTS wird entfernt.
        if (!$this->recoverIsSuperUser($app)) {
            $this->recoverRender('login', $token, $base);

            return;
        }

        // 7) Entfernen NUR per bestaetigtem POST + gueltigem Joomla-CSRF-Token.
        $isPost = isset($_SERVER['REQUEST_METHOD']) && strtoupper((string) $_SERVER['REQUEST_METHOD']) === 'POST';

        if ($isPost && isset($post['htp_recover_confirm']) && $this->recoverCsrfOk()) {
            $res = HtpGuard::remove();

            // Einmal-Nutzung: Token IMMER verbrauchen (auch wenn remove teils scheitert).
            HtpConfig::saveKeys(array('guard_recover' => ''));

            // KEINE eigene "entfernt"-Mail: der Hintergrund-Waechter ueberwacht
            // administrator/.htaccess (managedFiles) und meldet die Aenderung ohnehin -
            // eine zweite Mail hier waere doppelt.
            $this->recoverRender(!empty($res['success']) ? 'done' : 'failed', $token, $base);

            return;
        }

        // 8) Sonst: Bestaetigungs-Seite (Super-User bestaetigt das Entfernen aktiv).
        $this->recoverRender('confirm', $token, $base);
    }

    /**
     * Ist der aktuelle (Frontend-)Besucher ein eingeloggter Joomla-Super-User (core.admin)?
     * Fail-closed: jeder Zweifel -> false (dann wird NICHTS entfernt).
     *
     * @param   object  $app  Site-Anwendung
     *
     * @return boolean
     */
    private function recoverIsSuperUser($app)
    {
        try {
            $user = ($app && method_exists($app, 'getIdentity')) ? $app->getIdentity() : null;

            if (!$user && class_exists('Joomla\\CMS\\Factory')) {
                $a = Joomla\CMS\Factory::getApplication();
                $user = ($a && method_exists($a, 'getIdentity')) ? $a->getIdentity() : null;
            }

            if (!$user && class_exists('JFactory')) {
                $user = JFactory::getUser();
            }

            return $user && empty($user->guest) && method_exists($user, 'authorise')
                && $user->authorise('core.admin');
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }
    }

    /**
     * Joomla-CSRF-Token des POST pruefen. Fail-CLOSED: ohne verifizierbares Token wird
     * NICHT entfernt.
     *
     * @return boolean
     */
    private function recoverCsrfOk()
    {
        try {
            if (class_exists('Joomla\\CMS\\Session\\Session')
                && method_exists('Joomla\\CMS\\Session\\Session', 'checkToken')) {
                return (bool) Joomla\CMS\Session\Session::checkToken('post');
            }

            if (class_exists('JSession') && method_exists('JSession', 'checkToken')) {
                return (bool) JSession::checkToken('post');
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return false;
    }

    /**
     * Aktuelles Joomla-CSRF-Formular-Token (Feldname). '' wenn nicht ermittelbar.
     *
     * @return string
     */
    private function recoverFormToken()
    {
        try {
            if (class_exists('Joomla\\CMS\\Session\\Session')
                && method_exists('Joomla\\CMS\\Session\\Session', 'getFormToken')) {
                return (string) Joomla\CMS\Session\Session::getFormToken();
            }

            if (class_exists('JSession') && method_exists('JSession', 'getFormToken')) {
                return (string) JSession::getFormToken();
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return '';
    }

    /**
     * Minimal-HTML der Recovery-Seite ausgeben und die Anfrage beenden. Alles Dynamische
     * escaped; das Token ist bereits strikt formgeprueft.
     *
     * @param   string  $state  invalid|login|confirm|done|failed
     * @param   string  $token  formgeprueftes Token (64 Hex)
     * @param   string  $base   vertrauenswuerdige Site-Basis
     *
     * @return void
     */
    private function recoverRender($state, $token, $base)
    {
        while (ob_get_level() > 0) {
            @ob_end_clean();
        }

        if (!headers_sent()) {
            header('Content-Type: text/html; charset=utf-8');
            header('X-Robots-Tag: noindex, nofollow');
            header('Referrer-Policy: no-referrer');
        }

        $esc        = 'htp_esc';
        $actionBase = ($base !== '' && $base !== '/') ? rtrim((string) $base, '/') : (function_exists('htp_trusted_base') ? htp_trusted_base() : '');
        $action     = $esc(rtrim($actionBase, '/') . '/index.php');

        if ($state === 'done') {
            // Nach dem Entfernen direkt weiter ins Backend - der Nutzer muss die URL nicht
            // selbst tippen. /administrator ist jetzt wieder ohne Extra-Abfrage erreichbar.
            $adminUrl = rtrim($actionBase, '/') . '/administrator/';
            $inner = '<h1>' . $esc(htp_t('COM_HTPROTECT_RECOVER_DONE_TITLE')) . '</h1>'
                . '<p>' . $esc(htp_t('COM_HTPROTECT_RECOVER_DONE')) . '</p>'
                . '<p><a class="htpu-btn" href="' . $esc($adminUrl) . '">' . $esc(htp_t('COM_HTPROTECT_RECOVER_DONE_BTN')) . '</a></p>'
                . '<p class="htpu-dim">' . $esc(htp_t('COM_HTPROTECT_RECOVER_DONE_TIP')) . '</p>';
        } elseif ($state === 'failed') {
            $inner = '<h1>' . $esc(htp_t('COM_HTPROTECT_RECOVER_FAILED_TITLE')) . '</h1>'
                . '<p>' . $esc(htp_t('COM_HTPROTECT_RECOVER_FAILED')) . '</p>';
        } elseif ($state === 'invalid') {
            $inner = '<h1>' . $esc(htp_t('COM_HTPROTECT_RECOVER_INVALID_TITLE')) . '</h1>'
                . '<p>' . $esc(htp_t('COM_HTPROTECT_RECOVER_INVALID')) . '</p>'
                . '<p class="htpu-dim">' . $esc(htp_t('COM_HTPROTECT_RECOVER_FTP_HINT')) . '</p>';
        } elseif ($state === 'login') {
            // Nach dem Login direkt zurueck auf DIESE Recovery-Seite: Joomlas eigener
            // return-Parameter (base64-kodiert; der Login-Controller decodiert ihn und
            // laesst per Uri::isInternal NUR site-interne Ziele zu). So landet der Nutzer
            // sofort auf der Bestaetigungs-Seite statt auf seiner Profilseite.
            $ret      = base64_encode('index.php?htp_recover=' . $token);
            $loginUrl = rtrim($actionBase, '/') . '/index.php?option=com_users&view=login&return=' . rawurlencode($ret);
            $inner    = '<h1>' . $esc(htp_t('COM_HTPROTECT_RECOVER_LOGIN_TITLE')) . '</h1>'
                . '<p>' . $esc(htp_t('COM_HTPROTECT_RECOVER_LOGIN')) . '</p>'
                . '<p><a class="htpu-btn" href="' . $esc($loginUrl) . '">' . $esc(htp_t('COM_HTPROTECT_RECOVER_LOGIN_BTN')) . '</a></p>'
                . '<p class="htpu-dim">' . $esc(htp_t('COM_HTPROTECT_RECOVER_LOGIN_INFO')) . '</p>';
        } else {
            $formToken = $this->recoverFormToken();
            $inner     = '<h1>' . $esc(htp_t('COM_HTPROTECT_RECOVER_CONFIRM_TITLE')) . '</h1>'
                . '<p>' . $esc(htp_t('COM_HTPROTECT_RECOVER_CONFIRM_Q')) . '</p>'
                . '<form method="post" action="' . $action . '">'
                . '<input type="hidden" name="htp_recover" value="' . $esc($token) . '">'
                . '<input type="hidden" name="htp_recover_confirm" value="1">'
                . ($formToken !== '' ? '<input type="hidden" name="' . $esc($formToken) . '" value="1">' : '')
                . '<button type="submit" class="htpu-btn">' . $esc(htp_t('COM_HTPROTECT_RECOVER_CONFIRM_BTN')) . '</button>'
                . '</form>'
                . '<p class="htpu-dim">' . $esc(htp_t('COM_HTPROTECT_RECOVER_CONFIRM_INFO')) . '</p>';
        }

        $css = 'body{margin:0;background:#eef1f6;color:#1c2230;font:16px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif}'
            . '.htpu-wrap{max-width:520px;margin:8vh auto;padding:0 18px}'
            . '.htpu-card{background:#fff;border:1px solid #d9dee7;border-radius:14px;padding:26px 28px;box-shadow:0 1px 3px rgba(20,30,50,.08)}'
            . '.htpu-card h1{font-size:1.25rem;margin:0 0 12px}.htpu-card p{margin:0 0 14px}'
            . '.htpu-btn{display:inline-block;border:0;border-radius:8px;background:#1f9d57;color:#fff;font-weight:600;font-size:1rem;padding:11px 20px;cursor:pointer;text-decoration:none}'
            . '.htpu-btn:hover{background:#188048}.htpu-dim{color:#5b6473;font-size:.86rem}'
            . '.htpu-foot{color:#8b91a5;font-size:.8rem;text-align:center;margin-top:16px}';

        $html = '<!DOCTYPE html><html lang="' . $esc(htp_lang()) . '"><head><meta charset="utf-8">'
            . '<meta name="viewport" content="width=device-width,initial-scale=1">'
            . '<meta name="robots" content="noindex,nofollow">'
            . '<title>' . $esc(htp_t('COM_HTPROTECT_RECOVER_TITLE')) . '</title><style>' . $css . '</style></head>'
            . '<body><div class="htpu-wrap"><div class="htpu-card">' . $inner . '</div>'
            . '<div class="htpu-foot">HTProtect &middot; website-bereinigung.de</div></div></body></html>';

        echo $html;

        $app = $this->getApp();

        if ($app && method_exists($app, 'close')) {
            $app->close();
        }

        exit;
    }

    /**
     * Auto-Update-FEEDBACK aus der Post-Update-Mail. SURFACE-MINIMAL (default-AUS):
     * nur wenn der dedizierte Parameter htp_fb vorhanden UND alle Felder formgueltig
     * sind, wird ueberhaupt etwas geladen. GET zeigt nur die Bestaetigungsseite
     * (meldet NICHTS - schuetzt vor Mail-Prefetch); erst der bestaetigte POST schickt
     * EIN anonymes Zeugen-Signal an radar. Unfaelschbar (HMAC mit eigener Domaene),
     * REIN MELDEND: aendert NICHTS an der Seite (kein Rollback, kein Aussetzen).
     * Beendet die Anfrage bei Behandlung; sonst return.
     *
     * @return void
     */
    private function htpFeedback()
    {
        // 1) Dedizierter Parameter da? (fast immer NEIN -> sofort raus, 0 Overhead)
        $get  = &$this->sg('get');
        $post = &$this->sg('post');

        $hasGet  = isset($get['htp_fb']) && is_string($get['htp_fb']);
        $hasPost = isset($post['htp_fb']) && is_string($post['htp_fb']);

        if (!$hasGet && !$hasPost) {
            return;
        }

        $src    = $hasPost ? $post : $get;
        $result = (string) $src['htp_fb'];
        $key    = (isset($src['htp_fbe']) && is_string($src['htp_fbe'])) ? $src['htp_fbe'] : '';
        $ver    = (isset($src['htp_fbv']) && is_string($src['htp_fbv'])) ? $src['htp_fbv'] : '';
        $token  = (isset($src['htp_fbt']) && is_string($src['htp_fbt'])) ? $src['htp_fbt'] : '';
        $fbl    = (isset($src['htp_fbl']) && is_string($src['htp_fbl'])) ? $src['htp_fbl'] : '';

        // 2) Strenge Form ZUERST (vor jedem Laden). Ungueltig -> normale Seite weiter.
        if (($result !== '0' && $result !== '1')
            || !preg_match('~^[a-z0-9_.\-]*\|[a-z0-9_.\-]+\|[a-z0-9_.\-]*$~', $key)
            || !preg_match('~^[0-9]+(\.[0-9]+){0,3}([a-z0-9.\-]{0,12})?$~i', $ver)
            || !preg_match('~^[A-Za-z0-9_-]{16,64}$~', $token)) {
            return;
        }

        // 3) Nur die Site-Anwendung (nie Backend/CLI/API).
        $app = $this->getApp();

        if (!$app) {
            return;
        }

        if (method_exists($app, 'isClient')) {
            if (!$app->isClient('site')) {
                return;
            }
        } elseif (method_exists($app, 'isAdmin') && $app->isAdmin()) {
            return;
        }

        // 4) Engine laden (nur in diesem seltenen, formgeprueften Pfad).
        $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
            . '/components/com_htprotect/core/bootstrap.php';

        if (!is_file($bootstrap)) {
            return;
        }

        require_once $bootstrap;

        if (!class_exists('HtpConfig') || !class_exists('HtpFeedback')) {
            return;
        }

        // Report-Seite in der Sprache der ausloesenden Mail (htp_fbl) rendern statt in
        // der aktiven Frontend-/Site-Sprache. Sonst erscheint z. B. bei englischer Mail
        // die Seite deutsch (Site-Default). Fallback: aktive Sprache via htp_load_lang.
        if (class_exists('HtpMailLang') && $fbl !== '' && preg_match('~^[a-z]{2,3}(-[A-Za-z]{2})?$~', $fbl)) {
            HtpMailLang::loadTag($fbl);
        } elseif (function_exists('htp_load_lang')) {
            htp_load_lang();
        }

        $cfg    = HtpConfig::load();
        $secret = isset($cfg['unsub_secret']) ? (string) $cfg['unsub_secret'] : '';
        $base   = function_exists('htp_trusted_base') ? htp_trusted_base(isset($cfg['unsub_base']) ? $cfg['unsub_base'] : '') : '';

        // 5) Token pruefen. Ungueltig -> generische Seite (keine Enumeration).
        if ($secret === '' || !HtpFeedback::verify($result, $key, $ver, $token, $secret)) {
            $this->feedbackRender('invalid', $result, $key, $ver, $token, $base, null, '', $fbl);

            return;
        }

        // 6) GET = nur Bestaetigungsseite (meldet NICHTS -> Mail-Prefetch unschaedlich).
        $isConfirm = (isset($_SERVER['REQUEST_METHOD']) && strtoupper((string) $_SERVER['REQUEST_METHOD']) === 'POST')
            && isset($post['htp_fb_confirm']);

        if (!$isConfirm) {
            $this->feedbackRender('confirm', $result, $key, $ver, $token, $base, null, '', $fbl);

            return;
        }

        // Bei "Problem" optionale Beschreibung mitnehmen (sanitisieren: Steuerzeichen
        // raus, gedeckelt) - wird anonym mitgemeldet, damit auf radar sichtbar ist, WAS
        // gemeldet wurde.
        $msg = '';

        if ($result === '0' && isset($post['htp_fb_msg']) && is_string($post['htp_fb_msg'])) {
            $msg = (string) preg_replace('~[\x00-\x08\x0b\x0c\x0e-\x1f]+~', ' ', $post['htp_fb_msg']);
            $msg = trim(function_exists('mb_substr') ? mb_substr($msg, 0, 800) : substr($msg, 0, 800));
        }

        // 7) Bestaetigt: Danke-Seite SOFORT ausliefern, dann (Verbindung getrennt)
        //    anonym an radar melden -> der Klickende wartet nie auf die radar-Antwort.
        $this->feedbackRender($result === '1' ? 'done_ok' : 'done_bad', $result, $key, $ver, $token, $base, $cfg, $msg, $fbl);
    }

    /**
     * Das anonyme Zeugen-Signal an radar schicken - EINMAL je Erweiterung+Version+
     * Urteil (lokale Dedup im State-Ordner, damit wiederholte Klicks nicht spammen).
     * Schickt KEINE Datei, KEINEN Host, KEINE IP - nur Element/Version/Urteil. Fail-soft.
     *
     * @param   array   $cfg     Konfiguration
     * @param   string  $result  '1' (ok) | '0' (Problem)
     * @param   string  $key     Erweiterungs-Schluessel type|element|folder
     * @param   string  $ver     Zielversion
     *
     * @return void
     */
    private function reportFeedback($cfg, $result, $key, $ver, $msg = '')
    {
        $dir      = $this->statePath();
        $dedupKey = strtolower($key) . '@' . $ver . '@' . (string) $result;
        $file     = ($dir !== '') ? $dir . '/htprotect_feedback.json' : '';
        $map      = array();

        if ($file !== '') {
            $j = json_decode((string) @file_get_contents($file), true);

            if (is_array($j)) {
                $map = $j;
            }
        }

        // Schon gemeldet (gleiche Erweiterung+Version+Urteil) -> nicht erneut senden.
        if (isset($map[$dedupKey])) {
            return;
        }

        if (class_exists('HtpSwarm') && method_exists('HtpSwarm', 'reportSignal')) {
            try {
                $meta = array(
                    'kind'    => 'auto-update',
                    'key'     => strtolower($key),
                    'element' => HtpFeedback::elementOf($key),
                    'type'    => HtpFeedback::typeOf($key),
                    'version' => (string) $ver,
                    'verdict' => ($result === '1') ? 'ok' : 'broken',
                );

                if ((string) $msg !== '') {
                    $meta['msg'] = (string) $msg; // gemeldete Problem-Beschreibung (anonym)
                }

                HtpSwarm::reportSignal($cfg, 'fb', $meta);
            } catch (Exception $e) {
                // egal - Feedback ist best-effort
            } catch (Throwable $e) {
                // egal
            }
        }

        if ($file !== '') {
            $map[$dedupKey] = (int) time();

            // Gedeckelt halten (aelteste raus), damit die Datei nicht waechst.
            if (count($map) > 200) {
                asort($map);
                $map = array_slice($map, -200, null, true);
            }

            @file_put_contents($file, json_encode($map), LOCK_EX);
            @chmod($file, 0600);
        }
    }

    /**
     * Minimal-HTML der Feedback-Seite ausgeben und die Anfrage beenden. Alles
     * Dynamische ist escaped; Felder sind bereits strikt formgeprueft. REIN MELDEND:
     * keine Seite wird veraendert. Bei 'done_bad' zeigen wir einen Backend-Link zum
     * MANUELLEN Pruefen/Zurueckrollen (der Admin entscheidet selbst).
     *
     * @param   string  $state   confirm|done_ok|done_bad|invalid
     * @param   string  $result  '1' | '0'
     * @param   string  $key     Erweiterungs-Schluessel
     * @param   string  $ver     Version
     * @param   string  $token      formgeprueftes Token
     * @param   string  $base       vertrauenswuerdige Basis-URL
     * @param   array   $reportCfg  !== null -> NACH dem Ausliefern (Verbindung getrennt)
     *                              anonym an radar melden (nur done-States)
     *
     * @return void
     */
    private function feedbackRender($state, $result, $key, $ver, $token, $base = '', $reportCfg = null, $msg = '', $fbl = '')
    {
        while (ob_get_level() > 0) {
            @ob_end_clean();
        }

        if (!headers_sent()) {
            header('Content-Type: text/html; charset=utf-8');
            header('X-Robots-Tag: noindex, nofollow');
            header('Referrer-Policy: no-referrer');
        }

        $esc     = 'htp_esc';
        $element = $esc(HtpFeedback::elementOf($key));
        $verE    = $esc($ver);

        if ($state === 'invalid') {
            $inner = '<h1>' . $esc(htp_t('COM_HTPROTECT_FB_INVALID_TITLE')) . '</h1>'
                . '<p>' . $esc(htp_t('COM_HTPROTECT_FB_INVALID')) . '</p>';
        } elseif ($state === 'done_ok') {
            $inner = '<h1>' . $esc(htp_t('COM_HTPROTECT_FB_DONE_OK_TITLE')) . '</h1>'
                . '<p>' . $esc(htp_t('COM_HTPROTECT_FB_DONE_OK')) . '</p>';
        } elseif ($state === 'done_bad') {
            $inner = '<h1>' . $esc(htp_t('COM_HTPROTECT_FB_DONE_BAD_TITLE')) . '</h1>'
                . '<p>' . $esc(htp_t('COM_HTPROTECT_FB_DONE_BAD')) . '</p>'
                . '<p class="htpu-dim">' . $esc(htp_t('COM_HTPROTECT_FB_DONE_BAD_HINT')) . '</p>';
        } else {
            // confirm: was wird gemeldet? + POST-Knopf (nur ein bewusster Klick meldet).
            $verdict    = ($result === '1')
                ? $esc(htp_t('COM_HTPROTECT_FB_VERDICT_OK'))
                : $esc(htp_t('COM_HTPROTECT_FB_VERDICT_BAD'));
            $q          = str_replace(
                array('%1$s', '%2$s', '%3$s'),
                array('<strong>' . $element . '</strong>', '<strong>' . $verE . '</strong>', '<strong>' . $verdict . '</strong>'),
                $esc(htp_t('COM_HTPROTECT_FB_CONFIRM_Q'))
            );

            // Bei "Problem" ein optionales Textfeld: WAS war das Problem? Der Text wird
            // anonym mitgemeldet, damit auf radar sichtbar ist, was gemeldet wurde.
            $msgField = ($result === '0')
                ? '<p style="margin:14px 0 6px"><label for="htpfbm">' . $esc(htp_t('COM_HTPROTECT_FB_PROBLEM_LABEL')) . '</label></p>'
                    . '<textarea id="htpfbm" name="htp_fb_msg" rows="4" maxlength="800" class="htpu-ta" placeholder="' . $esc(htp_t('COM_HTPROTECT_FB_PROBLEM_PH')) . '"></textarea>'
                : '';

            $actionBase = ($base !== '' && $base !== '/') ? $base : htp_trusted_base();
            $action     = $esc(rtrim($actionBase, '/') . '/index.php');
            $inner      = '<h1>' . $esc(htp_t('COM_HTPROTECT_FB_PAGE_TITLE')) . '</h1>'
                . '<p>' . $q . '</p>'
                . '<form method="post" action="' . $action . '">'
                . '<input type="hidden" name="htp_fb" value="' . $esc($result) . '">'
                . '<input type="hidden" name="htp_fbe" value="' . $esc($key) . '">'
                . '<input type="hidden" name="htp_fbv" value="' . $esc($ver) . '">'
                . '<input type="hidden" name="htp_fbt" value="' . $esc($token) . '">'
                . '<input type="hidden" name="htp_fb_confirm" value="1">'
                . (($fbl !== '' && preg_match('~^[a-z]{2,3}(-[A-Za-z]{2})?$~', $fbl)) ? '<input type="hidden" name="htp_fbl" value="' . $esc($fbl) . '">' : '')
                . $msgField
                . '<p style="margin-top:16px"><button type="submit" class="htpu-btn">' . $esc(htp_t('COM_HTPROTECT_FB_CONFIRM_BTN')) . '</button></p>'
                . '</form>'
                . '<p class="htpu-dim">' . $esc(htp_t('COM_HTPROTECT_FB_CONFIRM_INFO')) . '</p>';
        }

        $css = 'body{margin:0;background:#eef1f6;color:#1c2230;font:16px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif}'
            . '.htpu-wrap{max-width:520px;margin:8vh auto;padding:0 18px}'
            . '.htpu-card{background:#fff;border:1px solid #d9dee7;border-radius:14px;padding:26px 28px;box-shadow:0 1px 3px rgba(20,30,50,.08)}'
            . '.htpu-card h1{font-size:1.25rem;margin:0 0 12px}.htpu-card p{margin:0 0 14px}'
            . '.htpu-btn{display:inline-block;border:0;border-radius:8px;background:#1f9d57;color:#fff;font-weight:600;font-size:1rem;padding:11px 20px;cursor:pointer}'
            . '.htpu-btn:hover{background:#188048}.htpu-dim{color:#5b6473;font-size:.86rem}'
            . '.htpu-ta{width:100%;box-sizing:border-box;border:1px solid #c9ced6;border-radius:8px;padding:10px 12px;font:inherit;font-size:.95rem;color:#1c2230;resize:vertical;min-height:88px}'
            . '.htpu-ta:focus{outline:none;border-color:#1f9d57}'
            . '.htpu-foot{color:#8b91a5;font-size:.8rem;text-align:center;margin-top:16px}';

        $html = '<!DOCTYPE html><html lang="' . $esc(htp_lang()) . '"><head><meta charset="utf-8">'
            . '<meta name="viewport" content="width=device-width,initial-scale=1">'
            . '<meta name="robots" content="noindex,nofollow">'
            . '<title>' . $esc(htp_t('COM_HTPROTECT_FB_PAGE_TITLE')) . '</title><style>' . $css . '</style></head>'
            . '<body><div class="htpu-wrap"><div class="htpu-card">' . $inner . '</div>'
            . '<div class="htpu-foot">HTProtect &middot; website-bereinigung.de</div></div></body></html>';

        echo $html;

        // Danke-Seite ist raus -> Verbindung trennen (FPM) und ERST DANN melden, damit
        // der Klickende nicht auf die radar-Antwort wartet. Nicht-FPM: laeuft inline.
        if ($reportCfg !== null) {
            $this->backgroundFlush();
            $this->reportFeedback($reportCfg, $result, $key, $ver, $msg);
        }

        $app = $this->getApp();

        if ($app && method_exists($app, 'close')) {
            $app->close();
        }

        exit;
    }

    /**
     * Mini-WAF: billiger Vorabtest ohne Engine, voller Signatur-Check nur
     * bei verdaechtigen Anfragen.
     *
     * @return void
     */
    private function htpWaf()
    {
        $app = $this->getApp();

        // Nur die Site-Anwendung (die Exploits sind unauthentifiziert/Frontend)
        if (!$app) {
            return;
        }

        // Site- ODER API-Anwendung (die /api-Anwendung war bisher ausgenommen -
        // jetzt mitgeprueft, z. B. fuer den API-Konfig-Leak).
        if (method_exists($app, 'isClient')) {
            if (!$app->isClient('site') && !$app->isClient('api')) {
                return;
            }
        } elseif (method_exists($app, 'isAdmin') && $app->isAdmin()) {
            return;
        }

        // ── Billiger Vorabtest (kein Engine-Load) ──
        // Rohe Request-Bags einmal gekapselt holen (siehe sg()).
        $get   = &$this->sg('get');
        $post  = &$this->sg('post');
        $files = &$this->sg('files');

        $opt = '';

        if (isset($get['option']) && is_string($get['option'])) {
            $opt = strtolower($get['option']);
        } elseif (isset($post['option']) && is_string($post['option'])) {
            $opt = strtolower($post['option']);
        }

        // SP-Page-Builder-catid-SQLi: eigener, sehr guenstiger Pfad (nur bei com_sppagebuilder; fuer allen
        // anderen Verkehr ist es nur DIESER eine String-Vergleich). Die Signatur-WAF sieht nur $_POST, nicht
        // den JSON-Body (php://input), in dem der Payload steckt.
        if ($opt === 'com_sppagebuilder') {
            $this->htpSppbGuard($app);
        }

        $query = isset($_SERVER['QUERY_STRING']) ? (string) $_SERVER['QUERY_STRING'] : '';
        $path  = $this->rawPath();

        // Schnellster Pfad, komplett ohne I/O: hartkodierte Marker + Kern-Optionen.
        $candidate = ($opt === 'com_ajax' || $opt === 'com_jce')
            || $this->hasMarker($query)
            || ($path !== '' && $this->hasMarker($path))
            || $this->postHasMarker();

        // Datengetrieben: macht NACHGELADENE Signaturen fuer neue Komponenten
        // ueberhaupt erst auswertbar. Die winzige Trigger-Datei wird nur bei
        // Anfragen MIT Eingabe gelesen (und pro Request gecacht) - reine
        // SEF-Seitenaufrufe (kein Query/POST) bleiben voellig I/O-frei.
        if (!$candidate && ($query !== '' || !empty($post))) {
            $candidate = $this->triggerHit($opt, $query, $path);
        }

        // Ein Datei-Upload ist immer pruefenswert (generischer Upload-Schutz),
        // auch ohne Signatur-Marker.
        $hasUpload = !empty($files);

        if (!$candidate && !$hasUpload) {
            return;
        }

        // ── Voller Check (Engine laden) ──
        if (!$this->loadEngine() || !class_exists('HtpConfig') || !class_exists('HtpDefs')) {
            return;
        }

        $cfg = HtpConfig::load();

        if (empty($cfg['x_shield'])) {
            return;
        }

        // ── Generischer Upload-Schutz (komponentenunabhaengig): ein nicht
        //    eingeloggter Besucher, der eine ausfuehrbare Datei hochlaedt, ist
        //    nie legitim -> sofort blocken. Faengt unauthentifizierte
        //    Upload-Luecken ab (z. B. iCagenda submit), ohne je Komponente eine
        //    eigene Signatur zu brauchen. Super-User/eingeloggte unberuehrt. ──
        if ($hasUpload && HtpDefs::hasExecutableUpload($files) && !$this->isLoggedIn($app)) {
            $this->logBlock('upload-executable', false);
            $this->block($app);

            return;
        }

        if (!$candidate) {
            // Nur ein (harmloser) Upload, keine verdaechtige Anfrage -> fertig.
            return;
        }

        $postString = $this->postString();
        $uriPath    = $this->relPath($cfg);

        $sig = HtpDefs::wafMatch($cfg, $query, $postString, $uriPath);

        if (empty($sig)) {
            return;
        }

        // Durchsetzung pro Signatur (siehe HtpDefs::wafShouldBlock):
        // - Super-User werden nie blockiert (nur protokolliert).
        // - Muster ohne legitime Verwendung (scope 'all') blocken ALLE
        //   anderen - auch eingeloggte/neu registrierte Nutzer.
        // - Muster mit moeglicher legitimer Nutzung (scope 'guest', z. B.
        //   JCE-Editor) blocken nur Gaeste.
        $loggedIn = $this->isLoggedIn($app);
        $isSuper  = $this->isSuperUser($app);
        $block    = HtpDefs::wafShouldBlock($sig, $loggedIn, $isSuper);

        $this->logBlock($sig['id'], !$block);

        if ($block) {
            $this->block($app);
        }
    }

    /**
     * Gezielter Echtzeit-Schutz gegen die SP-Page-Builder-SQLi (aktiv ausgenutzt, Hersteller-Patch erst mit
     * 6.8.0 angekuendigt): die Endpunkte articles.loadMoreArticles und dynamic_content lesen einen JSON-Body
     * (php://input) und reichen 'catid' ROH in getCategories() -> IN(...) einer SQL-Abfrage. 'catid' ist in
     * Joomla IMMER eine Kategorie-ID (Integer). Diese Wache liest DENSELBEN Body und blockt, sobald ein
     * catid-Wert keine reine Zahl ist - denn eine reine Zahl kann im IN(...) nie aus dem Zahlenkontext
     * ausbrechen (injektionssicher) und legitime Integer-Listen passieren unveraendert (FP-frei, Feature bleibt
     * heil). Deckt beide Vektoren. Die Signatur-WAF sieht den JSON-Body NICHT (nur $_POST), daher hier separat.
     * JSON-POST -> php://input bleibt fuer die Komponente lesbar. Strikt fail-open (jede Ausnahme -> kein Block).
     *
     * @return void
     */
    private function htpSppbGuard($app)
    {
        // Aufrufer (htpWaf) hat bereits geprueft: Site/API-Anwendung UND option === com_sppagebuilder.
        // Nur POST kann den JSON-Body tragen, den der verwundbare Pfad (php://input) liest.
        $method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper((string) $_SERVER['REQUEST_METHOD']) : '';

        if ($method !== 'POST') {
            return;
        }

        // Rohen Body lesen (bei JSON-POST bleibt php://input fuer die Komponente lesbar). Groesse hart kappen;
        // ohne 'catid' im Body gibt es nichts zu pruefen -> sofort raus (kein json_decode).
        $raw = @file_get_contents('php://input');

        if (!is_string($raw) || $raw === '' || strlen($raw) > 262144 || strpos($raw, 'catid') === false) {
            return;
        }

        $data = json_decode($raw, true);

        if (!is_array($data) || !$this->htpSppbCatidBad($data, 0)) {
            return; // kein JSON-Objekt ODER alle catid-Werte sauber -> durchlassen
        }

        // Ab hier: mindestens ein catid-Wert ist KEINE reine Zahl -> eindeutiger Injektionsversuch.
        // Schutzschild-Schalter respektieren (wie die WAF); Super-User nie aussperren (nur protokollieren).
        if (!$this->loadEngine() || !class_exists('HtpConfig')) {
            return; // Engine nicht ladbar -> fail-open
        }

        $cfg = HtpConfig::load();

        if (empty($cfg['x_shield'])) {
            return; // Exploit-Schild bewusst aus -> nicht blocken
        }

        $isSuper = $this->isSuperUser($app);
        $this->logBlock('sppb-catid-sqli', $isSuper);

        if (!$isSuper) {
            $this->block($app);
        }
    }

    /**
     * Rekursiv: enthaelt die Struktur ein 'catid' mit einem nicht-numerischen Wert? dynamic_content bettet
     * die Filter (mit catid) als JSON-STRING ein -> solche Strings werden aufgedeckt und mitgeprueft. Tiefe
     * hart gekappt (<= 6).
     *
     * @param   mixed  $node   Teilbaum
     * @param   int    $depth  aktuelle Tiefe
     *
     * @return boolean
     */
    private function htpSppbCatidBad($node, $depth)
    {
        if ($depth > 6) {
            return false;
        }

        if (is_string($node)) {
            $t = ltrim($node);

            if ($t !== '' && ($t[0] === '{' || $t[0] === '[') && strpos($node, 'catid') !== false) {
                $inner = json_decode($node, true);

                if (is_array($inner)) {
                    return $this->htpSppbCatidBad($inner, $depth + 1);
                }
            }

            return false;
        }

        if (!is_array($node)) {
            return false;
        }

        foreach ($node as $k => $v) {
            if ($k === 'catid' && !$this->htpSppbIntsOnly($v)) {
                return true;
            }

            if ($this->htpSppbCatidBad($v, $depth + 1)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Ist $v eine reine Zahl bzw. eine Liste/Verschachtelung aus reinen Zahlen? Leerer String/null/bool =
     * kein Angriff (ok). Eine reine Zahl kann in einem IN(...) nie aus dem Zahlenkontext ausbrechen ->
     * injektionssicher UND FP-frei (legitime Kategorie-IDs sind immer Zahlen).
     *
     * @param   mixed  $v  Wert
     *
     * @return boolean
     */
    private function htpSppbIntsOnly($v)
    {
        if (is_int($v) || is_float($v) || is_null($v) || is_bool($v)) {
            return true;
        }

        if (is_string($v)) {
            $s = trim($v);

            return $s === '' || is_numeric($s);
        }

        if (is_array($v)) {
            foreach ($v as $e) {
                if (!$this->htpSppbIntsOnly($e)) {
                    return false;
                }
            }

            return true;
        }

        return false; // Objekt o. ae. -> nicht als Kategorie-ID legitim
    }

    /**
     * Schnelltest auf typische Angriffs-Marker in einem String.
     *
     * @param   string  $s  Zu prüfender String
     *
     * @return boolean
     */
    private function hasMarker($s)
    {
        if ($s === '' || !is_string($s)) {
            return false;
        }

        $low = strtolower($s);

        foreach (array(
            '../', '..%2f', '%2e%2e', '%252e', '://', '..%5c', '..\\',
            'php:', 'phar:', 'data:', 'expect:', 'glob:', 'zlib:',
        ) as $needle) {
            if (strpos($low, $needle) !== false) {
                return true;
            }
        }

        // Serialisiertes PHP-Objekt (O:/C:<Ziffer>) - nur wenn ueberhaupt ein
        // ':' vorkommt (haelt den Normalfall billig). Lineare, sichere Regex.
        if (strpos($low, ':') !== false && preg_match('/(?<![a-z0-9])[oc]:[0-9]/', $low) === 1) {
            return true;
        }

        return false;
    }

    /**
     * Schnelltest auf Marker in den POST-Werten (nur wenn POST vorhanden).
     *
     * @return boolean
     */
    private function postHasMarker()
    {
        $post = &$this->sg('post');

        if (empty($post) || !is_array($post)) {
            return false;
        }

        $count = 0;

        return $this->scanForMarker($post, 0, $count);
    }

    /**
     * Rekursiver Marker-Scan ueber (verschachtelte) POST-Werte. Hart gekappt
     * (Tiefe <= 3, <= 100 Werte), damit auch in jform[...]-Strukturen
     * versteckte Payloads sichtbar werden - ohne Kostenrisiko.
     *
     * @param   array    $arr    Werte
     * @param   int      $depth  aktuelle Tiefe
     * @param   int      $count  bereits geprüfte Werte (Referenz)
     *
     * @return boolean
     */
    private function scanForMarker($arr, $depth, &$count)
    {
        if ($depth > 3) {
            return false;
        }

        foreach ($arr as $value) {
            if (++$count > 100) {
                return false;
            }

            if (is_array($value)) {
                if ($this->scanForMarker($value, $depth + 1, $count)) {
                    return true;
                }
            } elseif (is_string($value) && $this->hasMarker($value)) {
                return true;
            }
        }

        return false;
    }

    /**
     * POST-Parameter als "a.b.c=value&..."-String flach klopfen (auch
     * verschachtelt), damit Signaturen z. B. in jform[...]-Feldern greifen.
     * Hart gekappt (Tiefe <= 3, <= 100 Werte, <= 8 KB).
     *
     * @return string
     */
    private function postString()
    {
        $post = &$this->sg('post');

        if (empty($post) || !is_array($post)) {
            return '';
        }

        $pairs = array();
        $len   = 0;
        $count = 0;

        $this->flattenPost($post, '', $pairs, $len, $count, 0);

        return implode('&', $pairs);
    }

    /**
     * Helfer fuer postString(): rekursiv flach klopfen mit harten Kappen.
     *
     * @return void
     */
    private function flattenPost($arr, $prefix, &$pairs, &$len, &$count, $depth)
    {
        if ($depth > 3) {
            return;
        }

        foreach ($arr as $key => $value) {
            if ($count >= 100 || $len > 8192) {
                return;
            }

            $path = $prefix === '' ? (string) $key : $prefix . '.' . $key;

            if (is_array($value)) {
                $this->flattenPost($value, $path, $pairs, $len, $count, $depth + 1);
            } elseif (is_scalar($value)) {
                $pair = $path . '=' . $value;
                $len += strlen($pair) + 1;

                if ($len > 8192) {
                    return;
                }

                $pairs[] = $pair;
                $count++;
            }
        }
    }

    /**
     * Angefragten Pfad ohne Site-Präfix und führenden Slash.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return string
     */
    private function relPath($cfg)
    {
        $uri  = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '';
        $path = (string) parse_url($uri, PHP_URL_PATH);
        $rel  = ltrim(rawurldecode($path), '/');

        $sp = isset($cfg['site_path']) ? ltrim((string) $cfg['site_path'], '/') : '';

        if ($sp !== '' && stripos($rel . '/', $sp . '/') === 0) {
            $rel = ltrim((string) substr($rel, strlen($sp)), '/');
        }

        return $rel;
    }

    /**
     * Angefragter Pfad (dekodiert), ohne Engine/Config - fuer den Vorabtest.
     *
     * @return string
     */
    private function rawPath()
    {
        $uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '';

        return rawurldecode((string) parse_url($uri, PHP_URL_PATH));
    }

    /**
     * Vorabtest gegen die datengetriebene Trigger-Datei (von der Komponente
     * geschrieben). Macht NACHGELADENE Signaturen fuer neue Komponenten
     * ueberhaupt erst auswertbar. KEIN Engine-/DB-Zugriff.
     *
     * @param   string  $opt    Komponenten-Option (lowercase) oder ''
     * @param   string  $query  Roher Query-String
     * @param   string  $path   Dekodierter Pfad
     *
     * @return boolean
     */
    private function triggerHit($opt, $query, $path)
    {
        $t = $this->loadTriggers();

        if (!is_array($t)) {
            return false;
        }

        // Sicherheits-Fallback: eine Signatur, die der Vorfilter nicht abbilden
        // kann -> jede Anfrage mit Eingabe voll pruefen lassen.
        if (!empty($t['always'])) {
            return true;
        }

        if ($opt !== '' && !empty($t['opts']) && is_array($t['opts']) && in_array($opt, $t['opts'], true)) {
            return true;
        }

        if (!empty($t['needles']) && is_array($t['needles'])) {
            $hay = strtolower($query . ' ' . $path);

            foreach ($t['needles'] as $needle) {
                if (is_string($needle) && $needle !== '' && strpos($hay, $needle) !== false) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Trigger-Datei (klein, OS-gecacht) einmal je Request lesen.
     *
     * @return array|null
     */
    private function loadTriggers()
    {
        static $loaded = false;
        static $data   = null;

        if ($loaded) {
            return $data;
        }

        $loaded = true;
        $tmp    = $this->statePath();

        if ($tmp === '') {
            $data = null;

            return $data;
        }

        $file = $tmp . '/htprotect_waf.triggers';

        if (!is_file($file)) {
            $data = null;

            return $data;
        }

        $json = json_decode((string) @file_get_contents($file), true);
        $data = is_array($json) ? $json : null;

        return $data;
    }

    /**
     * Anfrage mit 403 beenden, bevor die Komponente läuft.
     *
     * @param   object  $app  Anwendung
     *
     * @return void
     */
    private function block($app)
    {
        if (!headers_sent()) {
            header('HTTP/1.1 403 Forbidden');
            header('Content-Type: text/plain; charset=utf-8');
            header('X-HTProtect: blocked');
        }

        if (function_exists('htp_load_lang')) {
            htp_load_lang();
        }

        echo function_exists('htp_t')
            ? htp_t('COM_HTPROTECT_WAF_BLOCKED')
            : '403 Forbidden - blocked by HTProtect.';

        if ($app && method_exists($app, 'close')) {
            $app->close();
        }

        exit;
    }

    /**
     * Geblockte Anfrage protokollieren (begrenzte Datei im Temp-Ordner).
     *
     * @param   string   $sigId    Signatur-ID
     * @param   boolean  $logOnly  true = nur protokolliert (Super-User)
     *
     * @return void
     */
    private function logBlock($sigId, $logOnly)
    {
        $tmp = $this->statePath();

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

        $file = $tmp . '/htprotect_waf.log';

        $entry = json_encode(array(
            't'    => function_exists('htp_local_time') ? htp_local_time(time(), 'Y-m-d H:i:s') : date('Y-m-d H:i:s'),
            'sig'  => preg_replace('/[^a-z0-9\-]/', '', (string) $sigId),
            'ip'   => isset($_SERVER['REMOTE_ADDR']) ? substr((string) $_SERVER['REMOTE_ADDR'], 0, 45) : '',
            'uri'  => substr(isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '', 0, 300),
            'mode' => $logOnly ? 'log' : 'block',
        )) . "\n";

        // Datei begrenzen (Flood-Schutz): bei >64 KB auf die letzten Zeilen kürzen
        if (is_file($file) && @filesize($file) > 65536) {
            $lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

            if (is_array($lines)) {
                @file_put_contents($file, implode("\n", array_slice($lines, -200)) . "\n", LOCK_EX);
            }
        }

        @file_put_contents($file, $entry, FILE_APPEND | LOCK_EX);
    }

    /**
     * Bootstrap der Engine laden (einmalig).
     *
     * @return boolean
     */
    private function loadEngine()
    {
        if (defined('HTP_ENGINE_OK')) {
            return HTP_ENGINE_OK;
        }

        $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
            . '/components/com_htprotect/core/bootstrap.php';

        if (!is_file($bootstrap)) {
            return false;
        }

        try {
            require_once $bootstrap;
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }

        return defined('HTP_ENGINE_OK') ? HTP_ENGINE_OK : class_exists('HtpDefs');
    }

    /**
     * Aktuelle Anwendung holen.
     *
     * @return object|null
     */
    private function getApp()
    {
        if (isset($this->app) && $this->app) {
            return $this->app;
        }

        try {
            if (class_exists('Joomla\\CMS\\Factory')) {
                return Joomla\CMS\Factory::getApplication();
            }

            if (class_exists('JFactory')) {
                return JFactory::getApplication();
            }
        } catch (Exception $e) {
            return null;
        }

        return null;
    }

    /**
     * Uninstall-Interception (Backend): Klickt jemand in Erweiterungen -> Verwalten auf
     * "Deinstallieren" und ist EINE der vier HTProtect-Registrierungen (Komponente ODER
     * eines der 3 Plugins) markiert, wird VOR dem Uninstall auf den Aufraeum-Assistenten
     * (Hilfe & Support) umgeleitet. Strikt fail-open; greift NUR exakt auf diesem einen
     * Task (com_installer / manage.remove / html). onAfterRoute + redirect sind ueberall
     * (J2.5-6) Kern -> versionsneutral. Kein Loop-Flag noetig: der Assistent deinstalliert
     * selbst (kein com_installer-Request mehr).
     *
     * @return void
     */
    private function htpUninstallIntercept()
    {
        $app = $this->getApp();

        if (!$app || !method_exists($app, 'redirect')) {
            return;
        }

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

        // Input VERSIONSNEUTRAL: J4+ getInput(), Joomla 3 die ->input-Eigenschaft.
        // (Vorher hier faelschlich auf getInput() gegated -> das gibt es erst ab J4, also
        // fiel J3 sofort raus und die Interception feuerte nie. DAS war der J3-Bug.)
        $input = null;

        if (method_exists($app, 'getInput')) {
            $input = $app->getInput();          // J4+
        } else {
            // Joomla 3: ->input ist zugreifbar, aber als MAGISCHE Eigenschaft (__get) -
            // Direktzugriff greift oeffentlich UND magisch; isset() wuerde bei einer
            // magischen Eigenschaft fehlschlagen. Fehlgriff faengt is_object() unten ab.
            try {
                $input = $app->input;
            } catch (Exception $e) {
                $input = null;
            } catch (Throwable $e) {
                $input = null;
            }
        }

        // NUR is_object pruefen: getCmd/getInt/... sind bei Joomlas Input MAGISCHE
        // Methoden (__call) -> method_exists() liefert dafuer false, ein Guard darauf
        // wuerde die Interception IMMER abbrechen (genau das war der Regressions-Bug).
        // Der magische Aufruf $input->getCmd(...) unten funktioniert trotzdem.
        if (!is_object($input)) {
            return;
        }

        // Exakt der Uninstall-Task von com_installer (Vollseiten-POST, kein AJAX/API).
        if ($input->getCmd('option', '') !== 'com_installer'
            || $input->getCmd('task', '') !== 'manage.remove'
            || $input->getCmd('format', 'html') !== 'html') {
            return;
        }

        // Markierte Extension-IDs.
        $cid = $input->get('cid', array(), 'array');

        if (!is_array($cid) || empty($cid)) {
            return;
        }

        $cid = array_filter(array_map('intval', $cid));

        // Ist eine der 4 HTProtect-IDs dabei? Wenn nicht -> normaler Uninstall.
        $own = $this->htpOwnExtensionIds();

        if (empty($own) || !array_intersect($cid, $own)) {
            return;
        }

        // -> Aufraeum-Assistent (klappt via htp_uninstall auf + flasht + Hinweis).
        $app->redirect('index.php?option=com_htprotect&sec=help&htp_uninstall=1#htp-uninstall');
    }

    /**
     * Die 4 Extension-IDs von HTProtect (Komponente + 3 Plugins). Leeres Array bei
     * Fehler -> fail-open (keine Interception).
     *
     * @return array
     */
    private function htpOwnExtensionIds()
    {
        $db = $this->getDb();

        if (!$db) {
            return array();
        }

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName('extension_id'))
                ->from($db->quoteName('#__extensions'))
                ->where(
                    $db->quoteName('element') . ' = ' . $db->quote('com_htprotect')
                    . ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
                    . ' AND ' . $db->quoteName('element') . ' IN ('
                    . $db->quote('htprotectwatch') . ', ' . $db->quote('htprotect') . ', ' . $db->quote('htpautoupdate') . '))'
                );
            $db->setQuery($q);

            return array_filter(array_map('intval', (array) $db->loadColumn()));
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }
    }

    /**
     * DB-Handle versionsneutral (J4+ Container / JFactory / Factory::getDbo).
     *
     * @return object|null
     */
    private function getDb()
    {
        try {
            if (class_exists('Joomla\\CMS\\Factory')) {
                if (method_exists('Joomla\\CMS\\Factory', 'getContainer')) {
                    $c = Joomla\CMS\Factory::getContainer();

                    if ($c && method_exists($c, 'has') && $c->has('DatabaseDriver')) {
                        return $c->get('DatabaseDriver');
                    }
                }

                if (method_exists('Joomla\\CMS\\Factory', 'getDbo')) {
                    return Joomla\CMS\Factory::getDbo();
                }
            }

            if (class_exists('JFactory')) {
                return JFactory::getDbo();
            }
        } catch (Exception $e) {
            return null;
        } catch (Throwable $e) {
            return null;
        }

        return null;
    }

    /**
     * Aktuellen Benutzer holen (versionsneutral).
     *
     * @param   object  $app  Anwendung
     *
     * @return object|null
     */
    private function getUser($app)
    {
        try {
            if ($app && method_exists($app, 'getIdentity')) {
                $user = $app->getIdentity();

                if ($user) {
                    return $user;
                }
            }

            if (class_exists('JFactory') && method_exists('JFactory', 'getUser')) {
                return JFactory::getUser();
            }
        } catch (Exception $e) {
            return null;
        }

        return null;
    }

    /**
     * Ist ein Benutzer eingeloggt (kein Gast)?
     *
     * @param   object  $app  Anwendung
     *
     * @return boolean
     */
    private function isLoggedIn($app)
    {
        $user = $this->getUser($app);

        if (!$user) {
            return false;
        }

        // Joomla-User: ->guest = 1 bei Gaesten, ->id > 0 bei Eingeloggten
        if (isset($user->guest)) {
            return empty($user->guest);
        }

        return isset($user->id) && (int) $user->id > 0;
    }

    /**
     * Ist ein eingeloggter Super-User unterwegs (nie aussperren)?
     *
     * @param   object  $app  Anwendung
     *
     * @return boolean
     */
    private function isSuperUser($app)
    {
        $user = $this->getUser($app);

        return $user && method_exists($user, 'authorise') && $user->authorise('core.admin');
    }

    /**
     * Echtzeit-Konten-Wache: feuert NUR beim tatsaechlichen Speichern eines
     * Benutzers (Backend-User-Manager, com_users-API, Profil-Plugins) - nie beim
     * normalen Seitenaufruf, also 0,0 Last im Hot-Path. Betrifft der Save ein
     * privilegiertes Konto (neuer Super-User, Gruppen-/E-Mail-/Passwortwechsel),
     * wird der gedrosselte Hintergrund-Tick fuer den naechsten Lauf entsperrt,
     * damit Konten-Pruefung + Benachrichtigung SOFORT statt erst nach bis zu
     * 15 min laufen - OHNE den Speichervorgang selbst zu verlangsamen (kein
     * synchroner Mailversand hier). Eine direkte DB-Aendererung feuert KEIN Event
     * und bleibt dem periodischen Abgleich ueberlassen. Strikt fail-open.
     *
     * @param   mixed  $user     Gespeicherte Benutzerdaten (Array oder Objekt)
     * @param   mixed  $isnew    Neuer Datensatz? (ignoriert)
     * @param   mixed  $success  Save erfolgreich?
     * @param   mixed  $msg      Meldung (ignoriert)
     *
     * @return void
     */
    public function onUserAfterSave($user, $isnew = false, $success = true, $msg = '')
    {
        try {
            $this->reactToPrivilegedChange($user, (bool) $success);
        } catch (Exception $e) {
            // Der Schutz darf die Site nie stoeren.
        } catch (Throwable $e) {
            // PHP 7+ (Error/TypeError).
        }
    }

    /**
     * Echtzeit-Konten-Wache beim Loeschen eines Benutzers (analog onUserAfterSave;
     * haelt die Lage aktuell, falls ein privilegiertes Konto entfernt wurde).
     *
     * @param   mixed  $user     Geloeschte Benutzerdaten
     * @param   mixed  $success  Erfolgreich?
     * @param   mixed  $msg      Meldung (ignoriert)
     *
     * @return void
     */
    public function onUserAfterDelete($user, $success = true, $msg = '')
    {
        try {
            $this->reactToPrivilegedChange($user, (bool) $success);
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still
        }
    }

    /**
     * Login-Zeitpunkt-Schutz (Admin-Schutz 2.6.9): stoppt einen eindeutig EINGESCHLEUSTEN Super-User
     * SOFORT bei seinem ersten Backend-Login - so entfaellt das ~15-Minuten-Fenster bis zum naechsten
     * Wachlauf, in dem ein Angreifer sein direkt in die DB gesetztes Admin-Konto sonst nutzen koennte.
     * NUR Administrator-Logins (kein Aufwand bei Frontend-Member-Logins). Strikt fail-open: ein
     * legitimes Login darf hier NIE scheitern. Die eigentliche Entscheidung/Aktion (nur Null-Anlage-
     * Datum/Kampagne, nie Baseline/Whitelist/id 1/letzter Admin, reversibel + Session-Killung) liegt
     * in HtpAccounts::guardLogin.
     *
     * @param   array  $options  Login-Optionen (ggf. mit 'user')
     *
     * @return void
     */
    public function onUserAfterLogin($options = array())
    {
        try {
            $app = null;

            if (class_exists('Joomla\\CMS\\Factory')) {
                $app = \Joomla\CMS\Factory::getApplication();
            } elseif (class_exists('JFactory')) {
                $app = JFactory::getApplication();
            }

            // Nur Backend-Logins pruefen - Super-User-Missbrauch spielt sich im Administrator ab.
            // isClient() gibt es ab J3.7; auf J2.5-3.6 traegt isAdmin() (sonst waere der Schutz dort stumm).
            $isBackend = false;

            if ($app && method_exists($app, 'isClient')) {
                $isBackend = (bool) $app->isClient('administrator');
            } elseif ($app && method_exists($app, 'isAdmin')) {
                $isBackend = (bool) $app->isAdmin();
            }

            if (!$isBackend) {
                return;
            }

            $uid = 0;

            if (is_array($options) && isset($options['user']) && is_object($options['user']) && isset($options['user']->id)) {
                $uid = (int) $options['user']->id;
            }

            if ($uid <= 0) {
                $u = null;

                if (class_exists('Joomla\\CMS\\Factory')) {
                    $u = \Joomla\CMS\Factory::getUser();
                } elseif (class_exists('JFactory')) {
                    $u = JFactory::getUser();
                }

                $uid = (is_object($u) && isset($u->id)) ? (int) $u->id : 0;
            }

            if ($uid <= 1) {
                return;
            }

            $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
                . '/components/com_htprotect/core/bootstrap.php';

            if (!is_file($bootstrap)) {
                return;
            }

            require_once $bootstrap;

            if (!class_exists('HtpConfig') || !class_exists('HtpAccounts') || !method_exists('HtpAccounts', 'guardLogin')) {
                return;
            }

            $cfg = HtpConfig::load();

            if (empty($cfg['accounts_watch'])) {
                return;
            }

            $res = HtpAccounts::guardLogin($cfg, $uid);

            if (is_array($res) && !empty($res['id'])) {
                // Konto ist jetzt gesperrt + entprivilegiert + Sessions gekillt (in guardLogin).
                // Wachlauf anstossen, damit die Meldung (acct_stopped) zeitnah rausgeht.
                $this->openTickGate();
            }
        } catch (Exception $e) {
            // Login nie stoeren.
        } catch (Throwable $e) {
        }
    }

    /**
     * §7b: Backup VOR einem manuellen Erweiterungs-Update. Joomlas Installer feuert
     * dieses Event BEVOR die neuen Dateien die alten ueberschreiben - bei Route
     * 'update' steht der alte Stand also noch auf der Platte. Eine Zeile in die
     * Modul-Logik (HtpAutoUpdate), die dort entscheidet (Schalter default AUS,
     * >= J5.4) und das Vorab-Backup macht. NIE blockierend, strikt fail-open: das
     * vom Admin ausgeloeste Update darf nie an uns scheitern.
     *
     * @param   mixed  $method    Route ('install'|'update'|'discover_install')
     * @param   mixed  $type      Erweiterungstyp
     * @param   mixed  $manifest  Manifest (SimpleXMLElement)
     * @param   mixed  $eid       Extension-ID (bei Updates gesetzt)
     *
     * @return void
     */
    public function onExtensionBeforeInstall($method = null, $type = null, $manifest = null, $eid = null)
    {
        // §7c: Ed25519-Verifizierung von HTProtects EIGENEM Update-Paket. Laeuft mit dem
        // AKTUELLEN (noch nicht ersetzten, vertrauenswuerdigen) Code VOR dem Kopieren der
        // neuen Dateien - eine Selbstpruefung durch den NEUEN Code waere umgehbar. Gibt IMMER
        // ein Verdikt zurueck (wirft NIE selbst). NUR bei EINDEUTIGER Manipulation ('tampered')
        // wird die Installation abgebrochen; fuer ein valides Paket ('ok') ODER Unklarheit
        // ('unclear': kein Manifest / kein libsodium / Fehler) passiert NICHTS -> ein valides
        // Update wird strukturell NIE blockiert (keine Side-Effects auf legitime Updates).
        if ($this->htpVerifyOwnPackage($method, $manifest, $eid) === 'tampered') {
            throw new \RuntimeException('HTProtect: Die Installation wurde aus Sicherheitsgründen abgebrochen. Dieses Paket trägt keinen gültigen Echtheitsnachweis von HTProtect. Bitte aktualisieren Sie HTProtect nur über die offizielle Aktualisierung.');
        }

        // §7b: bisheriges Vorab-Backup vor MANUELLEM Update (fail-open, unveraendert).
        try {
            if ((string) $method !== 'update') {
                return; // nur echte Updates, keine Neuinstallation
            }

            $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
                . '/components/com_htprotect/core/bootstrap.php';

            if (!is_file($bootstrap)) {
                return;
            }

            require_once $bootstrap;

            if (class_exists('HtpAutoUpdate')) {
                HtpAutoUpdate::manualBackupFromEvent($type, $manifest, $eid);
            }
        } catch (Exception $e) {
            // fail-open: das manuelle Update darf nie an uns scheitern
        } catch (Throwable $e) {
            // still (PHP 7+)
        }
    }

    /**
     * Verifiziert HTProtects EIGENES Update-Paket (Ed25519, HtpDefs::verifyPackage) am
     * entpackten Quellordner - BEVOR die Dateien kopiert werden. Gibt ein Verdikt zurueck
     * und wirft NIE selbst (der Aufrufer entscheidet ueber den Abbruch-Throw). Fail-OPEN
     * bei jedem Fehler/Unklarheit, damit ein valides Update NIE an uns scheitert.
     *
     * @param   mixed  $method    Route ('update' relevant)
     * @param   mixed  $manifest  Paket-Manifest (ungenutzt; Reserve)
     * @param   mixed  $eid       Extension-ID (ungenutzt; Quelle ist massgeblich)
     *
     * @return string  'ok' | 'unclear' | 'tampered' | 'skip'
     */
    private function htpVerifyOwnPackage($method, $manifest, $eid)
    {
        try {
            if ((string) $method !== 'update') {
                return 'skip';
            }

            $src = $this->htpInstallerSource();

            if ($src === '' || !is_dir($src)) {
                return 'skip'; // keine Quelle greifbar -> keine Aussage
            }

            // Nur HTProtects EIGENES Paket anfassen - andere Erweiterungen bleiben unberuehrt.
            if (!$this->htpSourceIsHtprotect($src)) {
                return 'skip';
            }

            $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
                . '/components/com_htprotect/core/bootstrap.php';

            if (!is_file($bootstrap)) {
                return 'unclear';
            }

            require_once $bootstrap;

            // Alt-Code ohne Verifizierer (< 2.4.12) -> nicht blockieren.
            if (!class_exists('HtpDefs') || !method_exists('HtpDefs', 'verifyPackage')) {
                return 'unclear';
            }

            $res    = HtpDefs::verifyPackage($src);
            $reason = (is_array($res) && isset($res['reason'])) ? (string) $res['reason'] : '';

            // Reines verifyPackage-Ergebnis zur Installations-Entscheidung verschaerfen:
            // ein WEGGELASSENES Signatur-Manifest ist bei pruefFAEHIGer Umgebung und
            // signatur-pflichtiger Paketversion Manipulation (sonst umgeht ein Angreifer
            // mit gekapertem Update-Server die Signatur durch simples Weglassen).
            // packageInstallDecision haelt legitime Updates (ab 2.4.12 immer signiert)
            // strukturell frei -> keine Side-Effects. Alt-Code ohne die Methode faellt
            // auf das bisherige Verhalten zurueck. Massgeblich ist dabei NICHT allein
            // die vom Paket SELBST deklarierte Version (die kann ein Angreifer klein
            // luegen), sondern die hoehere aus installiertem Stand und Paketangabe -
            // siehe htpPolicyVersion().
            if (method_exists('HtpDefs', 'packageInstallDecision')) {
                $status = HtpDefs::packageInstallDecision(
                    $res,
                    $this->htpPolicyVersion($src),
                    function_exists('sodium_crypto_sign_verify_detached')
                );
            } else {
                $status = (is_array($res) && isset($res['status'])) ? (string) $res['status'] : 'unclear';
            }

            if ($status === 'ok') {
                return 'ok';
            }

            // 'tampered' ODER 'unclear' -> alarmieren (fail-soft); nur 'tampered' blockiert.
            $this->htpPkgAlert($status, $reason);

            return $status === 'tampered' ? 'tampered' : 'unclear';
        } catch (Exception $e) {
            return 'unclear';
        } catch (Throwable $e) {
            return 'unclear';
        }
    }

    /**
     * Pfad des entpackten Paket-Quellordners aus Joomlas laufendem Installer (J3-J6).
     *
     * @return string  '' wenn nicht ermittelbar
     */
    private function htpInstallerSource()
    {
        try {
            if (class_exists('Joomla\\CMS\\Installer\\Installer')) {
                $inst = Joomla\CMS\Installer\Installer::getInstance();

                if ($inst && method_exists($inst, 'getPath')) {
                    return (string) $inst->getPath('source');
                }
            }

            if (class_exists('JInstaller')) {
                $inst = JInstaller::getInstance();

                if ($inst && method_exists($inst, 'getPath')) {
                    return (string) $inst->getPath('source');
                }
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return '';
    }

    /**
     * Ist der entpackte Quellordner HTProtects eigenes Paket? (Manifest vorhanden +
     * traegt die com_htprotect-Kennung.)
     *
     * @param   string  $src  Quellordner
     *
     * @return boolean
     */
    private function htpSourceIsHtprotect($src)
    {
        $mf = $src . '/htprotect.xml';

        if (!is_file($mf)) {
            return false;
        }

        $xml = @file_get_contents($mf);

        return is_string($xml) && strpos($xml, 'option=com_htprotect') !== false;
    }

    /**
     * Massgebliche Version fuer die Signatur-Policy: die HOEHERE aus dem auf dieser
     * Site tatsaechlich installierten Stand und der vom Paket selbst deklarierten
     * Version.
     *
     * Grund: Die Paketangabe stammt aus dem Kandidatenpaket SELBST und ist damit
     * Angreifer-kontrolliert. Wer die Signaturpflicht umgehen wollte, musste bisher
     * nur ein unsigniertes Paket als alte Vor-Signatur-Version ausgeben. Laeuft hier
     * bereits ein signatur-pflichtiger Stand (>= FIRST_SIGNED_VERSION), MUSS jedes
     * HTProtect-Paket ein gueltiges Signaturmanifest tragen - eine Rueckstufung auf
     * ein unsigniertes Alt-Paket ist dann ausgeschlossen.
     *
     * @param   string  $src  Quellordner
     *
     * @return  string  z. B. '2.4.12'; '' nur wenn beide Seiten unbekannt sind
     */
    private function htpPolicyVersion($src)
    {
        $pkg = $this->htpSourcePkgVersion($src);
        $ins = $this->htpInstalledVersion();

        if ($ins === '') {
            return $pkg;
        }

        if ($pkg === '' || version_compare($ins, $pkg, '>')) {
            return $ins;
        }

        return $pkg;
    }

    /**
     * Echte installierte Version der Komponente - aus dem XML-Manifest AUF DER PLATTE
     * (nicht aus dem evtl. veralteten DB-Cache). Der Aufrufer hat die Engine bereits
     * geladen; Alt-Code ohne den Helfer liefert '' (dann greift wie bisher allein die
     * Paketangabe).
     *
     * @return  string  '' wenn nicht ermittelbar
     */
    private function htpInstalledVersion()
    {
        try {
            if (class_exists('HtpDefs') && method_exists('HtpDefs', 'onDiskVersion')) {
                return (string) HtpDefs::onDiskVersion('component', 'com_htprotect', '', 1);
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return '';
    }

    /**
     * Ab dieser Version traegt JEDES offizielle Paket ein Signaturmanifest. Konstante
     * aus der Engine, mit Rueckfall fuer den Fall, dass sie (noch) nicht geladen ist.
     *
     * @return  string
     */
    private function htpSignedThreshold()
    {
        if (class_exists('HtpDefs') && defined('HtpDefs::FIRST_SIGNED_VERSION')) {
            return (string) constant('HtpDefs::FIRST_SIGNED_VERSION');
        }

        return '2.4.12';
    }

    /**
     * Deklarierte Version des entpackten Quellordners aus dessen htprotect.xml.
     * Akzeptiert x.y und x.y.z (auch x.y.z.a). Ist die Angabe NICHT lesbar, gilt sie
     * bewusst als NICHT aelter als die Signatur-Schwelle - eine unleserliche Version
     * darf die Signaturpflicht nicht mehr aushebeln (frueher fail-open).
     *
     * @param   string  $src  Quellordner
     *
     * @return  string  z. B. '2.4.12'
     */
    private function htpSourcePkgVersion($src)
    {
        $mf = $src . '/htprotect.xml';

        if (!is_file($mf)) {
            return $this->htpSignedThreshold();
        }

        $xml = @file_get_contents($mf);

        if (!is_string($xml)
            || !preg_match('~<version>\\s*([0-9]+\\.[0-9]+(?:\\.[0-9]+)*)\\s*</version>~i', $xml, $m)) {
            return $this->htpSignedThreshold();
        }

        return $m[1];
    }

    /**
     * Fail-soft-Alarm an die hinterlegte Alarm-Mail, wenn die Paket-Signatur nicht ok ist
     * (blockiert ODER unklar). Rein meldend.
     *
     * @param   string  $status  'tampered' | 'unclear'
     * @param   string  $reason  Kurzgrund aus verifyPackage
     *
     * @return void
     */
    private function htpPkgAlert($status, $reason)
    {
        try {
            if (!class_exists('HtpConfig') || !function_exists('htp_send_mail')) {
                return;
            }

            $cfg = HtpConfig::load();
            $to  = isset($cfg['notify_email']) ? trim((string) $cfg['notify_email']) : '';

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

            if (class_exists('HtpMailLang')) {
                HtpMailLang::load($cfg);
            }

            $key  = ($status === 'tampered') ? 'COM_HTPROTECT_PKGSIG_ALERT_BLOCKED' : 'COM_HTPROTECT_PKGSIG_ALERT_UNCLEAR';
            $body = htp_t($key, $reason) . "\n\n" . htp_t('COM_HTPROTECT_MAIL_SIGNATURE');
            $err  = '';

            @htp_send_mail($to, htp_t('COM_HTPROTECT_PKGSIG_ALERT_SUBJECT'), $body, '', $err);
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    // ─────────────────────────────────────────────────────────────────
    //  Notfall-Wiederherstellung nach abgebrochenem Selbst-Update (§7)
    // ─────────────────────────────────────────────────────────────────

    /**
     * Findet einen verwaisten Selbst-Update-Marker (HTProtect hat sich selbst
     * aktualisiert, der Lauf brach mitten drin ab und die neue Version koennte
     * kaputt sein) und stellt die Vorversion aus dem Backup wieder her - OHNE die
     * (evtl. kaputte) Engine zu laden, in plain PHP, mit data/-Ouroboros-Schutz.
     *
     * @return void
     */
    private function htpSelfHeal()
    {
        $dir = $this->statePath();

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

        $marker = $dir . '/htprotect_autoupdate.inprogress';

        if (!is_file($marker)) {
            return; // Normalfall: ein stat, dann raus.
        }

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

        // Nur Selbst-Update-Marker (com_htprotect) - alles andere kann die normale
        // Engine-Recovery erledigen (com_htprotect ist dort nicht kaputt).
        if (!is_array($m) || empty($m['self'])) {
            return;
        }

        // Nur eingreifen, wenn der urspruengliche Lauf sicher tot ist (Marker alt).
        $started = isset($m['started']) ? (int) $m['started'] : 0;

        if ($started === 0 || (time() - $started) < self::SELFHEAL_DELAY) {
            return;
        }

        // Heal-Sperre (kein Doppellauf). Veraltete Sperre nach 10 min freigeben.
        $lock = $dir . '/htprotect_autoupdate.heal';
        $fp   = @fopen($lock, 'x');

        if ($fp === false) {
            if (is_file($lock) && (time() - (int) @filemtime($lock)) > 600) {
                @unlink($lock);
            }

            return;
        }

        @fwrite($fp, (string) time());
        @fclose($fp);

        $restored = false;

        try {
            $restored = self::htpEmergencyRestoreSelf(
                $dir,
                isset($m['eid']) ? (int) $m['eid'] : 0,
                isset($m['snap']) ? (string) $m['snap'] : ''
            );
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still
        }

        if ($restored) {
            // Breadcrumb: die (wieder gesunde) Engine protokolliert + meldet es.
            @file_put_contents($dir . '/htprotect_autoupdate.healed', json_encode(array(
                't'    => time(),
                'name' => isset($m['name']) ? (string) $m['name'] : 'HTProtect',
                'from' => isset($m['from']) ? (string) $m['from'] : '',
                'to'   => isset($m['to']) ? (string) $m['to'] : '',
            )));
            @unlink($marker);
        }

        @unlink($lock);
    }

    /**
     * Self-contained Notfall-Restore von com_htprotect aus einem Snapshot (plain
     * PHP, KEINE Engine-Abhaengigkeit - laeuft auch wenn der neue Code fatal ist).
     * Stellt nur die festen com_htprotect-Pfade wieder her und laesst den
     * data/-Ordner (= die Backups selbst) garantiert unberuehrt.
     *
     * @param   string  $stateDir  Datenordner (= data/, der Ausschluss)
     * @param   int     $eid       Extension-ID
     * @param   string  $snap      Snapshot-ID
     *
     * @return boolean
     */
    public static function htpEmergencyRestoreSelf($stateDir, $eid, $snap)
    {
        $eid = (int) $eid;

        if ($eid <= 0 || !preg_match('~^\d{14}-[A-Za-z0-9._\-]{1,40}$~', (string) $snap)) {
            return false;
        }

        $stateDir = rtrim(str_replace('\\', '/', $stateDir), '/');

        // Snapshot am NEUEN gemeinsamen Ort (administrator/_htprotect_backups/autoupdate) ODER am
        // bisherigen (stateDir/autoupdate) suchen - deckt den 2.5.9-Umzug UND den Fallback-Ort ab.
        // Bewusst ENGINE-FREI (eigene Pfadaufloesung), damit der Notfall-Restore auch ohne die
        // Backup-Engine laeuft (der Sinn dieser Plugin-Kopie: wiederherstellen, wenn das Update die
        // Engine zerlegt hat).
        $adminForBk = defined('JPATH_ADMINISTRATOR')
            ? rtrim(str_replace('\\', '/', JPATH_ADMINISTRATOR), '/')
            : '';
        $newSnap = ($adminForBk !== '') ? ($adminForBk . '/_htprotect_backups/autoupdate/' . $eid . '/' . $snap) : '';
        $oldSnap = $stateDir . '/autoupdate/' . $eid . '/' . $snap;

        $snapDir = ($newSnap !== '' && is_file($newSnap . '/meta.json')) ? $newSnap : $oldSnap;

        if (!is_file($snapDir . '/meta.json')) {
            return false;
        }

        $meta = json_decode((string) @file_get_contents($snapDir . '/meta.json'), true);

        if (!is_array($meta) || empty($meta['self']) || empty($meta['paths']) || !is_array($meta['paths'])) {
            return false;
        }

        $admin = defined('JPATH_ADMINISTRATOR') ? rtrim(str_replace('\\', '/', JPATH_ADMINISTRATOR), '/') : '';
        $site  = defined('JPATH_ROOT') ? rtrim(str_replace('\\', '/', JPATH_ROOT), '/')
            : ($admin !== '' ? dirname($admin) : '');

        if ($admin === '' || $site === '') {
            return false;
        }

        $filesDir = $snapDir . '/files';
        $allowed  = array('components/com_htprotect', 'media/com_htprotect'); // strikte Whitelist
        $ok       = true;

        // Ab 2.5.9 liegt die Selbst-Sicherung als EINE snap.htpz vor (statt `files/`-Baum). Da sie nur
        // geschrieben wurde, wenn ZipArchive vorhanden ist, ist es beim Restore auf demselben Host
        // ebenfalls vorhanden. Ohne snap.htpz greift der Alt-`files/`-Pfad (rueckwaerts-kompatibel).
        $zip   = null;
        $isZip = is_file($snapDir . '/snap.htpz');

        if ($isZip) {
            if (!class_exists('ZipArchive')) {
                return false; // Zip-Snapshot, aber kein Entpacker -> ehrlich scheitern
            }

            $zip = new ZipArchive();

            if ($zip->open($snapDir . '/snap.htpz') !== true) {
                return false;
            }
        }

        foreach ($meta['paths'] as $entry) {
            if (!is_array($entry) || !isset($entry['root'], $entry['rel'])) {
                continue;
            }

            $rel = trim((string) $entry['rel'], '/');

            if (!in_array($rel, $allowed, true)) {
                continue;
            }

            $root    = ($entry['root'] === 'admin') ? 'admin' : 'site';
            $base    = ($root === 'admin') ? $admin : $site;
            $liveAbs = $base . '/' . $rel;

            if (strpos($liveAbs . '/', $base . '/') !== 0) {
                $ok = false;
                continue;
            }

            // 1) Live entfernen - data/ NIE anfassen (Ouroboros-Schutz).
            if (is_dir($liveAbs) || is_file($liveAbs)) {
                if (!self::htpRmExcept($liveAbs, $stateDir)) {
                    $ok = false;
                }
            }

            // 2) Aus dem Snapshot wiederherstellen.
            if (!empty($entry['existed'])) {
                if ($isZip) {
                    if (!self::htpZipRestoreMember($zip, $root . '/' . $rel, $root, $base)) {
                        $ok = false;
                    }
                } else {
                    $src = $filesDir . '/' . $root . '/' . $rel;

                    if (is_dir($src) || is_file($src)) {
                        if (!self::htpCopyTree($src, $liveAbs)) {
                            $ok = false;
                        }
                    }
                }
            }
        }

        if ($zip !== null) {
            $zip->close();
        }

        return $ok;
    }

    /**
     * Engine-freies Zip-Restore: alle Member unter dem Praefix `<root>/<rel>` in den Live-Baum
     * unter $base schreiben (Dateien 0644, leere Ordner 0755). Jedes Ziel wird gegen $base
     * abgesichert; '..'-Member werden verworfen. Spiegelt HtpUpdateBackup::zipRestoreMember, aber
     * ohne Engine-Abhaengigkeit (der Notfall-Pfad muss laufen, wenn das Update die Engine zerlegt hat).
     */
    private static function htpZipRestoreMember($zip, $memberBase, $root, $base)
    {
        $count = $zip->numFiles;
        $pre   = $memberBase . '/';
        $ok    = true;
        $base  = rtrim(str_replace('\\', '/', $base), '/');

        for ($i = 0; $i < $count; $i++) {
            $name = $zip->getNameIndex($i);

            if (!is_string($name) || $name === '') {
                continue;
            }

            if ($name !== $memberBase && strpos($name, $pre) !== 0) {
                continue;
            }

            $isDir     = (substr($name, -1) === '/');
            $memberRel = substr($name, strlen($root) + 1);

            if ($isDir) {
                $memberRel = rtrim($memberRel, '/');
            }

            if ($memberRel === '' || strpos($memberRel, '..') !== false || strpos($memberRel, "\0") !== false) {
                $ok = false;
                continue;
            }

            $dst = $base . '/' . $memberRel;

            if (strpos($dst . '/', $base . '/') !== 0) {
                $ok = false;
                continue;
            }

            if ($isDir) {
                if (!is_dir($dst) && !@mkdir($dst, 0755, true) && !is_dir($dst)) {
                    $ok = false;
                }

                continue;
            }

            $parent = dirname($dst);

            if (!is_dir($parent) && !@mkdir($parent, 0755, true) && !is_dir($parent)) {
                $ok = false;
                continue;
            }

            $data = $zip->getFromIndex($i);

            if ($data === false || @file_put_contents($dst, $data, LOCK_EX) === false) {
                $ok = false;
                continue;
            }

            @chmod($dst, 0644);
        }

        return $ok;
    }

    /** Liegt $path auf/innerhalb von $abs? */
    private static function htpInside($path, $abs)
    {
        $p = rtrim(str_replace('\\', '/', $path), '/');
        $a = rtrim(str_replace('\\', '/', $abs), '/');

        return $a !== '' && $p !== '' && ($p === $a || strpos($p . '/', $a . '/') === 0);
    }

    /** Liegt $abs INNERHALB von $dir (ist $dir Vorfahre)? */
    private static function htpContainsExcl($dir, $abs)
    {
        $d = rtrim(str_replace('\\', '/', $dir), '/');
        $a = rtrim(str_replace('\\', '/', $abs), '/');

        return $d !== '' && $a !== '' && strpos($a . '/', $d . '/') === 0 && $a !== $d;
    }

    /** Rekursiv loeschen, aber $excludeAbs (data/) samt Inhalt unangetastet lassen. */
    private static function htpRmExcept($path, $excludeAbs)
    {
        if ($excludeAbs !== '' && self::htpInside($path, $excludeAbs)) {
            return true; // data/ (Backups) NIE loeschen
        }

        if (is_link($path)) {
            return @unlink($path) || @rmdir($path);
        }

        if (is_file($path)) {
            if (@unlink($path)) {
                return true;
            }

            @chmod(dirname($path), 0755);
            @chmod($path, 0644);

            return @unlink($path);
        }

        if (!is_dir($path)) {
            return true;
        }

        $contains = ($excludeAbs !== '' && self::htpContainsExcl($path, $excludeAbs));

        @chmod($path, 0755);
        $items = @scandir($path);

        if ($items !== false) {
            foreach ($items as $n) {
                if ($n === '.' || $n === '..') {
                    continue;
                }

                self::htpRmExcept($path . '/' . $n, $excludeAbs);
            }
        }

        if ($contains) {
            return true; // Ordner behalten, damit data/ ueberlebt
        }

        if (@rmdir($path)) {
            return true;
        }

        @chmod(dirname($path), 0755);

        return @rmdir($path);
    }

    /**
     * Rekursiv aus dem Snapshot in den Live-Baum wiederherstellen (Symlinks ueberspringen).
     * Backup-Dateien sind KOMPRIMIERT abgelegt (<name>.htpgz, ab 2.5.3); dieser Notfall-Pfad
     * laeuft OHNE die Engine, entpackt also SELBST. Alt-Backups (Klartext ohne Suffix) werden
     * weiter 1:1 kopiert (rueckwaerts-kompatibel).
     */
    private static function htpCopyTree($src, $dst)
    {
        if (is_link($src)) {
            return true;
        }

        if (is_file($src)) {
            $parent = dirname($dst);

            if (!is_dir($parent) && !@mkdir($parent, 0755, true) && !is_dir($parent)) {
                return false;
            }

            return self::htpRestoreFile($src, $dst);
        }

        if (!is_dir($src)) {
            return true;
        }

        if (!is_dir($dst) && !@mkdir($dst, 0755, true) && !is_dir($dst)) {
            return false;
        }

        $items = @scandir($src);

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

        $ok  = true;
        $ext = '.htpgz'; // muss zu HtpUpdateBackup::PACK_EXT passen (hier hart, da Engine evtl. tot)
        $el  = strlen($ext);

        foreach ($items as $n) {
            if ($n === '.' || $n === '..' || is_link($src . '/' . $n)) {
                continue;
            }

            // Komprimierte Backup-Datei (<name>.htpgz) -> Live-Zielname OHNE Suffix; Ordner + Alt-
            // Klartext (ohne Suffix) 1:1.
            $dstName = (substr($n, -$el) === $ext && is_file($src . '/' . $n)) ? substr($n, 0, -$el) : $n;

            if (!self::htpCopyTree($src . '/' . $n, $dst . '/' . $dstName)) {
                $ok = false;
            }
        }

        return $ok;
    }

    /**
     * Eine (evtl. gepackte) Backup-Datei nach $dst schreiben. Endet die Quelle auf .htpgz ->
     * gzip entpacken (self-contained via gzdecode, das CRC+ISIZE validiert); sonst (Alt-Klartext
     * ohne Suffix) direkte Kopie. FAIL-CLOSED: ein .htpgz OHNE zlib oder ein korrupter/trunkierter
     * Blob liefert false (nie komprimierten Muell oder Teildaten als Live-Datei). Die eigenen
     * Connector-Dateien sind klein -> das nicht-streamende gzdecode ist hier speicherunkritisch.
     */
    private static function htpRestoreFile($src, $dst)
    {
        if (substr($src, -6) === '.htpgz') {
            if (!function_exists('gzdecode')) {
                return false;
            }

            $raw = @file_get_contents($src);

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

            $data = @gzdecode($raw);

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

            return @file_put_contents($dst, $data, LOCK_EX) !== false;
        }

        return (bool) @copy($src, $dst);
    }

    /**
     * Pruefen, ob ein User-Save/-Delete ein privilegiertes Konto betrifft, und nur
     * dann den Hintergrund-Tick fuer den naechsten Lauf entsperren. Laeuft
     * ausschliesslich im Kontext eines echten User-Saves (selten, nie im
     * Frontend-Hot-Path); die Engine wird erst hier geladen.
     *
     * @param   mixed    $user     Benutzerdaten (Array oder Objekt)
     * @param   boolean  $success  War die Operation erfolgreich?
     *
     * @return void
     */
    private function reactToPrivilegedChange($user, $success)
    {
        if (!$success) {
            return;
        }

        $u      = is_object($user) ? (array) $user : (is_array($user) ? $user : array());
        $id     = isset($u['id']) ? (int) $u['id'] : 0;
        $groups = (isset($u['groups']) && is_array($u['groups'])) ? array_map('intval', $u['groups']) : array();

        if ($id <= 0 && empty($groups)) {
            return;
        }

        // Engine erst jetzt laden (nur bei einem echten User-Save).
        $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
            . '/components/com_htprotect/core/bootstrap.php';

        if (!is_file($bootstrap)) {
            return;
        }

        require_once $bootstrap;

        if (!class_exists('HtpConfig') || !class_exists('HtpAccounts')) {
            return;
        }

        $cfg = HtpConfig::load();

        if (empty($cfg['accounts_watch'])) {
            return;
        }

        // Relevanz-Vorfilter: betrifft der Save ueberhaupt ein privilegiertes
        // Konto? (a) bereits in der Baseline gefuehrt (war/ist Super-User) ODER
        // (b) aktuell Mitglied einer Super-User-Gruppe. Sonst nichts tun - eine
        // normale Registrierung/Profilaenderung loest keinen Lauf aus.
        $base       = (isset($cfg['account_baseline']) && is_array($cfg['account_baseline'])) ? $cfg['account_baseline'] : array();
        $privileged = ($id > 0 && isset($base[$id]));

        if (!$privileged && !empty($groups)) {
            $admin      = array_map('intval', HtpAccounts::privilegedGroupIds());
            $privileged = (bool) array_intersect($groups, $admin);
        }

        if (!$privileged) {
            return;
        }

        // Diese Aenderung lief ueber Joomlas Nutzerverwaltung (nur dann feuert dieses
        // Event) -> als SANKTIONIERT markieren. Der Admin-Schutz (guardTick) setzt fuer
        // $id dann kurz aus (Karenzfenster) und dreht eine legitime Gruppen-/Sperr-
        // Aenderung des Betreibers NICHT zurueck. Kleine Map id=>ts, feldweise
        // (race-sicher), veraltete Eintraege werden abgeraeumt.
        if ($id > 0 && class_exists('HtpConfig')) {
            $sanct = (isset($cfg['account_sanctioned']) && is_array($cfg['account_sanctioned'])) ? $cfg['account_sanctioned'] : array();
            $sanct[$id] = time();
            $cut        = time() - 3600;

            foreach ($sanct as $sid => $sts) {
                if ((int) $sts < $cut) {
                    unset($sanct[$sid]);
                }
            }

            // Kern-Absicherung: diese ueber Joomla vorgenommene Aenderung ist der neue „Soll"-
            // Zustand -> die Konten-Baseline DAUERHAFT mitfuehren. accept() setzt den Baseline-
            // Eintrag auf den aktuellen Ist-Stand (Block/Gruppen) bzw. entfernt ihn, wenn das Konto
            // kein Super-User mehr ist. Damit dreht der Admin-Schutz (Restore-first) eine legitime
            // Sperrung/Demotion des Betreibers NIE zurueck und stuft einen ueber Joomla befoerderten
            // Admin sofort als bekannt ein (kein Fehlalarm-Containment).
            // Baseline unmittelbar davor FRISCH nachladen (verengt das Race-Fenster mit einem
            // parallelen privilegierten Save auf accept()+saveKeys), dann BEIDE Schluessel in EINEM
            // saveKeys schreiben (statt zwei getrennten Zyklen).
            $freshCfg = HtpConfig::load();

            if (is_array($freshCfg) && isset($freshCfg['account_baseline']) && is_array($freshCfg['account_baseline'])) {
                $cfg['account_baseline'] = $freshCfg['account_baseline'];
            }

            HtpAccounts::accept($cfg, $id);

            HtpConfig::saveKeys(array(
                'account_sanctioned' => $sanct,
                'account_baseline'   => (isset($cfg['account_baseline']) && is_array($cfg['account_baseline'])) ? $cfg['account_baseline'] : array(),
            ));
        }

        // Treffer -> Tick fuer den naechsten Lauf entsperren.
        $this->openTickGate();
    }

    /**
     * Den gedrosselten Hintergrund-Tick fuer den naechsten Lauf entsperren, indem
     * die mtime der State-Datei zurueckgesetzt wird. Das bestehende
     * Per-Request-Gate (htpRun) ist damit beim naechsten Request - im Backend der
     * unmittelbare Redirect-Folgeaufruf - offen und erledigt Konten-Pruefung +
     * Mailversand DETACHED nach der Ausgabe (kein langsameres Speichern). Stoesst
     * KEINE Pruefung an, falls noch keine State-Datei existiert (dann ist das Gate
     * ohnehin offen). Fail-open.
     *
     * @return void
     */
    private function openTickGate()
    {
        $tmp = $this->statePath();

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

        $stateFile = $tmp . '/htprotect_watch.state';

        if (is_file($stateFile)) {
            @touch($stateFile, time() - self::INTERVAL - 1);
        }
    }

    /**
     * Backend-Aufruf-Ausloeser fuer den Deferred Auto-Enroll (Masseninstallation): steht im geschuetzten
     * data/-Ordner ein Pending-Enroll-Marker, wird der Waechter-Tick DIESES Requests vorgezogen
     * (openTickGate) - so laeuft der Enroll beim Oeffnen des Backends, nicht erst nach bis zu 15 Min.
     * Nur Backend; bei fehlendem Marker genau 1 is_file-stat -> 0 Impact. Der Netz-Enroll selbst passiert
     * erst im backgroundDetachten htpRun (blockiert das Backend nie). Ergaenzt den Waechter-Tick als
     * ZWEITEN Ausloeser (Nutzerwunsch: nicht nur der zeitgesteuerte Tick). Fail-safe.
     *
     * @return void
     */
    private function htpEnrollWake()
    {
        $app = $this->getApp();

        if (!$app) {
            return;
        }

        $isAdmin = false;

        if (method_exists($app, 'isClient')) {
            $isAdmin = (bool) $app->isClient('administrator');
        } elseif (method_exists($app, 'isAdmin')) {
            $isAdmin = (bool) $app->isAdmin();
        }

        if (!$isAdmin) {
            return;
        }

        $dir = $this->statePath();

        if ($dir !== '' && is_file($dir . '/htprotect_enroll_pending.json')) {
            $this->openTickGate();
        }
    }

    /**
     * Leichter, backend-eager Konten-Schutz-Tick. Fuehrt NUR den BILLIGEN Teil des
     * Waechters aus - Klon-Abgleich + HtpAccounts::guardTick (ein paar indizierte
     * DB-Abfragen: ausgesperrte Eigentuemer zurueckholen + eingeschleuste Admins
     * stilllegen) - NICHT den teuren Sweep (Malware-Scan, Integritaet, Signatur-/
     * Joomla-Refresh, Heartbeat). So wird eine reine DB-Manipulation an Admin-Rechten
     * (feuert kein Joomla-Ereignis) schon beim naechsten Backend-Aufruf gefangen, statt
     * erst im 15-Min-Tick.
     *
     * Null Seitenlast:
     *   - Frontend               -> sofort raus (1 isClient-Check, keine I/O).
     *   - Backend, nicht faellig  -> 1 stat (Marker) und raus.
     *   - Backend, faellig        -> hoechstens alle ACCTGUARD_INTERVAL s; die Arbeit
     *                                laeuft DETACHED nach der Antwort (backgroundDetach),
     *                                der Aufruf wartet nie. Kein ausgehender Netz-Call
     *                                (guardTick ist rein DB; Mail nur bei echtem Ereignis).
     *
     * Teilt sich die State-Lock mit htpRun (nie gleichzeitig eine Konten-Pruefung) und
     * denselben Marker, den htpRun am Ende auf +ACCTGUARD_INTERVAL setzt -> nach einem
     * vollen Tick laeuft dieser Pfad 60 s NICHT (kein Doppellauf im selben Request).
     * Strikt fail-open.
     *
     * @return void
     */
    private function htpAccountGuardTick()
    {
        $app = $this->getApp();

        if (!$app) {
            return;
        }

        // NUR Backend (trafficarm - nur Admins - und genau dort materialisiert sich das
        // Risiko). isClient mit isAdmin-Fallback (J2.5-3.6).
        $isAdmin = false;

        if (method_exists($app, 'isClient')) {
            $isAdmin = (bool) $app->isClient('administrator');
        } elseif (method_exists($app, 'isAdmin')) {
            $isAdmin = (bool) $app->isAdmin();
        }

        if (!$isAdmin) {
            return;
        }

        $tmp = $this->statePath();

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

        $now    = time();
        $marker = $tmp . '/htprotect_acctguard.tick';

        // Billiges Sub-Gate (1 stat): der Marker traegt den naechsten erlaubten Zeitpunkt
        // (mtime in der Zukunft). htpRun setzt ihn am Ende ebenfalls -> nach einem vollen
        // Tick ist hier 60 s Ruhe.
        if (is_file($marker) && (int) @filemtime($marker) > $now) {
            return;
        }

        // Gemeinsame Lock mit htpRun: nie gleichzeitig eine Konten-Pruefung. Ist die Lock
        // vergeben (voller Tick laeuft), OHNE Marker-Reset raus -> der naechste Request
        // versucht es erneut. Veraltete Lock nach 5 Min freigeben.
        $lockFile = $tmp . '/htprotect_watch.lock';
        $fp       = @fopen($lockFile, 'x');

        if ($fp === false) {
            if (is_file($lockFile) && ($now - (int) @filemtime($lockFile)) > 300) {
                @unlink($lockFile);
            }

            return;
        }

        @fwrite($fp, (string) $now);
        @fclose($fp);

        // Lock gehalten -> Sub-Gate erst JETZT stellen (naechster Lauf fruehestens in 60 s).
        @touch($marker, $now + self::ACCTGUARD_INTERVAL);

        // Engine laden.
        $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
            . '/components/com_htprotect/core/bootstrap.php';

        if (!is_file($bootstrap)) {
            @unlink($lockFile);

            return;
        }

        require_once $bootstrap;

        if (!class_exists('HtpConfig') || !class_exists('HtpAccounts')
            || !method_exists('HtpAccounts', 'guardTick')) {
            @unlink($lockFile);

            return;
        }

        // Ab hier (Lock gehalten) ALLES im try: wirft etwas in der Vor-Arbeit (z. B.
        // HtpConfig::load bei korruptem Store), darf die GETEILTE Lock nicht bis zum
        // 300-s-Stale-Reclaim verwaisen (das wuerde auch den vollen Tick blockieren) -
        // das @unlink($lockFile) am Ende laeuft so in JEDEM Fall.
        try {
            $cfg = HtpConfig::load();

            if (function_exists('htp_load_lang')) {
                htp_load_lang();
            }

            // Antwort ist raus -> Rest im Hintergrund, 0,0 Seitenlast.
            $this->backgroundDetach();

            // Klon/Migration: nie gegen eine fremde (Template-)Baseline schuetzen. Reine
            // In-Memory-Pruefung (ein sha1), schreibt nur im Wechselfall.
            if (method_exists('HtpAccounts', 'reconcileSiteIdentity')) {
                HtpAccounts::reconcileSiteIdentity($cfg);
            }

            // attackActive = false: der leichte Pfad legt nur die EINDEUTIGEN Faelle still
            // (Null-Datum / bekanntes Angriffsmuster) + holt ausgesperrte Eigentuemer zurueck.
            // Die angriffs-korrelierte Hochstufung von Grenzfaellen bleibt dem vollen Tick,
            // der attackActive aus Malware/Defacement/rogue_admin herleitet. currentUserId=0
            // wie im vollen Tick: ein eingeschleuster Admin wird auch dann stillgelegt, wenn
            // gerade er den Request ausloest (ein legitimer Baseline-Admin ist ohnehin gefeit).
            $guardEvents = HtpAccounts::guardTick($cfg, false, 0);

            // Ereignisse (acct_stopped/acct_restored) melden - dieselbe Anti-Spam-Pipeline wie
            // htpRun (mergeNotified erhaelt fremde Melde-Zustaende -> kein Re-Mail-Spam). Ohne
            // Ereignisse KEIN Aufruf -> kein Netz/SMTP.
            if (is_array($guardEvents) && !empty($guardEvents)) {
                $this->notify($cfg, $guardEvents, array());
            }

            // Kern-Regressions-Hotfix headless nachziehen (byte-genau, hash-getort; siehe
            // HtpCoreHotfix). Backend-eager wie der Konten-Schutz und UNABHAENGIG vom
            // Auto-Update: eine exakt betroffene Joomla-Version (aktuell 5.4.8/6.1.3) wird
            // schon beim naechsten Backend-Aufruf gepatcht - genau dort, wo der Admin den
            // reparierten Template-Manager braucht. Billig: das interne Versions-Gate kehrt
            // auf allen anderen Versionen sofort zurueck; nach dem Patch idempotent.
            if (class_exists('HtpCoreHotfix')) {
                HtpCoreHotfix::tick();
            }
        } catch (Exception $e) {
            // Der Schutz darf die Site nie stoeren.
        } catch (Throwable $e) {
            // PHP 7+.
        }

        @unlink($lockFile);
    }

    /**
     * Eigentliche Pruefung (nur wenn das Intervall abgelaufen ist).
     *
     * @return void
     */
    private function htpRun()
    {
        $tmp = $this->statePath();

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

        $stateFile = $tmp . '/htprotect_watch.state';
        $lockFile  = $tmp . '/htprotect_watch.lock';
        $now       = time();

        // Billiger Vorab-Gate: nur EIN stat (kein Lesen/Decode), solange das
        // Intervall nicht um ist -> ~0 Kosten pro Request.
        if (is_file($stateFile) && ($now - (int) @filemtime($stateFile)) < self::INTERVAL) {
            return;
        }

        // Faellig: State einmal lesen (fuer das eigene, lange Integritaets-Intervall).
        $state = array();

        if (is_file($stateFile)) {
            $decoded = json_decode((string) @file_get_contents($stateFile), true);

            if (is_array($decoded)) {
                $state = $decoded;
            }
        }

        $lastIntegrity = isset($state['last_integrity']) ? (int) $state['last_integrity'] : 0;
        $integrityDue  = ($now - $lastIntegrity) >= self::INTEGRITY_INTERVAL;

        $lastTmpclean  = isset($state['last_tmpclean']) ? (int) $state['last_tmpclean'] : 0;
        $tmpcleanDue   = ($now - $lastTmpclean) >= self::TMPCLEAN_INTERVAL;

        $lastSeoscan   = isset($state['last_seoscan']) ? (int) $state['last_seoscan'] : 0;
        $seoDue        = ($now - $lastSeoscan) >= self::SEO_INTERVAL;

        $lastBackupSweep = isset($state['last_backup_sweep']) ? (int) $state['last_backup_sweep'] : 0;
        $backupSweepDue  = ($now - $lastBackupSweep) >= self::BACKUP_SWEEP_INTERVAL;
        $backupSwept     = false;

        $lastMailhealth = isset($state['last_mailhealth']) ? (int) $state['last_mailhealth'] : 0;
        $mailhealthDue  = ($now - $lastMailhealth) >= self::MAILHEALTH_INTERVAL;
        $mailhealthRan  = false;

        // Re-Entrancy-Sperre (atomar). Veraltete Sperre nach 5 Min. freigeben.
        $fp = @fopen($lockFile, 'x');

        if ($fp === false) {
            if (is_file($lockFile) && ($now - (int) @filemtime($lockFile)) > 300) {
                @unlink($lockFile);
            }

            return;
        }

        @fwrite($fp, (string) $now);
        @fclose($fp);

        // Engine laden
        $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
            . '/components/com_htprotect/core/bootstrap.php';

        if (!is_file($bootstrap)) {
            @unlink($lockFile);

            return;
        }

        require_once $bootstrap;

        if (!class_exists('HtpConfig') || !class_exists('HtpWatch') || !class_exists('HtpDefs')) {
            @unlink($lockFile);

            return;
        }

        $cfg = HtpConfig::load();

        if (function_exists('htp_load_lang')) {
            htp_load_lang();
        }

        // Ab hier nur noch Hintergrund-Arbeit: Browser-Verbindung schliessen,
        // damit der Seitenaufbau 0,0 belastet wird (FPM; sonst harmloser Nachlauf).
        $this->backgroundDetach();

        // 0) Site-Identitaet pruefen (Klon/Template/Migration): bei Pfad+DB-Wechsel
        //    klon-empfindliche pro-Site-Werte still verwerfen (Abmeldelink-Basis,
        //    HMAC-Secret, Konten-Baseline) -> kein Fehlalarm + Abmeldelinks zeigen
        //    nie aufs Template. Reine In-Memory-Pruefung (ein sha1), MUSS vor
        //    evaluate()/Mailversand laufen. Laeuft nur im gedrosselten Tick.
        if (class_exists('HtpAccounts')) {
            HtpAccounts::reconcileSiteIdentity($cfg);
        }

        // 0b) B/C-Zustand beobachten (Basis fuers Reaktivierungs-Fenster). Billig: ist
        //     B/C dauerhaft aus, gibt es bis zur 6-h-Gegenprobe gar keinen DB-Zugriff.
        if (class_exists('HtpCompat')) {
            HtpCompat::observeState();
        }

        // 1) Waechter: Fremdaenderung/Loeschung verwalteter Dateien
        $incidents = HtpWatch::check($cfg);

        // 1b) Konten-Ueberwachung: neue Super-User / geaenderte Passwoerter/E-Mails
        //     (baut beim ersten Lauf still die Baseline auf, kein Alarm).
        $accEvents = class_exists('HtpAccounts') ? HtpAccounts::evaluate($cfg) : array();
        $incidents = array_merge($incidents, $accEvents);

        // 1b2) Admin-Schutz: eingeschleuste Super-User stilllegen + ausgesperrte
        //      Eigentuemer wiederherstellen. Laeuft NACH evaluate (frische Baseline)
        //      und NACH reconcileSiteIdentity (nie auf einem Klon), detached, im Lock.
        //      EIGENER fail-open-Wrapper ist Pflicht (ein Throw wuerde sonst den Lock
        //      bis zum Stale-Reclaim halten). Die Ereignisse gehen NUR in die Mail
        //      ($incidents) - die rote Konten-Kachel ($accEvents) bleibt unberuehrt.
        if (class_exists('HtpAccounts') && method_exists('HtpAccounts', 'guardTick')) {
            try {
                $atkActive = false;

                foreach ($incidents as $incAtk) {
                    $ak = isset($incAtk['kind']) ? (string) $incAtk['kind'] : '';

                    if ($ak === 'malware' || $ak === 'rogue_admin' || $ak === 'defacement') {
                        $atkActive = true;
                        break;
                    }
                }

                $guardEvents = HtpAccounts::guardTick($cfg, $atkActive, 0);

                if (is_array($guardEvents) && !empty($guardEvents)) {
                    $incidents = array_merge($incidents, $guardEvents);
                }
            } catch (Exception $e) {
                // Der Schutz darf den Lauf nie stoeren.
            } catch (Throwable $e) {
                // PHP 7+.
            }
        }

        // 1c) Integritaets-/Anomalie-Check der Einstiegspunkte (eigenes 12-h-Gate):
        //     index.php / administrator|api/index.php (Hash + Inhalt, Re-Baseline bei
        //     Joomla-Update) + configuration.php + aktive Templates (nur Inhalt).
        $integrityDirty = false;

        if ($integrityDue && method_exists('HtpWatch', 'integrityCheck')) {
            $incidents      = array_merge($incidents, HtpWatch::integrityCheck($cfg));
            $integrityDirty = true;
        }

        // 1d) Waechter als ERSTES System-Plugin halten (idempotent, 12-h-Gate): so
        //     ist der Compat-Recovery-Shutdown-Handler vor fremden System-Plugins
        //     verankert und der PHP-WAF/die Selbstheilung laufen vor ihnen.
        if ($integrityDue && class_exists('HtpCompat')) {
            HtpCompat::ensureWatchFirst();
        }

        // 1e) Abgelaufene 1-Klick-Login-Guard-Paesse aus der administrator/.htaccess fegen: nur auf
        //     Seiten mit aktivem Basic-Auth-Guard relevant, mit billigem strpos-Gate (sonst 0
        //     Kosten). So verheilt der /administrator-Schutz von selbst, sollte ein Logout-Cleanup
        //     einmal ausbleiben; die Baseline wird dabei mitgezogen (keine Fremdaenderungs-Mail).
        if (class_exists('HtpSso') && method_exists('HtpSso', 'sweepGuardPasses')) {
            HtpSso::sweepGuardPasses();
        }

        // 2) Warnliste: verwundbare Erweiterungen
        $vuln = HtpDefs::checkExtensions();

        // 2b) Sobald eine installiert-verwundbare Erweiterung erkannt ist: EINMALIG einen
        //     gezielten frischen Joomla-Update-Check dafuer ausloesen, damit die Fix-Version
        //     SOFORT in Joomlas Update-Liste + HTProtects Ampel steht - unabhaengig vom
        //     Auto-Update-Schalter (auch fuer Nutzer, die selbst updaten). Backend-only,
        //     einmalig je Vuln-Auftreten, fail-soft (in HtpAutoUpdate gekapselt).
        if (!empty($vuln) && class_exists('HtpAutoUpdate')
            && method_exists('HtpAutoUpdate', 'surfaceVulnerableUpdates')) {
            HtpAutoUpdate::surfaceVulnerableUpdates($vuln);
        }

        // 2c) Update-Quellen-Waechter: von Joomlas Kern bei temporaeren Haengern
        //     selbst-deaktivierte Update-Quellen wieder aktivieren - die EIGENE immer,
        //     FREMDE per Schalter (keep_update_sites) + selbstlernendem 3-Runden-Opt-out.
        //     Eigene Tagessperre in keepUpdateSitesAlive(), UNABHAENGIG vom Auto-Update-
        //     Schalter (hilft auch Nutzern, die selbst updaten). Rein additiv, fail-soft.
        if (class_exists('HtpAutoUpdate') && method_exists('HtpAutoUpdate', 'keepUpdateSitesAlive')) {
            HtpAutoUpdate::keepUpdateSitesAlive($cfg);
        }

        // 3) Signaturen nachladen (eigene Tagessperre in refresh()).
        //    Die neuen Signaturen wirken sofort ueber die Echtzeit-WAF
        //    (onAfterRoute) - die .htaccess wird bewusst NICHT automatisch
        //    geschrieben (kein fremdgesteuerter Plattenschreibvorgang).
        HtpDefs::refresh(false);

        // 3a) Aktuelle Joomla-Version aus der offiziellen Downloads-API holen
        //     (eigene Tagessperre, last-known-good). Speist die Ampel, ohne dass
        //     je eine Versionsliste handgepflegt werden muss.
        HtpDefs::refreshJoomlaLatest(false);

        // 3a2) Malware-Scanner-Signaturen aus dem separaten signierten Feed
        //      auffrischen (eigene Tagessperre, fail-soft). Haelt den Cache fuer
        //      den on-demand-Scan UND den Integritaets-Waechter warm. Leerer/
        //      fehlender Feed -> Scanner verhaelt sich wie mit Built-ins allein.
        if (method_exists('HtpDefs', 'refreshMalwareSigs')) {
            HtpDefs::refreshMalwareSigs(false);
        }

        // 3b) Vorfilter-Trigger neu schreiben, damit der billige Vorabtest des
        //     Plugins auch frisch nachgeladene Signaturen (neue Komponenten)
        //     beruecksichtigt.
        HtpDefs::writeWafTriggerFile($cfg);

        // 3b3) Mail-Health 1x/24h - NUR bei gekoppelter Site (reine Dashboard-Funktion): ein
        //      SMTP-Connect-Test OHNE Versand, Ergebnis race-sicher in die Config gecacht. Der
        //      gleich folgende Heartbeat traegt den frischen Mail-Zustand ins sysfacts-Buendel.
        //      Detached (backgroundDetach lief oben) -> der Besucher zahlt den 5-7s-Connect nie.
        if ($mailhealthDue && !empty($cfg['link_paired'])
            && class_exists('HtpLink') && method_exists('HtpLink', 'runMailHealth')) {
            HtpLink::runMailHealth($cfg);
            $mailhealthRan = true;
            $cfg = HtpConfig::load(); // frische mailhealth fuer den Heartbeat sichtbar machen
        }

        // 3c) Fernverwaltungs-Herzschlag (Heartbeat + Command-Poll), sofern die Site an
        //     ein htprotect.app-Dashboard gekoppelt ist. Traffic-getriebener Pfad; die
        //     Antwort ist hier bereits raus (backgroundDetach), der Besucher zahlt nichts.
        //     KOMPLETT INERT fuer ungekoppelte/Einzelnutzer (billiges Config-Gate in tick()).
        if (class_exists('HtpLink')) {
            HtpLink::tick($cfg);
            // 3c1) Deferred Auto-Enroll (Masseninstallation): holt einen vom Installer vorgemerkten
            //      Selbst-Enroll nach (Pending-Marker im data/-Ordner). No-Op ohne Marker. Netz-Call
            //      ist hier gratis (backgroundDetach). Erfolg -> Marker weg; sonst naechster Tick erneut.
            HtpLink::enrollRetry($cfg);
        }

        // 3c2) NUR JOOMLA 3: Auto-Update-/Self-Update-Anstoss (Ersatz fuers Task-Scheduler-
        //      Plugin, das es auf J3 nicht gibt). heartbeat() kehrt auf allem AUSSER J3 sofort
        //      zurueck -> der 5.4+-Pfad (echtes Cron-Task-Plugin) bleibt voellig unberuehrt,
        //      J4-<5.4 ausgespart. Der zuegige Mehrphasen-Durchlauf laeuft danach ueber den
        //      J-neutralen com_ajax-Self-Retrigger (htpAutoUpdateTick). Strikt fail-open.
        if (class_exists('HtpAutoUpdateLegacy')) {
            HtpAutoUpdateLegacy::heartbeat($cfg);
        }

        // 3b2) Taegliche tmp-Reinigung (nur wenn aktiviert + 24h-Gate faellig):
        //      entfernt veraltete Reste/Angreifer-Sonden aus tmp/ (FP-sicher in
        //      HtpStorage::cleanTmp - nur > 24 h, Strukturdateien geschont).
        $tmpcleaned = false;

        if ($tmpcleanDue && !empty($cfg['tmp_autoclean'])
            && class_exists('HtpStorage') && method_exists('HtpStorage', 'cleanTmp')) {
            HtpStorage::cleanTmp();
            $tmpcleaned = true;
        }

        // 3b2b) Verwaiste/partielle Auto-Update-Backups + alte Editier-Sicherungen
        //       aufraeumen (hoechstens 1x taeglich, detached): entfernt Backups
        //       deinstallierter Erweiterungen, Crash-Reste und alte editbak-Kopien und
        //       gibt danach unreferenzierten Sicherungs-Speicher frei. Best effort.
        if ($backupSweepDue) {
            if (class_exists('HtpUpdateBackup') && method_exists('HtpUpdateBackup', 'sweepOrphans')) {
                HtpUpdateBackup::sweepOrphans();
            }

            // Altbestand entschlacken: den zur Laufzeit erzeugten backups/-Ordner (coreref-
            // Kern-Cache ~30 MB + Helix/SPPB/Kernscan-Sicherungen) aus VOR-2.5.7-Self-Update-
            // Sicherungen entfernen. Ab 2.5.7 wird er nicht mehr mitgesichert; das raeumt die
            // Altlast (je Sicherung bis ~33 MB) einmalig weg. Nur regenerierbarer Cache.
            if (class_exists('HtpUpdateBackup') && method_exists('HtpUpdateBackup', 'sweepSnapshotBloat')) {
                HtpUpdateBackup::sweepSnapshotBloat();
            }

            // Globale Aufraeum-Retention: den Auto-Update-Rollback-Speicher hart deckeln (hoechstens
            // GLOBAL_KEEP Rollback-Punkte insgesamt + nichts aelter als MAX_AGE_DAYS), damit er nicht
            // mit dem Erweiterungsbestand unbegrenzt waechst. Taeglich, best-effort.
            if (class_exists('HtpUpdateBackup') && method_exists('HtpUpdateBackup', 'capGlobal')) {
                HtpUpdateBackup::capGlobal();
            }

            // EINMALIGE Alt-Format-Bereinigung (marker-gated, idempotent): den verschwenderischen
            // VOR-2.5.9-Rollback-Bestand (loser `files/`-Baum + geteilter CAS = zehntausende Dateien)
            // entfernen. Ab 2.5.9 liegt jeder Rollback-Punkt als EINE snap.htpz vor.
            if (class_exists('HtpUpdateBackup') && method_exists('HtpUpdateBackup', 'migrateSlim')) {
                HtpUpdateBackup::migrateSlim();
            }

            if (class_exists('HtpScanner') && method_exists('HtpScanner', 'cleanEditBackups')) {
                HtpScanner::cleanEditBackups();
            }

            // Verwaiste/fehlgeschlagene USER-Backups (HtpBackup) taeglich raeumen - sonst blieben sie
            // auf Websites OHNE Backup-Zeitplan liegen (dort raeumt sonst nur der naechste Backup-Start)
            // und wuechsen zum Speicherfresser. Gefahrlos auch waehrend eines laufenden, stundenlangen
            // Backups (juengste Datei-mtime + 12-h-Schwelle oberhalb des 6-h-Job-Caps).
            if (class_exists('HtpBackup') && method_exists('HtpBackup', 'cleanFailedBackups')) {
                HtpBackup::cleanFailedBackups($now);
            }

            // Verwaiste Joomla-Sprach-Caches der HTProtect-Erweiterungen entfernen. Joomla legt beim
            // Laden jeder .ini einen geparsten Cache mit dem mtime im Namen an und raeumt alte nie auf;
            // durch HTProtects haeufige Self-Updates sammeln sich dutzende an. Je Datei nur den neuesten
            // behalten. Best-effort, nur HTProtect-eigene Caches.
            if (class_exists('HtpStorage') && method_exists('HtpStorage', 'cleanLangCache')) {
                HtpStorage::cleanLangCache();
            }

            $backupSwept = true;
        }

        // 3b3) Defacement-/SEO-Spam-Sweep (taeglich, detached): EIN normaler
        //      Startseiten-Abruf, mechanismus-basiert, FP-arm. Erstlauf bildet
        //      still die Basislinie. Bei sauberem Lauf Basislinie fortschreiben.
        $seoRan = false;
        // Letzter gespeicherter SEO-/Defacement-Befund (von der 1h-Uebersichts-
        // Schnellpruefung ODER einem frueheren Sweep). Default fuer den
        // Schnappschuss, falls dieser Tick selbst keinen SEO-Lauf macht - so
        // flackert die Dashboard-Kachel nicht (Befund bleibt bis zur Behebung).
        $seoFinding = (isset($cfg['seo_overview_finding']) && is_array($cfg['seo_overview_finding']))
            ? $cfg['seo_overview_finding'] : array();

        if ($seoDue && !empty($cfg['seo_watch']) && class_exists('HtpSeo')) {
            $seoIncidents = HtpSeo::scan($cfg);
            $incidents    = array_merge($incidents, $seoIncidents);
            $seoFinding   = HtpSeo::findingFromIncidents($seoIncidents);

            // Basislinie + Uebersichts-Befund/Drossel in einem Rutsch persistieren:
            // der frische Sweep deckt das 1h-Fenster der Uebersicht gleich mit ab.
            HtpConfig::saveKeys(array(
                'seo_baseline'         => isset($cfg['seo_baseline']) ? $cfg['seo_baseline'] : array(),
                'seo_overview_ts'      => $now,
                'seo_overview_finding' => $seoFinding,
            ));
            $cfg['seo_overview_ts']      = $now;
            $cfg['seo_overview_finding'] = $seoFinding;
            $seoRan = true;
        }

        // 3c) Untergeschobene Super-Admins (SP-Page-Builder-Kampagnenmuster)
        //     erkennen -> Mail-Vorfall (mit Anti-Spam) + roter Status. Erkennt
        //     auch schon vor der Installation gepflanzte Rogue-Admins.
        $rogue = class_exists('HtpAccounts')
            ? HtpAccounts::suspiciousAdmins(isset($cfg['admin_whitelist']) ? $cfg['admin_whitelist'] : array())
            : array();

        foreach ($rogue as $a) {
            $incidents[] = array(
                'path'  => 'rogueadmin:' . (int) $a['id'],
                'kind'  => 'rogue_admin',
                'id'    => (int) $a['id'],
                'user'  => (string) $a['username'],
                'label' => (string) $a['username'],
            );
        }

        // 3d) Bedrohungs-Schnappschuss fuer die Dashboard-Statuskachel (Quickicon)
        //     im Hintergrund aktuell halten: dieselbe rote Bedrohungssicht wie die
        //     Komponenten-Ampel (Rogue-Admin + verwundbare Erweiterung + aktive
        //     Angriffsspuren). scanMaliciousHtaccess prueft nur die oberste Ebene.
        if (class_exists('HtpStatus')) {
            // Aufgeschobene Warnungen NICHT rot in die Kachel mitzaehlen: die defer-bereinigte
            // Aktivliste mitgeben (HtpStatus::threatFromData bevorzugt sie). Fallback ohne
            // vulnPartition (Alt-Engine) = volle Liste = fail-safe zum Warnen.
            $vulnActiveSnap = (class_exists('HtpDefs') && method_exists('HtpDefs', 'vulnPartition'))
                ? HtpDefs::vulnPartition($vuln, isset($cfg['vuln_ack']) ? $cfg['vuln_ack'] : array())
                : array('active' => $vuln);
            HtpStatus::writeSnapshot($cfg, array(
                'suspicious_admins' => $rogue,
                'vuln'              => $vuln,
                'vuln_active'       => $vulnActiveSnap['active'],
                'account_changes'   => $accEvents,
                'jce_profiles'      => method_exists('HtpDefs', 'checkJceProfiles') ? HtpDefs::checkJceProfiles() : array(),
                'mal_htaccess'      => method_exists('HtpStorage', 'scanMaliciousHtaccess') ? HtpStorage::scanMaliciousHtaccess() : array(),
                'seo_finding'       => $seoFinding,
            ));
        }

        // 3d2) Sofort-Statuspush bei GEAENDERTER Bedrohungslage: der eben geschriebene Snapshot
        //      (Zero-Knowledge: nur Level/Kategorie/Anzahl, nie die Liste) unverzueglich ans
        //      Dashboard, statt bis zum naechsten faelligen Heartbeat (hb_min, bis 30 Min) zu warten.
        //      Muss NACH writeSnapshot stehen (der regulaere Heartbeat oben im Tick sah noch den
        //      Vorrunden-Snapshot). Push nur bei echter Aenderung -> kein Fluten. No-Op fuer
        //      ungekoppelte Einzelnutzer (isPaired-Gate in der Methode).
        if (class_exists('HtpLink') && method_exists('HtpLink', 'reportThreatIfChanged')) {
            HtpLink::reportThreatIfChanged($cfg);
        }

        // 3e) Willkommens-Mail (EINMALIG je Installation) - GRUEN-Pfad: ~30 Min nachdem die
        //     Ampel dauerhaft gruen ist ("alles erledigt"), die allgemeine Willkommens-Mail
        //     (ohne Backend-Zugangsteil) senden - fuer alle, die keinen Backend-Passwortschutz
        //     einrichten. Nutzt DASSELBE Flag welcome_sent wie der Guard-Pfad -> nie doppelt.
        //     Direkt nach writeSnapshot, damit card()/"gruen" den frischen Stand liest.
        $this->maybeWelcomeOnGreen($cfg, $now);

        // 4) Neue Probleme per E-Mail melden
        $this->notify($cfg, $incidents, $vuln);

        // 4b) HINWEIS: Das Erweiterungs-Auto-Update wird NICHT hier angetippt, sondern in
        //     onAfterRespond::htpAutoUpdateTick - dort bleibt die Session OFFEN (nur die
        //     FPM-Verbindung wird beendet), weil der Joomla-Installer den vollen Kontext
        //     braucht. Hier (htpRun) ist die Session via backgroundDetach bereits
        //     geschlossen -> ein Install wuerde fehlschlagen. Joomlas JS-Lazy-Scheduler
        //     feuert auf trafficarmen Sites nie; der Tick ersetzt ihn serverseitig.

        // Integritaets-Basis persistieren - aber NUR die vom Waechter geaenderten
        // Felder (feldweise, race-sicher). Der Waechter laeuft evtl. parallel zu
        // einem Backend-Speichern (z. B. notify_email); ein voller HtpConfig::save
        // mit dem - inzwischen veralteten - $cfg wuerde solche Felder ueberschreiben
        // (save ersetzt htp_shield komplett). saveKeys laedt frisch und schreibt
        // nur die genannten Schluessel darueber.
        if ($integrityDirty) {
            HtpConfig::saveKeys(array(
                'core_manifest'    => isset($cfg['core_manifest']) ? $cfg['core_manifest'] : array(),
                'core_jversion'    => isset($cfg['core_jversion']) ? $cfg['core_jversion'] : '',
                'watch_manifest'   => isset($cfg['watch_manifest']) ? $cfg['watch_manifest'] : array(),
                'account_baseline' => isset($cfg['account_baseline']) ? $cfg['account_baseline'] : array(),
            ));
        }

        @file_put_contents($stateFile, json_encode(array(
            'last_run'          => $now,
            'last_integrity'    => $integrityDue ? $now : $lastIntegrity,
            'last_tmpclean'     => $tmpcleaned ? $now : $lastTmpclean,
            'last_seoscan'      => $seoRan ? $now : $lastSeoscan,
            'last_backup_sweep' => $backupSwept ? $now : $lastBackupSweep,
            'last_mailhealth'   => $mailhealthRan ? $now : $lastMailhealth,
        )));

        // Der volle Tick hat die Konten-Pruefung (guardTick) soeben mit erledigt -> das
        // leichte Sub-Gate um ACCTGUARD_INTERVAL vorstellen, damit htpAccountGuardTick nicht
        // im selben Request (und die naechsten 60 s) unnoetig ein zweites Mal laeuft.
        @touch($tmp . '/htprotect_acctguard.tick', $now + self::ACCTGUARD_INTERVAL);
        @unlink($lockFile);
    }

    /**
     * GRUEN-Pfad der EINMALIGEN Willkommens-Mail: sobald die Dashboard-Ampel ~30 Min
     * ununterbrochen gruen ist ("alles erledigt"), die allgemeine Willkommens-Mail (ohne
     * Backend-Zugangsteil) senden. Braucht eine hinterlegte Alarm-Mail. Setzt welcome_sent
     * ZUERST + prueft direkt davor nochmal frisch -> kollidiert nie mit dem Guard-Pfad
     * (keine doppelte Begruessung). Billig: card() liest nur den gerade frisch geschriebenen
     * Schnappschuss + bewertet gelb/gruen live, kein Scan. Fail-soft.
     *
     * @param   array  $cfg  Konfiguration
     * @param   int    $now  Aktuelle Unix-Zeit (aus dem Tick)
     *
     * @return void
     */
    private function maybeWelcomeOnGreen($cfg, $now)
    {
        // Schon versendet -> nie wieder. Ohne Status/App/Config-Klasse oder Alarm-Mail: raus.
        if (!empty($cfg['welcome_sent']) || !class_exists('HtpStatus')
            || !class_exists('HtpApp') || !class_exists('HtpConfig')) {
            return;
        }

        $to = isset($cfg['notify_email']) ? trim((string) $cfg['notify_email']) : '';

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

        $card  = HtpStatus::card($cfg);
        $green = is_array($card) && isset($card['level']) && $card['level'] === 'success';

        if (!$green) {
            // Ampel (nicht mehr) gruen -> Uhr zuruecksetzen: es zaehlt nur DAUERHAFTES Gruen.
            if (!empty($cfg['welcome_green_since'])) {
                HtpConfig::saveKeys(array('welcome_green_since' => 0));
            }

            return;
        }

        $since = isset($cfg['welcome_green_since']) ? (int) $cfg['welcome_green_since'] : 0;

        if ($since <= 0 || $since > $now) {
            // Erstes Gruen (oder unplausibler Zukunfts-Stempel) -> Uhr jetzt starten.
            HtpConfig::saveKeys(array('welcome_green_since' => $now));

            return;
        }

        if (($now - $since) < self::WELCOME_GREEN_DELAY) {
            return; // noch keine 30 Min am Stueck gruen
        }

        // Dauerhaft gruen -> Begruessung. Flag ZUERST setzen (frisch nachladen + gegenpruefen,
        // damit eine parallele Guard-Einrichtung die Begruessung nicht doppelt ausloest).
        $fresh = HtpConfig::load();

        if (!empty($fresh['welcome_sent'])) {
            return;
        }

        HtpConfig::saveKeys(array('welcome_sent' => 1));

        if (method_exists('HtpApp', 'sendWelcomeMail')) {
            HtpApp::sendWelcomeMail($cfg, false, '', '');
        }
    }

    /**
     * Wie backgroundDetach, aber OHNE die Session zu schliessen: nur die FPM-Verbindung
     * zum Browser beenden (fastcgi_finish_request), damit die nachfolgende Arbeit den
     * Seitenaufbau nicht belastet - der volle App-/Session-Kontext bleibt aber erhalten.
     * Noetig fuer den Auto-Update-Install (Joomla-Installer braucht den Kontext; mit
     * geschlossener Session schlaegt der Hintergrund-Install fehl). Voll gekapselt.
     *
     * @return void
     */
    private function backgroundFlush()
    {
        try {
            if (!function_exists('fastcgi_finish_request')) {
                return;
            }

            $app   = $this->getApp();
            $debug = ($app && method_exists($app, 'get')) ? (bool) $app->get('debug') : false;

            if (!$debug) {
                @fastcgi_finish_request();
            }
        } catch (Exception $e) {
            // egal - dann laeuft die Arbeit eben inline
        } catch (Throwable $e) {
            // PHP 7+
        }
    }

    /**
     * Hintergrund-Entkopplung: nach der Ausgabe die Session schliessen (gibt die
     * Joomla-Sperre frei) und - unter PHP-FPM, ausserhalb des Debug-Modus - die
     * Browser-Verbindung beenden (fastcgi_finish_request). Die danach laufende
     * schwere Arbeit (Hashes, HTTP-Refresh, Mail) belastet den Seitenaufbau dann
     * 0,0. Voll gekapselt - faellt nie hart aus.
     *
     * @return void
     */
    private function backgroundDetach()
    {
        try {
            $app = $this->getApp();

            // Session schliessen -> Sperre frei (parallele Requests warten nicht).
            // Danach NICHT mehr auf die Session zugreifen.
            $closed = false;

            if ($app && method_exists($app, 'getSession')) {
                $sess = $app->getSession();

                if ($sess && method_exists($sess, 'close')) {
                    @$sess->close();
                    $closed = true;
                }
            }

            if (!$closed && function_exists('session_write_close')) {
                @session_write_close();
            }

            // Verbindung zum Browser schliessen - nur FPM und NICHT im Debug-Modus
            // (sonst wuerde eine Debug-Leiste abgeschnitten).
            if (function_exists('fastcgi_finish_request')) {
                $debug = ($app && method_exists($app, 'get')) ? (bool) $app->get('debug') : false;

                if (!$debug) {
                    @fastcgi_finish_request();
                }
            }
        } catch (Exception $e) {
            // egal - dann laeuft die Arbeit eben inline
        } catch (Throwable $e) {
            // PHP 7+
        }
    }

    /**
     * Faellige Vorfaelle/Warnungen per E-Mail melden. EINE Sammel-Mail je Lauf
     * (Laeufe >= 6 h auseinander); ein stehendes Problem hoechstens alle 14 Tage
     * erneut. Angriffsversuche tauchen hier NICHT auf - die werden ausschliesslich
     * protokolliert (logBlock) und loesen nie eine Mail aus.
     *
     * @param   array  $cfg        Konfiguration
     * @param   array  $incidents  Waechter-Vorfaelle
     * @param   array  $vuln       Verwundbare Erweiterungen
     *
     * @return void
     */
    private function notify($cfg, $incidents, $vuln)
    {
        // Einheitliche Mail-Sprache (identisch zu den Auto-Update-Mails): Backend-Sprache
        // des Empfaengers bzw. globale Backend-/Komponenten-Sprache - NICHT die aktive
        // CLI-/Cron-Sprache. VOR dem Bauen der sprachabhaengigen Event-Labels laden, damit
        // Labels UND Mailtext dieselbe Sprache haben (kein Mix innerhalb einer Mail).
        if (class_exists('HtpMailLang')) {
            HtpMailLang::load($cfg);
        } elseif (function_exists('htp_load_lang')) {
            htp_load_lang();
        }

        // Ereignisse vereinheitlichen (Waechter + Warnliste). AUFGESCHOBENE (still gestellte)
        // Erweiterungs-Warnungen werden hier von der Mail-Erinnerung ausgeschlossen - sie
        // verfallen aber automatisch (HtpDefs::vulnPartition/vulnAckActive: geaenderte Version
        // oder eskalierte Severity -> wieder aktiv). Fallback ohne vulnPartition: voll warnen
        // (fail-safe). Den Advisory-Link je Warnung baut die testbare HtpWatch::vulnEvents().
        $vulnMail = (class_exists('HtpDefs') && method_exists('HtpDefs', 'vulnPartition'))
            ? HtpDefs::vulnPartition($vuln, isset($cfg['vuln_ack']) ? $cfg['vuln_ack'] : array())
            : array('active' => $vuln);
        $events = array_merge(is_array($incidents) ? $incidents : array(), HtpWatch::vulnEvents($vulnMail['active']));

        $interval = HtpWatch::reminderInterval($cfg);

        if (empty($events)) {
            HtpWatch::recordNotification(array(), array(), $interval);

            return;
        }

        // Nur faellige Vorfaelle (neu oder Wiedererinnerung nach Frist). Den
        // Status erst NACH dem Versand fortschreiben.
        $due        = HtpWatch::dueForNotification($events, $interval);
        $recipients = class_exists('HtpUnsub')
            ? HtpUnsub::normalizeList(isset($cfg['notify_email']) ? $cfg['notify_email'] : '')
            : array();
        $mailed = array();

        if (!empty($due) && !empty($recipients)) {
            // Site-eigenes HMAC-Secret fuer unfaelschbare Abmeldelinks einmalig
            // sicherstellen (lazy, race-sicher ueber saveKeys).
            $secret = isset($cfg['unsub_secret']) ? (string) $cfg['unsub_secret'] : '';

            if ($secret === '' && class_exists('HtpUnsub')) {
                $secret = HtpUnsub::genSecret();
                HtpConfig::saveKeys(array('unsub_secret' => $secret));
            }

            // Vertrauenswuerdige Link-Basis (live_site / im Backend erfasst /
            // Fallback) - host-poisoning-fest fuer die Abmeldelinks.
            $linkBase = function_exists('htp_trusted_base')
                ? htp_trusted_base(isset($cfg['unsub_base']) ? $cfg['unsub_base'] : '')
                : '';

            // Pro Adresse EINE Mail mit eigenem Abmeldelink (Privacy: kein
            // gemeinsames To; die Abmeldung wirkt genau fuer diese Adresse).
            $sentAny = false;

            foreach ($recipients as $addr) {
                if ($this->sendMail($addr, $due, $secret, $linkBase)) {
                    $sentAny = true;
                }
            }

            if ($sentAny) {
                $mailed = $due;
            }
        }

        HtpWatch::recordNotification($events, $mailed, $interval);
    }

    /**
     * Benachrichtigungs-Mail zusammenstellen und senden.
     *
     * @param   string  $email  Empfaenger
     * @param   array   $fresh  Neue Ereignisse
     *
     * @return void
     */
    private function sendMail($email, $fresh, $secret = '', $linkBase = '')
    {
        if (!function_exists('htp_send_mail')) {
            return false;
        }

        // Gleiche einheitliche Mail-Sprache wie in notify() (kein Mix zwischen Mails).
        // sendMail() bekommt kein $cfg -> frisch laden (HtpConfig cached).
        if (class_exists('HtpMailLang') && class_exists('HtpConfig')) {
            HtpMailLang::load(HtpConfig::load());
        } elseif (function_exists('htp_load_lang')) {
            htp_load_lang();
        }

        $site = function_exists('htp_site_url') ? htp_site_url() : '';
        $lines = array();
        $lines[] = htp_t('COM_HTPROTECT_MAIL_INTRO');
        $lines[] = $site !== '' ? htp_t('COM_HTPROTECT_MAIL_SITE', $site) : '';
        $lines[] = '';

        foreach ($fresh as $event) {
            if ($event['kind'] === 'vulnerable') {
                $lines[] = htp_t('COM_HTPROTECT_MAIL_EVT_VULN', $event['label']);

                if (!empty($event['advisory'])) {
                    // Einrueckung bewusst hier im Code (JED beanstandet fuehrende
                    // Leerzeichen im Sprachstring).
                    $lines[] = '    ' . htp_t('COM_HTPROTECT_MAIL_EVT_ADVISORY', $event['advisory']);
                }
            } elseif ($event['kind'] === 'deleted') {
                $lines[] = htp_t('COM_HTPROTECT_MAIL_EVT_DELETED', $event['label']);
            } elseif ($event['kind'] === 'changed') {
                $lines[] = htp_t('COM_HTPROTECT_MAIL_EVT_CHANGED', $event['label']);
            } elseif ($event['kind'] === 'malware') {
                $lines[] = htp_t('COM_HTPROTECT_MAIL_EVT_MALWARE', $event['label']);
            } elseif ($event['kind'] === 'rogue_admin') {
                $lines[] = htp_t('COM_HTPROTECT_MAIL_EVT_ROGUEADMIN', $event['label']);
            } elseif ($event['kind'] === 'defacement') {
                $lines[] = htp_t('COM_HTPROTECT_MAIL_EVT_DEFACEMENT', $event['label']);
            } elseif ($event['kind'] === 'seo_spam') {
                $lines[] = htp_t('COM_HTPROTECT_MAIL_EVT_SEOSPAM', $event['label']);
            } elseif ($event['kind'] === 'site_error') {
                $lines[] = htp_t('COM_HTPROTECT_MAIL_EVT_SITE_ERROR', $event['label']);
            } else {
                $lines[] = htp_t('COM_HTPROTECT_MAIL_EVT_HINT', $event['label']);
            }

            // Konkreten Grund (Redirect-Ziel / HTTP-Code / Fremdlink-Zahl) anhaengen
            // -> macht den Defacement-/SEO-/Fehler-Alarm handlungsfaehig. Nur die
            // SEO-Arten tragen 'reasons'; andere Arten bleiben unveraendert.
            if (in_array($event['kind'], array('defacement', 'seo_spam', 'site_error'), true)
                && !empty($event['reasons']) && is_array($event['reasons'])) {
                $lines[] = '   - ' . implode('; ', array_map('strval', $event['reasons']));
            }
        }

        // FOOTER2 (Wiederherstellen) nur, wenn wirklich Schutzdateien betroffen
        // sind - bei Defacement/Konten/Erweiterungen waere der Hinweis irrefuehrend.
        $hasFileWatch = false;

        foreach ($fresh as $ev) {
            if (isset($ev['kind']) && ($ev['kind'] === 'changed' || $ev['kind'] === 'deleted')) {
                $hasFileWatch = true;
                break;
            }
        }

        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_MAIL_FOOTER1');

        if ($hasFileWatch) {
            $lines[] = htp_t('COM_HTPROTECT_MAIL_FOOTER2');
        }

        // Deeplink zur HTProtect-Uebersicht. $linkBase ist die vertrauenswuerdige Basis
        // (live_site / unsub_base, host-poisoning-fest). Im HTML ein Button, im Plain-Teil
        // eine URL-Zeile.
        $overviewUrl = ($linkBase !== '' && $linkBase !== '/')
            ? rtrim($linkBase, '/') . '/administrator/index.php?option=com_htprotect' : '';

        // HTML-Inhalt aus den (bisher reinen Text-)Ereigniszeilen bauen, BEVOR Deeplink/
        // Signatur/Abmelden angehaengt werden (die kommen als Button bzw. Footer).
        $useHtml     = class_exists('HtpMail');
        $contentHtml = '';

        if ($useHtml) {
            foreach ($lines as $l) {
                if ((string) $l !== '') {
                    $contentHtml .= HtpMail::lineP($l);
                }
            }

            if ($overviewUrl !== '') {
                $contentHtml .= HtpMail::buttonRow(array(HtpMail::button(htp_t('COM_HTPROTECT_MAIL_BTN_OVERVIEW'), $overviewUrl, 'primary')));
            }
        }

        // Plain-Text-Alternative: Deeplink + Signatur + Abmelde-Zeile anhaengen.
        if ($overviewUrl !== '') {
            $lines[] = '';
            $lines[] = htp_t('COM_HTPROTECT_MAIL_BTN_OVERVIEW') . ': ' . $overviewUrl;
        }

        $headers  = array();
        $unsubUrl = '';
        $lines[]  = '';
        $lines[]  = htp_t('COM_HTPROTECT_MAIL_SIGNATURE');

        if ($secret !== '' && $linkBase !== '' && $linkBase !== '/' && class_exists('HtpUnsub')) {
            $tok = HtpUnsub::token($email, $secret);

            if ($tok !== '') {
                $unsubUrl = HtpUnsub::link($linkBase, $tok);
                $lines[]  = '';
                $lines[]  = htp_t('COM_HTPROTECT_MAIL_UNSUB', $unsubUrl);
                $headers['List-Unsubscribe'] = '<' . $unsubUrl . '>';
            }
        }

        $subject = htp_t('COM_HTPROTECT_MAIL_SUBJECT');
        $html    = '';

        if ($useHtml) {
            // Ohne Ueberschrift (Betreff titelt; die Intro-Zeile fuehrt) -> spart Platz.
            $footer = ($unsubUrl !== '') ? HtpMail::smallLink(htp_t('COM_HTPROTECT_MAIL_UNSUB_SHORT'), $unsubUrl) : '';
            $html   = HtpMail::wrap('', $contentHtml, $footer);
        }

        $err = '';

        return htp_send_mail($email, $subject, implode("\n", $lines), '', $err, $headers, $html !== '' ? $html : null);
    }

    /**
     * Joomla-Temp-Verzeichnis ermitteln (ohne die Engine zu laden).
     *
     * @return string
     */
    /**
     * Dauerhafter, web-geschuetzter Datenordner fuer die Laufzeitdateien -
     * SELBST-ENTHALTEN (laeuft ohne Engine), aber bitidentisch zur Aufloesung
     * der Komponente (`htp_state_dir()`): bevorzugt
     * `administrator/components/com_htprotect/data/` (legt ihn bei Bedarf an +
     * schuetzt ihn), Fallback Joomla-tmp. Frontend + Backend = derselbe
     * Systembenutzer -> beide entscheiden gleich. Pro Request gecacht.
     *
     * @return string
     */
    private function statePath()
    {
        if ($this->statePathCache !== null) {
            return $this->statePathCache;
        }

        $admin = defined('JPATH_ADMINISTRATOR')
            ? JPATH_ADMINISTRATOR
            : (defined('JPATH_ROOT') ? JPATH_ROOT . '/administrator' : '');

        if ($admin !== '') {
            $data    = rtrim(str_replace('\\', '/', $admin), '/') . '/components/com_htprotect/data';
            $existed = is_dir($data);

            if (($existed || @mkdir($data, 0755, true) || is_dir($data)) && is_writable($data)) {
                if (!is_file($data . '/.htaccess')) {
                    @file_put_contents(
                        $data . '/.htaccess',
                        "# HTProtect Datenordner - kein direkter Web-Zugriff\n"
                        . "<IfModule mod_authz_core.c>\n    Require all denied\n</IfModule>\n"
                        . "<IfModule !mod_authz_core.c>\n    Order allow,deny\n    Deny from all\n</IfModule>\n"
                    );
                }

                if (!is_file($data . '/index.html')) {
                    @file_put_contents($data . '/index.html', '<!DOCTYPE html><title></title>');
                }

                // Beim ERSTEN Anlegen die bisherigen tmp-Laufzeitdateien sofort
                // uebernehmen (Kopie - das endgueltige Entfernen im tmp macht die
                // Engine-Migration). Verhindert eine kurze WAF-Vorfilter-Luecke
                // (Trigger) und einen ueberfluessigen Extra-Lauf (State) direkt
                // nach dem Update.
                if (!$existed) {
                    $tmp = $this->tmpPath();

                    if ($tmp !== '' && $tmp !== $data) {
                        foreach (array('htprotect_waf.triggers', 'htprotect_watch.state', 'htprotect_waf.log', 'htprotect_status.json') as $mig) {
                            if (is_file($tmp . '/' . $mig) && !is_file($data . '/' . $mig)) {
                                $c = @file_get_contents($tmp . '/' . $mig);

                                if ($c !== false) {
                                    @file_put_contents($data . '/' . $mig, $c);
                                }
                            }
                        }
                    }
                }

                $this->statePathCache = $data;

                return $data;
            }
        }

        $this->statePathCache = $this->tmpPath();

        return $this->statePathCache;
    }

    private function tmpPath()
    {
        $tmp = '';

        if (class_exists('Joomla\\CMS\\Factory')) {
            try {
                $app = Joomla\CMS\Factory::getApplication();

                if ($app && method_exists($app, 'get')) {
                    $tmp = (string) $app->get('tmp_path');
                }
            } catch (Exception $e) {
                $tmp = '';
            }
        }

        if ($tmp === '' && class_exists('JFactory')) {
            try {
                $config = JFactory::getConfig();

                if ($config && method_exists($config, 'get')) {
                    $tmp = (string) $config->get('tmp_path');
                }
            } catch (Exception $e) {
                $tmp = '';
            }
        }

        if ($tmp === '' || !is_dir($tmp) || !is_writable($tmp)) {
            return '';
        }

        return rtrim(str_replace('\\', '/', $tmp), '/');
    }

    /**
     * SHUTDOWN-Handler: (2) unbehandelter Legacy-Fatal -> Recovery-Seite; (3) ein
     * abgefangener B/C-Fehler hinterlaesst nur 5xx -> B/C still reaktivieren (Nutzer
     * laedt neu). Billige Vorfilter -> ~0 Kosten im Normalfall.
     *
     * @return void
     */
    public static function htpCompatShutdown()
    {
        try {
            $err      = error_get_last();
            $hasFatal = $err && isset($err['type'])
                && in_array((int) $err['type'], array(E_ERROR, E_COMPILE_ERROR, E_CORE_ERROR, E_PARSE, E_USER_ERROR), true);
            $code     = function_exists('http_response_code') ? (int) http_response_code() : 0;

            if (!$hasFatal && $code < 500) {
                return; // kein Fatal UND kein 5xx -> nichts zu tun
            }

            if (!self::htpCompatEngine() || !class_exists('HtpCompat')) {
                return;
            }

            // JEDER Fatal/5xx bei deaktiviertem B/C (J5 `compat` ODER J6 `compat6`) ->
            // reaktivieren + protokollieren. recoverFromFatal reagiert nur, wenn B/C
            // wirklich deaktiviert ist (kein Fehlalarm bei aktivem B/C). Deckt
            // "Class X not found", "Failed opening required ..." und jede andere
            // Fatal-Art gleichermassen ab - das universelle Netz.
            HtpCompat::recoverFromFatal(
                ($hasFatal && isset($err['message'])) ? (string) $err['message'] : '',
                ($hasFatal && isset($err['file'])) ? (string) $err['file'] : ''
            );
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open (PHP 7+)
        }
    }

    /**
     * ?recompat-Hebel (statisch, sehr frueh): Engine laden + an HtpCompat::recompat
     * (Super-User-gated, reaktiviert B/C + Redirect) delegieren.
     *
     * @return void
     */
    public static function htpCompatRecompat()
    {
        try {
            if (self::htpCompatEngine() && class_exists('HtpCompat')) {
                HtpCompat::recompat();
            }
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open
        }
    }

    /**
     * PRAEEMPTIV (Haupt-Mechanismus): Fehlt der Legacy-Alias JPlugin, ist das B/C-
     * Plugin deaktiviert (das compat-Plugin registriert die Aliase in seinem
     * Konstruktor VOR den System-Plugins; wir sind das erste System-Plugin). Dann
     * laden wir die compat-Classmap SELBST -> dieser Request crasht gar nicht erst -
     * und reaktivieren B/C + leeren den Plugin-Cache fuer kuenftige Requests. So
     * haengt die Recovery NICHT davon ab, wo Joomla den Fehler abfaengt.
     *
     * @return void
     */
    /** Re-Entrancy-Schutz + Zustand des Compat-Autoloaders (0=unbekannt, 1=aktiv, 2=Feature aus). */
    public static $htpCompatBusy  = false;
    public static $htpCompatState = 0;
    public static $htpAliasMap    = array();
    public static $htpRecorded    = false;

    /**
     * FALLBACK-AUTOLOADER fuer Legacy-J*-Klassen (HAUPT-Mechanismus, FEHLER-
     * GETRIGGERT): Wird von PHP NUR aufgerufen, wenn eine `J*`-Klasse angefordert
     * wird, die kein anderer Autoloader (JLoader/Composer) findet - also genau dann,
     * wenn eine Erweiterung eine Legacy-Klasse braucht UND das B/C-Plugin deaktiviert
     * ist (sonst haette Joomla den Alias schon). Dann: compat-Classmap selbst laden
     * (der Request crasht NICHT) + B/C dauerhaft reaktivieren. Wird B/C abgeschaltet
     * ohne dass jemand Legacy-Klassen nutzt, feuert dies NIE -> kein pauschales
     * Reaktivieren. Respektiert compat_guard.
     *
     * @param   string  $class  angeforderter Klassenname
     *
     * @return void
     */
    public static function htpCompatAutoload($class)
    {
        // Nur Legacy-J*-Namen (J + Grossbuchstabe). Sonst nicht unser Fall. (Bewusst NICHT
        // alle nicht-namespaced Namen: sonst wuerde der Autoloader bei normalen Optional-
        // Klassen-Checks wie class_exists('Memcached') feuern + die Engine laden -> 0-Impact
        // waere hin. Nicht-J*-Legacy-Klassen deckt das Shutdown-Netz ab.)
        if (!isset($class[1]) || $class[0] !== 'J' || $class[1] < 'A' || $class[1] > 'Z') {
            return;
        }

        if (self::$htpCompatState === 2 || self::$htpCompatBusy) {
            return; // Feature aus, oder Re-Entrancy
        }

        self::$htpCompatBusy = true;

        try {
            if (self::$htpCompatState === 0) {
                // Erstmaliger Treffer: compat_guard pruefen.
                $on = !self::htpCompatEngine() || !class_exists('HtpCompat') || HtpCompat::guardEnabled();

                if (!$on) {
                    self::$htpCompatState = 2; // Feature aus -> nie eingreifen
                    self::$htpCompatBusy  = false;

                    return;
                }

                // WURZEL-GATE: nur eingreifen, wenn ein compat/compat6-Plugin EXISTIERT
                // und DEAKTIVIERT ist. Sonst (B/C aktiv, oder gar kein B/C-Plugin wie
                // unter J3 / J4.0-4.3, oder eine fremde J*-Klasse) sind wir NICHT
                // zustaendig -> fuer diesen Request komplett inert. Verhindert die
                // Fehlalarme (J3 nrframework, J6 compat6-AN bei JComments etc.).
                if (!class_exists('HtpCompat') || !HtpCompat::bcDisabledNow()) {
                    self::$htpCompatState = 2;
                    self::$htpCompatBusy  = false;

                    return;
                }

                // ZEITFENSTER-GATE: Ist die Seite NICHT unmittelbar nach dem Deaktivieren
                // abgeschmiert (Fenster vorbei / B/C lange aus = Nutzer-Entscheidung), greift
                // HTProtect GAR NICHT mehr ein - kein Alias-Nachliefern, kein Reaktivieren,
                // keine Meldung. Der echte Fehler der Erweiterung bleibt sichtbar.
                if (!method_exists('HtpCompat', 'recoveryAllowed') || !HtpCompat::recoveryAllowed()) {
                    self::$htpCompatState = 2;
                    self::$htpCompatBusy  = false;

                    return;
                }

                // compat-Classmap selbst laden (registriert alle Legacy-Aliase).
                $root = defined('JPATH_ROOT') ? JPATH_ROOT : (defined('JPATH_SITE') ? JPATH_SITE : '');

                if ($root !== '') {
                    foreach (array(
                        '/plugins/behaviour/compat6/src/classmap/classmap.php', // J6 (Plugin heisst compat6)
                        '/plugins/behaviour/compat/src/classmap/classmap.php',  // J4.4+/J5
                        '/plugins/behaviour/compat/classmap/classmap.php',       // aeltere J4
                    ) as $rel) {
                        if (is_file($root . $rel)) {
                            require_once $root . $rel;
                            break;
                        }
                    }
                }

                // Alias-Map aus JLoader lesen: die Aliase sind jetzt registriert, aber
                // bei bereits geladenem Original (CMSPlugin etc.) hat Joomla sie NICHT
                // materialisiert (JLoader::load materialisiert nur registrierte Klassen,
                // keine reinen Alias-Namen) -> wir erzeugen sie selbst (s. unten).
                self::$htpAliasMap = array();

                try {
                    if (class_exists('JLoader')) {
                        $rp = new ReflectionProperty('JLoader', 'classAliases');
                        $rp->setAccessible(true);
                        $map = $rp->getValue();

                        if (is_array($map)) {
                            self::$htpAliasMap = $map;
                        }
                    }
                } catch (Exception $e) {
                    // egal
                } catch (Throwable $e) {
                    // egal
                }

                self::$htpCompatState = 1;
            }

            // NUR eine ECHTE, von compat/compat6 gelieferte Alias-Klasse materialisieren
            // UND protokollieren. Fremde J*-Klassen (z. B. JComments' JCommentsController)
            // stehen NICHT in der Map -> nicht anfassen, der echte Fehler der Erweiterung
            // bleibt sichtbar (KEINE falsche Recovery-Meldung).
            if (isset(self::$htpAliasMap[$class]) && !class_exists($class, false)) {
                $orig = ltrim((string) self::$htpAliasMap[$class], '\\');

                if ($orig !== '' && class_exists($orig, true)) {
                    @class_alias($orig, $class);
                }

                // Erst wenn der Alias wirklich materialisiert wurde: B/C reaktivieren +
                // EINMAL pro Request protokollieren - aber NUR, wenn recoverSilent()
                // tatsaechlich reaktiviert hat (es gibt false zurueck bei laufender
                // Diagnose oder bereits aktivem B/C -> kein Phantom-Eintrag).
                if (class_exists($class, false) && !self::$htpRecorded && class_exists('HtpCompat')) {
                    if (HtpCompat::recoverSilent()) {
                        self::$htpRecorded = true;
                        HtpCompat::recordRecovery($class, self::htpCompatCulpritFile());
                    }
                }
            }
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open
        }

        self::$htpCompatBusy = false;
    }

    /**
     * Engine statisch nachladen (Shutdown-/Top-Level-Kontext - keine Instanz da).
     *
     * @return boolean
     */
    private static function htpCompatEngine()
    {
        if (class_exists('HtpCompat')) {
            return true;
        }

        $bootstrap = (defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : JPATH_ROOT . '/administrator')
            . '/components/com_htprotect/core/bootstrap.php';

        if (!is_file($bootstrap)) {
            return false;
        }

        require_once $bootstrap;

        return class_exists('HtpCompat');
    }

    /**
     * Joomla-Hauptversion (gecacht). Das umschaltbare Backward-Compatibility-Plugin
     * gibt es erst ab J5 - darunter (J3/J4) ist das gesamte Compat-Feature inaktiv,
     * damit es dort NICHTS tut (J3 hat gar kein B/C-Plugin; J* sind native Klassen).
     *
     * @return integer
     */
    public static function htpJMajor()
    {
        static $m = -1;

        if ($m === -1) {
            $m = 0;

            try {
                if (class_exists('Joomla\\CMS\\Version')) {
                    $m = (int) constant('Joomla\\CMS\\Version::MAJOR_VERSION');
                }
            } catch (Exception $e) {
                // egal
            } catch (Throwable $e) {
                // egal
            }
        }

        return $m;
    }

    /**
     * Verursachende Erweiterungs-Datei aus dem aktuellen Backtrace (die Datei mit
     * `extends JPlugin` o. ae.). Sucht die erste Datei unter plugins/modules/
     * components/templates, die KEIN Joomla-Core (libraries/includes) und nicht der
     * Waechter selbst ist. Leer, wenn nichts Eindeutiges gefunden wird.
     *
     * @return string
     */
    private static function htpCompatCulpritFile()
    {
        try {
            foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 25) as $frame) {
                if (empty($frame['file'])) {
                    continue;
                }

                $f = str_replace('\\', '/', (string) $frame['file']);

                if (strpos($f, '/plugins/system/htprotectwatch/') !== false
                    || strpos($f, '/libraries/') !== false
                    || strpos($f, '/includes/') !== false) {
                    continue;
                }

                if (preg_match('~/(plugins|modules|components|templates)/~', $f)) {
                    return (string) $frame['file'];
                }
            }
        } catch (Exception $e) {
            // egal
        } catch (Throwable $e) {
            // egal
        }

        return '';
    }
}

// ── Backward-Compatibility-Recovery: so frueh wie moeglich verankern. NUR ab J5,
//    denn erst dort gibt es das umschaltbare B/C-Plugin (compat/compat6). Unter
//    J3/J4 wird hier GAR NICHTS registriert -> 100% inert (J3 hat kein B/C-Plugin,
//    J* sind native Klassen). Reiner Shutdown-Callback + ein isset() (dokumentierter
//    Superglobal-Chokepoint, reine Praesenzpruefung) -> praktisch 0 Kosten. ──
if (PlgSystemHtprotectwatch::htpJMajor() >= 5 && class_exists('Joomla\\CMS\\Plugin\\CMSPlugin')) {
    register_shutdown_function(array('PlgSystemHtprotectwatch', 'htpCompatShutdown'));

    // (Kein eigener set_exception_handler mehr: der globale Handler wird NICHT mehr
    // ersetzt. Uncaught J*-Fatals deckt das Shutdown-Netz via HTTP-500 ab - praeziser
    // und ohne die Gefahr, bei einer fremden J*-Klasse faelschlich zu "recovern".)

    if (isset($_GET['recompat'])) {
        PlgSystemHtprotectwatch::htpCompatRecompat();
    }

    // HAUPT-Mechanismus (fehler-getriggert): Fallback-Autoloader fuer Legacy-J*-
    // Klassen ANHAENGEN (niedrigste Prioritaet) - feuert nur, wenn eine Erweiterung
    // wirklich eine Legacy-Klasse braucht, die sonst niemand findet (B/C aus). Wir
    // sind das erste System-Plugin -> registriert, BEVOR dropfiles & Co. laden.
    spl_autoload_register(array('PlgSystemHtprotectwatch', 'htpCompatAutoload'));
}

}
