<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Laden/Speichern der Schild-Konfiguration. Abgelegt wird sie als JSON
 * in den Erweiterungs-Parametern von com_htprotect - das funktioniert
 * unveraendert von Joomla 2.5 bis 6 und uebersteht Updates.
 */

defined('_JEXEC') or die;

class HtpConfig
{
    /**
     * Standardwerte inkl. Laufzeit-Erkennung (HTTPS, Unterverzeichnis,
     * vorhandene Spezialdateien).
     *
     * @return array
     */
    public static function defaults()
    {
        $siteUrl = htp_site_url();
        $path    = self::detectSitePath();
        $https   = 0;

        $scheme = parse_url($siteUrl, PHP_URL_SCHEME);

        if ($scheme === 'https') {
            $https = 1;
        }

        // Die Joomla-Update-Endpunkte (extract.php/restore.php) sind im
        // Builder fest freigegeben und stehen deshalb nicht in dieser Liste.
        // Backup-Wiederherstellung immer freigeben - auch wenn Akeeba erst
        // spaeter installiert wird, soll restore.php nie am Schild scheitern.
        $files = array(
            'administrator/components/com_akeeba/restore.php',
            'administrator/components/com_akeebabackup/restore.php',
        );

        $optional = array(
            'joomla-connector.php',
        );

        $root = htp_docroot();

        foreach ($optional as $candidate) {
            if (is_file($root . '/' . $candidate)) {
                $files[] = $candidate;
            }
        }

        sort($files);

        return array(
            // ── Grundlagen ──
            'site_path'      => $path,   // '' = Domain-Root, sonst z. B. '/joomla'
            'sef_rules'      => 1,       // Joomla-Router (SEF-Weiterleitung an index.php)
            'api_router'     => 1,       // (Alt) SEF-Routing fuer /api - in api_mode migriert
            'api_mode'       => 'route', // 'route' = /api durchleiten, 'block' = /api sperren, 'off' = nichts tun
            'fastcgi_auth'   => 1,       // Authorization-Header unter FastCGI/FPM durchreichen
            'apache_options' => 1,       // Options/-Indexes, DirectoryIndex, ServerSignature
            'follow_symlink' => 0,       // Options +FollowSymlinks (nur bei Bedarf)

            // ── Kanonische Adresse ──
            'force_https'    => $https,
            'www_mode'       => 0,       // 0 = Host unveraendert, 1 = mit www, 2 = ohne www
            'canonical_host' => '',      // leer = automatisch aus aktueller Domain ableiten

            // ── Antwort-Header ──
            'h_frame'        => 1,
            'h_nosniff'      => 1,
            'h_referrer'     => 'strict-origin-when-cross-origin', // '' = aus
            'h_poweredby'    => 1,
            'h_hsts'         => 0,
            'h_hsts_sub'     => 0,
            'h_coop'         => 0,
            'h_permissions'  => 0,

            // ── Anfrage-Filter ──
            'f_query'        => 1,       // Injektionsmuster im Query-String
            'f_rfi'          => 1,       // Fremd-URLs/Traversal in GET-Parametern
            'f_methods'      => 1,       // TRACE/TRACK/DEBUG unterbinden
            'f_wpnoise'      => 1,       // WordPress-Scanrauschen direkt abweisen

            // .htaccess komplett HTProtect ueberlassen (0) ODER extern verwaltet (1):
            // Bei 1 schreibt/aendert/ueberwacht HTProtect die Haupt-.htaccess NICHT und
            // nagt nicht (Ampel bleibt gruen) - fuer Nutzer, die die .htaccess selbst
            // oder ueber andere Tools (z. B. Akeeba Admin Tools Pro .htaccess Maker)
            // pflegen. Real-Time-Plugin, Malware-Scan, Backend-Schutz usw. laufen weiter.
            'htaccess_external' => 0,

            // Schutzschild-Regelwerk automatisch aktuell halten: verbessert HTProtect sein
            // .htaccess-Regelwerk (neue Version), wird das Schild AUTOMATISCH neu geschrieben,
            // sobald es "veraltet" ist (HtpBuilder::shieldOutdated) - kein manuelles "Jetzt
            // absichern" je Seite mehr. Greift NUR, wenn das Schild bereits UNSERES ist; Selbsttest
            // + Auto-Rollback wie beim manuellen Schreiben; Hands-off (htaccess_external) + nginx
            // werden respektiert. DEFAULT AN (fuer NEUE Installationen; Bestand einmalig per
            // 2.4.15-Migration auf AN, danach freie Wahl).
            'shield_autoheal'   => 1,
            'shield_autoheal_last' => 0,    // Cooldown-Zeitstempel des letzten Auto-Heal-Schreibens (kein Spam)
            'mig_selfon_2415'   => 1,       // Einmal-Migrations-Marker (2.4.15): Bestands-Installs bekommen
                                            // autoupdate_self + shield_autoheal EINMALIG auf AN (postflight).
                                            // Default 1 => Neuinstalls (haben beides ohnehin per Default AN)
                                            // loesen die Migration NIE aus; danach freie Wahl des Admins.

            // Backend-Zugangsschutz bewusst uebersprungen (per X auf der Schritt-1-Karte):
            // dann kein Overview-Nag mehr und die Ampel-Zeile ist gruen statt rot.
            'guard_dismissed'   => 0,

            // ── KI-Steuerung (API-Modus) ──
            // Connector-verwaltet (KEIN Formularfeld): wird die Site zentral fuer die KI-Steuerung
            // freigeschaltet (ai.provision), setzt der Connector dies auf 1 -> allowedPaths() gibt die
            // Stufe-1-Web-Services-Ressourcen frei (auch bei api_mode=block). ai.revoke setzt zurueck.
            'ai_api_allow'      => 0,

            // ── Exploit-Schild ──
            'x_shield'       => 1,       // Exploit-Signaturen aktiv (JCE, Novarain, Astroid, com_ajax-LFI ...)
            'x_sigs_off'     => array(), // einzeln abgeschaltete Signatur-IDs
            'defs_version'   => '',      // Signaturen-Stand, der zuletzt in die .htaccess geschrieben wurde (NICHT im configHash)
            'mal_whitelist'  => array(), // Schaddatei-Tiefen-Scan: dauerhaft als sicher markierte Pfade/Ordner

            // ── Upload-Ordner-Haertung (eigene Mini-.htaccess je Ordner) ──
            'u_harden'       => 1,       // Mini-.htaccess in Upload-/System-Ordnern anlegen
            'u_dirs'         => self::defaultHardenDirs(),

            // ── Notfall-Modus (Cookie-Gate, unabhaengig vom /administrator-Schutz) ──
            'emergency_lock'  => 0,      // komplette Seite sperren: 503-Wartungsseite fuer alle ohne Zugangs-Cookie
            'emergency_key'   => '',     // geheimes Zugangs-Token; Einstieg via ?htp_<Token> setzt das Cookie (bei Aktivierung erzeugt)
            'emergency_title' => '',     // optionale eigene Ueberschrift der Wartungsseite (sonst Sprach-Default de/en)
            'emergency_text'  => '',     // optionaler eigener Text der Wartungsseite (HTML, gefiltert; sonst Sprach-Default)

            // ── Ueberwachung / Benachrichtigung ──
            'notify_email'        => '',    // Empfaenger fuer Waechter-/Warnlisten-Alarme
            'notify_reminder_days' => 14,   // offene Punkte erneut melden alle N Tage (2-30; Anti-Spam)
            'unsub_secret'        => '',    // site-eigenes HMAC-Secret fuer unfaelschbare Frontend-Abmeldelinks (lazy erzeugt, nie ausgegeben)
            'unsub_base'          => '',    // vertrauenswuerdige Site-Basis-URL (im Backend erfasst) fuer Mail-Links - host-poisoning-fest auch ohne live_site
            'site_fp'             => '',    // Site-Fingerabdruck (Pfad+DB); Wechsel (Klon/Template/Migration) => unsub_base/unsub_secret/account_baseline still verwerfen statt Fehlalarm/Falschlink
            'swarm_contrib_id'    => '',    // Schwarm-Schutz: anonyme, persistente Beitrags-ID (32 hex) fuer die geschlossene Rueckkopplung; kein PII
            'swarm_acked'         => array(),// Feed-Versionen, fuer die die "deine Meldung ist live"-Dankeszeile schon gezeigt wurde (kein Wiederholen)
            'watch_manifest'     => array(),// Pfad => sha1 der verwalteten Dateien (Waechter)
            'core_manifest'      => array(),// rel => sha1 der Einstiegspunkte (Integritaets-Waechter)
            'core_jversion'      => '',     // Joomla-Version der core_manifest-Basis (Re-Baseline bei Update)
            'watch_htp_version'  => '',     // HTP_VERSION der watch_manifest-Basis: Wechsel => stiller Re-Baseline (Update + einmalige Umstellung Roh->normalisierter Hash), nie Fehlalarm
            'accounts_watch'     => 1,      // Super-User-Konten auf Aenderungen ueberwachen
            'account_baseline'   => array(),// id => {username,email,phash,block,registered,groups} der Super-User (Baseline)
            'admin_whitelist'    => array(),// vom Admin bestaetigte Super-User-IDs (Rogue-Admin-Check SP Page Builder)
            'admin_guard'        => 1,      // Admin-Schutz an: eingeschleuste Super-User erkennen (+ im 'contain'-Modus stilllegen), ausgesperrte Eigentuemer wiederherstellen
            'admin_guard_mode'   => 'contain', // 'contain' = technisch eindeutig eingeschleuste Konten (Null-Anlage-Datum/Kampagne) automatisch stilllegen | 'observe' = nur melden
            'account_contained'  => array(),// Restore-Journal des Admin-Schutzes: id => {block,groups,registered,username,ts}
            'account_sanctioned' => array(),// id => ts der ueber Joomla sanktionierten privilegierten Aenderungen (Karenzfenster, kein Fehl-Restore/-Containment)
            'vuln_ack'           => array(),// Aufgeschobene Erweiterungs-Warnungen: extKey (element|type|folder) => {version, severity, mitigated, ts}. Die Warnung wird still (keine Mail-Erinnerung, Ampel nicht rot) - verfaellt aber automatisch bei geaenderter Version ODER eskalierter Severity (HtpDefs::vulnAckActive), dann wird wieder gewarnt. Nie ein echtes Loch verstecken.
            'helix_ignore'       => array(),// Helix-JS-Fund als unbedenklich markiert: Stil-id(string) => Inhalts-Signatur (sha1). Aendert sich der Inhalt, greift die Ignorierung nicht mehr (Alarm kommt wieder).
            'seo_watch'          => 1,      // Defacement-/SEO-Spam-Waechter standardmaessig AN (ruhig, meldet nur; bestehende Installs mit gespeichertem 0 bleiben aus)
            'compat_guard'       => 1,      // B/C-Recovery: Fatal durch deaktiviertes Backward-Compatibility-Plugin automatisch erkennen + reaktivieren (J4+, Sicherheitsnetz, 0 Kosten im Normalfall)
            'seo_cloaking_auto'  => 0,      // Phase 2: taeglicher Cloaking-Multi-Persona-Lauf (opt-in, Last)
            'seo_whitelist'      => array(),// URL/Pfad-Ausnahmen fuer den SEO-Waechter
            'seo_topic_ack'      => array(),// vom Nutzer bestaetigte Themen (legit casino/pharma) - Keyword-Rauschen aus
            'seo_baseline'       => array(),// stille Basislinie der Startseite (Titel/Domains/Shingles)
            'seo_overview_ts'    => 0,      // Zeitstempel der letzten 1h-Schnellpruefung in der Uebersicht (Drossel)
            'seo_overview_finding' => array(),// letzter definitiver SEO-/Defacement-Befund {kind,reasons} fuer Ampel/Kachel
            'core_overview_ts'   => 0,      // Drossel der 1h-Kerndatei-Anomalie-Schnellpruefung in der Uebersicht
            'core_overview_finding' => array(),// letzter Kerndatei-Anomalie-Befund {reasons} fuer Ampel/Kachel
            'plugin_initialized' => 0,      // Echtzeit-Schutz-Plugin einmalig automatisch aktiviert?
            'quickicon_initialized' => 0,   // Dashboard-Statuskachel einmalig automatisch aktiviert?
            'autosecure'         => 0,      // Selbstabsicherung fuer Templates/Klone: Schild beim ersten
                                            // Backend-Aufruf automatisch schreiben, falls (noch) nicht aktiv
            'autosecure_last'    => 0,      // Cooldown-Zeitstempel des letzten Auto-Versuchs (kein Spam)
            'tmp_autoclean'      => 0,      // tmp/ taeglich automatisch aufraeumen (>24h alte Reste); Sofort-Knopf separat
            'tmp_keep'           => array(),           // Glob-Muster (Dateiname/Pfad unter tmp/), die NIE aufgeraeumt werden (z. B. Backups/Exporte); initial leer - nur index.html bleibt strukturell geschuetzt

            // ── Erweiterungs-Auto-Update (nur >= Joomla 5.4; eigenes Modul HtpAutoUpdate) ──
            'autoupdate_notify'  => 0,      // NUR-BENACHRICHTIGEN-Modus: nichts installieren, nur ueber ALLE ausstehenden Updates mailen; blendet die Auto-Install-Schalter aus, DEFAULT AUS
            'autoupdate_ext'     => 1,      // Schalter 1 (Master): ANGREIFBARE Erweiterungen (bekannte Luecke) automatisch aktualisieren - Sicherheits-Prioritaet, DEFAULT AN (Security-First; abgesichert durch Backup+Health-Check+Auto-Rollback+Malscan). Betrifft nur NEUE Installationen; Bestand behaelt seine gespeicherte Wahl. Der "alle Erweiterungen"-Schalter (autoupdate_all) bleibt AUS.
            'autoupdate_all'     => 0,      // Schalter 2 (nur sichtbar/wirksam wenn Schalter 1 an): auf ALLE Erweiterungen mit erreichbarem Update-Server ausweiten; DEFAULT AUS
            'autoupdate_self'    => 1,      // HTProtect SELBST automatisch aktuell halten: eigener Sofort-Pfad - prueft mehrmals taeglich (SELF_CHECK_MIN 3h; der 15-Min-Tick ist nur der Waechter-Herzschlag). BEWUSST ENTKOPPELT vom Sicherheitsupdate-Master (autoupdate_ext): laeuft auch, wenn die Fremd-Auto-Updates AUS sind (installiert dann NUR HTProtect selbst, keine Dritt-Erweiterung). Ebenso unabhaengig von Zeitplan UND Schalter 2, OHNE Karenz (wie Sicherheitsupdates). DEFAULT AN (damit HTProtect ohne Zutun aktuell bleibt - Sicherheits-/Fix-Verteilung); per 1-Klick auf der Uebersicht abschaltbar. Betrifft nur NEUE Installationen; Bestand behaelt seine gespeicherte Wahl.
            'autoupdate_manual'  => 0,      // Backup vor JEDEM Update (auch manuell via Joomlas Aktualisieren-Knopf) -> Ein-Klick-Rollback; eigener Schalter, DEFAULT AUS
            'autoupdate_mail_success' => 0, // auch bei JEDEM erfolgreichen Auto-Update eine Mail senden (DEFAULT AUS; Fehlschlag/Rollback/Host-Wechsel melden ohnehin)
            'autoupdate_include' => array(),// Per-Erweiterung-AUSWAHL (Opt-in, stabile Schluessel type|element|folder): nur diese werden im "alle"-Modus auto-aktualisiert. Default leer = nichts (ausser angreifbare, die laufen immer).
            'autoupdate_exclude' => array(),// Per-Erweiterung-AUSNAHMEN (stabile Schluessel type|element|folder): NUR-SICHERHEIT - diese werden nicht im "alle"-Modus mitgezogen, ABER angreifbare Updates laufen weiter (Sicherheits-Boden). NICHT mit 'never' verwechseln. Vom Fleet-Dashboard (htprotect.app) via update.policy gesetzt (PROTOCOL.md 6a). Default leer.
            'autoupdate_never'   => array(),// Per-Erweiterung "NIE" (stabile Schluessel type|element|folder, ab 2.7.2): unterschreitet den Sicherheits-Boden - KEIN Auto-Update, auch KEIN Sicherheitsupdate. Bewusster Opt-out (z. B. Templates, oder Erweiterungen die beim Update Einstellungen zuruecksetzen). Die Erweiterung wird weiterhin als verwundbar GEMELDET (Warnung) und vom WAF geschuetzt - nur der Auto-Install unterbleibt. Default leer.
            'autoupdate_default' => 'security', // Fleet-Default fuer NICHT explizit gelistete Erweiterungen (PROTOCOL.md 6a): 'security' (nur angreifbare auto, EMPFOHLEN/Standard) | 'all' (alle mit erreichbarer Quelle auto) | 'never' (nichts auto, auch keine Sicherheit - nur Warnung+WAF) | 'off' (Legacy = wie 'security', nur nicht-alle). Lokal steuert weiterhin die Include-Auswahl; erst wenn htprotect.app die Site fleet-verwaltet, greift dieser Default fuer Unlisted.
            'autoupdate_keep'    => 1,      // Default fuer Neuinstallationen: 1 gute Vorversion je Erweiterung fuer den Ein-Klick-Rollback aufbewahren (Disk-Cap; per UI 1-3 waehlbar)
            'autoupdate_interval' => 86400, // (Legacy) Mindestabstand in Sekunden - durch autoupdate_schedule abgeloest, bleibt fuer Abwaertskompatibilitaet
            'autoupdate_schedule' => 'daily', // Update-Haeufigkeit der AUSGEWAEHLTEN Updates (Schalter 2): 'instant' (jeder 15-Min-Tick) | 'daily' | 'weekly'. DEFAULT 'daily' (21:00). SICHERHEITSUPDATES + HTProtect-Selbst-Update laufen IMMER sofort.
            'autoupdate_hour'    => 21,     // Stunde (0-23, Joomla-Zeitzone) fuer 'daily'/'weekly' (Default 21:00)
            'autoupdate_weekday' => 5,      // Wochentag fuer 'weekly' (1=Mo .. 7=So, ISO/PHP 'N'; Default 5=Freitag)
            'autoupdate_grace'   => 3,      // Karenz-Zeit/Soak in TAGEN fuer NICHT-angreifbare Auswahl-Updates (0=keine|1=24h|3|7|14): ein Update muss erst so lange gesehen/verfuegbar sein, bevor es automatisch installiert wird (Schutz vor frisch kompromittierten/fehlerhaften Releases). DEFAULT 3 Tage. SICHERHEITSUPDATES + HTProtect-Selbst-Update ignorieren die Karenz (laufen sofort ueber den eigenen Pfad).
            'autoupdate_malscan' => 1,      // Inhalts-Pruefung: nach erfolgreichem Install die Update-Dateien (ownedPaths der Erweiterung) gegen die Malware-Signaturen scannen; Fund -> Auto-Rollback + Mail (mit FP-Hinweis) + Aussetzen. DEFAULT AN; fail-open bei Scan-Timeout (kein Fehl-Rollback). Ein geflaggter Fund wird zudem IMMER anonym an radar gemeldet (1 POST/Version, HtpSwarm 'update', kein Host/IP) - kein eigener Schalter (Always-on).
            'autoupdate_skip_major' => 0,   // Grosse Versionsspruenge (SemVer-Major, z. B. 3.x -> 4.x) NICHT automatisch installieren - nur fuer NICHT-Sicherheits-Auswahl-Updates. Solche Updates bleiben sichtbar/manuell installierbar; SICHERHEITSUPDATES laufen unabhaengig weiter. DEFAULT AUS (Bestandsverhalten).
            'core_auto'          => 0,      // Automatische Joomla-KERN-Updates (nur GLEICHE Major, ab 5.4.0) via HtpCoreUpdate. DEFAULT AUS. Getrennt vom Erweiterungs-Auto-Update; ferngesteuert per 'core.auto'-Befehl. Angewendet hoechstens 1x/Tag im Job-Motor (maybeScheduledCoreAuto), wenn eine echt neuere Version derselben Major ansteht - mit Health-Baseline der Update-Engine (kein Cross-Major, kein J4/J3).
            'autoupdate_intent' => array(), // Zwei-Wege-Sync (htprotect.app <-> Joomla-Backend): versionierte ABSICHT je Erweiterung. Struktur {ext:{extKey:{val:'on'|'off'|'inherit'|'never',seq,ts,origin:'app'|'backend'}}, default:{val,seq,ts,origin}}. Das ist die WAHRHEIT fuer den Sync; die Flach-Keys autoupdate_include/exclude/never/default werden DARAUS abgeleitet (autoupdate.php bleibt unveraendert). LWW pro Schluessel (hoehere seq gewinnt). NICHT in die Listen-Coercion (unten) aufnehmen - verschachtelte Map, kein flaches Array.

            'keep_update_sites'  => 1,      // Update-Quellen-Waechter: von Joomlas Kern bei temporaeren Haengern selbst-deaktivierte Update-Quellen wieder aktivieren (hoechstens 1x/Tag, UNABHAENGIG vom Auto-Update-Schalter). Betrifft FREMDE Erweiterungen; die EIGENE Quelle (com_htprotect) + Erweiterungen mit aktivem Auto-Update bleiben IMMER aktiv. Reaktiviert NUR (nie ab); nach 3 vergeblichen Runden wird eine fremde Quelle in Ruhe gelassen (chronisch weg ODER bewusst aus) + genau einmal gemeldet. DEFAULT AN (aktive Quelle = Update-Hinweise = Sicherheit).
            'keep_update_sites_except' => array(), // Manuelle Ausnahmen (Liste stabiler extKeys type|element|folder) fuer den Update-Quellen-Waechter: diese Quellen werden bewusst NICHT aktiv gehalten (Ausnahmen-Dialog). Greift nur bei FREMDEN, nicht-auto-update Quellen; self/auto-update sind nicht ausnehmbar (Vorrang).
            // Hinweis: Lauf-/Log-/Aussetz-State liegt NICHT hier, sondern als Datei
            // im geschuetzten State-Ordner (htprotect_autoupdate.json).

            // ── Dateischild ──
            's_php'          => 1,       // PHP nur ueber freigegebene Einstiege
            's_types'        => 1,       // statische Auslieferung nur fuer erlaubte Endungen
            's_ext'          => self::defaultExtensions(),
            's_files'        => $files,  // Einzeldateien direkt freigeben
            's_dirs_php'     => array(   // Ordner komplett freigeben (inkl. PHP)
                'plugins/system/bfnetwork',
            ),
            's_dirs_static'  => array(), // Ordner fuer statische Inhalte freigeben (ohne PHP)
            'allow_paths'    => array(), // Vertrauenswuerdige Pfade: ueber ALLE Pfad-Sperren durchlassen (Lockdown + uri-Signaturen + Dateischild); Query-Injektionsfilter bleiben aktiv
            's_dotfiles'     => 1,       // versteckte Dateien/.git/.env sperren
            's_wellknown'    => 1,       // .well-known (Let's Encrypt & Co.) offen lassen
            's_sensitive'    => 1,       // configuration.php, Backups, Schluessel & Co. sperren
            's_manifests'    => 0,       // Erweiterungs-Manifeste (.xml) verstecken (opt-in; Versions-Fingerprinting erschweren)
            's_sysdirs'      => 1,       // interne Verzeichnisse komplett sperren
            's_sysdirs_list' => array(
                'administrator/cache',
                'administrator/logs',
                'cache',
                'cli',
                'log',
                'logs',
                'tmp',
            ),

            // ── Auslieferung/Performance ──
            'p_compress'     => 1,
            'p_precomp'      => 0,       // ENTFERNT ab Ruleset 7 (Content-Encoding-Doppelung): Schluessel bleibt fuer Abwaertskompatibilitaet, wird aber nicht mehr ausgewertet/geschrieben
            'p_expires'      => 1,
            'p_etag'         => 0,

            // ── Eigene Regeln ──
            'custom_top'     => '',
            'custom_bottom'  => '',

            // ── interner Zustand ──
            'file_sha1'      => '',
            'last_written'   => '',
            'last_test'      => array(),
            'guard_dir'      => '',      // Apache-Pfadsicht des administrator-Ordners ('' = wie PHP)
            'guard_mode'     => '',      // Backend-Schutz-Modus: '' (aus/Passwort-Standard) | 'password' (Option 1) | 'string' (Option 2, Cookie-Gate ueber Zugangs-String)
            'guard_string'   => '',      // Option 2: geheimer Zugangs-String ([A-Za-z0-9_-], >=5). Steht so auch in der administrator/.htaccess (kein Geheimnis wie ein Passwort - reiner URL-Parameter).
            'guard_recover'  => '',      // SHA-256-HASH des Backend-Schutz-Wiederherstellungs-Tokens (nie das Token selbst). '' = kein Recovery-Link aktiv. Wird nur gesetzt, wenn beim Einrichten eine Alarm-Mailadresse hinterlegt ist (dann geht der Roh-Token EINMALIG per Mail raus). Einmal-Nutzung: nach erfolgreichem Entfernen geleert.
            'welcome_sent'   => 0,       // 1 = die EINMALIGE Willkommens-Mail wurde versendet (per Backend-Passwort-Einrichtung ODER 30 Min nach dauerhaft gruener Ampel). Danach nie wieder - und die Guard-Einrichtung schickt dann nur noch die schlichte Bestaetigung.
            'welcome_green_since' => 0,  // Unix-Zeit, seit wann die Dashboard-Ampel ununterbrochen gruen ist (0 = aktuell nicht gruen). Ab +30 Min loest der Gruen-Pfad die Willkommens-Mail aus.
            'jed_review_ack' => '',      // Zuletzt per × bestaetigte JED-Bewertungs-GENERATION (leer = noch nie). Das Overview-Banner erscheint, wenn dieser Wert != HtpApp::JED_REVIEW_GEN ist UND die Ampel gruen ist UND >= 7 Tage seit welcome_green_since (bewaehrt, nicht sofort bei Neuinstallation). Kuenftige Versionen koennen die Generation bewusst hochsetzen (v1->v2) fuer EINE neue, dezente Einladung - NICHT pro Release (sonst spammy).
            'jed_mail_last'  => 0,       // Unix-Zeit der letzten JED-Bewertungs-Einladung in einer Sicherheitsupdate-Erfolgs-Mail. Anti-Spam-Drossel: der dezente Mail-Footer erscheint hoechstens alle 90 Tage und NUR bei echtem Erfolg (kein Rollback/Verdacht).
            'fv_notice_ack'  => 0,       // Zuletzt per × bestaetigte Fernverwaltungs-Hinweis-GENERATION (0 = noch nie). Das Overview-Banner (htprotect.app-Einladung) erscheint, wenn dieser Wert < HtpApp::FV_NOTICE_GEN ist UND der Connector verfuegbar ist UND die Site nicht bereits gekoppelt ist. Kuenftige Versionen koennen FV_NOTICE_GEN bewusst hochsetzen fuer EINE neue Einladung - NICHT pro Release.
            'gzip_notice_ack' => 0,      // 1 = der Hinweis auf die doppelte Komprimierung wurde per × dauerhaft geschlossen (der Betreiber will es bewusst so). Das Overview-Banner erscheint sonst, solange der Server die Seiten selbst komprimiert UND Joomlas eigene Komprimierung zusaetzlich an ist. HTProtect aendert die Joomla-Konfiguration NIE von sich aus - nur der Knopf im Banner schaltet ab.
            'langpack_notice_ack' => '', // (2.6.4) zuletzt per × verworfener Sprachpaket-Hinweis, als kanonischer Tag (leer = nie). Das Overview-Banner (1-Klick-Sprachpaket) erscheint, wenn die aktive Backend-Sprache weder de-* noch en-* ist, ein passendes Paket existiert, es NICHT installiert ist und dieser Wert != dem kanonischen Tag ist. Per-Tag (nicht Generation): ein Wechsel auf eine ANDERE nicht abgedeckte Sprache bietet erneut an.
            'htaccess_cap'   => array(), // Cache des aktiven .htaccess-Funktionstests: ['r'=>yes|no|unknown,'ts'=>..,'sw'=>SERVER_SOFTWARE]

            // ── Fernverwaltung (htprotect.app-Kopplung; fuer Einzelnutzer unsichtbar + inert) ──
            'link_paired'    => 0,       // 1 = an ein htprotect.app-Dashboard gekoppelt. Solange 0 ist der
                                         // Connector KOMPLETT inert: kein Heartbeat, kein Poll, keine
                                         // Netzaktivitaet, kein UI-Pixel. Nur dieses eine Flag lebt in der
                                         // Config (billiges Gate). Alles Weitere - Relay-URL, site_id, gepinnter
                                         // Workspace-Pubkey, transport_secret, Nonce-/cmd_id-Ring, Zeitstempel,
                                         // Site-Identity-Keypair - liegt NICHT hier, sondern im geschuetzten
                                         // State (htprotect_link.json, chmod 0600, nicht web-erreichbar).
            'site_host'      => '',      // Beim Koppeln GEPINNTER eigener Host (kanonisch: klein, ohne Schema/
                                         // Port/Pfad, ohne fuehrendes "www."). Vergleichsbasis fuer die
                                         // Empfaenger-Bindung ('aud') signierter SSO-/Download-Autorisierungen.
                                         // Unveraenderlich, solange gekoppelt; leer = noch nicht gepinnt.

            // ── Nur fuer Super-User (Sichtbarkeit + Bedienung, 2.5.13) ──
            'su_only'        => 1,       // 1 (Standard) = HTProtect ist fuer normale Administratoren KOMPLETT
                                         // ausgeblendet (Komponenten-Menuepunkt + Statuskachel) und nicht
                                         // bedienbar - nur Super-User. Technisch: core.manage am com_htprotect-
                                         // Asset = "Oeffentlich: Verboten" (vererbt sich an alle Nicht-Super-
                                         // Gruppen; Super-User umgehen ACL). 0 = auch normale Admins sehen UND
                                         // bedienen HTProtect. Zwei Ausnahmen bleiben immer super-user-only:
                                         // dieser Schalter selbst und die Fernverwaltungs-Kopplung.

            // ── HTProtect vor einzelnen Nutzern verbergen (Stealth, 2.5.0) ──
            'hidden_su'      => array(), // Benutzer-IDs, die HTProtect im Backend NICHT sehen (Menue-Eintrag,
                                         // Statuskachel, Komponenten-Liste, Direktzugriff). Default leer = ALLE
                                         // (die es ueberhaupt duerfen, s. su_only) sehen es. Der einrichtende
                                         // Super-User ist NIE enthalten (keine Selbst-Aussperrung). Rein
                                         // kosmetisch/Zugriff - der Schutz laeuft fuer alle weiter. Fail-open:
                                         // im Zweifel sichtbar.
        );
    }

