<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * HtpJoomShaperPatch - deployt JoomShapers EIGENE, offizielle Joomla-3-Security-
 * Pakete (Helix Ultimate, Helix3) auf Joomla-3-Sites, die der Joomla-Updater nicht
 * mehr erreicht (JoomShaper hat den J3-Support 2026-07-09 eingestellt, aber freie
 * standalone J3-Security-Pakete veroeffentlicht).
 *
 * Sicherheits-Design (bewusst "nur Vendor-Code, maximal reversibel, nie etwas brechen"):
 *  - Das Patch-ZIP wird NIE im Connector gebundelt, sondern ZUR LAUFZEIT von unserem
 *    Spiegel geladen und per GEPINNTEM SHA-256 verifiziert (== offizielle JoomShaper-
 *    SHA256SUMS). Der 2.7.1-Installer bleibt schlank.
 *  - Installiert wird ueber den NATIVEN Joomla-Installer (htp_install_from_dir), der die
 *    vendor-eigene preflight (verlangt Joomla 3.10.x, prueft Schreibrechte) und postflight
 *    (Audit-Log, self-uninstall) faehrt.
 *  - VOR der Installation ein reversibles, VOLLSTAENDIGES Verzeichnis-Backup (all-or-nothing
 *    mit .htp-complete-Marker; fail-closed: kein Backup -> kein Patch, Teil-Backup wird
 *    verworfen). Install + Marker-Verify laufen in try/catch(Throwable); fehlt danach der
 *    CODE-Marker -> ATOMARER Auto-Rollback (Kopie-neben-dran, dann Umschwenken; das Backup
 *    wird nur bei vollstaendig erfolgreicher Ruecksicherung verworfen).
 *  - "Schon gepatcht?" per CODE-Marker (nicht Version, weil der Helix-Ultimate-J3-Zweig auf
 *    2.1.4-j3sec < 2.2.10 bleibt). Fuer helixultimate MUESSEN BEIDE Marker sitzen (beweist
 *    das AKTUELLE Paket v1.0.2), fuer helix3 der Marker im tatsaechlich verwundbaren AJAX-
 *    Plugin (nicht die System-Plugin-Version).
 *  - Warn-Unterdrueckung (isPatched) ist an die konkrete Feed-'below'-Grenze GEBUNDEN
 *    (coversBelow): ein kuenftiger Feed-Bump ueber das vom J3-Paket abgedeckte Mass hinaus
 *    wird NICHT verschluckt (fail-open zur Warnung). Oberste Regel: eine WIRKLICHE Luecke
 *    darf NIE faelschlich als gepatcht unterdrueckt werden.
 *  - Der Auto-Ausloeser (autoTick) haengt am Sicherheits-Auto-Update-Master (autoupdate_ext);
 *    ein manuelles revert() setzt einen persistenten Opt-out, den der Auto-Ausloeser respektiert
 *    (kein Wieder-Verpatchen einer gerade reparierten Site).
 *  - Helix Ultimate: nach dem Patch (und generell nach jedem Helix-Update via execVerify)
 *    automatische, streng no-op-wenn-nichts-kaputt Reparatur zurueckgesetzter Template-Style-
 *    Params aus options.json + Helix-Cache leeren.
 *
 * SP Page Builder ist BEWUSST nicht dabei: es gibt kein offizielles JoomShaper-J3-Paket,
 * weil J3-SPPB (3.x) die verwundbare uploadCustomIcon-Funktion gar nicht hat (Hersteller-
 * Aussage). SPPB-Schutz laeuft ueber die WAF-Signaturen.
 *
 * Kern (Erkennung/Marker/Status) ist DB-frei + rein -> test/joomshaperpatchcheck.php.
 */

defined('_JEXEC') or die;

class HtpJoomShaperPatch
{
    /** Unser gespiegelter Paket-Host. Pakete werden ZUR LAUFZEIT geladen, nie gebundelt. */
    const PATCH_BASE_URL = 'https://update.wb-dev.de/patches/joomshaper/';

    /** Praefix der reversiblen Patch-Backups im Backup-Bereich. */
    const BACKUP_PREFIX = 'jspatch-';

    /** Kennzeichnet ein VOLLSTAENDIG angelegtes Backup (all-or-nothing). */
    const COMPLETE_MARKER = '.htp-complete';

    /** Das Helix-Ultimate-Site-Template, dessen Style-Params wir reparieren koennen. */
    const HELIX_TEMPLATE = 'shaper_helixultimate';

    /** Deckel des Retry-Backoffs je Erweiterung (dauerhafte Fehler warten hoechstens so lange). */
    const AUTO_INTERVAL = 21600; // 6 h

    /** Basis des exponentiellen Retry-Backoffs nach einem Fehlversuch (erster Retry). */
    const RETRY_BASE = 900; // 15 Min

