<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Steuerung: Berechtigungen, Aktionen, Datenaufbereitung, Rendering.
 */

defined('_JEXEC') or die;

class HtpApp
{
    /**
     * Empfaengeradresse des Support-Formulars.
     */
    const SUPPORT_EMAIL = 'mail@website-bereinigung.de';

    /** JED-Bewertungs-Einladung: aktuelle "Kampagnen-Generation". Overview-Banner (+ Mail-
     *  Footer) laden dezent zu einer Bewertung ein. Diese Generation NUR bewusst hochsetzen
     *  ('v1'->'v2'), wenn eine NEUE Einladungs-Welle gewuenscht ist - dann sehen auch Nutzer,
     *  die die vorige weggeklickt haben, EINMALIG wieder das Banner. NICHT pro Release erhoehen
     *  (sonst spammy). */
    const JED_REVIEW_GEN = 'v1';

    /** Ziel der Bewertungs-Einladung (JED-Listing, Reviews-Anker). */
    const JED_REVIEW_URL = 'https://extensions.joomla.org/extension/htprotect-security-firewall-auto-updates/#reviews';

    /** Generation des Fernverwaltungs-Hinweises (htprotect.app). Wird per × verbucht; erst eine
     *  KUENFTIGE Version, die diese Zahl erhoeht, zeigt allen, die weggeklickt haben, EINMALIG
     *  wieder das Banner. NICHT pro Release erhoehen (sonst spammy). */
    const FV_NOTICE_GEN = 1;

    /** (2.6.4) Sprachpakete: separater Update-Server (Sammel-Host, gleiches Verzeichnis wie der Kern).
     *  Der signierte Katalog nennt je kanonischer Sprache Version + ZIP-Dateiname + SHA-256. */
    const LANGPACK_BASE_URL    = 'https://update.wb-dev.de/';
    const LANGPACK_CATALOG_URL = 'https://update.wb-dev.de/htprotect-langpacks.json';

    /** Bewaehrungsfrist: erst so lange nach dauerhaft gruener Ampel (welcome_green_since)
     *  laedt HTProtect zur Bewertung ein - nicht sofort bei Neuinstallation. */
    const JED_REVIEW_MIN_AGE = 604800; // 7 Tage

    /**
     * Drossel der SEO-/Defacement-Schnellpruefung in der Uebersicht (1 h).
     * Hoechstens einmal pro Stunde ein Startseiten-Abruf, unabhaengig vom
     * 6h-Hintergrundwaechter (der nur die Automatik/Mail steuert).
     */
    const SEO_OVERVIEW_THROTTLE = 3600;

    /**
     * Drossel der Kerndatei-Anomalie-Schnellpruefung in der Uebersicht (1 h) -
     * damit eine in eine Kerndatei (configuration.php/Template) eingeschleuste
     * Hintertuer sofort in der Ampel erscheint, nicht nur im Hintergrundwaechter.
     */
    const CORE_OVERVIEW_THROTTLE = 3600;

    /**
     * Einstiegspunkt der Komponente.
     *
     * @return void
     */
    public static function run()
    {
        htp_load_lang();

        $user = htp_user();
        $cfg  = HtpConfig::load();

        // Konnte die gespeicherte Config gelesen werden? Sofort festhalten (spaetere load()/
        // saveKeys()-Aufrufe in diesem Request wuerden den globalen Status ueberschreiben). Nur bei
        // true duerfen die bedingungslosen Auto-Saves unten (nginx-Probe/Selbstabsicherung/Schild-
        // Selbstheilung) schreiben - sonst zementierten sie Defaults ueber eine nur transient
        // unlesbare Config (Ursache "Einstellungen setzen sich von selbst zurueck").
        $cfgReadOk = !HtpConfig::loadFailed();

        // Zugriffs-Gate. Standard (su_only=1): NUR Super-User. Ist "Nur fuer Super-User"
        // ausgeschaltet (su_only=0), duerfen auch normale Admins (core.manage) - deckungsgleich
        // mit der Sichtbarkeit (Menue/Kachel folgen derselben ACL). Super-User umgehen ACL ohnehin.
        $accessAction = self::accessAction($cfg);

        if (!$user || !method_exists($user, 'authorise') || !$user->authorise($accessAction, 'com_htprotect')) {
            echo '<div style="margin:30px auto;max-width:560px;padding:20px;border:1px solid #f0556b;'
                . 'border-radius:10px;background:#220d12;color:#f08a99;font-family:sans-serif">'
                . htp_esc(htp_t('COM_HTPROTECT_ACCESS_SUPERUSER')) . '</div>';

            return;
        }

        // Site-Identitaet pruefen (Klon/Template/Migration -> klon-empfindliche
        // pro-Site-Werte verwerfen, bevor irgendetwas sie benutzt): Abmeldelink-
        // Basis, HMAC-Secret, Konten-Baseline. Reine In-Memory-Pruefung (ein sha1).
        // Laeuft VOR der unsub_base-Erfassung: bei einem Klon setzt der Guard zuerst
        // zurueck, dann erfasst der Block unten sofort die echte Backend-URL neu.
        if (class_exists('HtpAccounts')) {
            HtpAccounts::reconcileSiteIdentity($cfg);
        }

        // Notaus erkannt? (Dashboard-Fernbefehl ueber rescue.php ODER manuell per FTP angelegt, um
        // sich zu entsperren.) Jetzt sauber finalisieren - nur fuer den bereits autorisierten
        // Backend-Nutzer; $cfg danach frisch laden, damit die UI den korrekten Stand zeigt.
        if ($cfgReadOk && self::emergencyFinalizeIfFlagged()) {
            $cfg       = HtpConfig::load();
            $cfgReadOk = !HtpConfig::loadFailed();
        }

        // Vertrauenswuerdige Site-Basis aus DIESEM authentifizierten Super-User-
        // Backend-Request festhalten (der Host ist hier nicht angreifer-setzbar).
        // Damit sind serverseitig erzeugte Links (Abmeldelinks in Mails) auch OHNE
        // gesetztes live_site host-poisoning-fest. Nur bei Aenderung schreiben.
        if (class_exists('JUri')) {
            try {
                $root = rtrim((string) JUri::root(), '/') . '/';
            } catch (Exception $e) {
                $root = '';
            }

            if ($root !== '' && $root !== '/' && preg_match('~^https?://~i', $root)
                && (!isset($cfg['unsub_base']) || $cfg['unsub_base'] !== $root)) {
                HtpConfig::saveKeys(array('unsub_base' => $root));
                $cfg['unsub_base'] = $root;
            }
        }

        $sections = array('overview', 'guard', 'shield', 'scan', 'backups', 'help');
        $sec      = htp_cmd('sec', 'overview');

        // Frueherer eigener Nginx-Bereich: lebt jetzt im Schutzschild
        if (htp_cmd('sec', '') === 'nginx') {
            $sec = 'shield';
        }

        // 'connect' = Fernverwaltungs-Einstieg (Dashboard-Kopplung). Bewusst NICHT in $sections,
        // erscheint also NICHT als interner Reiter der app-Navigation. Erreichbar ueber den eigenen
        // Joomla-Komponenten-Menuepunkt "Fernverwaltung" (script.php syncSubmenu), den Deep-Link vom
        // Dashboard (?sec=connect&code=...) und den dezenten Footer-Link. Darum hier durchlassen.
        if (!in_array($sec, $sections, true) && $sec !== 'connect') {
            $sec = 'overview';
        }

        $messages = array();
        $data     = array(
            'preview'        => null,
            'selftest'       => null,
            'scan'           => null,
            'urltest'        => null,
            'urltest_input'  => '',
            'backup_view'    => null,
            'import_top'     => null,
        );

        // Echtzeit-Schutz-Plugin gefahrlos im Normal-Request verwalten
        // (Erstaktivierung + Auffrischen; NIE im Installer wegen Re-Entrancy).
        self::syncWatchPlugin($cfg, $messages);

        // Dashboard-Statuskachel (Quickicon) genauso verwalten - still.
        self::syncQuickiconPlugin($cfg);

        // Versions-Selbstheilung: Task-Plugin auffrischen + nach einem Self-Update
        // steckengebliebene Komponenten-Version nachziehen. Hier beim Admin-Aufruf SOFORT;
        // zusaetzlich headless im Auto-Update-Task (alle 15 Min, Scheduler + Cron, s.
        // HtpAutoUpdate::runStep) -> es haengt nichts, auch wenn HTProtect fast nie
        // geoeffnet wird.
        self::selfHealVersions();

        // Aenderung: HTProtect schaltet Joomlas gzip NICHT mehr von sich aus ab. Trifft die
        // Doppelkompression zu (Server komprimiert bereits selbst UND Joomlas gzip ist an),
        // erscheint in der Uebersicht nur noch ein Hinweis-Banner mit Knopf - die Entscheidung
        // trifft der Betreiber. Siehe gzipNoticeDue() und den Task 'gzip_notice_off'.

        // ── .htaccess-Funktionstest (Apache-hinter-nginx zuverlaessig erkennen) ──
        // SERVER_SOFTWARE ist in Proxy-Setups unzuverlaessig (Plesk meldet oft
        // "nginx", obwohl Apache dahinter die .htaccess auswertet). Nur wenn nginx
        // gemeldet wird UND noch kein frischer Befund vorliegt, einmalig aktiv
        // testen (isolierter Wegwerf-Ordner, raeumt sich selbst auf). Ergebnis
        // wird gecacht (7 Tage) -> kein HTTP-Test bei jedem Aufruf.
        if (htp_is_nginx()) {
            $didProbe = false;
            HtpProbe::htaccessCapability($cfg, true, $didProbe);

            if ($didProbe && $cfgReadOk) {
                HtpConfig::save($cfg);
            }
        }

        // Maszgebliche Server-Entscheidung einmal festhalten (UI liest sie hieraus).
        $data['nginx_mode'] = htp_nginx_mode($cfg);
        $data['iis_mode']   = htp_iis_mode($cfg);

        // ── POST-Aktionen ──
        $task = htp_has('htp_task', 'post') ? htp_cmd('htp_task', '') : '';

        if ($task !== '') {
            if (!htp_check_token()) {
                $messages[] = self::msg('err', htp_t('COM_HTPROTECT_ERR_TOKEN'));
            } else {
                self::dispatch($task, $cfg, $sec, $messages, $data);
            }
        }

        // ── Daten fuer die Anzeige ──
        $data['status'] = HtpStorage::status($cfg);
        $data['guard']  = HtpGuard::status();

        $data['pending'] = false;

        if ($data['status']['exists'] && $data['status']['ours']) {
            // Inhaltsbasiert statt Fingerabdruck: nur wenn die FRISCH erzeugten
            // Regeln vom Stand auf der Platte abweichen (Version/Zeitstempel/
            // rs/cfg ausgeblendet), gibt es etwas zu schreiben. Ein reines
            // Versions-Update ohne echte Regelaenderung loest also KEINEN
            // Hinweis aus - und neue, verhaltensneutrale Konfig-Schluessel
            // brauchen keine pflegeintensive Ausnahmeliste mehr. Signatur-Feed-
            // Updates loesen bewusst nichts aus (WAF nutzt sie live); eine vom
            // Nutzer abgeschaltete, aber noch aktive Signatur dagegen schon.
            $data['pending'] = HtpBuilder::shieldOutdated($cfg, HtpStorage::read());
        }

        // ── Selbstabsicherung (Templates/Klone) ──
        // Ist autosecure aktiv, schreibt die Komponente das Schild beim ersten
        // Backend-Aufruf automatisch - serversicher mit Selbsttest + Auto-
        // Rollback. Zwei Anlaesse:
        //   (a) Schild (noch) NICHT von HTProtect aktiv (z. B. frisch geklonte
        //       Seite, deren Root-.htaccess der App-Installer ueberschrieb).
        //   (b) Pfad-Drift: Schild ist aktiv, aber die Installation wurde in ein
        //       anderes Verzeichnis kopiert (Live-Pfad != gespeicherter
        //       site_path). Dann RewriteBase/Router neu mit korrektem Pfad
        //       schreiben. Das Dateischild selbst ist pfad-relativ und sperrt
        //       in keinem Fall aus - die Korrektur betrifft nur SEF-Routing.
        // Nur auf Apache/LiteSpeed (unter nginx muss der Regelblock manuell
        // eingebunden werden), nur bei reinem Seitenaufruf (kein POST) und mit
        // Cooldown gegen Wiederholung bei dauerhaft fehlschlagendem Schreiben.
        $detectedPath = HtpConfig::detectSitePath();
        $pathDrift    = ($detectedPath !== (string) $cfg['site_path']);
        $shieldOurs   = ($data['status']['exists'] && $data['status']['ours']);

        if ($cfgReadOk // nie auf einer nur transient unlesbaren (Default-)Config selbst schreiben
            && !empty($cfg['autosecure'])
            && empty($cfg['htaccess_external']) // Hands-off: .htaccess extern -> nie selbst nachschreiben
            && $task === ''
            && !htp_nginx_mode($cfg)
            && !htp_iis_mode($cfg)
            && (!$shieldOurs || $pathDrift)
            && (time() - (int) (isset($cfg['autosecure_last']) ? $cfg['autosecure_last'] : 0)) >= 600) {
            if ($pathDrift) {
                $cfg['site_path'] = $detectedPath;
            }

            $cfg['autosecure_last'] = time();
            HtpConfig::save($cfg);

            self::writeShield($cfg, $messages, $data);

            // Status nach dem automatischen Schreiben aktualisieren
            $data['status']  = HtpStorage::status($cfg);
            $data['pending'] = false;
        }

        // ── Regelwerk automatisch aktuell halten (Schild-Selbstheilung) ──
        // Schild ist UNSERES + aktiv, aber HTProtect hat sein .htaccess-Regelwerk
        // verbessert -> $data['pending'] (= shieldOutdated, oben Z. 186) ist true.
        // Dann automatisch neu schreiben, damit der Admin nicht auf JEDER Seite manuell
        // "Jetzt absichern" klicken muss (Fleet-Verwaltung via YourSites o. ae.). Gleiche
        // Sicherung wie manuell: writeShield macht Selbsttest + Auto-Rollback. Hands-off
        // (htaccess_external) + nginx + Cooldown werden respektiert; laeuft NICHT parallel
        // zur Selbstabsicherung oben (die setzt pending bereits auf false).
        if ($cfgReadOk // nie auf einer nur transient unlesbaren (Default-)Config selbst schreiben
            && !empty($cfg['shield_autoheal'])
            && empty($cfg['htaccess_external'])
            && $task === ''
            && !htp_nginx_mode($cfg)
            && !htp_iis_mode($cfg)
            && !empty($data['pending'])
            && (time() - (int) (isset($cfg['shield_autoheal_last']) ? $cfg['shield_autoheal_last'] : 0)) >= 600) {
            $cfg['shield_autoheal_last'] = time();
            HtpConfig::save($cfg);

            self::writeShield($cfg, $messages, $data);

            // Baseline des Waechters auf den soeben von UNS geschriebenen Stand heben -
            // sonst meldet der naechste (headless) Waechter-Lauf die eigene Auto-Heilung
            // als Fremdaenderung (Fehlalarm-Mail). remember() -> nur $cfg, daher speichern.
            if (class_exists('HtpWatch')) {
                HtpWatch::remember($cfg);
                HtpConfig::save($cfg);
            }

            $data['status']  = HtpStorage::status($cfg);
            $data['pending'] = false;
        }

        if ($sec === 'shield') {
            $data['current'] = HtpStorage::read();
            $data['nginx']   = HtpNginx::build($cfg);
            $data['iis']     = class_exists('HtpIis') ? HtpIis::build($cfg) : '';
        }

        if ($sec === 'backups') {
            $data['backups'] = HtpStorage::backups();
            $data['helix_backups'] = class_exists('HtpHelix') ? HtpHelix::listBackups() : array();

            // Erweiterungs-Auto-Update teilt sich den Bereich "Backups & Updates"
            // (5.4+ ODER Joomla 3). Daten vom eigenen Modul (keine Logik hier).
            if (class_exists('HtpAutoUpdate') && HtpAutoUpdate::execEligible()) {
                // Selbstheilung: ist das Feature an, Task-Plugin + Scheduler-Zeile
                // idempotent sicherstellen (faengt Upgrades von der alten Detach-
                // Version ab, ohne dass der Nutzer neu speichern muss). NUR ab 5.4 -
                // auf J3 gibt es keinen com_scheduler (Anstoss laeuft ueber den Waechter).
                if (HtpAutoUpdate::menuEligible() && !empty($cfg['autoupdate_ext'])) {
                    HtpAutoUpdate::syncTaskPlugin($cfg);
                }

                $data['autoupdate'] = HtpAutoUpdate::viewData($cfg);
            }
        }

        if ($sec === 'scan') {
            // Seiten-Warteschlange fuer den schrittweisen Scan im Browser
            $data['scan_queue'] = HtpProbe::available() ? HtpProbe::queueUrls(30) : array();
        }

        if ($sec === 'overview' || $sec === 'guard') {
            self::enrichGuard($data);
            $data['watch']     = HtpWatch::check($cfg);
            $data['vuln']      = HtpDefs::checkExtensions();
            // Aufgeschobene (still gestellte) Warnungen von aktiven trennen - EINE Quelle
            // fuer Ampel/UI/Mail (HtpDefs::vulnPartition, fail-safe: alles ohne gueltiges
            // Ack ist AKTIV). Aufgeschobene verfallen automatisch bei Version-/Severity-Wechsel.
            $vpart             = HtpDefs::vulnPartition($data['vuln'], isset($cfg['vuln_ack']) ? $cfg['vuln_ack'] : array());
            $data['vuln_active'] = $vpart['active'];
            $data['vuln_acked']  = $vpart['acked'];
            $data['defs']      = HtpDefs::info();
            $data['dirshield'] = HtpDirShield::status($cfg);
            $data['plugin']    = htp_extension_enabled('plugin', 'htprotectwatch', 'system');
            $data['waf_active'] = !empty($cfg['x_shield']) && !empty($data['plugin']['enabled']);
            // EHRLICHKEIT: "mitigated" (HTProtect blockt den Angriff aktiv) ist eine Eigenschaft
            // der Luecke - aber nur REAL, wenn der schuetzende Schild AUCH LAEUFT. Ist die WAF
            // (x_shield + Plugin) ODER die Ordner-Haertung (u_harden) aus, blockt HTProtect gerade
            // NICHT -> eine aufgeschobene Warnung darf dann NICHT gruen ("aktiv geschuetzt")
            // erscheinen. Konservativ (im Zweifel gelb "Restrisiko"): Deckung nur anerkennen, wenn
            // beide Schilde live sind. Wirkt in Ampel UND Aufgeschoben-Liste (beide lesen mitigated).
            if (!empty($data['vuln_acked']) && (empty($data['waf_active']) || empty($cfg['u_harden']))) {
                foreach ($data['vuln_acked'] as $k => $h) {
                    $data['vuln_acked'][$k]['mitigated'] = false;
                }
            }
            $data['waf_log']   = self::readWafLog(20);
            // Aktive-Angriffs-Spuren (nur positive Funde): verdaechtige JCE-
            // Profile + boesartige .htaccess als Folge des JCE-/Upload-Hacks.
            $data['jce_profiles'] = HtpDefs::checkJceProfiles();
            $data['mal_htaccess'] = HtpStorage::scanMaliciousHtaccess();
            $data['helix_js']     = class_exists('HtpHelix') ? HtpHelix::suspiciousStyles(isset($cfg['helix_ignore']) ? $cfg['helix_ignore'] : array()) : array();
            // Helix-Ultimate-Mega-Menue-XSS (#__menu) - gleiche Tier-1-Erkennung wie helix_js.
            $data['menu_xss']     = class_exists('HtpHelix') ? HtpHelix::suspiciousMenu() : array();
            // SP-Page-Builder: eingeschleuste Assets + Rogue-Iconfonts (#__sppagebuilder_assets).
            $data['sppb']         = class_exists('HtpSppb') ? HtpSppb::scan() : array('injected' => array(), 'iconfont' => array());
            // Konten-Ueberwachung: neue Super-User / geaenderte Passwoerter
            // (baut beim ersten Aufruf still die Baseline auf, kein Alarm).
            $data['account_changes'] = class_exists('HtpAccounts') ? HtpAccounts::evaluate($cfg) : array();
            // Untergeschobene Super-Admins (SP-Page-Builder-Kampagnenmuster).
            $data['suspicious_admins'] = class_exists('HtpAccounts')
                ? HtpAccounts::suspiciousAdmins(isset($cfg['admin_whitelist']) ? $cfg['admin_whitelist'] : array())
                : array();
            // Gedrosselte SEO-/Defacement-Schnellpruefung (1h), UNABHAENGIG vom
            // 6h-Hintergrundwaechter - damit ein definitiver Fund (z. B. ein
            // eingeschleuster Script-Loader) sofort in der Uebersicht erscheint.
            $data['seo_finding'] = self::seoOverviewCheck($cfg);
            // Kerndatei-Anomalie (1h-Schnellpruefung): eingeschleuster Loader in
            // configuration.php/Template erscheint sofort in der Ampel, mit
            // Verweis auf den Malware-Scanner (Site-Scan) zur Bereinigung.
            $data['core_finding'] = self::coreOverviewCheck($cfg);
            // Schwarm-Schutz: geschlossene Rueckkopplung - wurde eine eigene Meldung
            // als Signatur ausgespielt? Banner bleibt stehen, bis es der Nutzer
            // per × bestaetigt (read-only -> kein Uebersehen).
            $data['swarm_thanks'] = class_exists('HtpSwarm') ? HtpSwarm::pendingThanks($cfg) : false;
            $data['ampel']     = self::ampel($cfg, $data);
            // JED-Bewertungs-Einladung: dezentes Overview-Banner, sobald HTProtect sich
            // bewaehrt hat (Ampel gruen + >= 7 Tage grün) und die aktuelle Einladungs-
            // Generation noch nicht weggeklickt wurde. NIE sofort bei Neuinstallation.
            $data['jed_review'] = self::jedReviewDue($cfg);

            // Fernverwaltungs-Einladung: sympathischer Overview-Hinweis auf htprotect.app (bis zu
            // 5 Seiten kostenlos zentral verwalten). SOFORT nach Install sichtbar, bis der Nutzer
            // ihn bewusst per × schliesst (dann dauerhaft weg, Generation verbucht).
            $data['fv_notice'] = self::fvNoticeDue($cfg);

            // Doppelkompression: Server komprimiert bereits selbst UND Joomlas eigene
            // Komprimierung ist zusaetzlich an -> reiner Hinweis mit Knopf. HTProtect
            // aendert die Joomla-Konfiguration NICHT mehr von sich aus.
            $data['gzip_notice'] = self::gzipNoticeDue($cfg);

            // (2.6.4) Sprachpaket-Hinweis: aktive Backend-Sprache ist weder de-* noch en-*, es gibt ein
            // passendes HTProtect-Sprachpaket und es ist noch nicht installiert/weggeklickt -> 1-Klick-Banner.
            $data['langpack'] = self::langPackNoticeDue($cfg);

            // Bedrohungs-Schnappschuss fuer die Dashboard-Kachel auffrischen,
            // solange der Admin ohnehin hinschaut (kein extra Scan).
            if (class_exists('HtpStatus')) {
                HtpStatus::writeSnapshot($cfg, $data);
            }
        }

        // Ist ein HTProtect-Selbst-Update verfuegbar? (billige Lese-Query auf
        // #__updates, von Joomlas Update-Check gefuellt) -> schmales Banner ganz oben.
        $data['htp_update'] = self::htpUpdateAvailable();

        HtpUi::page($sec, $cfg, $messages, $data);
    }

