<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Erzeugt aus der Konfiguration die komplette Haupt-.htaccess und
 * simuliert die Regeln fuer Site-Scan und Pfad-Test. Eigenentwicklung:
 * endungsbasiertes Dateischild + Ausnahme-Bedingungen statt
 * verzeichnisbasierter Passthrough-Listen.
 */

defined('_JEXEC') or die;

class HtpBuilder
{
    /**
     * Muster fuer ausfuehrbare PHP-Dateien (inkl. Doppel-Endungs-Trick).
     */
    const PHP_RULE_PATTERN = '\.(php[0-9]*|phtml|pht|phps|phar)(\.|/|$)';

    /**
     * Geteiltes PHP-Schild (RULESET 4):
     *  - PATHINFO = nachgestelltes Segment/Doppelendung (.php/ , .php.gif-Tarnung)
     *    -> IMMER 403 (PATH_INFO-Bypass-Schutz, unabhaengig von der Existenz).
     *  - DIRECT   = direkte .php-Anfrage -> nur 403, wenn die Datei TATSAECHLICH
     *    existiert (Shell). Nicht vorhandene .php-Pfade reicht Joomla an die echte
     *    index.php weiter (Non-SEF-Routing, z. B. relative index.php-Links bei
     *    abweichendem <base href>) -> nicht faelschlich blocken. Upload-Ordner
     *    (images/media) bleiben zusaetzlich ueber ihr eigenes Schild (DirShield)
     *    gegen PHP gesperrt.
     */
    const PHP_RULE_PATHINFO = '\.(php[0-9]*|phtml|pht|phps|phar)(\.|/)';
    const PHP_RULE_DIRECT   = '\.(php[0-9]*|phtml|pht|phps|phar)$';

    /**
     * Dasselbe Muster fuer die PHP-Simulation.
     */
    const PHP_SIM_PATTERN = '#\.(php[0-9]*|phtml|pht|phps|phar)(\.|/|$)#i';

    /**
     * Namentlich gesperrte sensible Dateien (alphabetisch).
     *
     * Enthaelt bewusst auch HTProtects EIGENE Laufzeit-/Zustandsdateien
     * (htprotect_*.json/.log/.state ...) und die Backup-Artefakte
     * (files.zip/files.tar.gz/db.sql.gz) - der data/-Ordner schuetzt sich sonst
     * nur ueber seine eigene .htaccess, die auf nginx-Hosts wirkungslos ist.
     * NAMENTLICH statt ueber SENSITIVE_EXT: die Endungen json/gz/zip global zu
     * sperren wuerde regulaere Seiteninhalte (Sprach-/Konfig-JSON, vorkomprimierte
     * Assets, Downloads) mit abschiessen. Der Backup-Download laeuft ueber den
     * signierten PHP-Endpunkt und ist davon nicht betroffen.
     */
    const SENSITIVE_NAMES = '^(\.user\.ini|CHANGELOG(\.md|\.php|\.txt)?|composer\.(json|lock)|configuration\.php(-dist)?|configuration\.(debug|gzip)\.bak(\.php)?|CONTRIBUTING\.md|db\.sql\.gz|error_log|files\.(tar\.gz|tar|zip)(\.part)?|htaccess\.txt|htprotect_[A-Za-z0-9_.-]+\.(bak|flag|json|lock|log|part|state|tick|triggers)(\.php)?|joomla\.xml|LICENSE(\.md|\.txt)?|package(-lock)?\.json|php\.ini|phpunit(\.dist)?\.xml(\.dist)?|README(\.md|\.txt)?|web\.config(\.txt)?)$';

    /**
     * Gesperrte Datei-Endungen: Sicherungen, Schluessel, Datenbanken,
     * Logs, Konfigurationen (alphabetisch).
     */
    const SENSITIVE_EXT = '\.(bak|bck|cer|conf|crt|csr|der|env|inc|ini|j[0-9]{2}|jpa|jps|key|log|mdb|old|orig|p12|pem|pfx|phps|sav|save|sql|sqlite|sqlite3|swo|swp)$';

    /**
     * Statische Theme-Assets, die Template-Frameworks (Astroid, Gantry, T3,
     * YOOtheme ...) unter cache/ minimiert ablegen und ueber das Web ausliefern.
     * Nur unkritische Endungen (CSS/JS/Schriften/Bilder) - serialisierte
     * Cache-Daten, .php und alles andere in cache/ bleiben gesperrt.
     */
    const CACHE_ASSET_PATTERN = '^(administrator/)?cache/.+\.(css|js|mjs|woff2?|ttf|otf|eot|svg|svgz|png|jpe?g|gif|webp|avif|ico)$';

    /**
     * Version des erzeugten Regelwerks (NICHT die Komponenten-Version).
     * NUR erhoehen, wenn sich der .htaccess-Output aendert (neue/geaenderte
     * Regeln). Eine bereits geschriebene Datei mit aelterem (oder fehlendem)
     * rs= im Marker gilt dann als "neu schreiben faellig" - so kommt eine
     * Regel-Verbesserung beim Update ohne stilles Datei-Schreiben beim Nutzer
     * an (Ampel "Schutzschild nicht auf aktuellem Stand").
     *   1 = Erstfassung
     *   2 = statische Theme-Assets aus cache/ ausgenommen (Astroid/Gantry)
     *   3 = Dateischild pfad-relativ (Umgebungs-Flag statt REQUEST_URI-Praefix):
     *       kein eingebackener Site-Pfad mehr -> Template-Klon in beliebigem
     *       Unterverzeichnis ohne Backend-Lockout
     *   4 = PHP-Schild geteilt: direkte .php nur bei vorhandener Datei (-f)
     *       blocken; .php/ (PATH_INFO) + Doppelendung weiter immer. Nicht
     *       existierende .php-Pfade werden geroutet (kein Fehl-403 bei Joomlas
     *       Non-SEF-index.php-Links). Upload-Ordner bleiben via DirShield dicht.
     *   6 = Vorkomprimierte Auslieferung (.br/.gz) mit [L]: sonst fiel die auf
     *       .gz/.br umgeschriebene Asset-Anfrage in den Joomla-Router durch und
     *       landete bei index.php -> 404/unstyled Backend auf REINEM Apache
     *       (nginx/Plesk liefert Statik direkt, daher dort nie aufgefallen).
     *   7 = Vorkomprimierte Auslieferung (.br/.gz) ERSATZLOS ENTFERNT: auf
     *       Servern, die .gz selbst encodieren (mod_mime), addierte der eigene
     *       Content-Encoding-Header ein zweites "gzip" -> "Content-Encoding:
     *       gzip, gzip" bei nur einfach komprimiertem Body -> CSS/JS der ganzen
     *       Seite unentschluesselbar (ERR_CONTENT_DECODING_FAILED, HTTP 200).
     *       Kein Joomla-Standardverhalten, nischiger Nutzen, reales Risiko.
     *       Live-Kompression (p_compress) bleibt und liefert den Nutzen sicher.
     *   8 = Sensible Dateinamen ergaenzt: HTProtects eigene Zustandsdateien
     *       (htprotect_*.json/.log/.state ...), die Config-Sicherungskopien und
     *       die Backup-Artefakte (files.zip/files.tar.gz/db.sql.gz) werden jetzt
     *       auch NAMENTLICH abgewiesen - greift damit ebenso im generierten
     *       nginx-Block, wo die .htaccess im data/-Ordner nicht wirkt.
     *   9 = Ruleset-Versions-Marker reaktiviert: shieldOutdated wertet jetzt das rs=
     *       im begin-Marker aus - eine mit AELTERER RULESET_VERSION geschriebene
     *       .htaccess gilt als "neu schreiben faellig". So kommt ein bewusst
     *       erhoehtes RULESET_VERSION flottenweit an und zieht beim naechsten
     *       Self-Heal ALLE aktuellen Signaturen (reihenfolge-unabhaengiges JCE,
     *       Gridbox, Balbooa ...) auch in die .htaccess-DATEI - nicht nur in die
     *       Echtzeit-WAF. Genau EIN Neuschreiben je Site (rs= konvergiert danach auf
     *       RULESET_VERSION); die Waechter-Baseline wird vom Self-Heal mitgehoben,
     *       daher KEIN "von aussen geaendert"-Fehlalarm. Der reine Bump von 8 auf 9
     *       ist der Ausloeser fuer diesen einmaligen Nachzug.
     *  10 = Feste Freigabe der BackupMonkey/RemoteMonkey-Kern-Update-Endpunkte
     *       (components/com_remotemonkey/restore.php + extract.php, David Jardin) -
     *       analog zu com_joomlaupdate. Der Bump zieht die neue Ausnahme beim
     *       naechsten Self-Heal flottenweit in die .htaccess-DATEI.
     *  11 = FP-freie Verschaerfung der Gridbox-showImage-Signatur (scope=all, in der
     *       .htaccess): legitime Bilder als absoluter Pfad/volle URL werden nicht mehr
     *       geblockt (Fall prolocore-vigezzo.it). Da der Signatur-Block vom
     *       shieldOutdated-Inhaltsvergleich ausgenommen ist, holt NUR dieser Bump die
     *       korrigierte Regel flottenweit in die .htaccess-DATEI (Echtzeit-WAF hat sie
     *       ohnehin sofort ueber den Feed).
     */
    const RULESET_VERSION = 11;

    /**
     * Kurz-ID der Konfiguration (fuer den Datei-Marker).
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return string
     */
    public static function configHash($cfg)
    {
        $relevant = $cfg;
        // Laufzeit-/Verwaltungsdaten, die den Inhalt der Haupt-.htaccess
        // NICHT als Konfigurationsaenderung gelten sollen, aus dem Marker-Hash
        // heraushalten. Insb. defs_version: ein Signaturen-Feed-Update darf
        // KEIN "noch nicht geschrieben" ausloesen - die Echtzeit-WAF nutzt die
        // neuen Signaturen ohnehin sofort. x_*, api_mode, emergency_lock bleiben
        // im Hash (echte Einstellungsaenderungen -> Schreiben faellig).
        unset(
            $relevant['file_sha1'], $relevant['last_written'], $relevant['last_test'], $relevant['guard_dir'],
            $relevant['watch_manifest'], $relevant['notify_email'], $relevant['notify_reminder_days'], $relevant['unsub_secret'], $relevant['unsub_base'],
            $relevant['defs_version'], $relevant['u_harden'], $relevant['u_dirs'], $relevant['api_router'],
            $relevant['plugin_initialized'], $relevant['quickicon_initialized'], $relevant['autosecure'], $relevant['autosecure_last'],
            $relevant['tmp_autoclean'],
            // Ueberwachungs-/Verwaltungsdaten ohne Einfluss auf die Haupt-.htaccess
            $relevant['mal_whitelist'], $relevant['accounts_watch'], $relevant['account_baseline'], $relevant['site_fp'], $relevant['admin_whitelist'],
            $relevant['compat_guard'],
            // Ergebnis-Cache des .htaccess-Funktionstests (reine Laufzeit-Erkennung)
            $relevant['htaccess_cap'],
            // SEO-/Defacement-Waechter (Ueberwachung, kein .htaccess-Einfluss)
            $relevant['seo_watch'], $relevant['seo_cloaking_auto'], $relevant['seo_whitelist'],
            $relevant['seo_topic_ack'], $relevant['seo_baseline'],
            $relevant['seo_overview_ts'], $relevant['seo_overview_finding'],
            $relevant['core_overview_ts'], $relevant['core_overview_finding'],
            // Schwarm-Schutz (Beitrags-/Rueckkopplungs-State, kein .htaccess-Einfluss)
            $relevant['swarm_contrib_id'], $relevant['swarm_acked'],
            // Erweiterungs-Auto-Update (eigenes Modul, kein .htaccess-Einfluss)
            $relevant['autoupdate_ext'], $relevant['autoupdate_all'], $relevant['autoupdate_manual'],
            $relevant['autoupdate_include'], $relevant['autoupdate_keep'], $relevant['autoupdate_interval'],
            $relevant['autoupdate_schedule'], $relevant['autoupdate_hour'], $relevant['autoupdate_weekday'],
            $relevant['autoupdate_mail_success']
        );
        ksort($relevant);

        return substr(sha1(json_encode($relevant)), 0, 12);
    }

    /**
     * Eine erzeugte/auf der Platte liegende .htaccess fuer den INHALTSVERGLEICH
     * normalisieren: die volatilen Teile der Begin-Markerzeile (Version v=,
     * Zeitstempel written=, Fingerabdruecke rs=/cfg=) ausblenden und Zeilenenden
     * vereinheitlichen. So laesst sich pruefen, ob die FRISCH erzeugten Regeln
     * sich vom Stand auf der Platte unterscheiden - ein reines Versions-/Marker-
     * Update ohne echte Regelaenderung ergibt identischen Inhalt und loest somit
     * keinen "neu schreiben"-Hinweis aus.
     *
     * @param   string  $content  .htaccess-Inhalt
     *
     * @return string
     */
    public static function normalizeForCompare($content)
    {
        $content = str_replace("\r\n", "\n", (string) $content);

        // (1) Signatur-Block ausblenden - FORMAT-UNABHAENGIG: vom ersten Signatur-
        //     Indikator (sigs-Marker ODER erste "# sig:"-Zeile) bis vor das
        //     schliessende </IfModule> des Exploit-Schilds. So loest WEDER ein
        //     Defs-Feed-/Builtin-Update NOCH die Einfuehrung der sigs-Marker
        //     (reine Format-Aenderung) ein "neu schreiben" aus. Der /api-Block
        //     davor bleibt erhalten -> api_mode wird weiter erkannt. Eine vom
        //     Nutzer ABGESCHALTETE Signatur wird separat geprueft (shieldOutdated).
        $content = preg_replace(
            '~\n[ \t]*(?:# htprotect-sigs:begin|# sig:).*?(?=\n[ \t]*</IfModule>)~s',
            "\n    # htprotect-sigs",
            (string) $content
        );

        // (2) Begin-Markerzeile (Version v=, Fingerabdruecke rs=/cfg=, Zeitstempel
        //     written=) komplett ausblenden.
        $content = preg_replace('~^# htprotect-shield:begin .*$~m', '# htprotect-shield:begin', (string) $content);

        // (3) Verbleibende Datums-/Zeitstempel neutralisieren - vor allem die
        //     "Stand: ..."-Kopfzeile traegt denselben Build-Zeitpunkt und wuerde
        //     den Vergleich sonst IMMER scheitern lassen (Dauer-"neu schreiben").
        $content = preg_replace('~\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}~', 'DATE', (string) $content);

        // (3b) Versionsnummern NUR in KOMMENTARZEILEN neutralisieren. Die Kopfzeile
        //      "... erzeugt von HTProtect Server-Schild 2.2.54" traegt die Version
        //      ein zweites Mal (neben dem in (2) ausgeblendeten begin-Marker) - ein
        //      reines Versions-Update wuerde sonst hier einen falschen "neu
        //      schreiben"-Hinweis ausloesen. Bewusst NUR auf Zeilen, die mit '#'
        //      beginnen: Kommentare aendern das Apache-Verhalten nie, also kann das
        //      kein echtes Regel-Update verschlucken. Funktionale Direktiven (z. B.
        //      "Require ip 192.168.1.4" in Eigenregeln) bleiben unangetastet -> eine
        //      echte Eigenregel-Aenderung loest weiterhin korrekt "faellig" aus.
        $content = preg_replace('~^(#.*?)\d+\.\d+\.\d+~m', '${1}VER', (string) $content);

        // (3c) SPRACHUNABHAENGIG: reine Kommentarzeilen komplett entfernen. Kommentare
        //      aendern das Apache-Verhalten NIE, ihr Text ist aber SPRACHABHAENGIG (die
        //      Kopf-/Abschnitts-Kommentare kommen aus htp_tc-Uebersetzungen). Ein Wechsel
        //      der Backend-Sprache wuerde den Vergleich sonst scheitern lassen und die
        //      .htaccess DAUERHAFT faelschlich als "veraltet" (orange "Aktualisierung
        //      verfuegbar") zeigen - obwohl sich keine einzige Regel geaendert hat. Nur
        //      Zeilen, die nach optionalem Whitespace mit '#' beginnen; funktionale
        //      Direktiven tragen in der erzeugten .htaccess nie ein fuehrendes '#'. So
        //      loest AUSSCHLIESSLICH eine echte Regel-/Direktiven-Aenderung noch "faellig"
        //      aus -> die Ampel bleibt bei blossen Sprach-/Kommentar-/Versionswechseln gruen.
        $content = preg_replace('~^[ \t]*#.*$~m', '', (string) $content);

        // (4) Leerzeilen vereinheitlichen: das format-unabhaengige Ausblenden in
        //     (1) kann je nach Quelle (mit/ohne Marker) eine Leerzeile mehr/weniger
        //     hinterlassen - rein kosmetisch, darf das Urteil nicht kippen.
        $content = preg_replace('~\n{2,}~', "\n", (string) $content);

        return (string) $content;
    }

