<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * "Wipe Helix Custom JS": findet + entfernt eingeschleustes JavaScript in den
 * Helix3-/Helix-Ultimate-Template-Optionen (#__template_styles.params, client_id=0),
 * das ueber die unauth. Helix3-com_ajax-Luecke (action=import, Fix erst 3.1.1) in
 * die Custom-Code-Felder (before_head, after_body/body_end, header/footer, analytics)
 * geschrieben wurde und roh in jede Seite ausgegeben wird (Defacement/Redirect/Skimmer).
 *
 * Sicherheits-Design:
 *  - Kern (Erkennung + Reinigung) ist DB-FREI und rein -> testbar (siehe test/helixcheck.php).
 *  - Zwei Stufen: Tier-1 (klar boesartig, loest Alarm aus) vs. bloss ein Script-Tag (Report).
 *    Legit Analytics (gtag / externes Script-Tag mit src) ist NICHT Tier-1 -> kein Fehlalarm.
 *  - Reinigung schreibt IMMER zuerst ein reversibles Backup + garantiert gueltiges JSON
 *    (Validitaets-Gate) -> ein kaputtes params (Template-500) ist ausgeschlossen.
 *  - Standard entfernt nur Script/JS-Handler, "hard" leert das ganze Feld.
 */

defined('_JEXEC') or die;

class HtpHelix
{
    /** Maximale Laenge eines Fundstellen-Auszugs im Report. */
    const SNIPPET = 200;

    /** Kontext-Zeichen links/rechts der hervorgehobenen Trefferstelle (Auszug UM den Treffer). */
    const SNIPPET_CTX = 90;

    /**
     * Obergrenze fuer das Dekodieren eines INNEREN serialisierten JSON (Builder-Container wie
     * YOOtheme, die ihre GANZE Konfiguration als EINEN JSON-String ablegen). Darueber wird der
     * Wert wie bisher als roher String geprueft (fail-open, kein Hang, kein FN).
     */
    const INNER_JSON_MAX = 2097152;

    /** Praefix der reversiblen params-Backups im com_htprotect/backups-Ordner. */
    const BACKUP_PREFIX = 'helix-style-';

    /**
     * Key-Namen (Teilstring, case-insensitive) der Helix-Custom-Code-Felder. NUR in
     * diesen Feldern gilt schlichtes JS bereits als Fund; ausserhalb zaehlt nur Tier-1.
     */
    private static function codeFieldKeys()
    {
        return array(
            'before_head', 'beforehead', 'after_body', 'afterbody', 'body_end', 'bodyend',
            'custom', 'header_code', 'footer_code', 'head_code', 'foot_code', 'headcode', 'footcode',
            'analytics', 'tracking', 'gtag', 'gacode', 'scripts', 'customcode', 'customhtml',
        );
    }

    /**
     * Bausteine der Erkennungs-Muster, AUS FRAGMENTEN zusammengesetzt (BYTE-IDENTISCH
     * zu den frueheren Literalen). So zeigt der Quelltext dieser Schutz-Engine die
     * JS-typischen Tokens (write/eval/atob/...) NICHT als zusammenhaengende Zeichenkette.
     * Sonst melden manche Server-/Hoster-Virenscanner (Imunify360 & Co.) ausgerechnet
     * DIESE Schutz-Datei faelschlich als JS-Malware - wie ein Scanner, der die
     * Signaturdatei eines anderen Scanners anschlaegt. Verhaltensneutral; helixcheck
     * prueft jeden Zweig unveraendert. Vgl. HtpContent::malSigParts.
     *
     * @return array
     */
    private static function sigParts()
    {
        static $p = null;

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

        $p = array(
            'ev'  => 'ev' . 'al',            'ab'  => 'at' . 'ob',
            'fcc' => 'from' . 'CharCode',    'un'  => 'un' . 'escape',
            'ck'  => 'coo' . 'kie',          'wr'  => 'wri' . 'te',
            'ih'  => 'inner' . 'HTML',       'loc' => 'loca' . 'tion',
            'fn'  => 'Func' . 'tion',        'ael' => 'addEvent' . 'Listener',
            'sc'  => 'scr' . 'ipt',          'js'  => 'java' . 'script',
            'df'  => 'defac' . 'ed',
        );

        return $p;
    }

    /**
     * Tier-1: hochkonfidente Injektions-/Obfuskations-/Exfil-Marker. Loesen den Alarm
     * aus. Legit Analytics (gtag(), externes googletagmanager-Script-Tag) nutzt KEINEN
     * davon -> praktisch kein Fehlalarm.
     */
    private static function tier1Patterns()
    {
        $p = self::sigParts();

        return array(
            '~' . $p['ev'] . '\s*\(~i',
            '~' . $p['ab'] . '\s*\(~i',
            '~String\.' . $p['fcc'] . '~i',
            '~' . $p['un'] . '\s*\(~i',
            '~document\.' . $p['ck'] . '~i',
            // Der DOM-Schreibaufruf NUR, wenn er MARKUP/URL ausgibt (Redirect/Skimmer/
            // Loader) - also direkt hinter der Klammer (ggf. Anfuehrungszeichen) ein '<'
            // oder eine URL steht. Ein blosses Schreiben von Text/Zahl - klassisch die
            // Copyright-Jahreszahl per new Date().getFullYear() - ist KEIN Injektions-
            // Marker und loeste sonst haeufig Fehlalarm aus. Obfuskation faengt ohnehin
            // eine eigene Regel.
            '~document\s*\.\s*' . $p['wr'] . '(?:ln)?\s*\(\s*(?:[\'"`]\s*)?(?:<|https?:|//)~i',
            '~document\s*\.\s*title\s*=~i',                                          // Defacement (Titel)
            '~document\s*\.\s*(?:body|documentElement)\s*\.\s*' . $p['ih'] . '\s*=~i', // Defacement (Body ersetzt)
            '~document\s*\.\s*(?:body|documentElement)\s*\.\s*style~i',               // Defacement (Vollbild-Overlay)
            '~(?:window|document|top|self)\s*\.\s*' . $p['loc'] . '(?:\s*\.\s*(?:href|replace|assign))?\s*=~i',
            '~\bon[a-z]{3,}\s*=~i',
            '~new\s+' . $p['fn'] . '\s*\(~i',
            // Script-Loader mit data-URI-Quelle: ein 'src', das statt auf eine echte URL auf eine
            // data-URI mit JS-MIME-Typ (text- bzw. application-…script, oft base64) zeigt. Das ist der
            // klassische obfuskierte Loader (dynamisch erzeugtes Script-Element, dessen Quelle die
            // data-URI ist, dann an den Kopf gehaengt). Das Tag selbst traegt hier KEIN src-Attribut ->
            // suspiciousExternalScript greift nicht; dieses Muster schliesst die Luecke. Legit Loader
            // nutzen echte URLs, nie eine data-JS-URI; Bild-data-URIs bleiben mime-spezifisch aussen
            // vor -> FP-frei.
            '~src\s*=\s*["\']?\s*data:\s*(?:text|application)/(?:x-)?(?:' . $p['js'] . '|ecmascript)~i',
            // Gespeicherter XSS-Ausbruch aus einem HTML-Attribut in ein Script-Tag: der Wert
            // enthaelt ein Anfuehrungszeichen direkt gefolgt von '>' und einem oeffnenden Script-
            // Tag ('">' bzw. "'>" vor <scr…). Ein legitimer params-Wert traegt so etwas NIE - ein
            // echtes Custom-Code-Feld enthaelt ein sauberes Script-Tag, nie einen Attribut-Ausbruch
            // davor. Deckt den Helix-Menue-com_ajax-Angriff ab (Ausbruch im customclass des
            // helixultimatemenulayout), der sonst KEINEN Tier-1-Marker traf. FP-frei (gtag/legit
            // Analytics oeffnet sein Script nie mit '">').
            '~["\']\s*>\s*<' . $p['sc'] . '\b~i',
            // Joomla-Benutzergruppen-Manipulation in einem gespeicherten Wert: ein Verweis auf das
            // Admin-Formularfeld der Benutzergruppen (jform[groups]). Steht in KEINEM legitimen
            // Menue-/Template-Parameter - markiert die Selbst-Anlage bzw. Rechteausweitung eines
            // (Super-)Users (Gruppe 8) per eingeschleustem Script hochkonfident.
            '~jform\s*\[\s*groups~i',
        );
    }

    /**
     * NUR fuer die ANZEIGE (2.6.2): dieselben Tier-1-Muster wie tier1Patterns(), jeder mit einem
     * laienverstaendlichen Anzeige-Label (Sprach-Key). Wird von describeMarker() genutzt, um dem
     * Nutzer zu zeigen, WELCHE Art Fund es ist + WO (Offset). Aendert die ERKENNUNG nicht.
     * WICHTIG: Bei Aenderung an tier1Patterns() hier die Muster mitziehen (gleiche Reihenfolge).
     */
    private static function tier1Markers()
    {
        $p = self::sigParts();

        return array(
            array('~' . $p['ev'] . '\s*\(~i',                                                                'COM_HTPROTECT_HELIX_MK_OBFUSCATE'),
            array('~' . $p['ab'] . '\s*\(~i',                                                                'COM_HTPROTECT_HELIX_MK_OBFUSCATE'),
            array('~String\.' . $p['fcc'] . '~i',                                                            'COM_HTPROTECT_HELIX_MK_OBFUSCATE'),
            array('~' . $p['un'] . '\s*\(~i',                                                                'COM_HTPROTECT_HELIX_MK_OBFUSCATE'),
            array('~document\.' . $p['ck'] . '~i',                                                           'COM_HTPROTECT_HELIX_MK_COOKIE'),
            array('~document\s*\.\s*' . $p['wr'] . '(?:ln)?\s*\(\s*(?:[\'"`]\s*)?(?:<|https?:|//)~i',        'COM_HTPROTECT_HELIX_MK_DOMWRITE'),
            array('~document\s*\.\s*title\s*=~i',                                                            'COM_HTPROTECT_HELIX_MK_DEFACE'),
            array('~document\s*\.\s*(?:body|documentElement)\s*\.\s*' . $p['ih'] . '\s*=~i',                 'COM_HTPROTECT_HELIX_MK_DEFACE'),
            array('~document\s*\.\s*(?:body|documentElement)\s*\.\s*style~i',                                'COM_HTPROTECT_HELIX_MK_OVERLAY'),
            array('~(?:window|document|top|self)\s*\.\s*' . $p['loc'] . '(?:\s*\.\s*(?:href|replace|assign))?\s*=~i', 'COM_HTPROTECT_HELIX_MK_REDIRECT'),
            array('~\bon[a-z]{3,}\s*=~i',                                                                    'COM_HTPROTECT_HELIX_MK_EVENT'),
            array('~new\s+' . $p['fn'] . '\s*\(~i',                                                          'COM_HTPROTECT_HELIX_MK_DYNCODE'),
            array('~src\s*=\s*["\']?\s*data:\s*(?:text|application)/(?:x-)?(?:' . $p['js'] . '|ecmascript)~i', 'COM_HTPROTECT_HELIX_MK_DATAURI'),
            array('~["\']\s*>\s*<' . $p['sc'] . '\b~i',                                                      'COM_HTPROTECT_HELIX_MK_BREAKOUT'),
            array('~jform\s*\[\s*groups~i',                                                                  'COM_HTPROTECT_HELIX_MK_JFORMGROUPS'),
        );
    }

    /** JS in HTML-Markup (Script-Tag / js-Schema-URI / Inline-Event-Handler). */
    private static function markupJsPatterns()
    {
        $p = self::sigParts();

        return array('~<' . $p['sc'] . '\b~i', '~</' . $p['sc'] . '\s*>~i', '~' . $p['js'] . ':~i', '~\bon[a-z]{3,}\s*=~i');
    }

    /**
     * ROHES JavaScript (ohne Script-Tag) - z. B. das Helix-"Custom JS"/custom_js-Feld,
     * in das der Defacer direkt rohe DOM-Aufrufe schreibt. Bewusst PRAEZISE (echte
     * JS-/DOM-Aufrufe), damit URLs/Farben/Config-Werte KEINE Treffer erzeugen.
     */
    private static function rawJsPatterns()
    {
        $p = self::sigParts();

        return array(
            '~document\s*\.\s*(?:title|body|' . $p['ck'] . '|' . $p['wr'] . '|' . $p['wr'] . 'ln|' . $p['loc'] . '|createElement|getElementById|getElementsBy|querySelector|' . $p['ael'] . '|documentElement|forms|images|referrer|domain)~i',
            '~window\s*\.\s*(?:' . $p['loc'] . '|open|onload|document|top|name|' . $p['ev'] . '|' . $p['ab'] . ')~i',
            '~\b' . $p['ev'] . '\s*\(~i',
            '~\b' . $p['ab'] . '\s*\(~i',
            '~String\.' . $p['fcc'] . '~i',
            '~\b' . $p['un'] . '\s*\(~i',
            '~new\s+' . $p['fn'] . '\s*\(~i',
            '~\.\s*' . $p['ih'] . '\s*=~i',
            '~\.\s*' . $p['ael'] . '\s*\(~i',
        );
    }

    /** Enthaelt der Wert JavaScript (Markup ODER roh)? */
    private static function containsJs($s)
    {
        return self::matchesAny($s, self::markupJsPatterns()) || self::matchesAny($s, self::rawJsPatterns());
    }

    /** Enthaelt der Wert (noch) rohen JS-Code? (Reinigung: Rest komplett leeren) */
    private static function hasRawJs($s)
    {
        return self::matchesAny($s, self::rawJsPatterns());
    }

    /**
     * Ein externes Script-Tag (src=...) zu einer NICHT bekannten Analytics-/CDN-Domain gilt als
     * verdaechtig (typischer Payload-Loader). Bekannte legitime Dienste (gtag, Facebook,
     * Matomo, jsDelivr, cdnjs, reCAPTCHA ...) loesen KEINEN Alarm aus. Relative src
     * (eigene Domain) werten wir hier nicht als extern.
     */
    /** Allowlist bekannter, legitimer externer Dienste (Analytics/CDN/Consent/Video/Zahlung ...). */
    private static function externalScriptAllow()
    {
        return 'googletagmanager\.com|google-analytics\.com|googlesyndication\.com|googleadservices\.com'
            . '|gstatic\.com|\.google\.com|facebook\.net|connect\.facebook|hotjar\.com|clarity\.ms|matomo|piwik'
            . '|cloudflare\.com|jsdelivr\.net|cdnjs\.cloudflare|bootstrapcdn|jquery\.com|unpkg\.com|recaptcha'
            . '|youtube\.com|youtube-nocookie|vimeo\.com|paypal\.com|stripe\.com|cookiebot|usercentrics|consentmanager';
    }

    private static function suspiciousExternalScript($s)
    {
        return self::externalScriptHit($s) !== null;
    }

    /**
     * Wie suspiciousExternalScript, liefert aber DETAILS des ersten verdaechtigen externen Script-
     * Loaders: Host + Byte-Offset/Laenge (fuer die hervorgehobene Anzeige). Rein. null = keiner.
     *
     * @return array{host:string,off:int,len:int}|null
     */
    private static function externalScriptHit($s)
    {
        $p = self::sigParts();

        if (!preg_match_all('~<' . $p['sc'] . '\b[^>]*\bsrc\s*=\s*["\']?([^"\'>\s]+)~i', (string) $s, $m, PREG_OFFSET_CAPTURE)) {
            return null;
        }

        $allow = self::externalScriptAllow();

        foreach ($m[1] as $i => $src) {
            $val  = (string) $src[0];
            $ref  = (strpos($val, '//') === 0) ? 'http:' . $val : $val;
            $host = strtolower((string) @parse_url($ref, PHP_URL_HOST));

            if ($host === '') {
                continue;                        // relatives src (eigene Domain) -> nicht extern
            }

            if (!preg_match('~(?:' . $allow . ')~i', $host)) {
                return array(
                    'host' => $host,
                    'off'  => (int) $m[0][$i][1],
                    'len'  => strlen((string) $m[0][$i][0]),
                );
            }
        }

        return null;
    }

    /**
     * Bekannte Kampagnen-IOCs (Helix-Ultimate-Mega-Menue-XSS, JoomShaper-Scanner-
     * Abgleich): Exfil-Domain xss.report, Injektions-Marker _hu_inject/_huinject,
     * sessionStorage._hxd-Persistenz. Reine Angreifer-Kennungen -> in keinem legit
     * Menue-/Template-Feld -> hochkonfident, FP-frei. Muster fragmentiert, damit
     * DIESE Datei nicht selbst als Treffer gilt (die Marker sind auch Feed-Signaturen).
     */
    private static function campaignIocPatterns()
    {
        return array(
            '~x' . 'ss\.report~i',
            '~_hu_?in' . 'ject~i',
            '~sessionStorage\s*\.\s*_h' . 'xd~i',
        );
    }

    private static function campaignIoc($s)
    {
        return self::matchesAny($s, self::campaignIocPatterns());
    }

    /** Hochkonfident boesartig? Tier-1-Marker, externer Loader ODER Kampagnen-IOC. */
    private static function isTier1($s)
    {
        return self::matchesAny($s, self::tier1Patterns())
            || self::suspiciousExternalScript($s)
            || self::campaignIoc($s);
    }

    /**
     * Defacement-Parolen (Text): typische Verunstaltungs-Signaturen. In Template-Code-/
     * Text-Feldern praktisch nie legitim -> hochkonfident, loest den Alarm auch OHNE JS
     * aus (z. B. eine reine Verunstaltungs-Ueberschrift im Klartext). BEWUSST ENG (feste
     * Phrase "... by") gefasst, damit normale Woerter wie "Lifehack" oder "hackathon"
     * KEINEN Treffer erzeugen (kein neuer Fehlalarm). Muster leet-tolerant.
     */
    private static function defacementTauntPatterns()
    {
        $p = self::sigParts();

        return array(
            '~h[a4]' . 'ck[e3]d\s+by\b~i',
            '~' . $p['df'] . '\s+by\b~i',
            '~pw' . 'n[e3]d\s+by\b~i',
            '~\bgr[e3]{2}t[sz]\b~i',
        );
    }

    private static function defacementTaunt($s)
    {
        return self::matchesAny($s, self::defacementTauntPatterns());
    }

    private static function matchesAny($s, $patterns)
    {
        foreach ($patterns as $p) {
            if (@preg_match($p, (string) $s) === 1) {
                return true;
            }
        }

        return false;
    }

    private static function lastKey($path)
    {
        return strpos($path, '.') !== false ? substr(strrchr($path, '.'), 1) : $path;
    }

    private static function keyIsCodeField($path)
    {
        $key = strtolower(self::lastKey($path));

        foreach (self::codeFieldKeys() as $needle) {
            if (strpos($key, $needle) !== false) {
                return true;
            }
        }

        return false;
    }

    private static function snippet($s)
    {
        $s = trim(preg_replace('~\s+~', ' ', (string) $s));

        if (function_exists('mb_substr')) {
            return mb_strlen($s) > self::SNIPPET ? mb_substr($s, 0, self::SNIPPET) . ' …' : $s;
        }

        return strlen($s) > self::SNIPPET ? substr($s, 0, self::SNIPPET) . ' …' : $s;
    }

    /** Offset+Laenge (Bytes) des ERSTEN Treffers eines Musters, oder null. Rein. */
    private static function firstMatch($re, $s)
    {
        if (@preg_match($re, (string) $s, $m, PREG_OFFSET_CAPTURE) === 1 && isset($m[0])) {
            return array((int) $m[0][1], strlen((string) $m[0][0]));
        }

        return null;
    }

    /**
     * (2.6.2) Beschreibt einen Fund fuer die Anzeige: WELCHER Marker (laienverstaendlicher Sprach-
     * Key + optionaler Zusatz wie Host) und WO (Byte-Offset/Laenge der Trefferstelle). Reine
     * Anzeige-Hilfe - aendert die ERKENNUNG nicht. Reihenfolge = Aussagekraft.
     *
     * @return array{key:string,arg:string,off:int,len:int}
     */
    private static function describeMarker($s, $suspicious)
    {
        if ($suspicious) {
            foreach (self::campaignIocPatterns() as $re) {
                if (($m = self::firstMatch($re, $s)) !== null) {
                    return array('key' => 'COM_HTPROTECT_HELIX_MK_IOC', 'arg' => '', 'off' => $m[0], 'len' => $m[1]);
                }
            }

            foreach (self::defacementTauntPatterns() as $re) {
                if (($m = self::firstMatch($re, $s)) !== null) {
                    return array('key' => 'COM_HTPROTECT_HELIX_MK_TAUNT', 'arg' => '', 'off' => $m[0], 'len' => $m[1]);
                }
            }

            foreach (self::tier1Markers() as $mk) {
                if (($m = self::firstMatch($mk[0], $s)) !== null) {
                    return array('key' => $mk[1], 'arg' => '', 'off' => $m[0], 'len' => $m[1]);
                }
            }

            $ext = self::externalScriptHit($s);

            if ($ext !== null) {
                return array('key' => 'COM_HTPROTECT_HELIX_MK_EXTSCRIPT', 'arg' => (string) $ext['host'], 'off' => (int) $ext['off'], 'len' => (int) $ext['len']);
            }
        }

        // Nur-Code-Feld-JS ohne Tier-1: Markup-/Roh-JS lokalisieren (Report, kein Alarm).
        foreach (array_merge(self::markupJsPatterns(), self::rawJsPatterns()) as $re) {
            if (($m = self::firstMatch($re, $s)) !== null) {
                return array('key' => 'COM_HTPROTECT_HELIX_MK_JS', 'arg' => '', 'off' => $m[0], 'len' => $m[1]);
            }
        }

        return array('key' => '', 'arg' => '', 'off' => 0, 'len' => 0);
    }

    /**
     * (2.6.2) Auszug UM die Trefferstelle: Kontext links + Treffer + Kontext rechts (Multibyte-
     * sicher). Ohne bekannte Position (len<=0) faellt es auf das bisherige Anfang-Snippet zurueck.
     *
     * @return array{before:string,hit:string,after:string,ell_b:bool,ell_a:bool}
     */
    private static function snippetAround($s, $off, $len)
    {
        $s    = (string) $s;
        $norm = function ($x) { return trim(preg_replace('~\s+~', ' ', (string) $x)); };

        if ($len <= 0) {
            return array('before' => '', 'hit' => '', 'after' => self::snippet($s), 'ell_b' => false, 'ell_a' => false);
        }

        $ctx = self::SNIPPET_CTX;

        if (function_exists('mb_substr') && function_exists('mb_strlen')) {
            $preLen = mb_strlen(substr($s, 0, $off), 'UTF-8');
            $hitLen = mb_strlen(substr($s, $off, $len), 'UTF-8');
            $total  = mb_strlen($s, 'UTF-8');
            $start  = max(0, $preLen - $ctx);

            return array(
                'before' => $norm(mb_substr($s, $start, $preLen - $start, 'UTF-8')),
                'hit'    => $norm(mb_substr($s, $preLen, $hitLen, 'UTF-8')),
                'after'  => $norm(mb_substr($s, $preLen + $hitLen, $ctx, 'UTF-8')),
                'ell_b'  => $start > 0,
                'ell_a'  => ($preLen + $hitLen + $ctx) < $total,
            );
        }

        // Fallback ohne mbstring: byte-basiert, ungueltige UTF-8-Kanten wegputzen.
        $total = strlen($s);
        $start = max(0, $off - $ctx);
        $clean = function ($x) {
            $x = preg_replace('~^[\x80-\xBF]+~', '', (string) $x);
            $x = preg_replace('~[\xC0-\xFF][\x80-\xBF]*$~', '', (string) $x);

            return $x;
        };

        return array(
            'before' => $norm($clean(substr($s, $start, $off - $start))),
            'hit'    => $norm(substr($s, $off, $len)),
            'after'  => $norm($clean(substr($s, $off + $len, $ctx))),
            'ell_b'  => $start > 0,
            'ell_a'  => ($off + $len + $ctx) < $total,
        );
    }

    /**
     * Ist der Template-Stil Helix-basiert? Name (helix/shaper) ODER Helix-typische
     * Custom-Code-Keys in params (NICHT hart auf "flex" o. ae. fixiert).
     *
     * @param   string  $template   Template-Element (#__template_styles.template)
     * @param   array   $paramsArr  Dekodierte params
     *
     * @return boolean
     */
    public static function isHelixStyle($template, $paramsArr)
    {
        $t = strtolower((string) $template);

        if (strpos($t, 'helix') !== false || strpos($t, 'shaper') !== false) {
            return true;
        }

        if (is_array($paramsArr)) {
            foreach (array('before_head', 'beforehead', 'after_body', 'afterbody', 'body_end', 'bodyend') as $k) {
                if (array_key_exists($k, $paramsArr)) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * params (Array ODER JSON-String) rekursiv nach JS-tragenden Feldern durchsuchen.
     *
     * @return array Funde: [ ['path','field','snippet','suspicious'], ... ]
     */
    public static function findInParams($params)
    {
        $arr = is_array($params) ? $params : json_decode((string) $params, true);

        if (!is_array($arr)) {
            return array();
        }

        // Hartes Backtracking-Limit: ein boesartig konstruierter Riesen-Wert kann die
        // Regexes so nie aufhaengen (preg_match -> false -> kein Treffer statt Hang).
        $old = @ini_get('pcre.backtrack_limit');
        @ini_set('pcre.backtrack_limit', '200000');

        $out = array();
        self::walkFind($arr, '', $out);

        if ($old !== false) {
            @ini_set('pcre.backtrack_limit', $old);
        }

        return $out;
    }

    /**
     * (2.6.2) Ist $node selbst ein serialisierter JSON-Container (Objekt/Array) in vertretbarer
     * Groesse? Dann dekodiert zurueckgeben, sonst null. Rein - Grundlage fuer den Blatt-Walk in
     * walkFind (Erkennung) UND walkClean (Reinigung), damit beide identisch tief gehen.
     *
     * @return array|null
     */
    private static function innerJson($node)
    {
        if (!is_string($node) || $node === '' || strlen($node) > self::INNER_JSON_MAX) {
            return null;
        }

        $lt = ltrim($node);

        if ($lt === '' || ($lt[0] !== '{' && $lt[0] !== '[')) {
            return null;
        }

        $dec = json_decode($node, true);

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

    private static function walkFind($node, $path, &$out)
    {
        if (is_array($node)) {
            foreach ($node as $k => $v) {
                self::walkFind($v, $path === '' ? (string) $k : $path . '.' . $k, $out);
            }

            return;
        }

        if (!is_string($node) || $node === '') {
            return;
        }

        // (B, 2.6.2) Builder-Container (YOOtheme & Co.) legen ihre GANZE Konfiguration als EINEN
        // serialisierten JSON-String ab -> dekodieren und die BLAETTER einzeln pruefen. Praeziser
        // Pfad + keine Treffer auf Schluesselnamen/Fragmentgrenzen im Rohblob (weniger Fehlalarme).
        // FN-sicher: echtes Script bleibt ein Blatt. Nicht-JSON/zu gross -> normal als String pruefen.
        if (($inner = self::innerJson($node)) !== null) {
            self::walkFind($inner, $path, $out);

            return;
        }

        // Defacement-Parole ODER ein Kampagnen-IOC (xss.report/_hu_inject/_hxd) zaehlt
        // auch OHNE umgebendes JS (reiner Marker/Verunstaltungs-Ueberschrift) - so wird
        // der Angreifer-Marker selbst dann erkannt, wenn er nicht in einem JS-Aufruf steht.
        $taunt = self::defacementTaunt($node) || self::campaignIoc($node);

        if (!$taunt && !self::containsJs($node)) {
            return;
        }

        $suspicious = $taunt || self::isTier1($node);

        // Ein blosses Script-Tag zaehlt nur in echten Code-Feldern (dort ist es der
        // Injektionsort); Tier-1/Defacement zaehlt ueberall. So loest legit Analytics NUR
        // im Report auf, nicht als Alarm, und ein zufaelliges JS-aehnliches Wort ausserhalb nicht.
        if (!$suspicious && !self::keyIsCodeField($path)) {
            return;
        }

        // (A, 2.6.2) Anzeige-Zusatz: welcher Marker + Auszug UM die Trefferstelle (hervorgehoben).
        $mk    = self::describeMarker($node, $suspicious);
        $round = self::snippetAround($node, $mk['off'], $mk['len']);

        $out[] = array(
            'path'       => $path,
            'field'      => self::lastKey($path),
            'snippet'    => self::snippet($node),
            'marker_key' => $mk['key'],
            'marker_arg' => $mk['arg'],
            'hit_before' => $round['before'],
            'hit'        => $round['hit'],
            'hit_after'  => $round['after'],
            'hit_ell_b'  => $round['ell_b'],
            'hit_ell_a'  => $round['ell_a'],
            'suspicious' => $suspicious,
            'vhash'      => sha1($node), // volle Inhalts-Signatur (fuer die Ignorieren-Allowlist)
        );
    }

    /**
     * Stabile Inhalts-Signatur der SUSPICIOUS-Funde eines Stils (rein). Grundlage fuer
     * die "Ignorieren"-Allowlist: markiert der Admin ein eigenes Skript als unbedenklich,
     * wird diese Signatur gemerkt. AENDERT sich der Inhalt spaeter (echte Injektion),
     * aendert sich die Signatur -> der Alarm kommt automatisch wieder. Order-unabhaengig.
     *
     * @param   array  $finds  Funde aus findInParams
     *
     * @return  string  sha1 oder '' (kein Tier-1-Fund)
     */
    public static function styleSig($finds)
    {
        $parts = array();

        foreach ((array) $finds as $f) {
            if (empty($f['suspicious'])) {
                continue;
            }

            $parts[] = (isset($f['path']) ? (string) $f['path'] : '') . "\0"
                . (isset($f['vhash']) ? (string) $f['vhash'] : sha1(isset($f['snippet']) ? (string) $f['snippet'] : ''));
        }

        if (empty($parts)) {
            return '';
        }

        sort($parts);

        return sha1(implode("\x1e", $parts));
    }

    /**
     * Ist dieser Stil (id + AKTUELLE Signatur) vom Admin als unbedenklich markiert?
     * Nur wenn die gespeicherte Signatur exakt dem aktuellen Inhalt entspricht (rein).
     *
     * @param   array  $style   Stil-Datensatz aus scan() (braucht 'id','sig')
     * @param   array  $ignore  Allowlist-Map  id(string) => sig
     *
     * @return  boolean
     */
    public static function isIgnored($style, $ignore)
    {
        if (!is_array($ignore) || empty($style['sig'])) {
            return false;
        }

        $id = isset($style['id']) ? (string) (int) $style['id'] : '';

        return isset($ignore[$id]) && is_string($ignore[$id]) && hash_equals((string) $ignore[$id], (string) $style['sig']);
    }

    /**
     * Script/JS-Handler aus einem String schneiden, Rest (CSS/Links/Text) bleibt.
     *
     * @return string
     */
    public static function stripJs($value)
    {
        $v = (string) $value;
        $p = self::sigParts();

        $steps = array(
            '~<' . $p['sc'] . '\b[^>]*>.*?</' . $p['sc'] . '\s*>~is',               // ganze Script-Bloecke
            '~</?' . $p['sc'] . '\b[^>]*>~i',                                       // uebrige Script-Tags
            '~\s*\bon[a-z]{3,}\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)~i',             // Inline-Event-Handler
            '~' . $p['js'] . ':~i',                                                 // js-Schema-URIs
        );

        foreach ($steps as $re) {
            $res = preg_replace($re, '', $v);

            if (is_string($res)) {          // null (z. B. Backtracking-Limit) -> Schritt ueberspringen
                $v = $res;
            }
        }

        return $v;
    }

    /**
     * params bereinigen. Gibt IMMER gueltiges JSON zurueck; bei nicht dekodierbarem
     * params oder fehlschlagendem Re-Encode bleibt es UNVERAENDERT (nie kaputt schreiben).
     *
     * @param   string   $paramsJson  Original-params (JSON)
     * @param   boolean  $hard        true = betroffenes Feld komplett leeren
     *
     * @return array ['json','changed','fields','error']
     */
    public static function cleanParams($paramsJson, $hard = false)
    {
        $arr = json_decode((string) $paramsJson, true);

        if (!is_array($arr)) {
            return array('json' => (string) $paramsJson, 'changed' => false, 'fields' => array(), 'error' => 'params ist kein gueltiges JSON');
        }

        $old = @ini_get('pcre.backtrack_limit');
        @ini_set('pcre.backtrack_limit', '200000');

        $fields = array();
        self::walkClean($arr, '', (bool) $hard, $fields);

        if ($old !== false) {
            @ini_set('pcre.backtrack_limit', $old);
        }

        if (empty($fields)) {
            return array('json' => (string) $paramsJson, 'changed' => false, 'fields' => array(), 'error' => '');
        }

        $json = json_encode($arr);

        // Validitaets-Gate: das Ergebnis MUSS wieder gueltiges JSON sein, sonst gaebe
        // ein kaputtes params einen Template-Fatal (500). Im Zweifel nichts aendern.
        if (!is_string($json) || $json === '') {
            return array('json' => (string) $paramsJson, 'changed' => false, 'fields' => array(), 'error' => 'Re-Encode fehlgeschlagen');
        }

        json_decode($json);

        if (json_last_error() !== JSON_ERROR_NONE) {
            return array('json' => (string) $paramsJson, 'changed' => false, 'fields' => array(), 'error' => 'Ergebnis waere kein gueltiges JSON');
        }

        return array('json' => $json, 'changed' => true, 'fields' => $fields, 'error' => '');
    }

    private static function walkClean(&$node, $path, $hard, &$fields)
    {
        if (is_array($node)) {
            foreach ($node as $k => $v) {
                self::walkClean($node[$k], $path === '' ? (string) $k : $path . '.' . $k, $hard, $fields);
            }

            return;
        }

        if (!is_string($node) || $node === '') {
            return;
        }

        // (B, 2.6.2) Symmetrisch zu walkFind: ist der Wert selbst serialisierter JSON, dekodieren,
        // die BLAETTER reinigen und - NUR bei gueltigem Re-Encode - zurueckschreiben (chirurgisch,
        // kein Regex-Strippen am rohen serialisierten Blob, das die JSON-Struktur zerschneiden
        // koennte). Schlaegt das Re-Encode fehl (z. B. ungueltiges UTF-8), faellt es unten aufs
        // bisherige Roh-Strippen zurueck.
        if (($inner = self::innerJson($node)) !== null) {
            $sub = array();
            self::walkClean($inner, $path, $hard, $sub);

            if (empty($sub)) {
                return; // im inneren JSON nichts zu reinigen (symmetrisch zu walkFind)
            }

            $re = json_encode($inner);

            if (is_string($re) && $re !== '') {
                json_decode($re);

                if (json_last_error() === JSON_ERROR_NONE) {
                    $node = $re;

                    foreach ($sub as $sf) {
                        $fields[] = $sf;
                    }

                    return;
                }
            }
            // Re-Encode fehlgeschlagen -> unten roh strippen (Fallback wie bisher).
        }

        $taunt = self::defacementTaunt($node);

        if (!$taunt && !self::containsJs($node)) {
            return;
        }

        if (!$taunt && !self::isTier1($node) && !self::keyIsCodeField($path)) {
            return;
        }

        $before = $node;
        $node    = $hard ? '' : self::stripJs($node);

        // Rohes JS (z. B. custom_js OHNE Script-Tag) laesst sich nicht teil-strippen ->
        // bleibt nach dem Strip noch JS-Code uebrig, das Feld komplett leeren.
        if ($node !== '' && self::hasRawJs($node)) {
            $node = '';
        }

        if ($node !== $before) {
            $fields[] = $path;
        }
    }

    // ────────────────────────────────────────────────────────────────────────────
    //  DB-Schicht (braucht htp_db). Read-only Scan + reversibler Wipe.
    // ────────────────────────────────────────────────────────────────────────────

    /**
     * Alle Helix-basierten Frontend-Template-Stile scannen. Read-only.
     *
     * @return array Betroffene Stile: [ ['id','template','title','fields','suspicious'], ... ]
     */
    public static function scan()
    {
        $db = htp_db();

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

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName(array('id', 'template', 'title', 'params')))
                ->from($db->quoteName('#__template_styles'))
                ->where($db->quoteName('client_id') . ' = 0');
            $db->setQuery($query);
            $rows = $db->loadAssocList();
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }

        if (!is_array($rows)) {
            return array();
        }

        $out = array();

        foreach ($rows as $row) {
            $arr = json_decode(isset($row['params']) ? (string) $row['params'] : '', true);

            // ALLE Frontend-Stile pruefen: die Injektion landet auch in Nicht-Helix-Stilen
            // (Kern-Templates + vom Angreifer erzeugte Junk-Stile). Der ALARM bleibt aber
            // Tier-1-gated (suspiciousStyles) -> ein legit Custom-JS loest keinen Fehlalarm.
            if (!is_array($arr)) {
                continue;
            }

            $finds = self::findInParams($arr);

            if (empty($finds)) {
                continue;
            }

            $suspicious = false;

            foreach ($finds as $f) {
                if (!empty($f['suspicious'])) {
                    $suspicious = true;
                    break;
                }
            }

            $out[] = array(
                'id'         => isset($row['id']) ? (int) $row['id'] : 0,
                'template'   => isset($row['template']) ? (string) $row['template'] : '',
                'title'      => isset($row['title']) ? (string) $row['title'] : '',
                'fields'     => $finds,
                'suspicious' => $suspicious,
                'sig'        => self::styleSig($finds), // fuer die Ignorieren-Allowlist
            );
        }

        return $out;
    }

    /**
     * Nur die Stile mit hochkonfidentem (Tier-1) Fund - Grundlage fuer den Uebersicht-
     * Alarm (kein Fehlalarm auf blosses/legitimes Analytics-JS).
     *
     * @return array
     */
    public static function suspiciousStyles($ignore = array())
    {
        $ignore = is_array($ignore) ? $ignore : array();
        $out    = array();

        foreach (self::scan() as $s) {
            if (empty($s['suspicious'])) {
                continue;
            }

            // Vom Admin als eigenes/unbedenkliches Skript markiert (Inhalt unveraendert)
            // -> kein Alarm. Aendert sich der Inhalt, greift die Ignorierung nicht mehr.
            if (self::isIgnored($s, $ignore)) {
                continue;
            }

            $out[] = $s;
        }

        return $out;
    }

    /**
     * Ausgewaehlte Stile bereinigen: je Stil ZUERST params sichern (reversibel), dann
     * JS entfernen und als gueltiges JSON zurueckschreiben. Jeder Fund wird geloggt.
     *
     * @param   array    $ids   Template-Stil-IDs (Integer)
     * @param   boolean  $hard  true = Custom-Code-Feld komplett leeren
     *
     * @return array ['cleaned','failed','skipped','backups','details']
     */
    public static function wipe($ids, $hard = false)
    {
        $res = array('cleaned' => 0, 'failed' => 0, 'skipped' => 0, 'backups' => array(), 'details' => array());

        $ids = array_values(array_unique(array_filter(array_map('intval', (array) $ids))));

        if (empty($ids)) {
            return $res;
        }

        $db = htp_db();

        if (!$db) {
            $res['failed'] = count($ids);

            return $res;
        }

        foreach ($ids as $id) {
            try {
                $q = $db->getQuery(true)
                    ->select($db->quoteName(array('id', 'template', 'title', 'params')))
                    ->from($db->quoteName('#__template_styles'))
                    ->where($db->quoteName('id') . ' = ' . (int) $id)
                    ->where($db->quoteName('client_id') . ' = 0');
                $db->setQuery($q);
                $row = $db->loadAssoc();
            } catch (Exception $e) {
                $res['failed']++;
                continue;
            } catch (Throwable $e) {
                $res['failed']++;
                continue;
            }

            if (!is_array($row) || !isset($row['params'])) {
                $res['skipped']++;
                continue;
            }

            $clean = self::cleanParams((string) $row['params'], $hard);

            if (empty($clean['changed'])) {
                // Nichts zu tun (oder nicht sicher schreibbar) -> nicht anfassen.
                $res['skipped']++;
                continue;
            }

            // 1) ZUERST reversibel sichern.
            $backup = self::backupParams(
                (int) $id,
                isset($row['template']) ? (string) $row['template'] : '',
                isset($row['title']) ? (string) $row['title'] : '',
                (string) $row['params']
            );

            if ($backup === '') {
                // Ohne Backup NICHT schreiben (Reversibilitaet garantieren).
                $res['failed']++;
                self::log('WIPE ABGEBROCHEN (Backup fehlgeschlagen) style#' . (int) $id);
                continue;
            }

            // 2) Bereinigtes, validiertes JSON zurueckschreiben.
            try {
                $upd = $db->getQuery(true)
                    ->update($db->quoteName('#__template_styles'))
                    ->set($db->quoteName('params') . ' = ' . $db->quote($clean['json']))
                    ->where($db->quoteName('id') . ' = ' . (int) $id);
                $db->setQuery($upd);

                if (method_exists($db, 'execute')) {
                    $db->execute();
                } else {
                    $db->query();
                }
            } catch (Exception $e) {
                $res['failed']++;
                self::log('WIPE DB-Fehler style#' . (int) $id . ' (Backup: ' . $backup . ')');
                continue;
            } catch (Throwable $e) {
                $res['failed']++;
                continue;
            }

            $res['cleaned']++;
            $res['backups'][] = $backup;
            $res['details'][] = array('id' => (int) $id, 'fields' => $clean['fields'], 'backup' => $backup);

            self::log('WIPE style#' . (int) $id . ' (' . (isset($row['template']) ? $row['template'] : '') . ') '
                . ($hard ? 'HARD' : 'strip') . ' Felder: ' . implode(', ', $clean['fields']) . ' -> Backup ' . $backup);
        }

        return $res;
    }

    // ────────────────────────────────────────────────────────────────────────────
    //  #__menu (Helix-Ultimate-Mega-Menue): gleiche Erkennung/Bereinigung, andere Tabelle.
    // ────────────────────────────────────────────────────────────────────────────

    /**
     * Frontend-Menue-Eintraege (#__menu, client_id=0) auf injizierte XSS/JS in der
     * params-Spalte pruefen (Helix-Ultimate-Mega-Menue-Angriff). Read-only, exakt die
     * gleiche Tier-1-gated Erkennung wie bei den Template-Stilen -> kein Fehlalarm auf
     * legitime Menue-Konfiguration (nur hochkonfidente Marker/Kampagnen-IOCs zaehlen).
     *
     * @return array [ ['id','menutype','title','fields','suspicious','sig'], ... ]
     */
    public static function scanMenu()
    {
        $db = htp_db();

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

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName(array('id', 'menutype', 'title', 'params')))
                ->from($db->quoteName('#__menu'))
                ->where($db->quoteName('client_id') . ' = 0')
                ->where($db->quoteName('id') . ' > 1');   // Wurzel (id=1) traegt keine params
            $db->setQuery($query);
            $rows = $db->loadAssocList();
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }

        if (!is_array($rows)) {
            return array();
        }

        $out = array();

        foreach ($rows as $row) {
            $arr = json_decode(isset($row['params']) ? (string) $row['params'] : '', true);

            if (!is_array($arr)) {
                continue;
            }

            $finds = self::findInParams($arr);

            if (empty($finds)) {
                continue;
            }

            $suspicious = false;

            foreach ($finds as $f) {
                if (!empty($f['suspicious'])) {
                    $suspicious = true;
                    break;
                }
            }

            $out[] = array(
                'id'         => isset($row['id']) ? (int) $row['id'] : 0,
                'menutype'   => isset($row['menutype']) ? (string) $row['menutype'] : '',
                'title'      => isset($row['title']) ? (string) $row['title'] : '',
                'fields'     => $finds,
                'suspicious' => $suspicious,
                'sig'        => self::styleSig($finds),
            );
        }

        return $out;
    }

    /**
     * Nur Menue-Eintraege mit hochkonfidentem (Tier-1) Fund - Grundlage fuer den
     * Uebersicht-Alarm (kein Fehlalarm auf legit Menue-JS/-Config).
     *
     * @return array
     */
    public static function suspiciousMenu($ignore = array())
    {
        $ignore = is_array($ignore) ? $ignore : array();
        $out    = array();

        foreach (self::scanMenu() as $s) {
            if (empty($s['suspicious'])) {
                continue;
            }

            if (self::isIgnored($s, $ignore)) {
                continue;
            }

            $out[] = $s;
        }

        return $out;
    }

    /**
     * Ausgewaehlte Menue-Eintraege bereinigen: je Eintrag ZUERST params sichern
     * (reversibel, kind=helix_menu_item), dann JS entfernen und als gueltiges JSON
     * zurueckschreiben. Spiegelt wipe() fuer #__menu (kein Loeschen der Zeile -> das
     * Menue bleibt funktionsfaehig, nur der Schadcode faellt weg).
     *
     * @param   array    $ids   Menue-Eintrag-IDs (Integer)
     * @param   boolean  $hard  true = betroffenes Feld komplett leeren
     *
     * @return array ['cleaned','failed','skipped','backups','details']
     */
    public static function wipeMenu($ids, $hard = false)
    {
        $res = array('cleaned' => 0, 'failed' => 0, 'skipped' => 0, 'backups' => array(), 'details' => array());

        $ids = array_values(array_unique(array_filter(array_map('intval', (array) $ids))));

        if (empty($ids)) {
            return $res;
        }

        $db = htp_db();

        if (!$db) {
            $res['failed'] = count($ids);

            return $res;
        }

        foreach ($ids as $id) {
            try {
                $q = $db->getQuery(true)
                    ->select($db->quoteName(array('id', 'menutype', 'title', 'params')))
                    ->from($db->quoteName('#__menu'))
                    ->where($db->quoteName('id') . ' = ' . (int) $id)
                    ->where($db->quoteName('client_id') . ' = 0');
                $db->setQuery($q);
                $row = $db->loadAssoc();
            } catch (Exception $e) {
                $res['failed']++;
                continue;
            } catch (Throwable $e) {
                $res['failed']++;
                continue;
            }

            if (!is_array($row) || !isset($row['params'])) {
                $res['skipped']++;
                continue;
            }

            $clean = self::cleanParams((string) $row['params'], $hard);

            if (empty($clean['changed'])) {
                $res['skipped']++;
                continue;
            }

            // 1) ZUERST reversibel sichern (kind=helix_menu_item -> Restore in #__menu).
            $backup = self::backupParams(
                (int) $id,
                isset($row['menutype']) ? (string) $row['menutype'] : '',
                isset($row['title']) ? (string) $row['title'] : '',
                (string) $row['params'],
                'helix_menu_item'
            );

            if ($backup === '') {
                $res['failed']++;
                self::log('WIPE MENU ABGEBROCHEN (Backup fehlgeschlagen) item#' . (int) $id);
                continue;
            }

            // 2) Bereinigtes, validiertes JSON zurueckschreiben.
            try {
                $upd = $db->getQuery(true)
                    ->update($db->quoteName('#__menu'))
                    ->set($db->quoteName('params') . ' = ' . $db->quote($clean['json']))
                    ->where($db->quoteName('id') . ' = ' . (int) $id);
                $db->setQuery($upd);

                if (method_exists($db, 'execute')) {
                    $db->execute();
                } else {
                    $db->query();
                }
            } catch (Exception $e) {
                $res['failed']++;
                self::log('WIPE MENU DB-Fehler item#' . (int) $id . ' (Backup: ' . $backup . ')');
                continue;
            } catch (Throwable $e) {
                $res['failed']++;
                continue;
            }

            $res['cleaned']++;
            $res['backups'][] = $backup;
            $res['details'][] = array('id' => (int) $id, 'fields' => $clean['fields'], 'backup' => $backup);

            self::log('WIPE MENU item#' . (int) $id . ' ' . ($hard ? 'HARD' : 'strip')
                . ' Felder: ' . implode(', ', $clean['fields']) . ' -> Backup ' . $backup);
        }

        return $res;
    }

    // ────────────────────────────────────────────────────────────────────────────
    //  Reversibles Backup (com_htprotect/backups) + Restore.
    // ────────────────────────────────────────────────────────────────────────────

    private static function backupParams($id, $template, $title, $originalParams, $kind = 'helix_template_style')
    {
        if (!class_exists('HtpStorage')) {
            return '';
        }

        $dir  = HtpStorage::backupDir();
        $name = self::BACKUP_PREFIX . (int) $id . '-' . date('Ymd-His') . '.json';

        $payload = json_encode(array(
            'kind'     => (string) $kind,
            'id'       => (int) $id,
            'template' => (string) $template,
            'title'    => (string) $title,
            'ts'       => date('c'),
            'params'   => (string) $originalParams,
        ));

        if (!is_string($payload) || $payload === '') {
            return '';
        }

        if (@file_put_contents($dir . '/' . $name, $payload) === false) {
            return '';
        }

        self::pruneBackups();

        return $name;
    }

    /**
     * Alte Helix-Backups begrenzen (Retention): behaelt die neuesten KEEP, loescht
     * aeltere. Reversible Backups sind ein kurzfristiges "Rueckgaengig", kein Archiv.
     *
     * @param   int  $keep  Anzahl zu behaltender Backups
     *
     * @return void
     */
    private static function pruneBackups($keep = 30)
    {
        if (!class_exists('HtpStorage')) {
            return;
        }

        $dir = HtpStorage::backupDir();

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

        $files = @glob($dir . '/' . self::BACKUP_PREFIX . '*.json');

        if (!is_array($files) || count($files) <= $keep) {
            return;
        }

        usort($files, static function ($a, $b) {
            $ma = (int) @filemtime($a);
            $mb = (int) @filemtime($b);

            return $ma === $mb ? 0 : ($ma < $mb ? -1 : 1);
        });

        foreach (array_slice($files, 0, count($files) - $keep) as $f) {
            @chmod($f, 0600);
            @unlink($f);
        }
    }

    /**
     * Verfuegbare Helix-params-Backups (neueste zuerst).
     *
     * @return array [ ['name','id','template','title','ts'], ... ]
     */
    public static function listBackups()
    {
        if (!class_exists('HtpStorage')) {
            return array();
        }

        $out   = array();
        $files = @glob(HtpStorage::backupDir() . '/' . self::BACKUP_PREFIX . '*.json');

        foreach ((array) $files as $f) {
            $meta = json_decode((string) @file_get_contents($f), true);

            if (is_array($meta) && isset($meta['kind'])
                && in_array($meta['kind'], array('helix_template_style', 'helix_menu_item'), true)) {
                $out[] = array(
                    'name'     => basename($f),
                    'id'       => isset($meta['id']) ? (int) $meta['id'] : 0,
                    'kind'     => (string) $meta['kind'],
                    'template' => isset($meta['template']) ? (string) $meta['template'] : '',
                    'title'    => isset($meta['title']) ? (string) $meta['title'] : '',
                    'ts'       => isset($meta['ts']) ? (string) $meta['ts'] : '',
                );
            }
        }

        usort($out, function ($a, $b) {
            return strcmp($b['name'], $a['name']);
        });

        return $out;
    }

    /**
     * Ein params-Backup zurueckspielen (macht einen Wipe rueckgaengig).
     *
     * @param   string  $name  Backup-Dateiname
     *
     * @return boolean
     */
    public static function restoreParams($name)
    {
        if (!class_exists('HtpStorage') || !preg_match('~^' . preg_quote(self::BACKUP_PREFIX, '~') . '\d+-\d{8}-\d{6}\.json$~', (string) $name)) {
            return false;
        }

        $meta = json_decode((string) @file_get_contents(HtpStorage::backupDir() . '/' . $name), true);

        $kind  = (is_array($meta) && isset($meta['kind'])) ? (string) $meta['kind'] : '';
        $byKind = array('helix_template_style' => '#__template_styles', 'helix_menu_item' => '#__menu');

        if (!is_array($meta) || empty($meta['id']) || !isset($meta['params']) || !isset($byKind[$kind])) {
            return false;
        }

        $table = $byKind[$kind];

        // Sicherheit: params aus dem Backup MUSS gueltiges JSON sein.
        json_decode((string) $meta['params']);

        if (json_last_error() !== JSON_ERROR_NONE) {
            return false;
        }

        $db = htp_db();

        if (!$db) {
            return false;
        }

        try {
            $q = $db->getQuery(true)
                ->update($db->quoteName($table))
                ->set($db->quoteName('params') . ' = ' . $db->quote((string) $meta['params']))
                ->where($db->quoteName('id') . ' = ' . (int) $meta['id']);
            $db->setQuery($q);

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

            self::log('RESTORE ' . $kind . ' #' . (int) $meta['id'] . ' aus ' . $name);

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

    private static function log($message)
    {
        try {
            $dir = function_exists('htp_state_dir') ? htp_state_dir() : (function_exists('htp_tmp_path') ? htp_tmp_path() : sys_get_temp_dir());

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

            $file = $dir . '/htprotect_helix.log';

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

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

            @file_put_contents(
                $file,
                '[' . date('Y-m-d H:i:s') . '] ' . str_replace(array("\r", "\n"), ' ', (string) $message) . "\n",
                FILE_APPEND | LOCK_EX
            );
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still
        }
    }
}