    /**
     * Unterverzeichnis der Installation aus der aktuellen Joomla-URL ableiten
     * ('' = Domain-Root, sonst z. B. '/joomla'). Bewusst eine eigene Methode:
     * der Wert wandert in die RewriteBase und die Router-Bedingungen. Wird die
     * Installation (etwa als Template-Klon) in ein anderes Verzeichnis kopiert,
     * vergleicht die Selbstabsicherung diesen Live-Wert mit dem gespeicherten
     * site_path und schreibt das Schild bei Abweichung mit korrektem Pfad neu.
     *
     * @return string
     */
    public static function detectSitePath()
    {
        $parsedPath = parse_url(htp_site_url(), PHP_URL_PATH);

        if (is_string($parsedPath)) {
            return rtrim($parsedPath, '/');
        }

        return '';
    }

    /**
     * Erlaubte statische Dateiendungen (alphabetisch, kommasepariert).
     *
     * @return string
     */
    public static function defaultExtensions()
    {
        return implode(',', array(
            '7z', 'avif', 'bmp', 'br', 'css', 'csv', 'doc', 'docx', 'eot',
            'geojson', 'gif', 'gml', 'gpx', 'gz', 'htm', 'html', 'ico', 'ics',
            'jp2', 'jpe', 'jpeg', 'jpg', 'js', 'json', 'kml', 'kmz', 'm4a',
            'm4v', 'map', 'mjs', 'mov', 'mp3',
            'mp4', 'mpeg', 'mpg', 'odp', 'ods', 'odt', 'oga', 'ogg', 'ogv',
            'opus', 'otf', 'pdf', 'png', 'ppt', 'pptx', 'rar', 'rtf', 'svg',
            'svgz', 'tif', 'tiff', 'ttf', 'txt', 'vtt', 'wasm', 'wav',
            'webm', 'webmanifest', 'webp', 'woff', 'woff2', 'xls', 'xlsx',
            'xml', 'xsl', 'zip',
        ));
    }