    /**
     * Offizielle, gepinnte Vendor-Pakete. sha256 == offizielle JoomShaper-SHA256SUMS.
     *
     * - present:        Datei, deren Existenz die Erweiterung als installiert beweist.
     * - versionFile:    Manifest-XML fuer die ANZEIGE-Version (Status).
     * - markers:        Datei => Substring; ALLE muessen vorhanden sein, damit "gepatcht" gilt
     *                   (Code-Nachweis, nicht Version). Fuer helixultimate zwei Marker -> beweist
     *                   das aktuelle Paket v1.0.2; fuer helix3 der Marker im verwundbaren AJAX-Plugin.
     * - patchedMinVersion / patchedVersionFile: optionaler Versions-Fallback fuer den PASSIVEN
     *                   Status (nicht fuer den Install-Verify). Die Version wird aus dem Plugin
     *                   gelesen, das den Marker/die Luecke TRAEGT (helix3: AJAX-Plugin), nie aus
     *                   einem versionsverschobenen Nachbar-Plugin.
     * - coversBelow:    hoechste Feed-'below'-Grenze, die das GEPINNTE Paket abdeckt. Die
     *                   Warn-Unterdrueckung greift nur, solange die Luecken-'below' <= coversBelow
     *                   ist - ein spaeterer Feed-Bump darueber hinaus wird NICHT unterdrueckt.
     * - package/sha256: ZIP-Name unter PATCH_BASE_URL + gepinnter SHA-256.
     * - backupDirs:     Pfade (relativ zu JPATH_ROOT), die vor der Installation gesichert werden.
     * - restoreMenu:    nach dem Patch die Helix-Style-Params aus options.json reparieren.
     *
     * @return array
     */
    private static function descriptors()
    {
        return array(
            'helixultimate' => array(
                'label'             => 'Helix Ultimate',
                'present'           => 'plugins/system/helixultimate/helixultimate.php',
                'versionFile'       => 'plugins/system/helixultimate/helixultimate.xml',
                // BEIDE Marker noetig: guardAjaxRequest belegt den com_ajax-Dispatcher-Fix,
                // rawFields belegt die Input-Sanitisierung des AKTUELLEN Pakets (v1.0.1+). Nur
                // beide zusammen beweisen das gepinnte v1.0.2 -> ein alter J3-Patch (v1.0.0) gilt
                // als NICHT aktuell und wird auf v1.0.2 gehoben.
                'markers'           => array(
                    'plugins/system/helixultimate/src/Platform/Helper.php'  => 'guardAjaxRequest',
                    'plugins/system/helixultimate/src/Platform/Request.php' => 'rawFields',
                ),
                'patchedMinVersion' => null, // J3-Zweig bleibt 2.1.4-j3sec (< 2.2.10) -> nur Marker
                'patchedVersionFile' => null,
                // v1.0.2 backportet die J3-tauglichen 2.2.10-Fixes (die drei nicht J3-portierbaren
                // Rest-Punkte sind J3-EOL-inhaerent). Deckt daher die Feed-Grenze 2.2.10 ab; ein
                // spaeterer Bump (neues CVE) darueber wird NICHT unterdrueckt.
                'coversBelow'       => '2.2.10',
                'package'           => 'helixultimate_j3_security_fixes_v1.0.2.zip',
                'sha256'            => 'f57d9600c488b81ab14ccac36a37188d4cfc48935b1f1bb03ad33c9c5885b6fb',
                'backupDirs'        => array(
                    'plugins/system/helixultimate',
                    'templates/shaper_helixultimate',
                ),
                'restoreMenu'       => true,
            ),
            'helix3' => array(
                'label'             => 'Helix3',
                'present'           => 'plugins/system/helix3/helix3.xml',
                'versionFile'       => 'plugins/system/helix3/helix3.xml',
                // Marker + Versions-Fallback beziehen sich auf das AJAX-Plugin - dort sitzt die
                // Luecke (onAjaxHelix3) und der Fix. NICHT die System-Plugin-Version pruefen, die
                // bei einem Teil-Install (System ok, AJAX fehlgeschlagen) faelschlich "gepatcht" saehe.
                'markers'           => array(
                    'plugins/ajax/helix3/helix3.php' => 'requireAuthorisedAdminRequest',
                ),
                'patchedMinVersion' => '3.1.2', // CVE-2026-49049: 3.1.1 ist NOCH verwundbar, Fix erst 3.1.2
                'patchedVersionFile' => 'plugins/ajax/helix3/helix3.xml',
                'coversBelow'       => '3.1.2',
                'package'           => 'pkg_helix3_j3_security_patch.zip',
                'sha256'            => '7f97aae46b8928d6bf937fa6a207080580b88a8a599662b4cb2aa35d7145681f',
                'backupDirs'        => array(
                    'plugins/system/helix3',
                    'plugins/ajax/helix3',
                    'templates/shaper_helix3',
                ),
                'restoreMenu'       => false,
            ),
        );
    }

    // ------------------------------------------------------------------
    // Status / Erkennung  (DB-frei, rein -> test/joomshaperpatchcheck.php)
    // ------------------------------------------------------------------

    /**
     * Nur Joomla 3.10.x. Der Vendor-Preflight verlangt genau das; auf J4+ laeuft die
     * regulaere Update-Site, dort haben diese Pakete nichts verloren.
     *
     * @return bool
     */
    public static function isJoomla310()
    {
        return defined('JVERSION')
            && version_compare(JVERSION, '3.10', '>=')
            && version_compare(JVERSION, '4.0', '<');
    }

    /**
     * Per-Erweiterung-Status fuer Dashboard/Auto-Update.
     *
     * @return array keyed by 'helixultimate'|'helix3' => array(
     *   present, version, patched(bool), vulnerable(bool), state, label )
     *   state: absent | patched | vulnerable | vulnerable_no_j3
     */
    public static function getStatus()
    {
        $out = array();

        foreach (self::descriptors() as $key => $desc) {
            $out[$key] = self::statusOne($key, $desc);
        }

        return $out;
    }

    /**
     * @param string $key
     * @param array  $desc
     *
     * @return array
     */
    private static function statusOne($key, $desc)
    {
        $res = array(
            'label'      => $desc['label'],
            'present'    => false,
            'version'    => null,
            'patched'    => false,
            'vulnerable' => false,
            'state'      => 'absent',
        );

        if (!self::fileExists($desc['present'])) {
            return $res;
        }

        $res['present'] = true;
        $res['version'] = self::readManifestVersion($desc['versionFile']);
        $res['patched'] = self::isDescriptorPatched($desc);

        if ($res['patched']) {
            $res['state'] = 'patched';

            return $res;
        }

        // Vorhanden + nicht gepatcht -> verwundbar. Auf J4+ waere es Sache der Update-Site.
        $res['vulnerable'] = true;
        $res['state']      = self::isJoomla310() ? 'vulnerable' : 'vulnerable_no_j3';

        return $res;
    }

