<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Backward-Compatibility-Recovery (Joomla 4+). Faengt Fatals ab, die durch ein
 * DEAKTIVIERTES "Behaviour - Backward Compatibility"-Plugin (behaviour/compat)
 * entstehen ("Class \"JFactory\" not found" o. ae.), reaktiviert das Plugin und
 * zeigt eine transparente Recovery-Seite mit dem konkreten Ausloeser. Plus ein
 * manueller ?recompat-Hebel (Super-User) und eine proaktive Backend-Warnung.
 *
 * Reine Logik (legacyFatalClass/culprit/isFatalType/throttled) ist getrennt
 * testbar; die Orchestratoren sind strikt fail-open (try/catch ueberall) - der
 * Schutz darf eine Site NIE zusaetzlich stoeren.
 */

defined('_JEXEC') or die;

class HtpCompat
{
    const BC_FOLDER   = 'behaviour';
    const BC_ELEMENT  = 'compat';   // Joomla 4 / 5
    const BC_ELEMENT6 = 'compat6';  // Joomla 6 (anderes Plugin, gleiche J*-Klassmap-Mechanik)
    const THROTTLE   = 120;                          // s zwischen Auto-Reaktivierungen
    const STATE_FILE = 'htprotect_compat.json';

    /** Auto-Reaktivierung NUR so lange nach dem letzten beobachteten "B/C an" (= kurz
     *  nach dem Deaktivieren). Laeuft B/C seit Wochen/Monaten aus (Nutzer-Entscheidung),
     *  wird NICHT mehr reaktiviert - nur der Alias-Crash-Schutz bleibt. */
    const REACT_WINDOW = 1800;   // 30 Min

    /** Deaktiviert der Admin B/C binnen dieser Zeit nach UNSERER Reaktivierung erneut,
     *  ist das ein bewusster Override -> kein erneutes Reaktivieren (kein Ping-Pong). */
    const OVERRIDE_GRACE = 1800;  // 30 Min

    /** Performance: ist B/C "eingerastet" dauerhaft aus, spart sich observeState() den
     *  DB-Check und prueft nur noch alle 6 h kurz gegen (fuer CLI/DB-Reaktivierungen). */
    const SETTLED_RECHECK = 21600; // 6 h

    /* ===================================================================== *
     *  REINE LOGIK (testbar, keine Seiteneffekte)
     * ===================================================================== */

    /**
     * Erkennt einen Legacy-J*-Klassen-Fatal und liefert den Klassennamen (sonst '').
     * Nur Klassen/Interfaces/Traits, die mit J + Grossbuchstabe beginnen (das vom
     * B/C-Plugin aliaste Legacy-Schema) - moderne Namespaces (App\Foo) matchen nie.
     *
     * @param   string  $message  Fehlertext aus error_get_last()
     *
     * @return string  Klassenname oder ''
     */
    public static function legacyFatalClass($message)
    {
        if (!is_string($message) || $message === '') {
            return '';
        }

        if (preg_match('~(?:Class|Interface|Trait)\s+["\']?(J[A-Z][A-Za-z0-9_]*)["\']?\s+not found~', $message, $m)) {
            return $m[1];
        }

        return '';
    }

    /**
     * Ist der Error-Typ ein (kompilier-/laufzeit-)Fatal?
     *
     * @param   int  $type  PHP-Error-Typ
     *
     * @return boolean
     */
    public static function isFatalType($type)
    {
        return in_array((int) $type, array(E_ERROR, E_COMPILE_ERROR, E_CORE_ERROR, E_PARSE, E_USER_ERROR), true);
    }

    /**
     * Datei-Pfad -> menschenlesbarer Verursacher ("Plugin system/foo", "Komponente
     * com_bar", "Modul mod_baz", "Template x", "Library y") oder Basename.
     *
     * @param   string  $file  Datei aus error_get_last()
     *
     * @return string
     */
    public static function culprit($file)
    {
        $file = str_replace('\\', '/', (string) $file);

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

        if (preg_match('~/plugins/([^/]+)/([^/]+)/~', $file, $m)) {
            return 'Plugin ' . $m[1] . '/' . $m[2];
        }

        if (preg_match('~/components/(com_[^/]+)/~', $file, $m)) {
            return 'Komponente ' . $m[1];
        }

        if (preg_match('~/modules/(mod_[^/]+)/~', $file, $m)) {
            return 'Modul ' . $m[1];
        }

        if (preg_match('~/templates/([^/]+)/~', $file, $m)) {
            return 'Template ' . $m[1];
        }

        if (preg_match('~/libraries/([^/]+)/~', $file, $m)) {
            return 'Library ' . $m[1];
        }

        return basename($file);
    }

    /**
     * Throttle-Entscheidung: liegt der letzte Lauf innerhalb des Fensters?
     *
     * @return boolean
     */
    public static function throttled($lastTs, $now, $window)
    {
        return ((int) $now - (int) $lastTs) < (int) $window;
    }

    /**
     * Muss der Waechter umsortiert werden, um garantiert ZUERST zu laden? Nur ein
     * STRIKT kleinerer ordering-Wert als das Minimum der anderen ist sicher -
     * GLEICHSTAND zaehlt als "nicht garantiert vorn" (Joomla bricht ties z. B. per
     * extension_id; ein frisch installiertes Plugin mit ordering 0 neben anderen
     * 0ern laedt zuletzt). Rein/testbar.
     *
     * @param   int  $cur       aktuelles ordering des Waechters
     * @param   int  $minOther  kleinstes ordering der anderen aktiven System-Plugins
     *
     * @return boolean
     */
    public static function reorderNeeded($cur, $minOther)
    {
        return (int) $cur >= (int) $minOther;
    }

    /**
     * REINE Reaktivierungs-Entscheidung: darf B/C automatisch reaktiviert werden?
     * Nur INNERHALB des Fensters nach dem letzten beobachteten "B/C an" (= kurz nach
     * dem Deaktivieren) UND nicht direkt nach einer eigenen Reaktivierung (Override-
     * Schutz). Lange aus / nie als an beobachtet -> nein. Testbar, keine Seiteneffekte.
     *
     * @param   int  $lastEnabled  Unix-Zeit, als B/C zuletzt als AN beobachtet wurde (0=nie)
     * @param   int  $reactAt      Unix-Zeit unserer letzten Auto-Reaktivierung (0=nie)
     * @param   int  $now          Jetzt
     * @param   int  $window       REACT_WINDOW
     * @param   int  $grace        OVERRIDE_GRACE
     *
     * @return boolean
     */
    public static function reactivationDecision($lastEnabled, $reactAt, $now, $window, $grace)
    {
        if ((int) $reactAt > 0 && ($now - (int) $reactAt) <= (int) $grace) {
            return false; // gerade reaktiviert + trotzdem (wieder) aus -> bewusster Override
        }

        if ((int) $lastEnabled <= 0) {
            return false; // nie als AN beobachtet -> konservativ (z. B. seit jeher aus)
        }

        return ($now - (int) $lastEnabled) <= (int) $window;
    }