    /**
     * Standard-Ordner fuer die Upload-Ordner-Haertung (Mini-.htaccess,
     * die PHP-Ausfuehrung dort sperrt). HtpDirShield filtert nicht
     * vorhandene und mit Freigaben kollidierende Ordner heraus.
     *
     * @return array
     */
    public static function defaultHardenDirs()
    {
        // Nur echte Upload-/Auslieferungs-Ordner haerten. Volatile System-
        // Ordner (cache, tmp, logs ...) bewusst NICHT: ihre Mini-.htaccess wird
        // durch Cache-/Temp-Leerung (Joomla bzw. Admin Tools) staendig entfernt
        // - das erzeugte nur Fehlalarme. Diese Ordner sind ohnehin ueber die
        // Haupt-.htaccess ("interne Verzeichnisse komplett sperren", s_sysdirs)
        // vollstaendig gesperrt, eine zusaetzliche PHP-Sperre dort waere doppelt.
        $dirs = array(
            'images',
            'media',
        );

        // Joomla 5.3+ bringt zusaetzlich einen /files-Medienordner mit - dasselbe
        // Upload-Ziel wie images/media. Nur aufnehmen, wenn vorhanden (s.
        // filesDirEligible). Auf aelteren/ohne-Ordner-Sites uebergeht DirShield
        // ihn ohnehin.
        if (self::filesDirEligible()) {
            $dirs[] = 'files';
        }

        return $dirs;
    }