    /**
     * "Ist per JoomShaper-J3-Patch bereits gefixt UND deckt der Patch die konkrete Luecke ab?"
     * - Hook fuer die Warn-Unterdrueckung in HtpDefs::checkExtensions(). Bewusst NUR auf Joomla
     * 3.10 aktiv. GEBUNDEN an $below: unterdrueckt nur, wenn die betroffene Feed-'below'-Grenze
     * innerhalb dessen liegt, was das gepinnte Paket abdeckt (coversBelow) - sonst fail-open zur
     * Warnung. So verschluckt der Marker nie eine spaeter dazugekommene, hoehere Luecke.
     *
     * @param string $element  Erweiterungs-Element ('helixultimate' | 'helix3')
     * @param string $below    Feed-'below' der konkreten Luecke ('' = keine Bindung, nur Marker)
     *
     * @return bool
     */
    public static function isPatched($element, $below = '')
    {
        if (!self::isJoomla310()) {
            return false;
        }

        $desc = self::descriptorFor($element);

        if ($desc === null || !self::fileExists($desc['present'])) {
            return false;
        }

        if (!self::isDescriptorPatched($desc)) {
            return false;
        }

        // Bindung an die konkrete Luecke: nur unterdruecken, wenn das Paket diese 'below'-Grenze
        // wirklich abdeckt. Fehlt eine Grenze (Alt-Aufrufer), gilt wie bisher nur der Marker.
        $below = (string) $below;

        if ($below !== '' && !empty($desc['coversBelow'])
            && version_compare($below, (string) $desc['coversBelow'], '>')) {
            return false;
        }

        return true;
    }

    /**
     * @param string $element
     *
     * @return array|null
     */
    private static function descriptorFor($element)
    {
        $element = strtolower((string) $element);
        $descs   = self::descriptors();

        return isset($descs[$element]) ? $descs[$element] : null;
    }

    /**
     * Patched-Nachweis - auch fuer den strikten POST-INSTALL-Verify in deployOne genutzt:
     *  - patchedMinVersion gesetzt (helix3): VERSIONS-basiert (Version aus patchedVersionFile >=
     *    patchedMinVersion). BEWUSST kein Marker, weil der onAjaxHelix3-Marker
     *    (requireAuthorisedAdminRequest) schon in der NOCH verwundbaren 3.1.1 stehen kann
     *    (CVE-2026-49049: bis 3.1.1 betroffen, Fix erst 3.1.2) -> ein Marker saehe 3.1.1
     *    faelschlich als gepatcht, und ein Teil-Install (ajax bleibt 3.1.1) faellt so korrekt durch.
     *  - sonst (helixultimate): ALLE Code-Marker muessen sitzen (die Version bleibt 2.1.4-j3sec,
     *    taugt also nicht als Nachweis).
     *
     * @param array $desc
     *
     * @return bool
     */
    private static function isDescriptorPatched($desc)
    {
        if (!empty($desc['patchedMinVersion'])) {
            $vf  = !empty($desc['patchedVersionFile']) ? $desc['patchedVersionFile'] : $desc['versionFile'];
            $ver = self::readManifestVersion($vf);

            return $ver !== null && version_compare($ver, $desc['patchedMinVersion'], '>=');
        }

        return self::markersPresent($desc);
    }

    /**
     * ALLE Code-Marker vorhanden? (strikter Nachweis fuer den Post-Install-Verify + Kern der
     * passiven Erkennung). Ein einzelner fehlender Marker -> nicht gepatcht.
     *
     * @param array $desc
     *
     * @return bool
     */
    private static function markersPresent($desc)
    {
        if (empty($desc['markers']) || !is_array($desc['markers'])) {
            return false;
        }

        foreach ($desc['markers'] as $relative => $needle) {
            $full = self::fullPath($relative);

            if (!is_file($full)) {
                return false;
            }

            $contents = @file_get_contents($full);

            if ($contents === false || strpos($contents, $needle) === false) {
                return false;
            }
        }

        return true;
    }

    // ------------------------------------------------------------------
    // Anwenden (Backup -> Download+Verify -> Install -> Marker-Verify -> Reparatur)
    // ------------------------------------------------------------------

    /**
     * Wendet alle anwendbaren offiziellen J3-Patches an (oder nur den einen $only). Ein
     * ausdruecklicher apply()-Aufruf (Dashboard/Befehl) hebt einen frueheren revert()-Opt-out auf.
     *
     * @param string|null $only  optional 'helixultimate'|'helix3'
     *
     * @return array keyed by ext => result von deployOne()
     */
    public static function apply($only = null)
    {
        // Ausdrueckliches Anwenden = der Betreiber will patchen -> Opt-out zuruecknehmen.
        self::setOptOut($only, false);

        $out = array();

        foreach (self::descriptors() as $key => $desc) {
            if ($only !== null && $only !== $key) {
                continue;
            }

            $out[$key] = self::deployOne($key, $desc);
        }

        return $out;
    }