    /**
     * Periodische, billige B/C-Zustands-Beobachtung (vom Waechter im gedrosselten
     * 15-Min-Lauf aufgerufen, im Hintergrund). Haelt `bc_last_enabled` frisch, solange
     * B/C AN ist - das ist die Basis fuer das Reaktivierungs-Fenster. PERFORMANCE: ist
     * B/C "eingerastet" dauerhaft aus, gibt es bis zur naechsten 6-h-Gegenprobe KEINEN
     * DB-Zugriff (nur ein billiger State-Read). Deckt J5 `compat` UND J6 `compat6` ab.
     *
     * @return void
     */
    public static function observeState()
    {
        try {
            if (!self::enabledByConfig()) {
                return;
            }

            $now = time();
            $st  = self::loadState();

            // Eingerastet-aus -> kein DB-Query, nur seltene Gegenprobe (Maximalperformance).
            if (!empty($st['bc_settled'])
                && isset($st['bc_settled_at'])
                && ($now - (int) $st['bc_settled_at']) < self::SETTLED_RECHECK) {
                return;
            }

            $db = self::db();

            if (!$db || !self::bcExists($db)) {
                return; // kein umschaltbares B/C-Plugin (J3/J4.0-4.3) -> nichts zu beobachten
            }

            if (!self::bcDisabled($db)) {
                // B/C ist AN -> Zeitpunkt merken, "eingerastet" loesen.
                $st['bc_last_enabled'] = $now;
                $st['bc_settled']      = 0;

                // Haelt der Admin B/C an (nicht unsere frische Reaktivierung) -> Override loeschen.
                $ra = isset($st['bc_reactivated_at']) ? (int) $st['bc_reactivated_at'] : 0;

                if ($ra === 0 || ($now - $ra) > self::OVERRIDE_GRACE) {
                    $st['bc_reactivated_at'] = 0;
                }
            } else {
                // B/C ist AUS. Ausserhalb des Fensters + kein Override mehr offen -> einrasten.
                $lastEnabled = isset($st['bc_last_enabled']) ? (int) $st['bc_last_enabled'] : 0;
                $ra          = isset($st['bc_reactivated_at']) ? (int) $st['bc_reactivated_at'] : 0;
                $beyond      = ($lastEnabled <= 0) || (($now - $lastEnabled) > self::REACT_WINDOW);
                $noOverride  = ($ra === 0) || (($now - $ra) > self::OVERRIDE_GRACE);

                if ($beyond && $noOverride) {
                    $st['bc_settled']    = 1;
                    $st['bc_settled_at'] = $now;
                }
            }

            self::saveState($st);
        } catch (\Throwable $e) {
            // egal
        } catch (\Exception $e) {
            // egal
        }
    }

    /**
     * Darf HTProtect ueberhaupt eingreifen (Alias nachliefern UND/ODER B/C reaktivieren)?
     * NUR im Zeitfenster kurz nach dem Deaktivieren. Ist die Seite NICHT unmittelbar
     * danach abgeschmiert (Fenster vorbei / lange aus), greift HTProtect GAR NICHT mehr -
     * kein Nachliefern, kein Reaktivieren, keine Meldung. Public, damit auch der
     * Wächter-Autoloader vor dem Materialisieren prüfen kann.
     */
    public static function recoveryAllowed()
    {
        $st = self::loadState();

        return self::reactivationDecision(
            isset($st['bc_last_enabled']) ? (int) $st['bc_last_enabled'] : 0,
            isset($st['bc_reactivated_at']) ? (int) $st['bc_reactivated_at'] : 0,
            time(),
            self::REACT_WINDOW,
            self::OVERRIDE_GRACE
        );
    }

    /** Nach einer Auto-Reaktivierung den Zeitpunkt merken (Override-Erkennung + Fenster). */
    private static function markReactivated()
    {
        $st = self::loadState();
        $now = time();
        $st['bc_reactivated_at'] = $now;
        $st['bc_last_enabled']   = $now; // B/C ist jetzt AN
        $st['bc_settled']        = 0;
        self::saveState($st);
    }

    /** Eingerastet-Status loesen (vom Backend bei com_plugins -> Re-Aktivierung erkennen). */
    public static function unsettle()
    {
        try {
            $st = self::loadState();

            if (!empty($st['bc_settled'])) {
                $st['bc_settled'] = 0;
                self::saveState($st);
            }
        } catch (\Throwable $e) {
        } catch (\Exception $e) {
        }
    }

    /* ===================================================================== *
     *  ORCHESTRATOREN (Seiteneffekte, fail-open)
     * ===================================================================== */

    /**
     * SHUTDOWN-FALLBACK: wurde der B/C-Fehler von einem inneren try/catch abgefangen
     * (weder unbehandelter Fatal noch ueber den globalen Handler), bleibt nur ein
     * 5xx im Response. Dann B/C STILL reaktivieren (Ausgabe ist schon raus -> kein
     * Render; der Nutzer laedt neu und die Site laeuft). Nur sinnvoll bei B/C-aus.
     *
     * @return void
     */
    public static function recoverSilent()
    {
        try {
            if (!self::enabledByConfig() || self::diagActive()) {
                return false; // Diagnose laeuft -> B/C bewusst aus lassen (Fehler sichtbar)
            }

            $db = self::db();

            if (!$db || !self::bcExists($db) || !self::bcDisabled($db)) {
                return false;
            }

            // ZEITFENSTER: nur reaktivieren, wenn B/C KURZ ZUVOR noch an war (= gerade
            // deaktiviert). Laeuft die Seite seit Wochen/Monaten bewusst ohne B/C, NICHT
            // reaktivieren - der Alias-Crash-Schutz (Autoloader) bleibt ohnehin aktiv.
            if (!self::recoveryAllowed()) {
                return false;
            }

            self::enableBc($db);
            self::markReactivated();
            self::cleanCache();

            return true; // tatsaechlich reaktiviert
        } catch (\Throwable $e) {
            return false;
        } catch (\Exception $e) {
            return false;
        }
    }