    /**
     * Ist der Joomla-5.3+-Medienordner /files vorhanden und damit ein Kandidat
     * fuer die Upload-Ordner-Haertung? Gemeinsam genutzt vom Default (greift bei
     * Neuinstallationen automatisch) und vom Ein-Klick-Nachruesten im Backend
     * (Bestandssites, die vor 5.3 installiert wurden).
     *
     * @return boolean
     */
    public static function filesDirEligible()
    {
        return function_exists('htp_jversion')
            && version_compare(htp_jversion(), '5.3', '>=')
            && @is_dir(htp_docroot() . '/files');
    }

    /** Zustand des letzten load(): 'found' | 'fresh' | 'error'. Siehe loadState(). */
    private static $loadState = 'error';

    // ── Auslagerung grosser maschineller Bloecke aus #__extensions.params (64-KB-TEXT-Grenze) in den
    //    separaten Blob-Store #__htprotect_store - GENAU wie der Signatur-Feed (HtpDefs), der die params
    //    frueher sprengte. Beim Laden transparent zurueckgemergt, beim Speichern herausgesplittet; so
    //    bleibt in params nur die kleine Nutzer-Config -> die 64-KB-Falle kann strukturell nicht wiederkehren.
    const STORE_TABLE  = '#__htprotect_store';
    const OFFLOAD_KEYS = array('watch_manifest', 'core_manifest', 'seo_baseline', 'account_baseline', 'account_contained', 'autoupdate_intent', 'mal_whitelist');
    private static $storeMemo       = null;  // Prozess-Cache der gelesenen Store-Bloecke (1x je Request)
    private static $storeReadFailed = false; // true NUR, wenn die Tabelle existiert, der Read aber scheiterte