    /**
     * Backup -> Download+SHA-256 -> nativer Install -> Marker-Verify (sonst Rollback) ->
     * Helix-Menue-Reparatur. Jeder Fehler (auch Error/Typeable) laesst die Site unveraendert
     * oder rollt zurueck. Wird von apply() und autoTick() genutzt.
     *
     * @param string $key
     * @param array  $desc
     *
     * @return array array('ok'=>bool,'state'=>string,'code'=>string)
     */
    private static function deployOne($key, $desc)
    {
        if (!self::isJoomla310()) {
            return array('ok' => false, 'state' => 'skipped', 'code' => 'not_joomla_3_10');
        }

        $status = self::statusOne($key, $desc);

        if (!$status['present']) {
            return array('ok' => true, 'state' => 'absent', 'code' => 'not_installed');
        }

        if ($status['patched']) {
            return array('ok' => true, 'state' => 'patched', 'code' => 'already_patched');
        }

        // 1) Reversibles, VOLLSTAENDIGES Backup. Kein Backup -> NICHT patchen (fail-closed).
        if (!self::backupDirs($key, $desc['backupDirs'])) {
            return array('ok' => false, 'state' => 'vulnerable', 'code' => 'backup_failed');
        }

        // 2) Paket laden + gepinnten SHA-256 pruefen + entpacken. Fehler => Site unveraendert.
        $dir = self::downloadVerifyExtract($desc);

        if ($dir === false) {
            return array('ok' => false, 'state' => 'vulnerable', 'code' => 'download_or_verify_failed');
        }

        // 3+4) Nativer Installer + Marker-Verify - komplett in Throwable-Guard, damit ein Error
        // des Vendor-Scriptfiles (PHP 8 auf J3.10) NICHT das Auto-Rollback ueberspringt.
        try {
            htp_install_from_dir($dir);
            self::cleanupDir($dir);

            // STRIKT: der tatsaechliche Patched-Nachweis (helixultimate = beide Code-Marker;
            // helix3 = Fix-Version 3.1.2 aus dem verwundbaren ajax-Plugin - so faellt ein Teil-
            // Install, bei dem das ajax-Plugin auf 3.1.1 stehen bleibt, korrekt durch). Fehlt er
            // nach dem Install, war er unwirksam/teilweise -> atomarer Rollback.
            if (!self::isDescriptorPatched($desc)) {
                self::restoreBackup($key, $desc['backupDirs']);

                return array('ok' => false, 'state' => 'vulnerable', 'code' => 'patch_ineffective_rolledback');
            }
        } catch (Exception $e) {
            self::cleanupDir($dir);
            self::restoreBackup($key, $desc['backupDirs']);

            return array('ok' => false, 'state' => 'vulnerable', 'code' => 'install_error_rolledback');
        } catch (Throwable $e) {
            self::cleanupDir($dir);
            self::restoreBackup($key, $desc['backupDirs']);

            return array('ok' => false, 'state' => 'vulnerable', 'code' => 'install_fatal_rolledback');
        }

        // 5) Helix Ultimate: zurueckgesetzte Style-Params aus options.json reparieren.
        if (!empty($desc['restoreMenu'])) {
            self::restoreHelixMenu();
        }

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

    /**
     * Stellt die vor einem Patch gesicherten Verzeichnisse wieder her (manueller Revert) UND
     * setzt einen persistenten Opt-out, damit der Auto-Ausloeser die gerade reparierte Site
     * nicht binnen Stunden wieder verpatcht. Ein spaeteres apply() (Dashboard) hebt den Opt-out auf.
     *
     * @param string|null $only
     *
     * @return array
     */
    public static function revert($only = null)
    {
        $out = array();

        foreach (self::descriptors() as $key => $desc) {
            if ($only !== null && $only !== $key) {
                continue;
            }

            $restored = self::restoreBackup($key, $desc['backupDirs']);
            // Opt-out IMMER setzen (auch ohne Backup): der Betreiber will diese Ext nicht (auto-)patchen.
            self::setOptOut($key, true);

            $out[$key] = $restored
                ? array('ok' => true, 'code' => 'reverted')
                : array('ok' => true, 'code' => 'opt_out_no_backup');
        }

        return $out;
    }

    // ------------------------------------------------------------------
    // Automatischer Ausloeser (an die Sicherheitsupdates gekoppelt, KEIN eigener Schalter)
    // ------------------------------------------------------------------

    /**
     * Headless-Tick: schliesst offene JoomShaper-J3-Luecken automatisch - GEKOPPELT an den
     * Sicherheits-Auto-Update-Master (autoupdate_ext, HTProtect-Default AN). Master aus -> kein
     * Auto-Patch (Dashboard-Ausloeser bleibt). Nur Joomla 3.10, self-gedrosselt (Backoff, VOR der
     * schweren Arbeit gestempelt, damit ein Abbruch nie zum Spiegel-Hammer wird), respektiert den
     * revert()-Opt-out, absolut fail-safe. Aufruf: HtpAutoUpdate::runStep (nach dem Heartbeat).
     *
     * @return void
     */
    public static function autoTick()
    {
        try {
            if (!self::isJoomla310() || !class_exists('HtpConfig')) {
                return;
            }

            $cfg = HtpConfig::load();

            // "Sicherheitsupdates aktiv" = der Foreign-Auto-Update-Master ist an.
            if (empty($cfg['autoupdate_ext'])) {
                return;
            }

            // Ohne persistierbaren State kann der Backoff nicht greifen -> dann NICHT automatisch
            // patchen (sonst 15-Min-Deploy-Schleife). Der Dashboard-Ausloeser bleibt moeglich.
            if (self::autoStateFile() === '') {
                return;
            }

            $now = time();

            foreach (self::descriptors() as $key => $desc) {
                // Frische Sicht JE Erweiterung -> ein NEBENLAEUFIG gesetzter Opt-out (Dashboard-
                // revert waehrend dieses Ticks) wird sofort gesehen und nie ueberbuegelt.
                $state = self::loadAutoState();

                // Opt-out des Betreibers respektieren (nach revert()).
                if (!empty($state[$key]['reverted'])) {
                    continue;
                }

                $status = self::statusOne($key, $desc);

                if ($status['state'] !== 'vulnerable') {
                    continue;
                }

                $last  = isset($state[$key]['t']) ? (int) $state[$key]['t'] : 0;
                $fails = isset($state[$key]['f']) ? (int) $state[$key]['f'] : 0;

                // Erster Versuch SOFORT (last=0 -> kein Warten): eine frisch erkannte Luecke wird
                // schon beim naechsten Tick (~15 Min) geschlossen, nicht erst nach 6 h. Danach
                // exponentieller Backoff, klein beginnend (15 Min) und bei AUTO_INTERVAL (6 h)
                // gedeckelt: 15m,30m,1h,2h,4h,6h -> transiente Fehler heilen schnell, dauerhafte
                // hammern nie den Spiegel (keine Dauerschleife, kein Last-Impact).
                $wait = $fails <= 0 ? 0 : (int) min(self::RETRY_BASE * pow(2, $fails - 1), self::AUTO_INTERVAL);

                if (($now - $last) < $wait) {
                    continue;
                }

                // Versuch ZUERST stempeln (Backoff greift auch bei Timeout/Fatal) - als read-modify-
                // write, das nur t/f dieses Keys aendert und alle uebrigen Felder/Keys (v. a. einen
                // nebenlaeufig gesetzten reverted-Opt-out) erhaelt. Ist inzwischen reverted -> nicht patchen.
                if (!self::stampAttempt($key, $now, $fails + 1)) {
                    continue;
                }

                $res = self::deployOne($key, $desc);

                if (!empty($res['ok']) && isset($res['state']) && $res['state'] === 'patched') {
                    self::stampSuccess($key); // Backoff zuruecksetzen; reverted bleibt erhalten
                }
            }
        } catch (Exception $e) {
            // Auto-Patch ist best-effort - darf den Update-Tick NIE stoeren.
        } catch (Throwable $e) {
            // dito (PHP 7+ Fehler)
        }
    }

    // ------------------------------------------------------------------
    // Auto-Drossel-/Opt-out-State
    // ------------------------------------------------------------------

    /**
     * @return string  Pfad der State-Datei im State-Dir, oder '' (nicht ermittelbar/nicht schreibbar)
     */
    private static function autoStateFile()
    {
        $dir = function_exists('htp_state_dir') ? htp_state_dir() : '';

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

        return rtrim(str_replace('\\', '/', $dir), '/') . '/jspatch.json';
    }

    private static function loadAutoState()
    {
        $f = self::autoStateFile();

        if ($f === '' || !is_file($f)) {
            return array();
        }

        $j = json_decode((string) @file_get_contents($f), true);

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

    private static function saveAutoState($state)
    {
        $f = self::autoStateFile();

        if ($f !== '') {
            @file_put_contents($f, json_encode($state));
        }
    }

    /**
     * Versuch stempeln (read-modify-write): laedt den State FRISCH, prueft erneut auf einen
     * (nebenlaeufig gesetzten) Opt-out und schreibt nur t/f dieses Keys zurueck - alle uebrigen
     * Felder/Keys bleiben erhalten. So kann autoTick einen parallelen rev()-Opt-out nie ueberbuegeln.
     *
     * @param string $key
     * @param int    $now
     * @param int    $fails
     *
     * @return bool  false, wenn inzwischen reverted -> nicht patchen
     */
    private static function stampAttempt($key, $now, $fails)
    {
        $state = self::loadAutoState();

        if (!empty($state[$key]['reverted'])) {
            return false;
        }

        if (!isset($state[$key]) || !is_array($state[$key])) {
            $state[$key] = array();
        }

        $state[$key]['t'] = (int) $now;
        $state[$key]['f'] = (int) $fails;
        self::saveAutoState($state);

        return true;
    }

    /**
     * Erfolg stempeln (read-modify-write): Backoff-Zaehler zuruecksetzen, dabei einen
     * nebenlaeufig gesetzten reverted-Opt-out erhalten.
     *
     * @param string $key
     *
     * @return void
     */
    private static function stampSuccess($key)
    {
        $state = self::loadAutoState();

        if (!isset($state[$key]) || !is_array($state[$key])) {
            $state[$key] = array();
        }

        $state[$key]['f'] = 0;
        self::saveAutoState($state);
    }

    /**
     * Opt-out-Merker je Erweiterung setzen/loeschen (nach revert() bzw. apply()).
     *
     * @param string|null $only
     * @param bool        $on
     *
     * @return void
     */
    private static function setOptOut($only, $on)
    {
        if (self::autoStateFile() === '') {
            return;
        }

        $state = self::loadAutoState();

        foreach (self::descriptors() as $key => $desc) {
            if ($only !== null && $only !== $key) {
                continue;
            }

            if (!isset($state[$key]) || !is_array($state[$key])) {
                $state[$key] = array();
            }

            if ($on) {
                $state[$key]['reverted'] = time();
            } else {
                unset($state[$key]['reverted']);
            }
        }

        self::saveAutoState($state);
    }

    // ------------------------------------------------------------------
    // Download + Verify + Extract
    // ------------------------------------------------------------------

    /**
     * Laedt das gepinnte Paket, prueft SHA-256, entpackt in-process. Nichts wird auf der
     * Site geschrieben - nur der Temp-Ordner. Rueckgabe: Extract-Verzeichnis oder false.
     *
     * @param array $desc
     *
     * @return string|bool
     */
    private static function downloadVerifyExtract($desc)
    {
        if (!class_exists('HtpProbe') || !class_exists('HtpCoreUpdateJ3')) {
            return false;
        }

        $z = HtpProbe::fetch(self::PATCH_BASE_URL . $desc['package'], true, null, 60);

        if (!is_array($z) || (int) (isset($z['status']) ? $z['status'] : 0) !== 200
            || !isset($z['body']) || $z['body'] === '') {
            return false;
        }

        // Gepinnter Integritaets-Hash (oeffentlich, kein Geheimnis -> hash_equals genuegt).
        $actual = hash('sha256', $z['body']);

        if (!hash_equals(strtolower((string) $desc['sha256']), strtolower($actual))) {
            return false;
        }

        $tmp = self::tmpPath();

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

        // EINDEUTIGER Temp-Name (kein deterministischer, den ein paralleler Lauf @unlink-t).
        $base = 'htp_jspatch_' . getmypid() . '_' . substr(md5($desc['package'] . uniqid('', true)), 0, 12) . '.zip';
        $file = rtrim($tmp, '/\\') . '/' . $base;

        if (@file_put_contents($file, $z['body']) === false) {
            return false;
        }

        $u = HtpCoreUpdateJ3::unpackInProcess($base);
        @unlink($file);

        if (!is_array($u) || empty($u['ok']) || empty($u['extractdir']) || !is_dir($u['extractdir'])) {
            if (is_array($u) && !empty($u['extractdir'])) {
                self::cleanupDir($u['extractdir']);
            }

            return false;
        }

        return $u['extractdir'];
    }

    // ------------------------------------------------------------------
    // Verzeichnis-Backup / -Restore  (reversibel, all-or-nothing, atomar)
    // ------------------------------------------------------------------

    /**
     * Legt EIN vollstaendiges Backup aller Pfade an (all-or-nothing). Existiert bereits ein
     * vollstaendiges Backup (COMPLETE_MARKER), bleibt es unangetastet. Ein abgebrochener Vorlauf
     * (Teil-Backup ohne Marker) wird verworfen und neu gemacht. Rueckgabe false, wenn nicht
     * vollstaendig gesichert werden konnte (dann NICHT patchen).
     *
     * @param string $key
     * @param array  $paths
     *
     * @return bool
     */
    private static function backupDirs($key, $paths)
    {
        $root = self::backupRoot($key);

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

        $marker = $root . '/' . self::COMPLETE_MARKER;

        if (is_file($marker)) {
            return true; // vollstaendiges Backup existiert bereits -> nie ueberschreiben
        }

        // Reste eines abgebrochenen Vorlaufs verwerfen, dann frisch + vollstaendig sichern.
        self::deleteDir($root);

        if (!self::ensureDir($root)) {
            return false;
        }

        foreach ($paths as $relative) {
            $source = self::fullPath($relative);

            if (!is_dir($source)) {
                continue; // nicht vorhandenes Ziel (z. B. Template fehlt) -> nichts zu sichern
            }

            $dest = $root . '/' . $relative;

            if (!self::ensureDir(dirname($dest)) || !self::copyDir($source, $dest)) {
                self::deleteDir($root); // Teil-Backup NIE zuruecklassen

                return false;
            }
        }

        @file_put_contents($marker, '1');

        return true;
    }

    /**
     * Stellt die gesicherten Verzeichnisse wieder her - ATOMAR je Pfad (erst neben das Live-
     * Verzeichnis kopieren, erst bei vollstaendigem Erfolg umschwenken) und mit geprueftem
     * Ergebnis. Das Backup wird NUR verworfen, wenn ALLE Pfade erfolgreich zurueckgespielt wurden
     * (sonst bleibt es fuer einen zweiten Versuch / revert erhalten).
     *
     * @param string $key
     * @param array  $paths
     *
     * @return bool  true, wenn vollstaendig wiederhergestellt
     */
    private static function restoreBackup($key, $paths)
    {
        $root = self::backupRoot($key);

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

        $allOk = true;

        foreach ($paths as $relative) {
            $backup = $root . '/' . $relative;
            $live   = self::fullPath($relative);

            if (!is_dir($backup)) {
                continue;
            }

            if (!self::swapDir($backup, $live)) {
                $allOk = false; // Backup NICHT verwerfen -> Retry/Revert bleibt moeglich
            }
        }

        if ($allOk) {
            self::deleteDir($root);
        }

        return $allOk;
    }

    /**
     * Ersetzt $live durch den Inhalt von $backup, ohne $live vor einer verifizierten Kopie zu
     * loeschen: erst nach $live.<tmp> kopieren, dann Live weg + umbenennen (Fallback: kopieren).
     * Scheitert die Kopie, bleibt $live unangetastet.
     *
     * @param string $backup
     * @param string $live
     *
     * @return bool
     */
    private static function swapDir($backup, $live)
    {
        $sfx = getmypid() . '-' . substr(md5(uniqid('', true)), 0, 8); // eindeutig (kein Kollisions-Race)
        $tmp = $live . '.htp-restore-' . $sfx;
        self::deleteDir($tmp); // evtl. Rest eines frueheren Laufs

        if (!self::copyDir($backup, $tmp)) {
            self::deleteDir($tmp);

            return false; // Live unangetastet
        }

        // $live NIE ohne verifizierten Ersatz verschwinden lassen: erst beiseite ($old), dann tmp -> live.
        // Ein Prozess-Kill zwischendurch laesst $live entweder intakt oder als $old wiederauffindbar
        // (und das Backup bleibt ohnehin bis zum Voll-Erfolg erhalten).
        $old = $live . '.htp-old-' . $sfx;
        self::deleteDir($old);

        if (is_dir($live)) {
            if (!@rename($live, $old)) {
                // $live liess sich nicht beiseiteschieben (Lock?) - direkter, minimal-fenstriger Weg.
                self::deleteDir($live);

                if (@rename($tmp, $live) || self::copyDir($tmp, $live)) {
                    self::deleteDir($tmp);
                    self::invalidateDir($live);

                    return true;
                }

                self::deleteDir($tmp);

                return false;
            }

            // $live steht als $old sicher zur Seite. Jetzt tmp -> live.
            if (@rename($tmp, $live) || self::copyDir($tmp, $live)) {
                self::deleteDir($tmp);
                self::deleteDir($old);
                self::invalidateDir($live);

                return true;
            }

            // tmp -> live gescheitert: $old zurueck an $live (Site bleibt beim Vor-Restore-Stand).
            @rename($old, $live);
            self::deleteDir($tmp);

            return false;
        }

        // $live existiert (noch) nicht -> direkt hinlegen.
        if (@rename($tmp, $live) || self::copyDir($tmp, $live)) {
            self::deleteDir($tmp);
            self::invalidateDir($live);

            return true;
        }

        self::deleteDir($tmp);

        return false;
    }

    /**
     * @param string $key
     *
     * @return string  Backup-Wurzel fuer diesen Patch, oder '' wenn kein Backup-Bereich da
     *
     * MERKSATZ: keyt nur auf $key, NICHT auf die Paketversion. Heute ungefaehrlich, weil deployOne()
     * bei bereits gesetzten Markern per 'already_patched' kurzschliesst (backupDirs re-nutzt nie ein
     * fremdes Paket-Backup). Aendert ein KUENFTIGES Release fuer denselben $key das gepinnte Paket/die
     * Marker/coversBelow, muss das Backup versioniert oder nach Erfolg geraeumt werden, sonst koennte
     * backupDirs ueber den COMPLETE_MARKER ein veraltetes Vor-Erst-Patch-Backup wiederverwenden.
     */
    private static function backupRoot($key)
    {
        if (!class_exists('HtpStorage')) {
            return '';
        }

        $base = (string) HtpStorage::backupDir();

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

        return rtrim($base, '/\\') . '/' . self::BACKUP_PREFIX . preg_replace('/[^a-z0-9_]/i', '', $key);
    }

    // ------------------------------------------------------------------
    // Helix-Ultimate Template-Style-Reparatur (options.json)
    // ------------------------------------------------------------------

    /**
     * Baut jede zurueckgesetzte Helix-Ultimate-Style aus den options.json-Defaults neu auf
     * (der Nebeneffekt eines Template-Reinstalls: params-Reset wischt Header/Logo/Mega-Menue).
     * Eigene Felder der Stub-Params gewinnen beim Merge (Custom-Code bleibt). Die vorherigen
     * params werden ins Site-Log geschrieben (manuell wiederherstellbar; KEIN automatischer
     * Revert dieser DB-Aenderung ueber revert()). Streng no-op-wenn-nichts-kaputt.
     *
     * Diese Reparatur laeuft GENERELL nach jedem Helix-Update (auch J4/5/6) und darf unter
     * keinen Umstaenden etwas brechen oder werfen (Exception UND Throwable gefangen).
     *
     * @return int  Anzahl reparierter Styles
     */
    public static function restoreHelixMenu()
    {
        $restored = 0;

        try {
            $defaults = self::readHelixOptionsJson();
            $db       = function_exists('htp_db') ? htp_db() : null;

            if ($defaults === null || !$db) {
                return 0;
            }

            foreach (self::loadHelixStyles($db) as $style) {
                if (!self::isResetParams($style->params)) {
                    continue;
                }

                $old = json_decode((string) $style->params, true);

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

                // options.json liefert die Struktur; die eigenen Keys des Stubs gewinnen beim
                // Merge -> Custom-Code/Copyright ueberleben. Validitaets-Gate.
                $merged = json_encode(array_merge($defaults, $old));

                if ($merged === false || $merged === '' || $merged[0] !== '{') {
                    continue;
                }

                self::backupHelixParams($style->id, $style->params);

                try {
                    $q = $db->getQuery(true)
                        ->update($db->quoteName('#__template_styles'))
                        ->set($db->quoteName('params') . ' = ' . $db->quote($merged))
                        ->where($db->quoteName('id') . ' = ' . (int) $style->id);
                    $db->setQuery($q);
                    $db->execute();
                    $restored++;
                } catch (Exception $e) {
                    // Eine misslungene Style darf die uebrigen nicht abbrechen.
                } catch (Throwable $e) {
                    // dito
                }
            }

            if ($restored > 0) {
                self::clearHelixCache();
            }
        } catch (Exception $e) {
            return $restored;
        } catch (Throwable $e) {
            return $restored;
        }

        return $restored;
    }

    /**
     * Leert den Helix-Ultimate-Render-/Compile-Cache (rein additiv sicher: Cache ist
     * regenerierbar). Versions-agnostisch ueber htp_root(); ehrt einen abweichenden cache_path,
     * wenn eine Config-Fabrik verfuegbar ist.
     *
     * @return void
     */
    private static function clearHelixCache()
    {
        try {
            $base = function_exists('htp_root') ? rtrim(str_replace('\\', '/', htp_root()), '/') . '/cache' : '';

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

            if (class_exists('JFactory')) {
                try {
                    $cp = (string) JFactory::getConfig()->get('cache_path');

                    if ($cp !== '') {
                        $base = $cp;
                    }
                } catch (Exception $e) {
                    // Default-cache behalten
                } catch (Throwable $e) {
                    // dito
                }
            }

            $dir = rtrim($base, '/\\') . '/helixultimate';

            if (!is_dir($dir)) {
                return;
            }

            $entries = @scandir($dir);

            if ($entries === false) {
                return;
            }

            foreach ($entries as $entry) {
                if ($entry === '.' || $entry === '..' || $entry === 'index.html') {
                    continue;
                }

                $p = $dir . '/' . $entry;

                if (is_dir($p)) {
                    self::deleteDir($p);
                } elseif (is_file($p)) {
                    @unlink($p);
                }
            }
        } catch (Exception $e) {
            // best-effort
        } catch (Throwable $e) {
            // best-effort
        }
    }

    /**
     * Das Helix-Ultimate-Template-Verzeichnis - Joomla 3: templates/<tpl>; Joomla 4+:
     * templates/site/<tpl>.
     *
     * @return string  relativer Pfad oder ''
     */
    private static function helixTemplateDir()
    {
        $candidates = array(
            'templates/' . self::HELIX_TEMPLATE,
            'templates/site/' . self::HELIX_TEMPLATE,
        );

        foreach ($candidates as $rel) {
            if (is_dir(self::fullPath($rel))) {
                return $rel;
            }
        }

        return '';
    }

    /**
     * @return array|null  dekodierte options.json oder null
     */
    private static function readHelixOptionsJson()
    {
        $dir = self::helixTemplateDir();

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

        $full = self::fullPath($dir . '/options.json');

        if (!is_file($full)) {
            return null;
        }

        $c = @file_get_contents($full);

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

        $decoded = json_decode($c, true);

        return (is_array($decoded) && !empty($decoded)) ? $decoded : null;
    }

    /**
     * @param object $db
     *
     * @return array
     */
    private static function loadHelixStyles($db)
    {
        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName(array('id', 'params')))
                ->from($db->quoteName('#__template_styles'))
                ->where($db->quoteName('client_id') . ' = 0')
                ->where($db->quoteName('template') . ' = ' . $db->quote(self::HELIX_TEMPLATE));
            $db->setQuery($q);
            $rows = $db->loadObjectList();
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }

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

    /**
     * Eine Style gilt als "zurueckgesetzt", wenn ihre params ein NICHT-leeres JSON-Objekt
     * sind, dem sowohl menu_type als auch layout fehlen - die zwei Struktur-Keys, die jede
     * echte Helix-Ultimate-Style traegt. Leere params sind gesund (options.json-Fallback).
     *
     * @param string|null $params
     *
     * @return bool
     */
    private static function isResetParams($params)
    {
        if ($params === null || trim((string) $params) === '') {
            return false;
        }

        $decoded = json_decode((string) $params, true);

        if (!is_array($decoded) || empty($decoded)) {
            return false;
        }

        return empty($decoded['menu_type']) && empty($decoded['layout']);
    }

    /**
     * @param int    $styleId
     * @param string $oldParams
     *
     * @return void
     */
    private static function backupHelixParams($styleId, $oldParams)
    {
        if (!class_exists('HtpStorage')) {
            return;
        }

        $dir = (string) HtpStorage::backupDir();

        if ($dir === '' || !self::ensureDir($dir)) {
            return;
        }

        $record = json_encode(array(
            'styleId'    => (int) $styleId,
            'restoredAt' => gmdate('c'),
            'oldParams'  => (string) $oldParams,
        ));

        if ($record !== false) {
            @file_put_contents($dir . '/helix-menu-restore.json', $record . "\n", FILE_APPEND);
        }
    }

    // ------------------------------------------------------------------
    // Kleine Datei-/Pfad-Helfer (JFolder/JFile wo verfuegbar, sonst nativ)
    // ------------------------------------------------------------------

    /**
     * @param string $relative
     *
     * @return string|null
     */
    private static function readManifestVersion($relative)
    {
        $full = self::fullPath($relative);

        if (!is_file($full)) {
            return null;
        }

        $c = @file_get_contents($full);

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

        if (preg_match('#<version>\s*([^<]+?)\s*</version>#i', $c, $m)) {
            return trim($m[1]);
        }

        return null;
    }

    /**
     * @return string  Joomla tmp_path oder ''
     */
    private static function tmpPath()
    {
        if (function_exists('htp_tmp_path')) {
            $p = (string) htp_tmp_path();

            if ($p !== '' && is_dir($p)) {
                return $p;
            }
        }

        return defined('JPATH_ROOT') && is_dir(JPATH_ROOT . '/tmp') ? JPATH_ROOT . '/tmp' : '';
    }

    /**
     * @param string $relative
     *
     * @return string
     */
    private static function fullPath($relative)
    {
        if (defined('JPATH_ROOT')) {
            $root = JPATH_ROOT;
        } elseif (defined('JPATH_BASE')) {
            $root = JPATH_BASE;
        } else {
            $root = '.';
        }

        return rtrim($root, '/\\') . '/' . ltrim((string) $relative, '/\\');
    }

    /**
     * @param string $relative
     *
     * @return bool
     */
    private static function fileExists($relative)
    {
        return is_file(self::fullPath($relative));
    }

    private static function ensureDir($path)
    {
        if (is_dir($path)) {
            return true;
        }

        if (class_exists('JFolder')) {
            try {
                return (bool) JFolder::create($path);
            } catch (Exception $e) {
                return false;
            } catch (Throwable $e) {
                return false;
            }
        }

        return @mkdir($path, 0755, true);
    }

    /**
     * Kopiert ein Verzeichnis rekursiv. Wirft NIE (JFolder::copy kann in J3 eine RuntimeException
     * werfen statt false zu liefern -> hier gefangen), gibt sauber bool zurueck.
     *
     * @param string $src
     * @param string $dest
     *
     * @return bool
     */
    private static function copyDir($src, $dest)
    {
        try {
            if (class_exists('JFolder')) {
                return (bool) JFolder::copy($src, $dest, '', true);
            }

            return self::copyDirNative($src, $dest);
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }
    }

    private static function copyDirNative($src, $dest)
    {
        if (!is_dir($src) || !self::ensureDir($dest)) {
            return false;
        }

        $entries = @scandir($src);

        if ($entries === false) {
            return false;
        }

        foreach ($entries as $entry) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }

            $from = $src . '/' . $entry;
            $to   = $dest . '/' . $entry;

            if (is_dir($from)) {
                if (!self::copyDirNative($from, $to)) {
                    return false;
                }
            } elseif (!@copy($from, $to)) {
                return false;
            }
        }

        return true;
    }

    private static function deleteDir($path)
    {
        if (!is_dir($path)) {
            return;
        }

        if (class_exists('JFolder')) {
            try {
                JFolder::delete($path);

                return;
            } catch (Exception $e) {
                // Fallback nativ
            } catch (Throwable $e) {
                // Fallback nativ
            }
        }

        $entries = @scandir($path);

        if ($entries !== false) {
            foreach ($entries as $entry) {
                if ($entry === '.' || $entry === '..') {
                    continue;
                }

                $p = $path . '/' . $entry;

                if (is_dir($p)) {
                    self::deleteDir($p);
                } else {
                    @unlink($p);
                }
            }
        }

        @rmdir($path);
    }

    private static function cleanupDir($dir)
    {
        self::deleteDir($dir);
    }

    private static function invalidateDir($dir)
    {
        if (!function_exists('opcache_invalidate') || !is_dir($dir)) {
            return;
        }

        $it = @scandir($dir);

        if ($it === false) {
            return;
        }

        foreach ($it as $entry) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }

            $p = $dir . '/' . $entry;

            if (is_dir($p)) {
                self::invalidateDir($p);
            } elseif (substr($p, -4) === '.php') {
                @opcache_invalidate($p, true);
            }
        }
    }
}