    /**
     * Universeller Shutdown-Recovery-Pfad: deckt JEDE Art von B/C-Abschaltungs-Fehler
     * ab (J5 `compat` UND J6 `compat6`) - egal ob "Class X not found", "Failed opening
     * required ..." oder anderer Fatal. Reagiert NUR, wenn B/C tatsaechlich deaktiviert
     * ist (kein Fehlalarm bei aktivem B/C). REIHENFOLGE bewusst: erst reaktivieren, dann
     * das Event sichern, Cache ZULETZT - denn cleanCache() kann im Shutdown nach einem
     * Fatal selbst straucheln und wuerde sonst Reaktivierung + Meldung verschlucken.
     *
     * @param   string  $message  Fehlermeldung (fuer die Backend-Meldung)
     * @param   string  $file     Datei des Fatals (Verursacher, falls Extension)
     *
     * @return  boolean  true, wenn reaktiviert wurde
     */
    public static function recoverFromFatal($message, $file)
    {
        try {
            if (!self::enabledByConfig() || self::diagActive()) {
                return false; // Diagnose laeuft -> B/C bewusst aus lassen (Fehler sichtbar)
            }

            $db = self::db();

            if (!$db || !self::bcExists($db) || !self::bcDisabled($db)) {
                return false; // B/C ist an / existiert nicht -> nicht die Ursache
            }

            // ZEITFENSTER (wie recoverSilent): nur direkt nach dem Deaktivieren auffangen,
            // nicht bei jedem beliebigen Fatal Monate spaeter (sonst staendig ungewolltes
            // Reaktivieren - siehe Forenfall). Lange aus -> Nutzer-Entscheidung respektieren.
            if (!self::recoveryAllowed()) {
                return false;
            }

            self::enableBc($db);                        // 1) reaktivieren (kritisch)
            self::markReactivated();                    // 1b) Zeitpunkt merken (Override/Fenster)
            self::recordFatalRecovery($message, $file); // 2) Event sichern (VOR cleanCache)
            self::rememberCrashUrl();                   // 3) URL fuer 1-Klick-Diagnose merken
            self::cleanCache();                         // 4) best effort (darf straucheln)

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

    /**
     * WURZEL-PRUEFUNG: existiert ein compat/compat6-B/C-Plugin UEBERHAUPT und ist es
     * DEAKTIVIERT? Nur dann ist HTProtect fuer fehlende J*-Klassen zustaendig. Liefert
     * unter J3 / J4.0-4.3 (kein solches Plugin) sowie bei AKTIVEM B/C immer false ->
     * verhindert Fehlalarme bei fremden J*-Klassen.
     *
     * @return boolean
     */
    public static function bcDisabledNow()
    {
        try {
            $db = self::db();

            return $db && self::bcExists($db) && self::bcDisabled($db);
        } catch (\Throwable $e) {
            return false;
        } catch (\Exception $e) {
            return false;
        }
    }

    /**
     * Manueller ?recompat-Hebel: nur eingeloggter Super-User -> B/C reaktivieren +
     * Redirect auf dieselbe URL ohne den Parameter.
     *
     * @return void
     */
    public static function recompat()
    {
        try {
            if (!self::enabledByConfig()) {
                return;
            }

            $app = self::app();

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

            $user = $app->getIdentity();

            if (!$user || !method_exists($user, 'authorise') || !$user->authorise('core.admin')) {
                return; // nur Super-User
            }

            $db = self::db();

            if (!$db || !self::bcExists($db)) {
                return;
            }

            self::enableBc($db);
            self::cleanCache();

            $url = self::currentUrlWithout('recompat');

            if (method_exists($app, 'redirect')) {
                $app->redirect($url);
            }
        } catch (\Throwable $e) {
            // fail-open
        } catch (\Exception $e) {
            // fail-open
        }
    }

    // ─── 1-Klick-Debug-Diagnose (nur wenn der Verursacher NICHT eindeutig ist) ───────

    /** Eingeloggter Super-User? */
    private static function isSuperUser($app)
    {
        try {
            if (!$app || !method_exists($app, 'getIdentity')) {
                return false;
            }

            $user = $app->getIdentity();

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

    /** Joomla-CSRF-Token im GET pruefen (Schutz der Debug-Umschalt-Links). */
    private static function checkToken()
    {
        try {
            if (class_exists('Joomla\\CMS\\Session\\Session')) {
                return (bool) \Joomla\CMS\Session\Session::checkToken('get');
            }
        } catch (\Throwable $e) {
        } catch (\Exception $e) {
        }

        return false;
    }

    /** Marker-Datei: zeigt an, dass HTProtect Debug aktiviert hat (fuer Auto-Off). */
    private static function debugFlagFile()
    {
        $dir = function_exists('htp_state_dir') ? htp_state_dir() : '';

        return $dir !== '' ? $dir . '/htprotect_debug.flag' : '';
    }

    /**
     * configuration.php ist eine kompilierte PHP-Klasse (JConfig). Nach einem Schreib-Patch MUSS
     * OPcache gezielt invalidiert werden, sonst liefert der Server dem naechsten Request die ALTE
     * kompilierte Fassung: Debug/Fehleranzeige waeren zwar gesetzt, der sysfacts-Heartbeat laese sie
     * aber stale und das Dashboard bekaeme die Aenderung erst beim naechsten regulaeren Heartbeat
     * (bis 30 Min) - das „mal geht es, mal nicht" beim schnellen Umschalten. clearstatcache dazu,
     * damit is_writable/is_file danach frisch sehen. Fail-soft: ist OPcache aus, ist es ein No-Op.
     *
     * @param   string  $file  absoluter Pfad zu configuration.php
     * @return  void
     */
    private static function invalidateConfigCache($file)
    {
        if (function_exists('opcache_invalidate')) {
            @opcache_invalidate($file, true);
        }

        if (function_exists('clearstatcache')) {
            @clearstatcache(true, $file);
        }
    }

    /**
     * Joomla-Debug gezielt umschalten ($debug in configuration.php). Mit Backup;
     * respektiert schreibgeschuetzte Config (chmod-Tanz). Schreibt NUR, wenn das
     * exakte Muster gefunden wird (sonst False, nie Config-Korruption).
     *
     * @param   boolean  $on
     * @return  boolean
     */
    public static function setJoomlaDebug($on)
    {
        try {
            $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_callback(
                '/(public\s+\$debug\s*=\s*)([\'"]?)(?:true|false|0|1)\2(\s*;)/i',
                function ($m) use ($on) {
                    // Format der Vorlage bewahren: J4-J6 nutzen ein echtes Boolean (public $debug = false;),
                    // J2.5/J3 einen gequoteten String (public $debug = '0';). So bleibt die Datei je
                    // Joomla-Generation nativ - kein Typwechsel, der irgendwo strikt verglichen wuerde.
                    $q = $m[2];

                    return $m[1] . $q . ($q === '' ? ($on ? 'true' : 'false') : ($on ? '1' : '0')) . $q . $m[3];
                },
                $src,
                1,
                $cnt
            );

            if ($new === null || $cnt < 1) {
                return false; // Muster nicht gefunden -> NICHT schreiben
            }

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

            // Backup in den GESCHUETZTEN Datenordner (nie web-ausgeliefert), NICHT
            // neben configuration.php (die Kopie enthaelt DB-Zugangsdaten); ein Docroot-
            // Alt-Backup frueherer Versionen aufraeumen.
            // Endung .php + 0600: so gibt die Kopie auch auf Servern nichts preis, die
            // die .htaccess des data/-Ordners ignorieren (nginx). Der INHALT bleibt
            // bewusst unveraendert - es ist eine reine JConfig-Klassendefinition, die
            // beim Aufruf nichts ausgibt, und laesst sich im Notfall 1:1 zurueckkopieren.
            // Die ungeschuetzte Kopie frueherer Versionen erst danach entfernen.
            if (function_exists('htp_state_file')) {
                $bak = htp_state_file('configuration.debug.bak.php');

                if (@copy($file, $bak)) {
                    @chmod($bak, 0600);
                    @unlink(htp_state_file('configuration.debug.bak'));
                }
            }

            @unlink($file . '.htpdebug.bak');
            $ro = !is_writable($file);

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

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

            if ($ro) {
                @chmod($file, 0444); // wieder schreibgeschuetzt wie vorgefunden
            }

            if ($ok) {
                self::invalidateConfigCache($file); // sonst liest der naechste Request die alte Config
            }

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

    /** Marker: ABSOLUTE Deadline (Unix-ts), ab der HTProtect error_reporting wieder auf 'none'
     *  stellt. Existiert nur, wenn HTProtect die Fehleranzeige HOCHgesetzt hat (Auto-Revert-Netz,
     *  „damit man es nicht vergisst"). Der Waechter (htprotectwatch) prueft die Frist billig. */
    const ERRDBG_FLAG = 'htprotect_errdbg.until';

    public static function errdbgFlagFile()
    {
        $dir = function_exists('htp_state_dir') ? htp_state_dir() : '';

        return $dir !== '' ? rtrim(str_replace('\\', '/', $dir), '/') . '/' . self::ERRDBG_FLAG : '';
    }

    /** Erlaubte Joomla-error_reporting-Stufen (Allow-Liste, gegen Injection ins configuration.php-Regex). */
    public static function errLevels()
    {
        return array('none', 'default', 'simple', 'maximum', 'development');
    }

    /** Stufen, bei denen Fehler SICHTBAR werden (display_errors=1) -> Auto-Revert scharf schalten. */
    public static function errLevelIsVisible($level)
    {
        return in_array(strtolower((string) $level), array('simple', 'maximum', 'development'), true);
    }

    /**
     * Joomlas $error_reporting in configuration.php gezielt setzen. Mit Backup + chmod-Tanz genau
     * wie setJoomlaDebug; schreibt NUR bei exaktem Muster (sonst false, NIE Config-Korruption).
     * $level wird streng gegen errLevels() geprueft -> keine Fremdwerte in die Datei.
     *
     * @param   string  $level  einer aus errLevels()
     * @return  boolean
     */
    public static function setJoomlaErrorReporting($level)
    {
        try {
            $level = strtolower((string) $level);

            if (!in_array($level, self::errLevels(), true)) {
                return false; // Fremdwert -> nie schreiben
            }

            $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;
            }

            // Debug-Modus an die Stufe koppeln: NUR 'maximum' = public $debug = true; jede andere
            // Stufe = false. Unabhaengig vom error_reporting-Schreibergebnis gesetzt, damit beide
            // Werte in configuration.php IMMER konsistent sind (Einzelknopf wie Batch-Aktion).
            self::setJoomlaDebug($level === 'maximum');

            $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}\'' . $level . '\'${3}',
                $src,
                1,
                $cnt
            );

            if ($new === null || $cnt < 1) {
                return false; // Muster nicht gefunden -> NICHT schreiben
            }

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

            // Backup in den GESCHUETZTEN Datenordner (enthaelt DB-Zugangsdaten), .php + 0600.
            if (function_exists('htp_state_file')) {
                $bak = htp_state_file('configuration.errdbg.bak.php');

                if (@copy($file, $bak)) {
                    @chmod($bak, 0600);
                }
            }

            $ro = !is_writable($file);

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

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

            if ($ro) {
                @chmod($file, 0444); // wieder schreibgeschuetzt wie vorgefunden
            }

            if ($ok) {
                self::invalidateConfigCache($file); // sonst liest der naechste Request die alte Config
            }

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

    /** Auto-Revert-Frist setzen (absolute Deadline in $seconds). */
    public static function errdbgArm($seconds = 1800)
    {
        $flag = self::errdbgFlagFile();

        if ($flag !== '') {
            @file_put_contents($flag, (string) (time() + (int) $seconds), LOCK_EX);
        }
    }

    /** Auto-Revert-Frist loeschen (z. B. wenn bewusst auf none/default gesetzt wurde). */
    public static function errdbgClear()
    {
        $flag = self::errdbgFlagFile();

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

    /**
     * BC-Plugin-Zustand fuer sysfacts: -1 = nicht anwendbar (kein DB/Plugin), 0 = deaktiviert,
     * 1 = aktiv. Deckt beide Elemente (compat J4/5, compat6 J6) ab.
     *
     * @return  int
     */
    public static function bcState()
    {
        try {
            $db = self::db();

            if (!$db || !self::bcExists($db)) {
                return -1;
            }

            return self::bcDisabled($db) ? 0 : 1;
        } catch (\Throwable $e) {
            return -1;
        } catch (\Exception $e) {
            return -1;
        }
    }

    /**
     * BC-Plugin(e) aktivieren (Dashboard-Befehl bc.enable). Idempotent.
     *
     * @return  string  'enabled' | 'already' | 'absent' | 'error'
     */
    public static function enableBcNow()
    {
        try {
            $db = self::db();

            if (!$db) {
                return 'error';
            }

            if (!self::bcExists($db)) {
                return 'absent';
            }

            if (!self::bcDisabled($db)) {
                return 'already';
            }

            self::enableBc($db);
            self::cleanCache();

            return 'enabled';
        } catch (\Throwable $e) {
            return 'error';
        } catch (\Exception $e) {
            return 'error';
        }
    }

    /**
     * PURE Transform: Joomlas eingeschaltetes gzip in configuration.php auf `false`
     * setzen. Matcht NUR die EIN-Formen (true / 1 / '1') - ist gzip schon aus
     * (false/0/'0') oder gar nicht vorhanden, gibt es NICHTS zu tun -> null.
     * Getrennt vom Dateisystem, damit die Regex testbar ist.
     *
     * @param   string  $src  Inhalt von configuration.php
     *
     * @return  string|null  Neuer Inhalt (gzip war AN) oder null (nichts zu tun)
     */
    public static function gzipOffTransform($src)
    {
        $cnt = 0;
        $new = preg_replace(
            '/(public\s+\$gzip\s*=\s*)(?:true|1|\'1\')(\s*;)/i',
            '${1}false${2}',
            (string) $src,
            1,
            $cnt
        );

        return ($cnt >= 1 && $new !== null) ? $new : null;
    }

    /**
     * Joomlas eigenes PHP-gzip STILL abschalten ($gzip in configuration.php). Nur wenn
     * es aktuell AN ist (sonst No-Op). Mit Backup + chmod-Tanz wie setJoomlaDebug;
     * schreibt NIE, wenn das Muster fehlt (keine Config-Korruption). Gedacht fuer
     * Server, die schon selbst komprimieren (LiteSpeed / zlib.output_compression) -
     * dort ist Joomlas gzip ueberfluessig und verursacht Doppelkompression.
     *
     * @return  boolean  true = aus (oder war schon aus); false = konnte nicht
     */
    public static function setJoomlaGzipOff()
    {
        try {
            $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;
            }

            $new = self::gzipOffTransform($src);

            if ($new === null) {
                return true; // schon aus / kein $gzip -> nichts zu tun
            }

            // Backup in den GESCHUETZTEN Datenordner (nie web-ausgeliefert), NICHT
            // neben configuration.php - die Kopie enthaelt DB-Zugangsdaten. Ein evtl.
            // im Docroot liegendes Alt-Backup frueherer Versionen aufraeumen.
            // Endung .php + 0600 wie bei setJoomlaDebug() - Begruendung siehe dort.
            if (function_exists('htp_state_file')) {
                $bak = htp_state_file('configuration.gzip.bak.php');

                if (@copy($file, $bak)) {
                    @chmod($bak, 0600);
                    @unlink(htp_state_file('configuration.gzip.bak'));
                }
            }

            @unlink($file . '.htpgzip.bak');
            $ro = !is_writable($file);

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

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

            if ($ro) {
                @chmod($file, 0444); // wieder schreibgeschuetzt wie vorgefunden
            }

            if ($ok) {
                self::invalidateConfigCache($file); // sonst liest der naechste Request die alte Config
            }

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

    /** Hat HTProtect aktuell den Debug-Modus aktiv? */
    public static function diagActive()
    {
        try {
            $state = self::loadState();

            return isset($state['diag']);
        } catch (\Throwable $e) {
            return false;
        } catch (\Exception $e) {
            return false;
        }
    }

    /**
     * 1-Klick: Joomla-Debug einschalten, damit der Super-User die Ursache beim erneuten
     * Ausloesen mit vollem Trace sieht. compat6 wird NICHT angefasst -> HTProtect bricht
     * nie selbst das Frontend (der Admin reproduziert wie beim Testen). Super-User + Token.
     */
    public static function diagEnableDebug($app)
    {
        try {
            if (!self::enabledByConfig() || !self::isSuperUser($app) || !self::checkToken()) {
                return;
            }

            if (self::setJoomlaDebug(true)) {
                $state         = self::loadState();
                $state['diag'] = array('since' => time());
                self::saveState($state);

                $flag = self::debugFlagFile();

                if ($flag !== '') {
                    @file_put_contents($flag, (string) time());
                }

                // compat6 DIREKT wieder deaktivieren -> der Fehler reproduziert sich beim
                // Redirect von selbst (mit vollem Debug-Trace). Die Auto-Recovery
                // reaktiviert es auf genau diesem Fehler-Request wieder -> Seite heilt sofort.
                $db = self::db();

                if ($db && self::bcExists($db) && !self::bcDisabled($db)) {
                    self::disableBc($db);
                    self::cleanCache();
                }
            }

            // Direkt zur ursaechlichen URL (oder Startseite) -> der Fehler erscheint sofort.
            self::redirectToCrash($app);
        } catch (\Throwable $e) {
        } catch (\Exception $e) {
        }
    }

    /** 1-Klick: Joomla-Debug wieder ausschalten. Super-User + Token. */
    public static function diagDisableDebug($app)
    {
        try {
            if (!self::isSuperUser($app) || !self::checkToken()) {
                return;
            }

            self::diagOff();
            self::redirectBack($app);
        } catch (\Throwable $e) {
        } catch (\Exception $e) {
        }
    }

    /** Auto-Abschaltung: laenger als 5 Min an -> selbst ausschalten (Sicherheitsnetz). */
    public static function diagAutoOff()
    {
        try {
            $state = self::loadState();

            if (!isset($state['diag']['since'])) {
                $flag = self::debugFlagFile(); // verwaister Marker?

                if ($flag !== '' && is_file($flag)) {
                    self::diagOff();
                }

                return;
            }

            if (time() - (int) $state['diag']['since'] > 300) {
                self::diagOff(); // Auto-Off nach 5 Min
            }
        } catch (\Throwable $e) {
        } catch (\Exception $e) {
        }
    }

    /** Debug aus + Marker weg (gemeinsamer Kern). */
    private static function diagOff()
    {
        try {
            self::setJoomlaDebug(false);

            // compat6 sicherheitshalber wieder aktivieren - diagEnableDebug hatte es
            // deaktiviert; wurde der Fehler nicht reproduziert, ist es noch aus.
            $db = self::db();

            if ($db && self::bcExists($db) && self::bcDisabled($db)) {
                self::enableBc($db);
                self::cleanCache();
            }

            $state = self::loadState();

            if (isset($state['diag'])) {
                unset($state['diag']);
                self::saveState($state);
            }

            $flag = self::debugFlagFile();

            if ($flag !== '' && is_file($flag)) {
                @unlink($flag);
            }
        } catch (\Throwable $e) {
        } catch (\Exception $e) {
        }
    }

    /** Nach der Aktion zur Referrer-/Admin-Seite zurueck. */
    private static function redirectBack($app)
    {
        try {
            if (!$app || !method_exists($app, 'redirect')) {
                return;
            }

            $url = '';

            if (method_exists($app, 'getInput')) {
                $ref = $app->getInput()->server->get('HTTP_REFERER', '', 'string');

                if (is_string($ref) && $ref !== '' && stripos($ref, 'htpcompatdebug') === false) {
                    $url = $ref;
                }
            }

            if ($url === '' && class_exists('Joomla\\CMS\\Uri\\Uri')) {
                $url = \Joomla\CMS\Uri\Uri::base();
            }

            if ($url !== '') {
                $app->redirect($url);
            }
        } catch (\Throwable $e) {
        } catch (\Exception $e) {
        }
    }

    /** B/C-Plugin (compat ODER compat6) deaktivieren - fuer die Debug-Reproduktion. */
    private static function disableBc($db)
    {
        $upd = $db->getQuery(true)
            ->update($db->quoteName('#__extensions'))
            ->set($db->quoteName('enabled') . ' = 0')
            ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
            ->where($db->quoteName('folder') . ' = ' . $db->quote(self::BC_FOLDER))
            ->where($db->quoteName('element') . ' IN (' . $db->quote(self::BC_ELEMENT) . ', ' . $db->quote(self::BC_ELEMENT6) . ')');
        $db->setQuery($upd);
        $db->execute();
    }

    /** URL des abgestuerzten Requests merken (fuer die 1-Klick-Diagnose-Reproduktion). */
    private static function rememberCrashUrl()
    {
        try {
            if (!class_exists('Joomla\\CMS\\Uri\\Uri')) {
                return;
            }

            // NUR Pfad + Query (kein Host/Schema) -> der spaetere Redirect bleibt
            // zwingend same-origin; kein Open-Redirect via Host-Header-Poisoning.
            $url = (string) \Joomla\CMS\Uri\Uri::getInstance()->toString(array('path', 'query'));

            if ($url === '' || stripos($url, 'htpcompatdebug') !== false || stripos($url, 'recompat') !== false) {
                return; // leer oder eigene Steuer-URL -> nicht merken
            }

            $state             = self::loadState();
            $state['crashurl'] = $url;
            self::saveState($state);
        } catch (\Throwable $e) {
        } catch (\Exception $e) {
        }
    }

    /** Gemerkte Crash-URL (oder ''). */
    private static function crashUrl()
    {
        try {
            $state = self::loadState();

            return isset($state['crashurl']) ? (string) $state['crashurl'] : '';
        } catch (\Throwable $e) {
            return '';
        } catch (\Exception $e) {
            return '';
        }
    }

    /** Zur Crash-URL (oder Startseite) weiterleiten - dort erscheint der Fehler. */
    private static function redirectToCrash($app)
    {
        try {
            if (!$app || !method_exists($app, 'redirect')) {
                return;
            }

            $rel  = self::crashUrl();
            $root = class_exists('Joomla\\CMS\\Uri\\Uri') ? \Joomla\CMS\Uri\Uri::root() : '';

            // Sicherheit: NUR einen relativen, same-origin Pfad zulassen (muss mit '/'
            // beginnen, kein Schema '://', kein protokoll-relatives '//'). Alles andere
            // -> Startseite. Schuetzt vor Open-Redirect.
            if ($rel !== '' && $rel[0] === '/' && strpos($rel, '//') !== 0 && strpos($rel, '://') === false) {
                $url = $root . ltrim($rel, '/');
            } else {
                $url = $root;
            }

            if ($url !== '') {
                $app->redirect($url);
            }
        } catch (\Throwable $e) {
        } catch (\Exception $e) {
        }
    }

    /** Token-geschuetzte Admin-URL fuer das Debug-Umschalten. */
    private static function diagUrl($mode)
    {
        try {
            $token = class_exists('Joomla\\CMS\\Session\\Session') ? \Joomla\CMS\Session\Session::getFormToken() : '';
            $base  = class_exists('Joomla\\CMS\\Uri\\Uri') ? \Joomla\CMS\Uri\Uri::base() : '';

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

            return $base . 'index.php?htpcompatdebug=' . $mode . '&' . $token . '=1';
        } catch (\Throwable $e) {
            return '';
        } catch (\Exception $e) {
            return '';
        }
    }

    /** Button zum Debug-Einschalten (Diagnose) - blau, deutlich abgesetzt. */
    private static function diagEnableLink()
    {
        $url = self::diagUrl('on');

        if ($url === '' || !function_exists('htp_t')) {
            return '';
        }

        return '<a href="' . htp_esc($url) . '" class="btn btn-primary btn-sm" role="button"'
            . ' style="display:inline-block;margin-top:.4rem">'
            . htp_esc(htp_t('COM_HTPROTECT_COMPAT_DEBUG_LINK')) . '</a>';
    }

    /** Button zum Debug-Ausschalten - gruen (Normalzustand wiederherstellen). */
    private static function diagDisableLink()
    {
        $url = self::diagUrl('off');

        if ($url === '' || !function_exists('htp_t')) {
            return '';
        }

        return '<a href="' . htp_esc($url) . '" class="btn btn-success btn-sm" role="button"'
            . ' style="display:inline-block;margin-top:.4rem">'
            . htp_esc(htp_t('COM_HTPROTECT_COMPAT_DEBUG_OFFLINK')) . '</a>';
    }

    /**
     * Proaktive Backend-Warnung: ist B/C deaktiviert, einmal pro Session einen
     * Hinweis mit ?recompat-Tipp in die Joomla-Nachrichtenschlange legen.
     *
     * @param   object  $app  Anwendung (Administrator)
     *
     * @return void
     */
    public static function backendNotice($app)
    {
        try {
            if (!$app || !method_exists($app, 'enqueueMessage')) {
                return;
            }

            // Sprachdatei laden - auf Nicht-HTProtect-Admin-Seiten ist sie sonst NICHT
            // geladen und htp_t() liefert nur den nackten Key (-> Meldung verworfen).
            if (function_exists('htp_load_lang')) {
                htp_load_lang();
            }

            // (1) Auto-Recovery-Meldungen: EINMAL zeigen (direkt als Reaktion auf die
            //     Recovery), dann verwerfen - sonst klebt die Meldung dauerhaft oben.
            $events = self::recoveredEvents();

            foreach ($events as $ev) {
                if (!function_exists('htp_t')) {
                    break;
                }

                if (array_key_exists('detail', $ev)) {
                    // Fatal-Event aus dem Shutdown-Netz.
                    if ($ev['detail'] !== '') {
                        // Konkreter Fehlertext (+ Verursacher falls ableitbar -> fett).
                        $key = 'COM_HTPROTECT_COMPAT_RECOVERED_FATAL';
                        $d   = (isset($ev['culprit']) && $ev['culprit'] !== '')
                            ? '<strong>' . htp_esc($ev['culprit']) . '</strong> - ' . htp_esc($ev['detail'])
                            : htp_esc($ev['detail']);
                        $msg = htp_t($key, $d); // $d ist bereits escaped/markiert
                    } else {
                        // Kein Detail ermittelbar (J6/Symfony schluckt den Fatal) -> generisch.
                        $key = 'COM_HTPROTECT_COMPAT_RECOVERED_GENERIC';
                        $msg = htp_t($key);
                    }
                } else {
                    // Legacy-J*-Klasse via Autoloader -> Verursacher FETT, Klasse als <code>.
                    $key     = 'COM_HTPROTECT_COMPAT_RECOVERED';
                    $culprit = (isset($ev['culprit']) && $ev['culprit'] !== '')
                        ? '<strong>' . htp_esc($ev['culprit']) . '</strong>'
                        : htp_esc(htp_t('COM_HTPROTECT_COMPAT_ANEXT'));
                    $cls     = isset($ev['class']) ? (string) $ev['class'] : '?';
                    $msg     = htp_t($key, $culprit, htp_esc($cls));
                }

                // Verursacher NICHT eindeutig (kein culprit)? -> 1-Klick-Debug-Diagnose
                // anbieten (nur Super-User, nur wenn Debug nicht eh schon laeuft).
                if ((!isset($ev['culprit']) || $ev['culprit'] === '') && !self::diagActive() && self::isSuperUser($app)) {
                    $link = self::diagEnableLink();

                    if ($link !== '') {
                        $msg .= ' ' . $link;
                    }
                }

                if ($msg !== '' && $msg !== $key) {
                    $app->enqueueMessage($msg, 'warning');
                }
            }

            if (!empty($events)) {
                self::clearRecovered(); // einmal gezeigt -> nicht wieder
            }

            // (1b) Debug-Diagnose laeuft -> persistenter Hinweis + 1-Klick-Ausschalten
            //      (Super-User; jede Admin-Seite, bis Debug aus ist).
            if (self::diagActive() && self::isSuperUser($app)) {
                $m    = htp_t('COM_HTPROTECT_COMPAT_DEBUG_ACTIVE');
                $link = self::diagDisableLink();

                if ($link !== '') {
                    $m .= ' ' . $link;
                }

                if ($m !== '' && $m !== 'COM_HTPROTECT_COMPAT_DEBUG_ACTIVE') {
                    $app->enqueueMessage($m, 'info'); // blau - klar abgesetzt von den gelben Recovery-Warnungen
                }
            }

            // (2) Ist B/C AKTUELL deaktiviert -> Hinweis (1x/Session, im Plugin-Manager).
            if (!self::enabledByConfig()) {
                return;
            }

            $session = method_exists($app, 'getSession') ? $app->getSession() : null;

            if ($session && $session->get('htp_compat_warned')) {
                return;
            }

            $db = self::db();

            if ($db && self::bcExists($db) && self::bcDisabled($db)) {
                $msg = function_exists('htp_t') ? htp_t('COM_HTPROTECT_COMPAT_WARN') : '';

                if ($msg !== '' && $msg !== 'COM_HTPROTECT_COMPAT_WARN') {
                    $app->enqueueMessage($msg, 'warning');
                }
            }

            if ($session) {
                $session->set('htp_compat_warned', 1);
            }
        } catch (\Throwable $e) {
            // fail-open
        } catch (\Exception $e) {
            // fail-open
        }
    }

    /**
     * Eine Auto-Recovery protokollieren (fuer die Backend-Meldung): welche veraltete
     * Klasse welche Erweiterung gebraucht hat. Dedupliziert, gedeckelt.
     *
     * @param   string  $class        angeforderte Legacy-Klasse (z. B. JPlugin)
     * @param   string  $culpritFile  Datei der verursachenden Erweiterung (oder '')
     *
     * @return void
     */
    public static function recordRecovery($class, $culpritFile)
    {
        try {
            $culprit = self::culprit((string) $culpritFile);
            $state   = self::loadState();
            $events  = isset($state['events']) && is_array($state['events']) ? $state['events'] : array();
            $key     = (string) $class . '|' . $culprit;

            foreach ($events as $e) {
                if (isset($e['k']) && $e['k'] === $key) {
                    return; // schon erfasst
                }
            }

            $events[] = array('k' => $key, 'class' => (string) $class, 'culprit' => $culprit, 'time' => time());

            if (count($events) > 8) {
                $events = array_slice($events, -8);
            }

            $state['events'] = $events;
            self::saveState($state);
        } catch (\Throwable $e) {
            // egal
        } catch (\Exception $e) {
            // egal
        }
    }

    /**
     * Recovery nach einem GENERISCHEN Fatal protokollieren (Shutdown-Pfad): wenn eine
     * Legacy-Klasse, die NICHT J* heisst (z. B. J6 `FinderIndexerAdapter`), zu einem
     * "Failed opening required ..."-Fatal fuehrt, fuer das es keinen Klassennamen gibt.
     * Speichert die (gekuerzte) Fehlermeldung als Detail.
     *
     * @param   string  $message  Fatal-Meldung (error_get_last)
     * @param   string  $file     Datei mit dem Fatal (falls Extension -> Verursacher)
     *
     * @return void
     */
    public static function recordFatalRecovery($message, $file)
    {
        try {
            $detail  = self::shortFatal((string) $message); // ggf. '' (J6/Symfony loescht error_get_last)
            $culprit = self::culprit((string) $file);
            $state   = self::loadState();
            $events  = isset($state['events']) && is_array($state['events']) ? $state['events'] : array();
            $key     = 'fatal|' . $detail;

            foreach ($events as $e) {
                if (isset($e['k']) && $e['k'] === $key) {
                    return; // schon erfasst
                }
            }

            $events[] = array('k' => $key, 'detail' => $detail, 'culprit' => $culprit, 'time' => time());

            if (count($events) > 8) {
                $events = array_slice($events, -8);
            }

            $state['events'] = $events;
            self::saveState($state);
        } catch (\Throwable $e) {
            // egal
        } catch (\Exception $e) {
            // egal
        }
    }

    /**
     * Fatal-Meldung fuer die Anzeige kuerzen (include_path-Anhang weg, deckeln).
     *
     * @param   string  $msg
     *
     * @return  string
     */
    private static function shortFatal($msg)
    {
        $msg = trim((string) $msg);
        $p   = strpos($msg, ' (include_path');

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

        if (function_exists('mb_strlen') ? mb_strlen($msg) > 200 : strlen($msg) > 200) {
            $msg = (function_exists('mb_substr') ? mb_substr($msg, 0, 200) : substr($msg, 0, 200)) . "\xE2\x80\xA6";
        }

        return $msg;
    }

    /**
     * Offene Auto-Recovery-Ereignisse (fuer die Backend-Meldung).
     *
     * @return array
     */
    public static function recoveredEvents()
    {
        try {
            $state  = self::loadState();
            $events = isset($state['events']) && is_array($state['events']) ? $state['events'] : array();
            $cutoff = time() - 604800; // Stale-Schutz: Events aelter als 7 Tage nie zeigen
            $out    = array();

            foreach ($events as $e) {
                if (!isset($e['time']) || (int) $e['time'] >= $cutoff) {
                    $out[] = $e;
                }
            }

            return $out;
        } catch (\Throwable $e) {
            return array();
        } catch (\Exception $e) {
            return array();
        }
    }

    /**
     * Recovery-Meldungen quittieren (wird beim Oeffnen der HTProtect-Uebersicht
     * aufgerufen).
     *
     * @return void
     */
    public static function clearRecovered()
    {
        try {
            $state = self::loadState();

            if (isset($state['events'])) {
                unset($state['events']);
                self::saveState($state);
            }
        } catch (\Throwable $e) {
            // egal
        } catch (\Exception $e) {
            // egal
        }
    }

    /**
     * htprotectwatch als ERSTES System-Plugin einsortieren (niedrigste ordering).
     * Schliesst die Recovery-Luecke (Shutdown-Handler vor fremden System-Plugins
     * registriert) UND laesst den PHP-WAF/Selbstheilung vor anderen Plugins laufen.
     * Idempotent: aendert nur, wenn der Waechter nicht ohnehin schon vorn liegt.
     *
     * @param   object|null  $db  optionale DB-Instanz
     *
     * @return boolean  true = vorn (oder bereits vorn)
     */
    public static function ensureWatchFirst($db = null)
    {
        try {
            $db = $db ? $db : self::db();

            if (!$db) {
                return false;
            }

            $minOther = (int) self::scalar(
                $db,
                $db->getQuery(true)
                    ->select('MIN(' . $db->quoteName('ordering') . ')')
                    ->from($db->quoteName('#__extensions'))
                    ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
                    ->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
                    ->where($db->quoteName('enabled') . ' = 1')
                    ->where($db->quoteName('element') . ' <> ' . $db->quote('htprotectwatch'))
            );

            $cur = self::scalar(
                $db,
                $db->getQuery(true)
                    ->select($db->quoteName('ordering'))
                    ->from($db->quoteName('#__extensions'))
                    ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
                    ->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
                    ->where($db->quoteName('element') . ' = ' . $db->quote('htprotectwatch'))
            );

            if ($cur === null) {
                return false; // Waechter nicht (mehr) registriert
            }

            if (!self::reorderNeeded($cur, $minOther)) {
                return true; // strikt vorn -> nichts tun (idempotent)
            }

            $upd = $db->getQuery(true)
                ->update($db->quoteName('#__extensions'))
                ->set($db->quoteName('ordering') . ' = ' . (int) ($minOther - 1))
                ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
                ->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('htprotectwatch'));
            $db->setQuery($upd);
            $db->execute();

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

    /* ===================================================================== *
     *  INTERNE HELFER
     * ===================================================================== */

    private static function enabledByConfig()
    {
        if (!class_exists('HtpConfig')) {
            return true; // ohne Config: Sicherheitsnetz aktiv
        }

        $cfg = HtpConfig::load();

        return !isset($cfg['compat_guard']) || !empty($cfg['compat_guard']);
    }

    /**
     * Oeffentlicher Toggle-Check (fuer den praeemptiven Waechter-Pfad).
     *
     * @return boolean
     */
    public static function guardEnabled()
    {
        try {
            return self::enabledByConfig();
        } catch (\Throwable $e) {
            return true;
        } catch (\Exception $e) {
            return true;
        }
    }

    private static function db()
    {
        return function_exists('htp_db') ? htp_db() : null;
    }

    private static function app()
    {
        try {
            if (class_exists('Joomla\\CMS\\Factory')) {
                return \Joomla\CMS\Factory::getApplication();
            }
        } catch (\Throwable $e) {
            return null;
        } catch (\Exception $e) {
            return null;
        }

        return null;
    }

    private static function scalar($db, $query)
    {
        $db->setQuery($query);

        return $db->loadResult();
    }

    private static function bcExists($db)
    {
        return (int) self::scalar(
            $db,
            $db->getQuery(true)
                ->select('COUNT(*)')
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
                ->where($db->quoteName('folder') . ' = ' . $db->quote(self::BC_FOLDER))
                ->where($db->quoteName('element') . ' IN (' . $db->quote(self::BC_ELEMENT) . ', ' . $db->quote(self::BC_ELEMENT6) . ')')
        ) > 0;
    }

    private static function bcDisabled($db)
    {
        $enabled = self::scalar(
            $db,
            $db->getQuery(true)
                ->select($db->quoteName('enabled'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
                ->where($db->quoteName('folder') . ' = ' . $db->quote(self::BC_FOLDER))
                ->where($db->quoteName('element') . ' IN (' . $db->quote(self::BC_ELEMENT) . ', ' . $db->quote(self::BC_ELEMENT6) . ')')
        );

        return $enabled !== null && (int) $enabled === 0;
    }

    private static function enableBc($db)
    {
        $upd = $db->getQuery(true)
            ->update($db->quoteName('#__extensions'))
            ->set($db->quoteName('enabled') . ' = 1')
            ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
            ->where($db->quoteName('folder') . ' = ' . $db->quote(self::BC_FOLDER))
            ->where($db->quoteName('element') . ' IN (' . $db->quote(self::BC_ELEMENT) . ', ' . $db->quote(self::BC_ELEMENT6) . ')');
        $db->setQuery($upd);
        $db->execute();
    }

    private static function cleanCache()
    {
        try {
            if (!class_exists('Joomla\\CMS\\Cache\\Cache')) {
                return;
            }

            foreach (array('_system', 'com_plugins') as $grp) {
                try {
                    $cache = \Joomla\CMS\Cache\Cache::getInstance('callback', array('defaultgroup' => $grp));

                    if (is_object($cache) && method_exists($cache, 'clean')) {
                        $cache->clean($grp);
                    }
                } catch (\Throwable $e) {
                } catch (\Exception $e) {
                }
            }
        } catch (\Throwable $e) {
        } catch (\Exception $e) {
        }
    }

    private static function currentUrlWithout($param)
    {
        try {
            if (class_exists('Joomla\\CMS\\Uri\\Uri')) {
                $uri = \Joomla\CMS\Uri\Uri::getInstance();
                $uri->delVar($param);

                // Nur Pfad + Query (same-origin) - kein Host/Schema im Redirect.
                return $uri->toString(array('path', 'query'));
            }
        } catch (\Throwable $e) {
        } catch (\Exception $e) {
        }

        return isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '/';
    }

    private static function stateDir()
    {
        return function_exists('htp_state_dir') ? htp_state_dir() : '';
    }

    private static function loadState()
    {
        $dir = self::stateDir();

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

        $file = rtrim(str_replace('\\', '/', $dir), '/') . '/' . self::STATE_FILE;

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

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

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

    private static function saveState($state)
    {
        $dir = self::stateDir();

        if ($dir === '' || !is_dir($dir)) {
            return;
        }

        $file = rtrim(str_replace('\\', '/', $dir), '/') . '/' . self::STATE_FILE;
        @file_put_contents($file, json_encode($state), LOCK_EX);
    }

}