    /**
     * Konfiguration laden (Defaults + gespeicherte Werte).
     *
     * @return array
     */
    public static function load()
    {
        $defaults = self::defaults();
        $stored   = array();
        $db       = htp_db();

        // Zustand dieses Reads (siehe loadState()): pessimistisch mit 'error' starten, bis ein
        // Lesen sauber gelingt. Verhindert, dass ein nur transient nicht lesbarer Stand als
        // "leer" durchgeht und dann von einem Auto-Save mit Defaults zementiert wird (Selbst-Reset).
        self::$loadState = 'error';

        if ($db) {
            try {
                $query = $db->getQuery(true)
                    ->select($db->quoteName('params'))
                    ->from($db->quoteName('#__extensions'))
                    ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                    ->where($db->quoteName('element') . ' = ' . $db->quote('com_htprotect'));
                $db->setQuery($query);
                $raw = $db->loadResult();

                if (!is_string($raw) || $raw === '') {
                    // Kein params-Wert -> frische/leere Installation (KEIN Fehler).
                    self::$loadState = 'fresh';
                } else {
                    $decoded = json_decode($raw, true);

                    if (!is_array($decoded)) {
                        // params vorhanden, aber unlesbar (z. B. an der 64-KB-Grenze der TEXT-
                        // Spalte abgeschnitten) -> NICHT als leer behandeln.
                        self::$loadState = 'error';
                    } elseif (!array_key_exists('htp_shield', $decoded)) {
                        // Gueltige params, aber (noch) kein htp_shield -> frisch.
                        self::$loadState = 'fresh';
                    } elseif (!is_array($decoded['htp_shield'])) {
                        // htp_shield vorhanden, aber beschaedigt -> Lesefehler.
                        self::$loadState = 'error';
                    } else {
                        $stored = $decoded['htp_shield'];
                        self::$loadState = 'found';
                    }
                }
            } catch (Exception $e) {
                self::$loadState = 'error';
            } catch (Throwable $e) {
                self::$loadState = 'error';
            }
        }

        $cfg = $defaults;

        foreach ($stored as $key => $value) {
            if (array_key_exists($key, $defaults)) {
                $cfg[$key] = $value;
            }
        }

        // Migration: altes api_router (0/1) -> api_mode, falls Letzteres
        // in den gespeicherten Daten noch fehlt (Bestandsinstallationen).
        if (!isset($stored['api_mode']) && isset($stored['api_router'])) {
            $cfg['api_mode'] = empty($stored['api_router']) ? 'off' : 'route';
        }

        if (!in_array($cfg['api_mode'], array('route', 'block', 'off'), true)) {
            $cfg['api_mode'] = 'route';
        }

        // Ausgelagerte Bloecke aus dem #__htprotect_store zurueckmergen (Store ist PRIMAER; ein evtl.
        // noch inline in params liegender Alt-Wert wird ueberschrieben). Danach greift die Listen-
        // Absicherung unten auch fuer die gemergten Arrays. Scheiterte der Store-Read OBWOHL die
        // Tabelle existiert (nicht: frische Site ohne Tabelle), gilt der Stand als NICHT lesbar
        // -> loadState 'error' -> saveKeys/Auto-Save setzen diesmal aus (Selbst-Reset-Schutz).
        $store = self::cfgStoreReadAll();
        foreach (self::OFFLOAD_KEYS as $ok) {
            if (array_key_exists($ok, $store)) {
                $cfg[$ok] = $store[$ok];
            }
        }
        if (self::$storeReadFailed) {
            self::$loadState = 'error';
        }

        // Listen-Typen absichern
        foreach (array('s_files', 's_dirs_php', 's_dirs_static', 'allow_paths', 's_sysdirs_list', 'last_test', 'x_sigs_off', 'u_dirs', 'watch_manifest', 'mal_whitelist', 'account_baseline', 'admin_whitelist', 'account_contained', 'account_sanctioned', 'helix_ignore', 'core_manifest', 'seo_whitelist', 'seo_topic_ack', 'seo_baseline', 'seo_overview_finding', 'core_overview_finding', 'tmp_keep', 'swarm_acked', 'autoupdate_include', 'autoupdate_exclude', 'autoupdate_never', 'hidden_su') as $listKey) {
            if (!is_array($cfg[$listKey])) {
                $cfg[$listKey] = array();
            }
        }

        return $cfg;
    }

    /**
     * Zustand des letzten load():
     *   'found' - eine gespeicherte htp_shield-Config wurde gelesen.
     *   'fresh' - es gibt noch keine gespeicherte Config (frische/leere Installation).
     *   'error' - es SOLLTE eine geben, sie war aber nicht lesbar (DB-Fehler, an der
     *             64-KB-Grenze abgeschnittenes JSON, beschaedigtes htp_shield).
     *
     * @return string
     */
    public static function loadState()
    {
        return self::$loadState;
    }

    /**
     * Kurzform: Konnte das letzte load() eine VORHANDENE Config nicht lesen (Fehler, nicht
     * bloss "frisch")? Dann darf kein bedingungsloser Auto-Save Defaults festschreiben - das
     * wuerde die eigentliche Nutzerwahl dauerhaft zementieren (Ursache "Einstellungen setzen
     * sich von selbst zurueck"). Aufrufer sollen bei true das Speichern diesmal auslassen.
     *
     * @return boolean
     */
    public static function loadFailed()
    {
        return self::$loadState === 'error';
    }

    /**
     * NUR einzelne Schluessel speichern (race-sicher): frisch laden, die
     * uebergebenen Werte darueberlegen, speichern. So ueberschreibt ein
     * Hintergrund-Schreibvorgang (z. B. der Waechter, der laenger arbeitet)
     * NICHT parallel im Backend geaenderte Felder wie notify_email mit einem
     * veralteten Stand - save() ersetzt htp_shield sonst komplett.
     *
     * @param   array  $changes  [schluessel => wert]
     *
     * @return boolean
     */
    public static function saveKeys($changes)
    {
        if (!is_array($changes) || empty($changes)) {
            return true;
        }

        $cfg = self::load();

        // Selbst-Reset-Schutz: Konnte die vorhandene Config nicht gelesen werden (transienter
        // DB-Fehler / an der 64-KB-Grenze abgeschnittenes JSON), NICHT Defaults + Aenderung
        // darueberschreiben - das wuerde die echte Nutzerwahl zementieren. Diesmal aussetzen;
        // beim naechsten (lesbaren) Durchlauf greift saveKeys normal. Ein frischer/leerer Stand
        // ('fresh') ist KEIN Fehler und wird normal initialisiert.
        if (self::$loadState === 'error') {
            self::$lastErr = 'read';

            return false;
        }

        foreach ($changes as $key => $value) {
            $cfg[$key] = $value;
        }

        return self::save($cfg);
    }

    /** Grund des letzten fehlgeschlagenen save() - fuer eine praezise Meldung statt pauschalem "Datenbankfehler". */
    private static $lastErr = '';

    /**
     * Grund des letzten fehlgeschlagenen save(): '' | 'nodb' | 'encoding' | 'toolarge' | 'write'.
     *
     * @return string
     */
    public static function lastSaveError()
    {
        return self::$lastErr;
    }

    /**
     * Passende, ehrliche Klartext-Meldung fuer das letzte fehlgeschlagene save()
     * (vermeidet das pauschale, irrefuehrende "Datenbankfehler").
     *
     * @return string
     */
    public static function saveErrorMessage()
    {
        switch (self::$lastErr) {
            case 'encoding':
                return htp_t('COM_HTPROTECT_ERR_SAVE_ENCODING');
            case 'toolarge':
                return htp_t('COM_HTPROTECT_ERR_SAVE_TOOLARGE');
            case 'nodb':
                return htp_t('COM_HTPROTECT_ERR_SAVE_NODB');
            default:
                return htp_t('COM_HTPROTECT_ERR_SAVE_WRITE');
        }
    }