    /**
     * Ist ein Neu-Schreiben der .htaccess faellig? Inhaltsbasiert + robust:
     *  1) Der Regel-Koerper (ohne Marker/Zeitstempel/Signatur-Block) weicht vom
     *     frisch erzeugten ab -> echte Konfig-/Regelaenderung (auch fremd-
     *     modifiziert). Ein reines Versions-/Feed-Update ohne Output-Aenderung
     *     loest hier NICHTS aus.
     *  2) Eine vom Nutzer ABGESCHALTETE Signatur (x_sigs_off) ist auf der Platte
     *     noch aktiv -> muss raus (der Signatur-Block wird in (1) ja ignoriert,
     *     darum hier explizit). Das Wieder-EINschalten einer Signatur greift
     *     dagegen ohnehin sofort ueber die Echtzeit-WAF -> kein Zwang zu schreiben.
     *
     * @param   array   $cfg   Konfiguration
     * @param   string  $disk  Aktueller .htaccess-Inhalt von der Platte
     *
     * @return boolean
     */
    public static function shieldOutdated($cfg, $disk)
    {
        if (self::normalizeForCompare($disk) !== self::normalizeForCompare(self::build($cfg))) {
            return true;
        }

        // Ruleset-Versions-Marker: wurde die (UNS gehoerende) .htaccess mit einer AELTEREN
        // RULESET_VERSION geschrieben, ist ein Neuschreiben faellig - auch wenn der reine
        // Struktur-Diff identisch waere (der Signatur-Block wird in normalizeForCompare ja
        // ausgeblendet, ein Signatur-Nachzug wuerde sonst nie in die DATEI kommen). So erreicht
        // ein bewusst erhoehtes RULESET_VERSION die Flotte: einmaliges Self-Heal-Neuschreiben,
        // das ALLE aktuellen Signaturen in die .htaccess holt. Greift NUR bei UNSEREM begin-
        // Marker mit rs= (kein Fehlgriff auf eine fremd verwaltete .htaccess) und NUR "rueckwaerts"
        // (Platten-rs < Code-Version -> nie ein Zwang, wenn die Datei schon neuer ist). Nach dem
        // Schreiben ist rs= == RULESET_VERSION -> konvergiert, keine Schleife. Das Self-Heal
        // (selfHealShield/writeShield) hebt die Waechter-Baseline mit -> kein "von aussen
        // geaendert"-Fehlalarm.
        if (preg_match('~^# htprotect-shield:begin\b[^\n]*\brs=(\d+)~m', (string) $disk, $rsm)
            && (int) $rsm[1] < self::RULESET_VERSION) {
            return true;
        }

        $off = (isset($cfg['x_sigs_off']) && is_array($cfg['x_sigs_off'])) ? $cfg['x_sigs_off'] : array();

        foreach ($off as $id) {
            $id = (string) $id;

            if ($id !== '' && preg_match('~(?m)^\s*# sig:' . preg_quote($id, '~') . '(\s|$)~', (string) $disk)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Komplette .htaccess erzeugen.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return string
     */
    public static function build($cfg)
    {
        $out = array();
        $now = htp_local_time(time(), 'Y-m-d H:i:s');

        $out[] = '# ==========================================================================';
        $out[] = '#  ' . htp_tc('COM_HTPROTECT_HTC_HEAD_TITLE', 'Haupt-.htaccess - erzeugt von HTProtect Server-Schild') . ' ' . HTP_VERSION;
        $out[] = '#  ' . htp_tc('COM_HTPROTECT_HTC_HEAD_DATE', 'Stand:') . ' ' . $now . ' | website-bereinigung.de';
        $out[] = '# --------------------------------------------------------------------------';
        $out[] = '#  ' . htp_tc('COM_HTPROTECT_HTC_HEAD_MANAGED', 'Diese Datei wird von der Joomla-Komponente "HTProtect" verwaltet.');
        $out[] = '#  ' . htp_tc('COM_HTPROTECT_HTC_HEAD_OVERWRITE', 'Manuelle Änderungen werden beim nächsten Schreiben überschrieben.');
        $out[] = '#  ' . htp_tc('COM_HTPROTECT_HTC_HEAD_OWNRULES', 'Eigene Direktiven bitte in der Komponente unter "Eigene Regeln" pflegen.');
        $out[] = '#';
        $out[] = '#  ' . htp_tc('COM_HTPROTECT_HTC_HEAD_EMERGENCY', 'NOTFALL (Fehler 500 / Seite nicht erreichbar):');
        $out[] = '#  ' . htp_tc('COM_HTPROTECT_HTC_HEAD_EMERGENCY2', 'Diese Datei per FTP löschen oder durch eine Sicherung ersetzen aus:');
        $out[] = '#  administrator/components/com_htprotect/backups/';
        $out[] = '# ==========================================================================';
        $out[] = '# htprotect-shield:begin v=' . HTP_VERSION . ' rs=' . self::RULESET_VERSION . ' cfg=' . self::configHash($cfg) . ' written=' . $now;
        $out[] = '';

        self::sectionEmergency($out, $cfg);
        self::sectionBasics($out, $cfg);
        self::sectionApacheOptions($out, $cfg);
        self::sectionHeaders($out, $cfg);
        self::sectionCustom($out, $cfg, 'custom_top', htp_tc('COM_HTPROTECT_HTC_HEAD_CUSTOMTOP', 'Eigene Regeln (Anfang)'));
        self::sectionAllow($out, $cfg);
        self::sectionFilters($out, $cfg);
        self::sectionExploits($out, $cfg);
        self::sectionCanonical($out, $cfg);
        self::sectionLockdown($out, $cfg);
        self::sectionShield($out, $cfg);
        self::sectionPerformance($out, $cfg);
        self::sectionRouter($out, $cfg);
        self::sectionCustom($out, $cfg, 'custom_bottom', htp_tc('COM_HTPROTECT_HTC_HEAD_CUSTOMBOTTOM', 'Eigene Regeln (Ende)'));

        // Notfall-Wartungsseite (ErrorDocument 503) GANZ AM ENDE - nach den Eigenregeln, damit sie
        // eine evtl. dort gesetzte eigene ErrorDocument-503-Regel sicher ueberschreibt (Apache:
        // "letzte gewinnt"). Nur bei aktivem Notfall-Modus; beim Abschalten faellt der Block weg.
        self::sectionEmergencyErrorDoc($out, $cfg);

        $out[] = '# htprotect-shield:end';
        $out[] = '';

        $content = implode("\n", $out);

        // Paranoia-Check: IfModule-Klammern muessen ausgeglichen sein
        if (substr_count($content, '<IfModule') !== substr_count($content, '</IfModule>')) {
            return '';
        }

        return $content;
    }

    // ─────────────────────────────────────────────────────────────────
    //  Abschnitte
    // ─────────────────────────────────────────────────────────────────

    private static function sectionBasics(&$out, $cfg)
    {
        self::head($out, '1', htp_tc('COM_HTPROTECT_HTC_HEAD_BASICS', 'Grundlagen'));

        $base = $cfg['site_path'] === '' ? '/' : $cfg['site_path'];

        $out[] = '<IfModule mod_rewrite.c>';
        $out[] = '    RewriteEngine On';
        $out[] = '    RewriteBase ' . $base;

        if (!empty($cfg['fastcgi_auth'])) {
            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_FASTCGI1', 'Authorization-Header auch unter FastCGI/PHP-FPM durchreichen');
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_FASTCGI2', '(nötig für Basic-Auth-Schutz und API-Token)');
            // /administrator BEWUSST ausnehmen: dort macht der Webserver die Basic-Auth
            // selbst (das Session-Backend-Login liest den Authorization-Header nie). Die
            // Durchreichung kollidiert sonst auf CGI/FastCGI-Hostern (Strato, IONOS/1&1
            // ...) mit einem mod_auth_basic-Verzeichnisschutz -> 401 trotz korrektem
            // Passwort. /api + Frontend behalten die Durchreichung unveraendert.
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_FASTCGI3', '(Backend ausgenommen - dort macht der Server die Passwortabfrage selbst)');
            $out[] = '    RewriteCond %{REQUEST_URI} "!^' . self::prefix($cfg) . '/administrator/" [NC]';
            $out[] = '    RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]';
        }

        $out[] = '</IfModule>';
        $out[] = '';

        // Saubere Apache-Fehlerseiten fuer die SCHUTZ-Antworten erzwingen. Manche Server/
        // Panels (z. B. bestimmte ISPConfig-vHosts) erben ein ErrorDocument, das 401/403 auf
        // eine CMS-Seite umbiegt -> dann erscheint KEIN Passwort-Prompt (Backend-Schutz) und
        // der sichtbare Status der Schild-Treffer wird verfaelscht (z. B. Schild-403 -> Joomla-
        // 404, was den Selbsttest faelschlich rot faerbt - der Schutz greift dabei korrekt).
        // "default" stellt Apaches eingebaute 401/403-Seiten her. 404 bleibt BEWUSST dem CMS
        // ueberlassen (schoene 404-Seite). Gleiche Override-Klasse (FileInfo) wie die Rewrites
        // -> kein zusaetzliches 500-Risiko. Greift nur fuer vom Server erzeugte 401/403 (Basic-
        // Auth, [F]-Schild); App-Antworten von Joomla (mit eigenem Body, z. B. API-401) bleiben.
        $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_ERRDOC', 'Schutz-Fehlerseiten sauber halten (manche Server biegen 401/403 sonst auf eine CMS-Seite um)');
        $out[] = 'ErrorDocument 401 default';
        $out[] = 'ErrorDocument 403 default';
        $out[] = '';
    }

    private static function sectionApacheOptions(&$out, $cfg)
    {
        if (empty($cfg['apache_options'])) {
            return;
        }

        self::head($out, '2', htp_tc('COM_HTPROTECT_HTC_HEAD_HARDENING', 'Server-Grundhärtung'));

        $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_HARDEN1', 'Hinweis: Erfordert "AllowOverride Options" - bei Fehler 500 diesen');
        $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_HARDEN2', 'Abschnitt in der Komponente deaktivieren.');
        $out[] = 'Options -Indexes';

        if (!empty($cfg['follow_symlink'])) {
            $out[] = 'Options +FollowSymlinks';
        }

        $out[] = 'DirectoryIndex index.php index.html';
        $out[] = 'ServerSignature Off';
        $out[] = '';
        $out[] = '<IfModule mod_autoindex.c>';
        $out[] = '    IndexIgnore *';
        $out[] = '</IfModule>';
        $out[] = '';
    }

    private static function sectionHeaders(&$out, $cfg)
    {
        $lines = array();

        if (!empty($cfg['h_frame'])) {
            $lines[] = '    Header always set X-Frame-Options "SAMEORIGIN"';
        }

        if (!empty($cfg['h_nosniff'])) {
            $lines[] = '    Header always set X-Content-Type-Options "nosniff"';
        }

        if (!empty($cfg['h_referrer']) && is_string($cfg['h_referrer'])) {
            $lines[] = '    Header always set Referrer-Policy "' . $cfg['h_referrer'] . '"';
        }

        if (!empty($cfg['h_coop'])) {
            $lines[] = '    Header always set Cross-Origin-Opener-Policy "same-origin"';
        }

        if (!empty($cfg['h_permissions'])) {
            $lines[] = '    Header always set Permissions-Policy "camera=(), geolocation=(), microphone=(), payment=()"';
        }

        if (!empty($cfg['h_hsts']) && !empty($cfg['force_https'])) {
            $value = 'max-age=31536000' . (!empty($cfg['h_hsts_sub']) ? '; includeSubDomains' : '');
            $lines[] = '    Header always set Strict-Transport-Security "' . $value . '"';
        }

        if (!empty($cfg['h_poweredby'])) {
            $lines[] = '    Header always unset X-Powered-By';
            $lines[] = '    Header always unset X-Content-Powered-By';
        }

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

        self::head($out, '3', htp_tc('COM_HTPROTECT_HTC_HEAD_HEADERS', 'Antwort-Header'));

        $out[] = '<IfModule mod_headers.c>';

        foreach ($lines as $line) {
            $out[] = $line;
        }

        $out[] = '</IfModule>';
        $out[] = '';

        // SVG-Dateien: Inline-JavaScript unterbinden (wie der Joomla-Standard).
        // Bewusst reproduziert, damit dieser Schutz beim Ueberschreiben der
        // Standard-.htaccess nicht verloren geht. An h_nosniff gekoppelt
        // (gleiche Content-Type-/Sniffing-Schutzklasse, per Default aktiv).
        if (!empty($cfg['h_nosniff'])) {
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_SVGCSP', 'SVG-Dateien: Inline-JavaScript unterbinden');
            $out[] = '<FilesMatch "\.svg$">';
            $out[] = '    <IfModule mod_headers.c>';
            $out[] = '        Header always set Content-Security-Policy "script-src \'none\'"';
            $out[] = '    </IfModule>';
            $out[] = '</FilesMatch>';
            $out[] = '';
        }
    }

    private static function sectionEmergency(&$out, $cfg)
    {
        if (empty($cfg['emergency_lock'])) {
            return;
        }

        // Zugangs-Token = zugleich der Cookie-Wert. OHNE gueltiges Token NICHT sperren -
        // sonst kaeme niemand mehr rein (Aussperr-Schutz; das Token wird beim Aktivieren
        // erzeugt). Nur [A-Za-z0-9], damit es .htaccess-sicher ist.
        $key = isset($cfg['emergency_key']) ? (string) $cfg['emergency_key'] : '';

        if (!preg_match('/^[A-Za-z0-9]{8,}$/', $key)) {
            return;
        }

        // Web-Pfad der statischen Wartungsseite (Root ODER Unterverzeichnis). Wird beim
        // Aktivieren aus den Sprachdateien (de/en) erzeugt und in den Docroot geschrieben.
        $maint = self::emergencyMaintWebPath($cfg);
        $cook  = 'htp_em';
        // Secure-Flag NUR bei HTTPS-Zwang: sonst speichert ein HTTP-only-Browser das Secure-
        // Cookie nicht und der Betreiber kaeme nie ueber den Einstiegs-Link rein (Aussperren).
        $secure = !empty($cfg['force_https']) ? 'secure' : '';

        // Diese Sektion laeuft als ERSTE (frueh sperren) - VOR dem globalen RewriteEngine
        // On weiter unten. Darum eigener IfModule-Block MIT eigenem RewriteEngine On.
        self::head($out, '0', htp_tc('COM_HTPROTECT_HTC_HEAD_EMERGENCYMODE', 'Notfall-Modus: komplette Seite gesperrt (Wartung)'));
        $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_EMERG1', 'Die ganze Seite ist gesperrt (Aufraeumen nach einem Hack oder Staging-Phase).');
        $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_EMERG2', 'Zugang nur ueber den geheimen Einstiegs-Link, der ein Cookie setzt.');
        $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_EMERG3', 'In der Komponente unter "Backend-Zugang" wieder abschaltbar.');
        $out[] = '<IfModule mod_rewrite.c>';
        $out[] = '    RewriteEngine On';
        // 1) Einstieg: ?htp_<TOKEN> setzt das Zugangs-Cookie (langlebig, Secure+HttpOnly)
        //    und leitet OHNE Query auf dieselbe URL um (QSD) - der Token bleibt nicht in
        //    der Adresszeile/Referer stehen. CO-Lebensdauer in MINUTEN (Apache-Konvention);
        //    die echte Gueltigkeit steuert ohnehin emergency_lock (aus -> Regeln weg).
        $out[] = '    RewriteCond %{QUERY_STRING} (^|&)htp_' . $key . '($|&)';
        $out[] = '    RewriteRule ^ %{REQUEST_URI} [CO=' . $cook . ':' . $key . ':%{HTTP_HOST}:525600:/:' . $secure . ':HttpOnly,QSD,R=302,L]';
        // 2) Alles ohne gueltiges Zugangs-Cookie -> 503 (temporaer, SEO-schonend: die URLs
        //    bleiben im Index, kein noindex). Ausnahmen: die Wartungsseite selbst + .well-known/
        //    (SSL-/ACME-Erneuerung darf nie ausgesperrt werden).
        // WICHTIG: KEIN literales Leerzeichen im Muster (z. B. [ ]) - Apache zerlegt die
        // .htaccess-Zeile am Space und liest den Rest als Flags -> "bad flag delimiters" -> 500.
        // Darum \s* (Whitespace-Klasse, PCRE) statt [ ]? fuer das Space nach dem Cookie-Semikolon.
        $out[] = '    RewriteCond %{HTTP_COOKIE} !(^|;\s*)' . $cook . '=' . $key . '(;|$)';
        // Notaus-Datei im Document-Root -> Sperre greift NICHT. Der Ausweg, wenn der Zugangs-Link
        // weg UND das Dashboard nicht erreichbar ist: per FTP/Hosting-Dateimanager eine leere Datei
        // "htp-emergency-off" im obersten Web-Verzeichnis anlegen. Dasselbe setzt der signierte
        // Dashboard-Fernbefehl ueber rescue.php. Die Engine finalisiert danach sauber (Sperre +
        // Block + Datei weg). KEIN literales Leerzeichen im Muster (Tokenizer): "!-f" ist ein
        // eigenes Argument (Standard-RewriteCond-Syntax), kein Space INNERHALB eines Musters.
        $out[] = '    RewriteCond %{DOCUMENT_ROOT}/htp-emergency-off !-f';
        $out[] = '    RewriteCond %{REQUEST_URI} !=' . $maint;
        // Der signierte Rescue-Endpunkt bleibt erreichbar - nur er kann per Operator-Signatur die
        // Notaus-Datei setzen (ohne gueltige Signatur tut er nichts). So laesst sich der Notfall-
        // Modus auch bei voll gesperrter Seite vom Dashboard aus wieder abschalten.
        $out[] = '    RewriteCond %{REQUEST_URI} !plugins/system/htprotectwatch/rescue\.php$ [NC]';
        $out[] = '    RewriteCond %{REQUEST_URI} !(^|/)\.well-known/acme-challenge/ [NC]';
        $out[] = '    RewriteRule ^ - [R=503,L]';
        $out[] = '</IfModule>';
        // Temporaer-Hinweis fuer Suchmaschinen ("kommt wieder"). Bewusst KEIN noindex.
        $out[] = '<IfModule mod_headers.c>';
        $out[] = '    Header always set Retry-After "3600"';
        $out[] = '</IfModule>';
        // HINWEIS: Die "ErrorDocument 503"-Zeile steht BEWUSST nicht hier, sondern GANZ AM ENDE
        // der .htaccess (nach den Eigenregeln) - siehe sectionEmergencyErrorDoc(). Apache nimmt
        // bei mehreren "ErrorDocument 503" die LETZTE; so ueberschreibt die HTProtect-Wartungsseite
        // eine evtl. eigene ErrorDocument-503-Zeile im Eigenregeln-Feld zuverlaessig. Der 503-Gate-
        // Rewrite oben muss frueh greifen; ErrorDocument selbst ist positions-unabhaengig.
        $out[] = '';
    }

    /**
     * Web-Pfad der statischen Notfall-Wartungsseite (Docroot ODER Unterverzeichnis).
     * Eine Quelle fuer sectionEmergency (Ausnahme-RewriteCond) UND sectionEmergencyErrorDoc.
     *
     * @param   array  $cfg
     *
     * @return string  z. B. "/htp-maintenance.html" oder "/unterordner/htp-maintenance.html"
     */
    private static function emergencyMaintWebPath($cfg)
    {
        $sub = (isset($cfg['site_path']) && $cfg['site_path'] !== '') ? '/' . trim((string) $cfg['site_path'], '/') : '';

        return $sub . '/htp-maintenance.html';
    }

    /**
     * "ErrorDocument 503 <Wartungsseite>" GANZ AM ENDE der .htaccess ausgeben (nach den Eigen-
     * regeln). Apache nimmt bei mehreren "ErrorDocument 503" die LETZTE - so gewinnt die HTProtect-
     * Wartungsseite auch, wenn im Eigenregeln-Feld eine eigene ErrorDocument-503-Zeile steht.
     * Nur bei aktivem Notfall-Modus mit gueltigem Token (identische Voraussetzungen wie
     * sectionEmergency) - beim Abschalten faellt der Block sauber wieder weg.
     *
     * @param   array  $out
     * @param   array  $cfg
     *
     * @return void
     */
    private static function sectionEmergencyErrorDoc(&$out, $cfg)
    {
        if (empty($cfg['emergency_lock'])) {
            return;
        }

        $key = isset($cfg['emergency_key']) ? (string) $cfg['emergency_key'] : '';

        if (!preg_match('/^[A-Za-z0-9]{8,}$/', $key)) {
            return;
        }

        self::head($out, 'Z', htp_tc('COM_HTPROTECT_HTC_HEAD_EMERG_ERRDOC', 'Notfall-Wartungsseite: Fehlerdokument ganz am Ende (Vorrang vor eigenen ErrorDocument-503-Regeln)'));
        $out[] = 'ErrorDocument 503 ' . self::emergencyMaintWebPath($cfg);
        $out[] = '';
    }

    /**
     * Statische, in sich geschlossene Wartungsseite (503) fuer den Notfall-Modus.
     * Wird beim Aktivieren/Speichern in den Docroot geschrieben - statisch, damit Apache
     * sie auch dann ausliefert, wenn PHP/Joomla kaputt ist (genau der Notfall). Neutral,
     * kein Branding, kein Grund/Zugangs-Hinweis. Joomla-6-Farbwelt (Marineblau).
     *   - Eigener Titel/Text (emergency_title/_text) gesetzt -> einsprachig, streng escaped.
     *   - Sonst zweisprachiger Default (de/en) aus den Sprachdateien; ein winziges JS zeigt
     *     Deutsch fuer de-Browser, sonst Englisch.
     *
     * @param   array  $cfg  Konfiguration (fuer den optionalen Eigen-Text)
     *
     * @return string  vollstaendiges HTML-Dokument
     */
    public static function emergencyMaintenanceHtml($cfg = array())
    {
        $customT = (isset($cfg['emergency_title']) ? trim((string) $cfg['emergency_title']) : '');
        $customB = (isset($cfg['emergency_text']) ? trim((string) $cfg['emergency_text']) : '');

        $deT = self::emergLangString('de-DE', 'COM_HTPROTECT_MAINT_TITLE');
        $deB = self::emergLangString('de-DE', 'COM_HTPROTECT_MAINT_BODY');
        $enT = self::emergLangString('en-GB', 'COM_HTPROTECT_MAINT_TITLE');
        $enB = self::emergLangString('en-GB', 'COM_HTPROTECT_MAINT_BODY');

        // Fallbacks, falls die Sprachdatei (noch) nicht ausliefert.
        if ($enT === '') { $enT = 'We will be right back'; }
        if ($enB === '') { $enB = 'This website is currently undergoing maintenance and will be back shortly. Thank you for your patience!'; }
        if ($deT === '') { $deT = $enT; }
        if ($deB === '') { $deB = $enB; }

        if ($customT !== '' || $customB !== '') {
            // Eigener Text -> einsprachig. Der TITEL bleibt reiner Text (Formularfeld) und wird
            // streng escaped (Anzeige fuer Besucher -> kein XSS). Der BODY ist WYSIWYG-HTML und
            // wurde beim Speichern bereits auf eine sichere HTML-Whitelist gefiltert (kein <script>,
            // keine Event-Handler etc. - siehe HtpApp::sanitizeMaintenanceHtml). Lokale Bilder
            // werden als data:-URI eingebettet, damit die Seite auch bei totaler Sperre (503, alle
            // URLs geblockt) in sich geschlossen bleibt.
            $t   = ($customT !== '') ? $customT : $enT;
            $ttl = htmlspecialchars($t, ENT_QUOTES, 'UTF-8');

            if ($customB === '') {
                // Titel eigen, aber kein eigener Body -> Default-Body (englisch), streng escaped.
                $inner = '<div><h1>' . $ttl . '</h1><p>' . nl2br(htmlspecialchars($enB, ENT_QUOTES, 'UTF-8')) . '</p></div>';
            } elseif (preg_match('/<[a-z!\/][^>]*>/i', $customB)) {
                // Body enthaelt HTML (WYSIWYG). Sicher filtern - AUCH hier beim Rendern, unabhaengig
                // vom Speicherpfad (Defense-in-Depth): gefaehrliches HTML raus, uebliche Formatierung
                // bleibt. Danach lokale Bilder als data:-URI einbetten (self-contained bei 503).
                $bodyHtml = self::inlineLocalImages(self::sanitizeHtml($customB));
                $inner    = '<div><h1>' . $ttl . '</h1><div class="body">' . $bodyHtml . '</div></div>';
            } else {
                // Reiner Text (Legacy vor 2.6.13 oder "Ohne Editor" ohne Tags) -> escapen und
                // Zeilenumbrueche als <br> (Rueckwaertskompatibilitaet, keine verlorenen Umbrueche).
                $inner = '<div><h1>' . $ttl . '</h1><p>' . nl2br(htmlspecialchars($customB, ENT_QUOTES, 'UTF-8')) . '</p></div>';
            }
        } else {
            // Zweisprachig de/en (JS-Umschaltung); Default-Strings sind vertrauenswuerdig.
            $ttl   = $enT;
            $inner = '<div data-l="de" hidden><h1>' . $deT . '</h1><p>' . $deB . '</p></div>'
                . '<div data-l="en"><h1>' . $enT . '</h1><p>' . $enB . '</p></div>';
        }

        // CSS (einfach-gequotet: enthaelt " aber weder ' noch $).
        $css = ':root{--bg1:#eef2fb;--bg2:#dde5f3;--card:#fff;--ink:#15264f;--dim:#55627f;--accent:#2f6fd6;--accent2:#1b3f8c;--ring:rgba(43,92,196,.16);--shadow:rgba(21,38,79,.16);--orb:.26}'
            . '@media(prefers-color-scheme:dark){:root{--bg1:#0b1220;--bg2:#0f1a30;--card:#152039;--ink:#eaf0fb;--dim:#9db0d2;--accent:#6f9bff;--accent2:#3f6fe0;--ring:rgba(112,152,255,.22);--shadow:rgba(0,0,0,.55);--orb:.20}}'
            . '*{box-sizing:border-box}html,body{height:100%;margin:0}'
            . 'body{position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;padding:1.5rem;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--ink);background:radial-gradient(1200px 640px at 50% -12%,var(--bg1),var(--bg2))}'
            . 'body::before,body::after{content:"";position:fixed;border-radius:50%;filter:blur(74px);opacity:var(--orb);z-index:0}'
            . 'body::before{width:360px;height:360px;background:var(--accent);top:-140px;left:-120px}'
            . 'body::after{width:400px;height:400px;background:var(--accent2);bottom:-160px;right:-130px}'
            . '.card{position:relative;z-index:1;max-width:30rem;width:100%;text-align:center;background:var(--card);border-radius:24px;padding:2.9rem 2.2rem;box-shadow:0 26px 64px -16px var(--shadow),0 3px 8px -3px var(--shadow)}'
            . '.badge{position:relative;width:82px;height:82px;margin:0 auto 1.6rem;display:flex;align-items:center;justify-content:center}'
            . '.badge .halo{position:absolute;inset:0;border-radius:50%;background:radial-gradient(circle,var(--ring),transparent 68%);animation:pulse 2.8s ease-in-out infinite}'
            . '.badge .disc{width:64px;height:64px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--accent2));display:flex;align-items:center;justify-content:center;box-shadow:0 12px 26px -8px var(--ring)}'
            . '.badge svg{width:32px;height:32px;color:#fff;animation:spin 6s linear infinite}'
            . 'h1{font-size:1.55rem;font-weight:660;letter-spacing:-.01em;margin:0 0 .6rem;color:var(--ink)}'
            . 'p{font-size:1.03rem;line-height:1.58;color:var(--dim);margin:0 auto;max-width:23rem}'
            . '.body{font-size:1.03rem;line-height:1.6;color:var(--dim);max-width:24rem;margin:0 auto}'
            . '.body>*:first-child{margin-top:0}.body>*:last-child{margin-bottom:0}'
            . '.body p{max-width:none;margin:0 0 .7rem}'
            . '.body h2,.body h3{color:var(--ink);margin:1rem 0 .5rem;line-height:1.3}.body h2{font-size:1.2rem}.body h3{font-size:1.05rem}'
            . '.body a{color:var(--accent)}'
            . '.body ul,.body ol{margin:.6rem 0;padding-left:1.3rem;text-align:left;display:inline-block}.body li{margin:.25rem 0}'
            . '.body img{max-width:100%;height:auto;border-radius:10px;margin:.5rem 0}'
            . '.body strong,.body b{color:var(--ink)}'
            . '@keyframes spin{to{transform:rotate(360deg)}}'
            . '@keyframes pulse{0%,100%{transform:scale(.9);opacity:.5}50%{transform:scale(1.14);opacity:1}}'
            . '@media(prefers-reduced-motion:reduce){.badge svg,.badge .halo{animation:none}}';

        $svg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>';

        $js = "(function(){var l=(navigator.language||'en').toLowerCase();if(l.indexOf('de')===0){var d=document.querySelector('[data-l=de]'),e=document.querySelector('[data-l=en]');if(d&&e){d.hidden=false;e.hidden=true;document.documentElement.lang='de';}}})();";

        return '<!DOCTYPE html>' . "\n" . '<html lang="en">' . "\n<head>\n"
            . '<meta charset="utf-8">' . "\n"
            . '<meta name="viewport" content="width=device-width, initial-scale=1">' . "\n"
            . '<title>' . $ttl . '</title>' . "\n"
            . '<style>' . $css . '</style>' . "\n</head>\n<body>\n"
            . '<main class="card"><div class="badge"><span class="halo"></span><span class="disc">' . $svg . '</span></div>' . "\n"
            . $inner . "\n</main>\n"
            . '<script>' . $js . '</script>' . "\n</body>\n</html>\n";
    }

    /**
     * WYSIWYG-Body auf eine sichere HTML-Whitelist filtern (Defense-in-Depth beim Rendern der
     * oeffentlichen Wartungsseite - unabhaengig davon, ob der Wert schon beim Speichern gefiltert
     * wurde). Nutzt Joomlas InputFilter, wenn vorhanden; sonst einen robusten Fallback, der
     * gefaehrliche Bloecke (script/style/iframe/...), Event-Handler (on...) und javascript-
     * Protokolle entfernt und die uebliche Formatierung behaelt.
     *
     * @param   string  $html
     *
     * @return string
     */
    private static function sanitizeHtml($html)
    {
        $html = (string) $html;

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

        try {
            if (class_exists('Joomla\\CMS\\Filter\\InputFilter')) {
                return (string) Joomla\CMS\Filter\InputFilter::getInstance()->clean($html, 'html');
            }
        } catch (\Throwable $e) {
            // Fallback unten
        }

        // Fallback ohne Joomla: gefaehrliche Element-Bloecke MIT Inhalt entfernen ...
        $html = preg_replace('#<\s*(script|style|iframe|object|embed|form|link|meta|base|applet)\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', $html);
        // ... offene/verwaiste gefaehrliche Tags entfernen ...
        $html = preg_replace('#<\s*/?\s*(script|style|iframe|object|embed|form|link|meta|base|applet)\b[^>]*>#is', '', $html);
        // ... Event-Handler (onclick, onerror, ...) entfernen ...
        $html = preg_replace('#\son\w+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $html);
        // ... und javascript-/vbscript-Protokolle in href/src neutralisieren.
        $html = preg_replace('#\b(href|src)\s*=\s*("|\')\s*(?:javascript|vbscript):[^"\']*\2#i', '$1="#"', $html);

        return (string) $html;
    }

    /**
     * Lokale (docroot-relative) Bilder im WYSIWYG-Body als data:-URI einbetten. So bleibt die
     * statische Wartungsseite in sich geschlossen und zeigt Bilder auch dann, wenn die Sperre
     * (503) jede andere URL blockiert. Externe (http/https) und bereits eingebettete (data:)
     * Bilder bleiben unveraendert - externe Domains sind waehrend der eigenen Wartung ja erreichbar.
     *
     * @param   string  $html
     *
     * @return string
     */
    private static function inlineLocalImages($html)
    {
        $html = (string) $html;

        if (stripos($html, '<img') === false) {
            return $html;
        }

        return preg_replace_callback(
            '/(<img\b[^>]*?\bsrc\s*=\s*)(["\'])(.*?)\2/i',
            function ($m) {
                $url = trim(html_entity_decode($m[3], ENT_QUOTES, 'UTF-8'));

                // data:, absolute externe URL (http[s]:// oder //) -> unveraendert lassen.
                if ($url === '' || stripos($url, 'data:') === 0 || preg_match('#^(https?:)?//#i', $url)) {
                    return $m[0];
                }

                $data = HtpBuilder::localImageDataUri($url);

                return ($data !== '') ? ($m[1] . $m[2] . $data . $m[2]) : $m[0];
            },
            $html
        );
    }

    /**
     * Ein lokales, docroot-relatives Bild als data:-URI zurueckgeben - streng auf den Docroot
     * begrenzt (kein Pfad-Ausbruch via ../), nur bekannte Bildtypen, max. 512 KB (Seite klein
     * halten). Gibt '' zurueck, wenn nichts Sicheres/Passendes gefunden wird (Bild bleibt dann
     * als normale URL stehen).
     *
     * @param   string  $url  z. B. "/images/logo.png" oder "images/logo.png"
     *
     * @return string  data:-URI oder ''
     */
    public static function localImageDataUri($url)
    {
        if (!function_exists('htp_docroot')) {
            return '';
        }

        $root = rtrim(str_replace('\\', '/', (string) htp_docroot()), '/');

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

        // Query/Fragment abschneiden, fuehrenden Slash normieren.
        $path = (string) $url;

        if (($p = strpos($path, '?')) !== false) { $path = substr($path, 0, $p); }
        if (($p = strpos($path, '#')) !== false) { $path = substr($path, 0, $p); }

        $path = rawurldecode($path);
        $file = $root . '/' . ltrim($path, '/');
        $real = @realpath($file);

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

        $real = str_replace('\\', '/', $real);

        // Muss INNERHALB des Docroot liegen (Pfad-Ausbruch verhindern).
        if ($real !== $root && strpos($real, $root . '/') !== 0) {
            return '';
        }

        $mimes = array(
            'png'  => 'image/png',
            'jpg'  => 'image/jpeg',
            'jpeg' => 'image/jpeg',
            'gif'  => 'image/gif',
            'webp' => 'image/webp',
            'svg'  => 'image/svg+xml',
            'ico'  => 'image/x-icon',
        );

        $ext = strtolower(pathinfo($real, PATHINFO_EXTENSION));

        if (!isset($mimes[$ext])) {
            return '';
        }

        $size = @filesize($real);

        if ($size === false || $size <= 0 || $size > 524288) {
            return '';
        }

        $bin = @file_get_contents($real);

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

        return 'data:' . $mimes[$ext] . ';base64,' . base64_encode($bin);
    }

    /**
     * Einen einzelnen Sprachstring aus der .ini einer BESTIMMTEN Sprache lesen (regex,
     * unabhaengig von der aktiven UI-Sprache) - fuer die zweisprachige Wartungsseite.
     *
     * @param   string  $tag  z. B. 'de-DE' | 'en-GB'
     * @param   string  $key  Sprachschluessel
     *
     * @return string  Wert oder '' wenn nicht gefunden
     */
    private static function emergLangString($tag, $key)
    {
        $base = defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : '';
        $file = $base . '/components/com_htprotect/language/' . $tag . '/' . $tag . '.com_htprotect.ini';

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

        $ini = @file_get_contents($file);

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

        if (preg_match('/^[ \t]*' . preg_quote($key, '/') . '[ \t]*=[ \t]*"(.*)"[ \t]*$/m', $ini, $m)) {
            return $m[1];
        }

        return '';
    }

    private static function sectionExploits(&$out, $cfg)
    {
        if (empty($cfg['x_shield']) || !class_exists('HtpDefs')) {
            // /api-Sperre wird auch ohne Signatur-Schild beruecksichtigt
            if (((string) (isset($cfg['api_mode']) ? $cfg['api_mode'] : 'route')) === 'block') {
                self::head($out, '4b', htp_tc('COM_HTPROTECT_HTC_HEAD_EXPLOIT', 'Exploit-Schild'));
                $out[] = '<IfModule mod_rewrite.c>';
                $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_APIBLOCK', 'Schnittstellen-Anwendung (/api) komplett sperren');
                $out[] = '    RewriteCond %{ENV:HTP_ALLOW} !=1';
                $out[] = '    RewriteRule "^api(/|$)" - [F,NC]';
                $out[] = '</IfModule>';
                $out[] = '';
            }

            return;
        }

        $signatures = HtpDefs::effective($cfg);
        $apiBlock   = ((string) (isset($cfg['api_mode']) ? $cfg['api_mode'] : 'route')) === 'block';

        if (empty($signatures) && !$apiBlock) {
            return;
        }

        self::head($out, '4b', htp_tc('COM_HTPROTECT_HTC_HEAD_EXPLOIT', 'Exploit-Schild'));
        $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_EXPLOIT1', 'Bekannte Angriffsmuster (JCE, Novarain, Astroid, com_ajax-LFI ...)');
        $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_EXPLOIT2', 'und generische Angriffsklassen abweisen. Pflegbar in der Komponente.');
        $out[] = '<IfModule mod_rewrite.c>';

        $p = self::prefix($cfg);

        if ($apiBlock) {
            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_APIBLOCK_UNUSED', 'Schnittstellen-Anwendung (/api) komplett sperren (ungenutzt)');
            $out[] = '    RewriteCond %{ENV:HTP_ALLOW} !=1';
            $out[] = '    RewriteRule "^api(/|$)" - [F,NC]';
        }

        // Signatur-Block in Marker fassen: reine Defs-Feed-/Builtin-Updates
        // sollen KEIN "neu schreiben" ausloesen (die Echtzeit-WAF nutzt neue
        // Signaturen ohnehin sofort) -> der Aktualitaets-Vergleich blendet
        // genau diesen Block aus (HtpBuilder::normalizeForCompare). Eine vom
        // Nutzer ABGESCHALTETE Signatur wird separat geprueft (shieldOutdated).
        $out[] = '    # htprotect-sigs:begin';

        foreach ($signatures as $sig) {
            // waf_only: nur im Echtzeit-Plugin durchsetzen, NICHT in die .htaccess
            // schreiben (Autorisierungs-Luecken, deren Endpunkt eingeloggte Nutzer
            // legitim aufrufen - die .htaccess kann den Login-Status nicht pruefen).
            if (!empty($sig['waf_only'])) {
                continue;
            }

            $front = !empty($sig['frontend_only']);
            $frontCond = '    RewriteCond %{REQUEST_URI} "!^' . $p . '/administrator/" [NC]';

            if (!empty($sig['qs'])) {
                $out[] = '';
                // Bewusst NUR die id, KEIN beschreibendes Label: dessen Klartext
                // (z. B. "Schadskript-Upload via plugin.rpc") laesst fremde Security-
                // Scanner die .htaccess faelschlich als Hack melden. Die volle
                // Beschreibung bleibt in der UI/den Defs; die id genuegt als Marker.
                $out[] = '    # sig:' . $sig['id'];

                if ($front) {
                    $out[] = $frontCond;
                }

                $out[] = '    RewriteCond %{QUERY_STRING} "' . $sig['qs'] . '" [NC]';
                $out[] = '    RewriteRule .* - [F]';
            }

            if (!empty($sig['uri'])) {
                $out[] = '';
                $out[] = '    # sig:' . $sig['id'] . ' ' . htp_tc('COM_HTPROTECT_HTC_SIG_PATH', '(Pfad)');
                $out[] = '    RewriteCond %{ENV:HTP_ALLOW} !=1';

                if ($front) {
                    $out[] = $frontCond;
                }

                $out[] = '    RewriteRule "' . $sig['uri'] . '" - [F,NC]';
            }
        }

        $out[] = '    # htprotect-sigs:end';
        $out[] = '</IfModule>';
        $out[] = '';
    }

    /**
     * Vertrauenswuerdige Pfade: setzen frueh das Umgebungs-Flag HTP_ALLOW. Die
     * PFAD-basierten Sperren weiter unten (Exploit-Schild uri-Signaturen +
     * /api-Sperre, Lockdown) lassen einen so markierten Pfad durch. Die
     * Query-Injektionsfilter (Abschnitt 4) bleiben bewusst unberuehrt, sodass
     * boesartige Parameter auch auf freigegebenen Pfaden geblockt werden.
     * Pfad-relativ (wie das Dateischild) - kein eingebackener Site-Pfad.
     */
    private static function sectionAllow(&$out, $cfg)
    {
        if (!class_exists('HtpConfig')) {
            return;
        }

        $paths = HtpConfig::allowedPaths($cfg);

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

        self::head($out, '1b', htp_tc('COM_HTPROTECT_HTC_HEAD_ALLOW', 'Vertrauenswürdige Pfade (überall durchlassen)'));
        $out[] = '<IfModule mod_rewrite.c>';

        foreach ($paths as $path) {
            $out[] = '    RewriteRule "^' . self::rx($path) . '(/|$)" - [E=HTP_ALLOW:1]';
        }

        $out[] = '</IfModule>';
        $out[] = '';
    }

    private static function sectionFilters(&$out, $cfg)
    {
        if (empty($cfg['f_query']) && empty($cfg['f_rfi']) && empty($cfg['f_methods']) && empty($cfg['f_wpnoise'])) {
            return;
        }

        self::head($out, '4', htp_tc('COM_HTPROTECT_HTC_HEAD_FILTERS', 'Anfrage-Filter'));

        $out[] = '<IfModule mod_rewrite.c>';

        if (!empty($cfg['f_methods'])) {
            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_F_METHODS', 'Diagnose-Methoden gehören nicht in den Produktivbetrieb');
            $out[] = '    RewriteCond %{REQUEST_METHOD} "^(TRACE|TRACK|DEBUG)$" [NC]';
            $out[] = '    RewriteRule .* - [F]';
        }

        if (!empty($cfg['f_query'])) {
            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_F_QUERY', 'Injektions- und Ausspäh-Muster im Query-String abweisen');
            $out[] = '    RewriteCond %{QUERY_STRING} "%00" [OR]';
            $out[] = '    RewriteCond %{QUERY_STRING} "(\.\./|\.\.%2f|%2e%2e/|%2e%2e%2f)" [NC,OR]';
            $out[] = '    RewriteCond %{QUERY_STRING} "(data|expect|glob|ogg|phar|php|zlib)(://|%3a%2f%2f)" [NC,OR]';
            $out[] = '    RewriteCond %{QUERY_STRING} "base64_(en|de)code\(" [NC,OR]';
            $out[] = '    RewriteCond %{QUERY_STRING} "(<|%3C)script" [NC,OR]';
            $out[] = '    RewriteCond %{QUERY_STRING} "proc/sel[f]/enviro[n]" [OR]';
            $out[] = '    RewriteCond %{QUERY_STRING} "mosConfig_[a-zA-Z_]{1,24}(=|%3D)" [OR]';
            $out[] = '    RewriteCond %{QUERY_STRING} "GLOBALS(=|\[|%[0-9A-Z]{0,2})" [OR]';
            $out[] = '    RewriteCond %{QUERY_STRING} "_REQUEST(=|\[|%[0-9A-Z]{0,2})" [OR]';
            $out[] = '    RewriteCond %{QUERY_STRING} "^=PHP[0-9A-Fa-f\-]{10,}"';
            $out[] = '    RewriteRule .* - [F]';
        }

        if (!empty($cfg['f_rfi'])) {
            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_F_RFI', 'Fremde URLs oder Pfad-Klettern als GET-Parameterwerte unterbinden');
            $out[] = '    RewriteCond %{REQUEST_METHOD} =GET';
            $out[] = '    RewriteCond %{QUERY_STRING} "=(https?|ftps?)(://|%3a%2f%2f)" [NC,OR]';
            $out[] = '    RewriteCond %{QUERY_STRING} "=(\.\.//?)+"';
            $out[] = '    RewriteRule .* - [F]';
        }

        if (!empty($cfg['f_wpnoise'])) {
            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_F_WPNOISE', 'WordPress-Scanrauschen sofort abweisen (spart Joomla-Bootstraps)');
            $out[] = '    RewriteRule "^(wp-admin|wp-content|wp-includes|wp-json)(/|$)" - [F,NC]';
            $out[] = '    RewriteRule "^(wp-login|wp-signup|wp-cron|xmlrpc)\.php" - [F,NC]';
        }

        $out[] = '</IfModule>';
        $out[] = '';
    }

    private static function sectionCanonical(&$out, $cfg)
    {
        $https = !empty($cfg['force_https']);
        $www   = (int) $cfg['www_mode'];

        if (!$https && $www === 0) {
            return;
        }

        self::head($out, '5', htp_tc('COM_HTPROTECT_HTC_HEAD_CANONICAL', 'Kanonische Adresse'));

        $host  = self::canonicalHost($cfg);
        $hostQ = $host !== '' ? self::rx($host) : '';

        $out[] = '<IfModule mod_rewrite.c>';

        if ($https && ($www === 0 && $cfg['canonical_host'] === '')) {
            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_C_HTTPS', 'Unverschlüsselte Aufrufe dauerhaft auf HTTPS heben');
            self::condHttpsOff($out);
            $out[] = '    RewriteRule .* "https://%{HTTP_HOST}%{REQUEST_URI}" [R=301,L,NE]';
        } elseif ($https && $host !== '') {
            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_C_STEP1', 'Schritt 1: unverschlüsselt -> direkt zur kanonischen Adresse (kein Umleitungs-Doppel)');
            self::condHttpsOff($out);
            $out[] = '    RewriteRule .* "https://' . $host . '%{REQUEST_URI}" [R=301,L,NE]';
            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_C_STEP2', 'Schritt 2: verschlüsselt, aber abweichender Hostname');
            $out[] = '    RewriteCond %{HTTP_HOST} "!^' . $hostQ . '$" [NC]';
            $out[] = '    RewriteRule .* "https://' . $host . '%{REQUEST_URI}" [R=301,L,NE]';
        } elseif (!$https && $www !== 0 && $host !== '') {
            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_C_HOST', 'Hostnamen vereinheitlichen, Verschlüsselung des Aufrufs beibehalten');
            self::condHttpsOn($out);
            $out[] = '    RewriteCond %{HTTP_HOST} "!^' . $hostQ . '$" [NC]';
            $out[] = '    RewriteRule .* "https://' . $host . '%{REQUEST_URI}" [R=301,L,NE]';
            $out[] = '';
            self::condHttpsOff($out);
            $out[] = '    RewriteCond %{HTTP_HOST} "!^' . $hostQ . '$" [NC]';
            $out[] = '    RewriteRule .* "http://' . $host . '%{REQUEST_URI}" [R=301,L,NE]';
        }

        $out[] = '</IfModule>';
        $out[] = '';
    }

    /**
     * RewriteCond-Block "Aufruf ist definitiv NICHT HTTPS" - alle Bedingungen
     * UND-verknuepft. Mehrere Proxy-Signale (X-Forwarded-Proto/-SSL/-Protocol,
     * SERVER_PORT 443) zusaetzlich zu %{HTTPS} verhindern Endlosschleifen, wenn
     * ein Subdomain-/Proxy-vhost das Schema ANDERS meldet als die Hauptdomain
     * (z. B. X-Forwarded-Proto nicht durchgereicht): sobald IRGENDEIN Signal
     * HTTPS anzeigt, greift die HTTPS-Umleitung NICHT mehr -> keine Schleife.
     *
     * @param   array  $out  Ausgabezeilen (per Referenz)
     *
     * @return void
     */
    private static function condHttpsOff(&$out)
    {
        $out[] = '    RewriteCond %{HTTPS} !=on';
        $out[] = '    RewriteCond %{HTTP:X-Forwarded-Proto} !=https';
        $out[] = '    RewriteCond %{HTTP:X-Forwarded-SSL} !=on';
        $out[] = '    RewriteCond %{HTTP:X-Forwarded-Protocol} !=https';
        $out[] = '    RewriteCond %{SERVER_PORT} !=443';
    }

    /**
     * RewriteCond-ODER-Gruppe "Aufruf IST HTTPS" (eines der Signale genuegt).
     * Die unmittelbar FOLGENDE RewriteCond wird mit dem Gruppenergebnis UND-
     * verknuepft (Apache-[OR]-Semantik): (A ODER B ODER ...) UND naechste Cond.
     *
     * @param   array  $out  Ausgabezeilen (per Referenz)
     *
     * @return void
     */
    private static function condHttpsOn(&$out)
    {
        $out[] = '    RewriteCond %{HTTPS} =on [OR]';
        $out[] = '    RewriteCond %{HTTP:X-Forwarded-Proto} =https [OR]';
        $out[] = '    RewriteCond %{HTTP:X-Forwarded-SSL} =on [OR]';
        $out[] = '    RewriteCond %{HTTP:X-Forwarded-Protocol} =https [OR]';
        $out[] = '    RewriteCond %{SERVER_PORT} =443';
    }

    private static function sectionLockdown(&$out, $cfg)
    {
        $sysdirs   = !empty($cfg['s_sysdirs']) && !empty($cfg['s_sysdirs_list']);
        $dotfiles  = !empty($cfg['s_dotfiles']);
        $sensitive = !empty($cfg['s_sensitive']);
        $manifests = !empty($cfg['s_manifests']);

        if (!$sysdirs && !$dotfiles && !$sensitive && !$manifests) {
            return;
        }

        self::head($out, '6', htp_tc('COM_HTPROTECT_HTC_HEAD_LOCKDOWN', 'Sperren für interne Bereiche'));

        if ($sysdirs || $dotfiles || $manifests) {
            $out[] = '<IfModule mod_rewrite.c>';

            if ($manifests) {
                $out[] = '';
                $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_L_MANIFEST1', 'Erweiterungs-/Kern-Manifeste (.xml) verstecken - erschwert das');
                $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_L_MANIFEST2', 'Ausspaehen installierter Versionen (Fingerprinting). sitemap.xml');
                $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_L_MANIFEST3', 'u. a. im Webroot bleiben erreichbar.');
                $out[] = '    RewriteCond %{ENV:HTP_ALLOW} !=1';
                $out[] = '    RewriteRule "^(administrator/)?(components|libraries|modules|plugins|templates)/.+\.xml$" - [F,NC]';
                $out[] = '    RewriteCond %{ENV:HTP_ALLOW} !=1';
                $out[] = '    RewriteRule "^administrator/manifests(/|$)" - [F,NC]';
            }

            if ($sysdirs) {
                $dirs = array();

                foreach ($cfg['s_sysdirs_list'] as $dir) {
                    $dirs[] = self::rx($dir);
                }

                sort($dirs);

                if (self::lockdownHasCache($cfg)) {
                    $out[] = '';
                    $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_L_CACHE1', 'Theme-Frameworks (Astroid, Gantry ...) und Bild-Filter (JA MegaFilter ...)');
                    $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_L_CACHE2', 'liefern statische Assets (CSS/JS/Bilder) aus cache/ UND administrator/cache/ -');
                    $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_L_CACHE3', 'diese freigeben; PHP/serialisierte Daten im Cache bleiben gesperrt.');
                    $out[] = '    RewriteRule "' . self::CACHE_ASSET_PATTERN . '" - [NC,L]';
                }

                $out[] = '';
                $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_L_INTERNAL', 'Interne Verzeichnisse sind über das Web tabu');
                $out[] = '    RewriteCond %{ENV:HTP_ALLOW} !=1';
                $out[] = '    RewriteRule "^(' . implode('|', $dirs) . ')(/|$)" - [F,NC]';
            }

            if ($dotfiles) {
                $out[] = '';
                $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_L_DOTFILES', 'Versteckte Dateien und Verzeichnisse (.git, .env, .htpasswd ...)');

                if (!empty($cfg['s_wellknown'])) {
                    $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_L_WELLKNOWN', '.well-known bleibt für Zertifikats-Validierung erreichbar');
                    $out[] = '    RewriteCond %{ENV:HTP_ALLOW} !=1';
                    $out[] = '    RewriteRule "(^|/)\.(?!well-known(/|$))" - [F]';
                } else {
                    $out[] = '    RewriteCond %{ENV:HTP_ALLOW} !=1';
                    $out[] = '    RewriteRule "(^|/)\." - [F]';
                }
            }

            $out[] = '</IfModule>';
            $out[] = '';
        }

        if ($sensitive) {
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_L_SENSNAME', 'Namentlich bekannte sensible Dateien');
            $out[] = '<FilesMatch "' . self::SENSITIVE_NAMES . '">';
            self::deny($out);
            $out[] = '</FilesMatch>';
            $out[] = '';
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_L_SENSEXT', 'Riskante Endungen: Sicherungen, Schlüssel, Datenbanken, Logs, Konfigurationen');
            $out[] = '<FilesMatch "(?i)' . self::SENSITIVE_EXT . '">';
            self::deny($out);
            $out[] = '</FilesMatch>';
            $out[] = '';
        }
    }

    private static function sectionShield(&$out, $cfg)
    {
        $php   = !empty($cfg['s_php']);
        $types = !empty($cfg['s_types']);

        if (!$php && !$types) {
            return;
        }

        self::head($out, '7', htp_tc('COM_HTPROTECT_HTC_HEAD_FILESHIELD', 'Dateischild'));

        // Bewusst PFAD-RELATIV: Die Freigaben sind RewriteRule-Operanden
        // (relativ zur RewriteBase) und setzen nur ein Umgebungs-Flag - kein
        // eingebackener Site-Pfad und kein [L]. Dadurch gilt dieselbe Datei
        // unveraendert in jedem Unterverzeichnis (Template-Klon, kein 403 auf
        // index.php), waehrend Router- und Auslieferungsblock fuer Freigaben
        // normal weiterlaufen (kein SEF-Hijack, keine ausgelassene Vorkompression).
        // Spiegelt exakt HtpBuilder::isAllowedEntry() der Simulation.
        $out[] = '<IfModule mod_rewrite.c>';

        if ($php) {
            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_S_PHP', 'PHP läuft nur über freigegebene Einstiegspunkte - alles andere: 403');

            foreach (self::coreEntries() as $file) {
                $out[] = '    RewriteRule "^' . self::rx($file) . '(/|$)" - [E=HTP_SHIELD_PHP:1,NC]';
            }

            // Restore-/Migrations-Verzeichnis (Akeeba-ANGIE / Joomla-Kern-Installer).
            foreach (self::restoreEntryDirs() as $dir) {
                $out[] = '    RewriteRule "^' . self::rx($dir) . '/" - [E=HTP_SHIELD_PHP:1,NC]';
            }

            foreach (self::phpFileExceptions($cfg) as $file) {
                if (in_array($file, self::coreEntries(), true)) {
                    continue;
                }

                $out[] = '    RewriteRule "^' . self::rx($file) . '(/|$)" - [E=HTP_SHIELD_PHP:1,NC]';
            }

            foreach ($cfg['s_dirs_php'] as $dir) {
                $out[] = '    RewriteRule "^' . self::rx($dir) . '/" - [E=HTP_SHIELD_PHP:1,NC]';
            }

            // (A) Nachgestelltes Segment/Doppelendung (.php/ , .php.) -> immer 403.
            //     Faengt den PATH_INFO-Bypass (shell.php/x) und als Bild getarnte
            //     Doppelendungen, unabhaengig davon, ob die Datei existiert.
            $out[] = '    RewriteCond %{ENV:HTP_SHIELD_PHP} !=1';
            $out[] = '    RewriteCond %{ENV:HTP_ALLOW} !=1';
            $out[] = '    RewriteRule "' . self::PHP_RULE_PATHINFO . '" - [F,NC]';

            // (B) Direkte .php-Anfrage -> nur 403, wenn die Datei TATSAECHLICH
            //     existiert (hochgeladene Shell). Nicht vorhandene .php-Pfade
            //     reicht der Router unten an die echte index.php weiter (Joomlas
            //     Non-SEF-Routing) -> nicht faelschlich blocken. Upload-Ordner
            //     bleiben ueber ihr eigenes DirShield gegen PHP gesperrt.
            $out[] = '    RewriteCond %{ENV:HTP_SHIELD_PHP} !=1';
            $out[] = '    RewriteCond %{ENV:HTP_ALLOW} !=1';
            $out[] = '    RewriteCond %{REQUEST_FILENAME} -f';
            $out[] = '    RewriteRule "' . self::PHP_RULE_DIRECT . '" - [F,NC]';
        }

        if ($types) {
            $extPattern = str_replace(',', '|', $cfg['s_ext']);

            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_S_TYPES1', 'Vorhandene Dateien werden nur mit freigegebener Endung ausgeliefert.');
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_S_TYPES2', 'Nicht existierende Pfade bleiben unberührt (SEF-Routing).');

            foreach (self::coreEntries() as $file) {
                $out[] = '    RewriteRule "^' . self::rx($file) . '(/|$)" - [E=HTP_SHIELD_TYPE:1,NC]';
            }

            // Restore-/Migrations-Verzeichnis: auch von der Endungs-Sperre ausnehmen.
            foreach (self::restoreEntryDirs() as $dir) {
                $out[] = '    RewriteRule "^' . self::rx($dir) . '/" - [E=HTP_SHIELD_TYPE:1,NC]';
            }

            if (!empty($cfg['s_wellknown'])) {
                $out[] = '    RewriteRule "^\.well-known/" - [E=HTP_SHIELD_TYPE:1]';
            }

            foreach ($cfg['s_files'] as $file) {
                if (in_array($file, self::coreEntries(), true)) {
                    continue;
                }

                $out[] = '    RewriteRule "^' . self::rx($file) . '(/|$)" - [E=HTP_SHIELD_TYPE:1,NC]';
            }

            foreach (array_merge($cfg['s_dirs_static'], $cfg['s_dirs_php']) as $dir) {
                $out[] = '    RewriteRule "^' . self::rx($dir) . '/" - [E=HTP_SHIELD_TYPE:1,NC]';
            }

            $out[] = '    RewriteCond %{ENV:HTP_SHIELD_TYPE} !=1';
            $out[] = '    RewriteCond %{REQUEST_FILENAME} -f';
            $out[] = '    RewriteCond %{REQUEST_URI} "!\.(' . $extPattern . ')$" [NC]';
            $out[] = '    RewriteCond %{ENV:HTP_ALLOW} !=1';
            $out[] = '    RewriteRule . - [F]';
        }

        $out[] = '</IfModule>';
        $out[] = '';
    }

    private static function sectionPerformance(&$out, $cfg)
    {
        // Dynamische Kompression (AddOutputFilterByType) NICHT auf LiteSpeed schreiben:
        // LiteSpeed komprimiert Antworten NATIV -> ein zweiter Filter wuerde doppeln
        // ("content encoding error"). Auf Apache ist der Block der Kompressor (bleibt).
        $compress = !empty($cfg['p_compress']) && !htp_is_litespeed();
        $expires  = !empty($cfg['p_expires']);
        $etag     = !empty($cfg['p_etag']);

        if (!$compress && !$expires && !$etag) {
            return;
        }

        self::head($out, '8', htp_tc('COM_HTPROTECT_HTC_HEAD_DELIVERY', 'Auslieferung und Caching'));

        // Die fruehere "vorkomprimierte Auslieferung (.br/.gz)" wurde ab Ruleset 7
        // ersatzlos entfernt: auf Servern mit eigenem .gz-Encoding (mod_mime) doppelte
        // sich der Content-Encoding-Header ("gzip, gzip") -> CSS/JS unentschluesselbar.
        // Die Live-Kompression unten uebernimmt den Nutzen sicher.

        if ($compress) {
            $types = 'application/javascript application/json application/ld+json '
                . 'application/manifest+json application/rss+xml application/vnd.ms-fontobject '
                . 'application/wasm application/x-javascript application/xhtml+xml application/xml '
                . 'font/otf font/ttf image/svg+xml text/css text/html text/javascript text/plain '
                . 'text/vtt text/xml';

            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_D_COMPRESS', 'Textbasierte Inhalte komprimiert übertragen');
            $out[] = '<IfModule mod_brotli.c>';
            $out[] = '    AddOutputFilterByType BROTLI_COMPRESS ' . $types;
            $out[] = '</IfModule>';
            $out[] = '<IfModule mod_deflate.c>';
            $out[] = '    AddOutputFilterByType DEFLATE ' . $types;
            $out[] = '</IfModule>';
            $out[] = '';
        }

        if ($expires) {
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_D_EXPIRES1', 'Browser-Cache: lange Laufzeiten nur für statische Inhalte.');
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_D_EXPIRES2', 'HTML/JSON/XML bekommen bewusst keine Laufzeit (immer frisch).');
            $out[] = '<IfModule mod_expires.c>';
            $out[] = '    ExpiresActive On';
            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_D_FONTS', 'Schriften: 1 Jahr');

            foreach (array('application/vnd.ms-fontobject', 'font/otf', 'font/ttf', 'font/woff', 'font/woff2') as $type) {
                $out[] = '    ExpiresByType ' . $type . ' "access plus 1 year"';
            }

            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_D_MEDIA', 'Bilder und Medien: 1 Jahr');

            $mediaTypes = array(
                'audio/mpeg', 'audio/ogg', 'image/avif', 'image/bmp', 'image/gif',
                'image/jpeg', 'image/png', 'image/svg+xml', 'image/tiff', 'image/webp',
                'image/x-icon', 'video/mp4', 'video/ogg', 'video/webm',
            );

            foreach ($mediaTypes as $type) {
                $out[] = '    ExpiresByType ' . $type . ' "access plus 1 year"';
            }

            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_D_CSSJS', 'Stylesheets und Skripte: 1 Jahr (bei Änderungen Versions-Query nutzen)');
            $out[] = '    ExpiresByType text/css "access plus 1 year"';
            $out[] = '    ExpiresByType application/javascript "access plus 1 year"';
            $out[] = '    ExpiresByType application/x-javascript "access plus 1 year"';
            $out[] = '    ExpiresByType text/javascript "access plus 1 year"';
            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_D_FEEDS', 'Feeds: 1 Stunde');
            $out[] = '    ExpiresByType application/atom+xml "access plus 1 hour"';
            $out[] = '    ExpiresByType application/rss+xml "access plus 1 hour"';
            $out[] = '</IfModule>';
            $out[] = '';
        }

        if ($etag) {
            $out[] = '# ' . htp_tc('COM_HTPROTECT_HTC_D_ETAG', 'ETags abschalten (konsistentes Caching hinter Load-Balancern)');
            $out[] = 'FileETag None';
            $out[] = '<IfModule mod_headers.c>';
            $out[] = '    Header unset ETag';
            $out[] = '</IfModule>';
            $out[] = '';
        }
    }

    private static function sectionRouter(&$out, $cfg)
    {
        if (empty($cfg['sef_rules'])) {
            return;
        }

        self::head($out, '9', htp_tc('COM_HTPROTECT_HTC_HEAD_ROUTER', 'Joomla-Router'));

        $p = self::prefix($cfg);

        $out[] = '<IfModule mod_rewrite.c>';

        if (((string) (isset($cfg['api_mode']) ? $cfg['api_mode'] : 'route')) === 'route') {
            $out[] = '';
            $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_R_API', 'Schnittstellen-Anwendung (Joomla 4+): /api/... an api/index.php');
            $out[] = '    RewriteCond %{REQUEST_URI} "^' . $p . '/api/"';
            $out[] = '    RewriteCond %{REQUEST_URI} "!^' . $p . '/api/index\.php"';
            $out[] = '    RewriteCond %{REQUEST_FILENAME} !-f';
            $out[] = '    RewriteCond %{REQUEST_FILENAME} !-d';
            $out[] = '    RewriteRule .* api/index.php [L]';
        }

        $out[] = '';
        $out[] = '    # ' . htp_tc('COM_HTPROTECT_HTC_R_ROUTER', 'Alle übrigen Anfragen auf nicht vorhandene Pfade an den Router übergeben');
        $out[] = '    RewriteCond %{REQUEST_URI} "!^' . $p . '/index\.php"';
        $out[] = '    RewriteCond %{REQUEST_FILENAME} !-f';
        $out[] = '    RewriteCond %{REQUEST_FILENAME} !-d';
        $out[] = '    RewriteRule .* index.php [L]';
        $out[] = '</IfModule>';
        $out[] = '';
    }

    private static function sectionCustom(&$out, $cfg, $key, $title)
    {
        if (!isset($cfg[$key]) || trim((string) $cfg[$key]) === '') {
            return;
        }

        self::head($out, $key === 'custom_top' ? 'A' : 'B', $title);

        $out[] = trim($cfg[$key]);
        $out[] = '';
    }

    /**
     * Fail-safe-Heuristik (NUR Warnung, NIE Auto-Rollback): Enthaelt eine EIGENE Regel in den
     * verwalteten Eigenregel-Feldern (custom_top/custom_bottom) eine POSITIVE, /administrator (bzw.
     * /adm*) blockierende Bedingung? Solche Regeln sperren den Betreiber aus dem Backend, und der
     * Schild-Rollback kann sie nicht entfernen (sie gehoeren dem Betreiber). Bewusst konservativ +
     * fehlalarmarm: NUR die Eigenregel-Felder werden gescannt (die generierten Regeln enthalten
     * /administrator in NEGIERTEN Ausnahmen -> die wuerden sonst falsch anschlagen); negierte
     * Bedingungen ("!...") werden ignoriert; es braucht eine admin-Bedingung ZUSAMMEN mit einem
     * Block-Flag ([F]/[R]) bzw. ein Deny/Require-denied im administrator-Kontext.
     *
     * @param   array  $cfg
     * @return  bool
     */
    public static function customRuleBlocksAdmin($cfg)
    {
        $blob = '';

        foreach (array('custom_top', 'custom_bottom') as $k) {
            if (isset($cfg[$k]) && is_string($cfg[$k])) {
                $blob .= "\n" . $cfg[$k];
            }
        }

        if (trim($blob) === '') {
            return false;
        }

        $lines        = preg_split('/\r\n|\r|\n/', $blob);
        $hasAdminCond = false;

        foreach ($lines as $ln) {
            $l = trim((string) $ln);

            if ($l === '' || $l[0] === '#') {
                continue;
            }

            // Deny/Require-denied direkt im administrator-Kontext -> Aussperrung.
            if (preg_match('~(administrator|/adm)~i', $l)
                && preg_match('~(Deny\s+from\s+all|Require\s+all\s+denied)~i', $l)) {
                return true;
            }

            if (preg_match('~RewriteCond\s+%\{REQUEST_URI\}~i', $l)) {
                // Negierte Bedingung ("!...") ist eine AUSNAHME, keine Sperre -> ignorieren.
                if (preg_match('~%\{REQUEST_URI\}\s+"?!~i', $l)) {
                    continue;
                }

                if (preg_match('~admin~i', $l)) {
                    $hasAdminCond = true;
                }

                continue;
            }

            // Eine RewriteRule mit Block-Flag ([F]/[R]) NACH einer positiven admin-Bedingung.
            if ($hasAdminCond && preg_match('~^RewriteRule\b~i', $l)) {
                if (preg_match('~\[[^\]]*\b(F|R=?\d*)\b~i', $l)) {
                    return true;
                }

                // RewriteRule ohne Block-Flag beendet die Bedingungsgruppe.
                $hasAdminCond = false;
            }
        }

        return false;
    }

    // ─────────────────────────────────────────────────────────────────
    //  Simulation (fuer Site-Scan und Pfad-Test)
    // ─────────────────────────────────────────────────────────────────

    /**
     * Prueft, wie die generierten Regeln einen Pfad behandeln wuerden.
     *
     * @param   array   $cfg   Konfiguration
     * @param   string  $path  Dekodierter Pfad relativ zum Joomla-Root, mit fuehrendem '/'
     *
     * @return array ['allowed' => bool, 'rule' => string, 'suggest' => array|null]
     */
    public static function simulate($cfg, $path)
    {
        $path = '/' . ltrim((string) $path, '/');

        while (strpos($path, '//') !== false) {
            $path = str_replace('//', '/', $path);
        }

        $rel = ltrim($path, '/');

        // [4] WordPress-Rauschfilter: laeuft in der erzeugten .htaccess
        // (Abschnitt 4) ungeschuetzt VOR den Pfad-Freigaben (KEIN HTP_ALLOW-Guard)
        // und trifft auch Verzeichnisse. Darum hier ganz vorn - so meldet der
        // URL-Test die Realitaet auch fuer mit '/' endende oder als
        // vertrauenswuerdig eingetragene wp-*-Pfade (auf Joomla nie legitim).
        if (!empty($cfg['f_wpnoise'])) {
            if (preg_match('#^(wp-admin|wp-content|wp-includes|wp-json)(/|$)#i', $rel)
                || preg_match('#^(wp-login|wp-signup|wp-cron|xmlrpc)\.php#i', $rel)) {
                return self::verdict(false, htp_t('COM_HTPROTECT_RULE_WPNOISE'), null);
            }
        }

        if ($path === '/' || substr($path, -1) === '/') {
            return self::verdict(true, htp_t('COM_HTPROTECT_RULE_DIRROUTE'), null);
        }

        $base = basename($path);

        // [1b] Vertrauenswuerdige Pfade heben ALLE pfad-basierten Sperren auf
        // (spiegelt das HTP_ALLOW-Flag der .htaccess). Query-Injektionsfilter
        // sind hier nicht relevant - simulate() bewertet rein den Pfad.
        if (class_exists('HtpConfig') && HtpConfig::isAllowedPath($cfg, $rel)) {
            return self::verdict(true, htp_t('COM_HTPROTECT_RULE_ALLOWLISTED'), null);
        }

        // [4b] Exploit-Schild: /api-Sperre und Pfad-basierte Signaturen.
        // Query-String-Signaturen lassen sich aus einem Pfad nicht ableiten
        // und werden hier bewusst nicht geprueft.
        if (((string) (isset($cfg['api_mode']) ? $cfg['api_mode'] : 'route')) === 'block'
            && preg_match('#^api(/|$)#i', $rel)) {
            return self::verdict(false, htp_t('COM_HTPROTECT_RULE_API_BLOCKED'), null);
        }

        if (!empty($cfg['x_shield']) && class_exists('HtpDefs')) {
            foreach (HtpDefs::effective($cfg) as $sig) {
                // waf_only-Signaturen stehen nicht in der .htaccess -> auch hier
                // nicht als Pfad-Sperre simulieren.
                if (empty($sig['uri']) || !empty($sig['waf_only'])) {
                    continue;
                }

                if (!empty($sig['frontend_only']) && preg_match('#^administrator(/|$)#i', $rel)) {
                    continue;
                }

                if (@preg_match('#' . $sig['uri'] . '#i', $rel)) {
                    return self::verdict(false, htp_t('COM_HTPROTECT_RULE_EXPLOIT', $sig['id']), null);
                }
            }
        }

        // [6] Statische Theme-Assets aus cache/ (Astroid/Gantry-Minify) sind
        // von der Verzeichnissperre ausgenommen - spiegelt die CACHE_ASSET-Regel.
        if (self::lockdownHasCache($cfg) && preg_match('#' . self::CACHE_ASSET_PATTERN . '#i', $rel)) {
            return self::verdict(true, htp_t('COM_HTPROTECT_RULE_CACHE_ASSET'), null);
        }

        // [6] Interne Verzeichnisse
        if (!empty($cfg['s_sysdirs'])) {
            foreach ($cfg['s_sysdirs_list'] as $dir) {
                if (preg_match('#^' . preg_quote($dir, '#') . '(/|$)#i', $rel)) {
                    return self::verdict(false, htp_t('COM_HTPROTECT_RULE_INTERNAL_DIR', $dir), null);
                }
            }
        }

        // [6] Versteckte Dateien
        if (!empty($cfg['s_dotfiles']) && preg_match('#(^|/)\.#', $path)) {
            $wellknown = !empty($cfg['s_wellknown']) && preg_match('#^\.well-known(/|$)#', $rel);

            if (!$wellknown) {
                return self::verdict(false, htp_t('COM_HTPROTECT_RULE_DOTFILE'), null);
            }
        }

        // [6] Sensible Dateien
        if (!empty($cfg['s_sensitive'])) {
            if (preg_match('#' . self::SENSITIVE_NAMES . '#', $base)) {
                return self::verdict(false, htp_t('COM_HTPROTECT_RULE_SENSITIVE_NAME'), null);
            }

            if (preg_match('#(?i)' . self::SENSITIVE_EXT . '#', $base)) {
                return self::verdict(false, htp_t('COM_HTPROTECT_RULE_SENSITIVE_EXT'), null);
            }
        }

        // [6] Erweiterungs-Manifeste (.xml) - Versions-Fingerprinting erschweren
        if (!empty($cfg['s_manifests'])) {
            if (preg_match('#^(administrator/)?(components|libraries|modules|plugins|templates)/.+\.xml$#i', $rel)
                || preg_match('#^administrator/manifests(/|$)#i', $rel)) {
                return self::verdict(false, htp_t('COM_HTPROTECT_RULE_MANIFEST'), null);
            }
        }

        // [7] PHP-Schild (RULESET 4): spiegelt die geteilte .htaccess-Regel + das
        //     Upload-Ordner-Schild (DirShield). Geblockt wird, wenn:
        //       - nachgestelltes Segment/Doppelendung (.php/ , .php.)  ODER
        //       - die .php-Datei tatsaechlich existiert (Shell)         ODER
        //       - der Pfad in einem gehaerteten Upload-Ordner liegt (DirShield).
        //     Ein nicht vorhandener .php-Pfad ausserhalb gehaerteter Ordner wird
        //     vom Router an index.php weitergereicht -> NICHT als blockiert melden
        //     (z. B. relative index.php-Links bei abweichendem <base href>).
        if (!empty($cfg['s_php']) && preg_match(self::PHP_SIM_PATTERN, $path)) {
            if (self::isAllowedEntry($cfg, $rel, false)) {
                return self::verdict(true, htp_t('COM_HTPROTECT_RULE_PHP_ENTRY'), null);
            }

            $pathInfo = (bool) preg_match('#' . self::PHP_RULE_PATHINFO . '#i', $path);
            $exists   = @is_file(htp_docroot() . $path);

            if ($pathInfo || $exists || self::inHardenedUploadDir($cfg, $rel)) {
                return self::verdict(
                    false,
                    htp_t('COM_HTPROTECT_RULE_PHP_SHIELD'),
                    array('type' => 'file', 'value' => $rel)
                );
            }
            // sonst: nicht vorhandener .php-Pfad -> wird geroutet (durchfallen)
        }

        // [7] Dateityp-Schild (nur real existierende Dateien)
        if (!empty($cfg['s_types'])) {
            $abs = htp_docroot() . $path;

            if (is_file($abs)) {
                $ext     = strtolower(pathinfo($path, PATHINFO_EXTENSION));
                $allowed = explode(',', strtolower($cfg['s_ext']));

                if (!in_array($ext, $allowed, true) && !self::isAllowedEntry($cfg, $rel, true)) {
                    $suggest = ($ext !== '' && preg_match('/^[a-z0-9]{1,12}$/', $ext))
                        ? array('type' => 'ext', 'value' => $ext)
                        : array('type' => 'file', 'value' => $rel);

                    return self::verdict(false, htp_t('COM_HTPROTECT_RULE_FILETYPE', $ext), $suggest);
                }
            }
        }

        return self::verdict(true, htp_t('COM_HTPROTECT_RULE_ALLOWED'), null);
    }

    /**
     * Ist der Pfad ueber Kern-Einstiege oder Freigabe-Listen erlaubt?
     * Spiegelt exakt die Ausnahme-Bedingungen der erzeugten Regeln:
     * Das PHP-Schild kennt nur Kern-Einstiege, Datei-Freigaben und
     * PHP-Ordner; das Dateityp-Schild zusaetzlich statische Ordner
     * und .well-known.
     *
     * @param   array    $cfg            Konfiguration
     * @param   string   $rel            Pfad relativ zum Root ohne fuehrenden Slash
     * @param   boolean  $includeStatic  true = Ausnahmen des Dateityp-Schilds anwenden
     *
     * @return boolean
     */
    private static function isAllowedEntry($cfg, $rel, $includeStatic)
    {
        foreach (self::coreEntries() as $core) {
            if (preg_match('#^' . preg_quote($core, '#') . '(/|$)#i', $rel)) {
                return true;
            }
        }

        foreach (self::restoreEntryDirs() as $dir) {
            if (preg_match('#^' . preg_quote($dir, '#') . '/#i', $rel)) {
                return true;
            }
        }

        foreach ($cfg['s_files'] as $file) {
            if (preg_match('#^' . preg_quote($file, '#') . '(/|$)#i', $rel)) {
                return true;
            }
        }

        foreach ($cfg['s_dirs_php'] as $dir) {
            if (preg_match('#^' . preg_quote($dir, '#') . '/#i', $rel)) {
                return true;
            }
        }

        if ($includeStatic) {
            foreach ($cfg['s_dirs_static'] as $dir) {
                if (preg_match('#^' . preg_quote($dir, '#') . '/#i', $rel)) {
                    return true;
                }
            }

            if (!empty($cfg['s_wellknown']) && preg_match('#^\.well-known/#', $rel)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Liegt der Pfad in einem per Upload-Ordner-Schild (DirShield) gehaerteten
     * Verzeichnis (u_dirs, z. B. images/media)? Dort sperrt eine eigene
     * Ordner-.htaccess PHP unabhaengig von der Datei-Existenz - die Simulation
     * muss das beruecksichtigen, damit der PHP-Schild-Teil (-f) dort nicht
     * faelschlich "erlaubt" meldet.
     *
     * @param   array   $cfg  Konfiguration
     * @param   string  $rel  Pfad relativ zum Root ohne fuehrenden Slash
     *
     * @return boolean
     */
    private static function inHardenedUploadDir($cfg, $rel)
    {
        if (empty($cfg['u_harden']) || empty($cfg['u_dirs']) || !is_array($cfg['u_dirs'])) {
            return false;
        }

        foreach ($cfg['u_dirs'] as $dir) {
            $dir = trim((string) $dir, '/');

            if ($dir !== '' && preg_match('#^' . preg_quote($dir, '#') . '(/|$)#i', $rel)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Die Query-String-Anfrage-Filter als pruefbare Muster - der Spiegel der
     * RewriteConds aus sectionFilters(). Damit kann der URL-Test offenlegen,
     * WELCHE Filter-Regel einen 403 ausloesen wuerde, ohne die .htaccess zu
     * erzeugen.
     *
     * MUSS exakt synchron zu sectionFilters() bleiben - der Test "Filter-Sync"
     * in logiccheck.php vergleicht beide Quellen und deckt jede Abweichung auf.
     * 'nc' = Apache-[NC] (Gross-/Kleinschreibung egal).
     *
     * @return array  ['f_query' => [['rx'=>…,'nc'=>bool], …], 'f_rfi' => […]]
     */
    public static function requestFilterRules()
    {
        return array(
            'f_query' => array(
                array('rx' => '%00', 'nc' => false),
                array('rx' => '(\.\./|\.\.%2f|%2e%2e/|%2e%2e%2f)', 'nc' => true),
                array('rx' => '(data|expect|glob|ogg|phar|php|zlib)(://|%3a%2f%2f)', 'nc' => true),
                array('rx' => 'base64_(en|de)code\(', 'nc' => true),
                array('rx' => '(<|%3C)script', 'nc' => true),
                array('rx' => 'proc/sel[f]/enviro[n]', 'nc' => false),
                array('rx' => 'mosConfig_[a-zA-Z_]{1,24}(=|%3D)', 'nc' => false),
                array('rx' => 'GLOBALS(=|\[|%[0-9A-Z]{0,2})', 'nc' => false),
                array('rx' => '_REQUEST(=|\[|%[0-9A-Z]{0,2})', 'nc' => false),
                array('rx' => '^=PHP[0-9A-Fa-f\-]{10,}', 'nc' => false),
            ),
            'f_rfi' => array(
                array('rx' => '=(https?|ftps?)(://|%3a%2f%2f)', 'nc' => true),
                array('rx' => '=(\.\.//?)+', 'nc' => false),
            ),
        );
    }

    /**
     * Den rohen Query-String gegen die Anfrage-Filter pruefen - genau so, wie
     * Apache es mit dem (nicht dekodierten) %{QUERY_STRING} taete. Liefert den
     * greifenden Filter samt getroffenem Muster oder null.
     *
     * @param   array   $cfg     Konfiguration (f_query/f_rfi-Schalter)
     * @param   string  $query   Roher Query-String (ohne fuehrendes '?')
     * @param   string  $method  HTTP-Methode (f_rfi greift nur bei GET)
     *
     * @return array|null  ['filter' => 'f_query'|'f_rfi', 'pattern' => string]
     */
    public static function matchRequestFilter($cfg, $query, $method = 'GET')
    {
        $query = (string) $query;

        if ($query === '') {
            return null;
        }

        $rules = self::requestFilterRules();

        if (!empty($cfg['f_query'])) {
            foreach ($rules['f_query'] as $r) {
                if (@preg_match('#' . $r['rx'] . '#' . ($r['nc'] ? 'i' : ''), $query) === 1) {
                    return array('filter' => 'f_query', 'pattern' => $r['rx']);
                }
            }
        }

        if (!empty($cfg['f_rfi']) && strtoupper((string) $method) === 'GET') {
            foreach ($rules['f_rfi'] as $r) {
                if (@preg_match('#' . $r['rx'] . '#' . ($r['nc'] ? 'i' : ''), $query) === 1) {
                    return array('filter' => 'f_rfi', 'pattern' => $r['rx']);
                }
            }
        }

        return null;
    }

    // ─────────────────────────────────────────────────────────────────
    //  Helfer
    // ─────────────────────────────────────────────────────────────────

    /**
     * Regex-escaped Site-Pfad-Praefix fuer REQUEST_URI-Bedingungen.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return string
     */
    private static function prefix($cfg)
    {
        return $cfg['site_path'] === '' ? '' : self::rx($cfg['site_path']);
    }

    /**
     * Apache-/PCRE-sicheres Escaping.
     *
     * @param   string  $value  Rohwert
     *
     * @return string
     */
    private static function rx($value)
    {
        return preg_quote($value);
    }

    /**
     * Wird der cache/-Ordner ueberhaupt durch die Verzeichnissperre erfasst?
     * Nur dann lohnt die Ausnahme fuer statische Theme-Assets (Astroid/Gantry).
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return boolean
     */
    private static function lockdownHasCache($cfg)
    {
        if (empty($cfg['s_sysdirs']) || empty($cfg['s_sysdirs_list'])) {
            return false;
        }

        foreach ($cfg['s_sysdirs_list'] as $dir) {
            $d = strtolower(trim((string) $dir, "/ \t"));

            // Frontend-cache/ ODER administrator/cache/ gesperrt -> statische Assets
            // (CSS/JS/Fonts/Bilder) daraus freigeben, PHP/serialisierte Daten bleiben tabu.
            if ($d === 'cache' || $d === 'administrator/cache') {
                return true;
            }
        }

        return false;
    }

    /**
     * Kanonischen Hostnamen bestimmen (Konfiguration oder aktuelle Domain).
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return string
     */
    public static function canonicalHost($cfg)
    {
        $host = $cfg['canonical_host'];

        if ($host === '') {
            $parsed = parse_url(htp_site_url(), PHP_URL_HOST);
            $host   = is_string($parsed) ? strtolower($parsed) : '';
        }

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

        if ((int) $cfg['www_mode'] === 1 && !preg_match('/^www\./i', $host)) {
            $host = 'www.' . $host;
        } elseif ((int) $cfg['www_mode'] === 2) {
            $host = preg_replace('/^www\./i', '', $host);
        }

        return $host;
    }

    /**
     * Fest freigegebene Kern-Endpunkte. Sie sind unabhaengig von den
     * konfigurierbaren Freigaben immer offen, damit Joomla-Core-Updates
     * (extract.php ab 4.0.4, restore.php bis 4.0.3) nie am Schild
     * scheitern koennen.
     *
     * @return array
     */
    public static function coreEntries()
    {
        return array(
            'index.php',
            'administrator/index.php',
            'api/index.php',
            'administrator/components/com_joomlaupdate/extract.php',
            'administrator/components/com_joomlaupdate/restore.php',
            // BackupMonkey / RemoteMonkey (David Jardin): dieselben Kern-Update-Endpunkte
            // wie Joomlas com_joomlaupdate, nur als Frontend-Komponente. Ohne feste Freigabe
            // scheitert deren Core-Update am PHP-Schild (403). Joomla 3.x nutzt restore.php,
            // Joomla 4+ extract.php. Analog zu com_joomlaupdate fest offen (die Datei existiert
            // nur, wenn das Tool installiert ist; Vektor braeuchte ohnehin Schreibzugriff).
            'components/com_remotemonkey/restore.php',
            'components/com_remotemonkey/extract.php',
            // HTProtects EIGENER crash-fester Rescue-Endpunkt (Ed25519-signaturgeprueft, ohne _JEXEC,
            // bewusst direkt aufrufbar). MUSS am PHP-Schild vorbei - sonst 403 auf die eigene
            // rescue.php, und der signierte Fern-Rescue (debug.set/bc.enable + Notfall-Modus AUS
            // ueber SiteRescueController) kommt NIE an -> Dashboard-"Beenden" faellt auf die Queue
            // zurueck, die bei gesperrter Seite verhungert. Signatur-Gate schuetzt den Endpunkt.
            'plugins/system/htprotectwatch/rescue.php',
        );
    }

    /**
     * Verzeichnisse, in denen PHP fuer einen RESTORE/MIGRATION laufen darf: Akeebas
     * ANGIE-Restore und Joomlas Kern-Installer liegen unter /installation/. Ohne
     * Durchlass scheitert jeder Restore mit 403 (/installation/index.php). Das
     * Verzeichnis existiert nur waehrend eines Restores; Joomla warnt selbst, solange
     * es da ist, und HTProtect meldet es zusaetzlich in der Uebersicht -> nach dem
     * Restore loeschen. Konsistent damit, dass coreEntries() bereits Joomlas eigene
     * Restore-Skripte (com_joomlaupdate/extract.php + restore.php) erlaubt.
     *
     * @return array
     */
    public static function restoreEntryDirs()
    {
        return array('installation');
    }

    /**
     * Datei-Freigaben, die das PHP-Muster betreffen.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array
     */
    private static function phpFileExceptions($cfg)
    {
        $files = array();

        foreach ($cfg['s_files'] as $file) {
            if (preg_match(self::PHP_SIM_PATTERN, '/' . $file)) {
                $files[] = $file;
            }
        }

        return $files;
    }

    /**
     * Mutmassliche Eigenregeln aus einer bestehenden .htaccess herausziehen.
     * Bekannte Joomla-Standardbloecke (htaccess.txt), klassische Exploit-
     * Filter und Generator-Trennzeilen werden entfernt - uebrig bleibt, was
     * vermutlich individuell ist (z. B. cPanel-PHP-Handler, eigene Redirects).
     * Das Ergebnis ist als Vorbelegung fuer "Eigene Regeln" gedacht und wird
     * vom Benutzer vor dem Speichern geprueft.
     *
     * @param   string  $content  Bisheriger Dateiinhalt
     *
     * @return string
     */
    public static function extractCustomRules($content, $sets = null)
    {
        $split = self::extractCore($content, $sets);
        $parts = array();

        if ($split['top'] !== '') {
            $parts[] = $split['top'];
        }

        if ($split['bottom'] !== '') {
            $parts[] = $split['bottom'];
        }

        return trim(implode("\n\n", $parts));
    }

    /**
     * Wie extractCustomRules(), liefert die Eigenregeln aber positionstreu
     * aufgeteilt: Direktiven, die im Original VOR der Joomla-Frontend-SEF-Regel
     * standen, kommen nach 'top' (Eigene Regeln Anfang), die danach nach
     * 'bottom' (Eigene Regeln Ende). So bleibt die Ausfuehrungsreihenfolge
     * relativ zum Router erhalten.
     *
     * @param   string      $content  Bisheriger Dateiinhalt
     * @param   array|null  $sets     Standard-Mengen (Tests); null = Standard
     *
     * @return array ['top' => string, 'bottom' => string]
     */
    public static function extractCustomRulesSplit($content, $sets = null)
    {
        return self::extractCore($content, $sets);
    }

    /**
     * KRITISCHE Infrastruktur-Direktiven aus einer bestehenden .htaccess ziehen:
     * die PHP-Handler-/Versionswahl (z. B. das Hoster-Makro "Use php-fpm 83" oder
     * eine Handler-/Typ-Direktive auf eine ...php-Endung). Deren VERLUST beim
     * Neuschreiben kann die Site unter die PHP-Mindestversion fallen lassen (Frontend
     * + Backend tot). Sie muessen darum schon im ERSTEN Schreibvorgang erhalten
     * bleiben, nicht erst ueber den nachgelagerten "uebernehmen?"-Bericht. Gefaehrliche
     * php.ini-Override-Vektoren (auto-prepend/-append) werden bewusst NICHT automatisch
     * mitgenommen (Hintertuer-Schutz - die bleiben dem Bericht/Sichtpruefung vorbehalten).
     *
     * @param   string  $content  Roh-Inhalt der bestehenden .htaccess
     *
     * @return array  Liste kritischer Direktiv-Zeilen (positionstreu, kann leer sein)
     */
    public static function extractCriticalDirectives($content)
    {
        $split = self::extractCustomRulesSplit($content);
        $lines = explode("\n", (string) $split['top'] . "\n" . (string) $split['bottom']);
        $out   = array();

        foreach ($lines as $line) {
            $t = trim($line);

            if ($t === '' || $t[0] === '#') {
                continue;
            }

            // Gefaehrliche php.ini-Override-Vektoren NIE automatisch uebernehmen.
            // (Token per Konkatenation zusammengesetzt -> byte-identische Regex, aber
            // kein zusammenhaengendes Muster im Klartext fuer Server-Virenscanner.)
            if (preg_match('~auto_(?:pre|ap)pen' . 'd_file~i', $t)) {
                continue;
            }

            // Kritische PHP-Handler-/Versionswahl (Verlust = Site unter PHP-Minimum).
            if (preg_match('~^(?:Use\s+\S*php|(?:Add|Set)Hand' . 'ler\b[^\n]*php|Add' . 'Type\b[^\n]*php)~i', $t)
                && !in_array($t, $out, true)) {
                $out[] = $t;
            }
        }

        return $out;
    }

    /**
     * Kern der Eigenregel-Erkennung. Vergleicht den ROHEN EFFEKT aktiver
     * Direktiven gegen die Joomla-Standard-Mengen (J3 + J4/5/6 + ggf. live
     * htaccess.txt): Whitespace/Tabs/Reihenfolge/Wrapper/Kommentare egal,
     * Argumente exakt. Nutzer-Kommentare direkt ueber einer Eigenregel reisen
     * mit; Joomla-Boilerplate-Kommentare fallen weg.
     *
     * @param   string      $content  Bisheriger Dateiinhalt
     * @param   array|null  $sets     Standard-Mengen (Tests); null = standardSets()
     *
     * @return array ['top' => string, 'bottom' => string]
     */
    private static function extractCore($content, $sets)
    {
        if ($sets === null) {
            $sets = self::standardSets();
        }

        $content = str_replace(array("\r\n", "\r"), "\n", (string) $content);

        // Eigenen Generator-Block (inkl. Banner davor) zuerst entfernen.
        $content = (string) preg_replace(
            '/(?:^[ \t]*#[^\n]*\n)*^[ \t]*# htprotect-shield:begin.*?# htprotect-shield:end[^\n]*\n?/sm',
            '',
            $content
        );
        $content = (string) preg_replace('/# htprotect-shield:begin.*?# htprotect-shield:end/s', '', $content);

        $stock    = self::stockPatterns($content);
        $top      = array();
        $bottom   = array();
        $buffer   = array();    // gepufferte Nutzer-Kommentare (ueber einer Eigenregel)
        $afterSef = false;      // hinter der Frontend-SEF-Regel?

        foreach (explode("\n", $content) as $line) {
            $trim = trim($line);

            if ($trim === '') {
                $buffer = array();
                continue;
            }

            // Kommentar: Joomla-Boilerplate verwerfen, Nutzer-Kommentar puffern
            if ($trim[0] === '#') {
                $ck = self::commentKey($trim);

                if ($ck !== '' && isset($sets['comments'][$ck])) {
                    $buffer = array();
                } else {
                    $buffer[] = $line;
                }

                continue;
            }

            // Section-Tag (<IfModule>, </FilesMatch> ...): strukturell behalten
            if ($trim[0] === '<') {
                if ($afterSef) {
                    $bottom[] = $line;
                } else {
                    $top[] = $line;
                }

                $buffer = array();
                continue;
            }

            $ek = self::htaccessEffectKey($trim);

            // SEF-Pivot: ab hier zaehlt alles als "nach dem Router"
            if ($ek === 'rewriterule .* index.php [L]') {
                $buffer   = array();
                $afterSef = true;
                continue;
            }

            $name       = self::directiveName($trim);
            $isStandard = ($ek !== '' && isset($sets['directives'][$ek]))
                || ($name !== '' && isset($sets['names'][$name]))
                || self::lineMatchesAny($trim, $stock);

            if ($isStandard) {
                $buffer = array();
                continue;
            }

            // Eigenregel: gepufferte Nutzer-Kommentare + Zeile uebernehmen
            if ($afterSef) {
                foreach ($buffer as $b) {
                    $bottom[] = $b;
                }

                $bottom[] = $line;
            } else {
                foreach ($buffer as $b) {
                    $top[] = $b;
                }

                $top[] = $line;
            }

            $buffer = array();
        }

        return array(
            'top'    => self::cleanupExtracted($top),
            'bottom' => self::cleanupExtracted($bottom),
        );
    }

    /**
     * Verworfene Standard-Mengen aufbauen (statisch gecacht): Effekt-Schluessel
     * aller Standard-Direktiven (aktiv ODER auskommentiert), normalisierte
     * Boilerplate-Kommentare, und Direktiven-Namen, die HTProtect immer selbst
     * aus der Konfiguration erzeugt (rewritebase -> Argument egal).
     *
     * @return array ['directives' => array, 'comments' => array, 'names' => array]
     */
    private static function standardSets()
    {
        static $sets = null;

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

        $directives = array();
        $comments   = array();
        $sources    = self::bundledStandardHtaccess();

        // Optional die live htaccess.txt der Installation (exakter Patch-/
        // Zukunftsstand). Enthaelt nur Joomla-Standard, erweitert die Toleranz.
        $live = htp_docroot() . '/htaccess.txt';

        if (@is_file($live)) {
            $raw = (string) @file_get_contents($live);

            if ($raw !== '') {
                $sources[] = $raw;
            }
        }

        foreach ($sources as $src) {
            $src = str_replace(array("\r\n", "\r"), "\n", (string) $src);

            foreach (explode("\n", $src) as $line) {
                $trim = trim($line);

                if ($trim === '' || $trim[0] === '<') {
                    continue;
                }

                if ($trim[0] === '#') {
                    $ck = self::commentKey($trim);

                    if ($ck !== '') {
                        $comments[$ck] = true;
                    }

                    // Auskommentierte Standard-Direktive (z. B. "# RewriteBase /")
                    $bare = ltrim(ltrim($trim, '#'));
                    $ek   = self::htaccessEffectKey($bare);

                    if ($ek !== '') {
                        $directives[$ek] = true;
                    }

                    continue;
                }

                $ek = self::htaccessEffectKey($trim);

                if ($ek !== '') {
                    $directives[$ek] = true;
                }
            }
        }

        $sets = array(
            'directives' => $directives,
            'comments'   => $comments,
            'names'      => array('rewritebase' => true),
        );

        return $sets;
    }

    /**
     * Effekt-Schluessel einer Zeile: '' fuer leer/Kommentar/Section-Tag, sonst
     * Whitespace kollabiert + Direktivenname kleingeschrieben (Rest unveraendert).
     *
     * @param   string  $line  Rohzeile
     *
     * @return string
     */
    private static function htaccessEffectKey($line)
    {
        $t = trim((string) $line);

        if ($t === '' || $t[0] === '#' || $t[0] === '<') {
            return '';
        }

        $t  = (string) preg_replace('/\s+/', ' ', $t);
        $sp = strpos($t, ' ');

        if ($sp === false) {
            return strtolower($t);
        }

        return strtolower(substr($t, 0, $sp)) . substr($t, $sp);
    }

    /**
     * Kleingeschriebener Direktivenname einer Zeile ('' fuer Nicht-Direktiven).
     *
     * @param   string  $line  Rohzeile
     *
     * @return string
     */
    private static function directiveName($line)
    {
        $t = trim((string) $line);

        if ($t === '' || $t[0] === '#' || $t[0] === '<') {
            return '';
        }

        $t  = (string) preg_replace('/\s+/', ' ', $t);
        $sp = strpos($t, ' ');

        return strtolower($sp === false ? $t : substr($t, 0, $sp));
    }

    /**
     * Normalisierter Kommentar-Schluessel: fuehrende '#' weg, Whitespace
     * kollabiert, kleingeschrieben ('' wenn kein Kommentar/leer).
     *
     * @param   string  $line  Rohzeile
     *
     * @return string
     */
    private static function commentKey($line)
    {
        $t = trim((string) $line);

        if ($t === '' || $t[0] !== '#') {
            return '';
        }

        $t = ltrim($t, '#');
        $t = (string) preg_replace('/\s+/', ' ', trim($t));

        return strtolower($t);
    }

    /**
     * Trifft eine Zeile eines der (Generator-)Stock-Muster?
     *
     * @param   string  $trim      Getrimmte Zeile
     * @param   array   $patterns  Regex-Liste aus stockPatterns()
     *
     * @return boolean
     */
    private static function lineMatchesAny($trim, $patterns)
    {
        foreach ($patterns as $pattern) {
            if (preg_match($pattern, $trim)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Verwaiste Fragmente entschaerfen + leere Container/Leerraum aufraeumen,
     * Ergebnis als getrimmter String (oder '' ohne aktive Direktive).
     *
     * @param   array  $lines  Behaltene Zeilen
     *
     * @return string
     */
    /**
     * UNAUSGEGLICHENE Section-Tags aus extrahierten Eigenregeln entfernen. Beim
     * Import einer fremden .htaccess kann ein verwaistes </IfModule> (ohne
     * passendes <IfModule>) - oder ein nie geschlossenes <IfModule> - in den
     * Eigenregeln landen; das liesse spaeter den Builder-Paranoia-Check
     * (ausgeglichene Bloecke) hart scheitern ("nichts geschrieben"). Hier:
     *  - ein schliessendes Tag ohne offenes Pendant davor wird verworfen,
     *  - ein oeffnendes Tag, das bis Listenende nie geschlossen wird, ebenso.
     * Korrekt verschachtelte Paare (inkl. ihres Inhalts) bleiben unveraendert.
     * Betrifft nur die Container-Tags IfModule/Files/FilesMatch/Directory/Limit.
     *
     * @param   array  $lines  Zeilen der extrahierten Eigenregeln
     *
     * @return array
     */
    private static function dropOrphanSectionTags($lines)
    {
        $containers = 'IfModule|Files|FilesMatch|Directory|DirectoryMatch|Location|LocationMatch|Limit|LimitExcept';
        $drop  = array();   // Zeilen-Indizes, die entfernt werden
        $stack = array();   // offene Tags: [index, name]

        foreach ($lines as $i => $line) {
            $t = ltrim($line);

            // Schliessendes Tag </Name>
            if (preg_match('~^</\s*(' . $containers . ')\s*>~i', $t, $m)) {
                $name = strtolower($m[1]);
                $matched = false;

                for ($k = count($stack) - 1; $k >= 0; $k--) {
                    if ($stack[$k][1] === $name) {
                        array_splice($stack, $k); // schliesst dieses + alle darin offenen
                        $matched = true;
                        break;
                    }
                }

                if (!$matched) {
                    $drop[$i] = true; // verwaistes </Name>
                }

                continue;
            }

            // Oeffnendes Tag <Name ...> (kein selbstschliessendes / kein </>)
            if (preg_match('~^<\s*(' . $containers . ')\b~i', $t, $m)) {
                $stack[] = array($i, strtolower($m[1]));
            }
        }

        // Am Ende noch offene Tags = nie geschlossen -> verwerfen.
        foreach ($stack as $open) {
            $drop[$open[0]] = true;
        }

        if (empty($drop)) {
            return $lines;
        }

        $out = array();

        foreach ($lines as $i => $line) {
            if (!isset($drop[$i])) {
                $out[] = $line;
            }
        }

        return $out;
    }

    private static function cleanupExtracted($lines)
    {
        $lines  = self::dropOrphanRewriteFragments($lines);
        $lines  = self::dropOrphanSectionTags($lines);
        $result = implode("\n", $lines);

        for ($i = 0; $i < 6; $i++) {
            $cleaned = (string) preg_replace(
                '~<(IfModule|Files|FilesMatch)[^>]*>\s*(?:#[^\n]*\s*)*</\1>~i',
                '',
                $result
            );

            if ($cleaned === $result) {
                break;
            }

            $result = $cleaned;
        }

        $result = (string) preg_replace("/\n{3,}/", "\n\n", $result);
        $result = trim($result);

        // Nur noch Kommentare/Leerraum/Tags ohne aktive Direktive? -> leer.
        $hasDirective = false;

        foreach (explode("\n", $result) as $line) {
            $t = trim($line);

            if ($t !== '' && $t[0] !== '#' && $t[0] !== '<') {
                $hasDirective = true;
                break;
            }
        }

        return $hasDirective ? $result : '';
    }

    /**
     * (Generator-)Stock-Regex-Liste zum Aussortieren bekannter Nicht-Eigen-
     * Regeln (Joomla-Grundgeruest in Schreibvarianten, Admin-Tools-Generator).
     * Ergaenzt die effekt-basierte Standard-Erkennung (Union).
     *
     * @param   string  $content  Gesamtinhalt (fuer Admin-Tools-Erkennung)
     *
     * @return array
     */
    private static function stockPatterns($content)
    {
        $stock = array(
            // Joomla htaccess.txt Grundgeruest
            '~^RewriteEngine\s+On$~i',
            '~^RewriteBase\s~i',
            '~^Options\s+\+FollowSymlinks$~i',
            '~^Options\s+\+SymLinksIfOwnerMatch$~i',
            '~^Options\s+-Indexes$~i',
            '~^IndexIgnore\s+\*$~i',
            '~^DirectoryIndex\s~i',
            '~^ServerSignature\s~i',
            '~^RewriteRule\s+\.\*\s+-\s+\[E=HTTP_AUTHORIZATION~i',
            // Joomla-Router (SEF-Block, alle Varianten)
            '~^RewriteCond\s+%\{REQUEST_URI\}\s+!?\^?/?index\\\\?\.php~i',
            '~^RewriteCond\s+%\{REQUEST_URI\}\s+\(/\[\^\.\]\*~i',
            '~^RewriteCond\s+%\{REQUEST_FILENAME\}\s+!-[fd]$~i',
            '~^RewriteRule\s+(\.\*|\^\(\.\*\)\$)\s+(/?)index\.php\s+\[L\]$~i',
            '~^RewriteCond\s+%\{REQUEST_URI\}\s+\^/?api/~i',
            '~^RewriteRule\s+\.\*\s+api/index\.php\s+\[L\]$~i',
            // Legacy-SEF-Block (Joomla 1.0/1.5-Aera): interne Umschreibung auf
            // index.php OHNE [L] und/oder mit Capture-Gruppe, plus die typische
            // Dateiendungs-Bedingung auf REQUEST_URI. Wird von neueren Joomla-
            // Versionen NICHT mehr ausgeliefert, kann aber aus alten .htaccess
            // stammen und wuerde sonst als vermeintliche Eigenregel uebernommen
            // -> bricht den Seitenaufbau (CSS/JS), siehe Forum 183328.
            '~^RewriteCond\s+%\{REQUEST_URI\}\s+!?\(.*\.(feed|pdf|raw|vcf)\b.*\)\$~i',
            '~^RewriteRule\s+(\.\*|\(\.\*\)|\^\(\.\*\)\$)\s+/?index\.php(\s+\[[^\]]*\])?\s*$~i',
            // Klassische Exploit-Filter aus htaccess.txt
            '~^RewriteCond\s+%\{QUERY_STRING\}\s+.*base64_~i',
            '~^RewriteCond\s+%\{QUERY_STRING\}\s+\(<\|%3C\)~i',
            '~^RewriteCond\s+%\{QUERY_STRING\}\s+GLOBALS~i',
            '~^RewriteCond\s+%\{QUERY_STRING\}\s+_REQUEST~i',
            '~^RewriteCond\s+%\{QUERY_STRING\}\s+proc/sel~i',
            '~^RewriteCond\s+%\{QUERY_STRING\}\s+mosConfig_~i',
            '~^RewriteRule\s+\.\*\s+index\.php\s+\[F\]$~i',
            // Performance-Direktiven (deckt das Schutzschild selbst ab)
            '~^ExpiresActive\b~i',
            '~^ExpiresByType\b~i',
            '~^ExpiresDefault\b~i',
            '~^AddOutputFilterByType\b~i',
            '~^FileETag\b~i',
            '~^mod_gzip_~i',
            '~^SetEnvIfNoCase\s+\^\(Accept-EncodXng~i',
            '~^RequestHeader\s+append\s+Accept-Encoding~i',
            '~^Header\s+always\s+unset\s+X-(Content-)?Powered-By~i',
            // Joomlas "GZIP & BROTLI"-Praekompress-Block (.br/.gz-Auslieferung): HTProtect
            // erzeugt ihn ab Ruleset 7 NICHT mehr (Content-Encoding-Doppelung ->
            // ERR_CONTENT_DECODING_FAILED) und darf ihn daher beim IMPORT NIE als Eigenregel
            // uebernehmen (sonst kaeme der Bug ueber den Import-Weg zurueck) -> immer als
            // Boilerplate verwerfen, unabhaengig vom Generator (frueher nur im Admin-Tools-Zweig).
            '~^RewriteCond\s+"%\{HTTP:Accept-encoding\}"~i',
            '~^RewriteCond\s+"%\{REQUEST_FILENAME\}\\\.(br|gz)"~i',
            '~^RewriteRule\s+"\^\(\.\*\)\\\.(css|js)"~i',
            '~^RewriteRule\s+"\\\.(css|js)\\\.(br|gz)\$"~i',
            '~^Header\s+set\s+Content-Encoding~i',
            '~^Header\s+append\s+Vary\s+Accept-Encoding~i',
            // Marker-/Trennzeilen gaengiger Generatoren
            '~^##+\s*(Begin|End)\b~i',
            '~^#####~',
            '~^###\s~',
            '~^###$~',
        );

        // Stammt die Datei erkennbar von Admin Tools, zusaetzlich dessen
        // bekannte Generator-Regeln aussortieren (eigene Eintraege wie
        // Datei-Passthroughs bleiben absichtlich erhalten).
        if (stripos($content, 'generated by Admin Tools') !== false) {
            $stock = array_merge($stock, array(
                '~^RewriteRule\s+\^administrator~i',
                '~^RewriteRule\s+\^\(components\|~i',
                '~^RewriteRule\s+\^\(cache\|~i',
                '~^RewriteRule\s+\^\(configuration~i',
                '~^RewriteRule\s+\^\(htaccess~i',
                '~^RewriteRule\s+\^includes/js/~i',
                '~^RewriteRule\s+\^media/plg_editors_tinymce~i',
                '~^RewriteRule\s+\^index\.php\(~i',
                '~^RewriteRule\s+\^api/index\.php\(~i',
                '~^RewriteRule\s+\^robots\.txt\$~i',
                '~^RewriteRule\s+\(\.\*\\\.php\)\$~i',
                '~^RewriteRule\s+!\^index\.php\$~i',
                '~^RewriteRule\s+\^\\\.well\\\-known/~i',
                '~^RewriteRule\s+\^installation/~i',
                '~^RewriteCond\s+%\{REQUEST_FILENAME\}\s+!?\(\\\.php\)\$~i',
                '~^RewriteCond\s+%\{REQUEST_FILENAME\}\s+-[fd]$~i',
                '~^RewriteCond\s+%\{REQUEST_URI\}\s+!\^/$~i',
                '~^RewriteCond\s+%\{HTTPS\}~i',
                '~^RewriteCond\s+%\{HTTP:X-Forwarded-Proto\}~i',
                '~^RewriteCond\s+%\{HTTP_HOST\}\s+!\^www\\\.~i',
                '~^RewriteRule\s+\^\(\.\*\)\$\s+https?://(www\.)?%\{HTTP_HOST\}~i',
                '~^RewriteCond\s+%\{QUERY_STRING\}\s+\[a-zA-Z0-9_\]=~i',
                '~^RewriteCond\s+%\{REQUEST_METHOD\}\s+GET$~i',
                '~^RewriteCond\s+"%\{HTTP:Accept-encoding\}"~i',
                '~^RewriteCond\s+"%\{REQUEST_FILENAME\}\\\.(br|gz)"~i',
                '~^RewriteRule\s+"\^\(\.\*\)\\\.(css|js)"~i',
                '~^RewriteRule\s+"\\\.(css|js)\\\.(br|gz)\$"~i',
                '~^Header\s+set\s+Content-Encoding~i',
                '~^Header\s+append\s+Vary~i',
                '~^Header\s+unset\s+ETag$~i',
                '~^Header\s+set\s+(Cache-Control|Pragma|Expires)\b~i',
                '~^Header\s+always\s+set\s+Referrer-Policy~i',
                '~^ExpiresActive\s+Off$~i',
                '~^##~',
                '~^#\s*--~',
                '~^####~',
            ));
        }

        return $stock;
    }

    /**
     * Verbatim-Standard-htaccess.txt von Joomla als Referenz fuer den Effekt-
     * Vergleich. J4/5/6 sind identisch -> nur zwei Saetze (J3 + J4PLUS). Quelle:
     * offizielle joomla-cms-Tags 3.10.12 bzw. 5.4.6. Whitespace ist egal (wird
     * normalisiert); die Kommentartexte zaehlen als Boilerplate.
     *
     * @return array
     */
    private static function bundledStandardHtaccess()
    {
        $j3 = <<<'HTP_J3'
## No directory listings
<IfModule mod_autoindex.c>
  IndexIgnore *
</IfModule>

## Suppress mime type detection in browsers for unknown types
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
</IfModule>

## Can be commented out if causes errors, see notes above.
Options +FollowSymlinks
Options -Indexes

## Disable inline JavaScript when directly opening SVG files or embedding them with the object-tag
<FilesMatch "\.svg$">
  <IfModule mod_headers.c>
    Header always set Content-Security-Policy "script-src 'none'"
  </IfModule>
</FilesMatch>

## Mod_rewrite in use.

RewriteEngine On

## Begin - Rewrite rules to block out some common exploits.
# If you experience problems on your site then comment out the operations listed
# below by adding a # to the beginning of the line.
# This attempts to block the most common type of exploit `attempts` on Joomla!
#
# Block any script trying to base64_encode data within the URL.
RewriteCond %{QUERY_STRING} base64_encode[^(]*\([^)]*\) [OR]
# Block any script that includes a <script> tag in URL.
RewriteCond %{QUERY_STRING} (<|%3C)([^s]*s)+cript.*(>|%3E) [NC,OR]
# Block any script trying to set a PHP GLOBALS variable via URL.
RewriteCond %{QUERY_STRING} GLOBALS(=|\[|\%[0-9A-Z]{0,2}) [OR]
# Block any script trying to modify a _REQUEST variable via URL.
RewriteCond %{QUERY_STRING} _REQUEST(=|\[|\%[0-9A-Z]{0,2})
# Return 403 Forbidden header and show the content of the root home page
RewriteRule .* index.php [F]
#
## End - Rewrite rules to block out some common exploits.

## Begin - Custom redirects
#
# If you need to redirect some pages, or set a canonical non-www to
# www redirect (or vice versa), place that code here. Ensure those
# redirects use the correct RewriteRule syntax and the [R=301,L] flags.
#
## End - Custom redirects

##
# Uncomment the following line if your webserver's URL
# is not directly related to physical file paths.
# Update Your Joomla! Directory (just / for root).
##

# RewriteBase /

## Begin - Joomla! core SEF Section.
#
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
#
# If the requested path and file is not /index.php and the request
# has not already been internally rewritten to the index.php script
RewriteCond %{REQUEST_URI} !^/index\.php
# and the requested path and file doesn't directly match a physical file
RewriteCond %{REQUEST_FILENAME} !-f
# and the requested path and file doesn't directly match a physical folder
RewriteCond %{REQUEST_FILENAME} !-d
# internally rewrite the request to the index.php script
RewriteRule .* index.php [L]
#
## End - Joomla! core SEF Section.
HTP_J3;

        $j4plus = <<<'HTP_J4PLUS'
## Can be commented out if causes errors, see notes above.
Options +FollowSymlinks
Options -Indexes

## No directory listings
<IfModule mod_autoindex.c>
	IndexIgnore *
</IfModule>

## Suppress mime type detection in browsers for unknown types
<IfModule mod_headers.c>
	Header always set X-Content-Type-Options "nosniff"
</IfModule>

## Protect against certain cross-origin requests. More information can be found here:
## https://developer.mozilla.org/en-US/docs/Web/HTTP/Cross-Origin_Resource_Policy_(CORP)
## https://web.dev/why-coop-coep/
#<IfModule mod_headers.c>
#	Header always set Cross-Origin-Resource-Policy "same-origin"
#	Header always set Cross-Origin-Embedder-Policy "require-corp"
#</IfModule>

## Disable inline JavaScript when directly opening SVG files or embedding them with the object-tag
<FilesMatch "\.svg$">
  <IfModule mod_headers.c>
    Header always set Content-Security-Policy "script-src 'none'"
  </IfModule>
</FilesMatch>

## These directives are only enabled if the Apache mod_rewrite module is enabled
<IfModule mod_rewrite.c>
	RewriteEngine On

	## Begin - Rewrite rules to block out some common exploits.
	# If you experience problems on your site then comment out the operations listed
	# below by adding a # to the beginning of the line.
	# This attempts to block the most common type of exploit `attempts` on Joomla!
	#
	# Block any script trying to base64_encode data within the URL.
	RewriteCond %{QUERY_STRING} base64_encode[^(]*\([^)]*\) [OR]
	# Block any script that includes a <script> tag in URL.
	RewriteCond %{QUERY_STRING} (<|%3C)([^s]*s)+cript.*(>|%3E) [NC,OR]
	# Block any script trying to set a PHP GLOBALS variable via URL.
	RewriteCond %{QUERY_STRING} GLOBALS(=|\[|\%[0-9A-Z]{0,2}) [OR]
	# Block any script trying to modify a _REQUEST variable via URL.
	RewriteCond %{QUERY_STRING} _REQUEST(=|\[|\%[0-9A-Z]{0,2})
	# Return 403 Forbidden header and show the content of the root home page
	RewriteRule .* index.php [F]
	#
	## End - Rewrite rules to block out some common exploits.

	## Begin - Custom redirects
	#
	# If you need to redirect some pages, or set a canonical non-www to
	# www redirect (or vice versa), place that code here. Ensure those
	# redirects use the correct RewriteRule syntax and the [R=301,L] flags.
	#
	## End - Custom redirects

	##
	# Uncomment the following line if your webserver's URL
	# is not directly related to physical file paths.
	# Update Your Joomla! Directory (just / for root).
	##

	# RewriteBase /

	## Begin - Joomla! core SEF Section.
	#
	# PHP FastCGI fix for HTTP Authorization, required for the API application
	RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
	# -- SEF URLs for the API application
	# If the requested path starts with /api, the file is not /api/index.php
	# and the request has not already been internally rewritten to the
	# api/index.php script
	RewriteCond %{REQUEST_URI} ^/api/
	RewriteCond %{REQUEST_URI} !^/api/index\.php
	# and the requested path and file doesn't directly match a physical file
	RewriteCond %{REQUEST_FILENAME} !-f
	# and the requested path and file doesn't directly match a physical folder
	RewriteCond %{REQUEST_FILENAME} !-d
	# internally rewrite the request to the /api/index.php script
	RewriteRule .* api/index.php [L]
	# -- SEF URLs for the public frontend application
	# If the requested path and file is not /index.php and the request
	# has not already been internally rewritten to the index.php script
	RewriteCond %{REQUEST_URI} !^/index\.php
	# and the requested path and file doesn't directly match a physical file
	RewriteCond %{REQUEST_FILENAME} !-f
	# and the requested path and file doesn't directly match a physical folder
	RewriteCond %{REQUEST_FILENAME} !-d
	# internally rewrite the request to the index.php script
	RewriteRule .* index.php [L]
	#
	## End - Joomla! core SEF Section.
</IfModule>

## These directives are only enabled if the Apache mod_rewrite module is disabled
<IfModule !mod_rewrite.c>
	<IfModule mod_alias.c>
		RedirectMatch 302 ^/$ /index.php/
	</IfModule>
</IfModule>
HTP_J4PLUS;

        return array($j3, $j4plus);
    }

    /**
     * Entfernt nach dem Filtern uebrig gebliebene Rewrite-Fragmente:
     * - RewriteCond-Ketten, auf die keine RewriteRule mehr folgt
     * - Catch-all-Regeln (.* mit [F]/[L]-Ziel "-"), denen keine
     *   RewriteCond mehr vorausgeht
     *
     * @param   array  $lines  Zeilen nach dem Stock-Filter
     *
     * @return array
     */
    private static function dropOrphanRewriteFragments($lines)
    {
        $count = count($lines);
        $drop  = array();

        for ($i = 0; $i < $count; $i++) {
            $trim = trim($lines[$i]);

            // RewriteCond-Kette pruefen: folgt (Kommentare/Leerzeilen
            // ueberspringend) irgendwann eine RewriteRule?
            if (preg_match('~^RewriteCond\b~i', $trim)) {
                $chain = array($i);
                $j     = $i + 1;
                $endsInRule = false;

                while ($j < $count) {
                    $next = trim($lines[$j]);

                    if ($next === '' || strpos($next, '#') === 0) {
                        $j++;
                        continue;
                    }

                    if (preg_match('~^RewriteCond\b~i', $next)) {
                        $chain[] = $j;
                        $j++;
                        continue;
                    }

                    $endsInRule = (bool) preg_match('~^RewriteRule\b~i', $next);
                    break;
                }

                if (!$endsInRule) {
                    foreach ($chain as $index) {
                        $drop[$index] = true;
                    }
                }

                $i = $j - 1;
                continue;
            }

            // Catch-all-Sperre ohne vorausgehende (erhaltene) RewriteCond?
            if (preg_match('~^RewriteRule\s+"?\.?\(?\.\*\)?\$?"?\s+-\s+\[(F|L)~i', $trim)) {
                $hasCond = false;

                for ($k = $i - 1; $k >= 0; $k--) {
                    if (isset($drop[$k])) {
                        continue;
                    }

                    $prev = trim($lines[$k]);

                    if ($prev === '' || strpos($prev, '#') === 0) {
                        continue;
                    }

                    $hasCond = (bool) preg_match('~^RewriteCond\b~i', $prev);
                    break;
                }

                if (!$hasCond) {
                    $drop[$i] = true;
                }
            }
        }

        $out = array();

        foreach ($lines as $index => $line) {
            if (!isset($drop[$index])) {
                $out[] = $line;
            }
        }

        return $out;
    }

    private static function head(&$out, $no, $title)
    {
        $out[] = '# --------------------------------------------------------------------------';
        $out[] = '#  [' . $no . '] ' . $title;
        $out[] = '# --------------------------------------------------------------------------';
    }

    /**
     * Text fuer eine .htaccess-Kommentarzeile entschaerfen (einzeilig).
     *
     * @param   string  $text  Rohtext
     *
     * @return string
     */
    private static function comment($text)
    {
        return trim(preg_replace('/[\r\n]+/', ' ', (string) $text));
    }

    /**
     * Lokalisiertes Signatur-Label (de/en) fuer den .htaccess-Kommentar.
     * Sprache reist im Defs-Eintrag mit -> auch nachgeladene Signaturen.
     *
     * @param   array  $sig  Signatur
     *
     * @return string
     */
    private static function sigLabel($sig)
    {
        return HtpDefs::localized($sig, 'label');
    }

    private static function deny(&$out)
    {
        $out[] = '    <IfModule mod_authz_core.c>';
        $out[] = '        Require all denied';
        $out[] = '    </IfModule>';
        $out[] = '    <IfModule !mod_authz_core.c>';
        $out[] = '        Order allow,deny';
        $out[] = '        Deny from all';
        $out[] = '    </IfModule>';
    }

    private static function verdict($allowed, $rule, $suggest)
    {
        return array('allowed' => $allowed, 'rule' => $rule, 'suggest' => $suggest);
    }
}