    /**
     * Verfuegbare HTProtect-Version aus #__updates (von Joomlas Update-Check gefuellt,
     * Update-Server aus dem Manifest). Liefert die Zielversion, wenn sie ECHT hoeher
     * als die installierte ist, sonst ''. Read-only, kein Netzwerk.
     *
     * @return string
     */
    private static function htpUpdateAvailable()
    {
        $db = function_exists('htp_db') ? htp_db() : null;

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

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName('version'))
                ->from($db->quoteName('#__updates'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_htprotect'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'));
            $db->setQuery($query, 0, 1);
            $avail = (string) $db->loadResult();
        } catch (Exception $e) {
            return '';
        } catch (Throwable $e) {
            return '';
        }

        if ($avail === '' || !preg_match('~^[0-9]+(?:\.[0-9]+){1,3}~', $avail)) {
            return '';
        }

        $installed = defined('HTP_VERSION') ? (string) HTP_VERSION : '';

        if ($installed !== '' && version_compare($avail, $installed, '<=')) {
            return ''; // nicht hoeher -> kein Banner
        }

        return $avail;
    }

    /**
     * Fernverwaltung: Pairing/Entkopplung ausfuehren (nur lokal, nie remote).
     * Gibt den anzuzeigenden Bereich zurueck ('connect'). Defense in Depth:
     * Super-User erneut pruefen (CSRF wurde bereits in run() geprueft).
     *
     * @return string
     */
    private static function dispatchLink($task, &$cfg, &$messages)
    {
        $u = function_exists('htp_user') ? htp_user() : null;

        if (!$u || !method_exists($u, 'authorise') || !$u->authorise('core.admin', 'com_htprotect')) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_ERR_TOKEN'));

            return 'connect';
        }

        if (!class_exists('HtpLink')) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_FV_UNAVAILABLE'));

            return 'connect';
        }

        try {
            if ($task === 'fv_unpair') {
                HtpLink::unpair($cfg);

                // Die IN-MEMORY-Config mitziehen: unpair() schreibt link_paired per saveKeys in die
                // DB, bekommt $cfg aber BY VALUE - unsere Kopie bliebe sonst veraltet. Ein spaeterer
                // HtpConfig::save($cfg) im selben Request wuerde den Wert dann wieder zurueckdrehen.
                $cfg['link_paired'] = 0;

                $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_FV_UNPAIRED'));

                return 'connect';
            }

            // Relay-Adresse aus der KONSTANTE, nicht mehr aus dem POST-Body (frueher 'fv_relay').
            // Sie ist unveraenderlich; sie durch den Browser zu schicken brachte nichts, loeste aber
            // bei mod_security-Hostern einen 406 aus und liess ausgerechnet den Wert, der ueber das
            // Vertrauensziel der Website entscheidet, von der Gegenseite bestimmen.
            $relay = HtpLink::RELAY;
            $code  = htp_cmd('fv_code', '');
            $r     = HtpLink::pair($relay, $code, $cfg);

            if (!empty($r['ok'])) {
                // WICHTIG (sonst verliert die Site ihre Kopplung wieder): pair() schreibt
                // link_paired=1 per saveKeys in die DB, erhaelt $cfg aber BY VALUE. Ohne dieses
                // Nachziehen traegt unsere Kopie weiter link_paired=0 - und der naechste
                // HtpConfig::save($cfg) im selben Request macht die Kopplung still rueckgaengig
                // (Symptom: "Verbindung verloren", waehrend htprotect_link.json paired:1 zeigt).
                $cfg['link_paired'] = 1;

                $msg = htp_t('COM_HTPROTECT_FV_PAIRED');

                if (!empty($r['ws_fpr'])) {
                    $msg .= ' &middot; ' . htp_esc($r['ws_fpr']);
                }

                $messages[] = self::msg('ok', $msg);
            } else {
                $err = isset($r['error']) ? (string) $r['error'] : 'error';
                $messages[] = self::msg('err', htp_t('COM_HTPROTECT_FV_PAIR_FAIL') . ' [' . htp_esc($err) . ']');
            }
        } catch (Exception $e) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_FV_PAIR_FAIL'));
        } catch (Throwable $e) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_FV_PAIR_FAIL'));
        }

        return 'connect';
    }

    /**
     * Die Zugriffs-Aktion je nach "Nur fuer Super-User" (su_only). Standard = an (Super-User-only,
     * 'core.admin'); ausgeschaltet -> auch normale Admins ('core.manage'). Ein Ort fuer alle Gates.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return string  'core.admin' | 'core.manage'
     */
    public static function accessAction($cfg)
    {
        return (!is_array($cfg) || !isset($cfg['su_only']) || !empty($cfg['su_only'])) ? 'core.admin' : 'core.manage';
    }

    /**
     * "Nur fuer Super-User" durchsetzen/aufheben: setzt bzw. entfernt am com_htprotect-Asset die
     * Regel core.manage = "Oeffentlich (Gruppe 1): Verboten". Das versteckt Komponenten-Menuepunkt +
     * Quickicon-Kachel und sperrt den Zugriff fuer ALLE Nicht-Super-User (die Deny vererbt sich von
     * Public an jede Gruppe; Super-User umgehen ACL via core.admin -> nie Selbst-Aussperren).
     * Idempotent, versionssicher (J2.5-6, reines #__assets), rein best-effort.
     *
     * @param   boolean  $enable  true = Super-User-only erzwingen, false = Regel entfernen
     *
     * @return boolean
     */
    public static function syncSuOnlyAcl($enable)
    {
        $db = function_exists('htp_db') ? htp_db() : null;

        if (!$db) {
            return false;
        }

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName(array('id', 'rules')))
                ->from($db->quoteName('#__assets'))
                ->where($db->quoteName('name') . ' = ' . $db->quote('com_htprotect'));
            $db->setQuery($q, 0, 1);
            $row = $db->loadObject();

            if (!$row) {
                return false; // Asset (noch) nicht vorhanden -> Install-Postflight holt es nach
            }

            $rules = json_decode((string) $row->rules, true);

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

            $manage = (isset($rules['core.manage']) && is_array($rules['core.manage'])) ? $rules['core.manage'] : array();

            if ($enable) {
                $manage['1'] = 0; // Public = Verboten
            } else {
                unset($manage['1']); // zurueck auf Vererbung
            }

            if (empty($manage)) {
                unset($rules['core.manage']);
            } else {
                $rules['core.manage'] = $manage;
            }

            $upd = $db->getQuery(true)
                ->update($db->quoteName('#__assets'))
                ->set($db->quoteName('rules') . ' = ' . $db->quote(json_encode($rules)))
                ->where($db->quoteName('id') . ' = ' . (int) $row->id);
            $db->setQuery($upd);
            $db->execute();

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

    /**
     * Normale Admin-User (Backend-Login, aber KEINE Super-User): Nutzer in Gruppen, die
     * core.login.admin am Root-Asset haben - EXPLIZIT oder per Gruppen-VERERBUNG -, ohne die
     * Super-User-Gruppen. Fuer die erweiterte Stealth-Auswahl bei ausgeschaltetem "Nur fuer
     * Super-User". WICHTIG: Auf Standard-Joomla ist nur "Manager" (6) explizit gesetzt;
     * "Administrator" (7) ist ein KIND davon und erbt das Recht - wird also mitgezaehlt.
     *
     * @return array  id => {id,name,username,block}
     */
    public static function adminUsers()
    {
        $out = array();
        $db  = function_exists('htp_db') ? htp_db() : null;

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

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName('rules'))
                ->from($db->quoteName('#__assets'))
                ->where($db->quoteName('id') . ' = 1');
            $db->setQuery($q);
            $rules = json_decode((string) $db->loadResult(), true);

            // Am Root explizit gesetzte core.login.admin-Regeln je Gruppe (Erlaubt/Verboten).
            $allow = array();
            $deny  = array();

            if (is_array($rules) && isset($rules['core.login.admin']) && is_array($rules['core.login.admin'])) {
                foreach ($rules['core.login.admin'] as $gid => $v) {
                    if ((int) $v === 1) {
                        $allow[(int) $gid] = true;
                    } elseif ((int) $v === 0) {
                        $deny[(int) $gid] = true;
                    }
                }
            }

            if (empty($allow)) {
                return $out;
            }

            // core.login.admin VERERBT sich im Gruppenbaum. Standard-Joomla setzt nur "Manager"
            // (6); "Administrator" (7) ist dessen Kind und erbt das Recht, steht aber NICHT in der
            // Regel. Ohne diese Aufloesung fehlten die normalen Administratoren komplett. Also den
            // Gruppenbaum laden und je Gruppe ueber die Ahnenkette aufloesen (naechste explizite
            // Regel gewinnt: ein Verbot naeher am Knoten schlaegt ein Erlaubt weiter oben).
            $q = $db->getQuery(true)
                ->select($db->quoteName(array('id', 'parent_id')))
                ->from($db->quoteName('#__usergroups'));
            $db->setQuery($q);
            $tree = array();

            foreach ((array) $db->loadAssocList() as $g) {
                $tree[(int) $g['id']] = (int) $g['parent_id'];
            }

            $login = array();

            foreach (array_keys($tree) as $gid) {
                $cur  = (int) $gid;
                $seen = array();

                while ($cur > 0 && !isset($seen[$cur])) {
                    $seen[$cur] = true;

                    if (isset($deny[$cur])) {
                        break; // explizites Verbot (naeher) gewinnt
                    }

                    if (isset($allow[$cur])) {
                        $login[] = (int) $gid;
                        break;
                    }

                    $cur = isset($tree[$cur]) ? $tree[$cur] : 0;
                }
            }

            $super = class_exists('HtpAccounts') ? array_map('intval', HtpAccounts::privilegedGroupIds()) : array(8);
            $admin = array_values(array_diff($login, $super));

            if (empty($admin)) {
                return $out;
            }

            $q = $db->getQuery(true)
                ->select('DISTINCT ' . $db->quoteName('u.id') . ', ' . $db->quoteName('u.name')
                    . ', ' . $db->quoteName('u.username') . ', ' . $db->quoteName('u.block'))
                ->from($db->quoteName('#__users', 'u'))
                ->join('INNER', $db->quoteName('#__user_usergroup_map', 'm')
                    . ' ON ' . $db->quoteName('m.user_id') . ' = ' . $db->quoteName('u.id'))
                ->where($db->quoteName('m.group_id') . ' IN (' . implode(',', array_map('intval', $admin)) . ')')
                ->order($db->quoteName('u.name') . ' ASC');
            $db->setQuery($q);
            $rows = $db->loadAssocList();

            if (is_array($rows)) {
                foreach ($rows as $r) {
                    $id = (int) $r['id'];
                    $out[$id] = array(
                        'id'       => $id,
                        'name'     => (string) $r['name'],
                        'username' => (string) $r['username'],
                        'block'    => (int) $r['block'],
                    );
                }
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return $out;
    }

    /**
     * Kandidaten fuer die Sichtbarkeits-Auswahl (Stealth). IMMER die Super-User; bei
     * ausgeschaltetem "Nur fuer Super-User" zusaetzlich alle normalen Admins (die es dann
     * ohnehin sehen/bedienen). Jeder Eintrag traegt 'super' (1|0) fuer die UI-Gruppierung;
     * Super-User haben Vorrang, falls ein Nutzer in beiden Rollen ist.
     *
     * @param   array|null  $cfg  Konfiguration (oder null -> laden)
     *
     * @return array  id => {id,name,username,block,super}
     */
    public static function stealthCandidates($cfg = null)
    {
        if ($cfg === null) {
            $cfg = HtpConfig::load();
        }

        $suOnly = !is_array($cfg) || !isset($cfg['su_only']) || !empty($cfg['su_only']);
        $out    = array();

        // Super-User immer zuerst (Vorrang bei Doppelrolle, fuehren die Liste an).
        foreach (self::superUsers() as $id => $s) {
            $s['super'] = 1;
            $out[$id]   = $s;
        }

        // Normale Admins nur, wenn "Nur fuer Super-User" AUS ist (dann sehen/bedienen sie es ohnehin).
        if (!$suOnly) {
            foreach (self::adminUsers() as $id => $a) {
                if (isset($out[$id])) {
                    continue; // bereits als Super-User gelistet -> nicht doppeln
                }

                $a['super'] = 0;
                $out[$id]   = $a;
            }
        }

        return $out;
    }

    /**
     * Alle Super-User der Site (id => {id,name,username,block}). Fuer die Stealth-
     * Auswahlliste. Nutzt die robusten Privileg-Gruppen aus HtpAccounts.
     *
     * @return array
     */
    public static function superUsers()
    {
        $out = array();
        $db  = function_exists('htp_db') ? htp_db() : null;

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

        try {
            $gids = class_exists('HtpAccounts') ? HtpAccounts::privilegedGroupIds() : array(8);
            $gids = array_values(array_unique(array_map('intval', is_array($gids) ? $gids : array(8))));

            if (empty($gids)) {
                $gids = array(8);
            }

            $q = $db->getQuery(true)
                ->select('DISTINCT ' . $db->quoteName('u.id') . ', ' . $db->quoteName('u.name')
                    . ', ' . $db->quoteName('u.username') . ', ' . $db->quoteName('u.block'))
                ->from($db->quoteName('#__users', 'u'))
                ->join('INNER', $db->quoteName('#__user_usergroup_map', 'm')
                    . ' ON ' . $db->quoteName('m.user_id') . ' = ' . $db->quoteName('u.id'))
                ->where($db->quoteName('m.group_id') . ' IN (' . implode(',', $gids) . ')')
                ->order($db->quoteName('u.name') . ' ASC');
            $db->setQuery($q);
            $rows = $db->loadAssocList();

            if (is_array($rows)) {
                foreach ($rows as $r) {
                    $id = (int) $r['id'];
                    $out[$id] = array(
                        'id'       => $id,
                        'name'     => (string) $r['name'],
                        'username' => (string) $r['username'],
                        'block'    => (int) $r['block'],
                    );
                }
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return $out;
    }

    /**
     * Stealth speichern: die abgewaehlten (nicht sichtbaren) Super-User in hidden_su
     * schreiben. Der EIGENE Super-User wird NIE aufgenommen (keine Selbst-Aussperrung) -
     * seine Checkbox ist im Formular disabled und wird daher gar nicht uebermittelt.
     *
     * @return string
     */
    private static function taskStealthSave(&$cfg, &$messages)
    {
        $u = function_exists('htp_user') ? htp_user() : null;

        // Defense in Depth: die Verbergen-Liste bleibt super-user-only - auch bei su_only=AUS,
        // wo normale Admins HTProtect zwar bedienen, aber weder den Sichtbarkeits-Schalter noch
        // diese Liste aendern duerfen (sonst koennte ein Admin Super-User aus der HTProtect-UI
        // aussperren). Gleicher Schutz wie taskSuOnlySave / dispatchLink; das UI-Panel wird fuer
        // Nicht-Super ohnehin nicht gerendert, aber der POST-Endpunkt braucht die Pruefung selbst.
        if (!$u || !method_exists($u, 'authorise') || !$u->authorise('core.admin', 'com_htprotect')) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_ERR_TOKEN'));

            return 'guard';
        }

        try {
            $meId = ($u && isset($u->id)) ? (int) $u->id : 0;

            $vis    = htp_raw('stealth_vis', array(), 'post');
            $visIds = array();

            if (is_array($vis)) {
                foreach ($vis as $v) {
                    $visIds[] = (int) $v;
                }
            }

            $hidden = array();

            foreach (array_keys(self::stealthCandidates($cfg)) as $id) {
                $id = (int) $id;

                if ($id === $meId) {
                    continue; // NIE das eigene Konto verbergen
                }

                if (!in_array($id, $visIds, true)) {
                    $hidden[] = $id;
                }
            }

            HtpConfig::saveKeys(array('hidden_su' => $hidden));
            $cfg['hidden_su'] = $hidden;
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_STEALTH_SAVED'));
        } catch (Exception $e) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_STEALTH_SAVE_FAIL'));
        } catch (Throwable $e) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_STEALTH_SAVE_FAIL'));
        }

        return 'guard';
    }

    /**
     * "Nur fuer Super-User" umschalten (su_only). NUR Super-User duerfen diesen Schalter bedienen -
     * er bleibt selbst dann super-user-only, wenn er gerade AUS ist (sonst koennte ein normaler
     * Admin sich die Komponente dauerhaft sichern oder die Sperre eigenmaechtig wieder aktivieren).
     * Neben dem Config-Wert wird die zugehoerige ACL am com_htprotect-Asset idempotent nachgezogen,
     * damit Menuepunkt + Kachel + Zugriff deckungsgleich folgen.
     *
     * @return string
     */
    private static function taskSuOnlySave(&$cfg, &$messages)
    {
        $u = function_exists('htp_user') ? htp_user() : null;

        if (!$u || !method_exists($u, 'authorise') || !$u->authorise('core.admin', 'com_htprotect')) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_ERR_TOKEN'));

            return 'guard';
        }

        try {
            $enable = htp_has('su_only', 'post') ? 1 : 0;

            HtpConfig::saveKeys(array('su_only' => $enable));
            $cfg['su_only'] = $enable;

            // ACL deckungsgleich nachziehen (Menue/Kachel/Zugriff). Best-effort: schlaegt es fehl,
            // bleibt wenigstens der Config-Wert stehen - beim naechsten Speichern/Update wird die
            // ACL erneut synchronisiert (script.php-Postflight ruft syncSuOnlyAcl ebenfalls).
            self::syncSuOnlyAcl($enable === 1);

            $messages[] = self::msg('ok', htp_t($enable ? 'COM_HTPROTECT_SUONLY_SAVED_ON' : 'COM_HTPROTECT_SUONLY_SAVED_OFF'));
        } catch (Exception $e) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_STEALTH_SAVE_FAIL'));
        } catch (Throwable $e) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_STEALTH_SAVE_FAIL'));
        }

        return 'guard';
    }

    /**
     * POST-Aktion ausfuehren.
     *
     * @param   string  $task      Aktionsname
     * @param   array   $cfg      Konfiguration (Referenz)
     * @param   string  $sec      Aktiver Bereich (Referenz)
     * @param   array   $messages Meldungen (Referenz)
     * @param   array   $data     Anzeigedaten (Referenz)
     *
     * @return void
     */
    private static function dispatch($task, &$cfg, &$sec, &$messages, &$data)
    {
        // Erweiterungs-Auto-Update: eigenstaendiges Modul mit eigenem Dispatch
        // (Super-User + CSRF werden dort erneut geprueft). Ein Block, keine Logik
        // hier - der Bereich "Backups & Updates" beherbergt das Feature.
        if (strpos($task, 'autoupdate_') === 0) {
            $sec = HtpAutoUpdate::dispatchTask($task, $cfg, $messages, $data);

            return;
        }

        // Fernverwaltung (versteckter Einstieg): Pairing/Entkopplung. AUSSCHLIESSLICH
        // lokal (nie ueber die Command-Queue). Super-User + CSRF werden in dispatchLink
        // erneut geprueft (Defense in Depth).
        if ($task === 'fv_pair' || $task === 'fv_unpair') {
            $sec = self::dispatchLink($task, $cfg, $messages);

            return;
        }

        // WAF-sichere Datei-Pfade (Firewall-Umgehung). Manche Hoster (z. B. Alfahosting) setzen eine
        // Server-Firewall/mod_security ein, die den POST-BODY inspiziert. Der Schaddatei-Pfad im Klartext
        // (htp_path=…) matcht dort ein Angriffsmuster -> die legitime Loesch-/Whitelist-/Ansicht-Aktion
        // wird mit 403 geblockt. Der Browser sendet Pfade daher HEX-kodiert (htp_pathx/htp_pathsx) - reines
        // Hex ist fuer die Firewall unauffaellig. Hier EINMALIG zentral zurueckdekodieren und als
        // htp_path/htp_paths in $_POST bereitstellen (htp_post liest $_POST per Referenz, s. compat.php),
        // sodass alle Task-Handler unveraendert weiterlesen. Fallback auf ein evtl. vorhandenes Klartext-
        // Feld (aeltere, zwischengespeicherte UI) bleibt bestehen. Reine Transport-Kodierung - die
        // eigentliche Sicherheitspruefung (deleteMalwareFile: nur echte Malware, kein Ausbruch) bleibt.
        if (htp_has('htp_pathx', 'post')) {
            $htpDp = self::hexToPath(htp_post('htp_pathx', ''));
            if ($htpDp !== null) {
                $_POST['htp_path'] = $htpDp;
            }
        }
        if (htp_has('htp_pathsx', 'post')) {
            $htpDp = self::hexToPath(htp_post('htp_pathsx', ''));
            if ($htpDp !== null) {
                $_POST['htp_paths'] = $htpDp;
            }
        }

        switch ($task) {
            case 'guard_save':
                $sec = self::taskGuardSave($cfg, $messages);
                break;

            case 'guard_string_save':
                $sec = self::taskGuardStringSave($cfg, $messages);
                break;

            case 'stealth_save':
                $sec = self::taskStealthSave($cfg, $messages);
                break;

            case 'su_only_save':
                $sec = self::taskSuOnlySave($cfg, $messages);
                break;

            case 'guard_remove':
                $sec    = 'guard';
                $result = HtpGuard::remove();
                $messages[] = self::msg($result['success'] ? 'ok' : 'err', $result['message']);

                if ($result['success'] && class_exists('HtpWatch')) {
                    HtpWatch::remember($cfg);
                    HtpConfig::save($cfg);
                }
                break;

            case 'guard_user_add':
                // Weiteren Backend-Zugang anlegen. Kein HtpWatch::remember/HtpConfig::save:
                // der Waechter baselined nur die .htaccess, hier aendert sich ausschliesslich
                // die .htpasswd -> kein Fehlalarm, kein Config-Schluessel betroffen. Der
                // Eigen-Datei-Recheck steckt in addUser() (Defense-in-Depth gegen gefaelschte POSTs).
                $sec = 'guard';

                if (htp_post('htp_guard_pass', '') !== htp_post('htp_guard_pass2', '')) {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_GUARD_PW_MISMATCH'));
                    break;
                }

                $result = HtpGuard::addUser(htp_post('htp_guard_user', ''), htp_post('htp_guard_pass', ''));
                $messages[] = self::msg($result['success'] ? 'ok' : 'err', $result['message']);

                if (!empty($result['warnings'])) {
                    foreach ($result['warnings'] as $w) {
                        $messages[] = self::msg('warn', $w);
                    }
                }
                break;

            case 'guard_user_remove':
                $sec        = 'guard';
                $result     = HtpGuard::removeUser(htp_post('htp_guard_ruser', ''));
                $messages[] = self::msg($result['success'] ? 'ok' : 'err', $result['message']);
                break;

            case 'uninstall_go':
                // Bereinigt (falls gewaehlt) + deinstalliert die Komponente; leitet
                // danach weiter (Exit). $sec ist nur ein nie erreichter Fallback.
                self::taskUninstall($cfg);
                $sec = 'help';
                break;

            case 'shield_save':
                $sec = 'shield';
                self::taskShieldSave($cfg, $messages, $data);
                break;

            case 'import_custom':
                $sec = 'shield';
                self::taskImportCustom($cfg, $messages, $data);
                break;

            case 'quick_secure':
                $sec = 'overview';
                self::taskQuickSecure($cfg, $messages, $data);
                break;

            case 'quick_adopt':
                $sec = 'overview';
                self::taskQuickAdopt($cfg, $messages, $data);
                break;

            case 'delete_mal_htaccess':
                $sec = 'overview';
                $res = HtpStorage::deleteMaliciousHtaccess();
                if ($res['deleted'] > 0) {
                    $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_MALHT_DELETED', (int) $res['deleted']));
                }
                if ($res['failed'] > 0) {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_MALHT_FAILED', (int) $res['failed']));
                }
                if ($res['deleted'] === 0 && $res['failed'] === 0) {
                    $messages[] = self::msg('info', htp_t('COM_HTPROTECT_MALHT_NONE'));
                }
                break;

            case 'malscan':
                // AJAX: Tiefen-Scan nach Schaddateien (beendet die Anfrage selbst)
                self::taskMalScan();
                break;

            case 'delete_malfile':
                // AJAX: eine verifizierte Schaddatei loeschen
                self::taskMalDeleteOne();
                break;

            case 'view_malfile':
                // AJAX: vollen Inhalt einer erkannten Schaddatei anzeigen
                self::taskMalView();
                break;

            case 'save_malfile':
                // AJAX: gesaeuberten Inhalt zurueckschreiben (Backup + Neubewertung)
                self::taskMalSave();
                break;

            case 'save_malchunk':
                // AJAX: einen Inhalts-Block (Datei-Upload-Part) eines stueckweisen
                // Speichervorgangs anhaengen (grosse Dateien um post_max_size/ModSec)
                self::taskMalSaveChunk();
                break;

            case 'commit_malfile':
                // AJAX: stueckweise hochgeladenen Inhalt festschreiben (gleiches Gate)
                self::taskMalCommit();
                break;

            case 'delete_malfiles_all':
                // AJAX: alle gefundenen Schaddateien loeschen
                self::taskMalDeleteAll();
                break;

            case 'delete_malfiles_selected':
                // AJAX: nur die markierten (jeweils verifizierten) Schaddateien
                self::taskMalDeleteSelected();
                break;

            case 'quar_scan':
                // AJAX: eine vom Hoster gesperrte Datei (chmod 000) kurz entsperr-scannen (einordnen)
                self::taskQuarScan();
                break;

            case 'quar_delete':
                // AJAX: eine gesperrte Datei loeschen (Standard nicht blind - Fehlalarm-Schutz)
                self::taskQuarDelete();
                break;

            case 'quar_restore':
                // AJAX: Rechte einer faelschlich gesperrten Datei wiederherstellen
                self::taskQuarRestore();
                break;

            case 'mal_wl_add':
                // AJAX: Pfad dauerhaft auf die Schaddatei-Whitelist setzen
                self::taskMalWhitelistAdd($cfg);
                break;

            case 'mal_wl_remove':
                // AJAX: Pfad von der Schaddatei-Whitelist entfernen
                self::taskMalWhitelistRemove($cfg);
                break;

            case 'corescan':
                // AJAX: Kerndatei-Abgleich (resumbare Phasen-Maschine, beendet die Anfrage selbst)
                self::taskCoreScan();
                break;

            case 'corescan_restore':
                // AJAX: Kerndatei(en) aus dem Original-Paket wiederherstellen
                self::taskCoreRestore();
                break;

            case 'corescan_diff':
                // AJAX: Zeilen-Diff einer geaenderten Kerndatei gegen das Original
                self::taskCoreDiff();
                break;

            case 'corescan_ignore':
                // AJAX: geaenderte Kerndatei(en) als bewusst akzeptiert markieren (checksum-gebunden,
                // z. B. vom Webhoster eingespielte Fremd-Patches wie Patchman)
                self::taskCoreIgnore();
                break;

            case 'corescan_unignore':
                // AJAX: Bestaetigung zuruecknehmen -> Datei wird wieder als Abweichung gefuehrt
                self::taskCoreUnignore();
                break;

            case 'tmp_clean':
                // tmp/ jetzt aufraeumen + Automatik-Schalter speichern
                $sec = 'scan';
                self::taskTmpClean($cfg, $messages, $data);
                break;

            case 'seo_save':
                // Defacement-/SEO-Waechter: Schalter + Whitelist speichern
                $sec = 'scan';
                self::taskSeoSave($cfg, $messages);
                break;

            case 'seo_scan':
                // Defacement-/SEO-Waechter: jetzt pruefen (speichert + laeuft)
                $sec = 'scan';
                self::taskSeoSave($cfg, $messages);
                self::taskSeoScan($cfg, $messages, $data);
                break;

            case 'swarm_fp':
                // Schwarm-Schutz: Fehlalarme (Nutzer-Whitelist) an das Portal melden
                $sec = 'scan';
                self::taskSwarmReport($cfg, $messages, 'fp');
                break;

            case 'swarm_fn':
                // Schwarm-Schutz: uebersehene Schaddatei(en) per Pfad melden
                $sec = 'scan';
                self::taskSwarmReport($cfg, $messages, 'fn');
                break;

            case 'swarm_thanks_dismiss':
                // Schwarm-Dank-Banner per × geschlossen -> Feed-Version verbuchen
                $sec = 'overview';
                if (class_exists('HtpSwarm')) {
                    HtpSwarm::dismissThanks($cfg);
                }
                break;

            case 'jed_review_dismiss':
                // JED-Bewertungs-Banner per × geschlossen -> aktuelle Einladungs-Generation
                // verbuchen (erscheint erst wieder, wenn eine kuenftige Version die Generation
                // bewusst hochsetzt). Kein Nag, keine Wiederholung.
                $sec = 'overview';
                $cfg['jed_review_ack'] = self::JED_REVIEW_GEN;
                HtpConfig::save($cfg);
                break;

            case 'fv_notice_dismiss':
                // Fernverwaltungs-Hinweis per × geschlossen -> aktuelle Generation verbuchen. Er
                // erscheint erst wieder, wenn eine kuenftige Version FV_NOTICE_GEN bewusst
                // hochsetzt (EINE neue Einladung) - kein Nag, keine Wiederholung pro Release.
                $sec = 'overview';
                $cfg['fv_notice_ack'] = self::FV_NOTICE_GEN;
                HtpConfig::save($cfg);
                break;

            case 'langpack_notice_dismiss':
                // (2.6.4) Sprachpaket-Hinweis per × geschlossen -> genau DIESE Sprache verbuchen (per-Tag).
                // Ein spaeterer Wechsel auf eine ANDERE nicht abgedeckte Sprache bietet erneut an.
                $sec = 'overview';
                $lpTag = self::langPackActiveTag();

                if ($lpTag !== '') {
                    $cfg['langpack_notice_ack'] = $lpTag;
                    HtpConfig::save($cfg);
                }
                break;

            case 'langpack_install':
                // (2.6.4) 1-Klick-Install des passenden Sprachpakets vom separaten Update-Server.
                // Fail-closed (Katalog Ed25519, ZIP SHA-256, verifyPackage MUSS 'ok').
                $sec = 'overview';
                $lpRes = self::langPackInstall();

                if (!empty($lpRes[0])) {
                    $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_LANGPACK_OK', htp_esc((string) $lpRes[1])));
                } else {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_LANGPACK_FAIL', htp_esc((string) $lpRes[1])));
                }
                break;

            case 'gzip_notice_off':
                // Betreiber hat den Knopf im Hinweis-Banner gedrueckt -> JETZT (und nur
                // jetzt) darf Joomlas eigene Komprimierung in der configuration.php
                // abgeschaltet werden. Aenderung: nie mehr automatisch, immer auf
                // ausdrueckliche Aktion hin - mit klarer Rueckmeldung.
                $sec = 'overview';

                if (class_exists('HtpCompat') && HtpCompat::setJoomlaGzipOff()) {
                    // Die bereits geladene Konfiguration kennt den alten Wert noch -
                    // nachziehen, damit der Hinweis SOFORT verschwindet (nicht erst
                    // beim naechsten Aufruf).
                    $gzApp = function_exists('htp_app') ? htp_app() : null;

                    if (is_object($gzApp) && method_exists($gzApp, 'set')) {
                        $gzApp->set('gzip', 0);
                    }

                    $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_GZIPNOTICE_DONE'));
                } else {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_GZIPNOTICE_FAILED'));
                }
                break;

            case 'gzip_notice_dismiss':
                // Hinweis per × geschlossen -> dauerhaft still. Fuer Betreiber, die die
                // doppelte Komprimierung bewusst so wollen. Kein Nag, keine Wiederholung.
                $sec = 'overview';
                $cfg['gzip_notice_ack'] = 1;
                HtpConfig::save($cfg);
                break;

            case 'guard_dismiss':
                // Backend-Zugangsschutz bewusst uebersprungen (X auf der Schritt-1-Karte):
                // Hero verschwindet, Ampel-Zeile wird gruen statt rot. Jederzeit unter
                // "Backend-Zugang" nachruestbar (das setzt guard_dismissed wieder zurueck).
                $sec = 'overview';
                $cfg['guard_dismissed'] = 1;
                HtpConfig::save($cfg);
                $messages[] = self::msg('info', htp_t('COM_HTPROTECT_GUARD_SKIPPED_MSG'));
                break;

            case 'delete_jce_profiles':
                $sec = 'overview';
                $jres = HtpDefs::deleteSuspiciousJceProfiles();
                if ($jres['deleted'] > 0) {
                    $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_JCE_DELETED', (int) $jres['deleted']));
                }
                if ($jres['failed'] > 0) {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_JCE_FAILED', (int) $jres['failed']));
                }
                if ($jres['deleted'] === 0 && $jres['failed'] === 0) {
                    $messages[] = self::msg('info', htp_t('COM_HTPROTECT_JCE_NONE'));
                }
                break;

            case 'helix_wipe':
                $sec  = 'overview';
                $hHard = (int) htp_post('htp_helix_hard', 0) === 1;
                $hIds  = array();
                if (class_exists('HtpHelix')) {
                    // Ignorierte (als eigenes Skript markierte) Stile bleiben ausgeklammert.
                    foreach (HtpHelix::suspiciousStyles(isset($cfg['helix_ignore']) ? $cfg['helix_ignore'] : array()) as $hs) {
                        if ((int) $hs['id'] > 0) {
                            $hIds[] = (int) $hs['id'];
                        }
                    }
                    $hres = HtpHelix::wipe($hIds, $hHard);
                } else {
                    $hres = array('cleaned' => 0, 'failed' => 0);
                }
                if ($hres['cleaned'] > 0) {
                    $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_HELIX_DONE', (int) $hres['cleaned']));
                    $messages[] = self::msg('info', htp_t('COM_HTPROTECT_HELIX_DONE_NOTE'));
                }
                if (!empty($hres['failed'])) {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_HELIX_FAILED', (int) $hres['failed']));
                }
                if (empty($hres['cleaned']) && empty($hres['failed'])) {
                    $messages[] = self::msg('info', htp_t('COM_HTPROTECT_HELIX_NONE'));
                }
                break;

            case 'helix_ignore':
                // Admin markiert ein selbst eingefuegtes Skript als unbedenklich
                // (Allowlist per Inhalts-Signatur; aendert sich der Inhalt -> Alarm zurueck).
                $sec = 'overview';
                self::taskHelixIgnore($cfg, $messages);
                break;

            case 'helix_restore':
                $sec = 'backups';
                if (class_exists('HtpHelix') && HtpHelix::restoreParams(htp_post('htp_helix_backup', ''))) {
                    $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_HELIX_RESTORED'));
                } else {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_HELIX_RESTORE_FAIL'));
                }
                break;

            case 'menu_wipe':
                // Helix-Ultimate-Mega-Menue-XSS bereinigen: JS aus #__menu.params strippen
                // (reversibel gesichert). Nur AKTUELL verdaechtige Eintraege - der Server
                // stellt die Liste selbst zusammen (kein Vertrauen in POST-IDs).
                $sec  = 'overview';
                $mIds = array();
                if (class_exists('HtpHelix')) {
                    foreach (HtpHelix::suspiciousMenu() as $ms) {
                        if ((int) $ms['id'] > 0) {
                            $mIds[] = (int) $ms['id'];
                        }
                    }
                    $mres = HtpHelix::wipeMenu($mIds, (int) htp_post('htp_menu_hard', 0) === 1);
                } else {
                    $mres = array('cleaned' => 0, 'failed' => 0);
                }
                if (!empty($mres['cleaned'])) {
                    $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_MENU_DONE', (int) $mres['cleaned']));
                    $messages[] = self::msg('info', htp_t('COM_HTPROTECT_MENU_DONE_NOTE'));
                }
                if (!empty($mres['failed'])) {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_MENU_FAILED', (int) $mres['failed']));
                }
                if (empty($mres['cleaned']) && empty($mres['failed'])) {
                    $messages[] = self::msg('info', htp_t('COM_HTPROTECT_MENU_NONE'));
                }
                break;

            case 'sppb_clean':
                // SP-Page-Builder: eingeschleuste Asset-Zeilen + Rogue-Iconfonts entfernen
                // (reversibel gesichert). Server stellt die Liste frisch zusammen; HtpSppb::clean
                // prueft jede Zeile nochmals -> nie eine legitime Zeile loeschen.
                $sec  = 'overview';
                $sIds = array();
                if (class_exists('HtpSppb')) {
                    $sScan = HtpSppb::scan();
                    foreach (array_merge($sScan['injected'], $sScan['iconfont']) as $sf) {
                        if ((int) $sf['id'] > 0) {
                            $sIds[] = (int) $sf['id'];
                        }
                    }
                    $sres = HtpSppb::clean($sIds);
                } else {
                    $sres = array('deleted' => 0, 'failed' => 0);
                }
                if (!empty($sres['deleted'])) {
                    $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_SPPB_DONE', (int) $sres['deleted']));
                }
                if (!empty($sres['failed'])) {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_SPPB_FAILED', (int) $sres['failed']));
                }
                if (empty($sres['deleted']) && empty($sres['failed'])) {
                    $messages[] = self::msg('info', htp_t('COM_HTPROTECT_SPPB_NONE'));
                }
                break;

            case 'account_accept':
                $sec = 'overview';
                if (class_exists('HtpAccounts')) {
                    HtpAccounts::accept($cfg, htp_post('htp_id', ''));
                    HtpConfig::save($cfg);
                    $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_ACCOUNTS_ACCEPTED'));
                }
                break;

            case 'account_accept_all':
                $sec = 'overview';
                if (class_exists('HtpAccounts')) {
                    HtpAccounts::remember($cfg);
                    HtpConfig::save($cfg);
                    $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_ACCOUNTS_ACCEPTED_ALL'));
                }
                break;

            case 'admin_approve':
                $sec = 'overview';
                if (class_exists('HtpAccounts')) {
                    HtpAccounts::approveAdmin($cfg, htp_post('htp_id', ''));
                    HtpConfig::save($cfg);
                    $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_ROGUEADMIN_APPROVED'));
                }
                break;

            case 'dirshield_apply':
                $sec = 'shield';
                self::taskDirShield($cfg, $messages, true);
                break;

            case 'dirshield_remove':
                $sec = 'shield';
                self::taskDirShield($cfg, $messages, false);
                break;

            case 'defs_update':
                // Im Scan-Bereich bleiben, wenn der Aktualisieren-Button dort
                // geklickt wurde (Formular postet mit sec=scan); sonst Uebersicht.
                $sec = ($sec === 'scan') ? 'scan' : 'overview';
                self::taskDefsUpdate($cfg, $messages, $data);
                break;

            case 'selftest':
                $sec = 'overview';
                self::taskSelfTest($cfg, $messages, $data);
                break;

            case 'watch_save':
                $sec = 'overview';
                self::taskWatchSave($cfg, $messages);
                break;

            case 'watch_test_mail':
                $sec = 'overview';
                self::taskWatchTestMail($cfg, $messages);
                break;

            case 'watch_enable':
                $sec = 'overview';
                self::taskWatchToggle($cfg, $messages, true);
                break;

            case 'watch_disable':
                $sec = 'overview';
                self::taskWatchToggle($cfg, $messages, false);
                break;

            case 'watch_restore':
                $sec = 'overview';
                self::taskWatchRestore($cfg, $messages);
                break;

            case 'watch_dismiss':
                $sec = 'overview';
                self::taskWatchDismiss($cfg, $messages);
                break;

            case 'au_self_on':
                // 1-Klick auf der Uebersicht: HTProtect selbst automatisch aktuell halten
                // (Master + Selbst-Sofort, ohne Karenz). Ruehrt keine anderen Optionen an.
                $sec = 'overview';
                self::taskAuSelfOn($cfg, $messages);
                break;

            case 'au_self_off':
                // Gegenstueck: Selbst-Sofort-Modus wieder abschalten (Master/Sicherheits-
                // updates bleiben unberuehrt).
                $sec = 'overview';
                self::taskAuSelfOff($cfg, $messages);
                break;

            case 'vuln_ack':
                // Angreifbare Erweiterung, die nicht aktualisiert werden kann/soll,
                // aufschieben: Mail-Erinnerung still, Ampel nicht rot. Verfaellt automatisch
                // bei geaenderter Version/eskalierter Severity.
                $sec = 'overview';
                self::taskVulnAck($cfg, $messages);
                break;

            case 'vuln_unack':
                // Aufgeschobene Warnung wieder aktivieren.
                $sec = 'overview';
                self::taskVulnUnack($cfg, $messages);
                break;

            case 'waf_clear':
                $sec = 'overview';
                self::clearWafLog();
                $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_MSG_WAFLOG_CLEARED'));
                break;

            case 'support_send':
                $sec = 'help';
                self::taskSupportSend($cfg, $messages, $data);
                break;

            case 'emergency_on':
                $sec = 'guard';
                self::taskEmergency($cfg, $messages, $data, true);
                break;

            case 'emergency_off':
                $sec = 'guard';
                self::taskEmergency($cfg, $messages, $data, false);
                break;

            case 'emergency_rekey':
                $sec = 'guard';
                self::taskEmergencyRekey($cfg, $messages, $data);
                break;

            case 'emergency_page_save':
                $sec = 'guard';
                self::taskEmergencyPageSave($cfg, $messages, $data);
                break;

            case 'nginx_write':
                $sec     = 'shield';
                $content = HtpNginx::build($cfg);
                $target  = htp_docroot() . '/htprotect-nginx.conf';

                if ($content === '') {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_NGINX_ERR_BUILD'));
                } elseif (@file_put_contents($target, $content, LOCK_EX) === false) {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_NGINX_ERR_WRITE'));
                } else {
                    @chmod($target, 0644);
                    $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_NGINX_OK_WRITE'));
                }
                break;

            case 'nginx_test':
                $sec = 'shield';

                if (!HtpProbe::available()) {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_NGINX_ERR_NOHTTP'));
                } else {
                    $data['nginx_test'] = HtpProbe::selfTest($cfg, HtpProbe::baseline());
                    $messages[] = self::msg('info', htp_t('COM_HTPROTECT_NGINX_TEST_DONE'));
                }
                break;

            case 'iis_write':
                $sec     = 'shield';
                $content = class_exists('HtpIis') ? HtpIis::build($cfg) : '';
                // BEWUSST NICHT nach web.config schreiben - die wuerde IIS SOFORT lesen und bei einem
                // Fehler die ganze Site 500en. Stattdessen eine INAKTIVE Datei ablegen, die der Betreiber
                // prueft und selbst nach web.config umbenennt/platziert (idealerweise erst auf Staging).
                $target  = htp_docroot() . '/htprotect-web.config.txt';

                if ($content === '') {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_IIS_ERR_BUILD'));
                } elseif (@file_put_contents($target, $content, LOCK_EX) === false) {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_IIS_ERR_WRITE'));
                } else {
                    @chmod($target, 0644);
                    $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_IIS_OK_WRITE'));
                }
                break;

            case 'scan_step':
                // AJAX: eine einzelne Seite abrufen (beendet die Anfrage selbst)
                self::taskScanStep($cfg);
                break;

            case 'scan_run':
                $sec   = 'scan';
                $found = htp_post('htp_found', '');

                if ($found !== '') {
                    // Vom Browser schrittweise eingesammelte Funde auswerten
                    $data['scan'] = self::scanFromCollected($cfg, $found);
                } else {
                    // Fallback ohne JavaScript: synchron crawlen
                    $pages = (int) htp_post('htp_scan_pages', '12');
                    $pages = max(3, min(30, $pages));
                    $data['scan'] = HtpProbe::crawl($cfg, $pages);
                }
                break;

            case 'scan_apply':
                $sec = 'scan';
                self::taskScanApply($cfg, $messages);
                break;

            case 'allow_add':
                $sec = 'scan';
                self::taskAllowAdd($cfg, $messages);
                break;

            case 'url_test':
            case 'path_test': // Rueckwaertskompatibler Alias
                $sec = 'scan';
                self::taskUrlTest($cfg, $data);
                break;

            case 'backup_view':
                $sec  = 'backups';
                $name = htp_post('htp_backup', '');
                $content = HtpStorage::readBackup($name);

                if ($content === null) {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_BACKUP_NOTFOUND'));
                } else {
                    $data['backup_view'] = array('name' => $name, 'content' => $content);
                }
                break;

            case 'backup_restore':
                $sec  = 'backups';
                $name = htp_post('htp_backup', '');

                if (HtpStorage::restore($name)) {
                    $restored = HtpStorage::read();

                    if (strpos($restored, '# htprotect-shield:begin') !== false) {
                        $cfg['file_sha1'] = sha1($restored);
                    } else {
                        $cfg['file_sha1'] = '';
                    }

                    if (class_exists('HtpWatch')) {
                        HtpWatch::remember($cfg);
                    }

                    HtpConfig::save($cfg);
                    $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_BACKUP_RESTORED', htp_esc($name)));
                } else {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_BACKUP_RESTORE_FAIL'));
                }
                break;

            case 'backup_delete':
                $sec  = 'backups';
                $name = htp_post('htp_backup', '');

                if (HtpStorage::deleteBackup($name)) {
                    $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_BACKUP_DELETED', htp_esc($name)));
                } else {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_BACKUP_DELETE_FAIL'));
                }
                break;

            default:
                $messages[] = self::msg('err', htp_t('COM_HTPROTECT_ERR_UNKNOWN_ACTION'));
                break;
        }
    }

    // ─────────────────────────────────────────────────────────────────
    //  Aktionen
    // ─────────────────────────────────────────────────────────────────

    private static function taskGuardSave(&$cfg, &$messages)
    {
        $username = htp_post('htp_guard_user', '');
        $password = htp_post('htp_guard_pass', '');
        $confirm  = htp_post('htp_guard_pass2', '');
        $manual   = htp_post('htp_guard_dir', '');

        if ($password !== $confirm) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_GUARD_PW_MISMATCH'));

            return 'guard';
        }

        $stored = isset($cfg['guard_dir']) ? (string) $cfg['guard_dir'] : '';
        $result = HtpGuard::setup($username, $password, trim($manual) !== '' ? $manual : $stored);
        $messages[] = self::msg($result['success'] ? 'ok' : 'err', $result['message']);

        if (!empty($result['success'])) {
            $cfg['guard_dismissed'] = 0; // Schutz eingerichtet -> evtl. frueheres "Ueberspringen" aufheben
        }

        if ($result['success'] && isset($result['auth_dir'])) {
            // Erkannten Serverpfad merken, damit Erneuerungen ihn direkt nutzen
            $dir = $result['auth_dir'] === str_replace('\\', '/', htp_admin_root()) ? '' : $result['auth_dir'];

            if ($dir !== $stored) {
                $cfg['guard_dir'] = $dir;
            }
        }

        if ($result['success'] && class_exists('HtpWatch')) {
            HtpWatch::remember($cfg);
            HtpConfig::save($cfg);
        }

        // Backend-Schutz-Wiederherstellung: sofern eine Alarm-Mailadresse hinterlegt ist,
        // beim Einrichten EINEN 256-bit-Einmal-Token erzeugen, NUR dessen SHA-256-Hash
        // speichern (guard_recover) und den Roh-Token als Recovery-Link per Mail schicken -
        // samt Benutzername + "im Passwort-Manager speichern"-Hinweis (das Passwort selbst
        // geht NIE per Mail raus). Ohne Alarm-Mail keinen Link vorhalten (Hash leeren) ->
        // dann bleibt der FTP-Weg. Der Link entfernt den Schutz erst nach Super-User-Login
        // auf der Website + CSRF (s. Waechter htpRecover). Fail-soft.
        if ($result['success']) {
            $mailTo = isset($cfg['notify_email']) ? trim((string) $cfg['notify_email']) : '';
            $base   = function_exists('htp_trusted_base')
                ? (string) htp_trusted_base(isset($cfg['unsub_base']) ? $cfg['unsub_base'] : '') : '';

            // Token NUR erzeugen/vorhalten, wenn er auch zustellbar ist (Alarm-Mail gesetzt
            // UND eine vertrauenswuerdige Frontend-Basis fuer den Link bekannt). Sonst kein
            // Recovery-Link -> guard_recover leeren (nur FTP-Weg).
            $raw  = ($mailTo !== '' && $base !== '') ? HtpGuard::makeRecoverToken() : '';
            $link = ($raw !== '') ? HtpGuard::recoverLink($base, $raw) : '';

            $cfg['guard_recover'] = ($link !== '') ? HtpGuard::hashRecoverToken($raw) : '';

            // Die begruessende Willkommens-Variante nur EINMAL je Installation - hier direkt
            // bei der Backend-Passwort-Einrichtung. Dasselbe Flag deckt auch den Gruen-Pfad
            // (Waechter) ab -> die Begruessung kann NIE doppelt kommen. Flag ZUERST setzen
            // (auch wenn der Versand scheitert -> kein Dauerversuch, kein Doppelversand).
            $welcome = ($mailTo !== '') && empty($cfg['welcome_sent']);

            if ($welcome) {
                $cfg['welcome_sent'] = 1;
            }

            HtpConfig::save($cfg);

            if ($mailTo !== '') {
                // Mail geht immer, sofern Alarm-Mail gesetzt: Benutzername + PW-Manager-
                // Hinweis + (falls vorhanden) Recovery-Link + FTP-Fallback. Kein Passwort.
                // Beim allerersten Mal die warme Willkommens-Variante (Doku + Joomla-Wuerdigung).
                self::sendGuardSetupMail($cfg, $username, $link, $welcome);
            }
        }

        if (isset($result['warnings']) && is_array($result['warnings'])) {
            foreach ($result['warnings'] as $warning) {
                $messages[] = self::msg('warn', $warning);
            }
        }

        if ($result['success']) {
            $messages[] = self::msg('info', htp_t('COM_HTPROTECT_GUARD_SETUP_INFO'));
        }

        return $result['success'] ? 'overview' : 'guard';
    }

    /**
     * Option 2 (Alternative zum Passwortschutz): Backend-Zugang per geheimem Zugangs-String
     * (Cookie-Gate) im Standalone-Backend einrichten - OHNE Dashboard. Ruft HtpGuard::setupString,
     * das guard_mode/guard_string via saveKeys persistiert; danach frisch laden, damit der Waechter
     * die neue .htaccess korrekt baseline't und HtpConfig::save() die gemerkten Werte nicht ueberschreibt.
     * Lockout-sicher: der Betreiber kennt den String und ruft /administrator/?<String> selbst auf.
     */
    private static function taskGuardStringSave(&$cfg, &$messages)
    {
        $string = trim(htp_post('htp_guard_string', ''));
        $result = HtpGuard::setupString($string);
        $messages[] = self::msg($result['success'] ? 'ok' : 'err', $result['message']);

        if (!empty($result['success'])) {
            if (class_exists('HtpConfig')) {
                $cfg = HtpConfig::load(); // guard_mode/guard_string (aus setupString) uebernehmen
            }

            $cfg['guard_dismissed'] = 0;

            // Wie beim Passwortschutz: Einmal-Deaktivierungs-Token erzeugen (NUR den SHA-256-Hash
            // speichern, guard_recover) und den Roh-Token als FRONTEND-Deaktivierungs-Link per Mail
            // schicken. Rettung, falls der Zugangs-String verloren geht: der Link entfernt nach
            // Super-User-Login auf der Website den Schutz (HtpGuard::remove strippt auch das Gate).
            // Nur wenn Alarm-Mail + vertrauenswuerdige Frontend-Basis vorhanden; sonst kein Link.
            $mailTo = isset($cfg['notify_email']) ? trim((string) $cfg['notify_email']) : '';
            $base   = function_exists('htp_trusted_base')
                ? (string) htp_trusted_base(isset($cfg['unsub_base']) ? $cfg['unsub_base'] : '') : '';
            $raw    = ($mailTo !== '' && $base !== '') ? HtpGuard::makeRecoverToken() : '';
            $link   = ($raw !== '') ? HtpGuard::recoverLink($base, $raw) : '';
            $cfg['guard_recover'] = ($link !== '') ? HtpGuard::hashRecoverToken($raw) : '';

            if (class_exists('HtpWatch')) {
                HtpWatch::remember($cfg);
            }

            if (class_exists('HtpConfig')) {
                HtpConfig::save($cfg);
            }

            if ($mailTo !== '') {
                self::sendGuardStringSetupMail($cfg, $link);
            }
        }

        if (isset($result['warnings']) && is_array($result['warnings'])) {
            foreach ($result['warnings'] as $warning) {
                $messages[] = self::msg('warn', $warning);
            }
        }

        return $result['success'] ? 'overview' : 'guard';
    }

    /**
     * SCHLICHTE Bestaetigungs-Mail nach ERNEUTER Einrichtung des /administrator-Passwort-
     * schutzes (Benutzername + "im Passwort-Manager speichern" + optionaler Recovery-Link +
     * FTP-Fallback; das Passwort selbst NIE per Mail). Beim ALLERERSTEN Mal ($welcome=true)
     * stattdessen die grosse Willkommens-Variante (sendWelcomeMail, mit Backend-Zugangsteil).
     * Fail-soft.
     *
     * @param   array    $cfg      Konfiguration (notify_email)
     * @param   string   $user     Basic-Auth-Benutzername
     * @param   string   $link     Recovery-Link (oder '' -> nur FTP-Fallback)
     * @param   boolean  $welcome  true = einmalige begruessende Variante
     *
     * @return void
     */
    private static function sendGuardSetupMail($cfg, $user, $link, $welcome = false)
    {
        // Erste Einrichtung -> die grosse Willkommens-Mail MIT Backend-Zugangsteil.
        if ($welcome) {
            self::sendWelcomeMail($cfg, true, $user, $link);

            return;
        }

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

        if ($to === '' || !function_exists('htp_send_mail')) {
            return;
        }

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

        $user = (string) $user;
        $link = (string) $link;

        // Site-URL fuer die Kopfzeile "Fuer deine Website: <host>" (macht klar, um WELCHE Site es geht).
        $siteUrl  = function_exists('htp_trusted_base') ? rtrim((string) htp_trusted_base(isset($cfg['unsub_base']) ? $cfg['unsub_base'] : ''), '/') : '';
        $siteHost = ($siteUrl !== '' && $siteUrl !== '/')
            ? (parse_url($siteUrl, PHP_URL_HOST) ?: preg_replace('~^https?://~i', '', $siteUrl)) : '';

        if ($siteHost === '') {
            $siteUrl = '';
        }

        // ── Plain-Text ──
        $lines   = array();
        $lines[] = htp_t('COM_HTPROTECT_GUARDMAIL_INTRO');

        if ($siteUrl !== '') {
            $lines[] = htp_t('COM_HTPROTECT_MAIL_SITE_LABEL') . ' ' . $siteUrl;
        }

        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_GUARDMAIL_USER', $user);
        $lines[] = htp_t('COM_HTPROTECT_GUARDMAIL_PWMGR');
        $lines[] = '';

        if ($link !== '') {
            $lines[] = htp_t('COM_HTPROTECT_GUARDMAIL_RECOVER');
            $lines[] = $link;
            $lines[] = '';
        }

        $lines[] = htp_t('COM_HTPROTECT_GUARDMAIL_FTP');
        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_MAIL_SIGNATURE');

        // ── HTML (optional) ──
        $html = '';

        if (class_exists('HtpMail')) {
            $content = HtpMail::lineP(htp_t('COM_HTPROTECT_GUARDMAIL_INTRO'))
                . ($siteUrl !== '' ? HtpMail::siteLine(htp_t('COM_HTPROTECT_MAIL_SITE_LABEL'), $siteHost, $siteUrl) : '')
                . HtpMail::lineP(htp_t('COM_HTPROTECT_GUARDMAIL_USER', $user))
                . HtpMail::muted(htp_t('COM_HTPROTECT_GUARDMAIL_PWMGR'));

            if ($link !== '') {
                $content .= HtpMail::lineP(htp_t('COM_HTPROTECT_GUARDMAIL_RECOVER'))
                    . HtpMail::button(htp_t('COM_HTPROTECT_GUARDMAIL_RECOVER_BTN'), $link, 'primary');
            }

            $content .= HtpMail::muted(htp_t('COM_HTPROTECT_GUARDMAIL_FTP'));
            $html     = HtpMail::wrap(htp_t('COM_HTPROTECT_GUARDMAIL_SUBJECT'), $content);
        }

        $err = '';

        try {
            htp_send_mail($to, htp_t('COM_HTPROTECT_GUARDMAIL_SUBJECT'), implode("\n", $lines), '', $err, array(), $html !== '' ? $html : null);
        } catch (Exception $e) {
            // egal - eine fehlgeschlagene Info-Mail darf die Einrichtung nie stoeren
        } catch (Throwable $e) {
            // egal
        }
    }

    /**
     * Bestaetigungs-Mail nach Einrichtung des ZUGANGS-STRINGS (Option 2, Cookie-Gate) - analog zur
     * Passwort-Setup-Mail: Hinweis auf den aktiven Schutz + Freischalt-URL-Muster + (falls vorhanden)
     * ein FRONTEND-DEAKTIVIERUNGS-LINK (entfernt bei verlorenem String den Schutz nach Super-User-Login
     * auf der Website) + FTP-Fallback. Der Zugangs-String selbst geht aus Sicherheitsgruenden NIE per
     * Mail (analog zum Passwort). Fail-soft - ein Mailfehler darf die Einrichtung nie stoeren.
     *
     * @param   array   $cfg   Konfiguration (notify_email)
     * @param   string  $link  Frontend-Deaktivierungs-Link (oder '' -> nur FTP-Fallback)
     *
     * @return void
     */
    private static function sendGuardStringSetupMail($cfg, $link)
    {
        $to = isset($cfg['notify_email']) ? trim((string) $cfg['notify_email']) : '';

        if ($to === '' || !function_exists('htp_send_mail')) {
            return;
        }

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

        $link = (string) $link;

        $siteUrl  = function_exists('htp_trusted_base') ? rtrim((string) htp_trusted_base(isset($cfg['unsub_base']) ? $cfg['unsub_base'] : ''), '/') : '';
        $siteHost = ($siteUrl !== '' && $siteUrl !== '/')
            ? (parse_url($siteUrl, PHP_URL_HOST) ?: preg_replace('~^https?://~i', '', $siteUrl)) : '';

        if ($siteHost === '') {
            $siteUrl = '';
        }

        // ── Plain-Text ──
        $lines   = array();
        $lines[] = htp_t('COM_HTPROTECT_GUARDGATEMAIL_INTRO');

        if ($siteUrl !== '') {
            $lines[] = htp_t('COM_HTPROTECT_MAIL_SITE_LABEL') . ' ' . $siteUrl;
        }

        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_GUARDGATEMAIL_UNLOCK');
        $lines[] = '';

        if ($link !== '') {
            $lines[] = htp_t('COM_HTPROTECT_GUARDGATEMAIL_RECOVER');
            $lines[] = $link;
            $lines[] = '';
        }

        $lines[] = htp_t('COM_HTPROTECT_GUARDMAIL_FTP');
        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_MAIL_SIGNATURE');

        // ── HTML (optional) ──
        $html = '';

        if (class_exists('HtpMail')) {
            $content = HtpMail::lineP(htp_t('COM_HTPROTECT_GUARDGATEMAIL_INTRO'))
                . ($siteUrl !== '' ? HtpMail::siteLine(htp_t('COM_HTPROTECT_MAIL_SITE_LABEL'), $siteHost, $siteUrl) : '')
                . HtpMail::muted(htp_t('COM_HTPROTECT_GUARDGATEMAIL_UNLOCK'));

            if ($link !== '') {
                $content .= HtpMail::lineP(htp_t('COM_HTPROTECT_GUARDGATEMAIL_RECOVER'))
                    . HtpMail::button(htp_t('COM_HTPROTECT_GUARDMAIL_RECOVER_BTN'), $link, 'primary');
            }

            $content .= HtpMail::muted(htp_t('COM_HTPROTECT_GUARDMAIL_FTP'));
            $html     = HtpMail::wrap(htp_t('COM_HTPROTECT_GUARDGATEMAIL_SUBJECT'), $content);
        }

        $err = '';

        try {
            htp_send_mail($to, htp_t('COM_HTPROTECT_GUARDGATEMAIL_SUBJECT'), implode("\n", $lines), '', $err, array(), $html !== '' ? $html : null);
        } catch (Exception $e) {
            // egal - eine fehlgeschlagene Info-Mail darf die Einrichtung nie stoeren
        } catch (Throwable $e) {
            // egal
        }
    }

    /**
     * Die EINMALIGE Willkommens-Mail (starke Userbindung + gutes Joomla-Bild). Zwei Auspraegungen:
     *  - $hasGuard=true : direkt nach der Backend-Passwort-Einrichtung, MIT Zugangsteil
     *    (Benutzername + Passwort-Manager-Hinweis + Recovery-Link + FTP-Fallback).
     *  - $hasGuard=false: der Gruen-Pfad (Waechter, ~30 Min dauerhaft gruene Ampel), OHNE
     *    Zugangsteil - fuer alle, die keinen Backend-Passwortschutz einrichten.
     * Gemeinsam: Schutz-Gesamtbild (Frontend-Schild + Mini-WAF, nur wenn aktiv; Auto-
     * Sicherheitsupdates), Handbuch-PDF, Joomla-Wuerdigung, Feedback + Unterstuetzer-Dank.
     * Der Aufrufer stellt via Flag welcome_sent sicher, dass sie NIE doppelt kommt. Fail-soft.
     *
     * @param   array    $cfg       Konfiguration (notify_email)
     * @param   boolean  $hasGuard  true = mit Backend-Zugangsteil
     * @param   string   $user      Basic-Auth-Benutzername (nur bei $hasGuard)
     * @param   string   $link      Recovery-Link (nur bei $hasGuard; '' -> nur FTP)
     *
     * @return void
     */
    public static function sendWelcomeMail($cfg, $hasGuard, $user = '', $link = '')
    {
        $to = isset($cfg['notify_email']) ? trim((string) $cfg['notify_email']) : '';

        if ($to === '' || !function_exists('htp_send_mail')) {
            return;
        }

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

        $hasGuard = (bool) $hasGuard;
        $user     = (string) $user;
        $link     = (string) $link;

        // Sprachrichtiges Handbuch-PDF + Unterstuetzer-Link (Deutsch vs. Englisch).
        $isDe    = function_exists('htp_lang') && strncasecmp((string) htp_lang(), 'de', 2) === 0;
        $docUrl  = $isDe ? 'https://htprotect.org/htprotect-dokumentation.pdf'
                         : 'https://htprotect.org/htprotect-documentation-en.pdf';
        $support = $isDe ? 'https://htprotect.org/#unterstuetzer'
                         : 'https://htprotect.org/en/#unterstuetzer';

        // Site-URL fuer die Kopfzeile "Fuer deine Website: <host>" (macht klar, um WELCHE Site es geht).
        $siteUrl  = function_exists('htp_trusted_base') ? rtrim((string) htp_trusted_base(isset($cfg['unsub_base']) ? $cfg['unsub_base'] : ''), '/') : '';
        $siteHost = ($siteUrl !== '' && $siteUrl !== '/')
            ? (parse_url($siteUrl, PHP_URL_HOST) ?: preg_replace('~^https?://~i', '', $siteUrl)) : '';

        if ($siteHost === '') {
            $siteUrl = '';
        }

        // Frontend-Schild (Haupt-.htaccess-Haertung + Mini-WAF) nur erwaehnen, wenn er auch
        // WIRKLICH aktiv ist (abgesichert UND Exploit-Signaturen an, nicht Hands-off). Auto-
        // Sicherheitsupdates sind Default-AN (autoupdate_ext) und werden immer erwaehnt.
        $shieldOn = false;

        if (class_exists('HtpStorage')) {
            $st       = HtpStorage::status($cfg);
            $shieldOn = empty($cfg['htaccess_external']) && !empty($st['ours']) && !empty($cfg['x_shield']);
        }

        // Guard-Variante spricht den Backend-Zugang an; die allgemeine fuehrt mit der Website.
        $subjectKey = $hasGuard ? 'COM_HTPROTECT_GUARDMAIL_WELCOME_SUBJECT' : 'COM_HTPROTECT_GUARDMAIL_WELCOME_GENERAL_SUBJECT';
        $titleKey   = $hasGuard ? 'COM_HTPROTECT_GUARDMAIL_WELCOME_TITLE'   : 'COM_HTPROTECT_GUARDMAIL_WELCOME_GENERAL_TITLE';
        $introKey   = $hasGuard ? 'COM_HTPROTECT_GUARDMAIL_WELCOME_INTRO'   : 'COM_HTPROTECT_GUARDMAIL_WELCOME_GENERAL';

        // ── Plain-Text ──
        $lines   = array();
        $lines[] = htp_t($introKey);

        if ($siteUrl !== '') {
            $lines[] = htp_t('COM_HTPROTECT_MAIL_SITE_LABEL') . ' ' . $siteUrl;
        }

        $lines[] = '';

        if ($hasGuard) {
            $lines[] = htp_t('COM_HTPROTECT_GUARDMAIL_USER', $user);
            $lines[] = htp_t('COM_HTPROTECT_GUARDMAIL_PWMGR');
            $lines[] = '';
        }

        if ($shieldOn) {
            $lines[] = htp_t('COM_HTPROTECT_GUARDMAIL_WELCOME_SHIELD');
        }

        $lines[] = htp_t('COM_HTPROTECT_GUARDMAIL_WELCOME_UPDATES');
        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_GUARDMAIL_DOCS');
        $lines[] = $docUrl;
        $lines[] = '';

        if ($hasGuard && $link !== '') {
            $lines[] = htp_t('COM_HTPROTECT_GUARDMAIL_RECOVER');
            $lines[] = $link;
            $lines[] = '';
        }

        $lines[] = htp_t('COM_HTPROTECT_GUARDMAIL_JOOMLA');
        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_GUARDMAIL_FEEDBACK');
        $lines[] = '';

        if ($hasGuard) {
            $lines[] = htp_t('COM_HTPROTECT_GUARDMAIL_FTP');
            $lines[] = '';
        }

        $lines[] = htp_t('COM_HTPROTECT_MAIL_SIGNATURE');
        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_GUARDMAIL_THANKS', htp_t('COM_HTPROTECT_GUARDMAIL_THANKS_LINK')) . ' ' . $support;

        // ── HTML (optional) ──
        $html = '';

        if (class_exists('HtpMail')) {
            $content = HtpMail::lineP(htp_t($introKey));

            if ($siteUrl !== '') {
                $content .= HtpMail::siteLine(htp_t('COM_HTPROTECT_MAIL_SITE_LABEL'), $siteHost, $siteUrl);
            }

            if ($hasGuard) {
                $content .= HtpMail::infoBox(htp_t('COM_HTPROTECT_GUARDMAIL_USER_LABEL'), $user)
                    . HtpMail::muted(htp_t('COM_HTPROTECT_GUARDMAIL_PWMGR'));
            }

            $content .= HtpMail::divider()
                . ($shieldOn ? HtpMail::lineP(htp_t('COM_HTPROTECT_GUARDMAIL_WELCOME_SHIELD')) : '')
                . HtpMail::lineP(htp_t('COM_HTPROTECT_GUARDMAIL_WELCOME_UPDATES'))
                . HtpMail::lineP(htp_t('COM_HTPROTECT_GUARDMAIL_DOCS'))
                . HtpMail::cta(htp_t('COM_HTPROTECT_GUARDMAIL_DOCS_BTN'), $docUrl);

            if ($hasGuard && $link !== '') {
                $content .= HtpMail::lineP(htp_t('COM_HTPROTECT_GUARDMAIL_RECOVER'))
                    . HtpMail::button(htp_t('COM_HTPROTECT_GUARDMAIL_RECOVER_BTN'), $link, 'ghost');
            }

            $content .= HtpMail::divider()
                . HtpMail::lineP(htp_t('COM_HTPROTECT_GUARDMAIL_JOOMLA'))
                . HtpMail::muted(htp_t('COM_HTPROTECT_GUARDMAIL_FEEDBACK'));

            if ($hasGuard) {
                $content .= HtpMail::muted(htp_t('COM_HTPROTECT_GUARDMAIL_FTP'));
            }

            $thanks = htp_t('COM_HTPROTECT_GUARDMAIL_THANKS', HtpMail::smallLink(htp_t('COM_HTPROTECT_GUARDMAIL_THANKS_LINK'), $support));
            $html   = HtpMail::wrap(htp_t($titleKey), $content, $thanks);
        }

        $err = '';

        try {
            htp_send_mail($to, htp_t($subjectKey), implode("\n", $lines), '', $err, array(), $html !== '' ? $html : null);
        } catch (Exception $e) {
            // egal - eine fehlgeschlagene Info-Mail darf die Einrichtung nie stoeren
        } catch (Throwable $e) {
            // egal
        }
    }

    private static function taskShieldSave(&$cfg, &$messages, &$data)
    {
        $cfg = HtpConfig::applyShieldForm($cfg);

        // Ein-Klick aus der Upload-Karte: den Joomla-5.3+-Ordner /files mit
        // aufnehmen, sofort haerten (Mini-.htaccess) und ins Watch-Manifest
        // uebernehmen (genau wie images/media). Honoriert die uebrigen
        // Formular-Edits (applyShieldForm) und kurzschliesst den Normalfluss.
        if (htp_post('htp_files_add', '') !== '') {
            if (HtpConfig::filesDirEligible() && !in_array('files', $cfg['u_dirs'], true)) {
                $cfg['u_dirs'][] = 'files';
                $cfg['u_harden'] = 1; // die Aktion HAERTET -> Schutz sicher an
                $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_UPLOAD_FILES_ADDED'));
                self::hardenAndRemember($cfg, $messages, true);
            } else {
                HtpConfig::save($cfg);
                $messages[] = self::msg('info', htp_t('COM_HTPROTECT_UPLOAD_FILES_NOOP'));
            }

            return;
        }

        $mode = htp_cmd('htp_mode', 'save');

        if (!HtpConfig::save($cfg)) {
            $messages[] = self::msg('err', HtpConfig::saveErrorMessage());

            return;
        }

        // Vorfilter-Trigger der Mini-WAF aktualisieren (x_shield/abgeschaltete
        // Signaturen koennen sich geaendert haben).
        if (class_exists('HtpDefs')) {
            HtpDefs::writeWafTriggerFile($cfg);
        }

        if ($mode === 'save') {
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_SHIELD_SAVED_NOWRITE'));

            return;
        }

        if ($mode === 'preview') {
            $content = HtpBuilder::build($cfg);

            if ($content === '') {
                $messages[] = self::msg('err', htp_t('COM_HTPROTECT_ERR_BUILD'));

                return;
            }

            $data['preview'] = $content;
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_SHIELD_PREVIEW'));

            return;
        }

        // mode === 'write'
        if (self::writeShield($cfg, $messages, $data)) {
            // Idiotensicher: direkt nach dem Schreiben besucht HTProtect die eigene Site
            // und gibt legitime, dort VERLINKTE Downloads automatisch frei - so macht das
            // Absichern die eigenen Dateien der Site nie unerreichbar.
            self::selfCheckAutoAllow($cfg, $messages);
        }
    }

    /**
     * Selbst-Check NACH dem Absichern (die idiotensichere Eigenschaft): HTProtect durchsucht
     * via DB (ohne HTTP) die in ALLEN Beitraegen + Modulen VERLINKTEN Ressourcen (= gewollte
     * Inhalte) und gibt jene mit UNGEFAEHRLICHER Endung, die der frische Schild sonst
     * blocken wuerde, automatisch frei (.htaccess wird rein lockernd neu geschrieben ->
     * kann die Site nicht brechen, daher ohne Selbsttest/Rollback). Riskante Faelle
     * (ausfuehrbar, sensibel, Pfad-/Ordner-Sperre) werden NUR gemeldet, nie automatisch
     * geoeffnet. Strikt fail-open - der Check darf das Absichern nie stoeren.
     *
     * @param   array  $cfg       Konfiguration (Referenz)
     * @param   array  $messages  Meldungen (Referenz)
     *
     * @return void
     */
    private static function selfCheckAutoAllow(&$cfg, &$messages)
    {
        try {
            if (!class_exists('HtpProbe') || !class_exists('HtpBuilder')) {
                return;
            }

            $safe       = array();   // sichere STATISCHE Endungen (aus dem DB-Scan)
            $riskyPaths = array();   // sieht legitim aus, aber nicht auto-freigebbar -> benennen
            $dbClean    = false;

            // (1) DB-basiert (KEIN HTTP): prueft die verlinkten Ressourcen ALLER veroeffentlichten
            // Beitraege + eigenen Module auf einmal -> nahezu null Last und viel gruendlicher als
            // ein paar gecrawlte Seiten (findet Downloads/Formulare auch auf tiefen Menuepunkten).
            $scan = HtpProbe::scanDbResources($cfg);

            if (!empty($scan['ok'])) {
                $blocked = (isset($scan['blocked']) && is_array($scan['blocked'])) ? $scan['blocked'] : array();

                if (empty($blocked)) {
                    $dbClean = true; // alles Verlinkte erreichbar
                } else {
                    foreach ($blocked as $item) {
                        $sug = isset($item['suggest']) ? $item['suggest'] : null;

                        if (is_array($sug) && isset($sug['type'], $sug['value'])
                            && $sug['type'] === 'ext' && self::isAutoAllowableExt($sug['value'])) {
                            $safe[strtolower((string) $sug['value'])] = true;
                        } elseif (isset($item['path']) && $item['path'] !== '') {
                            // Riskant (PHP/ausfuehrbar/sensibel/Pfad-Sperre) -> NIE auto, aber
                            // BENENNEN, damit der Nutzer sein legitimes Formular wiedererkennt.
                            $riskyPaths[ltrim((string) $item['path'], '/')] = true;
                        }
                    }
                }
            }

            // (2) Dynamische Stylesheet-/Skript-Einstiege (.php, die sich selbst als CSS/JS deklarieren:
            // MaxiMenu-CK-Themes, viele Template-CSS als .php). Der Browser laedt sie direkt per
            // per Link-/Skript-Tag -> der PHP-Schild macht sie unerreichbar und "zerlegt" das Layout.
            // HTProtect findet sie ueber ihre Selbst-Deklaration (nicht ueber die DB - sie stehen im
            // Template-/Modul-Kopf, nicht im Beitragstext) und gibt den ORDNER frei, in dem sie liegen
            // (ein Design-Ordner hat oft viele weitere/kuenftige Asset-Namen -> Ordner-Freigabe deckt sie
            // alle ab, kaum Wiederhol-403). Sicherheits-Bedingung: der GANZE Ordner muss malware-sauber
            // sein; eine eingeschleuste Shell im Ordner verhindert die Freigabe (sie wird nur benannt).
            $freedDirs = array();

            if (class_exists('HtpScanner') && method_exists('HtpScanner', 'findDynamicAssetPhp')) {
                $pendingDirs = array();

                foreach (HtpScanner::findDynamicAssetPhp($cfg, 3.0) as $c) {
                    $rel = ltrim((string) (isset($c['rel']) ? $c['rel'] : ''), '/');

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

                    // Schon erreichbar (Datei- ODER Ordner-Freigabe) -> nichts zu tun. Die Simulation ist
                    // deckungsgleich zur .htaccess.
                    $v = HtpBuilder::simulate($cfg, $rel);

                    if (!empty($v['allowed'])) {
                        continue;
                    }

                    $dir = str_replace('\\', '/', dirname($rel));
                    $dir = ($dir === '.' || $dir === '/') ? '' : ltrim($dir, '/');

                    // Die Wurzel nie als PHP-Ordner oeffnen; bereits freigegebene Ordner ueberspringen.
                    if ($dir === '' || in_array($dir, $cfg['s_dirs_php'], true)) {
                        continue;
                    }

                    $pendingDirs[$dir] = true;
                }

                foreach (array_keys($pendingDirs) as $dir) {
                    // Ganzen Ordner pruefen: sauber -> freigeben; beanstandet -> NICHT oeffnen, nur benennen.
                    $hits = method_exists('HtpScanner', 'scanMalwareUnder')
                        ? HtpScanner::scanMalwareUnder(array(htp_docroot() . $dir), 400, 4.0)
                        : array();

                    if (empty($hits)) {
                        $freedDirs[] = $dir;
                    } else {
                        foreach ($hits as $h) {
                            $riskyPaths[ltrim((string) (isset($h['rel']) ? $h['rel'] : ''), '/')] = true;
                        }
                    }
                }
            }

            // (3) Aenderungen (statische Endungen + datei-genaue PHP-Assets) anwenden - hoechstens
            // EIN Neu-Schreiben der .htaccess (rein lockernd -> kann die Site nicht brechen).
            $addedExts = array();
            $changed   = false;

            if (!empty($safe)) {
                $current = array_values(array_filter(array_map('trim', explode(',', (string) $cfg['s_ext']))));

                foreach (array_keys($safe) as $ext) {
                    if ($ext !== '' && !in_array($ext, $current, true)) {
                        $current[]   = $ext;
                        $addedExts[] = $ext;
                    }
                }

                if (!empty($addedExts)) {
                    sort($current);
                    $cfg['s_ext'] = implode(',', $current);
                    $changed      = true;
                }
            }

            if (!empty($freedDirs)) {
                $list = $cfg['s_dirs_php'];

                foreach ($freedDirs as $dir) {
                    if (!in_array($dir, $list, true)) {
                        $list[] = $dir;
                    }
                }

                sort($list);
                $cfg['s_dirs_php'] = $list;
                $changed           = true;
            }

            if ($changed) {
                HtpConfig::save($cfg);

                $content = HtpBuilder::build($cfg);

                if ($content !== '' && HtpStorage::write($content)) {
                    // ENTSCHEIDEND: Wir haben die Haupt-.htaccess soeben SELBST neu geschrieben.
                    // Ohne das Nachziehen der Waechter-Basis vergleicht der naechste Lauf die
                    // neue Datei gegen die ALTE Pruefsumme und meldet die eigene Freigabe als
                    // „von aussen geaendert" - inklusive Alarm-Mail an den Betreiber.
                    // Dieselbe Absicherung wie im Auto-Heilungs-Pfad und in HtpWatch::restore().
                    $cfg['file_sha1']    = sha1($content);
                    $cfg['last_written'] = date('Y-m-d H:i:s');

                    if (class_exists('HtpWatch')) {
                        HtpWatch::remember($cfg);
                    }

                    // saveKeys, NICHT save: save() ersetzt htp_shield komplett durch das
                    // uebergebene Array - hier werden gezielt nur die drei beruehrten
                    // Schluessel geschrieben (gleiche Linie wie HtpSso::rebaselineGuard).
                    HtpConfig::saveKeys(array(
                        'file_sha1'      => $cfg['file_sha1'],
                        'last_written'   => $cfg['last_written'],
                        'watch_manifest' => isset($cfg['watch_manifest']) ? $cfg['watch_manifest'] : array(),
                    ));
                }
            }

            // (4) Meldungen.
            if (!empty($addedExts)) {
                sort($addedExts);
                $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_SELFCHECK_FREED', htp_esc(implode(', ', $addedExts))));
            }

            if (!empty($freedDirs)) {
                sort($freedDirs);
                $shown = array_slice($freedDirs, 0, 5);
                $more  = count($freedDirs) - count($shown);
                $list  = htp_esc(implode(', ', $shown)) . ($more > 0 ? ' (+' . (int) $more . ')' : '');
                $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_SELFCHECK_ASSETFREED', $list));
            }

            if (!empty($riskyPaths)) {
                $paths = array_keys($riskyPaths);
                $shown = array_slice($paths, 0, 3);
                $more  = count($paths) - count($shown);
                $list  = htp_esc(implode(', ', $shown)) . ($more > 0 ? ' (+' . (int) $more . ')' : '');
                $messages[] = self::msg('info', htp_t('COM_HTPROTECT_SELFCHECK_RISKY', $list));
            }

            // Nichts freizugeben, nichts Riskantes UND der DB-Scan war sauber -> beruhigende Bestaetigung.
            if (empty($addedExts) && empty($freedDirs) && empty($riskyPaths) && $dbClean) {
                $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_SELFCHECK_CLEAN'));
            }
        } catch (Exception $e) {
            // still - der Selbst-Check darf das Absichern nie stoeren
        } catch (Throwable $e) {
            // still
        }
    }

    /**
     * Darf dieser Datei-Typ automatisch freigegeben werden? NUR ungefaehrliche,
     * statische Endungen - nie ausfuehrbare (php, phtml, exe ...) oder sensible
     * (bak, sql, key ...). So bleibt die Auto-Freigabe grundsaetzlich sicher.
     *
     * @param   string  $ext
     *
     * @return boolean
     */
    private static function isAutoAllowableExt($ext)
    {
        $ext = strtolower(trim((string) $ext, " \t."));

        if ($ext === '' || strlen($ext) > 12 || !preg_match('/^[a-z0-9]+$/', $ext)) {
            return false;
        }

        // Ausfuehrbare / server-interpretierte oder aktiv-inhaltliche Typen -> NIE auto.
        $dangerous = array(
            'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'pht', 'phps',
            'phar', 'phpt', 'cgi', 'fcgi', 'pl', 'py', 'rb', 'sh', 'bash', 'asp',
            'aspx', 'jsp', 'jspx', 'exe', 'dll', 'so', 'com', 'bat', 'cmd', 'scr',
            'msi', 'shtml', 'shtm', 'svg', 'svgz', 'htm', 'html', 'xhtml', 'htaccess',
            'htpasswd',
        );

        if (in_array($ext, $dangerous, true)) {
            return false;
        }

        // Sensible Endungen (spiegelt HtpBuilder::SENSITIVE_EXT) -> NIE auto.
        $sensitive = array(
            'bak', 'bck', 'cer', 'conf', 'crt', 'csr', 'der', 'env', 'inc', 'ini',
            'jpa', 'jps', 'key', 'log', 'mdb', 'old', 'orig', 'p12', 'pem', 'pfx',
            'sav', 'save', 'sql', 'sqlite', 'sqlite3', 'swo', 'swp',
        );

        if (in_array($ext, $sensitive, true) || preg_match('/^j[0-9]{2}$/', $ext)) {
            return false;
        }

        return true;
    }

    /**
     * Kern-Schreibvorgang der Haupt-.htaccess: Sicherung -> Erzeugen ->
     * atomar schreiben -> Selbsttest -> Auto-Rollback bei Regression ->
     * Upload-Ordner haerten -> Waechter-Manifest aktualisieren.
     *
     * Wiederverwendet von Schutzschild-Schreiben, Ein-Klick-Absicherung,
     * Notfall-Modus, Waechter-Wiederherstellung und dem Auto-Apply des
     * Hintergrund-Plugins.
     *
     * @param   array  $cfg       Konfiguration (Referenz, bereits angewandt)
     * @param   array  $messages  Meldungen (Referenz)
     * @param   array  $data      Anzeigedaten (Referenz)
     * @param   array  $opts      Optionen: expect_home_401 (bool), harden (bool, Default true)
     *
     * @return boolean Erfolg (geschrieben und keine Regression)
     */
    public static function writeShield(&$cfg, &$messages, &$data, $opts = array())
    {
        $harden = !array_key_exists('harden', $opts) || $opts['harden'];

        if (htp_nginx_mode($cfg)) {
            $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_SHIELD_NGINX_NOWRITE'));

            return false;
        }

        // IIS: eine .htaccess wird nicht ausgewertet -> nicht schreiben. Die passende web.config
        // erzeugt/exportiert der IIS-Panel (unten im Schild-Bereich).
        if (htp_iis_mode($cfg)) {
            $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_SHIELD_IIS_NOWRITE'));

            return false;
        }

        // Hands-off: die HAUPT-.htaccess (Basisverzeichnis) wird NICHT angefasst (extern
        // verwaltet). Die Upload-Ordner-Haertung schreibt aber SEPARATE Mini-.htaccess in
        // die Upload-Verzeichnisse - voellig unabhaengig von der Haupt-.htaccess -> sie
        // laeuft trotzdem. hardenAndRemember haertet (je u_harden), aktualisiert das
        // Waechter-Manifest (das die Haupt-.htaccess im External-Modus ohnehin auslaesst,
        // s. HtpWatch) und speichert die Konfiguration.
        if (!empty($cfg['htaccess_external'])) {
            self::hardenAndRemember($cfg, $messages, $harden);
            $messages[] = self::msg('info', htp_t('COM_HTPROTECT_HTEXT_NOWRITE'));

            return false;
        }

        $content = HtpBuilder::build($cfg);

        if ($content === '') {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_ERR_BUILD'));

            return false;
        }

        // Fail-safe-Hinweis (Heuristik, NIE Auto-Rollback): blockiert eine EIGENE Regel in den
        // Eigenregel-Feldern /administrator (z. B. RewriteCond auf /adm bzw. /admin)? Dann sperrt sie
        // den Betreiber aus, und der Schild-Rollback koennte sie nicht entfernen -> klar warnen
        // (unten, nach dem Schreiben; greift auch ohne HTTP-Selbsttest).
        $customAdminWarn = HtpBuilder::customRuleBlocksAdmin($cfg);

        $hadFile    = is_file(HtpStorage::htaccessPath());
        $backupName = HtpStorage::backup('auto');
        $data['last_backup'] = $backupName;

        if ($hadFile && $backupName === '') {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_SHIELD_ERR_BACKUP'));

            return false;
        }

        $probeOk  = HtpProbe::available();
        $baseline = $probeOk ? HtpProbe::baseline() : array();

        if (!HtpStorage::write($content)) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_SHIELD_ERR_WRITE'));

            return false;
        }

        if ($backupName !== '') {
            $messages[] = self::msg('info', htp_t('COM_HTPROTECT_SHIELD_BACKED_UP', htp_esc($backupName)));
        }

        if (!$probeOk) {
            $cfg['file_sha1']    = sha1($content);
            $cfg['last_written'] = date('Y-m-d H:i:s');
            $cfg['last_test']    = array();
            self::hardenAndRemember($cfg, $messages, $harden);

            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_SHIELD_WRITTEN'));
            $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_SHIELD_NOHTTP'));

            if ($customAdminWarn) {
                $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_SHIELD_CUSTOM_ADMIN_BLOCK'));
            }

            return true;
        }

        $test = HtpProbe::selfTest($cfg, $baseline, $opts);

        if ($test['regression']) {
            // Auto-Rollback
            $rolledBack = false;

            if ($backupName !== '') {
                $previous = HtpStorage::readBackup($backupName);

                if ($previous !== null && HtpStorage::write($previous)) {
                    $rolledBack = true;
                }
            } else {
                // 0444-Haertung vor dem Entfernen aufheben (Windows/Defensive;
                // unter Linux haengt das Loeschen ohnehin am Ordner-Recht).
                @chmod(HtpStorage::htaccessPath(), 0644);

                if (@unlink(HtpStorage::htaccessPath())) {
                    $rolledBack = true;
                }
            }

            $cfg['last_test'] = $test;
            HtpConfig::save($cfg);
            $data['selftest'] = $test;

            if ($rolledBack) {
                $reason = isset($test['reason']) ? $test['reason'] : '';

                if ($reason === 'redirect_loop') {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_SHIELD_ROLLBACK_LOOP', (int) $test['checks'][0]['status']));
                } elseif ($reason === 'admin_blocked') {
                    // /administrator war nach dem Schreiben nicht mehr erreichbar -> zurueckgerollt.
                    // Haeufigste Ursache: eine EIGENE .htaccess-Regel (z. B. RewriteCond auf /adm bzw.
                    // /admin), die der Schild-Rollback NICHT entfernen kann -> Betreiber klar hinweisen.
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_SHIELD_ROLLBACK_ADMIN', isset($test['admin_status']) ? (int) $test['admin_status'] : 0));
                } else {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_SHIELD_ROLLBACK_OK', (int) $test['checks'][0]['status']));
                }
            } else {
                $messages[] = self::msg('err', htp_t('COM_HTPROTECT_SHIELD_ROLLBACK_FAIL'));
            }

            return false;
        }

        $cfg['file_sha1']    = sha1($content);
        $cfg['last_written'] = date('Y-m-d H:i:s');
        $cfg['last_test']    = $test;
        self::hardenAndRemember($cfg, $messages, $harden);

        $data['selftest'] = $test;

        $allOk = true;

        foreach ($test['checks'] as $check) {
            if (empty($check['ok'])) {
                $allOk = false;
            }
        }

        if ($allOk) {
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_SHIELD_ACTIVE_OK'));
        } else {
            $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_SHIELD_ACTIVE_WARN'));
        }

        if ($customAdminWarn) {
            $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_SHIELD_CUSTOM_ADMIN_BLOCK'));
        }

        return true;
    }

    /**
     * Deinstallations-Bereinigung: setzt die vom Assistenten gewaehlten Teile
     * zurueck. Bewusst reine Datei-Ops auf der VOLL lebenden Komponente -> auf
     * J2.5-6 gleich zuverlaessig, unabhaengig vom (headless/heiklen) Uninstall-
     * Kontext. Nutzt ausschliesslich vorhandene, marker-/besitz-basierte
     * Entfernung (kein Fremd-Schutz wird angetastet).
     *
     * @param   array  $cfg   Konfiguration
     * @param   array  $opts  ['main' => 'original|default|delete|leave',
     *                         'uploads' => bool, 'admin' => bool]
     *
     * @return array  ['admin' => bool|null, 'uploads' => bool|null, 'main' => bool|null]
     *                (null = nicht angefordert)
     */
    public static function uninstallCleanup($cfg, array $opts)
    {
        $res = array('admin' => null, 'uploads' => null, 'main' => null);

        // 1) /administrator-Passwortschutz: eigener Block + eigene .htpasswd
        //    (fremde Auth bleibt unangetastet - HtpGuard::remove strippt nur den Marker-Block).
        if (!empty($opts['admin'])) {
            $r = HtpGuard::remove();
            $res['admin'] = !empty($r['success']);
        }

        // 2) Upload-Ordner-Mini-.htaccess: nur eigene dirshield-Bloecke.
        if (!empty($opts['uploads']) && class_exists('HtpDirShield')) {
            HtpDirShield::remove($cfg);
            $res['uploads'] = true;
        }

        // 3) Haupt-.htaccess zuruecksetzen (Original/Joomla-Standard/loeschen).
        $mode = isset($opts['main']) ? (string) $opts['main'] : 'leave';

        if ($mode !== 'leave') {
            $res['main'] = HtpStorage::restoreMainHtaccess($mode);
        }

        return $res;
    }

    /**
     * Deinstallations-Assistent: Bereinigung (falls gewaehlt) ausfuehren und die
     * Komponente danach deinstallieren. Die Bereinigung laeuft auf der VOLL lebenden
     * Komponente; die Deinstallation entfernt via script.php::uninstall() auch die
     * 3 Plugins + Scheduler-Zeile kaskadierend. Endet IMMER mit Weiterleitung (Exit).
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return void
     */
    private static function taskUninstall($cfg)
    {
        // "nur deinstallieren" (Escape) -> Bereinigung ueberspringen, nichts anfassen.
        if (htp_post('htp_un_skip', '') === '') {
            self::uninstallCleanup($cfg, array(
                'main'    => self::sanitizeMainMode(htp_post('htp_un_main', 'leave')),
                'uploads' => htp_post('htp_un_uploads', '') !== '',
                'admin'   => htp_post('htp_un_admin', '') !== '',
            ));
        }

        // Meldungstexte VOR der Deinstallation holen (Sprachdateien sind gleich weg).
        $msgOk  = htp_t('COM_HTPROTECT_UNINST_DONE');
        $msgErr = htp_t('COM_HTPROTECT_UNINST_ERR');

        // Selbst-Deinstallation. Die echo-Ausgabe des Uninstall-Skripts schlucken,
        // sonst scheitert der Redirect-Header ("headers already sent").
        ob_start();
        $ok = self::selfUninstall();
        @ob_end_clean();

        $url = 'index.php?option=com_installer&view=manage';
        $app = function_exists('htp_app') ? htp_app() : null;

        if ($app && method_exists($app, 'redirect')) {
            if (method_exists($app, 'enqueueMessage')) {
                $app->enqueueMessage($ok ? $msgOk : $msgErr, $ok ? 'message' : 'error');
            }

            $app->redirect($url); // ruft intern close()/exit
        }

        @header('Location: ' . $url);
        exit;
    }

    /**
     * Die Komponente selbst deinstallieren (kaskadiert via script.php die 3 Plugins
     * + Scheduler-Zeile). Gleiches Muster wie script.php::removePlugin: frische
     * Installer-Instanz mit gesetzter DB. Faellt nie hart aus.
     *
     * @return boolean
     */
    private static function selfUninstall()
    {
        $id = self::componentExtensionId();

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

        try {
            if (class_exists('Joomla\\CMS\\Installer\\Installer')) {
                $installer = new Joomla\CMS\Installer\Installer();

                if (method_exists($installer, 'setDatabase') && function_exists('htp_db')) {
                    $installer->setDatabase(htp_db());
                }

                return (bool) $installer->uninstall('component', $id);
            }

            if (class_exists('JInstaller')) {
                return (bool) JInstaller::getInstance()->uninstall('component', $id);
            }
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }

        return false;
    }

    /**
     * Extension-ID der Komponente com_htprotect (0 = nicht gefunden).
     *
     * @return integer
     */
    private static function componentExtensionId()
    {
        $db = function_exists('htp_db') ? htp_db() : null;

        if (!$db) {
            return 0;
        }

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

            return (int) $db->loadResult();
        } catch (Exception $e) {
            return 0;
        } catch (Throwable $e) {
            return 0;
        }
    }

    /**
     * Haupt-.htaccess-Modus des Assistenten absichern (Whitelist).
     *
     * @param   string  $m
     *
     * @return string
     */
    private static function sanitizeMainMode($m)
    {
        $m = strtolower(trim((string) $m));

        return in_array($m, array('original', 'default', 'delete', 'leave'), true) ? $m : 'leave';
    }

    /**
     * Nach erfolgreichem Schreiben: optional Upload-Ordner haerten,
     * Waechter-Manifest aktualisieren und alles speichern.
     *
     * @param   array    $cfg       Konfiguration (Referenz)
     * @param   array    $messages  Meldungen (Referenz)
     * @param   boolean  $harden    Upload-Ordner mithaerten?
     *
     * @return void
     */
    private static function hardenAndRemember(&$cfg, &$messages, $harden)
    {
        if ($harden && class_exists('HtpDirShield')) {
            if (!empty($cfg['u_harden'])) {
                $res = HtpDirShield::apply($cfg);

                if ($res['written'] > 0) {
                    $messages[] = self::msg('info', htp_t('COM_HTPROTECT_DIRSHIELD_HARDENED', (int) $res['written']));
                }
            } else {
                // Haertung ausgeschaltet -> bestehende eigene Bloecke entfernen
                HtpDirShield::remove($cfg);
            }
        }

        // Festhalten, welcher Signaturen-Stand jetzt in der .htaccess steckt
        // (nur informativ; nicht Teil des configHash).
        if (class_exists('HtpDefs')) {
            $info = HtpDefs::info();
            $cfg['defs_version'] = $info['version'];
        }

        if (class_exists('HtpWatch')) {
            HtpWatch::remember($cfg);
        }

        HtpConfig::save($cfg);
    }

    /**
     * Ein-Klick-Absicherung: empfohlene Einstellungen setzen und schreiben.
     */
    private static function taskQuickSecure(&$cfg, &$messages, &$data)
    {
        // Bisherige .htaccess festhalten, bevor sie ueberschrieben wird - daraus
        // ermitteln wir hinterher transparent, welche Eigenregeln NICHT in die
        // neue, gehaertete Datei uebernommen wurden (das Original liegt im Backup).
        $old = HtpStorage::read();

        $recommended = array(
            'f_query', 'f_rfi', 'f_methods', 'f_wpnoise',
            'x_shield', 'u_harden', 'apache_options', 'fastcgi_auth',
            's_php', 's_types', 's_dotfiles', 's_sensitive', 's_sysdirs', 's_wellknown',
            'h_frame', 'h_nosniff', 'h_poweredby',
        );

        foreach ($recommended as $key) {
            $cfg[$key] = 1;
        }

        // HTTPS nur erzwingen, wenn die Site bereits verschluesselt laeuft
        if (strpos(htp_site_url(), 'https://') === 0) {
            $cfg['force_https'] = 1;
        }

        // Kritische PHP-Handler-/Versions-Direktiven (z. B. das Hoster-Makro
        // "Use php-fpm 83") schon JETZT in "Eigene Regeln (Anfang)" uebernehmen -
        // sonst faellt die Site bei einer Frisch-Installation (custom_top noch leer)
        // mit dem ersten Schreibvorgang unter die PHP-Mindestversion, und das fuer
        // die nachgelagerte "uebernehmen?"-Frage noetige Backend ist dann auch tot.
        foreach (HtpBuilder::extractCriticalDirectives($old) as $directive) {
            $cfg['custom_top'] = self::appendCustomRule((string) $cfg['custom_top'], $directive);
        }

        // PHP-Handler nur in "Anfang" - falls "Ende" (z. B. aus einem frueheren Adopt)
        // noch eine Handler-Zeile haelt, hierher zurueckholen (heilt Bestaende).
        self::enforceCriticalTop($cfg);

        if (!HtpConfig::save($cfg)) {
            $messages[] = self::msg('err', HtpConfig::saveErrorMessage());

            return;
        }

        $messages[] = self::msg('info', htp_t('COM_HTPROTECT_QUICK_INFO'));
        $ok = self::writeShield($cfg, $messages, $data);

        // Bericht nur bei tatsaechlichem Schreiben (kein Rollback): echte Eigen-
        // regeln aus der alten Datei, die weder Standard noch bereits in der
        // Konfiguration sind, positionstreu sammeln.
        if ($ok) {
            // Auch der Einsteiger-Ein-Klick (Uebersicht -> "Jetzt absichern") prueft die
            // Site und gibt legitime, verlinkte Downloads/Formulare automatisch frei -
            // idiotensicher, genau wie der Schutzschild-Schreibvorgang.
            self::selfCheckAutoAllow($cfg, $messages);

            $split  = HtpBuilder::extractCustomRulesSplit($old);
            // Kritische PHP-Handler sind bereits nach "Anfang" bewahrt -> NICHT erneut als
            // (adoptierbare) Eigenregel anbieten, sonst landet derselbe Handler doppelt
            // (u. a. in "Ende").
            $crit   = HtpBuilder::extractCriticalDirectives($old);
            $sTop   = self::stripCriticalRules($split['top'], $crit);
            $sBot   = self::stripCriticalRules($split['bottom'], $crit);
            $curTop = (string) $cfg['custom_top'];
            $curBot = (string) $cfg['custom_bottom'];
            $newTop = ($sTop !== '' && strpos($curTop, $sTop) === false) ? $sTop : '';
            $newBot = ($sBot !== '' && strpos($curBot, $sBot) === false) ? $sBot : '';
            $removed = trim($newTop . (($newTop !== '' && $newBot !== '') ? "\n\n" : '') . $newBot);

            if ($removed !== '') {
                $data['quick_removed']        = $removed;
                $data['quick_removed_backup'] = isset($data['last_backup']) ? (string) $data['last_backup'] : '';
            }
        }
    }

    /**
     * Aus dem Bericht heraus: die im (eben erzeugten) Backup gefundenen Eigen-
     * regeln positionstreu in die Konfiguration uebernehmen (vor dem Router ->
     * "Eigene Regeln (Anfang)", danach -> "Ende") und die .htaccess neu
     * schreiben. Reihenfolge relativ zum Joomla-Router bleibt erhalten.
     */
    private static function taskQuickAdopt(&$cfg, &$messages, &$data)
    {
        $name   = htp_post('htp_backup', '');
        $backup = $name !== '' ? HtpStorage::readBackup($name) : null;

        if ($backup === null || $backup === '') {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_QUICK_REMOVED_NOBACKUP'));

            return;
        }

        $split = HtpBuilder::extractCustomRulesSplit($backup);

        if ($split['top'] === '' && $split['bottom'] === '') {
            $messages[] = self::msg('info', htp_t('COM_HTPROTECT_IMPORT_ONLY_STD'));

            return;
        }

        $prevTop = (string) $cfg['custom_top'];
        $prevBot = (string) $cfg['custom_bottom'];

        $cfg['custom_top']    = self::appendCustomRule($prevTop, HtpConfig::cleanCustomRules($split['top']));
        $cfg['custom_bottom'] = self::appendCustomRule($prevBot, HtpConfig::cleanCustomRules($split['bottom']));

        // PHP-Handler gehoeren nur nach "Anfang" - eine ueber den Router-Pivot als "Ende"
        // eingestufte Handler-Zeile hierher zurueckholen (verhindert die Dopplung top+bottom).
        self::enforceCriticalTop($cfg);

        if (!HtpConfig::save($cfg)) {
            $messages[] = self::msg('err', HtpConfig::saveErrorMessage());

            return;
        }

        if (self::writeShield($cfg, $messages, $data)) {
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_QUICK_REMOVED_ADOPTED'));

            return;
        }

        // Schreiben fehlgeschlagen / Auto-Rollback (z. B. Redirect-Schleife durch
        // eine kollidierende Eigenregel): die Uebernahme wieder verwerfen, damit
        // die problematische Regel nicht in der Konfiguration haengen bleibt.
        $cfg['custom_top']    = $prevTop;
        $cfg['custom_bottom'] = $prevBot;
        HtpConfig::save($cfg);
        $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_QUICK_REMOVED_REVERTED'));
    }

    /**
     * Regelblock an vorhandene Eigene Regeln anhaengen (Dublettenschutz).
     *
     * @param   string  $existing  Bisheriger Inhalt
     * @param   string  $add       Anzuhaengender Block
     *
     * @return string
     */
    private static function appendCustomRule($existing, $add)
    {
        $existing = (string) $existing;
        $add      = trim((string) $add);

        if ($add === '' || strpos($existing, $add) !== false) {
            return $existing;
        }

        return trim($existing) === '' ? $add : rtrim($existing) . "\n\n" . $add;
    }

    /**
     * Bestimmte (getrimmte) Zeilen aus einem Regel-Block entfernen - z. B. die
     * kritischen PHP-Handler-Direktiven aus dem "Ende"-Teil. Reihenfolge der uebrigen
     * Zeilen bleibt erhalten.
     */
    private static function stripCriticalRules($text, $lines)
    {
        $text = (string) $text;

        if (empty($lines) || $text === '') {
            return $text;
        }

        $keep = array();

        foreach (explode("\n", $text) as $line) {
            if (!in_array(trim($line), $lines, true)) {
                $keep[] = $line;
            }
        }

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

    /**
     * Invariante erzwingen: die PHP-Handler-/Versionswahl gehoert NUR in "Eigene Regeln
     * (Anfang)" (custom_top), NIE in "Ende" (custom_bottom) - die Version wird einmal,
     * weit oben gesetzt. Liegt dort (z. B. durch einen frueheren "uebernehmen?"-Schritt
     * doppelt eingetragen) eine Handler-Zeile, wird sie aus "Ende" entfernt und nach
     * "Anfang" verschoben. Heilt auch Bestaende; andere Eigenregeln bleiben unberuehrt.
     */
    private static function enforceCriticalTop(&$cfg)
    {
        if (!class_exists('HtpBuilder')) {
            return;
        }

        $crit = HtpBuilder::extractCriticalDirectives((string) $cfg['custom_bottom']);

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

        $cfg['custom_bottom'] = self::stripCriticalRules((string) $cfg['custom_bottom'], $crit);

        foreach ($crit as $directive) {
            $cfg['custom_top'] = self::appendCustomRule((string) $cfg['custom_top'], $directive);
        }
    }

    /**
     * Upload-Ordner-Haertung ein-/ausschalten.
     */
    private static function taskDirShield(&$cfg, &$messages, $enable)
    {
        $cfg['u_harden'] = $enable ? 1 : 0;

        if ($enable) {
            $res = HtpDirShield::apply($cfg);
            // Trennleerzeichen vor dem Fremdinhalt-Zusatz bewusst hier im Code
            // (JED beanstandet fuehrende Leerzeichen im Sprachstring).
            $msg = htp_t('COM_HTPROTECT_DIRSHIELD_ACTIVE', (int) $res['written'])
                . ($res['foreign'] > 0 ? ' ' . htp_t('COM_HTPROTECT_DIRSHIELD_FOREIGN', (int) $res['foreign']) : '')
                . ($res['failed'] > 0 ? htp_t('COM_HTPROTECT_DIRSHIELD_FAILED', (int) $res['failed']) : '') . '.';
            $messages[] = self::msg('ok', $msg);

            $canary = HtpDirShield::canaryTest($cfg);

            if ($canary['done']) {
                $messages[] = self::msg($canary['ok'] ? 'ok' : 'warn', $canary['message']);
            }
        } else {
            $res = HtpDirShield::remove($cfg);
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_DIRSHIELD_REMOVED', (int) $res['removed']));
        }

        HtpWatch::remember($cfg);
        HtpConfig::save($cfg);
    }

    /**
     * Definitionen vom Update-Server nachladen. Es wird NICHTS automatisch
     * in die .htaccess geschrieben - die Echtzeit-WAF nutzt die neuen
     * Signaturen sofort. Optional kann der Nutzer sie ins .htaccess-Fundament
     * uebernehmen (Schutzschild -> schreiben), z. B. fuer Seiten ohne Plugin.
     */
    /**
     * Selbsttest erneut ausfuehren (Reload-Button bei "System & Selbsttest"), OHNE
     * die .htaccess neu zu schreiben - nur die Live-Regeln erneut per HTTP pruefen
     * und das Ergebnis (last_test) auffrischen.
     *
     * @param   array  $cfg       Konfiguration (Referenz)
     * @param   array  $messages  Meldungen (Referenz)
     * @param   array  $data      Anzeigedaten (Referenz)
     *
     * @return void
     */
    private static function taskSelfTest(&$cfg, &$messages, &$data)
    {
        if (!class_exists('HtpProbe') || !HtpProbe::available()) {
            $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_SHIELD_NOHTTP'));

            return;
        }

        try {
            $test = HtpProbe::selfTest($cfg, HtpProbe::baseline());
        } catch (Exception $e) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_AU_ERR_GENERIC'));

            return;
        } catch (Throwable $e) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_AU_ERR_GENERIC'));

            return;
        }

        if (!is_array($test) || empty($test['checks'])) {
            $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_SELFTEST_NONE'));

            return;
        }

        $cfg['last_test'] = $test;
        HtpConfig::save($cfg);
        $data['selftest'] = $test;

        $ok    = 0;
        $total = count($test['checks']);

        foreach ($test['checks'] as $c) {
            if (!empty($c['ok'])) {
                $ok++;
            }
        }

        $messages[] = self::msg($ok === $total ? 'ok' : 'warn', htp_t('COM_HTPROTECT_SELFTEST_PASSED', $ok, $total));
    }

    private static function taskDefsUpdate(&$cfg, &$messages, &$data)
    {
        $result = HtpDefs::refresh(true);
        $messages[] = self::msg($result['changed'] ? 'ok' : 'info', $result['message']);

        // Malware-Scanner-Signaturen (eigener Feed) gleich MIT forcen - sonst zoge
        // der Button nur die WAF-Defs, nicht die Malware-Signaturen/fp-Unterdrueckungen.
        // Eigene Statuszeile, damit die Meldung BEIDE Feed-Staende nennt (sonst sah
        // man nur die WAF-Defs-Version und hielt den Malware-Feed faelschlich fuer
        // veraltet). Vorher/Nachher-Version vergleichen = "geaendert?".
        if (method_exists('HtpDefs', 'refreshMalwareSigs') && method_exists('HtpDefs', 'malwareVersion')) {
            $mwBefore = (string) HtpDefs::malwareVersion();
            HtpDefs::refreshMalwareSigs(true);
            $mwAfter  = (string) HtpDefs::malwareVersion();

            if ($mwAfter !== '') {
                $messages[] = self::msg(
                    $mwAfter !== $mwBefore ? 'ok' : 'info',
                    htp_t($mwAfter !== $mwBefore ? 'COM_HTPROTECT_MALWFEED_UPDATED' : 'COM_HTPROTECT_MALWFEED_UPTODATE', $mwAfter)
                );
            }
        } elseif (method_exists('HtpDefs', 'refreshMalwareSigs')) {
            HtpDefs::refreshMalwareSigs(true);
        }

        // Aktuelle Joomla-Version gleich mit auffrischen (live, Downloads-API).
        HtpDefs::refreshJoomlaLatest(true);

        // Vorfilter-Trigger neu schreiben (frisch nachgeladene Signaturen).
        HtpDefs::writeWafTriggerFile($cfg);

        if (!$result['changed']) {
            return;
        }

        $pluginOn = htp_extension_enabled('plugin', 'htprotectwatch', 'system');

        if (!empty($pluginOn['enabled'])) {
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_DEFS_LIVE_ON'));
        } else {
            $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_DEFS_LIVE_OFF'));
        }
    }

    /**
     * Mailadresse und Auto-Apply-Schalter speichern.
     */
    private static function taskWatchSave(&$cfg, &$messages)
    {
        // Kommaseparierte Empfaengerliste: jede Adresse einzeln pruefen.
        $parsed = HtpConfig::cleanEmailList(htp_post('htp_notify_email', ''));

        if (!empty($parsed['invalid'])) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_WATCH_EMAIL_INVALID'));

            return;
        }

        $cfg['notify_email'] = implode(', ', $parsed['valid']);

        // Wiedererinnerungs-Abstand (Tage) auf [MIN, MAX] begrenzen (Anti-Spam).
        $cfg['notify_reminder_days'] = HtpWatch::clampReminderDays(
            htp_post('htp_notify_reminder_days', HtpWatch::REMINDER_DEFAULT_DAYS)
        );

        // Super-User-Konten ueberwachen (Checkbox im Monitor-Bereich).
        $cfg['accounts_watch'] = htp_post_bool('htp_accounts_watch');

        // B/C-Recovery (Backward-Compatibility-Plugin-Fatal auto-reaktivieren). Die
        // Checkbox gibt es NUR ab Joomla 5 - auf J<5 daher den Wert NICHT aus dem
        // Formular nehmen (sonst setzte das fehlende, weil ausgeblendete Feld ihn still
        // auf 0), sondern unveraendert lassen.
        if (version_compare(JVERSION, '5.0', '>=')) {
            $cfg['compat_guard'] = htp_post_bool('htp_compat_guard');
        }

        if (HtpConfig::save($cfg)) {
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_WATCH_SAVED'));
        } else {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_WATCH_SAVE_FAIL'));
        }
    }

    /**
     * Test-Mail an die eingetragene(n) Adresse(n) senden - prueft, ob der
     * Mailversand fuer die Ueberwachungs-Alarme ueberhaupt funktioniert.
     * Adresse aus dem Formular (Test auch vor dem Speichern moeglich), sonst
     * die gespeicherte. Sendet NICHT an etwas anderes als die geprueften
     * Empfaenger; speichert nichts.
     */
    private static function taskWatchTestMail(&$cfg, &$messages)
    {
        $raw = trim(htp_post('htp_notify_email', ''));

        if ($raw === '') {
            $raw = (string) $cfg['notify_email'];
        }

        $parsed = HtpConfig::cleanEmailList($raw);

        if (!empty($parsed['invalid'])) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_WATCH_EMAIL_INVALID'));

            return;
        }

        if (empty($parsed['valid'])) {
            $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_WATCH_NO_EMAIL'));

            return;
        }

        $to    = implode(', ', $parsed['valid']);
        $error = '';

        $ok = htp_send_mail(
            $to,
            htp_t('COM_HTPROTECT_WATCH_TEST_SUBJECT'),
            htp_t('COM_HTPROTECT_WATCH_TEST_BODY', htp_site_url()),
            '',
            $error
        );

        if ($ok) {
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_WATCH_TEST_SENT', $to));
        } else {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_WATCH_TEST_FAIL', $error));
        }
    }

    /**
     * Automatische Ueberwachung (Hintergrund-Plugin) ein-/ausschalten.
     */
    private static function taskWatchToggle(&$cfg, &$messages, $enable)
    {
        if ($enable) {
            $state = htp_extension_enabled('plugin', 'htprotectwatch', 'system');

            if (!$state['installed']) {
                $dir = realpath(HTP_CORE . '/../assets/plg_htprotectwatch');

                if ($dir === false || !htp_install_from_dir($dir)) {
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_WATCH_PLUGIN_INSTALL_FAIL'));

                    return;
                }
            }

            if (self::setPluginEnabled(1)) {
                // E-Mail des eingeloggten Super-Users vorbelegen, falls noch leer.
                $prefilled = self::prefillNotifyEmail($cfg);
                // Manifest direkt anlegen, damit der erste Lauf eine Basis hat
                HtpWatch::remember($cfg);
                HtpConfig::save($cfg);
                $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_WATCH_ENABLED'));

                if ($prefilled !== '') {
                    $messages[] = self::msg('info', htp_t('COM_HTPROTECT_WATCH_EMAIL_PREFILLED', htp_esc($prefilled)));
                } elseif (trim((string) $cfg['notify_email']) === '') {
                    $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_WATCH_NO_EMAIL'));
                }
            } else {
                $messages[] = self::msg('err', htp_t('COM_HTPROTECT_WATCH_ENABLE_FAIL'));
            }
        } else {
            self::setPluginEnabled(0);
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_WATCH_DISABLED'));
        }
    }

    /**
     * 1-Klick (Uebersicht): HTProtect selbst automatisch aktuell halten.
     * Delegiert an HtpAutoUpdate (Master + Selbst-Sofort, ohne Karenz, Scheduler-Sync);
     * beruehrt keine anderen Auto-Update-Optionen. Nur wirksam, wo die Auto-Update-
     * Maschine ueberhaupt greift; sonst neutraler Hinweis.
     */
    private static function taskAuSelfOn(&$cfg, &$messages)
    {
        if (!class_exists('HtpAutoUpdate') || !HtpAutoUpdate::enableSelfImmediate($cfg)) {
            $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_AU_SELF_ON_FAIL'));

            return;
        }

        $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_AU_SELF_ON_OK'));
    }

    /**
     * 1-Klick (Uebersicht): HTProtect-Selbst-Sofort-Modus wieder abschalten.
     * Sicherheitsupdates anderer Erweiterungen (Master) bleiben unberuehrt.
     */
    private static function taskAuSelfOff(&$cfg, &$messages)
    {
        if (class_exists('HtpAutoUpdate')) {
            HtpAutoUpdate::disableSelfImmediate($cfg);
        } else {
            $cfg['autoupdate_self'] = 0;
            HtpConfig::save($cfg);
        }

        $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_AU_SELF_OFF_OK'));
    }

    /**
     * Eine angreifbare Erweiterung aufschieben (still stellen). SICHER: es laesst sich NUR
     * ein WIRKLICH aktuell erkannter Fund aufschieben (kein Speichern beliebiger Schluessel);
     * gespeichert wird ein Schnappschuss aus Version + Severity + mitigated, an dem der
     * Auto-Verfall haengt (geaenderte Version/eskalierte Severity -> warnt wieder).
     *
     * @return void
     */
    private static function taskVulnAck(&$cfg, &$messages)
    {
        $extKey = (string) htp_post('htp_extkey', '');

        if ($extKey === '' || !class_exists('HtpDefs')) {
            return;
        }

        $match = null;

        foreach (HtpDefs::checkExtensions() as $hit) {
            if (HtpDefs::vulnExtKey($hit) === $extKey) {
                $match = $hit;
                break;
            }
        }

        if ($match === null) {
            return; // nicht (mehr) erkannt -> nichts speichern
        }

        $ack = (isset($cfg['vuln_ack']) && is_array($cfg['vuln_ack'])) ? $cfg['vuln_ack'] : array();
        $ack[$extKey] = array(
            'version'   => (string) (isset($match['installed']) ? $match['installed'] : ''),
            'severity'  => (string) (isset($match['severity']) ? $match['severity'] : 'high'),
            'below'     => (string) (isset($match['below']) ? $match['below'] : ''),
            'mitigated' => !empty($match['mitigated']),
            'ts'        => time(),
        );
        $cfg['vuln_ack'] = $ack;
        HtpConfig::save($cfg);

        $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_VULN_ACK_OK'));
    }

    /**
     * Eine aufgeschobene Warnung wieder aktivieren (Marker entfernen).
     *
     * @return void
     */
    private static function taskVulnUnack(&$cfg, &$messages)
    {
        $extKey = (string) htp_post('htp_extkey', '');
        $ack    = (isset($cfg['vuln_ack']) && is_array($cfg['vuln_ack'])) ? $cfg['vuln_ack'] : array();

        if ($extKey !== '' && isset($ack[$extKey])) {
            unset($ack[$extKey]);
            $cfg['vuln_ack'] = $ack;
            HtpConfig::save($cfg);
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_VULN_UNACK_OK'));
        }
    }

    /**
     * Helix-JS-Fund als unbedenklich markieren (Ignorieren-Allowlist). Nimmt id + Signatur
     * NUR an, wenn der Stil AKTUELL genau diese Tier-1-Signatur traegt (frischer Abgleich).
     * So wird kein Alt-/Fremd-Wert gespeichert; aendert sich der Inhalt spaeter (echte
     * Injektion), passt die gespeicherte Signatur nicht mehr -> der Alarm kommt zurueck.
     */
    private static function taskHelixIgnore(&$cfg, &$messages)
    {
        $id  = (int) htp_post('htp_helix_id', 0);
        $sig = preg_replace('~[^0-9a-f]~', '', strtolower((string) htp_post('htp_helix_sig', '')));

        if ($id <= 0 || strlen($sig) !== 40 || !class_exists('HtpHelix')) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_HELIX_IGNORE_FAIL'));

            return;
        }

        $match = false;

        foreach (HtpHelix::scan() as $s) {
            if ((int) $s['id'] === $id && !empty($s['suspicious']) && isset($s['sig']) && hash_equals((string) $s['sig'], $sig)) {
                $match = true;
                break;
            }
        }

        if (!$match) {
            // Inhalt hat sich seit der Anzeige geaendert -> nicht blind ignorieren.
            $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_HELIX_IGNORE_CHANGED'));

            return;
        }

        $ignore = (isset($cfg['helix_ignore']) && is_array($cfg['helix_ignore'])) ? $cfg['helix_ignore'] : array();
        $ignore[(string) $id] = $sig;
        $cfg['helix_ignore']  = $ignore;
        HtpConfig::save($cfg);

        $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_HELIX_IGNORED'));
    }

    /**
     * Verwaltete Datei nach Fremdaenderung wiederherstellen.
     */
    private static function taskWatchRestore(&$cfg, &$messages)
    {
        $rel    = htp_post('htp_watch_path', '');
        $result = HtpWatch::restore($cfg, $rel);
        $messages[] = self::msg($result['success'] ? 'ok' : 'err', $result['message']);
    }

    private static function taskWatchDismiss(&$cfg, &$messages)
    {
        $rel    = htp_post('htp_watch_path', '');
        $result = HtpWatch::accept($cfg, $rel); // aktuellen Stand als Baseline akzeptieren

        if (!empty($result['success'])) {
            HtpConfig::save($cfg); // Manifest-Aenderung sicher persistieren
        }

        $messages[] = self::msg($result['success'] ? 'ok' : 'err', $result['message']);
    }

    /**
     * Notfall-Modus (komplette Seite sperren) ein-/ausschalten. Eigenes Cookie-Gate,
     * UNABHAENGIG vom /administrator-Schutz (kein .htpasswd noetig). Beim Aktivieren wird
     * ein frisches Zugangs-Token erzeugt und die statische 503-Wartungsseite geschrieben.
     */
    private static function taskEmergency(&$cfg, &$messages, &$data, $enable)
    {
        if (!self::emergencyAuthorised($messages)) {
            return;
        }

        $ok = self::applyEmergency($enable, $cfg, $messages, $data);

        if ($enable) {
            if ($ok) {
                // Diesen Browser sofort eintragen (kein Selbst-Aussperren, noch bevor der Link
                // ueberhaupt aufgerufen wurde). Der "Website oeffnen"-Link ist der sichtbare Fallback.
                self::emergencySetCookie($cfg);
                $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_EMERGENCY_ON'));

                if (self::sendEmergencyLinkMail($cfg)) {
                    $messages[] = self::msg('info', htp_t('COM_HTPROTECT_EMERGENCY_MAIL_SENT'));
                } else {
                    $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_EMERGENCY_MAIL_NONE'));
                }
            }
            // Bei Fehlschlag hat writeShield bereits zurueckgerollt + eine Meldung gesetzt.
        } else {
            if ($ok) {
                $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_EMERGENCY_OFF'));
            }
            // Bei Fehlschlag (Entsperren) hat writeShield bereits eine Meldung gesetzt.
        }
    }

    /**
     * Kern des Notfall-Modus (Ein/Aus) OHNE UI-Autorisierung/Mail - gemeinsam genutzt vom Backend-
     * Task UND vom signierten Dashboard-Fernbefehl (remoteEmergency). Kapselt die kritische
     * Reihenfolge: Notaus-Datei raeumen, Token, Wartungsseite, Config, dann die .htaccess mit
     * Selbsttest (+ Rollback bei Aussperrung). Gibt true bei Erfolg zurueck.
     *
     * @return boolean
     */
    private static function applyEmergency($enable, &$cfg, &$messages, &$data)
    {
        if ($enable) {
            // Eine liegengebliebene Notaus-Datei ZUERST entfernen - sonst haette die .htaccess-
            // Ausnahme den frisch gesetzten Notfall-Modus sofort wieder ausgehebelt.
            self::emergencyClearFlag();

            // Frisches Token (setzt zugleich alte Links/Cookies ausser Kraft) + Wartungs-
            // seite aus den Sprachdateien in den Docroot schreiben, DANN die .htaccess.
            $cfg['emergency_key']  = self::emergencyToken();
            $cfg['emergency_lock'] = 1;
            self::writeMaintenancePage($cfg);
            HtpConfig::save($cfg);

            // Selbsttest: die Startseite liefert jetzt 503 (temporaer) statt 2xx.
            $ok = self::writeShield($cfg, $messages, $data, array('expect_home_401' => true));

            if (!$ok) {
                $cfg['emergency_lock'] = 0;
                $cfg['emergency_key']  = '';
                HtpConfig::save($cfg);
                self::removeMaintenancePage();
            }

            return $ok;
        }

        // Beenden: die .htaccess ZUERST ohne Block schreiben (dafuer muss lock=0 im in-memory
        // $cfg stehen). Config + Notaus-Datei erst NACH erfolgreichem Entsperren angleichen -
        // sonst behauptete die Config "offen", waehrend der 503-Block noch stuende, und der
        // Orphan-Zweig loeschte spaeter die Notaus-Datei (= dauerhaft gesperrt trotz "aus").
        $prevKey               = isset($cfg['emergency_key']) ? (string) $cfg['emergency_key'] : '';
        $cfg['emergency_lock'] = 0;
        $cfg['emergency_key']  = '';
        $ok                    = self::writeShield($cfg, $messages, $data);

        if ($ok) {
            HtpConfig::save($cfg);
            self::removeMaintenancePage();
            self::emergencyClearFlag();

            return true;
        }

        // Entsperren fehlgeschlagen (.htaccess nicht schreibbar o. ae.): der 503-Block steht noch
        // -> Config NICHT auf "aus" zementieren und die Notaus-Datei als Ausweg BEHALTEN. In-Memory
        // zuruecksetzen, damit der gespeicherte Stand (lock=1) und $cfg uebereinstimmen.
        $cfg['emergency_lock'] = 1;
        $cfg['emergency_key']  = $prevKey;

        return false;
    }

    /**
     * Zugangs-Token rotieren: neuer geheimer Link, ALLE alten Links/Cookies sofort tot -
     * ohne den Notfall-Modus zu beenden (Notbremse bei geleaktem Link / verlorenem Geraet).
     */
    private static function taskEmergencyRekey(&$cfg, &$messages, &$data)
    {
        if (!self::emergencyAuthorised($messages)) {
            return;
        }

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

        $old = isset($cfg['emergency_key']) ? (string) $cfg['emergency_key'] : '';
        $cfg['emergency_key'] = self::emergencyToken();
        HtpConfig::save($cfg);

        if (self::writeShield($cfg, $messages, $data, array('expect_home_401' => true))) {
            // Neues Token sofort in diesen Browser (das alte Cookie ist jetzt ungueltig).
            self::emergencySetCookie($cfg);
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_EMERGENCY_REKEYED'));

            if (self::sendEmergencyLinkMail($cfg)) {
                $messages[] = self::msg('info', htp_t('COM_HTPROTECT_EMERGENCY_MAIL_SENT'));
            } else {
                $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_EMERGENCY_MAIL_NONE'));
            }
        } else {
            // writeShield hat die .htaccess (alter Key) zurueckgerollt -> config wieder
            // angleichen, sonst zeigen UI/config den NEUEN Key, die .htaccess aber den alten
            // (alte Geheimlinks wuerden weiterleben, der neue Link funktioniert nicht).
            $cfg['emergency_key'] = $old;
            HtpConfig::save($cfg);
        }
    }

    /**
     * Eigenen Text der Wartungsseite speichern (Ueberschrift + Text). Reiner Plaintext:
     * HTML wird entfernt (die Seite wird Besuchern gezeigt -> kein XSS), Laenge gedeckelt.
     * Danach die statische Wartungsseite neu erzeugen. Leer = Sprach-Default (de/en).
     */
    private static function taskEmergencyPageSave(&$cfg, &$messages, &$data)
    {
        if (!self::emergencyAuthorised($messages)) {
            return;
        }

        // Titel bleibt REINER Text (Formularfeld) -> alle Tags raus. Der Body ist WYSIWYG-HTML
        // und wird auf eine sichere HTML-Whitelist gefiltert (kein Script-Tag, keine Event-Handler).
        $title = strip_tags(trim(htp_post('htp_em_title', '')));
        $text  = self::sanitizeMaintenanceHtml((string) htp_post('htp_em_text', ''));

        if (function_exists('mb_substr')) {
            $title = mb_substr($title, 0, 200);
            $text  = mb_substr($text, 0, 20000);
        } else {
            $title = substr($title, 0, 200);
            $text  = substr($text, 0, 20000);
        }

        // Der Standardtext ist im Formular vorbelegt. Nur wenn er KOMPLETT UNANGETASTET geblieben
        // ist (oder geleert wurde), wieder als "leer" ablegen -> dann nutzt die Wartungsseite den
        // automatisch zweisprachigen de/en-Default (siehe HtpBuilder::emergencyMaintenanceHtml).
        // WICHTIG: Der Body-Vergleich erfolgt ueber das ROH-HTML, NICHT ueber den reinen Text -
        // sonst gaelte "Standardtext nur formatiert (fett/Absaetze)" faelschlich als unveraendert
        // und die Formatierung ginge verloren. Ein Fehlurteil hier ist harmlos: dann wird der
        // (formatierte) Eigen-Text einsprachig gezeigt statt der automatischen Zweisprachigkeit.
        $defTitle    = trim((string) htp_t('COM_HTPROTECT_MAINT_TITLE'));
        $prefillBody = self::sanitizeMaintenanceHtml('<p>' . htp_t('COM_HTPROTECT_MAINT_BODY') . '</p>');

        if ((trim($title) === '' || trim($title) === $defTitle)
            && ($text === '' || $text === $prefillBody)) {
            $title = '';
            $text  = '';
        }

        $cfg['emergency_title'] = $title;
        $cfg['emergency_text']  = $text;
        HtpConfig::save($cfg);

        // Nur neu schreiben, wenn der Notfall-Modus aktiv ist (sonst existiert die Seite nicht).
        if (!empty($cfg['emergency_lock'])) {
            self::writeMaintenancePage($cfg);
        }

        $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_EMERGENCY_PAGE_SAVED'));
    }

    /**
     * Zufaelliges Zugangs-Token (12 Zeichen [A-Za-z0-9]). Reicht als Lesezeichen-Geheimnis
     * (~71 Bit) und ist .htaccess-sicher. htp_random_bytes ist PHP-5.6-fest.
     *
     * @return string
     */
    private static function emergencyToken()
    {
        $alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
        $n     = strlen($alpha);
        $raw   = function_exists('htp_random_bytes') ? (string) htp_random_bytes(16) : '';
        $out   = '';

        for ($i = 0, $l = strlen($raw); $i < $l; $i++) {
            $out .= $alpha[ord($raw[$i]) % $n];
        }

        while (strlen($out) < 12) {
            $out .= $alpha[mt_rand(0, $n - 1)];
        }

        return substr($out, 0, 12);
    }

    /**
     * WYSIWYG-Body der Wartungsseite auf eine sichere HTML-Whitelist filtern. Die Eingabe stammt
     * vom Super-Admin, aber die Seite wird oeffentlich ausgeliefert -> Defense-in-Depth. Joomlas
     * InputFilter entfernt Script-Tags, Event-Handler (on...), gefaehrliche Protokolle etc. und laesst
     * uebliche Formatierung (Absaetze, Listen, Links, Bilder, Fett/Kursiv, Ueberschriften) stehen.
     * Faellt auf strip_tags (mit Whitelist) zurueck, falls die Klasse fehlt.
     *
     * @param   string  $html
     *
     * @return string
     */
    private static function sanitizeMaintenanceHtml($html)
    {
        $html = trim((string) $html);

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

        try {
            if (class_exists('Joomla\\CMS\\Filter\\InputFilter')) {
                $filter = Joomla\CMS\Filter\InputFilter::getInstance();

                return trim((string) $filter->clean($html, 'html'));
            }
        } catch (\Throwable $e) {
            // Fallback unten
        }

        return trim(strip_tags($html, '<p><br><strong><b><em><i><u><ul><ol><li><a><h2><h3><h4><span><div><img><blockquote><hr><small><code><pre>'));
    }

    /** Statische 503-Wartungsseite (de/en oder Eigen-Text) in den Docroot schreiben. */
    private static function writeMaintenancePage($cfg)
    {
        if (function_exists('htp_docroot') && class_exists('HtpBuilder')) {
            @file_put_contents(htp_docroot() . '/htp-maintenance.html', HtpBuilder::emergencyMaintenanceHtml($cfg));
        }
    }

    /** Wartungsseite wieder entfernen (beim Beenden / bei fehlgeschlagener Aktivierung). */
    private static function removeMaintenancePage()
    {
        if (function_exists('htp_docroot')) {
            $file = htp_docroot() . '/htp-maintenance.html';

            if (is_file($file)) {
                @unlink($file);
            }
        }
    }

    /**
     * Pfad der "Notaus"-Datei im Document-Root. Genau diese Datei prueft der Notfall-.htaccess-
     * Block (RewriteCond %{DOCUMENT_ROOT}/htp-emergency-off !-f) UND setzt der signierte Rescue-
     * Endpunkt. $_SERVER['DOCUMENT_ROOT'] = derselbe Wert, den Apache in der .htaccess sieht ->
     * pruefende und schreibende Seite stimmen ueberein. Fallback htp_docroot() nur im CLI.
     * Seltener FPM-Override (PHP-DOCUMENT_ROOT weicht von Apaches ab): der SOFORT-Ausweg per
     * .htaccess-Ausnahme greift dann nicht, aber emergencyFinalizeIfFlagged nutzt denselben
     * $_SERVER-Wert und entsperrt beim naechsten Heartbeat/Backend-Aufruf -> Recovery bleibt,
     * nur nicht sofort.
     *
     * @return string  absoluter Pfad oder '' wenn unbestimmbar
     */
    private static function emergencyFlagPath()
    {
        $dr = isset($_SERVER['DOCUMENT_ROOT']) ? rtrim(str_replace('\\', '/', (string) $_SERVER['DOCUMENT_ROOT']), '/') : '';

        if ($dr === '' && function_exists('htp_docroot')) {
            $dr = rtrim(str_replace('\\', '/', (string) htp_docroot()), '/');
        }

        return $dr === '' ? '' : $dr . '/htp-emergency-off';
    }

    /** Notaus-Datei entfernen (bei Aktivierung: eine alte weg; beim Beenden: aufraeumen). */
    private static function emergencyClearFlag()
    {
        $flag = self::emergencyFlagPath();

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

    /**
     * Diesen Browser sofort als Cookie-Inhaber eintragen (htp_em=<token>), damit sich der
     * aktivierende Super-User NICHT selbst aussperrt - noch BEVOR er den Zugangs-Link ueberhaupt
     * aufruft. Nur im Backend-UI-Task (echter Browser-Request); der Fernbefehl setzt bewusst kein
     * Cookie (kein Browser-Kontext). PHP-5.6-sichere setcookie-Signatur (kein SameSite-Array).
     * Der Wert = Token; die .htaccess prueft nur die Anwesenheit von htp_em=<token>.
     */
    private static function emergencySetCookie($cfg)
    {
        $key = isset($cfg['emergency_key']) ? (string) $cfg['emergency_key'] : '';

        if ($key === '' || headers_sent()) {
            return;
        }

        // Secure nur bei HTTPS-Zwang (deckt sich mit dem CO-Flag in der .htaccess; sonst wuerde ein
        // reiner HTTP-Zugang das Secure-Cookie nicht speichern). Lange Lebensdauer (1 Jahr) wie das
        // CO-Flag; die echte Gueltigkeit steuert ohnehin emergency_lock. Pfad '/', HttpOnly.
        $secure = !empty($cfg['force_https']);
        @setcookie('htp_em', $key, time() + 31536000, '/', '', $secure, true);
        $_COOKIE['htp_em'] = $key; // im laufenden Request konsistent (z. B. fuer den Banner)
    }

    /**
     * Notaus erkannt? Wurde die Freigabe-Datei angelegt (Dashboard-Fernbefehl ueber rescue.php ODER
     * manuell per FTP), den Notfall-Modus jetzt sauber zu Ende bringen: Sperre + .htaccess-Block +
     * Datei entfernen. Self-contained (eigene $messages/$data, wie selfHealShield) - laeuft aus dem
     * Backend-Request UND aus dem Connector-Heartbeat. Idempotent, fail-open.
     *
     * @return boolean  true = es wurde gerade finalisiert (Aufrufer sollte $cfg neu laden)
     */
    public static function emergencyFinalizeIfFlagged()
    {
        try {
            if (!class_exists('HtpConfig')) {
                return false;
            }

            $flag = self::emergencyFlagPath();

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

            $cfg = HtpConfig::load();

            if (HtpConfig::loadFailed()) {
                return false; // transient unlesbar -> nicht auf Defaults handeln
            }

            if (empty($cfg['emergency_lock'])) {
                // Verwaiste Notaus-Datei (Notfall-Modus ist gar nicht aktiv) -> nur wegraeumen.
                self::emergencyClearFlag();

                return false;
            }

            $messages = array();
            $data     = array();

            // true NUR bei echtem Entsperren (Flag dann geloescht). Schlaegt writeShield fehl,
            // bleibt lock=1 + die Notaus-Datei erhalten -> der naechste Trigger versucht es erneut.
            return self::applyEmergency(false, $cfg, $messages, $data);
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open
        }

        return false;
    }

    /**
     * Notfall-Modus per signiertem Dashboard-Fernbefehl schalten. KEINE UI-Autorisierung: die
     * Ed25519-Signatur im Befehlskanal (link.php verifyCommand) IST die Autoritaet. Self-contained
     * (eigene $messages/$data, wie selfHealShield). $action: 'on' | 'off' | 'rekey'.
     *
     * Zustellung: 'on' laeuft ueber den normalen Poll (die Site ist beim Aktivieren noch offen).
     * Das zuverlaessige 'off' bei bereits gesperrter Seite laeuft NICHT hierueber (der Poll
     * verhungert ohne Traffic), sondern ueber den ausgenommenen rescue.php-Push, der die Notaus-
     * Datei setzt; emergencyFinalizeIfFlagged() raeumt dann auf. Dieses 'off' ist der saubere
     * Direkt-Weg, wenn die Site noch erreichbar ist.
     *
     * @return array  ['ok'=>bool, 'code'=>string]
     */
    public static function remoteEmergency($action)
    {
        if (!class_exists('HtpConfig') || !class_exists('HtpBuilder') || !class_exists('HtpProbe')) {
            return array('ok' => false, 'code' => 'no_engine');
        }

        $cfg = HtpConfig::load();

        if (HtpConfig::loadFailed()) {
            return array('ok' => false, 'code' => 'cfg_unreadable');
        }

        $messages = array();
        $data     = array();

        if ($action === 'on') {
            // OHNE HTTP-Selbsttest KEIN headless-Schreiben (kein Rollback-Netz) -> ablehnen.
            if (!HtpProbe::available()) {
                return array('ok' => false, 'code' => 'no_selftest');
            }

            $ok = self::applyEmergency(true, $cfg, $messages, $data);

            if ($ok) {
                // Backup-Mail an die Benachrichtigungs-Adresse - wie beim Backend-Task, damit der
                // Zugangs-Link auch bei Dashboard-Aktivierung zusaetzlich per Mail ankommt (fail-soft).
                self::sendEmergencyLinkMail($cfg);
            }

            return $ok
                ? array('ok' => true, 'code' => 'emergency_on')
                : array('ok' => false, 'code' => 'emergency_on_failed');
        }

        if ($action === 'off') {
            // Beenden ist die SICHERE Richtung (stellt Zugang wieder her) -> kein Probe-Gate.
            $ok = self::applyEmergency(false, $cfg, $messages, $data);

            return $ok
                ? array('ok' => true, 'code' => 'emergency_off')
                : array('ok' => false, 'code' => 'emergency_off_failed');
        }

        if ($action === 'rekey') {
            if (empty($cfg['emergency_lock'])) {
                return array('ok' => false, 'code' => 'emergency_not_active');
            }

            $old                  = isset($cfg['emergency_key']) ? (string) $cfg['emergency_key'] : '';
            $cfg['emergency_key'] = self::emergencyToken();
            HtpConfig::save($cfg);

            if (self::writeShield($cfg, $messages, $data, array('expect_home_401' => true))) {
                self::sendEmergencyLinkMail($cfg); // neuen Link auch per Mail (fail-soft)

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

            // Rollback der .htaccess (alter Key) -> config angleichen (keine Divergenz).
            $cfg['emergency_key'] = $old;
            HtpConfig::save($cfg);

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

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

    /**
     * Notfall-Aktionen bleiben super-user-only (Defense in Depth, auch bei su_only=AUS): ein
     * normaler Administrator darf nicht die ganze Seite per 503 sperren (DoS gegen Super-User).
     * Gleiche Pruefung wie taskStealthSave/taskSuOnlySave - der POST-Endpunkt braucht sie selbst.
     *
     * @return boolean
     */
    private static function emergencyAuthorised(&$messages)
    {
        $u = function_exists('htp_user') ? htp_user() : null;

        if (!$u || !method_exists($u, 'authorise') || !$u->authorise('core.admin', 'com_htprotect')) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_ERR_TOKEN'));

            return false;
        }

        return true;
    }

    /**
     * Den geheimen Zugangs-Link zusaetzlich per Mail an die Benachrichtigungs-Adresse
     * schicken (Backup, falls Dashboard/Backend mal nicht erreichbar sind). Fail-soft -
     * eine fehlgeschlagene Info-Mail darf die Aktivierung nie stoeren. In Mail-Sprache.
     *
     * @return boolean  true = versendet (Empfaenger gesetzt), sonst false
     */
    private static function sendEmergencyLinkMail($cfg)
    {
        $to = isset($cfg['notify_email']) ? trim((string) $cfg['notify_email']) : '';

        if ($to === '' || !function_exists('htp_send_mail')) {
            return false;
        }

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

        if ($key === '' || $base === '' || $base === '/') {
            return false;
        }

        $link     = $base . '/?htp_' . $key;
        $siteHost = parse_url($base, PHP_URL_HOST);
        $siteHost = $siteHost ? $siteHost : preg_replace('~^https?://~i', '', $base);

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

        $subject = htp_t('COM_HTPROTECT_EMERGENCY_MAIL_SUBJECT');

        // Plain-Text
        $lines   = array();
        $lines[] = htp_t('COM_HTPROTECT_EMERGENCY_MAIL_INTRO');
        $lines[] = '';

        if ($siteHost !== '') {
            $lines[] = htp_t('COM_HTPROTECT_MAIL_SITE_LABEL') . ' ' . $base;
        }

        $lines[] = $link;
        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_MAIL_SIGNATURE');

        // HTML (optional)
        $html = '';

        if (class_exists('HtpMail')) {
            $content = HtpMail::lineP(htp_t('COM_HTPROTECT_EMERGENCY_MAIL_INTRO'))
                . ($siteHost !== '' ? HtpMail::siteLine(htp_t('COM_HTPROTECT_MAIL_SITE_LABEL'), $siteHost, $base) : '')
                . HtpMail::button(htp_t('COM_HTPROTECT_EMERGENCY_MAIL_BTN'), $link, 'primary')
                . HtpMail::muted($link);
            $html    = HtpMail::wrap($subject, $content);
        }

        $err = '';

        try {
            htp_send_mail($to, $subject, implode("\n", $lines), '', $err, array(), $html !== '' ? $html : null);
        } catch (Exception $e) {
            // egal - Info-Mail darf die Aktivierung nie stoeren
        } catch (Throwable $e) {
            // egal
        }

        return true;
    }

    /**
     * Mitgeliefertes Echtzeit-Schutz-Plugin im normalen Admin-Request
     * (nie im Installer -> keine Re-Entrancy/Stack-Overflow) verwalten:
     *  - Beim ERSTEN Oeffnen der Komponente einmalig installieren UND
     *    aktivieren (Echtzeit-Schutz ab Werk an). Das Flag plugin_initialized
     *    verhindert, dass eine spaetere bewusste Deinstallation rueckgaengig
     *    gemacht wird.
     *  - Ist es installiert aber veraltet: aus den Assets auffrischen.
     * Fail-silent.
     *
     * @param   array  $cfg       Konfiguration (Referenz)
     * @param   array  $messages  Meldungen (Referenz)
     *
     * @return void
     */
    private static function syncWatchPlugin(&$cfg, &$messages)
    {
        try {
            $state = htp_extension_enabled('plugin', 'htprotectwatch', 'system');
            $dir   = realpath(HTP_CORE . '/../assets/plg_htprotectwatch');

            if (empty($state['installed'])) {
                // Verwaiste Dateien OHNE DB-Eintrag (nur via "Installieren ->
                // Ueberpruefen" auffindbar) IMMER heilen; sonst nur beim ersten
                // Mal automatisch installieren. Eine bewusste Deinstallation
                // entfernt auch die Dateien -> bleibt respektiert (Flag = 1).
                $onDisk = @is_file(htp_root() . '/plugins/system/htprotectwatch/htprotectwatch.php');

                if (!$onDisk && !empty($cfg['plugin_initialized'])) {
                    return;
                }

                // Das Flag erst NACH erfolgreicher Installation setzen: so sperrt
                // ein einmaliger Fehlschlag den Waechter nicht dauerhaft aus
                // (Retry beim naechsten Komponenten-Aufruf). Robust: verwaiste
                // Reste werden vor dem Installieren entfernt (htp_install_plugin_clean).
                if ($dir !== false && is_dir($dir)
                    && htp_install_plugin_clean($dir, 'htprotectwatch', 'system') && self::setPluginEnabled(1)) {
                    $prefilled = self::prefillNotifyEmail($cfg);
                    $cfg['plugin_initialized'] = 1;
                    HtpConfig::save($cfg);

                    if (class_exists('HtpCompat')) {
                        HtpCompat::ensureWatchFirst();
                    }

                    $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_WATCH_AUTOINIT'));

                    if ($prefilled !== '') {
                        $messages[] = self::msg('info', htp_t('COM_HTPROTECT_WATCH_EMAIL_PREFILLED', htp_esc($prefilled)));
                    }
                }

                return;
            }

            // Installiert: Dateien fehlen (verwaister Eintrag ohne Dateien),
            // Versionsabweichung ODER die installierte Datei weicht inhaltlich vom
            // mitgelieferten Stand ab (Bugfix bei gleicher Version) -> aus den
            // Assets wiederherstellen/auffrischen.
            $onDiskInst = @is_file(htp_root() . '/plugins/system/htprotectwatch/htprotectwatch.php');
            $fileStale  = htp_plugin_file_stale(
                $dir . '/htprotectwatch.php',
                htp_root() . '/plugins/system/htprotectwatch/htprotectwatch.php'
            );

            if (($onDiskInst === false || self::installedPluginVersion() !== HTP_VERSION || $fileStale)
                && $dir !== false && is_dir($dir)) {
                htp_install_from_dir($dir);
            }

            // Waechter als ERSTES System-Plugin einsortieren (idempotent) - so ist
            // der Compat-Recovery-Shutdown-Handler vor fremden System-Plugins
            // verankert und der PHP-WAF/die Selbstheilung laufen vor ihnen. Greift
            // auch bei Bestandsinstallationen direkt beim naechsten Komponenten-Aufruf.
            if (class_exists('HtpCompat')) {
                HtpCompat::ensureWatchFirst();
            }
        } catch (Exception $e) {
            // still
        }
    }

    /**
     * Dashboard-Statuskachel (plg_quickicon_htprotect) genauso verwalten wie das
     * Hintergrund-Plugin: beim ersten Oeffnen einmalig installieren + aktivieren
     * (Flag quickicon_initialized verhindert ein Wieder-Aktivieren nach bewusster
     * Deinstallation), danach bei Versionsabweichung aus den Assets auffrischen.
     * Fail-silent - eine fehlende Kachel darf die Komponente nie stoeren.
     *
     * @param   array  $cfg  Konfiguration (Referenz)
     *
     * @return void
     */
    private static function syncQuickiconPlugin(&$cfg)
    {
        try {
            $dir = realpath(HTP_CORE . '/../assets/plg_quickicon_htprotect');

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

            $state = htp_extension_enabled('plugin', 'htprotect', 'quickicon');

            // Liegt die Plugin-Datei bereits auf der Platte? Wichtig fuer beide
            // verwaisten Reste: Dateien OHNE DB-Eintrag (Joomla listet sie dann
            // unter "Installieren -> Pruefen/Entdecken") UND DB-Eintrag OHNE Dateien.
            $onDisk = @is_file(htp_root() . '/plugins/quickicon/htprotect/htprotect.php');

            // (1) Kein #__extensions-Eintrag.
            if (empty($state['installed'])) {
                // Selbstheilung: liegen die Dateien schon (entdeckt/halb-installiert),
                // IMMER neu registrieren - unabhaengig vom Flag. Sonst (keine Dateien)
                // nur beim ersten Mal automatisch installieren; eine bewusste
                // Deinstallation entfernt auch die Dateien und bleibt so respektiert.
                if ($onDisk || empty($cfg['quickicon_initialized'])) {
                    // Robust: verwaiste Dateien (nur via "Ueberpruefen" auffindbar)
                    // erst entfernen, dann sauber installieren -> zuverlaessiger
                    // #__extensions-Eintrag statt erneutem Fehlschlag.
                    if (htp_install_plugin_clean($dir, 'htprotect', 'quickicon') && self::setExtEnabled('htprotect', 'quickicon', 1)) {
                        $cfg['quickicon_initialized'] = 1;
                        HtpConfig::save($cfg);
                    }
                }

                return;
            }

            // (2) DB-Eintrag vorhanden, aber Dateien fehlen (verwaister Rest, z. B.
            //     manuell/teilweise geloeschte Plugin-Dateien). Das ist kein
            //     bewusster Zustand -> Dateien aus den Assets wiederherstellen
            //     (Upgrade-Install behaelt den vorhandenen Aktiv-Status bei).
            if (!$onDisk) {
                htp_install_from_dir($dir);

                if (empty($cfg['quickicon_initialized']) && self::setExtEnabled('htprotect', 'quickicon', 1)) {
                    $cfg['quickicon_initialized'] = 1;
                    HtpConfig::save($cfg);
                }

                return;
            }

            // (3) Registriert + Dateien da, aber deaktiviert.
            if (empty($state['enabled'])) {
                // Hat die Erst-Initialisierung das Aktivieren nie abgeschlossen
                // (Flag noch nicht gesetzt), jetzt nachholen. Eine spaetere bewusste
                // Deaktivierung (Flag steht auf 1) bleibt unangetastet.
                if (empty($cfg['quickicon_initialized']) && self::setExtEnabled('htprotect', 'quickicon', 1)) {
                    $cfg['quickicon_initialized'] = 1;
                    HtpConfig::save($cfg);
                }

                return;
            }

            // (4) Registriert + aktiv -> auffrischen, wenn die installierte
            //     Version AELTER ist ODER die installierte Plugin-Datei inhaltlich
            //     vom mitgelieferten Stand abweicht. Letzteres ist entscheidend:
            //     ein Bugfix am Plugin bei GLEICHER Version (z. B. mehrere Builds
            //     unter derselben Nummer) wuerde sonst die Installation nie
            //     erreichen - die Datei friert ein.
            $fileStale = htp_plugin_file_stale(
                $dir . '/htprotect.php',
                htp_root() . '/plugins/quickicon/htprotect/htprotect.php'
            );

            $verOlder = false;

            if (class_exists('HtpDefs')) {
                $assetVer = HtpDefs::versionFromManifest($dir . '/htprotect.xml', 'plugin');
                $instVer  = self::installedExtVersion('htprotect', 'quickicon');
                $verOlder = ($assetVer !== '' && $instVer !== '' && version_compare($instVer, $assetVer, '<'));
            }

            if ($verOlder || $fileStale) {
                htp_install_from_dir($dir);
            }
        } catch (Exception $e) {
            // still - eine fehlende Kachel darf die Komponente nie stoeren.
        }
    }

    /**
     * Versions-Selbstheilung buendeln: Task-Plugin-Code/-Version auffrischen + eine nach
     * einem Self-Update steckengebliebene Komponenten-Version nachziehen. OEFFENTLICH, damit
     * es nicht nur beim Admin-Aufruf (HtpApp::run), sondern auch HEADLESS aus dem Auto-
     * Update-Task (HtpAutoUpdate::runStep, alle 15 Min via Scheduler + Cron) laufen kann -
     * so haengt nichts auf altem Stand, selbst wenn das Backend praktisch nie geoeffnet wird.
     * Billig + idempotent (1-2 Versions-Vergleiche; schreibt nur bei echter Abweichung).
     *
     * @return void
     */
    public static function selfHealVersions()
    {
        try {
            self::syncTaskPluginCode();
            self::selfHealComponentVersion();
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still
        }
    }

    /**
     * Schutzschild-Regelwerk HEADLESS aktuell halten (ohne Backend-Login) - laeuft ueber den
     * 15-Min-Task (HtpAutoUpdate::runStep, Scheduler + Cron + Waechter-Self-Trigger). Hat
     * HTProtect sein .htaccess-Regelwerk verbessert (shieldOutdated) UND ist der Schalter
     * `shield_autoheal` an, wird das Schild automatisch neu geschrieben - mit demselben
     * Selbsttest + Auto-Rollback wie beim manuellen "Jetzt absichern". So muss niemand nach
     * einem Update auf JEDER Seite von Hand absichern (Fleet-Verwaltung).
     *
     * SICHER by design:
     *  - schreibt NUR, wenn der HTTP-Selbsttest verfuegbar ist (HtpProbe::available) - sonst
     *    gaebe es kein Rollback-Netz (writeShield schreibt dann ungetestet); in dem Fall bleibt
     *    es beim Backend-Aufruf-Pfad (dort ist der Probe da).
     *  - Hands-off (htaccess_external) + nginx werden respektiert; NUR ein bereits UNSERES,
     *    aktives Schild wird angefasst; nur bei ECHTER Regelwerk-Aenderung (shieldOutdated).
     *  - Cooldown (shield_autoheal_last) gegen Dauer-Schreiben bei fehlschlagendem Write.
     * Fail-open: jeder Fehler wird verschluckt (der 15-Min-Task darf nie daran haengen).
     *
     * @return void
     */
    public static function selfHealShield()
    {
        try {
            if (!class_exists('HtpConfig') || !class_exists('HtpStorage')
                || !class_exists('HtpBuilder') || !class_exists('HtpProbe')
                || !function_exists('htp_nginx_mode')) {
                return;
            }

            $cfg = HtpConfig::load();

            // Selbst-Reset-Schutz: Konnte die gespeicherte Config nicht gelesen werden (transienter
            // DB-Fehler / an der 64-KB-Grenze abgeschnittenes JSON), NIE headless mit Defaults
            // weiterarbeiten und diese gar festschreiben - das ist der Weg, auf dem sich die
            // Einstellungen ganz ohne Zutun (alle 15 Min) auf Werkszustand zuruecksetzen. Vertagen.
            if (HtpConfig::loadFailed()) {
                return;
            }

            if (empty($cfg['shield_autoheal']) || !empty($cfg['htaccess_external']) || htp_nginx_mode($cfg)) {
                return;
            }

            // Cooldown wie im Backend-Pfad.
            if ((time() - (int) (isset($cfg['shield_autoheal_last']) ? $cfg['shield_autoheal_last'] : 0)) < 600) {
                return;
            }

            $st = HtpStorage::status($cfg);

            // Nur ein bereits UNSERES, aktives Schild - und nur bei echter Regelwerk-Aenderung.
            if (empty($st['exists']) || empty($st['ours'])
                || !HtpBuilder::shieldOutdated($cfg, HtpStorage::read())) {
                return;
            }

            // OHNE HTTP-Selbsttest KEIN headless-Schreiben (kein Rollback-Netz) -> vertagen.
            if (!HtpProbe::available()) {
                return;
            }

            $cfg['shield_autoheal_last'] = time();
            HtpConfig::save($cfg);

            $messages = array();
            $data     = array();
            self::writeShield($cfg, $messages, $data);

            // ENTSCHEIDEND: nach dem headless Auto-Heilen die Waechter-Baseline auf den
            // soeben von UNS geschriebenen .htaccess-Stand heben. Sonst vergleicht der
            // naechste Waechter-Lauf die neue .htaccess gegen die ALTE Pruefsumme und
            // meldet die EIGENE Auto-Heilung als "von aussen geaendert" (Fehlalarm-Mail).
            // remember() aktualisiert nur $cfg['watch_manifest'] -> muss gespeichert werden.
            if (class_exists('HtpWatch')) {
                HtpWatch::remember($cfg);
                HtpConfig::save($cfg);
            }
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open
        }
    }

    /**
     * NUTZER-GETRIGGERTES Heilen aus dem Dashboard ("Schutzschild heilen" / Sammelaktion "neu schreiben").
     *
     * Im Gegensatz zum headless selfHealShield() (das bewusst NUR eine vorhandene, driftende .htaccess
     * heilt) schreibt DIESES hier den Schild FORCE - erstellt also eine FEHLENDE/geloeschte .htaccess neu.
     * Das ist der ganze Sinn des Knopfes: ist die .htaccess weg, holt der Betreiber sie aus dem Dashboard
     * zurueck. Es respektiert die realen Grenzen (nginx/IIS werten keine .htaccess aus; Hands-off = extern
     * verwaltet) und gibt einen EHRLICHEN Code zurueck, statt pauschal "geheilt" zu melden.
     *
     * @return array{ok:boolean,code:string}
     */
    public static function healShieldNow()
    {
        try {
            if (!class_exists('HtpConfig') || !class_exists('HtpStorage')
                || !class_exists('HtpBuilder') || !function_exists('htp_nginx_mode')) {
                return array('ok' => false, 'code' => 'no_engine');
            }

            $cfg = HtpConfig::load();

            // Selbst-Reset-Schutz (wie selfHealShield): unlesbare Config -> NICHT mit Defaults schreiben.
            if (HtpConfig::loadFailed()) {
                return array('ok' => false, 'code' => 'config_unavailable');
            }

            // nginx/IIS werten keine .htaccess aus, extern = Hands-off. writeShield lehnt das ebenfalls ab,
            // aber wir geben dem Dashboard hier den KONKRETEN Grund zurueck (statt "write_failed").
            if (htp_nginx_mode($cfg)) {
                return array('ok' => false, 'code' => 'nginx_no_htaccess');
            }
            if (function_exists('htp_iis_mode') && htp_iis_mode($cfg)) {
                return array('ok' => false, 'code' => 'iis_no_htaccess');
            }
            if (!empty($cfg['htaccess_external'])) {
                return array('ok' => false, 'code' => 'htaccess_external');
            }

            // Force-Schreiben: writeShield sichert eine evtl. vorhandene Datei, baut das Regelwerk und
            // schreibt es - und erstellt dabei eine fehlende .htaccess NEU (hadFile=false ist ok). Bei
            // verfuegbarem HTTP-Selbsttest inkl. Auto-Rollback-Netz.
            $messages = array();
            $data     = array();

            if (!self::writeShield($cfg, $messages, $data)) {
                return array('ok' => false, 'code' => 'write_failed');
            }

            HtpConfig::save($cfg);

            // Waechter-Baseline auf den soeben von UNS geschriebenen Stand heben - sonst meldet der
            // naechste Waechter-Lauf die eigene Heilung als "von aussen geaendert" (Fehlalarm-Mail).
            if (class_exists('HtpWatch')) {
                HtpWatch::remember($cfg);
                HtpConfig::save($cfg);
            }

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

    /**
     * Task-Plugin (plg_task_htpautoupdate) code-/versionsaktuell halten - dem Muster von
     * Waechter/Quickicon folgend, aber NUR auffrischend: ist das Plugin bereits installiert
     * und weicht Version oder Datei vom mitgelieferten Stand ab, aus den Assets neu
     * einspielen. Die ERST-Installation + die Scheduler-Zeile bleiben bewusst ans Feature
     * gekoppelt (HtpAutoUpdate::syncTaskPlugin) - hier wird nichts Neues provisioniert.
     * Behebt den Versions-Mix: bisher wurde das Task-Plugin nur auf der "Backups &
     * Updates"-Seite angefasst (und dort nur installiert, nie aktualisiert). Fail-silent.
     *
     * @return void
     */
    private static function syncTaskPluginCode()
    {
        try {
            if (!function_exists('htp_extension_enabled') || !defined('HTP_CORE')) {
                return;
            }

            $info = htp_extension_enabled('plugin', 'htpautoupdate', 'task');

            // Nicht installiert -> nichts tun (Installation bleibt Feature-gegated).
            if (empty($info['installed'])) {
                return;
            }

            $dir = realpath(HTP_CORE . '/../assets/plg_task_htpautoupdate');

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

            $pluginsDir = defined('JPATH_PLUGINS') ? JPATH_PLUGINS : htp_root() . '/plugins';
            $dst        = $pluginsDir . '/task/htpautoupdate';

            $verMismatch = (self::installedExtVersion('htpautoupdate', 'task') !== HTP_VERSION);
            $fileStale   = htp_plugin_file_stale($dir . '/htpautoupdate.php', $dst . '/htpautoupdate.php');

            if (!$verMismatch && !$fileStale) {
                return; // alles aktuell - kein Aufwand
            }

            // Anders als Waechter/Quickicon laesst sich das "task"-Plugin per JInstaller
            // NICHT zuverlaessig ueber sich selbst aktualisieren (install() liefert false).
            // Daher der gleiche robuste, installer-freie Weg wie bei der Komponenten-
            // Selbstheilung + den Postflight-Copies in script.php: betroffene Code-/Manifest-
            // Dateien atomar kopieren und die DB-Version (manifest_cache) nachziehen.
            if (@is_dir($dst)) {
                foreach (array('htpautoupdate.php', 'htpautoupdate.xml') as $rel) {
                    $src     = $dir . '/' . $rel;
                    $dstFile = $dst . '/' . $rel;

                    if (@is_file($src) && @md5_file($src) !== @md5_file($dstFile)) {
                        $tmp = $dstFile . '.htpnew';

                        if (@copy($src, $tmp) && @filesize($tmp) === @filesize($src)) {
                            @rename($tmp, $dstFile); // atomar - nie eine halbe Datei
                        } else {
                            @unlink($tmp);
                        }
                    }
                }
            }

            self::patchExtVersion('plugin', 'htpautoupdate', 'task', HTP_VERSION);
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still
        }
    }

    /**
     * Self-Update-Selbstheilung: das in #__extensions registrierte manifest_cache-Versionsfeld
     * der Komponente an die TATSAECHLICH laufenden Dateien (HTP_VERSION) angleichen - in BEIDE
     * Richtungen, weil die laufenden Dateien die Wahrheit sind:
     *  - HOCH: ein Auto-Update-Self-Update konnte seine eigene Registrierung im laufenden Request
     *    nicht finalisieren (Ouroboros) -> DB hing auf ALT, „Verwalten" zeigte alt + Joomla bot
     *    dasselbe Update endlos erneut an.
     *  - RUNTER: ein Crash-Fenster-Notfall-Restore (Waechter-Plugin) hat den Code auf die ALTE
     *    Version zurueckgestellt, die DB aber auf NEU gelassen (er entfernt den In-Progress-Marker,
     *    bevor die DB-korrigierende Crash-Recovery laeuft) -> „Verwalten" zeigt NEU, obwohl der ALTE
     *    Code laeuft, und Joomla bietet das (evtl. Sicherheits-)Update nie wieder an. GENAU der Fall
     *    „zurueckgerollt auf X, Erweiterung steht auf Y".
     * GUARD: waehrend eines LAUFENDEN Self-Updates NICHT angleichen (da sind die Dateien evtl. schon
     * NEU + die DB frisch gebumpt, aber HTP_VERSION im laufenden Request noch ALT -> ein Abgleich
     * wuerde den frischen Bump faelschlich zurueckdrehen). Der Self-Marker deckt genau dieses Fenster
     * ab; nach Abschluss/Notfall-Restore ist er weg. Idempotent, fail-silent.
     *
     * @return void
     */
    private static function selfHealComponentVersion()
    {
        try {
            if (class_exists('HtpAutoUpdate') && HtpAutoUpdate::selfMarkerActive()) {
                return; // laeuft gerade ein Self-Update -> nicht mitten im Swap angleichen
            }

            $cur = self::installedExtVersion('com_htprotect', null, 'component');

            // DB-Etikett an die laufenden Dateien angleichen (hoch ODER runter). Im Normalfall
            // gleich -> patchExtVersion schreibt ohnehin nur bei echter Abweichung (idempotent).
            if ($cur !== '' && defined('HTP_VERSION') && (string) HTP_VERSION !== $cur) {
                self::patchExtVersion('component', 'com_htprotect', null, HTP_VERSION);
            }
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still
        }
    }

    /**
     * manifest_cache-Versionsfeld einer Erweiterung in #__extensions nachziehen - reiner
     * Metadaten-Fix (KEIN Script-Lauf, kein Installer). Schreibt nur, wenn die Version
     * wirklich abweicht. folder=null -> Bedingung entfaellt (fuer Komponenten). Fail-silent.
     *
     * @param   string       $type     extensions.type (component|plugin|...)
     * @param   string       $element  extensions.element
     * @param   string|null  $folder   extensions.folder (null = ignorieren)
     * @param   string       $newVer   Zielversion
     *
     * @return void
     */
    private static function patchExtVersion($type, $element, $folder, $newVer)
    {
        $db = function_exists('htp_db') ? htp_db() : null;

        if (!$db) {
            return;
        }

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName(array('extension_id', 'manifest_cache')))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote($type))
                ->where($db->quoteName('element') . ' = ' . $db->quote($element));

            if ($folder !== null && $folder !== '') {
                $q->where($db->quoteName('folder') . ' = ' . $db->quote($folder));
            }

            $db->setQuery($q);
            $row = $db->loadAssoc();

            if (!is_array($row) || empty($row['extension_id']) || empty($row['manifest_cache'])) {
                return;
            }

            $mc = json_decode((string) $row['manifest_cache'], true);

            if (!is_array($mc) || !isset($mc['version']) || (string) $mc['version'] === (string) $newVer) {
                return;
            }

            $mc['version'] = (string) $newVer;

            $u = $db->getQuery(true)
                ->update($db->quoteName('#__extensions'))
                ->set($db->quoteName('manifest_cache') . ' = ' . $db->quote(json_encode($mc)))
                ->where($db->quoteName('extension_id') . ' = ' . (int) $row['extension_id']);
            $db->setQuery($u);
            $db->execute();
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still
        }
    }

    /**
     * Installierte Version des Hintergrund-Plugins aus manifest_cache lesen.
     *
     * @return string '' wenn unbekannt
     */
    private static function installedPluginVersion()
    {
        return self::installedExtVersion('htprotectwatch', 'system');
    }

    /**
     * Installierte Version eines mitgelieferten Plugins aus manifest_cache lesen.
     *
     * @param   string  $element  Plugin-Element
     * @param   string  $folder   Plugin-Gruppe
     *
     * @return string '' wenn unbekannt
     */
    private static function installedExtVersion($element, $folder, $type = 'plugin')
    {
        $db = htp_db();

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

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName('manifest_cache'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote($type))
                ->where($db->quoteName('element') . ' = ' . $db->quote($element));

            // Komponenten haben kein Plugin-folder -> Bedingung nur fuer Plugins.
            if ($folder !== null && $folder !== '') {
                $query->where($db->quoteName('folder') . ' = ' . $db->quote($folder));
            }

            $db->setQuery($query);
            $raw = $db->loadResult();

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

                if (is_array($cache) && isset($cache['version'])) {
                    return (string) $cache['version'];
                }
            }
        } catch (Exception $e) {
            // unbekannt
        }

        return '';
    }

    /**
     * Support-Formular: Nachricht an den Entwickler senden (mit Reply-To
     * auf die Adresse des Absenders und optionalen Systeminfos).
     */
    private static function taskSupportSend(&$cfg, &$messages, &$data)
    {
        $name    = trim(htp_post('htp_sup_name', ''));
        $email   = trim(htp_post('htp_sup_email', ''));
        $subject = trim(htp_post('htp_sup_subject', ''));
        $text    = trim(htp_post('htp_sup_message', ''));
        $withSys = htp_post_bool('htp_sup_sysinfo');

        // Eingaben fuer eine erneute Anzeige im Formular merken
        $data['support'] = array('name' => $name, 'email' => $email, 'subject' => $subject, 'message' => $text, 'sysinfo' => $withSys);

        if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_SUPPORT_ERR_EMAIL'));

            return;
        }

        if ($text === '') {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_SUPPORT_ERR_MESSAGE'));

            return;
        }

        if (!class_exists('HtpProbe') || !function_exists('htp_send_mail')) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_SUPPORT_ERR_NOMAIL'));

            return;
        }

        $lines   = array();
        $lines[] = 'Support-Anfrage über HTProtect';
        $lines[] = '================================';
        $lines[] = 'Name:    ' . ($name !== '' ? $name : '(nicht angegeben)');
        $lines[] = 'E-Mail:  ' . $email;
        $lines[] = 'Betreff: ' . ($subject !== '' ? $subject : '(ohne)');
        $lines[] = '';
        $lines[] = 'Nachricht:';
        $lines[] = $text;

        if ($withSys) {
            $guard  = HtpGuard::status();
            $plugin = htp_extension_enabled('plugin', 'htprotectwatch', 'system');
            $st     = HtpStorage::status($cfg);

            $lines[] = '';
            $lines[] = '-- Systeminfos (vom Absender mitgesendet) --';
            $lines[] = 'Seite:        ' . htp_site_url();
            $lines[] = 'HTProtect:    ' . HTP_VERSION;
            $lines[] = 'Joomla:       ' . htp_jversion();
            $lines[] = 'PHP:          ' . PHP_VERSION;
            $lines[] = 'Server:       ' . htp_server_software();
            $cap = (isset($cfg['htaccess_cap']['r'])) ? (string) $cfg['htaccess_cap']['r'] : '-';
            $lines[] = 'nginx-Modus:  ' . (htp_nginx_mode($cfg) ? 'ja' : 'nein')
                . ' (SERVER_SOFTWARE nginx: ' . (htp_is_nginx() ? 'ja' : 'nein')
                . ', .htaccess-Test: ' . $cap . ')';
            $lines[] = 'Echtzeit-Schutz (Plugin): ' . (!empty($plugin['enabled']) ? 'aktiv' : 'inaktiv');
            $lines[] = 'Backend-Schutz: ' . (!empty($guard['active']) ? 'HTProtect' : (!empty($guard['foreign_auth']) ? 'fremd/.htaccess' : 'aus/extern'));
            $lines[] = '.htaccess:    ' . ($st['exists'] ? ($st['ours'] ? 'von HTProtect' : 'fremd') : 'nicht vorhanden');
        }

        $subjectLine = 'HTProtect-Support: ' . ($subject !== '' ? $subject : 'Anfrage') . ' (' . (string) parse_url(htp_site_url(), PHP_URL_HOST) . ')';

        $error = '';

        if (htp_send_mail(self::SUPPORT_EMAIL, $subjectLine, implode("\n", $lines), $email, $error)) {
            $messages[] = self::msg('info', htp_t('COM_HTPROTECT_SUPPORT_SENT', htp_esc($email)));
            $data['support']['sent'] = true;
        } else {
            $errSuffix = $error !== '' ? ': <em>' . htp_esc($error) . '</em>' : '.';
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_SUPPORT_FAIL', $errSuffix));
            $data['support']['failed'] = true;
        }
    }

    /**
     * Aktivierungszustand des Hintergrund-Plugins setzen.
     *
     * @param   integer  $enabled  1 = aktiv, 0 = inaktiv
     *
     * @return boolean
     */
    private static function setPluginEnabled($enabled)
    {
        return self::setExtEnabled('htprotectwatch', 'system', $enabled);
    }

    /**
     * Benachrichtigungs-E-Mail mit der Adresse des eingeloggten Super-Users
     * vorbelegen - aber NUR, wenn noch keine gesetzt ist. So kommen Alarme ab
     * Werk an, ohne Zutun; ein spaeteres bewusstes Leeren bleibt unangetastet
     * (wir fuellen nur eine leere Adresse, nie eine vorhandene). Voll gekapselt -
     * faellt nie hart aus.
     *
     * @param   array  $cfg  Konfiguration (Referenz)
     *
     * @return string Die gesetzte Adresse (fuer eine Hinweismeldung) oder ''
     */
    private static function prefillNotifyEmail(&$cfg)
    {
        if (trim((string) (isset($cfg['notify_email']) ? $cfg['notify_email'] : '')) !== '') {
            return '';
        }

        $email = self::currentUserEmail();

        if ($email !== '') {
            $cfg['notify_email'] = $email;
        }

        return $email;
    }

    /**
     * E-Mail des aktuell eingeloggten Super-Users (core.admin), versionsneutral
     * (J3-J6) und nur bei gueltiger Adresse. Sonst '' - nie ein Fehler.
     *
     * @return string
     */
    private static function currentUserEmail()
    {
        try {
            $user = null;

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

                if ($app && method_exists($app, 'getIdentity')) {
                    $user = $app->getIdentity();
                }
            }

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

            if (!$user || empty($user->id)) {
                return '';
            }

            // Nur ein echter Super-User (core.admin) - die Person, die den Schutz
            // verwaltet. Andere Konten ignorieren.
            if (method_exists($user, 'authorise') && !$user->authorise('core.admin')) {
                return '';
            }

            $email = isset($user->email) ? trim((string) $user->email) : '';

            return (filter_var($email, FILTER_VALIDATE_EMAIL) !== false) ? $email : '';
        } catch (Exception $e) {
            return '';
        } catch (Throwable $e) {
            return '';
        }
    }

    /**
     * Aktivierungszustand eines mitgelieferten Plugins setzen (generisch).
     *
     * @param   string   $element  Plugin-Element
     * @param   string   $folder   Plugin-Gruppe
     * @param   integer  $enabled  1 = aktiv, 0 = inaktiv
     *
     * @return boolean
     */
    private static function setExtEnabled($element, $folder, $enabled)
    {
        $db = htp_db();

        if (!$db) {
            return false;
        }

        try {
            $query = $db->getQuery(true)
                ->update($db->quoteName('#__extensions'))
                ->set($db->quoteName('enabled') . ' = ' . (int) $enabled)
                ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
                ->where($db->quoteName('element') . ' = ' . $db->quote($element))
                ->where($db->quoteName('folder') . ' = ' . $db->quote($folder));
            $db->setQuery($query);

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

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

    /**
     * Bestehende .htaccess analysieren und mutmassliche Eigenregeln als
     * Vorbelegung fuer "Eigene Regeln (Anfang)" anbieten. Gespeichert wird
     * erst, wenn der Benutzer das Formular pruefend abschickt.
     */
    private static function taskImportCustom($cfg, &$messages, &$data)
    {
        $content = HtpStorage::read();

        if ($content === '') {
            $messages[] = self::msg('info', htp_t('COM_HTPROTECT_IMPORT_NONE'));

            return;
        }

        $extracted = HtpBuilder::extractCustomRules($content);

        if ($extracted === '') {
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_IMPORT_ONLY_STD'));

            return;
        }

        if (trim($cfg['custom_top']) !== '' && strpos($cfg['custom_top'], $extracted) !== false) {
            $messages[] = self::msg('info', htp_t('COM_HTPROTECT_IMPORT_ALREADY'));

            return;
        }

        $block = '# == Import aus bestehender .htaccess vom ' . htp_local_time(time(), 'd.m.Y H:i') . ' - bitte pruefen ==' . "\n" . $extracted;
        $current = trim($cfg['custom_top']);

        $data['import_top'] = $current === '' ? $block : $current . "\n\n" . $block;

        $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_IMPORT_SUGGEST'));
    }

    /**
     * AJAX: stueckweiser (resumebarer) Tiefen-Scan nach Schaddateien. Ein
     * Aufruf scannt nur ein kurzes Zeitbudget und liefert die in DIESEM
     * Abschnitt neu gefundenen Treffer + Fortschritt; der naechste Aufruf
     * (mit derselben scan_id) setzt fort. So bleibt jede Anfrage kurz (kein
     * Server-/Gateway-Timeout) und der Speicher flach - auch auf Riesen-Sites.
     *
     * Modi: ohne Basislinie / nach Komponenten-Update / woechentlich bzw. auf
     * Wunsch ('full') laeuft ein VOLLER Scan; sonst ein Delta-Scan, der seit
     * dem letzten vollen Scan unveraenderte Dateien ueberspringt (nahezu
     * sofort). Beendet die Anfrage selbst.
     */
    private static function taskMalScan()
    {
        @ignore_user_abort(true);

        // Grund-Code -> Sprachschluessel (explizit, kein dynamischer Key).
        $reasonKeys = array(
            'goto'       => 'COM_HTPROTECT_MALF_R_GOTO',
            'evaldecode' => 'COM_HTPROTECT_MALF_R_EVALDECODE',
            'strrev'     => 'COM_HTPROTECT_MALF_R_STRREV',
            'idxbuild'   => 'COM_HTPROTECT_MALF_R_IDXBUILD',
            'mixedcase'  => 'COM_HTPROTECT_MALF_R_MIXEDCASE',
            'wrapper'    => 'COM_HTPROTECT_MALF_R_WRAPPER',
            'inject'     => 'COM_HTPROTECT_MALF_R_INJECT',
            'rce'        => 'COM_HTPROTECT_MALF_R_RCE',
            'jceprofile' => 'COM_HTPROTECT_MALF_R_JCEPROFILE',
            'images_php' => 'COM_HTPROTECT_MALF_R_IMAGES_PHP',
            'media_php'  => 'COM_HTPROTECT_MALF_R_MEDIA_PHP',
            'htaccess_exec' => 'COM_HTPROTECT_MALF_R_HTACCESS',
            'tmp_exec'   => 'COM_HTPROTECT_MALF_R_TMP_EXEC',
            'filemanager' => 'COM_HTPROTECT_MALF_R_FILEMANAGER',
            'phtml'      => 'COM_HTPROTECT_MALF_R_PHTML',
            'shtml'      => 'COM_HTPROTECT_MALF_R_SHTML',
            'php_image'  => 'COM_HTPROTECT_MALF_R_PHP_IMAGE',
            'image_shell' => 'COM_HTPROTECT_MALF_R_IMAGE_SHELL',
            'defacement' => 'COM_HTPROTECT_MALF_R_DEFACEMENT',
            'webshell'   => 'COM_HTPROTECT_MALF_R_WEBSHELL',
            'hexfuncs'   => 'COM_HTPROTECT_MALF_R_HEXFUNCS',
            'jsmalware'  => 'COM_HTPROTECT_MALF_R_JSMALWARE',
            'knownbad'   => 'COM_HTPROTECT_MALF_R_KNOWNBAD',
        );

        // Basislinien-Schluessel = Extension-Version PLUS Malware-Feed-Version. So
        // erzwingt nicht nur ein Extension-Update, sondern auch ein FEED-Update
        // (neue/geaenderte Signaturen) einen Voll-Scan -> frische Signaturen greifen
        // dann auch auf bereits vorhandene, UNVERAENDERTE Dateien (die der Delta-Scan
        // sonst ueberspringt). Fail-soft: ohne Feed-Methode bleibt es die Ext-Version.
        $version = defined('HTP_VERSION') ? HTP_VERSION : '';

        if (class_exists('HtpDefs') && method_exists('HtpDefs', 'malwareVersion')) {
            $version .= '|' . (string) HtpDefs::malwareVersion();
        }

        $scanId  = (string) htp_post('htp_scan_id', '');
        $state   = ($scanId !== '') ? HtpStorage::scanStateRead($scanId) : null;

        if (!is_array($state)) {
            // Neuen Scan starten: verwaiste Zustaende aufraeumen, Modus waehlen.
            HtpStorage::scanStateCleanup();
            $scanId = HtpStorage::scanStateNewId();

            // Scan-Start: deklarative Zusatz-Signaturen frisch ziehen (eigene
            // Tagessperre, fail-soft) - frischeste Sigs genau im wichtigen Moment.
            if (class_exists('HtpDefs') && method_exists('HtpDefs', 'refreshMalwareSigs')) {
                HtpDefs::refreshMalwareSigs(false);
            }

            $base = HtpStorage::malwareBaselineRead();

            // Voll-Scan erzwungen: auf Wunsch, ohne Basislinie, nach Update
            // (neue Signaturen) oder woechentlich (Schutz gegen mtime-Tricks).
            $forceFull = (htp_post('htp_scan_mode', '') === 'full')
                || $base['ts'] <= 0
                || $base['ver'] !== $version
                || $base['ts'] < time() - 7 * 86400;

            $mode  = $forceFull ? 'full' : 'delta';
            $known = array();

            foreach ($base['hits'] as $hp) {
                $known[(string) $hp] = 1;
            }

            $state = array(
                'mode'     => $mode,
                'started'  => time(),
                'baseline' => $mode === 'delta' ? (int) $base['ts'] : 0,
                'known'    => $mode === 'delta' ? $known : array(),
                'hitpaths' => array(),
            );
        }

        // Einen Abschnitt scannen (Zeitbudget; sicher unter jedem Host-Timeout).
        try {
            $r = HtpStorage::scanMalwareChunk($state, 5.0, 300, HtpStorage::MAL_SCAN_DIR_HARD);
        } catch (\Throwable $e) {
            self::json(array('ok' => false));
        }

        $items = array();

        foreach ($r['hits'] as $h) {
            $key = isset($reasonKeys[$h['reason']]) ? $reasonKeys[$h['reason']] : 'COM_HTPROTECT_MALF_R_GOTO';

            $items[] = array(
                'path'        => (string) $h['path'],
                'size'        => (int) $h['size'],
                'mtime'       => isset($h['mtime']) ? (int) $h['mtime'] : 0,
                'reason'      => htp_t($key),
                // Fund-Vorschau base64 (Client dekodiert) - haelt den Schadcode aus
                // dem HTTP-Body fern, damit Browser-Virenscanner die Scan-Seite nicht
                // faelschlich als Schadskript melden. Reversibel, kein Sicherheits-Gate.
                'preview_b64' => base64_encode((string) $h['preview']),
            );
        }

        $done     = !empty($r['done']);
        $mode     = isset($state['mode']) ? (string) $state['mode'] : 'full';
        $hitpaths = (isset($state['hitpaths']) && is_array($state['hitpaths'])) ? $state['hitpaths'] : array();

        if ($done) {
            // Basislinie fortschreiben: voller Scan setzt Zeitstempel+Version neu,
            // Delta laesst sie unveraendert - in beiden Faellen die aktuellen
            // Treffer als bekannte Treffer hinterlegen (Wieder-Anzeige im Delta).
            if ($mode === 'full') {
                HtpStorage::malwareBaselineWrite((int) $state['started'], $version, $hitpaths);
            } else {
                $base = HtpStorage::malwareBaselineRead();
                HtpStorage::malwareBaselineWrite($base['ts'], $base['ver'], $hitpaths);
            }

            HtpStorage::scanStateDelete($scanId);

            // Das frische Ergebnis dem Dashboard SOFORT melden (nur bei gekoppelter Site): sonst
            // zeigt die dortige Malware-Kachel den alten Stand, bis man extra einen Remote-Scan
            // anstoesst. count($hitpaths) = aktuelle Trefferzahl (bekannte Funde werden im Delta
            // re-verifiziert, s. scanner.php) -> dieselbe Zahl wie ein Remote-Scan. Zero-Knowledge
            // (nur ts+hits ans Relay), best-effort - stoert den Scan nie.
            if (class_exists('HtpLink') && method_exists('HtpLink', 'reportScanResult')) {
                $qc = (int) (isset($state['quar_total']) ? $state['quar_total'] : 0); // echte Gesamtzahl (ungekappt)
                HtpLink::reportScanResult(count($hitpaths), $qc);
            }
        } else {
            HtpStorage::scanStateWrite($scanId, $state);
        }

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

        // Vom Hoster gesperrte Dateien (chmod 000): die (bis hierhin) gesammelte Liste mitgeben. Nur
        // Pfade/Groesse/mtime, KEIN Inhalt (den kann der Scan hier ohnehin nicht lesen) - eingeordnet
        // wird spaeter on-demand ueber den Entsperr-Scan (taskQuarScan).
        $quarantine = (isset($state['quarantine']) && is_array($state['quarantine'])) ? $state['quarantine'] : array();

        self::json(array(
            'ok'         => true,
            'scan_id'    => $scanId,
            'done'       => $done,
            'mode'       => $mode,
            'items'      => $items,
            'quarantine' => $quarantine,
            'quar_total' => (int) (isset($state['quar_total']) ? $state['quar_total'] : 0),
            'files'      => (int) $r['files'],
            'skipped'    => (int) $r['skipped'],
            'seen'       => $seen,
            'queued'     => $queued,
            'progress'   => $progress,
        ));
    }

    /**
     * Dekodiert einen HEX-transportierten Pfad-Parameter zurueck (WAF-Umgehung, s. Dispatcher).
     * Nur reines Hex gerader Laenge wird akzeptiert; sonst null (Aufrufer faellt auf das Klartext-
     * Feld zurueck). NUL-Bytes werden entfernt. Reine Transport-Dekodierung ohne Sicherheitswirkung -
     * die Pfad-Validierung passiert unveraendert in HtpStorage::deleteMalwareFile().
     *
     * @param   string  $hex
     * @return  string|null
     */
    private static function hexToPath($hex)
    {
        $hex = (string) $hex;

        if ($hex === '' || (strlen($hex) % 2) !== 0 || !ctype_xdigit($hex)) {
            return null;
        }

        $bin = @hex2bin($hex);

        if (!is_string($bin)) {
            return null;
        }

        return str_replace(chr(0), '', $bin);
    }

    /**
     * AJAX: eine einzelne (verifizierte) Schaddatei loeschen. Die Pruefung,
     * dass die Datei wirklich eine Malware-Signatur traegt, liegt in
     * HtpStorage::deleteMalwareFile() -> kein Loeschen beliebiger Dateien.
     */
    private static function taskMalDeleteOne()
    {
        self::json(array('ok' => HtpStorage::deleteMalwareFile(htp_post('htp_path', ''))));
    }

    /**
     * AJAX: vollen Inhalt einer erkannten Schaddatei lesen. Gleiches Sicherheits-
     * Gate wie beim Loeschen (HtpStorage::readMalwareFile) -> kein beliebiges
     * Datei-Auslesen. Anzeige im JS per textContent (XSS-frei).
     */
    private static function taskMalView()
    {
        $data = HtpStorage::readMalwareFile(htp_post('htp_path', ''));

        if (!is_array($data)) {
            self::json(array('ok' => false));
        }

        // Fund-Inhalt base64-kodiert ueber den Draht (Client dekodiert per JS).
        // Grund: Browser-Virenscanner (z. B. Bitdefender Web-Schutz) wuerden den
        // angezeigten Schadcode sonst als zusammenhaengendes Muster in der HTTP-
        // Antwort sehen und die HTProtect-Scan-Seite faelschlich als Schadskript
        // melden. Kein Sicherheits-Feature, reine FP-Vermeidung; Inhalt bleibt
        // unveraendert (reversibel). 'content' aus Abwaertskompatibilitaet leer.
        self::json(array(
            'ok'          => true,
            'content_b64' => base64_encode((string) $data['content']),
            'truncated'   => !empty($data['truncated']),
            'size'        => (int) $data['size'],
            'line'        => isset($data['line']) ? (int) $data['line'] : 0,
        ));
    }

    /**
     * AJAX: gesaeuberten Inhalt in eine erkannte Schaddatei zurueckschreiben.
     * Gleiches Sicherheits-Gate wie Lesen/Loeschen (HtpStorage::saveMalwareFile):
     * nur erkannte Schaddateien, Original wird vorher gesichert, danach neu
     * bewertet (clean = jetzt sauber).
     */
    private static function taskMalSave()
    {
        // Erwartete Byte-Laenge des Inhalts (Schutz gegen abgeschnittenen POST).
        // Fehlt sie (ganzer POST per post_max_size verworfen) -> -1 -> Abbruch.
        $len = htp_has('htp_len', 'post') ? (int) htp_post('htp_len', '-1') : -1;
        $res = HtpStorage::saveMalwareFile(htp_post('htp_path', ''), htp_post('htp_content', ''), $len);

        if (!is_array($res)) {
            self::json(array('ok' => false));
        }

        if (empty($res['ok'])) {
            // POST unvollstaendig -> NICHT geschrieben (Datei unveraendert).
            self::json(array('ok' => false, 'incomplete' => !empty($res['incomplete']), 'unwritable' => !empty($res['unwritable'])));
        }

        self::json(array(
            'ok'     => true,
            'clean'  => !empty($res['clean']),
            'reason' => (string) $res['reason'],
        ));
    }

    /**
     * AJAX: einen Inhalts-Block eines stueckweisen Speichervorgangs anhaengen.
     * Der Browser sendet sehr grosse Dateien in kleinen Datei-Upload-Parts, um
     * Body-Limits (post_max_size, ModSecurity SecRequestBodyNoFilesLimit, Proxy)
     * zu umgehen. Der Block kommt als ECHTER Upload (is_uploaded_file) - die Bytes
     * werden in die Transfer-Zwischendatei angehaengt. Gate liegt in HtpStorage.
     */
    private static function taskMalSaveChunk()
    {
        $chunk = htp_raw('htp_chunk', null, 'files');
        $file  = is_array($chunk) ? $chunk : null;

        if (!$file || !empty($file['error']) || !is_uploaded_file((string) $file['tmp_name'])) {
            self::json(array('ok' => false));
        }

        $bytes = (string) @file_get_contents((string) $file['tmp_name']);

        self::json(HtpStorage::appendEditChunk(
            htp_post('htp_path', ''),
            htp_post('htp_xfer', ''),
            (int) htp_post('htp_seq', '0'),
            $bytes
        ));
    }

    /**
     * AJAX: stueckweise hochgeladenen Inhalt festschreiben. Setzt die Transfer-
     * Zwischendatei zusammen und schreibt ueber dasselbe strenge Gate wie save
     * (nur erkannte Schaddateien, Original-Backup, exakte Laenge, Neubewertung).
     */
    private static function taskMalCommit()
    {
        $len = htp_has('htp_len', 'post') ? (int) htp_post('htp_len', '-1') : -1;
        $res = HtpStorage::commitMalwareFile(htp_post('htp_path', ''), htp_post('htp_xfer', ''), $len);

        if (!is_array($res)) {
            self::json(array('ok' => false));
        }

        if (empty($res['ok'])) {
            self::json(array('ok' => false, 'incomplete' => !empty($res['incomplete']), 'unwritable' => !empty($res['unwritable'])));
        }

        self::json(array(
            'ok'     => true,
            'clean'  => !empty($res['clean']),
            'reason' => (string) $res['reason'],
        ));
    }

    /**
     * Schwarm-Schutz: eine Meldung (FP = Nutzer-Whitelist, FN = gemeldete Pfade)
     * an das Melde-Portal schicken. Bewusste, eingewilligte Aktion (Super-User +
     * CSRF). Pfade/Notiz roh aus dem POST - die Sicherheit liegt in der docroot-
     * begrenzten Aufloesung (HtpSwarm::resolveOne), nicht im Eingabefilter.
     *
     * @param   array   $cfg       Konfiguration (Referenz)
     * @param   array   $messages  Meldungen (Referenz)
     * @param   string  $type      'fp' | 'fn'
     */
    private static function taskSwarmReport(&$cfg, &$messages, $type)
    {
        if (!class_exists('HtpSwarm')) {
            return;
        }

        $paths = array();
        $note  = '';

        if ($type === 'fn') {
            $rawPaths = htp_raw('htp_swarm_paths', '', 'post');
            $rawNote  = htp_raw('htp_swarm_note', '', 'post');
            $raw      = is_string($rawPaths) ? $rawPaths : '';
            $note     = is_string($rawNote) ? $rawNote : '';
            $paths    = preg_split('~[\r\n]+~', (string) $raw, -1, PREG_SPLIT_NO_EMPTY);
        }

        $res = HtpSwarm::report($cfg, $type, $paths, $note);

        if (!empty($res['ok'])) {
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_SWARM_SENT', (int) $res['count']));

            return;
        }

        // Klare, ursachenbezogene Meldung statt eines Sammeltextes: 'reason' kommt
        // aus dem Transport (htp_http_reason) bzw. report() (nofiles/zip).
        $reason = isset($res['reason']) ? (string) $res['reason'] : '';

        if ($reason === 'nofiles' || (isset($res['error']) && $res['error'] === 'nofiles')) {
            $messages[] = self::msg('info', htp_t('COM_HTPROTECT_SWARM_NOFILES'));
        } elseif ($reason === 'zip') {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_SWARM_ZIPFAIL'));
        } elseif ($reason === 'http') {
            // Portal war erreichbar, hat die Meldung aber abgewiesen -> Statuscode nennen.
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_SWARM_REJECTED', (int) (isset($res['status']) ? $res['status'] : 0)));
        } elseif ($reason === 'ssl' || $reason === 'connect' || $reason === 'timeout') {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_SWARM_UNREACHABLE'));
        } else {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_SWARM_FAIL'));
        }
    }

    /**
     * AJAX: alle aktuell gefundenen Schaddateien loeschen (frischer Scan,
     * jede Loeschung erneut verifiziert).
     */
    private static function taskMalDeleteAll()
    {
        $res = HtpStorage::deleteAllMalwareFiles();
        self::json(array('ok' => true, 'deleted' => (int) $res['deleted'], 'failed' => (int) $res['failed']));
    }

    /**
     * AJAX: nur die markierten Schaddateien loeschen (htp_paths, zeilenweise).
     * Jede Loeschung wird in HtpStorage::deleteMalwareFile() erneut gegen die
     * Malware-Signatur verifiziert -> kein Loeschen beliebiger Dateien. Liefert
     * die tatsaechlich geloeschten Pfade zurueck, damit das UI genau diese Zeilen
     * entfernt.
     */
    private static function taskMalDeleteSelected()
    {
        $raw   = htp_post('htp_paths', '');
        $paths = $raw === '' ? array() : preg_split('/\r\n|\r|\n/', $raw);

        $deleted = 0;
        $failed  = 0;
        $okPaths = array();

        foreach ((array) $paths as $p) {
            $p = trim((string) $p);

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

            if (HtpStorage::deleteMalwareFile($p)) {
                $deleted++;
                $okPaths[] = $p;
            } else {
                $failed++;
            }

            if (($deleted + $failed) >= 500) {
                break;
            }
        }

        self::json(array('ok' => true, 'deleted' => $deleted, 'failed' => $failed, 'ok_paths' => $okPaths));
    }

    /**
     * AJAX: eine vom Hoster gesperrte Datei (chmod 000) kurz entsperr-scannen (einordnen). Entsperrt nur
     * auf 0400 (Eigentuemer-Lesen), scannt mit der GLEICHEN Pipeline wie der Scan, sperrt sofort wieder.
     * Nur Backend (ACL+CSRF, s. run()); loescht/aendert dauerhaft nichts. htp_path ist WAF-hex-dekodiert.
     */
    private static function taskQuarScan()
    {
        $r = HtpStorage::quarantineScanFile(htp_post('htp_path', ''));
        self::json(is_array($r) ? $r : array('ok' => false, 'readable' => false, 'malware' => false, 'reason' => ''));
    }

    /**
     * AJAX: eine gesperrte Datei loeschen. Standard NICHT blind (Engine verweigert bei entsperrbar +
     * unauffaellig -> Fehlalarm; das UI bietet dann Wiederherstellen an). htp_force=1 = ausdruecklich trotzdem.
     */
    private static function taskQuarDelete()
    {
        $force = htp_post('htp_force', '') === '1';
        $r     = HtpStorage::quarantineDeleteFile(htp_post('htp_path', ''), $force);
        self::json(is_array($r) ? $r : array('ok' => false, 'code' => 'no_engine'));
    }

    /**
     * AJAX: Rechte einer faelschlich gesperrten (unauffaelligen) Datei wiederherstellen (0644) -> die
     * Website funktioniert wieder. Fremder Eigentuemer -> false (nur der Hoster kann ran).
     */
    private static function taskQuarRestore()
    {
        $r = HtpStorage::quarantineRestoreFile(htp_post('htp_path', ''));
        self::json(is_array($r) ? $r : array('ok' => false, 'code' => 'no_engine'));
    }

    /**
     * AJAX: einen ODER mehrere Pfade dauerhaft auf die Schaddatei-Whitelist
     * setzen (htp_path einzeln oder htp_paths zeilenweise). Liefert die
     * aktualisierte Liste zurueck.
     */
    private static function taskMalWhitelistAdd(&$cfg)
    {
        $list = HtpStorage::normalizeWhitelist(isset($cfg['mal_whitelist']) ? $cfg['mal_whitelist'] : array());

        $incoming = array();
        $single   = htp_post('htp_path', '');

        if ($single !== '') {
            $incoming[] = $single;
        }

        $multi = htp_post('htp_paths', '');

        if ($multi !== '') {
            $incoming = array_merge($incoming, preg_split('/\r\n|\r|\n/', $multi));
        }

        $add     = HtpStorage::normalizeWhitelist($incoming);
        $changed = false;

        foreach ($add as $a) {
            if (!in_array($a, $list, true)) {
                $list[]  = $a;
                $changed = true;
            }
        }

        if ($changed) {
            sort($list);
            $cfg['mal_whitelist'] = $list;
            HtpConfig::save($cfg);
        }

        self::json(array('ok' => true, 'list' => array_values($list)));
    }

    /**
     * AJAX: einen Pfad von der Schaddatei-Whitelist entfernen.
     */
    private static function taskMalWhitelistRemove(&$cfg)
    {
        $rel  = HtpStorage::normalizeWhitelist(array(htp_post('htp_path', '')));
        $rel  = !empty($rel) ? $rel[0] : '';
        $list = HtpStorage::normalizeWhitelist(isset($cfg['mal_whitelist']) ? $cfg['mal_whitelist'] : array());
        $new  = array();

        foreach ($list as $e) {
            if ($e !== $rel) {
                $new[] = $e;
            }
        }

        if (count($new) !== count($list)) {
            $cfg['mal_whitelist'] = $new;
            HtpConfig::save($cfg);
        }

        self::json(array('ok' => true, 'list' => array_values($new)));
    }

    /**
     * AJAX: ein Schritt des Kerndatei-Abgleichs. Resumbare Phasen-Maschine
     * (detect/download/manifest/compare) - jeder Aufruf ist zeitbudgetiert, der
     * Zustand wird per scan_id zwischen den Aufrufen persistiert (spiegelt die
     * Orchestrierung des Malware-Scanners). Beendet die Anfrage selbst.
     *
     * @return void
     */
    private static function taskCoreScan()
    {
        @ignore_user_abort(true);

        if (!class_exists('HtpCoreScan')) {
            self::json(array('ok' => false, 'phase' => 'error', 'label' => htp_t('COM_HTPROTECT_CORE_ERR_UNAVAIL')));
        }

        $scanId = (string) htp_post('htp_scan_id', '');
        $state  = ($scanId !== '') ? HtpCoreScan::stateRead($scanId) : null;

        if (!is_array($state)) {
            HtpCoreScan::stateCleanup();
            $scanId = HtpCoreScan::newId();
            $state  = HtpCoreScan::begin();
        }

        try {
            $r = HtpCoreScan::step($state);
        } catch (\Throwable $e) {
            HtpCoreScan::stateDelete($scanId);
            self::json(array('ok' => false, 'phase' => 'error', 'label' => htp_t('COM_HTPROTECT_CORE_ERR_GENERIC')));
        }

        $done  = HtpCoreScan::isDone($state);
        $phase = isset($state['phase']) ? (string) $state['phase'] : 'error';

        $items = array();

        foreach ((isset($r['items']) && is_array($r['items']) ? $r['items'] : array()) as $d) {
            $items[] = array(
                'path'  => (string) $d['path'],
                'kind'  => (string) $d['kind'],
                'label' => htp_t($d['kind'] === 'missing' ? 'COM_HTPROTECT_CORE_KIND_MISSING' : 'COM_HTPROTECT_CORE_KIND_CHANGED'),
                'size'  => (int) $d['size'],
                'mtime' => (int) $d['mtime'],
            );
        }

        if ($done) {
            HtpCoreScan::stateDelete($scanId);

            // Nach abgeschlossenem Lauf Alt-Caches aufraeumen: fremde Versionen UND alte
            // Cache-Generationen (z. B. das fruehere Voll-Paket) - nur der aktuelle bleibt.
            if ($phase !== 'error' && isset($state['version']) && $state['version'] !== '') {
                HtpCoreScan::cleanCache((string) $state['version']);
            }
        } else {
            HtpCoreScan::stateWrite($scanId, $state);
        }

        $counts = isset($state['counts']) && is_array($state['counts'])
            ? $state['counts']
            : array('ok' => 0, 'changed' => 0, 'missing' => 0, 'patched' => 0);

        // Von der Nachpruefung als offizieller Vorwaerts-Patch entlarvte Pfade -> die
        // Oberflaeche nimmt die (bereits gezeigten) Zeilen wieder heraus.
        $patched = array();

        foreach ((isset($r['patched']) && is_array($r['patched']) ? $r['patched'] : array()) as $p) {
            $patched[] = (string) $p;
        }

        self::json(array(
            'ok'       => $phase !== 'error',
            'scan_id'  => $scanId,
            'done'     => $done,
            'phase'    => $phase,
            'label'    => self::corePhaseLabel($phase, $state),
            'progress' => isset($r['progress']) ? (float) $r['progress'] : 0.0,
            'version'  => isset($state['version']) ? (string) $state['version'] : '',
            'counts'   => array(
                'ok'      => (int) $counts['ok'],
                'changed' => (int) $counts['changed'],
                'missing' => (int) $counts['missing'],
                'patched' => isset($counts['patched']) ? (int) $counts['patched'] : 0,
            ),
            'items'    => $items,
            'patched'  => $patched,
        ));
    }

    /**
     * Laien-verstaendliches Phasen-/Fehler-Label fuer den Kern-Abgleich.
     *
     * @param   string  $phase  Phase
     * @param   array   $state  Zustand (fuer den Fehlercode)
     *
     * @return string
     */
    private static function corePhaseLabel($phase, $state)
    {
        if ($phase === 'download') {
            return htp_t('COM_HTPROTECT_CORE_PH_DOWNLOAD');
        }

        if ($phase === 'manifest') {
            return htp_t('COM_HTPROTECT_CORE_PH_MANIFEST');
        }

        if ($phase === 'compare') {
            return htp_t('COM_HTPROTECT_CORE_PH_COMPARE');
        }

        if ($phase === 'recheck') {
            return htp_t('COM_HTPROTECT_CORE_PH_RECHECK');
        }

        if ($phase === 'error') {
            $e   = isset($state['error']) ? (string) $state['error'] : '';
            $map = array(
                'noversion'  => 'COM_HTPROTECT_CORE_ERR_VERSION',
                'nocurl'     => 'COM_HTPROTECT_CORE_ERR_NOCURL',
                'nowrite'    => 'COM_HTPROTECT_CORE_ERR_NOWRITE',
                'stalled'    => 'COM_HTPROTECT_CORE_ERR_DOWNLOAD',
                'nozip'      => 'COM_HTPROTECT_CORE_ERR_NOZIP',
                'badzip'     => 'COM_HTPROTECT_CORE_ERR_BADZIP',
                'nomanifest' => 'COM_HTPROTECT_CORE_ERR_BADZIP',
            );

            if (isset($map[$e])) {
                return htp_t($map[$e]);
            }

            if (strncmp($e, 'http', 4) === 0) {
                return htp_t('COM_HTPROTECT_CORE_ERR_HTTP', (int) substr($e, 4));
            }

            return htp_t('COM_HTPROTECT_CORE_ERR_GENERIC');
        }

        return '';
    }

    /**
     * AJAX: eine oder mehrere Kerndateien aus dem Original-Paket wiederherstellen.
     * Nimmt einen einzelnen `htp_path` ODER eine Zeilenliste `htp_paths` (Batch,
     * gekappt) + die abgeglichene Version `htp_ver`. Liefert die erfolgreich
     * wiederhergestellten Pfade zurueck (das UI entfernt genau diese Zeilen).
     *
     * @return void
     */
    private static function taskCoreRestore()
    {
        @ignore_user_abort(true);

        if (!class_exists('HtpCoreScan')) {
            self::json(array('ok' => false));
        }

        $ver = (string) htp_post('htp_ver', '');
        $raw = (string) htp_post('htp_paths', '');

        if ($raw === '') {
            $raw = (string) htp_post('htp_path', '');
        }

        $paths = array();

        foreach (preg_split('~\r\n|\r|\n~', $raw) as $p) {
            $p = trim((string) $p);

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

        $paths   = array_slice(array_values(array_unique($paths)), 0, 300);
        $okPaths = array();
        $failed  = 0;

        foreach ($paths as $p) {
            $res = HtpCoreScan::restore($p, $ver);

            if (!empty($res['ok'])) {
                $okPaths[] = $p;
            } else {
                $failed++;
            }
        }

        self::json(array('ok' => true, 'restored' => count($okPaths), 'failed' => $failed, 'ok_paths' => $okPaths));
    }

    /**
     * AJAX: Zeilen-Diff einer geaenderten Kerndatei gegen das offizielle Original -
     * macht eine Injektion unmittelbar sichtbar (nur bei Kind = geaendert sinnvoll).
     *
     * @return void
     */
    private static function taskCoreDiff()
    {
        @ignore_user_abort(true);

        if (!class_exists('HtpCoreScan')) {
            self::json(array('ok' => false));
        }

        $r = HtpCoreScan::diffFile((string) htp_post('htp_path', ''), (string) htp_post('htp_ver', ''));

        self::json(array(
            'ok'      => !empty($r['ok']),
            'binary'  => !empty($r['binary']),
            'added'   => (isset($r['added']) && is_array($r['added'])) ? $r['added'] : array(),
            'removed' => (isset($r['removed']) && is_array($r['removed'])) ? $r['removed'] : array(),
        ));
    }

    /**
     * Kern-relative Pfade aus dem Post lesen (htp_paths = mehrzeilig fuer "alle", sonst htp_path).
     * Gedeckelt, dedupliziert - identisch zur Wiederherstellen-Logik.
     *
     * @return array
     */
    private static function coreScanPaths()
    {
        $raw = (string) htp_post('htp_paths', '');

        if ($raw === '') {
            $raw = (string) htp_post('htp_path', '');
        }

        $paths = array();

        foreach (preg_split('~\r\n|\r|\n~', $raw) as $p) {
            $p = trim((string) $p);

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

        return array_slice(array_values(array_unique($paths)), 0, 300);
    }

    /**
     * AJAX: geaenderte Kerndatei(en) als bewusst akzeptiert markieren (checksum-gebunden). Fuer
     * legitime Fremd-Patches (z. B. vom Webhoster via Patchman eingespielte Sicherheits-Backports),
     * die den Kern veraendern. Aendert sich die Datei spaeter, greift die Bestaetigung nicht mehr.
     *
     * @return void
     */
    private static function taskCoreIgnore()
    {
        @ignore_user_abort(true);

        if (!class_exists('HtpCoreScan')) {
            self::json(array('ok' => false));
        }

        $ok     = 0;
        $failed = 0;

        foreach (self::coreScanPaths() as $p) {
            $r = HtpCoreScan::acknowledge($p);

            if (!empty($r['ok'])) {
                $ok++;
            } else {
                $failed++;
            }
        }

        self::json(array('ok' => true, 'ignored' => $ok, 'failed' => $failed));
    }

    /**
     * AJAX: eine Ignorieren-Bestaetigung zuruecknehmen - die Datei wird wieder als Abweichung gefuehrt.
     *
     * @return void
     */
    private static function taskCoreUnignore()
    {
        @ignore_user_abort(true);

        if (!class_exists('HtpCoreScan')) {
            self::json(array('ok' => false));
        }

        $ok = 0;

        foreach (self::coreScanPaths() as $p) {
            $r = HtpCoreScan::unacknowledge($p);

            if (!empty($r['ok'])) {
                $ok++;
            }
        }

        self::json(array('ok' => true, 'unignored' => $ok));
    }

    /**
     * tmp/ jetzt aufraeumen + die Automatik-Einstellung speichern. Formular-Post
     * (kein AJAX) - der Schalter `htp_tmp_autoclean` und die Sofort-Aktion sitzen
     * im selben Mini-Formular im Scan-Bereich.
     *
     * @param   array  $cfg       Konfiguration (Referenz)
     * @param   array  $messages  Meldungen (Referenz)
     * @param   array  $data      Anzeigedaten (Referenz)
     *
     * @return void
     */
    /**
     * Alle Schalter/Einstellungen der Site-Scan-Seite aus dem (gemeinsamen)
     * Formular in $cfg uebernehmen. Wird von JEDEM Button dort aufgerufen, damit
     * keine umgelegte Einstellung verloren geht. Idempotent (speichert den
     * angezeigten Stand). Schreibt NICHT selbst - der Aufrufer ruft danach save().
     *
     * @param   array  $cfg  Konfiguration (Referenz)
     *
     * @return void
     */
    private static function applyScanSettings(&$cfg)
    {
        $cfg['tmp_autoclean'] = htp_post('htp_tmp_autoclean', '') !== '' ? 1 : 0;
        $cfg['seo_watch']     = htp_post('htp_seo_watch', '') !== '' ? 1 : 0;

        $wl  = preg_split('~[\r\n,]+~', (string) htp_post('htp_seo_whitelist', ''));
        $out = array();

        foreach ((array) $wl as $e) {
            $e = trim((string) $e);

            if ($e !== '' && strpos($e, "\0") === false) {
                $out[$e] = 1;
            }
        }

        $cfg['seo_whitelist'] = array_keys($out);

        // tmp-Reinigung: Ausschlussliste (Glob-Muster, eine Regel pro Zeile/Komma).
        $tk    = preg_split('~[\r\n,]+~', (string) htp_post('htp_tmp_keep', ''));
        $tkOut = array();

        foreach ((array) $tk as $e) {
            $e = trim((string) $e);

            if ($e !== '' && strpos($e, "\0") === false) {
                $tkOut[$e] = 1;
            }
        }

        $cfg['tmp_keep'] = array_keys($tkOut);
    }

    private static function taskTmpClean(&$cfg, &$messages, &$data)
    {
        // Alle Schalter dieser Seite uebernehmen + speichern (gemeinsames Formular).
        self::applyScanSettings($cfg);
        HtpConfig::save($cfg);

        // Sofort aufraeumen.
        $removed = (class_exists('HtpStorage') && method_exists('HtpStorage', 'cleanTmp'))
            ? HtpStorage::cleanTmp()
            : array();

        $data['tmpclean'] = $removed;

        $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_TMPCLEAN_DONE', count($removed)));
    }

    /**
     * Defacement-/SEO-Waechter: Schalter + URL-Whitelist aus dem Formular
     * uebernehmen und speichern.
     *
     * @param   array  $cfg       Konfiguration (Referenz)
     * @param   array  $messages  Meldungen (Referenz)
     *
     * @return void
     */
    private static function taskSeoSave(&$cfg, &$messages)
    {
        // Alle Schalter dieser Seite uebernehmen + speichern (gemeinsames Formular).
        self::applyScanSettings($cfg);
        HtpConfig::save($cfg);
    }

    /**
     * Defacement-/SEO-Waechter: jetzt pruefen (synchron, Admin wartet bewusst).
     * Zeigt das Urteil; bei sauberem Lauf wird die Basislinie still fortgeschrieben.
     *
     * @param   array  $cfg       Konfiguration (Referenz)
     * @param   array  $messages  Meldungen (Referenz)
     * @param   array  $data      Anzeigedaten (Referenz)
     *
     * @return void
     */
    private static function taskSeoScan(&$cfg, &$messages, &$data)
    {
        // Erst alle Schalter dieser Seite uebernehmen + speichern (gemeinsames
        // Formular) - so geht der gerade umgelegte Schalter nicht verloren.
        self::applyScanSettings($cfg);

        // Manueller "Jetzt pruefen"-Lauf prueft IMMER - auch bei ausgeschaltetem
        // Schalter (der steuert nur die 6h-Automatik im Hintergrund). Ohne
        // HTTP-Client kann nicht abgerufen werden -> ehrlicher Hinweis statt "sauber".
        if (!class_exists('HtpSeo') || !class_exists('HtpProbe') || !HtpProbe::available()) {
            HtpConfig::save($cfg);
            $messages[] = self::msg('warn', htp_t('COM_HTPROTECT_PROBE_NOHTTP'));

            return;
        }

        $incidents = HtpSeo::scan($cfg, true);

        // Den manuellen Lauf zugleich als Uebersichts-Schnellpruefung werten:
        // Befund + Drossel-Zeitstempel mitschreiben, damit die Ampel/Kachel sofort
        // den frischen Stand zeigt (und die Uebersicht nicht gleich erneut abruft).
        $cfg['seo_overview_ts']      = time();
        $cfg['seo_overview_finding'] = HtpSeo::findingFromIncidents($incidents);

        HtpConfig::save($cfg); // Einstellungen + Basislinie + Uebersichts-Befund persistieren

        $data['seo_result'] = $incidents;

        if (empty($incidents)) {
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_SEO_CLEAN'));
        } else {
            foreach ($incidents as $inc) {
                $reasons = (isset($inc['reasons']) && is_array($inc['reasons'])) ? implode('; ', $inc['reasons']) : '';
                $messages[] = self::msg('err', htp_t('COM_HTPROTECT_SEO_ALERT', htp_esc((string) $inc['url']), htp_esc($reasons)));
            }
        }
    }

    /**
     * AJAX-Schritt des Site-Scans: genau eine Seite der eigenen Site
     * abrufen und deren lokale Ressourcen-Pfade als JSON liefern.
     * Beendet die Anfrage selbst (kein Seiten-Rendering).
     */
    private static function taskScanStep($cfg)
    {
        $url      = substr(htp_post('htp_page', ''), 0, 2000);
        $siteHost = strtolower((string) parse_url(htp_site_url(), PHP_URL_HOST));
        $own      = array($siteHost, 'www.' . $siteHost, (string) preg_replace('/^www\./', '', $siteHost));
        $host     = strtolower((string) parse_url($url, PHP_URL_HOST));
        $scheme   = strtolower((string) parse_url($url, PHP_URL_SCHEME));

        $out = array('ok' => false, 'status' => 0, 'paths' => array());

        // Nur Seiten der eigenen Site duerfen abgerufen werden
        if (in_array($scheme, array('http', 'https'), true)
            && in_array($host, $own, true)
            && HtpProbe::available()) {
            $result = HtpProbe::crawlPage($url, $siteHost, $cfg['site_path']);
            $out    = array('ok' => true, 'status' => $result['status'], 'paths' => $result['paths']);
        }

        self::json($out);
    }

    /**
     * JSON ausgeben und die Anfrage beenden.
     */
    private static function json($payload)
    {
        while (ob_get_level() > 0) {
            @ob_end_clean();
        }

        if (!headers_sent()) {
            header('Content-Type: application/json; charset=utf-8');
        }

        // Robust gegen ungueltiges UTF-8 in Schaddatei-Inhalten/-Namen: sonst
        // liefert json_encode FALSE und der ganze Scan kaeme leer zurueck.
        $flags = 0;

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

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

        $json = json_encode($payload, $flags);

        echo ($json === false) ? '{"ok":false}' : $json;

        $app = htp_app();

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

        exit;
    }

    /**
     * Vom Browser schrittweise eingesammelte Scan-Funde auswerten.
     * Erwartet JSON: {"pages": {url: status, ...}, "paths": {pfad: fundort, ...}}
     */
    private static function scanFromCollected($cfg, $json)
    {
        $payload = json_decode($json, true);

        if (!is_array($payload)) {
            return array(
                'ok'          => false,
                'error'       => htp_t('COM_HTPROTECT_SCAN_ERR_DATA'),
                'pages'       => array(),
                'checked'     => 0,
                'blocked'     => array(),
                'suggestions' => array(),
            );
        }

        $pages = array();

        if (isset($payload['pages']) && is_array($payload['pages'])) {
            foreach ($payload['pages'] as $url => $status) {
                if (count($pages) >= 60) {
                    break;
                }

                $pages[substr((string) $url, 0, 500)] = (int) $status;
            }
        }

        $resources = array();

        if (isset($payload['paths']) && is_array($payload['paths'])) {
            foreach ($payload['paths'] as $path => $source) {
                if (count($resources) >= 1000) {
                    break;
                }

                $path = (string) $path;

                if ($path === '' || $path[0] !== '/' || strlen($path) > 2048) {
                    continue;
                }

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

                $resources[$path] = substr((string) $source, 0, 500);
            }
        }

        $eval = HtpProbe::evaluate($cfg, $resources);

        return array(
            'ok'          => true,
            'error'       => '',
            'pages'       => $pages,
            'checked'     => $eval['checked'],
            'blocked'     => $eval['blocked'],
            'suggestions' => $eval['suggestions'],
        );
    }

    /**
     * 1-Klick aus Site-Scan / URL-Test: einen Pfad als vertrauenswürdig
     * eintragen (hebt Lockdown + uri-Signaturen + Dateischild für den Pfad
     * auf). Wirkt nach „Speichern + .htaccess schreiben".
     */
    private static function taskAllowAdd(&$cfg, &$messages)
    {
        $path = HtpConfig::cleanPathEntry(htp_post('htp_allow_path', ''), $cfg['site_path']);

        if ($path === '') {
            $messages[] = self::msg('info', htp_t('COM_HTPROTECT_ALLOW_NONE'));

            return;
        }

        if (in_array($path, $cfg['allow_paths'], true)) {
            $messages[] = self::msg('info', htp_t('COM_HTPROTECT_ALLOW_EXISTS', htp_esc($path)));

            return;
        }

        $cfg['allow_paths'][] = $path;
        sort($cfg['allow_paths']);
        HtpConfig::save($cfg);
        $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_ALLOW_ADDED', htp_esc($path)));
    }

    private static function taskScanApply(&$cfg, &$messages)
    {
        $selected = htp_post_array('sg');
        $applied  = 0;

        foreach ($selected as $item) {
            $parts = explode('|', $item, 2);

            if (count($parts) !== 2) {
                continue;
            }

            $key   = $parts[0];
            $value = $parts[1];

            if ($key === 'ext') {
                $value = strtolower(trim($value));

                if (preg_match('/^[a-z0-9]{1,12}$/', $value)) {
                    $cfg['s_ext'] = HtpConfig::cleanExtensions($cfg['s_ext'] . ',' . $value);
                    $applied++;
                }

                continue;
            }

            $clean = HtpConfig::cleanPathEntry($value, $cfg['site_path']);

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

            if ($key === 'file' && !in_array($clean, $cfg['s_files'], true)) {
                $cfg['s_files'][] = $clean;
                sort($cfg['s_files']);
                $applied++;
            } elseif ($key === 'dir_php') {
                $clean = rtrim($clean, '/');

                if ($clean !== '' && !in_array($clean, $cfg['s_dirs_php'], true)) {
                    $cfg['s_dirs_php'][] = $clean;
                    sort($cfg['s_dirs_php']);
                    $applied++;
                }
            }
        }

        if ($applied > 0) {
            HtpConfig::save($cfg);
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_SCAN_APPLIED', $applied));
        } else {
            $messages[] = self::msg('info', htp_t('COM_HTPROTECT_SCAN_NONE'));
        }
    }

    /**
     * URL-Test: eine komplette URL (mit Query-String) ODER einen einzelnen
     * Pfad gegen alle Schutzschichten pruefen und - bei einer vollen, eigenen
     * URL - zusaetzlich deren eingebundene Ressourcen mittesten.
     */
    private static function taskUrlTest($cfg, &$data)
    {
        // Eingabe (alte Pfad-Test-Felder als Fallback). Eingebundene Ressourcen
        // werden IMMER mitgeprueft - bewusst ohne Schalter (maximale Einfachheit).
        $input = trim(htp_post('htp_test_url', htp_post('htp_test_path', '')));
        $data['urltest_input'] = $input;

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

        $siteHost = strtolower((string) parse_url(htp_site_url(), PHP_URL_HOST));
        $fetchUrl = '';

        if (preg_match('#^https?://#i', $input)) {
            $path = HtpProbe::localPath($input, $siteHost, $cfg['site_path']);

            if ($path === '') {
                // Fremde Domain - dafuer ist keine Aussage moeglich.
                $data['urltest'] = array('foreign' => true, 'input' => $input);

                return;
            }

            $query    = (string) parse_url($input, PHP_URL_QUERY);
            $fetchUrl = $input;
        } else {
            $raw   = '/' . ltrim(str_replace('\\', '/', $input), '/');
            $query = (string) parse_url($raw, PHP_URL_QUERY);
            $path  = (string) preg_replace('/[?#].*$/', '', $raw);

            // Site-Unterverzeichnis tolerieren
            if ($cfg['site_path'] !== '' && stripos($path . '/', $cfg['site_path'] . '/') === 0) {
                $path = '/' . ltrim((string) substr($path, strlen($cfg['site_path'])), '/');
            }

            // Fuer die Ressourcen-Pruefung eine eigene, vollstaendige URL bauen.
            $fetchUrl = rtrim(htp_site_url(), '/') . $path . ($query !== '' ? '?' . $query : '');
        }

        $result = array(
            'foreign'   => false,
            'input'     => $input,
            'path'      => $path,
            'query'     => $query,
            'verdict'   => HtpProbe::inspectRequest($cfg, $path, $query, 'GET'),
            'fetched'   => false,
            'status'    => 0,
            'resources' => null,
        );

        // Ressourcen mittesten: Seite abrufen, eingebundene Pfade einsammeln
        // und jeden gegen die Pfad-Regeln pruefen (wie der Site-Scan).
        if ($fetchUrl !== '' && HtpProbe::available()) {
            $crawl             = HtpProbe::crawlPage($fetchUrl, $siteHost, $cfg['site_path']);
            $result['fetched'] = true;
            $result['status']  = (int) $crawl['status'];

            $resources = array();

            foreach ($crawl['paths'] as $rp) {
                $resources[$rp] = $fetchUrl;
            }

            $eval = HtpProbe::evaluate($cfg, $resources);
            $result['resources'] = array(
                'checked' => $eval['checked'],
                'blocked' => $eval['blocked'],
            );
        }

        $data['urltest'] = $result;
    }

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

    /**
     * cfg=-Kennung aus der aktiven .htaccess lesen.
     *
     * @return string
     */
    /**
     * Sicherheits-Ampel: kompakte Statusliste fuer die Uebersicht.
     *
     * @param   array  $cfg   Konfiguration
     * @param   array  $data  Anzeigedaten (status, guard, pending, vuln, dirshield, plugin)
     *
     * @return array Liste von ['state' => ok|warn|err, 'label' => string, 'sec' => string, 'hint' => string]
     */
    private static function ampel($cfg, $data)
    {
        $items  = array();
        $isHttps = strpos(htp_site_url(), 'https://') === 0;

        // ── Aktive-Angriffs-Spuren ganz oben (nur bei positivem Fund) ──
        $adm = isset($data['suspicious_admins']) ? $data['suspicious_admins'] : array();

        if (!empty($adm)) {
            $items[] = self::ampelItem('err', htp_t('COM_HTPROTECT_AMPEL_ROGUEADMIN', count($adm)), 'overview', htp_t('COM_HTPROTECT_AMPEL_ROGUEADMIN_HINT'), 'htp-a-admins');
        }

        $jce = isset($data['jce_profiles']) ? $data['jce_profiles'] : array();

        if (!empty($jce)) {
            $items[] = self::ampelItem('err', htp_t('COM_HTPROTECT_AMPEL_JCE_HACK', count($jce)), 'overview', htp_t('COM_HTPROTECT_AMPEL_JCE_HACK_HINT'), 'htp-a-jce');
        }

        $mal = isset($data['mal_htaccess']) ? $data['mal_htaccess'] : array();

        if (!empty($mal)) {
            $items[] = self::ampelItem('err', htp_t('COM_HTPROTECT_AMPEL_MALHT', count($mal)), 'overview', htp_t('COM_HTPROTECT_AMPEL_MALHT_HINT'), 'htp-a-malht');
        }

        $acc = isset($data['account_changes']) ? $data['account_changes'] : array();

        if (!empty($acc)) {
            $items[] = self::ampelItem('err', htp_t('COM_HTPROTECT_AMPEL_ACCOUNTS', count($acc)), 'overview', htp_t('COM_HTPROTECT_AMPEL_ACCOUNTS_HINT'), 'htp-a-accounts');
        }

        // Defacement-/SEO-Spam-Befund (definitiv) - 1h-Schnellpruefung der
        // Startseite, unabhaengig vom 6h-Hintergrundwaechter. Der Befund-Text
        // (z. B. der eingeschleuste Script-Loader) steht direkt in der Ampel.
        $seo = isset($data['seo_finding']) && is_array($data['seo_finding']) ? $data['seo_finding'] : array();

        if (!empty($seo) && !empty($seo['reasons'])) {
            // Befund-Text wird als HTML in die Ampel gerendert -> die (potenziell
            // angreifer-beeinflusste, z. B. Redirect-Ziel) dynamische Zeichenkette
            // escapen; die statische Vorlage behaelt ihre Entities.
            $items[] = self::ampelItem('err', htp_t('COM_HTPROTECT_AMPEL_SEO', htp_esc((string) $seo['reasons'][0])), 'scan', htp_t('COM_HTPROTECT_AMPEL_SEO_HINT'), 'htp-a-seowatch');
        }

        // Eingeschleuster Code in einer Kerndatei (configuration.php/Template) -
        // 1h-Schnellpruefung. Der Hinweis verweist auf den Malware-Scanner
        // (Site-Scan) zur Bereinigung; der dynamische Datei-Name wird escaped.
        $core = isset($data['core_finding']) && is_array($data['core_finding']) ? $data['core_finding'] : array();

        if (!empty($core) && !empty($core['reasons'])) {
            $items[] = self::ampelItem('err', htp_t('COM_HTPROTECT_AMPEL_CORE', htp_esc((string) $core['reasons'][0])), 'scan', htp_t('COM_HTPROTECT_AMPEL_CORE_HINT'), 'htp-a-malfiles');
        }

        // HTTPS erzwungen
        if (!empty($cfg['force_https'])) {
            $items[] = self::ampelItem('ok', htp_t('COM_HTPROTECT_AMPEL_HTTPS_FORCED'), 'shield', '', 'htp-a-https');
        } elseif ($isHttps) {
            $items[] = self::ampelItem('warn', htp_t('COM_HTPROTECT_AMPEL_HTTPS_NOTFORCED'), 'shield', htp_t('COM_HTPROTECT_AMPEL_HTTPS_NOTFORCED_HINT'), 'htp-a-https');
        } else {
            $items[] = self::ampelItem('warn', htp_t('COM_HTPROTECT_AMPEL_HTTPS_NONE'), 'shield', htp_t('COM_HTPROTECT_AMPEL_HTTPS_NONE_HINT'), 'htp-a-https');
        }

        // Schutzschild geschrieben & aktuell
        $st = $data['status'];

        if (!empty($cfg['htaccess_external'])) {
            // Haupt-.htaccess wird bewusst extern verwaltet (z. B. Admin Tools Pro
            // .htaccess Maker): HTProtect schreibt/prueft/nagt sie nicht -> KEIN
            // "nicht geschrieben"-Alarm. Der Schalter betrifft NUR die Haupt-.htaccess;
            // die Upload-Ordner-Haertung (eigene Ampel-Zeile) laeuft unabhaengig weiter.
            $items[] = self::ampelItem('ok', htp_t('COM_HTPROTECT_AMPEL_SHIELD_EXTERNAL'), 'shield', htp_t('COM_HTPROTECT_AMPEL_SHIELD_EXTERNAL_HINT'), 'htp-a-write');
        } elseif (htp_nginx_mode($cfg)) {
            $items[] = self::ampelItem('warn', htp_t('COM_HTPROTECT_AMPEL_SHIELD_NGINX'), 'shield', htp_t('COM_HTPROTECT_AMPEL_SHIELD_NGINX_HINT'), 'htp-a-write');
        } elseif (!$st['exists'] || !$st['ours']) {
            $items[] = self::ampelItem('err', htp_t('COM_HTPROTECT_AMPEL_SHIELD_UNWRITTEN'), 'shield', htp_t('COM_HTPROTECT_AMPEL_SHIELD_UNWRITTEN_HINT'), 'htp-a-write');
        } elseif (!empty($data['pending']) || $st['modified']) {
            $items[] = self::ampelItem('warn', htp_t('COM_HTPROTECT_AMPEL_SHIELD_STALE'), 'shield', htp_t('COM_HTPROTECT_AMPEL_SHIELD_STALE_HINT'), 'htp-a-write');
        } else {
            $items[] = self::ampelItem('ok', htp_t('COM_HTPROTECT_AMPEL_SHIELD_OK'), 'shield', '', 'htp-a-write');
        }

        // Exploit-Schild (.htaccess, Schicht 1)
        if (!empty($cfg['x_shield'])) {
            $items[] = self::ampelItem('ok', htp_t('COM_HTPROTECT_AMPEL_EXPLOIT_ON'), 'shield', '', 'htp-a-exploit');
        } else {
            $items[] = self::ampelItem('warn', htp_t('COM_HTPROTECT_AMPEL_EXPLOIT_OFF'), 'shield', htp_t('COM_HTPROTECT_AMPEL_EXPLOIT_OFF_HINT'), 'htp-a-exploit');
        }

        // Echtzeit-Schutz (Mini-WAF im Plugin, Schicht 2) - fängt auch POST-Exploits
        if (!empty($cfg['x_shield'])) {
            if (!empty($data['plugin']['enabled'])) {
                $items[] = self::ampelItem('ok', htp_t('COM_HTPROTECT_AMPEL_WAF_ON'), 'overview', '', 'htp-a-monitor');
            } else {
                $items[] = self::ampelItem('warn', htp_t('COM_HTPROTECT_AMPEL_WAF_OFF'), 'overview', htp_t('COM_HTPROTECT_AMPEL_WAF_OFF_HINT'), 'htp-a-monitor');
            }
        }

        // Backend-Schutz (eigener ODER extern/serverseitig erkannt)
        $gsrc = isset($data['guard']['source']) ? $data['guard']['source'] : 'none';

        if (!empty($data['guard']['protected'])) {
            $label = $gsrc === 'server' ? htp_t('COM_HTPROTECT_AMPEL_BACKEND_SERVER')
                : ($gsrc === 'foreign' ? htp_t('COM_HTPROTECT_AMPEL_BACKEND_FOREIGN')
                : ($gsrc === 'ecc' ? htp_t('COM_HTPROTECT_AMPEL_BACKEND_ECC')
                : htp_t('COM_HTPROTECT_AMPEL_BACKEND_PROTECTED')));
            $items[] = self::ampelItem('ok', $label, 'guard', '', 'htp_guard_user');
        } elseif (!empty($cfg['guard_dismissed'])) {
            // Bewusst uebersprungen (X auf der Schritt-1-Karte ODER Batch-Remove) hat VORRANG vor
            // 'unknown': kein dringender Punkt mehr (gruen), ehrlicher Text. Ohne diesen Vorrang kippte
            // der bewusst uebersprungene Punkt wieder auf gelb, sobald die HTTP-Selbstprobe einmal nicht
            // durchkommt (gsrc='unknown' ist volatil). Jederzeit unter "Backend-Zugang" nachruestbar.
            $items[] = self::ampelItem('ok', htp_t('COM_HTPROTECT_AMPEL_BACKEND_SKIPPED'), 'guard', htp_t('COM_HTPROTECT_AMPEL_BACKEND_SKIPPED_HINT'), 'htp_guard_user');
        } elseif ($gsrc === 'unknown') {
            $items[] = self::ampelItem('warn', htp_t('COM_HTPROTECT_AMPEL_BACKEND_UNKNOWN'), 'guard', htp_t('COM_HTPROTECT_AMPEL_BACKEND_UNKNOWN_HINT'), 'htp_guard_user');
        } else {
            $items[] = self::ampelItem('err', htp_t('COM_HTPROTECT_AMPEL_BACKEND_NONE'), 'guard', htp_t('COM_HTPROTECT_AMPEL_BACKEND_NONE_HINT'), 'htp_guard_user');
        }

        // Upload-Ordner-Haertung
        if (!empty($cfg['u_harden'])) {
            $status     = isset($data['dirshield']) ? $data['dirshield'] : array();
            $unprotected = 0;

            foreach ($status as $state) {
                if ($state === 'unprotected') {
                    $unprotected++;
                }
            }

            if ($unprotected === 0) {
                $items[] = self::ampelItem('ok', htp_t('COM_HTPROTECT_AMPEL_UPLOAD_OK'), 'shield', '', 'htp-a-upload');
            } else {
                $items[] = self::ampelItem('warn', htp_t('COM_HTPROTECT_AMPEL_UPLOAD_PARTIAL'), 'shield', htp_t('COM_HTPROTECT_AMPEL_UPLOAD_PARTIAL_HINT', $unprotected), 'htp-a-uploaddirs');
            }
        } else {
            $items[] = self::ampelItem('warn', htp_t('COM_HTPROTECT_AMPEL_UPLOAD_OFF'), 'shield', htp_t('COM_HTPROTECT_AMPEL_UPLOAD_OFF_HINT'), 'htp-a-upload');
        }

        // Verwundbare Erweiterungen: AKTIVE warnen (rot). Sind nur noch AUFGESCHOBENE offen,
        // ist die Ampel deckungsbewusst - GRUEN wenn ALLE aufgeschobenen von HTProtect aktiv
        // geblockt werden (echt geschuetzt, JED-faehig), sonst GELB (Restrisiko, nie "alles
        // gruen" vorgaukeln). Die Mail-Erinnerung ist fuer aufgeschobene generell still.
        $vActive = isset($data['vuln_active']) ? $data['vuln_active'] : (isset($data['vuln']) ? $data['vuln'] : array());
        $vAcked  = isset($data['vuln_acked']) ? $data['vuln_acked'] : array();

        if (!empty($vActive)) {
            $items[] = self::ampelItem('err', htp_t('COM_HTPROTECT_AMPEL_VULN_FOUND', count($vActive)), 'overview', htp_t('COM_HTPROTECT_AMPEL_VULN_FOUND_HINT'), 'htp-a-vuln');
        } elseif (!empty($vAcked)) {
            $allMit = true;

            foreach ($vAcked as $h) {
                if (empty($h['mitigated'])) {
                    $allMit = false;
                    break;
                }
            }

            $items[] = self::ampelItem(
                $allMit ? 'ok' : 'warn',
                htp_t('COM_HTPROTECT_AMPEL_VULN_ACKED', count($vAcked)),
                'overview',
                htp_t($allMit ? 'COM_HTPROTECT_AMPEL_VULN_ACKED_HINT' : 'COM_HTPROTECT_AMPEL_VULN_ACKED_RISK_HINT'),
                'htp-a-vuln'
            );
        } else {
            $items[] = self::ampelItem('ok', htp_t('COM_HTPROTECT_AMPEL_VULN_NONE'), 'overview', '', 'htp-a-sysinfo');
        }

        // Automatische Ueberwachung
        $pluginOn = !empty($data['plugin']['enabled']);
        $hasMail  = trim((string) $cfg['notify_email']) !== '';

        if ($pluginOn && $hasMail) {
            $items[] = self::ampelItem('ok', htp_t('COM_HTPROTECT_AMPEL_WATCH_OK'), 'overview', '', 'htp-a-monitor');
        } elseif ($pluginOn) {
            $items[] = self::ampelItem('warn', htp_t('COM_HTPROTECT_AMPEL_WATCH_NOMAIL'), 'overview', htp_t('COM_HTPROTECT_AMPEL_WATCH_NOMAIL_HINT'), 'htp_notify_email');
        } else {
            $items[] = self::ampelItem('warn', htp_t('COM_HTPROTECT_AMPEL_WATCH_OFF'), 'overview', htp_t('COM_HTPROTECT_AMPEL_WATCH_OFF_HINT'), 'htp-a-monitor');
        }

        // Joomla-Version
        $jv = htp_jversion();

        if (preg_match('/^(\d+)\.(\d+)/', $jv, $m)) {
            $major  = $m[1];
            $latest = HtpDefs::joomlaLatest();

            if ((int) $major < 5) {
                $items[] = self::ampelItem('warn', htp_t('COM_HTPROTECT_AMPEL_JOOMLA_OLD', $jv), 'overview', htp_t('COM_HTPROTECT_AMPEL_JOOMLA_OLD_HINT'), 'htp-a-sysinfo');
            } elseif (isset($latest[$major]) && version_compare($jv, $latest[$major], '<')) {
                $items[] = self::ampelItem('warn', htp_t('COM_HTPROTECT_AMPEL_JOOMLA_UPDATE', $jv, $latest[$major]), 'overview', htp_t('COM_HTPROTECT_AMPEL_JOOMLA_UPDATE_HINT'), 'htp-a-sysinfo');
            } else {
                $items[] = self::ampelItem('ok', htp_t('COM_HTPROTECT_AMPEL_JOOMLA_OK', $jv), 'overview', '', 'htp-a-sysinfo');
            }
        }

        // PHP-Version
        if (version_compare(PHP_VERSION, '7.4.0', '<')) {
            $items[] = self::ampelItem('err', htp_t('COM_HTPROTECT_AMPEL_PHP_OLD', PHP_VERSION), 'overview', htp_t('COM_HTPROTECT_AMPEL_PHP_OLD_HINT'), 'htp-a-sysinfo');
        } elseif (version_compare(PHP_VERSION, '8.1.0', '<')) {
            $items[] = self::ampelItem('warn', htp_t('COM_HTPROTECT_AMPEL_PHP_SOON', PHP_VERSION), 'overview', htp_t('COM_HTPROTECT_AMPEL_PHP_SOON_HINT'), 'htp-a-sysinfo');
        } else {
            $items[] = self::ampelItem('ok', htp_t('COM_HTPROTECT_AMPEL_PHP_OK', PHP_VERSION), 'overview', '', 'htp-a-sysinfo');
        }

        return $items;
    }

    private static function ampelItem($state, $label, $sec, $hint, $anchor = '')
    {
        return array('state' => $state, 'label' => $label, 'sec' => $sec, 'anchor' => $anchor, 'hint' => $hint);
    }

    /**
     * REINE Entscheidung (testbar): Soll das JED-Bewertungs-Banner erscheinen?
     * Nur wenn die aktuelle Einladungs-Generation NICHT weggeklickt wurde ($ack != GEN),
     * die Ampel gruen ist ($ampelAllGreen) und HTProtect sich >= JED_REVIEW_MIN_AGE seit
     * dauerhaft gruener Ampel bewaehrt hat. So NIE sofort bei Neuinstallation und NIE
     * unter einer offenen Warnung.
     *
     * @param   string    $ack             bisher bestaetigte Generation (cfg jed_review_ack)
     * @param   int       $greenSince      welcome_green_since (0 = aktuell nicht gruen)
     * @param   bool      $statusGreen     Statuskachel gruen? (von der aufrufenden Logik bestimmt)
     * @param   int|null  $now             Zeitbasis (Default time())
     *
     * @return boolean
     */
    public static function jedReviewShow($ack, $greenSince, $statusGreen, $now = null)
    {
        if ((string) $ack === self::JED_REVIEW_GEN) {
            return false; // aktuelle Generation bereits weggeklickt
        }

        if (!$statusGreen) {
            return false; // keine Bitte, solange die Statuskachel nicht gruen ist
        }

        $since = (int) $greenSince;
        $now   = ($now === null) ? time() : (int) $now;

        return $since > 0 && ($now - $since) >= self::JED_REVIEW_MIN_AGE;
    }

    /**
     * Config-Huelle zu jedReviewShow: liest ack + welcome_green_since aus der Config und
     * bestimmt "gruen" GENAU wie die Dashboard-Statuskachel (HtpStatus::card) - NICHT aus der
     * vollen Uebersichts-Ampel. So verhaelt sich die Einladung exakt wie die Kachel und
     * ignoriert dieselben Nicht-Bedrohungs-Punkte: Backend-Zugangsschutz, Joomla-/PHP-Version,
     * Haertungs-Hinweise. Konsistent mit dem 7-Tage-Timer (welcome_green_since), der ebenfalls
     * card()['level']==='success' nutzt.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return boolean
     */
    private static function jedReviewDue($cfg)
    {
        $ack   = isset($cfg['jed_review_ack']) ? (string) $cfg['jed_review_ack'] : '';
        $green = isset($cfg['welcome_green_since']) ? (int) $cfg['welcome_green_since'] : 0;

        // "Gruen" = Statuskachel gruen (kein Angriff/Vuln, Schild an, .htaccess ok). Guard,
        // Joomla-/PHP-Version u. a. Ampel-Hinweise zaehlen hier BEWUSST nicht mit.
        $tileGreen = false;

        if (class_exists('HtpStatus')) {
            $card      = HtpStatus::card($cfg);
            $tileGreen = is_array($card) && isset($card['level']) && $card['level'] === 'success';
        }

        return self::jedReviewShow($ack, $green, $tileGreen);
    }

    /**
     * Soll der Fernverwaltungs-Hinweis (htprotect.app) im Overview erscheinen? Nur wenn der
     * Connector ueberhaupt verfuegbar ist, die Site NICHT bereits gekoppelt ist (dann kennt der
     * Nutzer das Dashboard schon) und die aktuelle Einladungs-Generation noch nicht per × bestaetigt
     * wurde. Bewusst KEINE Bewaehrungs-/Ampel-Schwelle: der Hinweis darf sofort einladen.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return boolean
     */
    private static function fvNoticeDue($cfg)
    {
        // Der Fernverwaltungs-Link (Highlight-Ziel) existiert nur, wenn der Connector geladen ist -
        // ohne ihn gaebe es nichts zu bewerben. SONST bewusst KEINE Schwelle: SOFORT nach Install
        // sichtbar (auch schon gekoppelt), bis der Nutzer bewusst per × schliesst. Voellig entkoppelt
        // von der Bewertungs-Einladung (die erst nach Bewaehrung erscheint).
        if (!class_exists('HtpLink')) {
            return false;
        }

        $ack = isset($cfg['fv_notice_ack']) ? (int) $cfg['fv_notice_ack'] : 0;

        return $ack < self::FV_NOTICE_GEN;
    }

    /**
     * (2.6.4) Roher aktiver Backend-Sprach-Tag (was der eingeloggte Admin JETZT sieht). Bewusst
     * getLanguage()->getTag() und NICHT htp_lang() (das kollabiert auf de/en).
     *
     * @return string  z. B. "es-ES" oder '' (kein Sprachsystem)
     */
    private static function langPackRawTag()
    {
        try {
            $app = function_exists('htp_app') ? htp_app() : null;

            if ($app && method_exists($app, 'getLanguage')) {
                $l = $app->getLanguage();

                if ($l && method_exists($l, 'getTag')) {
                    return (string) $l->getTag();
                }
            }
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still
        }

        return '';
    }

    /**
     * (2.6.4) Kanonischer Sprachpaket-Tag zur aktiven Backend-Sprache, oder '' (abgedeckt/kein Paket).
     *
     * @return string
     */
    private static function langPackActiveTag()
    {
        $tag = self::langPackRawTag();

        if ($tag === '' || !function_exists('htp_langpack_for')) {
            return '';
        }

        $canon = htp_langpack_for($tag);

        return $canon === null ? '' : $canon;
    }

    /**
     * (2.6.4) Anzeigename der aktiven Sprache (native, z. B. "Espanol (Espana)") fuer Banner/Meldung.
     *
     * @return string
     */
    private static function langPackActiveLabel()
    {
        try {
            $app = function_exists('htp_app') ? htp_app() : null;

            if ($app && method_exists($app, 'getLanguage')) {
                $l = $app->getLanguage();

                if ($l && method_exists($l, 'getName')) {
                    $n = (string) $l->getName();

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

        $t = self::langPackActiveTag();

        return $t !== '' ? $t : '';
    }

    /**
     * (2.6.4) Soll der Sprachpaket-Hinweis im Overview erscheinen? Nur wenn die aktive Backend-Sprache
     * weder de-* noch en-* ist, ein passendes Paket existiert, es NICHT installiert ist und der Hinweis
     * fuer genau diese Sprache noch nicht per × verworfen wurde. Rueckgabe: Info-Array oder false.
     *
     * @param   array  $cfg  Konfiguration
     * @return  array|boolean
     */
    private static function langPackNoticeDue($cfg)
    {
        $tag = self::langPackActiveTag();

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

        $ack = isset($cfg['langpack_notice_ack']) ? (string) $cfg['langpack_notice_ack'] : '';

        if ($ack === $tag) {
            return false; // fuer genau diese Sprache bewusst weggeklickt
        }

        // Schon installiert? Datei auf Platte pruefen (robust, unabhaengig vom Extension-Element).
        $adm = defined('JPATH_ADMINISTRATOR')
            ? JPATH_ADMINISTRATOR
            : (defined('JPATH_ROOT') ? JPATH_ROOT . '/administrator' : '');

        if ($adm !== '' && is_file($adm . '/language/' . $tag . '/' . $tag . '.com_htprotect.ini')) {
            return false;
        }

        return array('tag' => $tag, 'label' => self::langPackActiveLabel());
    }

    /**
     * (2.6.4) 1-Klick-Installation des passenden Sprachpakets vom separaten Update-Server. Fail-closed:
     * signierter Katalog (Ed25519) -> ZIP per HTTPS -> SHA-256 gegen Katalog -> Paket-Signatur
     * (verifyPackage MUSS 'ok') -> Installer. Version-neutral (J2.5-6 / PHP 5.6-8.5).
     *
     * @return array  array(bool erfolg, string label-oder-fehlergrund)
     */
    private static function langPackInstall()
    {
        $tag = self::langPackActiveTag();

        if ($tag === '') {
            return array(false, 'no_pack');
        }

        $inner = self::langPackCatalogFetch();

        if (!is_array($inner) || empty($inner['packs']) || !is_array($inner['packs']) || empty($inner['packs'][$tag])) {
            return array(false, 'catalog');
        }

        $res = self::langPackInstallFromCatalog($tag, $inner);

        // Erfolg -> das freundliche Sprach-Label zurueckgeben (wie bisher), sonst den Fehlergrund.
        return (is_array($res) && !empty($res[0]))
            ? array(true, self::langPackActiveLabel())
            : (is_array($res) ? $res : array(false, 'install'));
    }

    /**
     * (2.6.11) Signierten Sprachpaket-Katalog holen + Ed25519-verifizieren (Envelope wie der
     * Defs-Feed). Liefert das entpackte Innere (mit 'packs') oder null.
     */
    private static function langPackCatalogFetch()
    {
        if (!class_exists('HtpProbe') || !class_exists('HtpDefs')) {
            return null;
        }

        $cat = HtpProbe::fetch(self::LANGPACK_CATALOG_URL, true, null, 20);

        if (!is_array($cat) || (int) $cat['status'] !== 200 || !is_string($cat['body']) || $cat['body'] === '') {
            return null;
        }

        $outer = json_decode($cat['body'], true);

        return is_array($outer) ? HtpDefs::verifyAndUnwrap($outer) : null;
    }

    /**
     * (2.6.11) Ein Sprachpaket zu $tag aus dem BEREITS verifizierten Katalog installieren bzw.
     * aktualisieren: ZIP per HTTPS -> SHA-256 gegen Katalog -> Paket-Signatur (verifyPackage MUSS
     * 'ok') -> Installer (method=upgrade, version-neutral). Fail-closed. array(bool ok, string code).
     */
    private static function langPackInstallFromCatalog($tag, $inner)
    {
        if (!is_array($inner) || empty($inner['packs'][$tag]) || !is_array($inner['packs'][$tag])) {
            return array(false, 'catalog_sig');
        }

        if (!class_exists('HtpProbe') || !class_exists('HtpDefs') || !class_exists('ZipArchive')) {
            return array(false, 'unavailable');
        }

        $entry = $inner['packs'][$tag];
        $file  = isset($entry['file']) ? (string) $entry['file'] : '';
        $sha   = isset($entry['sha256']) ? strtolower((string) $entry['sha256']) : '';

        // Dateiname streng validieren (kein Pfad/Redirect): htprotect-lang-<xx-YY>-<version>.zip
        if (!preg_match('~^htprotect-lang-[A-Za-z]{2}-[A-Za-z]{2}-[0-9][0-9.]*\.zip$~', $file)) {
            return array(false, 'bad_entry');
        }

        // SHA-256 aus dem SIGNIERTEN Katalog ist PFLICHT (kein/ungueltiges sha -> kein Install).
        // Sie bindet das ZIP kryptografisch an den Ed25519-signierten Katalog, BEVOR geladen und
        // entpackt wird. Ein kompromittierter Origin kann so kein manipuliertes ZIP unterschieben,
        // das VOR der Paket-Signaturpruefung auf die Platte entpackt wuerde (zip-slip-Schutz). Der
        // signierte Katalog liefert immer ein sha; fehlt es, brechen wir bewusst fail-closed ab.
        if (!preg_match('~^[a-f0-9]{64}$~', $sha)) {
            return array(false, 'no_sha');
        }

        // ZIP holen (HTTPS, TLS-verifiziert durch HtpProbe).
        $z = HtpProbe::fetch(self::LANGPACK_BASE_URL . $file, true, null, 40);

        if (!is_array($z) || (int) $z['status'] !== 200 || !is_string($z['body']) || $z['body'] === '') {
            return array(false, 'download');
        }

        // SHA-256 pruefen (jetzt garantiert vorhanden) - bindet die geladenen Bytes an den Katalog.
        if (!hash_equals($sha, strtolower(hash('sha256', $z['body'])))) {
            return array(false, 'sha');
        }

        $tmpRoot = defined('JPATH_ROOT') ? JPATH_ROOT . '/tmp' : sys_get_temp_dir();

        if (!is_dir($tmpRoot) || !is_writable($tmpRoot)) {
            $tmpRoot = sys_get_temp_dir();
        }

        $stamp   = substr(md5($file . microtime()), 0, 10);
        $zipPath = $tmpRoot . '/htp_lp_' . $stamp . '.zip';
        $dir     = $tmpRoot . '/htp_lp_' . $stamp;

        if (@file_put_contents($zipPath, $z['body']) === false) {
            self::langPackCleanup($zipPath, $dir);
            return array(false, 'tmp');
        }

        @mkdir($dir, 0755, true);

        $zip = new ZipArchive();

        if ($zip->open($zipPath) !== true || !$zip->extractTo($dir)) {
            if ($zip) {
                @$zip->close();
            }

            self::langPackCleanup($zipPath, $dir);
            return array(false, 'unzip');
        }

        $zip->close();

        // Paket-Signatur pruefen - FAIL-CLOSED: nur bei 'ok' installieren (strenger als der Kern-Watcher).
        $v = HtpDefs::verifyPackage($dir);

        if (!is_array($v) || !isset($v['status']) || $v['status'] !== 'ok') {
            self::langPackCleanup($zipPath, $dir);
            return array(false, 'verify');
        }

        // Installieren/aktualisieren (Installer::getInstance(); version-neutral, method=upgrade).
        $installed = htp_install_from_dir($dir);

        self::langPackCleanup($zipPath, $dir);

        return array((bool) $installed, $installed ? 'ok' : 'install');
    }

    /**
     * (2.6.11) Installierte HTProtect-Sprachpakete + ihre Version. Element file_htprotect_<xx_YY>
     * -> Tag <xx-YY>. Liefert z. B. array('nl-NL' => '2.6.4', 'ru-RU' => '2.6.4').
     */
    private static function langPackInstalledVersions()
    {
        $out = array();
        $db  = function_exists('htp_db') ? htp_db() : null;

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

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName(array('element', 'manifest_cache')))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('file'))
                ->where($db->quoteName('element') . ' LIKE ' . $db->quote('file_htprotect_%'));
            $db->setQuery($query);
            $rows = $db->loadAssocList();

            if (!is_array($rows)) {
                return $out;
            }

            foreach ($rows as $r) {
                $el = isset($r['element']) ? (string) $r['element'] : '';

                if (strpos($el, 'file_htprotect_') !== 0) {
                    continue;
                }

                // file_htprotect_nl_NL -> nl-NL
                $tag = str_replace('_', '-', substr($el, strlen('file_htprotect_')));
                $mc  = isset($r['manifest_cache']) ? json_decode((string) $r['manifest_cache'], true) : null;
                $ver = (is_array($mc) && isset($mc['version'])) ? (string) $mc['version'] : '';

                if ($tag !== '' && $ver !== '') {
                    $out[$tag] = $ver;
                }
            }
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }

        return $out;
    }

    /**
     * (2.6.11) Auto-Update der installierten HTProtect-Sprachpakete - wird vom Auto-Update-Lauf
     * NUR aufgerufen, wenn "HTProtect immer aktuell halten" (autoupdate_self) aktiv ist, und dort
     * gedrosselt. Jedes installierte Paket, das AELTER als der signierte Katalog ist, wird still
     * aktualisiert (derselbe fail-closed Weg wie die 1-Klick-Installation: signierter Katalog ->
     * SHA-256 -> Paket-Signatur -> Installer). UNABHAENGIG von Joomlas Update-Site-Erkennung, greift
     * also auch dort, wo der normale Updater die Datei-Erweiterungen nicht sauber zuordnet.
     * Fail-soft: eine Sprachpaket-Aktualisierung darf den Auto-Update-Lauf NIE stoeren.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return void
     */
    public static function langPackAutoUpdate($cfg)
    {
        try {
            if (!class_exists('HtpProbe') || !class_exists('HtpDefs') || !class_exists('ZipArchive')) {
                return;
            }

            $installed = self::langPackInstalledVersions();

            if (empty($installed)) {
                return; // keine HTProtect-Sprachpakete installiert -> nichts zu tun
            }

            $inner = self::langPackCatalogFetch();

            if (!is_array($inner) || empty($inner['packs']) || !is_array($inner['packs'])) {
                return;
            }

            // Sicherheitsventil: hoechstens einige Pakete pro Lauf installieren, damit ein
            // Versionssprung, der VIELE installierte Sprachpakete gleichzeitig veraltet, keinen
            // ueberlangen Wächter-Tick erzeugt. Der Rest folgt beim naechsten gedrosselten Lauf
            // (real hat eine Site 1-2 Backend-Sprachen, das Limit greift praktisch nie).
            $budget = 8;

            foreach ($installed as $tag => $curVer) {
                if ($budget <= 0) {
                    break;
                }

                if (empty($inner['packs'][$tag]) || !is_array($inner['packs'][$tag])) {
                    continue;
                }

                $newVer = isset($inner['packs'][$tag]['version']) ? (string) $inner['packs'][$tag]['version'] : '';

                // NUR bei echt neuerer Katalog-Version aktualisieren (kein Downgrade, kein Re-Install).
                if ($newVer === '' || version_compare($newVer, (string) $curVer, '<=')) {
                    continue;
                }

                self::langPackInstallFromCatalog($tag, $inner); // fail-closed intern; Fehler -> naechster Lauf
                $budget--;
            }
        } catch (Throwable $e) {
            // fail-soft
        } catch (Exception $e) {
            // fail-soft
        }
    }

    /**
     * (2.6.4) Temp-ZIP + Temp-Ordner der Sprachpaket-Installation entfernen (fail-silent).
     */
    private static function langPackCleanup($zipPath, $dir)
    {
        if (is_string($zipPath) && $zipPath !== '') {
            @unlink($zipPath);
        }

        if (is_string($dir) && $dir !== '' && is_dir($dir)) {
            if (function_exists('htp_delete_dir')) {
                htp_delete_dir($dir);
            } else {
                @rmdir($dir);
            }
        }
    }

    /**
     * Soll der Hinweis auf die doppelte Komprimierung im Overview erscheinen? Nur wenn der
     * Server die Seiten bereits selbst komprimiert (LiteSpeed nativ / zlib.output_compression)
     * UND Joomlas eigene Komprimierung zusaetzlich AN ist - dann bringt sie nichts und kann
     * Darstellungsfehler ausloesen. Der Hinweis verschwindet von selbst, sobald die Ursache
     * weg ist, und dauerhaft, wenn der Betreiber ihn per × schliesst (bewusste Entscheidung).
     * Aenderung: reiner HINWEIS - HTProtect schaltet nichts mehr von sich aus ab.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return boolean
     */
    private static function gzipNoticeDue($cfg)
    {
        if (!empty($cfg['gzip_notice_ack'])) {
            return false; // bewusst weggeklickt -> dauerhaft still
        }

        if (!function_exists('htp_server_precompresses') || !htp_server_precompresses()) {
            return false; // Server komprimiert nicht selbst -> Joomlas gzip ist dort sinnvoll
        }

        return self::joomlaGzipOn();
    }

    /**
     * Ist Joomlas eigene Komprimierung ($gzip) aktuell eingeschaltet? Liest die bereits
     * geladene Konfiguration (in-memory, kein Datei-I/O). Fail-closed: laesst sich der
     * Wert nicht ermitteln, gilt die Komprimierung als AUS (kein Hinweis auf Verdacht).
     *
     * @return boolean
     */
    private static function joomlaGzipOn()
    {
        try {
            $app = function_exists('htp_app') ? htp_app() : null;

            if (!is_object($app) || !method_exists($app, 'get')) {
                return false;
            }

            $gzip = $app->get('gzip');

            return !(empty($gzip) || $gzip === '0' || $gzip === 0 || $gzip === false);
        } catch (\Throwable $e) {
            return false;
        } catch (\Exception $e) {
            return false;
        }
    }

    /**
     * Gedrosselte SEO-/Defacement-Schnellpruefung fuer die Uebersicht (1 h),
     * UNABHAENGIG vom seo_watch-Schalter (force=true) - der steuert nur die
     * 6h-Mail-Automatik im Hintergrund. So erscheint ein definitiver Fund (z. B.
     * ein eingeschleuster Script-Loader) sofort in der Ampel, ohne auf den
     * naechsten Hintergrund-Tick zu warten. Hoechstens 1 Startseiten-Abruf/Std.
     * Zwischen den Fenstern wird der zuletzt gespeicherte Befund weiterverwendet
     * (kein Flackern der Ampel/Dashboard-Kachel).
     *
     * @param   array  $cfg  Konfiguration (Referenz)
     *
     * @return array array('kind'=>…,'reasons'=>[…]) oder array() (sauber)
     */
    private static function seoOverviewCheck(&$cfg)
    {
        $finding = (isset($cfg['seo_overview_finding']) && is_array($cfg['seo_overview_finding']))
            ? $cfg['seo_overview_finding'] : array();

        // Ohne SEO-/Probe-Faehigkeit nur den letzten bekannten Befund anzeigen
        // (kein Reset, kein Abruf).
        if (!class_exists('HtpSeo') || !class_exists('HtpProbe') || !HtpProbe::available()) {
            return $finding;
        }

        $last = (int) (isset($cfg['seo_overview_ts']) ? $cfg['seo_overview_ts'] : 0);

        if ((time() - $last) < self::SEO_OVERVIEW_THROTTLE) {
            return $finding; // Drossel: noch innerhalb des 1h-Fensters
        }

        // scan(force=true) prueft unabhaengig vom Schalter und schreibt bei sauberem Lauf
        // still die Basislinie in $cfg fort. deep=FALSE: der Uebersichts-Schnellcheck laeuft
        // beim Render und darf NICHT durch zusaetzliche Sprach-Startseiten-Abrufe langsam
        // werden - die Multi-URL-Pruefung uebernimmt der 6h-Waechter + der manuelle Lauf.
        $incidents = HtpSeo::scan($cfg, true, false);
        $finding   = HtpSeo::findingFromIncidents($incidents);

        $cfg['seo_overview_ts']      = time();
        $cfg['seo_overview_finding'] = $finding;

        HtpConfig::saveKeys(array(
            'seo_overview_ts'      => $cfg['seo_overview_ts'],
            'seo_overview_finding' => $finding,
            'seo_baseline'         => isset($cfg['seo_baseline']) && is_array($cfg['seo_baseline']) ? $cfg['seo_baseline'] : array(),
        ));

        return $finding;
    }

    /**
     * Gedrosselte Kerndatei-Anomalie-Schnellpruefung fuer die Uebersicht (1 h),
     * read-only (beruehrt KEINE Basislinie). Liefert {reasons:[Datei-Label]} bei
     * eingeschleustem Code in einer Kerndatei (configuration.php/Template), sonst
     * leer, und persistiert den Befund (damit er zwischen den Drossel-Fenstern
     * stehen bleibt). Spiegelt seoOverviewCheck.
     *
     * @param   array  $cfg  Konfiguration (Referenz)
     *
     * @return array
     */
    private static function coreOverviewCheck(&$cfg)
    {
        $finding = (isset($cfg['core_overview_finding']) && is_array($cfg['core_overview_finding']))
            ? $cfg['core_overview_finding'] : array();

        if (!class_exists('HtpWatch') || !method_exists('HtpWatch', 'coreAnomalyScan')) {
            return $finding; // ohne Methode nur den letzten bekannten Befund
        }

        $last = (int) (isset($cfg['core_overview_ts']) ? $cfg['core_overview_ts'] : 0);

        // Drossel NUR im sauberen Zustand. Steht ein Befund (Ampel rot), bei JEDEM
        // Aufruf neu pruefen -> die Ampel wird gruen, SOBALD der Nutzer die Kerndatei
        // bereinigt hat (kein Warten auf das 1h-Fenster; ein externer Editor-Fix
        // wuerde sonst bis zu 1h lang faelschlich rot bleiben). coreAnomalyScan liest
        // nur wenige Kerndateien -> im roten Zustand guenstig genug fuer jeden Aufruf.
        if (empty($finding) && (time() - $last) < self::CORE_OVERVIEW_THROTTLE) {
            return $finding;
        }

        $finding = array();

        foreach (HtpWatch::coreAnomalyScan() as $inc) {
            if (is_array($inc) && isset($inc['label']) && $inc['label'] !== '') {
                $finding = array('reasons' => array((string) $inc['label']));
                break;
            }
        }

        $cfg['core_overview_ts']      = time();
        $cfg['core_overview_finding'] = $finding;

        HtpConfig::saveKeys(array(
            'core_overview_ts'      => $cfg['core_overview_ts'],
            'core_overview_finding' => $finding,
        ));

        return $finding;
    }

    /**
     * Backend-Schutz-Status anreichern: erkennt zusaetzlich zum eigenen
     * Schutz auch eine extern (serverseitig/Hosting) oder per fremder
     * .htaccess eingerichtete Basic-Auth. Setzt:
     *   $data['guard']['protected'] (bool) und ['source']
     *     'htprotect' | 'server' | 'foreign' | 'none' | 'unknown'
     *
     * @param   array  $data  Anzeigedaten (Referenz)
     *
     * @return void
     */
    private static function enrichGuard(&$data)
    {
        $g = $data['guard'];
        $g['external'] = false;

        if (!empty($g['active'])) {
            $g['protected'] = true;
            $g['source']    = 'htprotect';
            $data['guard']  = $g;

            return;
        }

        // EasyCalcCheck Plus: Backend-Login per Token geschuetzt? Lokal + sicher
        // aus der Plugin-Konfiguration lesbar (kein HTTP-Probe noetig; der
        // Token-Schutz wuerde per HTTP auch kein 401 liefern).
        if (HtpGuard::eccBackendProtected()) {
            $g['protected'] = true;
            $g['source']    = 'ecc';
            $data['guard']  = $g;

            return;
        }

        // Nicht von uns -> per HTTP pruefen (erkennt Hosting-/vHost-Basic-Auth)
        $probeStatus = -1;

        if (HtpProbe::available()) {
            $probe       = HtpGuard::probeExternal();
            $probeStatus = $probe['status'];
            $g['external'] = !empty($probe['protected']);
        }

        if ($g['external']) {
            $g['protected'] = true;
            $g['source']    = !empty($g['foreign_auth']) ? 'foreign' : 'server';
        } elseif ($probeStatus === 0 || $probeStatus === -1) {
            // Nicht pruefbar (Server erreicht sich selbst nicht) -> Datei-Hinweis nutzen,
            // aber NICHT faelschlich "ungeschuetzt" melden.
            $g['protected'] = !empty($g['foreign_auth']);
            $g['source']    = !empty($g['foreign_auth']) ? 'foreign' : 'unknown';
        } else {
            // Backend antwortete ohne 401 -> tatsaechlich kein Schutz
            $g['protected'] = false;
            $g['source']    = 'none';
        }

        $data['guard'] = $g;
    }

    /**
     * Pfad der WAF-Protokolldatei.
     *
     * @return string
     */
    private static function wafLogPath()
    {
        return htp_state_dir() . '/htprotect_waf.log';
    }

    /**
     * Letzte Eintraege des Echtzeit-Schutz-Protokolls lesen (neueste zuerst).
     *
     * @param   integer  $max  Maximalanzahl
     *
     * @return array
     */
    private static function readWafLog($max)
    {
        $file = self::wafLogPath();

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

        $lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

        if (!is_array($lines)) {
            return array();
        }

        $lines = array_slice($lines, -$max);
        $out   = array();

        foreach (array_reverse($lines) as $line) {
            $entry = json_decode($line, true);

            if (is_array($entry) && isset($entry['sig'])) {
                $out[] = $entry;
            }
        }

        return $out;
    }

    /**
     * WAF-Protokoll leeren.
     *
     * @return void
     */
    private static function clearWafLog()
    {
        $file = self::wafLogPath();

        if (is_file($file)) {
            @unlink($file);
        }
    }

    private static function fileConfigHash()
    {
        $content = HtpStorage::read();

        if (preg_match('/# htprotect-shield:begin v=\S+(?: rs=\d+)? cfg=([0-9a-f]{12})/', $content, $m)) {
            return $m[1];
        }

        return '';
    }

    private static function msg($type, $html)
    {
        return array('type' => $type, 'html' => $html);
    }
}