    /**
     * Konfiguration speichern. Andere Parameter der Erweiterung bleiben erhalten.
     *
     * @param   array  $cfg  Komplette Konfiguration
     *
     * @return boolean
     */
    public static function save($cfg)
    {
        self::$lastErr = '';
        $db = htp_db();

        if (!$db) {
            self::$lastErr = 'nodb';

            return false;
        }

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName('params'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_htprotect'));
            $db->setQuery($query);
            $raw = $db->loadResult();

            $params = array();

            if (is_string($raw) && $raw !== '') {
                $decoded = json_decode($raw, true);

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

            // Grosse Bloecke ZUERST in den Store schreiben und aus der params-Kopie entfernen, damit die
            // 64-KB-TEXT-Spalte nur die kleine Nutzer-Config traegt. Reihenfolge (Store vor params) macht
            // es torn-write-fest: scheitert ein Store-Write, bleibt der Block inline in params (fail-open,
            // kein Datenverlust). Nur vorhandene Keys (isset) beruehren - so ueberschreibt ein $cfg ohne
            // einen Offload-Key dessen Store-Zeile NICHT.
            $small = $cfg;
            // NUR auslagern, wenn der Store diesen Request lesbar war. Scheiterte der Read (Tabelle da,
            // aber Fehler), koennten die Offload-Keys in $cfg Defaults (leer) sein - dann NICHT in den
            // Store schreiben (kein Clobbern echter Werte); die Bloecke bleiben inline in params und der
            // echte Store-Wert heilt beim naechsten lesbaren load() zurueck.
            if (!self::$storeReadFailed) {
                foreach (self::OFFLOAD_KEYS as $ok) {
                    if (isset($cfg[$ok]) && self::cfgStorePut('htp_cfg_' . $ok, $cfg[$ok])) {
                        unset($small[$ok]);
                    }
                }
                self::$storeMemo = null; // nach dem Schreiben neu einlesen lassen
            }

            $params['htp_shield'] = $small;

            $json = json_encode($params);

            if (!is_string($json) && defined('JSON_INVALID_UTF8_SUBSTITUTE')) {
                // Robuste Rettung: enthaelt irgendein Feld ungueltiges UTF-8, ersetzt
                // JSON_INVALID_UTF8_SUBSTITUTE (PHP 7.2+) die kaputten Sequenzen statt
                // zu scheitern - ein einzelnes defektes Zeichen (z. B. aus einer
                // eingefuegten eigenen Regel) darf das Speichern nie blockieren.
                $json = json_encode($params, JSON_INVALID_UTF8_SUBSTITUTE);
            }

            if (!is_string($json)) {
                self::$lastErr = 'encoding';
                error_log('HTProtect: Konfiguration nicht gespeichert - json_encode fehlgeschlagen (' . json_last_error_msg() . ').');

                return false;
            }

            // Schutz vor der 64-KB-Grenze der Joomla-`params`-TEXT-Spalte: ein Ueberlauf
            // wuerde im non-strict-Modus stillschweigend ABGESCHNITTEN -> zerstoerte Config.
            // Lieber sauber ablehnen und praezise melden. Der groesste Block ist dabei i. d. R.
            // NICHT die Nutzer-Config, sondern der wachsende Signatur-Feed (htp_defs).
            if (strlen($json) > 65535) {
                self::$lastErr = 'toolarge';
                error_log('HTProtect: Konfiguration nicht gespeichert - zu gross fuer die params-Spalte (' . strlen($json) . ' Bytes > 65535).');

                return false;
            }

            $query = $db->getQuery(true)
                ->update($db->quoteName('#__extensions'))
                ->set($db->quoteName('params') . ' = ' . $db->quote($json))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_htprotect'));
            $db->setQuery($query);

            if (method_exists($db, 'execute')) {
                $db->execute();
            } else {
                $db->query();
            }

            return true;
        } catch (Exception $e) {
            self::$lastErr = (isset($json) && is_string($json) && strlen($json) > 60000) ? 'toolarge' : 'write';
            error_log('HTProtect: Konfiguration nicht gespeichert - ' . self::$lastErr . ' (DB): ' . $e->getMessage());

            return false;
        } catch (Throwable $e) {
            self::$lastErr = 'write';
            error_log('HTProtect: Konfiguration nicht gespeichert - Fehler: ' . $e->getMessage());

            return false;
        }
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Blob-Store fuer ausgelagerte Config-Bloecke (#__htprotect_store) - eigenstaendig, damit
    //  config.php nicht von HtpDefs abhaengt (config wird sehr frueh geladen). Schema/Format sind
    //  identisch zum Defs-Store (CREATE TABLE IF NOT EXISTS ist idempotent -> teilen beide dieselbe
    //  Tabelle). Je Offload-Key eine eigene Zeile 'htp_cfg_<key>' (getrennte Zeilen = saveKeys-race-fest).
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Alle ausgelagerten Bloecke in EINEM Query lesen (memoisiert je Request). Unterscheidet sauber:
     *   - Tabelle fehlt (frische Site, nie geschrieben) -> leer, KEIN Fehler.
     *   - Tabelle da, Read scheitert -> $storeReadFailed=true (load() setzt dann loadState='error').
     *
     * @return array<string,mixed>  key => Wert (nur vorhandene/dekodierbare)
     */
    private static function cfgStoreReadAll()
    {
        if (self::$storeMemo !== null) {
            return self::$storeMemo;
        }

        $out = array();
        self::$storeReadFailed = false;
        $db = htp_db();

        if ($db) {
            $names = array();
            foreach (self::OFFLOAD_KEYS as $ok) {
                $names[] = $db->quote('htp_cfg_' . $ok);
            }

            try {
                $q = $db->getQuery(true)
                    ->select(array($db->quoteName('name'), $db->quoteName('data')))
                    ->from($db->quoteName(self::STORE_TABLE))
                    ->where($db->quoteName('name') . ' IN (' . implode(',', $names) . ')');
                $db->setQuery($q);
                $rows = $db->loadAssocList();

                if (is_array($rows)) {
                    foreach ($rows as $r) {
                        $key = substr((string) $r['name'], 8); // strlen('htp_cfg_')
                        $val = self::cfgStoreDecode((string) $r['data']);

                        if ($key !== '' && $val !== null) {
                            $out[$key] = $val;
                        }
                    }
                }
            } catch (Exception $e) {
                self::$storeReadFailed = self::cfgStoreTableExists($db);
            } catch (Throwable $e) {
                self::$storeReadFailed = self::cfgStoreTableExists($db);
            }
        }

        self::$storeMemo = $out;

        return $out;
    }

    /** Existiert die Store-Tabelle? (nur im Fehlerpfad aufgerufen, um "frisch" von "hartem Fehler" zu trennen) */
    private static function cfgStoreTableExists($db)
    {
        try {
            $db->setQuery('SHOW TABLES LIKE ' . $db->quote($db->replacePrefix(self::STORE_TABLE)));

            return (bool) $db->loadResult();
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }
    }

    /** Einen ausgelagerten Block schreiben (Upsert, treiberneutral). Legt die Tabelle bei Bedarf an. */
    private static function cfgStorePut($name, $value)
    {
        $db = htp_db();

        if (!$db) {
            return false;
        }

        try {
            self::cfgStoreEnsure($db);
            $blob = self::cfgStoreEncode($value);

            $db->setQuery(
                $db->getQuery(true)
                    ->select($db->quoteName('name'))
                    ->from($db->quoteName(self::STORE_TABLE))
                    ->where($db->quoteName('name') . ' = ' . $db->quote($name))
            );
            $exists = $db->loadResult();

            if ($exists !== null) {
                $q = $db->getQuery(true)
                    ->update($db->quoteName(self::STORE_TABLE))
                    ->set($db->quoteName('data') . ' = ' . $db->quote($blob))
                    ->set($db->quoteName('ts') . ' = ' . (int) time())
                    ->where($db->quoteName('name') . ' = ' . $db->quote($name));
            } else {
                $q = $db->getQuery(true)
                    ->insert($db->quoteName(self::STORE_TABLE))
                    ->columns(array($db->quoteName('name'), $db->quoteName('data'), $db->quoteName('ts')))
                    ->values($db->quote($name) . ', ' . $db->quote($blob) . ', ' . (int) time());
            }

            $db->setQuery($q);
            $db->execute();

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

    /** Tabelle anlegen (idempotent). Schema byte-gleich zum Defs-Store; bewusst ohne ENGINE/CHARSET. */
    private static function cfgStoreEnsure($db)
    {
        $db->setQuery(
            'CREATE TABLE IF NOT EXISTS ' . $db->quoteName(self::STORE_TABLE) . ' ('
            . $db->quoteName('name') . ' VARCHAR(64) NOT NULL, '
            . $db->quoteName('data') . ' MEDIUMTEXT NULL, '
            . $db->quoteName('ts') . ' INT NOT NULL DEFAULT 0, '
            . 'PRIMARY KEY (' . $db->quoteName('name') . ')'
            . ')'
        );
        $db->execute();
    }

    /** JSON -> gzip+base64 ('z:'), Rueckfall roh base64 ('r:') ohne zlib. */
    private static function cfgStoreEncode($value)
    {
        $json = json_encode($value);

        if (!is_string($json)) {
            $json = '[]';
        }

        if (function_exists('gzencode')) {
            $gz = @gzencode($json, 6);

            if (is_string($gz) && $gz !== '') {
                return 'z:' . base64_encode($gz);
            }
        }

        return 'r:' . base64_encode($json);
    }

    /** Umkehrung von cfgStoreEncode; null bei Unlesbarkeit. */
    private static function cfgStoreDecode($blob)
    {
        if (!is_string($blob) || strlen($blob) < 2) {
            return null;
        }

        $pfx = substr($blob, 0, 2);
        $raw = base64_decode(substr($blob, 2), true);

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

        if ($pfx === 'z:') {
            if (!function_exists('gzdecode')) {
                return null;
            }

            $raw = @gzdecode($raw);

            if (!is_string($raw)) {
                return null;
            }
        } elseif ($pfx !== 'r:') {
            return null;
        }

        $val = json_decode($raw, true);

        return is_array($val) ? $val : null;
    }

    /**
     * Formulareingaben des Schutzschild-Formulars in die Konfiguration
     * uebernehmen (validiert/normalisiert).
     *
     * @param   array  $cfg  Bisherige Konfiguration
     *
     * @return array Aktualisierte Konfiguration
     */
    public static function applyShieldForm($cfg)
    {
        // Checkboxen
        $bools = array(
            'sef_rules', 'fastcgi_auth', 'apache_options', 'follow_symlink',
            'force_https',
            'h_frame', 'h_nosniff', 'h_poweredby', 'h_hsts', 'h_hsts_sub', 'h_coop', 'h_permissions',
            'f_query', 'f_rfi', 'f_methods', 'f_wpnoise',
            'x_shield', 'u_harden',
            's_php', 's_types', 's_dotfiles', 's_wellknown', 's_sensitive', 's_manifests', 's_sysdirs',
            'p_compress', 'p_expires', 'p_etag',
            'autosecure', 'htaccess_external', 'shield_autoheal',
        );

        foreach ($bools as $key) {
            $cfg[$key] = htp_post_bool('htp_' . $key);
        }

        // Auswahlfelder
        $www = (int) htp_post('htp_www_mode', '0');
        $cfg['www_mode'] = in_array($www, array(0, 1, 2), true) ? $www : 0;

        // /api-Modus (Tri-State); api_router fuer Rueckwaertskompatibilitaet mitfuehren
        $apiMode = htp_post('htp_api_mode', 'route');
        $cfg['api_mode']   = in_array($apiMode, array('route', 'block', 'off'), true) ? $apiMode : 'route';
        $cfg['api_router'] = $cfg['api_mode'] === 'route' ? 1 : 0;

        // Exploit-Signaturen: abgeschaltete IDs sammeln (alles, was bekannt
        // ist, aber NICHT angehakt wurde). Neue Signaturen bleiben so an.
        if (class_exists('HtpDefs')) {
            $rawSig    = htp_raw('htp_sig', null, 'post');
            $submitted = is_array($rawSig) ? array_keys($rawSig) : array();
            $off = array();

            foreach (HtpDefs::catalog($cfg) as $sig) {
                if (!in_array($sig['id'], $submitted, true)) {
                    $off[] = $sig['id'];
                }
            }

            $cfg['x_sigs_off'] = $off;
        }

        // Upload-Ordner-Liste
        $cfg['u_dirs'] = self::cleanPathList(htp_post('htp_u_dirs', ''), '', false);

        $referrer = htp_post('htp_h_referrer', '');
        $allowedReferrer = array(
            '', 'no-referrer', 'same-origin', 'strict-origin',
            'strict-origin-when-cross-origin', 'no-referrer-when-downgrade',
        );
        $cfg['h_referrer'] = in_array($referrer, $allowedReferrer, true)
            ? $referrer : 'strict-origin-when-cross-origin';

        // Pfad/Host
        // Unterverzeichnis IMMER automatisch aus Joomlas Basis-URL erkennen (nie aus
        // dem Formular) -> im UI read-only. detectSitePath() = parse_url(JUri::root())
        // spiegelt exakt Joomlas eigenen Routing-Basispfad; ein manueller Override
        // koennte RewriteBase/Router gegen Joomlas Selbstverstaendnis laufen lassen.
        // Escape fuer Exoten (Reverse-Proxy): $live_site in configuration.php - das
        // beruecksichtigt JUri::root() und damit auch diese Erkennung.
        $cfg['site_path']      = self::detectSitePath();
        $cfg['canonical_host'] = self::cleanHost(htp_post('htp_canonical_host', ''));

        // Endungen
        $cfg['s_ext'] = self::cleanExtensions(htp_post('htp_s_ext', $cfg['s_ext']));

        // Freigabe-Listen
        $cfg['s_files']        = self::cleanPathList(htp_post('htp_s_files', ''), $cfg['site_path'], true);
        $cfg['s_dirs_php']     = self::cleanPathList(htp_post('htp_s_dirs_php', ''), $cfg['site_path'], false);
        $cfg['s_dirs_static']  = self::cleanPathList(htp_post('htp_s_dirs_static', ''), $cfg['site_path'], false);
        // wantFiles=false: abschliessende Slashes entfernen. Vertrauenswuerdige
        // Pfade matchen als Praefix ("^<pfad>(/|$)") - ein Trailing-Slash wuerde
        // die erzeugte Regel sonst nur den Ordner selbst, nicht seine Dateien
        // treffen lassen (Datei-Endungen sind hier ohnehin slash-frei).
        $cfg['allow_paths']    = self::cleanPathList(htp_post('htp_allow_paths', ''), $cfg['site_path'], false);
        $cfg['s_sysdirs_list'] = self::cleanPathList(htp_post('htp_s_sysdirs_list', ''), $cfg['site_path'], false);

        // Eigene Regeln: eigene Marker entfernen, damit die Datei eindeutig bleibt
        $cfg['custom_top']    = self::cleanCustomRules(htp_post('htp_custom_top', ''));
        $cfg['custom_bottom'] = self::cleanCustomRules(htp_post('htp_custom_bottom', ''));

        return $cfg;
    }

    /**
     * Unterverzeichnis-Pfad normalisieren ('' oder '/pfad').
     *
     * @param   string  $path  Eingabe
     *
     * @return string
     */
    public static function cleanSitePath($path)
    {
        $path = trim((string) $path);
        $path = str_replace('\\', '/', $path);
        $path = trim($path, " \t/");

        if ($path === '' || strpos($path, '..') !== false) {
            return '';
        }

        if (!preg_match('#^[A-Za-z0-9._~/\-]+$#', $path)) {
            return '';
        }

        return '/' . $path;
    }

    /**
     * Hostnamen normalisieren (nur Domain, ohne Schema/Pfad).
     *
     * @param   string  $host  Eingabe
     *
     * @return string
     */
    public static function cleanHost($host)
    {
        $host = strtolower(trim((string) $host));
        $host = preg_replace('#^[a-z]+://#', '', $host);
        $host = preg_replace('#[/:].*$#', '', $host);

        if ($host === '' || !preg_match('/^[a-z0-9.\-]+$/', $host)) {
            return '';
        }

        return $host;
    }

    /**
     * Endungsliste normalisieren: klein, alphanumerisch, dedupliziert,
     * alphabetisch sortiert.
     *
     * @param   string  $raw  Kommagetrennte Eingabe
     *
     * @return string
     */
    public static function cleanExtensions($raw)
    {
        $parts = preg_split('/[\s,;]+/', strtolower((string) $raw));
        $clean = array();

        foreach ($parts as $part) {
            $part = trim($part, " .\t");

            if ($part !== '' && preg_match('/^[a-z0-9]{1,12}$/', $part)) {
                $clean[$part] = true;
            }
        }

        if (empty($clean)) {
            return self::defaultExtensions();
        }

        $list = array_keys($clean);
        sort($list);

        return implode(',', $list);
    }

    /**
     * Kommaseparierte Empfaengerliste fuer die Waechter-Mails normalisieren.
     * Trennt an Komma/Semikolon, trimmt, entfernt Dubletten (klein verglichen)
     * und prueft jede Adresse einzeln. Eine einzelne Adresse bleibt damit
     * gueltig (1-elementige Liste).
     *
     * @param   string  $raw  Rohtext aus dem Formular
     *
     * @return array ['valid' => array, 'invalid' => array]
     */
    public static function cleanEmailList($raw)
    {
        $parts   = preg_split('/[,;]+/', (string) $raw);
        $valid   = array();
        $invalid = array();
        $seen    = array();

        foreach ($parts as $part) {
            $addr = trim($part);

            if ($addr === '') {
                continue;
            }

            $key = strtolower($addr);

            if (isset($seen[$key])) {
                continue;
            }

            $seen[$key] = true;

            if (filter_var($addr, FILTER_VALIDATE_EMAIL)) {
                $valid[] = $addr;
            } else {
                $invalid[] = $addr;
            }
        }

        return array('valid' => $valid, 'invalid' => $invalid);
    }

    /**
     * Pfadliste (eine Zeile pro Eintrag) normalisieren:
     * - fuehrende Slashes/Site-Pfad/komplette URLs der eigenen Site entfernen
     * - Traversal, Steuerzeichen und Anfuehrungszeichen ablehnen
     * - deduplizieren und alphabetisch sortieren
     *
     * @param   string   $raw        Eingabetext
     * @param   string   $sitePath   Unterverzeichnis ('' oder '/pfad')
     * @param   boolean  $wantFiles  true = Dateipfade, false = Verzeichnisse
     *
     * @return array
     */
    public static function cleanPathList($raw, $sitePath, $wantFiles)
    {
        $lines = preg_split('/\r\n|\r|\n/', (string) $raw);
        $clean = array();

        foreach ($lines as $line) {
            $entry = self::cleanPathEntry($line, $sitePath);

            if ($entry === '') {
                continue;
            }

            if (!$wantFiles) {
                $entry = rtrim($entry, '/');
            }

            if ($entry !== '') {
                $clean[$entry] = true;
            }
        }

        $list = array_keys($clean);
        sort($list);

        return $list;
    }

    /** Akeeba-Panopticon-Connector (Joomla 4/5: /api-Anwendung), token-gesichert. */
    const PANOPTICON_PATH = 'api/index.php/v1/panopticon';

    /** Akeeba-Panopticon-Connector (Joomla 3: eigener Front-End-Pfad), token-gesichert. */
    const PANOPTICON_J3_PATH = 'panopticon_api';

    /** Wie PANOPTICON_J3_PATH, aber non-SEF (Alternate Endpoint /index.php/panopticon_api). */
    const PANOPTICON_J3_ALT_PATH = 'index.php/panopticon_api';

    /**
     * KI-Steuerung (API-Modus): die Web-Services-Ressourcen der Stufe 1, die bei aktiver KI-
     * Freischaltung (cfg['ai_api_allow']) als vertrauenswuerdig gelten - erreichbar auch bei
     * api_mode=block oder aktiven uri-Signaturen (token-gesichert wie Panopticon). BEWUSST OHNE
     * /v1/config (die api-config-leak-Signatur, CVE-2023-23752, bleibt wirksam) und OHNE /v1/users
     * (User-Verwaltung = Stufe 2+). Jeder Eintrag ist ein Praefix (isAllowedPath matcht "^<pfad>(/|$)").
     */
    const AI_API_PATHS = array(
        'api/index.php/v1/content',
        'api/index.php/v1/tags',
        'api/index.php/v1/media',
        'api/index.php/v1/modules',
        'api/index.php/v1/menus',
        'api/index.php/v1/languages',
        'api/index.php/v1/templates',
        'api/index.php/v1/fields',
        'api/index.php/v1/contact',
        'api/index.php/v1/newsfeeds',
        'api/index.php/v1/banners',
        'api/index.php/v1/associations',
        'api/index.php/v1/checkin',
        'api/index.php/v1/extensions',
    );

    /**
     * Vertrauenswuerdige Pfade (Nutzer-Freigaben + feste Defaults) als rel.
     * Pfadliste. Der Akeeba-Panopticon-Connector ist fest enthalten - beide
     * Varianten: J4/5 ueber die /api-Anwendung, J3 ueber den eigenen
     * Front-End-Pfad `panopticon_api`. Beide sind durch einen API-Token
     * geschuetzt (ohne gueltigen Token 401/403) -> gefahrlos offen, damit
     * Monitoring/Updates ab Werk laufen, auch bei api_mode=block oder aktiven
     * Signaturen.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array  rel. Pfade ohne fuehrenden Slash
     */
    public static function allowedPaths($cfg)
    {
        $list = (isset($cfg['allow_paths']) && is_array($cfg['allow_paths'])) ? $cfg['allow_paths'] : array();
        $list[] = self::PANOPTICON_PATH;
        $list[] = self::PANOPTICON_J3_PATH;
        $list[] = self::PANOPTICON_J3_ALT_PATH;

        // KI-Steuerung: bei aktiver Freischaltung die genutzten Stufe-1-Web-Services-Ressourcen
        // freigeben (token-gesichert). NICHT /v1/config, NICHT /v1/users (siehe AI_API_PATHS).
        if (!empty($cfg['ai_api_allow'])) {
            foreach (self::AI_API_PATHS as $aiPath) {
                $list[] = $aiPath;
            }
        }

        // Schraegstriche normalisieren (fuehrend/abschliessend): die in
        // sectionAllow erzeugte Regel "^<pfad>(/|$)" muss exakt so matchen wie
        // isAllowedPath() weiter unten. Ein versehentlicher Trailing-Slash (z. B.
        // ein kopierter Ordnerpfad) ergaebe sonst eine Regel, die Unterdateien
        // NICHT mehr durchlaesst - der URL-Test zeigte "erlaubt", der Server
        // blockte (403). Greift auch fuer bereits gespeicherte Alt-Eintraege.
        $out = array();

        foreach ($list as $p) {
            $p = trim((string) $p, '/');

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

        return $out;
    }

    /**
     * Ist der Pfad als vertrauenswuerdig freigegeben (Praefix-Treffer)? Hebt
     * PFAD-basierte Sperren auf (Lockdown, uri-Signaturen, Dateischild) -
     * NICHT die Query-Injektionsfilter (die bleiben pro Anfrage aktiv).
     *
     * @param   array   $cfg  Konfiguration
     * @param   string  $rel  Pfad relativ zum Root (mit/ohne fuehrenden Slash)
     *
     * @return boolean
     */
    public static function isAllowedPath($cfg, $rel)
    {
        $rel = ltrim((string) $rel, '/');

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

        foreach (self::allowedPaths($cfg) as $entry) {
            $entry = trim((string) $entry, '/');

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

        return false;
    }

    /**
     * Einzelnen Pfadeintrag normalisieren (relativ zum Joomla-Root, ohne
     * fuehrenden Slash). Liefert '' bei ungueltiger Eingabe.
     *
     * @param   string  $line      Eingabezeile
     * @param   string  $sitePath  Unterverzeichnis ('' oder '/pfad')
     *
     * @return string
     */
    public static function cleanPathEntry($line, $sitePath)
    {
        $entry = trim((string) $line);

        if ($entry === '' || strpos($entry, '#') === 0) {
            return '';
        }

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

        // Komplette URL der eigenen Site? Dann nur den Pfad behalten.
        if (preg_match('#^https?://[^/]+(/.*)?$#i', $entry, $m)) {
            $entry = isset($m[1]) ? $m[1] : '/';
        }

        // Query/Fragment abschneiden
        $entry = preg_replace('/[?#].*$/', '', $entry);
        $entry = '/' . ltrim($entry, '/');

        // Site-Unterverzeichnis abstreifen, falls mitgeliefert
        if ($sitePath !== '' && stripos($entry, $sitePath . '/') === 0) {
            $entry = substr($entry, strlen($sitePath));
        }

        $entry = ltrim($entry, '/');
        $entry = rawurldecode($entry);

        if ($entry === ''
            || strpos($entry, '..') !== false
            || strpos($entry, chr(0)) !== false
            || strpos($entry, '"') !== false
            || preg_match('/[\x00-\x1f]/', $entry)) {
            return '';
        }

        if (!preg_match('#^[A-Za-z0-9 ._~@+(),\[\]/\-]+$#', $entry)) {
            return '';
        }

        return $entry;
    }

    /**
     * Eigene Regeln bereinigen (eigene Sentinel-Marker neutralisieren).
     *
     * @param   string  $raw  Eingabe
     *
     * @return string
     */
    public static function cleanCustomRules($raw)
    {
        $raw = str_replace(chr(0), '', (string) $raw);

        // Gueltiges UTF-8 erzwingen: sonst scheitert beim Config-Speichern das
        // json_encode() (JSON_ERROR_UTF8) und der Nutzer bekommt nur einen
        // pauschalen "Datenbankfehler" - ohne erkennbare Ursache. Ungueltige
        // Byte-Folgen entstehen typisch beim Einfuegen aus einem ANSI/Latin-1-
        // Editor oder aus Office/PDF. Verlustarm nach UTF-8 wandeln; echte
        // .htaccess-Regeln sind ohnehin reines ASCII.
        if ($raw !== '' && !preg_match('//u', $raw)) {
            $raw = function_exists('mb_convert_encoding')
                ? (string) mb_convert_encoding($raw, 'UTF-8', 'UTF-8, Windows-1252, ISO-8859-1')
                : (string) @iconv('UTF-8', 'UTF-8//IGNORE', $raw);
        }

        $raw = str_replace("\r\n", "\n", $raw);
        $raw = str_replace("\r", "\n", $raw);
        $raw = preg_replace('/^#\s*htprotect-shield:(begin|end).*$/mi', '# (entfernt)', $raw);

        return trim($raw);
    }
}
