<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Datei-Backup, -Wiederherstellung und Rollback einer EINZELNEN Erweiterung
 * fuer das Auto-Update-Modul. Der heikelste Teil des Features - darum bewusst
 * isoliert in dieser eigenen Datei und am staerksten getestet.
 *
 * SICHERHEIT (s. plan-auto-updates.md, Abschnitt 9):
 *  - Pfad-sicher: alle Pfade werden aus VALIDIERTEN Erweiterungs-Element-Namen
 *    abgeleitet (kein '..'/Nullbyte/Backslash, Zeichen-Whitelist) und liegen
 *    garantiert INNERHALB von Docroot bzw. /administrator. Snapshots speichern
 *    NUR root+relativen Pfad, nie absolute Pfade -> kein Stored-Path-Angriff,
 *    site-move-fest.
 *  - Der eigene Datenordner (com_htprotect) ist nie im Wirkungsbereich
 *    (Selbst-Update ist Schritt 2) - zusaetzlich hart ausgeschlossen.
 *  - Groessen-gecappt + Pruning; Ablage im web-geschuetzten State-Ordner.
 *  - Reine Pfad-Politik (ownedPaths/relSafe/supportedType) ist statisch und
 *    unit-testbar; FS-Seiteneffekte stehen in klar benannten Methoden.
 */

defined('_JEXEC') or die;

class HtpUpdateBackup
{
    /** Unterordner im gemeinsamen Backup-Ort (htp_backup_root(), Fallback htp_state_dir()). */
    const DIRNAME = 'autoupdate';

    /** Datei-Cap pro Snapshot (zu gross -> Update wird uebersprungen, nicht gewagt). */
    const MAX_BYTES = 209715200; // 200 MB

    /** Gueltige Snapshot-ID: YYYYMMDDHHMMSS-<version-sanitisiert>. */
    const SNAP_RE = '~^\d{14}-[A-Za-z0-9._\-]{1,40}$~';

    /** Maximallaenge/Whitelist eines Erweiterungs-Element-/Ordnernamens. */
    const NAME_RE = '~^[A-Za-z0-9_][A-Za-z0-9_.\-]{0,63}$~';

    /** DB-Backup-Caps (Schritt 2): zu grosse Tabellen werden uebersprungen. */
    const DB_MAX_ROWS  = 50000;
    const DB_MAX_BYTES = 33554432; // 32 MB Dump gesamt

    /** Strikte Identifier-Validierung (Tabellen-/Spaltennamen). */
    const IDENT_RE = '~^[A-Za-z0-9_]+$~';

    /** SHA-256-Hex-Validierung (Content-Addressed-Store-Blob-Adressen). */
    const SHA256_RE = '~^[a-f0-9]{64}$~';

    /** Globale Aufraeum-Retention ueber ALLE Erweiterungen (nicht nur per-eid keep). Verhindert,
     *  dass der Rollback-Speicher mit dem Erweiterungsbestand unbegrenzt waechst: hoechstens so
     *  viele Rollback-Punkte insgesamt, und nichts aelter als X Tage - laeuft taeglich automatisch.
     *  Ein Rollback ist ohnehin fast immer fuer ein GERADE schiefgelaufenes (also junges) Update. */
    const GLOBAL_KEEP  = 12;   // max. Rollback-Punkte insgesamt (neueste zuerst), Untergrenze/Boden
    const MAX_AGE_DAYS = 30;   // aeltere Rollback-Punkte darueber hinaus entfernen

    /** Karenz fuer meta-lose Snapshot-Ordner: juenger = evtl. gerade in Arbeit, nicht loeschen. */
    const PARTIAL_GRACE      = 3600; // 1 h

    /**
     * Suffix fuer KOMPRIMIERT abgelegte Backup-Dateien (Snapshot-`files/`-Baum + CAS-Blobs).
     * Eine Gzip-Datei ist ein opaker Blob - fremde Malware-Scanner (mySites.guru etc.) koennen
     * keine Text-/Code-Muster mehr matchen, die legitime HTProtect-Sicherung wird nicht mehr
     * faelschlich als „gehackt" geflaggt. BEWUSST ein HTProtect-eigener Suffix (nicht `.gz`):
     * Erweiterungen liefern legitim `.gz`-Assets aus - waere der Marker `.gz`, koennte der
     * Restore ein solches Alt-Backup-Asset faelschlich entpacken. `.htpgz` kollidiert mit keiner
     * realen Datei -> ein Backup-Eintrag mit diesem Suffix stammt IMMER von uns. Alt-Backups
     * (ohne Suffix, Klartext) bleiben voll rueckwaerts-kompatibel restaurierbar.
     */
    const PACK_EXT = '.htpgz';

    /**
     * Name der EINEN Zip-Sicherungsdatei pro Rollback-Punkt (ab 2.5.9). Statt tausender
     * loser .htpgz-Dateien + geteiltem CAS wird das ganze Oekosystem der aktualisierten
     * Erweiterung in EINE deflate-komprimierte Zip abgelegt. Der PACK_EXT-Suffix haelt
     * Fremd-Scanner (mySites.guru etc.) davon ab, den opaken Blob als Schadcode zu flaggen;
     * .htpz kollidiert mit keiner realen Datei -> ein Eintrag mit diesem Namen stammt
     * IMMER von uns. Zip-Member: `<root>/<rel>[/subpfad]` (root = site|admin).
     */
    const SNAP_ZIP = 'snap.htpz';

    /**
     * Marker-Datei fuer die einmalige Alt-Format-Bereinigung (migrateSlim). Sobald sie im
     * Backup-Wurzelordner liegt, laeuft die Bereinigung nicht erneut (idempotent).
     */
    const SLIM_MARKER = '.slim2';

    // ─────────────────────────────────────────────────────────────────
    //  Reine Pfad-Politik (statisch, unit-testbar - kein FS/DB)
    // ─────────────────────────────────────────────────────────────────

    /**
     * Werden Datei-Backup/-Rollback fuer diesen Erweiterungstyp unterstuetzt?
     * Bewusst nur Typen mit klar abgegrenztem, kanonischem Datei-Layout. 'package'
     * (buendelt viele Erweiterungen) und 'file' (deklariert verstreute Einzel-
     * dateien) lassen sich nicht sauber abgrenzen -> kein Auto-Update (wird ehrlich
     * uebersprungen, manuell weiterhin moeglich).
     *
     * @param   string  $type  Erweiterungstyp
     *
     * @return boolean
     */
    public static function supportedType($type)
    {
        return in_array(strtolower((string) $type), array('component', 'plugin', 'module', 'template', 'library', 'package'), true);
    }

    /**
     * Ist ein Element-/Ordnername unbedenklich (keine Traversal, nur Whitelist)?
     *
     * @param   string  $name  Name
     *
     * @return boolean
     */
    public static function validName($name)
    {
        return is_string($name) && preg_match(self::NAME_RE, $name) === 1;
    }

    /**
     * Ist ein relativer Pfad unbedenklich? Kein '..'/Nullbyte/Backslash/fuehrender
     * Slash, nur eine enge Zeichen-Whitelist, und NIE ein blosser geteilter
     * Wurzel-Ordner (components, media, modules, plugins, templates, libraries,
     * administrator, manifests ...) - solche wuerden bei einem Restore ganze
     * Fremdbereiche treffen.
     *
     * @param   string  $rel  Relativer Pfad (forward slashes)
     *
     * @return boolean
     */
    public static function relSafe($rel)
    {
        $rel = (string) $rel;

        if ($rel === '' || $rel[0] === '/' || strpos($rel, '..') !== false
            || strpos($rel, "\0") !== false || strpos($rel, '\\') !== false) {
            return false;
        }

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

        // Nie ein geteilter Wurzelordner allein.
        $shared = array(
            'components', 'media', 'modules', 'plugins', 'templates', 'libraries',
            'administrator', 'api', 'administrator/components', 'administrator/modules',
            'administrator/templates', 'administrator/manifests', 'administrator/manifests/libraries',
            'administrator/manifests/files', 'administrator/manifests/packages', 'api/components',
            'media/templates', 'media/templates/site', 'media/templates/administrator',
            // Admin-root-Formen (rel relativ zu /administrator) der Manifest-Ordner.
            'manifests', 'manifests/libraries', 'manifests/files', 'manifests/packages',
        );

        if (in_array(trim($rel, '/'), $shared, true)) {
            return false;
        }

        // Den eigenen Datenbereich nie anfassen (Selbst-Update = Schritt 2).
        if (stripos($rel, 'com_htprotect') !== false) {
            return false;
        }

        return true;
    }

    /**
     * Kanonische, der Erweiterung gehoerende Pfade (relativ) - jeweils mit dem
     * zustaendigen Wurzelbereich ('site' = Joomla-Root, 'admin' = /administrator).
     * Rein aus Typ + (validiertem) Element/Ordner abgeleitet, OHNE Platten-Zugriff.
     * Die Liste enthaelt die KANDIDATEN; ob ein Pfad existiert, entscheidet erst
     * der Snapshot (Praesenz-Karte).
     *
     * @param   string  $type      component|plugin|module|template|library
     * @param   string  $element   z. B. com_jce, jce, nrframework, astroid
     * @param   string  $folder    Plugin-Gruppe (z. B. system), sonst ''
     * @param   int     $clientId  0 = Site, 1 = Administrator
     *
     * @return array Liste ['root' => 'site'|'admin', 'rel' => string]
     */
    public static function ownedPaths($type, $element, $folder, $clientId)
    {
        $type    = strtolower((string) $type);
        $element = (string) $element;
        $folder  = (string) $folder;
        $client  = (int) $clientId;

        if (!self::validName($element)) {
            return array();
        }

        // Ordner nur pruefen, wenn vorhanden (Plugins). Leerer Ordner ist gueltig
        // fuer Nicht-Plugin-Typen.
        if ($folder !== '' && !self::validName($folder)) {
            return array();
        }

        $bare = preg_replace('~^com_~', '', $element); // com_jce -> jce
        $out  = array();

        switch ($type) {
            case 'component':
                $out[] = array('root' => 'admin', 'rel' => 'components/' . $element);
                $out[] = array('root' => 'site',  'rel' => 'components/' . $element);
                $out[] = array('root' => 'site',  'rel' => 'api/components/' . $element);
                $out[] = array('root' => 'site',  'rel' => 'media/' . $element);

                if ($bare !== $element) {
                    $out[] = array('root' => 'site', 'rel' => 'media/' . $bare);
                }
                break;

            case 'plugin':
                if ($folder === '') {
                    return array();
                }

                $out[] = array('root' => 'site', 'rel' => 'plugins/' . $folder . '/' . $element);
                $out[] = array('root' => 'site', 'rel' => 'media/plg_' . $folder . '_' . $element);
                break;

            case 'module':
                if ($client === 1) {
                    $out[] = array('root' => 'admin', 'rel' => 'modules/' . $element);
                } else {
                    $out[] = array('root' => 'site', 'rel' => 'modules/' . $element);
                }

                $out[] = array('root' => 'site', 'rel' => 'media/' . $element);
                break;

            case 'template':
                if ($client === 1) {
                    $out[] = array('root' => 'admin', 'rel' => 'templates/' . $element);
                    $out[] = array('root' => 'site',  'rel' => 'media/templates/administrator/' . $element);
                } else {
                    $out[] = array('root' => 'site', 'rel' => 'templates/' . $element);
                    $out[] = array('root' => 'site', 'rel' => 'media/templates/site/' . $element);
                }
                break;

            case 'library':
                $out[] = array('root' => 'site',  'rel' => 'libraries/' . $element);
                $out[] = array('root' => 'admin', 'rel' => 'manifests/libraries/' . $element . '.xml');
                $out[] = array('root' => 'site',  'rel' => 'media/lib_' . $element);
                break;

            default:
                return array();
        }

        // Letzte Sicherung: jeden Eintrag durch relSafe schicken.
        $safe = array();

        foreach ($out as $entry) {
            if (self::relSafe($entry['rel'])) {
                $safe[] = $entry;
            }
        }

        return $safe;
    }

    /**
     * Kanonische Pfade eines PAKETS (rein): das Paket-Manifest + die Vereinigung
     * der ownedPaths ALLER Kind-Erweiterungen. Ein Joomla-Paket (z. B. pkg_jce,
     * pkg_icagenda) buendelt Komponente + Plugins + Module + Library; ein Update
     * ersetzt alle - also muessen alle gesichert/zurueckgerollt werden. Die Kinder
     * werden EXPLIZIT uebergeben (testbar; die DB-Abfrage steckt in packageChildren).
     *
     * @param   string  $element   Paket-Element (z. B. pkg_jce)
     * @param   array   $children  Liste der Kinder [{type,element,folder,client_id}]
     *
     * @return array  Liste ['root','rel'] (dedupliziert, relSafe-gefiltert)
     */
    public static function packageOwnedPaths($element, $children)
    {
        if (!self::validName($element)) {
            return array();
        }

        $out = array(
            // Das Paket-Manifest selbst (administrator/manifests/packages/<element>.xml).
            array('root' => 'admin', 'rel' => 'manifests/packages/' . $element . '.xml'),
        );

        foreach ((array) $children as $c) {
            if (!is_array($c)) {
                continue;
            }

            $ctype = isset($c['type']) ? (string) $c['type'] : '';
            $celem = isset($c['element']) ? (string) $c['element'] : '';
            $cfold = isset($c['folder']) ? (string) $c['folder'] : '';
            $ccli  = isset($c['client_id']) ? (int) $c['client_id'] : 0;

            foreach (self::ownedPaths($ctype, $celem, $cfold, $ccli) as $p) {
                $out[] = $p;
            }
        }

        // Deduplizieren + relSafe absichern.
        $seen = array();
        $uniq = array();

        foreach ($out as $p) {
            if (!isset($p['root'], $p['rel']) || !self::relSafe($p['rel'])) {
                continue;
            }

            $k = $p['root'] . '|' . $p['rel'];

            if (!isset($seen[$k])) {
                $seen[$k] = true;
                $uniq[]   = $p;
            }
        }

        return $uniq;
    }

    /**
     * Kind-Erweiterungen eines Pakets aus #__extensions (package_id). Side-effecting
     * (DB). Liefert [{type,element,folder,client_id}].
     *
     * @param   int  $eid  Extension-ID des Pakets
     *
     * @return array
     */
    public static function packageChildren($eid)
    {
        $eid = (int) $eid;
        $db  = function_exists('htp_db') ? htp_db() : null;

        if (!$db || $eid <= 0) {
            return array();
        }

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName(array('type', 'element', 'folder', 'client_id')))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('package_id') . ' = ' . $eid);
            $db->setQuery($query, 0, 200);
            $rows = $db->loadAssocList();
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }

        $out = array();

        foreach ((array) $rows as $r) {
            $out[] = array(
                'type'      => isset($r['type']) ? (string) $r['type'] : '',
                'element'   => isset($r['element']) ? (string) $r['element'] : '',
                'folder'    => isset($r['folder']) ? (string) $r['folder'] : '',
                'client_id' => isset($r['client_id']) ? (int) $r['client_id'] : 0,
            );
        }

        return $out;
    }

    /**
     * Vendor-/Oekosystem-Kennung eines Erweiterungs-Elements (rein). Streift die generischen
     * Joomla-Praefixe (com_/mod_/plg_/lib_/tpl_/pkg_) ab und liefert den Rest kleingeschrieben.
     * Das entspricht EXAKT dem markanten Pfadsegment, das ownedPaths aus dem Element bildet
     * (z. B. Astroid-Template `astroid` -> templates/astroid; Astroid-Library `astroid` ->
     * libraries/astroid) - der GEMEINSAME Vendor-Stamm mehrerer Erweiterungen desselben
     * Frameworks. Damit laesst sich der Astroid-Kaskadenfall erfassen, ohne fremde Vendoren
     * einzusammeln. Leerer String bei ungueltigem Element.
     *
     * @param   string  $element  Erweiterungs-Element
     *
     * @return string
     */
    public static function vendorToken($element)
    {
        $b = strtolower((string) $element);
        $b = preg_replace('~^(com_|mod_|plg_|lib_|tpl_|pkg_)~', '', $b);

        return (string) $b;
    }

    /**
     * REINE POLICY (kein DB/FS, voll unit-testbar): der Datei-SCOPE einer Sicherung = das
     * OEKOSYSTEM der aktualisierten Erweiterung. Das ist die Vereinigung aus
     *   (a) den ownedPaths der Zielerweiterung (bzw. Paket-Manifest + Kinder bei Paketen) und
     *   (b) den ownedPaths JEDER installierten Nicht-Kern-Erweiterung, die einen gemeinsamen
     *       Vendor-Stamm (vendorToken) mit dem Ziel teilt.
     * So bleibt die Kaskade in fremde Erweiterungen desselben Frameworks abgedeckt (Astroid:
     * Library-Update ersetzt Template/Plugin/Modul unter gemeinsamem `astroid`-Segment), waehrend
     * unbeteiligte Fremd-Vendoren wegfallen (bewusst zugesagter, enger Kompromiss). Kern
     * (coreElements) + com_htprotect (Ouroboros) sind IMMER ausgeschlossen; jeder Eintrag laeuft
     * durch relSafe. Ersetzt das getrennte Wide-Backup + den geteilten CAS.
     *
     * @param   string  $type       Ziel-Typ
     * @param   string  $element    Ziel-Element
     * @param   string  $folder     Plugin-Gruppe oder ''
     * @param   int     $clientId   0|1
     * @param   array   $children   Paket-Kinder [{type,element,folder,client_id}] (leer sonst)
     * @param   array   $installed  Alle installierten Erweiterungs-Zeilen [{type,element,folder,client_id}]
     *
     * @return array  dedupliziert ['root','rel'] (leer, wenn das Ziel keine Pfade hat)
     */
    public static function ecosystemScope($type, $element, $folder, $clientId, $children, $installed)
    {
        $type     = strtolower((string) $type);
        $children = is_array($children) ? $children : array();

        // Ziel-Pfade + Ziel-Vendor-Kennungen bestimmen.
        $tokens = array();

        if ($type === 'package') {
            $paths = self::packageOwnedPaths($element, $children);

            $tk = self::vendorToken($element);

            if ($tk !== '') {
                $tokens[$tk] = true;
            }

            foreach ($children as $c) {
                $ce = (is_array($c) && isset($c['element'])) ? (string) $c['element'] : '';
                $t  = self::vendorToken($ce);

                if ($t !== '') {
                    $tokens[$t] = true;
                }
            }
        } else {
            $paths = self::ownedPaths($type, $element, $folder, $clientId);

            $tk = self::vendorToken($element);

            if ($tk !== '') {
                $tokens[$tk] = true;
            }
        }

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

        $seen = array();
        $out  = array();

        // Deduplizierendes Einsammeln (relSafe zwingend).
        $collect = function ($list) use (&$seen, &$out) {
            foreach ((array) $list as $p) {
                if (!is_array($p) || !isset($p['root'], $p['rel']) || !self::relSafe($p['rel'])) {
                    continue;
                }

                $root = ($p['root'] === 'admin') ? 'admin' : 'site';
                $k    = $root . '|' . $p['rel'];

                if (!isset($seen[$k])) {
                    $seen[$k] = true;
                    $out[]    = array('root' => $root, 'rel' => $p['rel']);
                }
            }
        };

        $collect($paths);

        // Oekosystem-Geschwister: gleicher Vendor-Stamm, kein Kern, nicht wir selbst.
        $core = self::coreElements();

        foreach ((array) $installed as $r) {
            if (!is_array($r)) {
                continue;
            }

            $rt = isset($r['type']) ? strtolower((string) $r['type']) : '';
            $re = isset($r['element']) ? (string) $r['element'] : '';
            $rf = isset($r['folder']) ? (string) $r['folder'] : '';
            $rc = isset($r['client_id']) ? (int) $r['client_id'] : 0;

            if ($re === '' || isset($core[$re]) || $re === 'com_htprotect' || !self::supportedType($rt)) {
                continue;
            }

            $rtoken = self::vendorToken($re);

            if ($rtoken === '' || !isset($tokens[$rtoken])) {
                continue;
            }

            // ownedPaths deckt component/plugin/module/template/library ab; ein Geschwister-PAKET
            // liefert hier [] (seine Kinder stehen als eigene installierte Zeilen und werden je
            // einzeln ueber den Vendor-Stamm erfasst) -> keine DB noetig, rein.
            $collect(self::ownedPaths($rt, $re, $rf, $rc));
        }

        return $out;
    }

    /**
     * Welche bereits gespeicherten Snapshots sind ueber das Aufbewahrungs-Limit
     * hinaus zu loeschen? Rein (sortiert absteigend nach Zeit, behaelt die ersten
     * $keep). Liefert die zu loeschenden Snapshot-IDs.
     *
     * @param   array  $snaps  Liste mit ['id' => string, 'ts' => int]
     * @param   int    $keep   Wie viele behalten (mind. 1)
     *
     * @return array  zu loeschende IDs
     */
    public static function pruneList($snaps, $keep)
    {
        $keep = max(1, (int) $keep);

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

        $items = array_values($snaps);
        usort($items, array(__CLASS__, 'cmpTsDesc'));

        $drop = array();

        foreach (array_slice($items, $keep) as $it) {
            if (isset($it['id'])) {
                $drop[] = (string) $it['id'];
            }
        }

        return $drop;
    }

    /** Vergleichsfunktion: neuere (groesseres ts) zuerst. */
    public static function cmpTsDesc($a, $b)
    {
        $ta = isset($a['ts']) ? (int) $a['ts'] : 0;
        $tb = isset($b['ts']) ? (int) $b['ts'] : 0;

        if ($ta === $tb) {
            return 0;
        }

        return ($ta > $tb) ? -1 : 1;
    }

    // ─────────────────────────────────────────────────────────────────
    //  FS-Seiteneffekte (Snapshot/Rollback/Pruning)
    // ─────────────────────────────────────────────────────────────────

    /**
     * Wurzel des Backup-Bereichs (im web-geschuetzten State-Ordner). Legt den
     * Ordner bei Bedarf an. Leerer String, wenn nicht beschreibbar.
     *
     * @return string  ohne abschliessenden Slash, oder ''
     */
    public static function backupRoot()
    {
        // Gemeinsamer neuer Ablageort mit den .app-Backups: /administrator/_htprotect_backups
        // (gehaertet, leicht auszuschliessen). htp_backup_root() faellt bei Bedarf auf den
        // bisherigen State-Ordner zurueck + migriert Alt-Snapshots. Alt-Fallback fuer alte compat.
        $base = function_exists('htp_backup_root')
            ? htp_backup_root()
            : (function_exists('htp_state_dir') ? htp_state_dir() : '');

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

        $dir = rtrim(str_replace('\\', '/', $base), '/') . '/' . self::DIRNAME;

        if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) {
            return '';
        }

        @chmod($dir, 0700);

        return is_writable($dir) ? $dir : '';
    }

    /**
     * Absoluten Wurzelpfad eines Bereichs liefern ('site' = Joomla-Root,
     * 'admin' = /administrator). Immer LIVE abgeleitet (nie gespeichert).
     *
     * @param   string  $root  'site'|'admin'
     *
     * @return string
     */
    private static function absBase($root)
    {
        return ($root === 'admin') ? htp_admin_root() : htp_root();
    }

    /**
     * Erweiterungs-EIGENE Backup-/Datenordner innerhalb der zu sichernden Pfade
     * finden - UNIVERSELL (nicht auf eine Erweiterung hartkodiert). Solche Ordner
     * enthalten die u. U. gigabyte-grossen Website-Backups; sie sind NICHT Teil des
     * Erweiterungs-Codes. Wuerde man sie mitsichern, sprengt EIN Archiv den
     * Snapshot-Deckel -> Backup schlaegt fehl -> Update ewig "uebersprungen".
     * Wuerde man sie beim Restore loeschen, waeren die Backups weg. Darum werden sie
     * wie der Selbst-Update-data/-Ordner an ALLEN drei Stellen ausgeschlossen
     * (Snapshot, Verify, Restore).
     *
     * Erkennung eines direkten Unterordners eines gesicherten Verzeichnisses:
     *   (a) per Namensmuster: backup, backups, backup_output (deckt Akeeba
     *       'backup' + Easy Joomla Backup 'backups' + weitere ab), ODER
     *   (b) inhaltsbasiert: enthaelt (bis 1 Ebene tief) Backup-Archive
     *       (.jpa/.jps/.jNN - Akeeba-Formate inkl. Split-Teile) -> faengt auch
     *       Erweiterungen mit abweichendem Ausgabeordner-Namen, ODER
     *   (c) per Namensmuster fuer NUTZER-Uploads/-Content: uploads, userfiles,
     *       user_uploads, attachments u. ae. Das ist vom Nutzer hochgeladener
     *       Inhalt (Formular-Anhaenge, Medien), NICHT Erweiterungs-Code -> gehoert
     *       wie die Datenordner nicht in den Roll-back-Snapshot. Ein Upload-Formular
     *       (z. B. Convert Forms media/com_convertforms/uploads) fuellt sich sonst bis
     *       ueber den Snapshot-Deckel und laesst das Update dauerhaft "uebersprungen".
     * Nur innerhalb der EIGENEN ownedPaths der Erweiterung gescannt (nie fremde).
     *
     * @return array Liste ['root'=>'admin'|'site','rel'=>relPfad] der Daten-/Upload-Ordner
     */
    private static function dataExcludeRels($paths)
    {
        $out       = array();
        $seen      = array();
        $nameRe    = '/^(backups?|backup[_-]?output)$/i';
        // Nutzer-Upload-/Content-Ordner (kein Code): wie Datenordner ausschliessen.
        $uploadRe  = '/^(uploads?|user[_-]?files?|userfiles|user[_-]?uploads?|attachments?)$/i';
        $archiveRe = '/\.(jpa|jps|j\d{2})$/i';

        foreach ((array) $paths as $entry) {
            if (!is_array($entry) || !isset($entry['root'], $entry['rel'])) {
                continue;
            }

            $root = ($entry['root'] === 'admin') ? 'admin' : 'site';
            $rel  = trim((string) $entry['rel'], '/');

            if ($rel === '' || !self::relSafe($rel)) {
                continue;
            }

            $base = self::absBase($root);
            $dir  = $base . '/' . $rel;

            if (!self::within($dir, $base) || !is_dir($dir)) {
                continue;
            }

            foreach ((array) @scandir($dir) as $name) {
                if ($name === '.' || $name === '..') {
                    continue;
                }

                $sub = $dir . '/' . $name;

                if (!is_dir($sub) || is_link($sub)) {
                    continue;
                }

                if (preg_match($nameRe, $name) !== 1 && preg_match($uploadRe, $name) !== 1
                    && !self::hasArchiveFiles($sub, $archiveRe)) {
                    continue;
                }

                $subRel = $rel . '/' . $name;
                $key    = $root . '|' . $subRel;

                if (!isset($seen[$key])) {
                    $seen[$key] = true;
                    $out[]      = array('root' => $root, 'rel' => $subRel);
                }
            }
        }

        return $out;
    }

    /**
     * Gespeicherte Ausschluss-Rels (['root','rel']) in absolute, normalisierte
     * Pfade aufloesen (fuer copyInto/treeComplete/rmTree). Der Snapshot friert die
     * Liste im Meta ein -> Verify + Restore nutzen EXAKT dieselbe Menge wie das
     * Sichern (kein transientes Neu-Scannen -> Restore-Konsistenz garantiert).
     *
     * @return array Absolute Pfade (forward slashes, ohne Trailing-Slash)
     */
    private static function excludeAbsList($rels)
    {
        $out = array();

        foreach ((array) $rels as $r) {
            if (!is_array($r) || !isset($r['root'], $r['rel'])) {
                continue;
            }

            $root = ($r['root'] === 'admin') ? 'admin' : 'site';
            $rel  = trim((string) $r['rel'], '/');

            if ($rel === '' || !self::relSafe($rel)) {
                continue;
            }

            $base = self::absBase($root);
            $abs  = $base . '/' . $rel;

            if (self::within($abs, $base)) {
                $out[] = rtrim(str_replace('\\', '/', $abs), '/');
            }
        }

        return $out;
    }

    /**
     * Enthaelt $dir (bis 1 Ebene tief, gedeckelt) eine Datei, die auf ein
     * Backup-Archiv-Muster passt? Dient der inhaltsbasierten Erkennung von
     * Ausgabeordnern mit unkonventionellem Namen.
     *
     * @return boolean
     */
    private static function hasArchiveFiles($dir, $re, $depth = 0)
    {
        $items = @scandir($dir);

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

        $subdirs = array();
        $checked = 0;

        foreach ($items as $name) {
            if ($name === '.' || $name === '..') {
                continue;
            }

            if (++$checked > 4000) {
                break; // Deckel gegen Riesenordner
            }

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

            if (is_link($p)) {
                continue;
            }

            if (is_file($p)) {
                if (preg_match($re, $name) === 1) {
                    return true;
                }
            } elseif ($depth < 1 && is_dir($p)) {
                $subdirs[] = $p;
            }
        }

        foreach ($subdirs as $s) {
            if (self::hasArchiveFiles($s, $re, $depth + 1)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Liegt $abs garantiert innerhalb von $base (kein Ausbruch)?
     *
     * @param   string  $abs   Zielpfad (forward slashes)
     * @param   string  $base  Erlaubter Wurzelpfad
     *
     * @return boolean
     */
    private static function within($abs, $base)
    {
        $abs  = rtrim(str_replace('\\', '/', $abs), '/');
        $base = rtrim(str_replace('\\', '/', $base), '/');

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

        return $abs === $base || strpos($abs . '/', $base . '/') === 0;
    }

    /**
     * Snapshot der Dateien einer Erweiterung VOR dem Update anlegen. Sichert die
     * kanonischen Pfade samt Praesenz (existiert/existiert-nicht), damit ein
     * Rollback spaeter byte-genau wiederherstellt UND vom Update neu angelegte
     * Ordner wieder entfernt.
     *
     * @param   int     $eid       Extension-ID (positive Ganzzahl)
     * @param   string  $type      Erweiterungstyp
     * @param   string  $element   Element-Name
     * @param   string  $folder    Plugin-Gruppe oder ''
     * @param   int     $clientId  0|1
     * @param   string  $version   Aktuell installierte Version (fuer die ID/Anzeige)
     * @param   boolean $withDb    Auch die extension-eigenen DB-Tabellen sichern (Schritt 2)
     * @param   array   $excludeExtra       Zusaetzliche absolute Ausschluesse (bekannte Schaddateien)
     * @param   array   $installedOverride  NUR fuer Tests/DI: die installierten Erweiterungs-Zeilen
     *                                       fuer den Oekosystem-Scope injizieren. null (Produktion) =
     *                                       LIVE aus #__extensions (installedRows).
     *
     * @return string  Snapshot-ID oder '' bei Fehlschlag (dann KEIN Update wagen)
     */
    public static function snapshot($eid, $type, $element, $folder, $clientId, $version, $withDb = false, $excludeExtra = array(), $installedOverride = null)
    {
        $eid = (int) $eid;

        if ($eid <= 0 || !self::supportedType($type) || !self::validName($element)) {
            return '';
        }

        // Paket = Buendel: DB-Tabellen aller Kind-Erweiterungen. Sonst die einzelne Erweiterung.
        // $targetPaths = kanonische Pfade NUR der Zielerweiterung (Fallback-Scope + Basis fuer
        // dataExcludeRels); der Zip-Pfad weitet das unten auf das ganze Oekosystem aus.
        if (strtolower((string) $type) === 'package') {
            $children    = self::packageChildren($eid);
            $targetPaths = self::packageOwnedPaths($element, $children);
            $dbElements  = array();

            foreach ($children as $c) {
                if (isset($c['element']) && $c['element'] !== '') {
                    $dbElements[] = (string) $c['element'];
                }
            }
        } else {
            $children    = array();
            $targetPaths = self::ownedPaths($type, $element, $folder, $clientId);
            $dbElements  = array($element);
        }

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

        $rootDir = self::backupRoot();

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

        $snapId = date('YmdHis') . '-' . self::sanitizeVersion($version);

        if (!preg_match(self::SNAP_RE, $snapId)) {
            return '';
        }

        $snapDir = $rootDir . '/' . $eid . '/' . $snapId;

        // Bekannte Schaddateien nie ins Backup-Paket (kein Wiedereinschleusen beim Restore, keine
        // AV-Quarantaene/Schwarm-Fehlalarme auf das Paket). Best-effort, normalisiert.
        $malAbs = array();

        foreach ((array) $excludeExtra as $ex) {
            $ex = rtrim(str_replace('\\', '/', (string) $ex), '/');

            if ($ex !== '') {
                $malAbs[] = $ex;
            }
        }

        // ── ZIP-Pfad: EINE snap.htpz ueber das gesamte OEKOSYSTEM (ersetzt narrow + Wide-CAS) ──
        if (class_exists('ZipArchive')) {
            // Scope = Ziel + Vendor-Geschwister. Ohne DB (Standalone) bleibt es beim Ziel-Scope.
            $installed = is_array($installedOverride) ? $installedOverride : self::installedRows();
            $scope     = self::ecosystemScope($type, $element, $folder, $clientId, $children, $installed);

            if (empty($scope)) {
                $scope = $targetPaths;
            }

            // Erweiterungs-eigene Backup-/Datenordner (Akeeba, EJB, generisch) im Meta EINFRIEREN:
            // nie mitsichern (sonst sprengt EIN Website-Archiv den Deckel) und beim Restore nie
            // loeschen. Plus die bekannten Schaddateien.
            $exRels   = self::dataExcludeRels($scope);
            $excludes = self::excludeAbsList($exRels);

            if (!empty($malAbs)) {
                $excludes = array_merge($excludes, $malAbs);
            }

            $entries = self::writeZipSnapshot($snapDir, $scope, $excludes);

            if ($entries === null) {
                // Cap ueberschritten, Zip-Fehler oder Ordner nicht anlegbar -> Snapshot verwerfen.
                self::rmTree($snapDir);

                return '';
            }

            $fmt = 'zip';
        } else {
            // ── FALLBACK (kein ZipArchive): bisheriger narrow-`files/`-Baum, NUR Ziel (ohne Wide) ──
            $filesDir = $snapDir . '/files';

            if (!@mkdir($filesDir, 0700, true) && !is_dir($filesDir)) {
                return '';
            }

            $exRels   = self::dataExcludeRels($targetPaths);
            $excludes = self::excludeAbsList($exRels);

            if (!empty($malAbs)) {
                $excludes = array_merge($excludes, $malAbs);
            }

            $bytes   = 0;
            $entries = array();

            foreach ($targetPaths as $entry) {
                $rel  = $entry['rel'];
                $root = $entry['root'];

                if (!self::relSafe($rel)) {
                    continue;
                }

                $base    = self::absBase($root);
                $srcAbs  = $base . '/' . $rel;
                $existed = false;

                if (self::within($srcAbs, $base) && (is_dir($srcAbs) || is_file($srcAbs))) {
                    $dstAbs = $filesDir . '/' . $root . '/' . $rel;

                    if (!self::copyInto($srcAbs, $dstAbs, $bytes, $excludes, 0700, 0600, 'pack')) {
                        // Cap ueberschritten oder Kopierfehler -> Snapshot verwerfen.
                        self::rmTree($snapDir);

                        return '';
                    }

                    $existed = true;
                }

                $entries[] = array('root' => $root, 'rel' => $rel, 'existed' => $existed);
            }

            $fmt = 'files';
        }

        // Optional die extension-eigenen DB-Tabellen mitsichern (Schritt 2). Schlaegt
        // das DB-Backup fehl/uebergross, bleibt das Datei-Backup gueltig - es macht den
        // Snapshot NICHT ungueltig.
        $db = array('tables' => array(), 'skipped' => array());

        if ($withDb) {
            $db = self::dumpDb($eid, $snapId, $dbElements);
        }

        // Phase A: kompletten #__extensions-Registry-Zustand mitsichern (klein, aber erlaubt
        // extension-agnostisches Zuruecksetzen von Versionen + ID-Wechseln beim Rollback).
        $registry = self::snapshotRegistry($snapDir);

        $meta = array(
            'eid'         => $eid,
            'type'        => strtolower((string) $type),
            'element'     => $element,
            'folder'      => $folder,
            'client_id'   => (int) $clientId,
            'version'     => (string) $version,
            'ts'          => time(),
            'paths'       => $entries,
            'excludes'    => $exRels,
            'db'          => $db,
            'registry'    => $registry,
            'fmt'         => $fmt,
            'mal_omitted' => count($malAbs),
            // Bewusst aus dem Backup ausgeschlossene bekannte Schaddateien (absolute Pfade, this-site).
            // verify() MUSS sie kennen, sonst wertet es die noch on-disk liegende, absichtlich NICHT
            // gesicherte Malware als "fehlendes Backup" und blockiert dauerhaft (u. U. sicherheits-
            // relevante) Updates auf bereits infizierten Erweiterungen. Der Rollback nutzt sie NICHT
            // (er raeumt die Malware wie bisher via rmTree weg) - nur verify ueberspringt sie.
            'mal_abs'     => array_values($malAbs),
        );

        if (@file_put_contents($snapDir . '/meta.json', json_encode($meta)) === false) {
            self::rmTree($snapDir);

            return '';
        }

        @chmod($snapDir . '/meta.json', 0600);

        return $snapId;
    }

    /**
     * Alle installierten Erweiterungs-Zeilen [{type,element,folder,client_id}] fuer die
     * Oekosystem-Scope-Bestimmung (ecosystemScope). Side-effecting (DB). Leer ohne DB
     * (Standalone/Test) -> ecosystemScope faellt dann auf den Ziel-Scope zurueck.
     *
     * @return array
     */
    private static function installedRows()
    {
        $db = function_exists('htp_db') ? htp_db() : null;

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

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName(array('type', 'element', 'folder', 'client_id')))
                ->from($db->quoteName('#__extensions'));
            $db->setQuery($q);
            $rows = $db->loadAssocList();
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }

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

    /**
     * Den Oekosystem-Scope in EINE snap.htpz schreiben (ZipArchive). Legt $snapDir an, fuegt je
     * Scope-Eintrag die Roh-Dateiinhalte unter dem Zip-Member `<root>/<rel>[/subpfad]` ein
     * (Zip deflated selbst), wendet die Ausschluesse ($excludes: Datenordner + Malware) und den
     * MAX_BYTES-Deckel auf die ORIGINAL-Groesse an. Liefert die paths-Meta-Liste
     * [{root,rel,existed}] fuer JEDEN Scope-Eintrag - oder null bei Fehler/Cap (Aufrufer verwirft).
     * Symlinks werden uebersprungen. addFile liest den Inhalt erst bei close() - da der Snapshot
     * VOR dem Update vollstaendig geschrieben (geschlossen) wird, ist der eingefrorene Stand
     * garantiert der Vor-Update-Stand, und grosse Dateien belasten den Speicher nicht (Streaming).
     *
     * @param   string   $snapDir   Snapshot-Verzeichnis
     * @param   array    $scope     ['root','rel']-Liste (Oekosystem bzw. selfPaths)
     * @param   array    $excludes  Absolute Ausschlusspfade
     * @param   boolean  $self      true = Selbst-Update (relSafeSelf statt relSafe, da
     *                              com_htprotect fuer relSafe bewusst gesperrt ist)
     *
     * @return array|null
     */
    private static function writeZipSnapshot($snapDir, $scope, $excludes, $self = false)
    {
        if (!@mkdir($snapDir, 0700, true) && !is_dir($snapDir)) {
            return null;
        }

        $zip  = new ZipArchive();
        $zabs = $snapDir . '/' . self::SNAP_ZIP;

        if ($zip->open($zabs, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            return null;
        }

        $bytes   = 0;
        $entries = array();

        foreach ((array) $scope as $entry) {
            if (!is_array($entry) || !isset($entry['root'], $entry['rel'])) {
                continue;
            }

            $root = ($entry['root'] === 'admin') ? 'admin' : 'site';
            $rel  = (string) $entry['rel'];

            if (!($self ? self::relSafeSelf($rel) : self::relSafe($rel))) {
                continue;
            }

            $base    = self::absBase($root);
            $srcAbs  = $base . '/' . $rel;
            $existed = false;

            if (self::within($srcAbs, $base) && (is_dir($srcAbs) || is_file($srcAbs))) {
                if (!self::zipAddInto($zip, $srcAbs, $root . '/' . $rel, $bytes, $excludes)) {
                    $zip->close();

                    return null; // Cap ueberschritten oder Zip-Fehler
                }

                $existed = true;
            }

            $entries[] = array('root' => $root, 'rel' => $rel, 'existed' => $existed);
        }

        if ($zip->close() !== true) {
            return null;
        }

        @chmod($zabs, 0600);

        return $entries;
    }

    /**
     * Datei ODER Verzeichnis (rekursiv) unter dem Zip-Member-Praefix $member in $zip ablegen.
     * Laufende Byte-Zaehlung gegen MAX_BYTES (ORIGINAL-Groesse). Ausschluesse (Datenordner +
     * Malware) + Symlinks werden uebersprungen. false = Cap ueberschritten oder addFile-Fehler.
     *
     * @param   ZipArchive  $zip       Offene Zip
     * @param   string      $src       Absolute Quelle
     * @param   string      $member    Ziel-Member-Pfad in der Zip (forward slashes)
     * @param   int         $bytes     Laufende Gesamtgroesse (Referenz)
     * @param   array       $excludes  Absolute Ausschlusspfade
     *
     * @return boolean
     */
    private static function zipAddInto($zip, $src, $member, &$bytes, $excludes)
    {
        if (is_link($src)) {
            return true; // Symlinks nie folgen
        }

        if (!empty($excludes) && self::isExcluded($src, $excludes)) {
            return true; // Datenordner/Malware nie mitsichern
        }

        if (is_file($src)) {
            $size = (int) @filesize($src);

            if (($bytes + $size) > self::MAX_BYTES) {
                return false;
            }

            if ($zip->addFile($src, $member) !== true) {
                return false;
            }

            // Deflate lohnt bei bereits komprimierten Assets (jpg/png/zip/...) nicht und kostet nur
            // CPU; wo verfuegbar, den Kompressionsgrad pro Eintrag zuruecknehmen (best-effort).
            if (method_exists($zip, 'setCompressionName')) {
                @$zip->setCompressionName($member, ZipArchive::CM_DEFLATE, 6);
            }

            $bytes += $size;

            return true;
        }

        if (!is_dir($src)) {
            return true; // Sonderdatei -> ueberspringen (kein Fehler)
        }

        // Leere Ordner strukturtreu erhalten (der Restore legt sie sonst nicht wieder an).
        @$zip->addEmptyDir($member);

        $items = @scandir($src);

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

        foreach ($items as $name) {
            if ($name === '.' || $name === '..') {
                continue;
            }

            if (!self::zipAddInto($zip, $src . '/' . $name, $member . '/' . $name, $bytes, $excludes)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Alle Zip-Member unter dem Praefix $memberBase (= `<root>/<rel>`) in den Live-Baum unter
     * $base wiederherstellen: Dateien streamend (0644), leere Ordner (addEmptyDir) als 0755.
     * Jedes Ziel wird per within() gegen $base abgesichert; '..'-Member werden verworfen. Liefert
     * false, wenn ein Member scheiterte ODER (als existiert vermerkter Eintrag) KEIN einziger
     * Member vorlag - dann rollt der Aufrufer sichtbar zurueck statt einen Leer-Restore zu melden.
     *
     * @param   ZipArchive  $zip         Offene Zip
     * @param   string      $memberBase  Member-Praefix `<root>/<rel>`
     * @param   string      $root        'admin'|'site' (Praefix-Laenge zum Abschneiden)
     * @param   string      $base        Absoluter Live-Wurzelpfad
     *
     * @return boolean
     */
    private static function zipRestoreMember($zip, $memberBase, $root, $base)
    {
        $count = $zip->numFiles;
        $pre   = $memberBase . '/';
        $ok    = true;
        $any   = false;

        for ($i = 0; $i < $count; $i++) {
            $name = $zip->getNameIndex($i);

            if (!is_string($name) || $name === '') {
                continue;
            }

            // Gehoert der Member zu diesem Eintrag? (exakt ODER unterhalb `<memberBase>/`)
            if ($name !== $memberBase && strpos($name, $pre) !== 0) {
                continue;
            }

            $any   = true;
            $isDir = (substr($name, -1) === '/');

            // Member ohne den fuehrenden `<root>/` -> relativer Ziel-Pfad unter $base.
            $memberRel = substr($name, strlen($root) + 1);

            if ($isDir) {
                $memberRel = rtrim($memberRel, '/');
            }

            if ($memberRel === '' || strpos($memberRel, '..') !== false || strpos($memberRel, "\0") !== false) {
                $ok = false;
                continue;
            }

            $dst = $base . '/' . $memberRel;

            if (!self::within($dst, $base)) {
                $ok = false;
                continue;
            }

            if ($isDir) {
                if (!is_dir($dst) && !@mkdir($dst, 0755, true) && !is_dir($dst)) {
                    $ok = false;
                }

                continue;
            }

            if (!self::zipExtractStream($zip, $i, $dst, 0644)) {
                $ok = false;
            }
        }

        return $ok && $any;
    }

    /**
     * Einen Zip-Eintrag (Index) speichersicher (streamend) nach $dstAbs schreiben - atomar via
     * temp+rename, danach chmod. Legt fehlende Zielordner (0755) an. Ohne Stream-Unterstuetzung
     * Fallback auf getFromIndex (unsere Backup-Dateien sind klein). true bei Erfolg.
     *
     * @param   ZipArchive  $zip       Offene Zip
     * @param   int         $index     Eintrags-Index
     * @param   string      $dstAbs    Ziel-Dateipfad
     * @param   int         $fileMode  Dateirechte
     *
     * @return boolean
     */
    private static function zipExtractStream($zip, $index, $dstAbs, $fileMode)
    {
        $parent = dirname($dstAbs);

        if (!is_dir($parent) && !@mkdir($parent, 0755, true) && !is_dir($parent)) {
            return false;
        }

        $name = $zip->getNameIndex($index);
        $in   = is_string($name) ? @$zip->getStream($name) : false;
        $tmp  = $dstAbs . '.' . getmypid() . '.tmp';

        if (!is_resource($in)) {
            // Fallback: ganzen Inhalt lesen (0-Byte -> '' ist gueltig, NICHT false).
            $data = $zip->getFromIndex($index);

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

            if (@file_put_contents($tmp, $data, LOCK_EX) === false) {
                @unlink($tmp);

                return false;
            }

            @chmod($tmp, $fileMode);

            if (@rename($tmp, $dstAbs)) {
                return true;
            }

            @unlink($tmp);

            return false;
        }

        $out = @fopen($tmp, 'wb');

        if (!$out) {
            @fclose($in);

            return false;
        }

        $good = true;

        while (!feof($in)) {
            $chunk = @fread($in, 262144);

            if ($chunk === false) { $good = false; break; }
            if ($chunk === '') break;
            if (@fwrite($out, $chunk) === false) { $good = false; break; }
        }

        @fclose($in);

        if (!@fclose($out)) {
            $good = false;
        }

        if ($good) {
            @chmod($tmp, $fileMode);

            if (@rename($tmp, $dstAbs)) {
                return true;
            }
        }

        @unlink($tmp);

        return false;
    }

    /**
     * Gespeicherte Snapshots einer Erweiterung (neueste zuerst).
     *
     * @param   int  $eid  Extension-ID
     *
     * @return array Liste der Meta-Datensaetze (inkl. 'id')
     */
    public static function listSnapshots($eid)
    {
        $eid = (int) $eid;

        if ($eid <= 0) {
            return array();
        }

        $rootDir = self::backupRoot();

        if ($rootDir === '') {
            return array();
        }

        $extDir = $rootDir . '/' . $eid;

        if (!is_dir($extDir)) {
            return array();
        }

        $out = array();

        foreach ((array) @scandir($extDir) as $name) {
            if ($name === '.' || $name === '..' || !preg_match(self::SNAP_RE, $name)) {
                continue;
            }

            $meta = self::readMeta($eid, $name);

            if ($meta !== null) {
                $meta['id'] = $name;
                $out[]      = $meta;
            }
        }

        usort($out, array(__CLASS__, 'cmpTsDesc'));

        return $out;
    }

    /**
     * Alle Extension-IDs, fuer die ueberhaupt Snapshots vorliegen (fuer die
     * Rollback-Liste der UI). Nur numerische Unterordner.
     *
     * @return array Liste positiver Ganzzahlen
     */
    public static function listExtIds()
    {
        $rootDir = self::backupRoot();

        if ($rootDir === '') {
            return array();
        }

        $out = array();

        foreach ((array) @scandir($rootDir) as $name) {
            if ($name === '.' || $name === '..') {
                continue;
            }

            if (preg_match('~^[1-9][0-9]*$~', $name) && is_dir($rootDir . '/' . $name)) {
                $out[] = (int) $name;
            }
        }

        return $out;
    }

    /**
     * Alle Snapshots einer Erweiterung loeschen (Aufraeumen, z. B. wenn die
     * Erweiterung deinstalliert wurde).
     *
     * @param   int  $eid  Extension-ID
     *
     * @return boolean
     */
    public static function deleteAll($eid)
    {
        $eid = (int) $eid;

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

        $rootDir = self::backupRoot();

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

        $dir = $rootDir . '/' . $eid;

        return is_dir($dir) ? self::rmTree($dir) : true;
    }

    /**
     * Verwaiste + partielle Backups aufraeumen (taeglich vom Waechter angestossen):
     *  - Snapshot-Ordner von Erweiterungen, die es nicht mehr gibt (deinstalliert) -> ganz weg.
     *  - Meta-lose Snapshot-Ordner (Crash/Timeout mitten im Anlegen, aelter als PARTIAL_GRACE) -> weg.
     *  - danach unreferenzierte CAS-Blobs freigeben.
     * Ohne DB-Liste werden KEINE eid-Ordner geloescht (nur die eindeutig partiellen).
     * Best effort; darf nie hart fehlschlagen.
     *
     * @return void
     */
    public static function sweepOrphans()
    {
        $root = self::backupRoot();

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

        // Liste aller aktuell installierten Erweiterungs-IDs (null, wenn nicht ermittelbar).
        $alive = null;
        $db    = function_exists('htp_db') ? htp_db() : null;

        if ($db) {
            try {
                $db->setQuery(
                    $db->getQuery(true)
                        ->select($db->quoteName('extension_id'))
                        ->from($db->quoteName('#__extensions'))
                );
                $alive = array();

                foreach ((array) $db->loadColumn() as $id) {
                    $alive[(int) $id] = true;
                }
            } catch (Exception $e) {
                $alive = null;
            } catch (Throwable $e) {
                $alive = null;
            }
        }

        $now = time();

        foreach (self::listExtIds() as $eid) {
            // 1) Tote Erweiterung -> kompletten Backup-Ordner entfernen.
            if (is_array($alive) && !isset($alive[$eid])) {
                self::deleteAll($eid);

                continue;
            }

            // 2) Partielle/abgestuerzte Snapshots (kein meta.json, aelter als Karenz) entfernen.
            $extDir = $root . '/' . $eid;

            foreach ((array) @scandir($extDir) as $name) {
                if ($name === '.' || $name === '..' || !preg_match(self::SNAP_RE, $name)) {
                    continue;
                }

                $snapDir = $extDir . '/' . $name;

                if (is_file($snapDir . '/meta.json')) {
                    continue; // vollstaendig
                }

                $mt = @filemtime($snapDir);

                if ($mt !== false && ($now - $mt) < self::PARTIAL_GRACE) {
                    continue; // evtl. gerade in Arbeit -> in Ruhe lassen
                }

                self::rmTree($snapDir);
            }
        }

        // 3) Jetzt nicht mehr referenzierte CAS-Blobs freigeben (idempotenter Mark-Sweep).
        self::casGc();
    }

    /**
     * Meta-Datensatz eines Snapshots lesen (oder null).
     *
     * @param   int     $eid     Extension-ID
     * @param   string  $snapId  Snapshot-ID
     *
     * @return array|null
     */
    public static function readMeta($eid, $snapId)
    {
        $eid = (int) $eid;

        if ($eid <= 0 || !preg_match(self::SNAP_RE, (string) $snapId)) {
            return null;
        }

        $rootDir = self::backupRoot();

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

        $file = $rootDir . '/' . $eid . '/' . $snapId . '/meta.json';

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

        $json = json_decode((string) @file_get_contents($file), true);

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

    /**
     * Rollback: die Dateien aus einem Snapshot byte-genau wiederherstellen und
     * vom Update neu angelegte (im Snapshot NICHT vorhandene) Pfade entfernen.
     * Pfade werden IMMER frisch aus root+rel des Snapshots abgeleitet (nie
     * gespeicherte absolute Pfade) und vor jeder Operation revalidiert.
     *
     * @param   int      $eid        Extension-ID
     * @param   string   $snapId     Snapshot-ID
     * @param   boolean  $restoreDb  Auch die gesicherten DB-Tabellen wiederherstellen
     *                               (Auto-Rollback: immer; manuell: opt-in)
     *
     * @return array ['ok' => bool, 'restored' => int, 'removed' => int, 'errors' => array,
     *                'db' => array|null]
     */
    public static function rollback($eid, $snapId, $restoreDb = false)
    {
        $res  = array('ok' => false, 'restored' => 0, 'removed' => 0, 'errors' => array(), 'db' => null);
        $meta = self::readMeta($eid, $snapId);

        if ($meta === null || empty($meta['paths']) || !is_array($meta['paths'])) {
            $res['errors'][] = 'meta';

            return $res;
        }

        $rootDir  = self::backupRoot();
        $snapDir  = $rootDir . '/' . ((int) $eid) . '/' . $snapId;
        $filesDir = $snapDir . '/files';
        $ok       = true;
        $self     = !empty($meta['self']);
        // Ist dies ein Zip-Snapshot (EINE snap.htpz) oder das Alt-/Fallback-`files/`-Format?
        $isZip    = (isset($meta['fmt']) && $meta['fmt'] === 'zip') || is_file($snapDir . '/' . self::SNAP_ZIP);
        // Selbst-Update: den data/-Ordner (= die Backups selbst) NIE loeschen/ueber-
        // schreiben, sonst zerstoert der Rollback seine eigene Grundlage (Ouroboros).
        // Fremd-Update: die im Snapshot eingefrorenen Backup-/Datenordner (Akeeba,
        // EJB, generisch) beim Restore NIE loeschen -> Website-Backups bleiben erhalten.
        $excludeAbs = $self
            ? self::selfExcludeList()
            : self::excludeAbsList(isset($meta['excludes']) ? $meta['excludes'] : array());

        // Zip-Snapshots brauchen ZipArchive zum Restaurieren. Fehlt es (extrem selten, z. B. nach
        // Umzug auf einen zip-losen Host), ehrlich als Fehler melden - nie stillschweigend leer.
        $zip = null;

        if ($isZip) {
            if (!class_exists('ZipArchive')) {
                $res['errors'][] = 'zip-noext';

                return $res;
            }

            $zip = new ZipArchive();

            if ($zip->open($snapDir . '/' . self::SNAP_ZIP) !== true) {
                $res['errors'][] = 'zip-open';

                return $res;
            }
        }

        foreach ($meta['paths'] as $entry) {
            if (!is_array($entry) || !isset($entry['root'], $entry['rel'])) {
                continue;
            }

            $root = ($entry['root'] === 'admin') ? 'admin' : 'site';
            $rel  = (string) $entry['rel'];

            if (!($self ? self::relSafeSelf($rel) : self::relSafe($rel))) {
                $ok = false;
                $res['errors'][] = $rel;
                continue;
            }

            $base    = self::absBase($root);
            $liveAbs = $base . '/' . $rel;

            if (!self::within($liveAbs, $base)) {
                $ok = false;
                $res['errors'][] = $rel;
                continue;
            }

            // 1) Live-Stand entfernen (chmod-gehaertet; data/ bleibt unangetastet).
            if (is_dir($liveAbs) || is_file($liveAbs)) {
                if (!self::rmTree($liveAbs, $excludeAbs)) {
                    $ok = false;
                    $res['errors'][] = $rel;
                    // weiter: ein evtl. Teil-Restore ist besser als Abbruch
                }
            }

            if (!empty($entry['existed'])) {
                // 2) Aus dem Snapshot wiederherstellen. Restore in den LIVE-Baum: normale
                //    Joomla-Rechte (0755 Ordner / 0644 Dateien) - NICHT die privaten Snapshot-
                //    Rechte. Sonst 403 auf CSS/JS/Fonts, wenn der Webserver als anderer User
                //    laeuft als der PHP-Owner (suexec). data/ bleibt unangetastet (der Snapshot
                //    enthaelt es nie -> der Restore fasst live data/ nie an).
                if ($isZip) {
                    // ZIP: alle Member unter `<root>/<rel>` streamend nach $base/<...> schreiben
                    // (within-Check je Ziel; 0755/0644).
                    if (self::zipRestoreMember($zip, $root . '/' . $rel, $root, $base)) {
                        $res['restored']++;
                    } else {
                        $ok = false;
                        $res['errors'][] = $rel;
                    }
                } else {
                    // FALLBACK/ALT: entpackender `files/`-Restore-Baum bzw. Einzeldatei
                    // (KOMPRIMIERT PACK_EXT -> entpacken; Alt-Klartext 1:1).
                    $srcBase = $filesDir . '/' . $root . '/' . $rel;
                    $bytes   = 0;

                    if (is_dir($srcBase)) {
                        if (self::copyInto($srcBase, $liveAbs, $bytes, '', 0755, 0644, 'unpack')) {
                            $res['restored']++;
                        } else {
                            $ok = false;
                            $res['errors'][] = $rel;
                        }
                    } else {
                        $srcPacked = self::packedPath($srcBase);

                        if ($srcPacked !== '') {
                            if (self::unpackOne($srcPacked, $liveAbs, 0644)) {
                                $res['restored']++;
                            } else {
                                $ok = false;
                                $res['errors'][] = $rel;
                            }
                        }
                        // sonst: als existiert vermerkt, aber kein Backup-File -> nichts (wie bisher)
                    }
                }
            } else {
                // Pfad existierte vor dem Update NICHT -> bleibt entfernt.
                $res['removed']++;
            }
        }

        if ($zip !== null) {
            $zip->close();
        }

        // Versions-Zeiger angleichen: nach dem Datei-Rollback liegt die ALTE
        // Manifest-XML wieder auf Platte, aber #__extensions.manifest_cache nennt noch
        // die neuere Version (Datei alt, DB-Zeiger neu = inkonsistent -> Joomla zeigt
        // die falsche Version + bietet das Update nicht erneut an). Joomlas
        // refreshManifestCache liest die XML neu. Gilt fuer Ziel + Paket-Kinder. Die
        // extension-eigenen DATEN-Tabellen bleiben opt-in (unten, $restoreDb) - der
        // Versions-Zeiger gehoert hingegen IMMER zum Datei-Rollback. Self-Update
        // (com_htprotect) ausgenommen (eigener, in-memory-/Ouroboros-sensibler Pfad).
        if (!$self) {
            // Phase B: Datei-Kaskade fremder Erweiterungen aus dem CAS wiederherstellen
            // (diff-basiert) - faengt Frameworks, die beim Update Plugin/Modul/Captcha
            // mit-ersetzen (Astroid). NUR fuer Alt-/Fallback-Snapshots: der Zip-Snapshot
            // sichert das Oekosystem bereits selbst (kein separater Wide/CAS mehr). Ohne
            // Wide-Backup (neue Fallback-Snapshots): no-op.
            if (!$isZip) {
                $wide        = self::restoreWide((int) $eid, $snapId);
                $res['wide'] = $wide;

                if (empty($wide['ok'])) {
                    $res['errors'][] = 'wide';
                }
            }

            // Phase A: Registry exakt zuruecksetzen - revidiert Versionen UND ID-Wechsel
            // der ganzen Kaskade (extension-agnostisch, robust auch bei Astroid-artigen
            // Frameworks mit Library-ID-Wechsel). refreshExtensionVersion bleibt als
            // Fallback, falls kein Registry-Backup vorliegt (alte Snapshots).
            self::restoreRegistry((int) $eid, $snapId);
            self::refreshExtensionVersion((int) $eid);
        }

        // Optional die DB-Tabellen wiederherstellen (Schritt 2). Beeinflusst das
        // Datei-ok nicht direkt; eigenes Teil-Ergebnis in $res['db'].
        if ($restoreDb && self::hasDbBackup($eid, $snapId)) {
            $dbRes      = self::restoreDb($eid, $snapId);
            $res['db']  = $dbRes;

            if (empty($dbRes['ok'])) {
                $ok = false;
                $res['errors'][] = 'db';
            }
        }

        $res['ok'] = $ok;

        return $res;
    }

    /**
     * Den Versions-Zeiger (#__extensions.manifest_cache) einer Erweiterung + ihrer
     * Paket-Kinder an den ON-DISK-Stand angleichen. Nach einem Datei-Rollback liegt
     * die alte Manifest-XML wieder auf Platte; ohne Auffrischen meldet Joomla weiter
     * die neuere Version (inkonsistent -> falsche Anzeige, Update wird nicht erneut
     * angeboten). Joomlas Installer::refreshManifestCache liest die XML je Erweiterung
     * neu und schreibt die korrekte Version zurueck. Best-effort, fail-open.
     *
     * @param   int  $eid  Extension-ID (Ziel; bei Paketen werden Kinder mit-aufgefrischt)
     *
     * @return void
     */
    private static function refreshExtensionVersion($eid)
    {
        $eid = (int) $eid;
        $db  = function_exists('htp_db') ? htp_db() : null;

        if ($eid <= 0 || !$db) {
            return;
        }

        try {
            $ids = array($eid);

            // Paket-Kinder mit auffrischen (deren Versionen wurden mit-gebumpt).
            $q = $db->getQuery(true)
                ->select($db->quoteName('extension_id'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('package_id') . ' = ' . $eid);
            $db->setQuery($q, 0, 200);

            foreach ((array) $db->loadColumn() as $cid) {
                $ids[] = (int) $cid;
            }

            $installer = class_exists('Joomla\\CMS\\Installer\\Installer')
                ? Joomla\CMS\Installer\Installer::getInstance() : null;

            foreach (array_unique($ids) as $id) {
                if ($id <= 0) {
                    continue;
                }

                // 1) Joomla-nativ (frischt den ganzen manifest_cache auf).
                if ($installer && method_exists($installer, 'refreshManifestCache')) {
                    try {
                        $installer->refreshManifestCache($id);
                    } catch (Exception $e) {
                        // egal
                    } catch (Throwable $e) {
                        // egal
                    }
                }

                // 2) Robust nachziehen: die manifest_cache-VERSION direkt an die On-Disk-
                //    XML angleichen. refreshManifestCache greift z. B. bei LIBRARY-Typen
                //    NICHT - sonst bliebe der Versions-Zeiger nach dem Rollback falsch
                //    stehen (Joomla zeigt die neue Version, bietet das Update nicht neu an).
                self::syncManifestCacheVersion($db, $id);
            }
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open
        }
    }

    /**
     * Die VERSION im manifest_cache einer Erweiterung direkt an die On-Disk-Manifest-
     * XML angleichen (typ-agnostisch, also auch fuer Libraries, wo Joomlas
     * refreshManifestCache nicht greift). Nur die Version wird angepasst; schon
     * synchron -> no-op. Best-effort, fail-open.
     *
     * @param   object  $db   DB
     * @param   int     $eid  Extension-ID
     *
     * @return void
     */
    private static function syncManifestCacheVersion($db, $eid)
    {
        if (!class_exists('HtpDefs')) {
            return;
        }

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName(array('type', 'element', 'folder', 'client_id', 'manifest_cache')))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('extension_id') . ' = ' . (int) $eid);
            $db->setQuery($q);
            $row = $db->loadAssoc();

            if (!is_array($row)) {
                return;
            }

            $disk = HtpDefs::onDiskVersion(
                isset($row['type']) ? (string) $row['type'] : '',
                isset($row['element']) ? (string) $row['element'] : '',
                isset($row['folder']) ? (string) $row['folder'] : '',
                isset($row['client_id']) ? (int) $row['client_id'] : 0
            );

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

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

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

            if (isset($mc['version']) && (string) $mc['version'] === (string) $disk) {
                return; // schon synchron
            }

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

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

    /**
     * VOLLSTAENDIGKEIT eines Snapshots verifizieren (plan-Haertung: "kein
     * verlaessliches Backup -> kein Update"). Geht den LIVE-Stand der gesicherten
     * Pfade durch und prueft, dass JEDE vorhandene Datei eine groessen-gleiche
     * Kopie im Snapshot hat. Schlaegt fehl -> der Aufrufer ueberspringt das Update
     * (nichts wagen, was wir nicht sicher zurueckrollen koennen).
     *
     * @param   int     $eid     Extension-ID
     * @param   string  $snapId  Snapshot-ID
     *
     * @return boolean
     */
    public static function verify($eid, $snapId)
    {
        $meta = self::readMeta($eid, $snapId);

        if ($meta === null || empty($meta['paths']) || !is_array($meta['paths'])) {
            return false;
        }

        $rootDir  = self::backupRoot();

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

        $snapDir  = $rootDir . '/' . ((int) $eid) . '/' . $snapId;
        $filesDir = $snapDir . '/files';
        $self     = !empty($meta['self']);
        $isZip    = (isset($meta['fmt']) && $meta['fmt'] === 'zip') || is_file($snapDir . '/' . self::SNAP_ZIP);
        // Dieselbe (im Snapshot eingefrorene) Ausschlussliste wie beim Sichern -> die
        // Backup-/Datenordner fehlen im Snapshot und werden hier auch nicht erwartet.
        $excludeAbs = $self
            ? self::selfExcludeList()
            : self::excludeAbsList(isset($meta['excludes']) ? $meta['excludes'] : array());

        // Bewusst ausgeschlossene bekannte Schaddateien ebenfalls von der Vollstaendigkeits-Pruefung
        // ausnehmen: sie liegen absichtlich in KEINEM Backup-Member und duerfen nicht als "fehlend"
        // gelten (sonst blockiert verify dauerhaft das Update einer bereits infizierten Erweiterung).
        if (isset($meta['mal_abs']) && is_array($meta['mal_abs']) && !empty($meta['mal_abs'])) {
            $excludeAbs = array_merge((array) $excludeAbs, array_values($meta['mal_abs']));
        }

        // ── ZIP-Snapshot: Member-Groessen-Index gegen den LIVE-Stand pruefen ──
        if ($isZip) {
            if (!class_exists('ZipArchive')) {
                return false; // nicht verifizierbar -> Update sicherheitshalber ueberspringen
            }

            $zip = new ZipArchive();

            if ($zip->open($snapDir . '/' . self::SNAP_ZIP) !== true) {
                return false;
            }

            $sizes = self::zipSizeIndex($zip);
            $zip->close();

            foreach ($meta['paths'] as $entry) {
                if (!is_array($entry) || empty($entry['existed']) || !isset($entry['root'], $entry['rel'])) {
                    continue;
                }

                $root = ($entry['root'] === 'admin') ? 'admin' : 'site';
                $rel  = (string) $entry['rel'];

                if (!($self ? self::relSafeSelf($rel) : self::relSafe($rel))) {
                    return false;
                }

                $base    = self::absBase($root);
                $liveAbs = $base . '/' . $rel;
                $member  = $root . '/' . $rel;

                // Bewusst nicht gesichert (bekannte Schaddatei / Datenordner) -> nicht als fehlend werten.
                if (self::isExcluded($liveAbs, $excludeAbs)) {
                    continue;
                }

                if (is_file($liveAbs)) {
                    if (!isset($sizes[$member]) || $sizes[$member] !== (int) @filesize($liveAbs)) {
                        return false;
                    }
                } elseif (is_dir($liveAbs)) {
                    if (!self::zipTreeComplete($liveAbs, $member, $sizes, $excludeAbs)) {
                        return false;
                    }
                }
                // Live verschwunden seit dem Snapshot -> egal (die Zip hat den Stand).
            }

            return true;
        }

        foreach ($meta['paths'] as $entry) {
            if (!is_array($entry) || empty($entry['existed']) || !isset($entry['root'], $entry['rel'])) {
                continue;
            }

            $root = ($entry['root'] === 'admin') ? 'admin' : 'site';
            $rel  = (string) $entry['rel'];

            if (!($self ? self::relSafeSelf($rel) : self::relSafe($rel))) {
                return false;
            }

            $base    = self::absBase($root);
            $liveAbs = $base . '/' . $rel;
            $backAbs = $filesDir . '/' . $root . '/' . $rel;

            // Bewusst nicht gesichert (bekannte Schaddatei / Datenordner) -> nicht als fehlend werten.
            if (self::isExcluded($liveAbs, $excludeAbs)) {
                continue;
            }

            if (is_file($liveAbs)) {
                // Backup ist komprimiert -> Original-Groesse aus dem gzip-ISIZE-Trailer
                // (packedOrigSize), Alt-Klartext ueber filesize. -1 = kein Backup da.
                $bsz = self::packedOrigSize($backAbs);

                if ($bsz < 0 || $bsz !== (int) @filesize($liveAbs)) {
                    return false;
                }
            } elseif (is_dir($liveAbs)) {
                if (!self::treeComplete($liveAbs, $backAbs, 0, $excludeAbs)) {
                    return false;
                }
            }
            // Live verschwunden seit dem Snapshot -> egal (Backup hat den Stand).
        }

        return true;
    }

    /**
     * Groessen-Index der Zip-Member: name => unkomprimierte Groesse (Ordner-Eintraege raus).
     * Fuer den Vollstaendigkeits-Vergleich in verify() (OHNE Entpacken).
     *
     * @param   ZipArchive  $zip  Offene Zip
     *
     * @return array
     */
    private static function zipSizeIndex($zip)
    {
        $out   = array();
        $count = $zip->numFiles;

        for ($i = 0; $i < $count; $i++) {
            $st = $zip->statIndex($i);

            if (!is_array($st) || !isset($st['name'])) {
                continue;
            }

            $name = (string) $st['name'];

            if ($name === '' || substr($name, -1) === '/') {
                continue; // Ordner-Eintrag
            }

            $out[$name] = isset($st['size']) ? (int) $st['size'] : -1;
        }

        return $out;
    }

    /**
     * Hat jede (Nicht-Symlink-)Datei unter $live einen groessen-gleichen Zip-Member unter dem
     * Praefix $memberBase? (Zip-Pendant zu treeComplete fuer verify(); Symlinks + ausgeschlossene
     * Datenordner uebersprungen.)
     *
     * @param   string  $live        Live-Ordner
     * @param   string  $memberBase  Member-Praefix `<root>/<rel>`
     * @param   array   $sizes       Member-Groessen-Index (zipSizeIndex)
     * @param   mixed   $excludeAbs  Ausgeschlossene Pfade
     * @param   int     $depth       Rekursionstiefe
     *
     * @return boolean
     */
    private static function zipTreeComplete($live, $memberBase, $sizes, $excludeAbs, $depth = 0)
    {
        if ($depth > 64) {
            return false;
        }

        $items = @scandir($live);

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

        foreach ($items as $name) {
            if ($name === '.' || $name === '..' || is_link($live . '/' . $name)) {
                continue;
            }

            $l = $live . '/' . $name;
            $m = $memberBase . '/' . $name;

            if (!empty($excludeAbs) && self::isExcluded($l, $excludeAbs)) {
                continue; // Datenordner (data/ bzw. Akeeba/EJB) -> nicht in der Zip erwartet
            }

            if (is_file($l)) {
                if (!isset($sizes[$m]) || $sizes[$m] !== (int) @filesize($l)) {
                    return false;
                }
            } elseif (is_dir($l)) {
                if (!self::zipTreeComplete($l, $m, $sizes, $excludeAbs, $depth + 1)) {
                    return false;
                }
            }
        }

        return true;
    }

    /**
     * Hat jede (Nicht-Symlink-)Datei unter $live eine groessen-gleiche Kopie unter
     * $back? (Vollstaendigkeits-Helfer fuer verify(); Symlinks werden - wie beim
     * Kopieren - uebersprungen.)
     *
     * @param   string  $live        Live-Ordner
     * @param   string  $back        Backup-Ordner
     * @param   int     $depth       aktuelle Tiefe
     * @param   string  $excludeAbs  ausgeschlossener Pfad (Selbst-Update: data/)
     *
     * @return boolean
     */
    private static function treeComplete($live, $back, $depth = 0, $excludeAbs = '')
    {
        if ($depth > 64) {
            return false;
        }

        if (!is_dir($back)) {
            return false;
        }

        $items = @scandir($live);

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

        foreach ($items as $name) {
            if ($name === '.' || $name === '..' || is_link($live . '/' . $name)) {
                continue;
            }

            $l = $live . '/' . $name;
            $b = $back . '/' . $name;

            // Ausgeschlossen (data/ bzw. Backup-/Datenordner) -> nicht im Backup erwartet.
            if (!empty($excludeAbs) && self::isExcluded($l, $excludeAbs)) {
                continue;
            }

            if (is_file($l)) {
                // Backup komprimiert -> Original-Groesse via packedOrigSize (gzip-ISIZE /
                // Klartext-filesize). -1 = kein passendes Backup vorhanden.
                $bsz = self::packedOrigSize($b);

                if ($bsz < 0 || $bsz !== (int) @filesize($l)) {
                    return false;
                }
            } elseif (is_dir($l)) {
                if (!self::treeComplete($l, $b, $depth + 1, $excludeAbs)) {
                    return false;
                }
            }
        }

        return true;
    }

    /**
     * Aufbewahrung durchsetzen: alte Snapshots ueber das Limit hinaus loeschen.
     *
     * @param   int  $eid   Extension-ID
     * @param   int  $keep  Wie viele behalten (mind. 1)
     *
     * @return int  Anzahl geloeschter Snapshots
     */
    public static function prune($eid, $keep)
    {
        $eid = (int) $eid;

        if ($eid <= 0) {
            return 0;
        }

        $snaps   = self::listSnapshots($eid);
        $rootDir = self::backupRoot();

        if ($rootDir === '') {
            return 0;
        }

        $dropped = 0;

        foreach (self::pruneList($snaps, $keep) as $id) {
            if (preg_match(self::SNAP_RE, $id) && self::rmTree($rootDir . '/' . $eid . '/' . $id)) {
                $dropped++;
            }
        }

        // Phase B: nach dem Loeschen alter Snapshots verwaiste CAS-Inhalte freigeben.
        if ($dropped > 0) {
            self::casGc();
        }

        return $dropped;
    }

    /**
     * Globale Aufraeum-Retention ueber ALLE Erweiterungen (zusaetzlich zum per-eid keep): begrenzt die
     * GESAMTZAHL der Rollback-Punkte auf hoechstens GLOBAL_KEEP (aelteste zuerst raus) und entfernt
     * alles aelter als MAX_AGE_DAYS. Laeuft taeglich automatisch aus dem Waechter-Tick. Grund: der
     * per-eid keep begrenzt nur PRO Erweiterung, nicht die ANZAHL der Erweiterungen - jede je auto-
     * aktualisierte bekommt einen eigenen Rollback-Punkt, sodass der Speicher sonst mit dem Bestand
     * unbegrenzt waechst. Ein Rollback ist praktisch immer fuer ein GERADE schiefgelaufenes (junges)
     * Update; uralte Rollback-Punkte laengst weiter-aktualisierter Erweiterungen braucht niemand.
     * Nach dem Loeschen CAS-Mark-Sweep. Best-effort, fail-open.
     *
     * @return int  Anzahl entfernter Rollback-Punkte
     */
    public static function capGlobal()
    {
        $rootDir = self::backupRoot();

        if ($rootDir === '') {
            return 0;
        }

        // Alle Rollback-Punkte ueber ALLE eids einsammeln: {eid, id, ts}.
        $all = array();

        foreach (self::listExtIds() as $eid) {
            foreach (self::listSnapshots($eid) as $snap) {
                $id = isset($snap['id']) ? (string) $snap['id'] : '';

                if ($id === '' || !preg_match(self::SNAP_RE, $id)) {
                    continue;
                }

                $all[] = array(
                    'eid' => (int) $eid,
                    'id'  => $id,
                    'ts'  => isset($snap['ts']) ? (int) $snap['ts'] : 0,
                );
            }
        }

        if (empty($all)) {
            return 0;
        }

        // Neueste zuerst (grosses ts = jung); Tie-Break ueber die zeitsortierte Snapshot-ID.
        usort($all, function ($a, $b) {
            if ($a['ts'] !== $b['ts']) {
                return $b['ts'] < $a['ts'] ? -1 : 1;
            }

            return strcmp($b['id'], $a['id']);
        });

        $ageCut  = time() - self::MAX_AGE_DAYS * 86400;
        $dropped = 0;
        $rank    = 0;

        foreach ($all as $s) {
            $overCap = ($rank >= self::GLOBAL_KEEP);
            $tooOld  = ($s['ts'] > 0 && $s['ts'] < $ageCut);

            if ($overCap || $tooOld) {
                if (self::rmTree($rootDir . '/' . $s['eid'] . '/' . $s['id'])) {
                    $dropped++;
                }

                continue;
            }

            $rank++;
        }

        // Verwaiste CAS-Inhalte der eben entfernten Rollback-Punkte freigeben.
        if ($dropped > 0) {
            self::casGc();
        }

        return $dropped;
    }

    /**
     * EINMALIGE Alt-Format-Bereinigung (idempotent via Marker SLIM_MARKER). Ab 2.5.9 liegt jeder
     * Rollback-Punkt als EINE snap.htpz (oder - ohne ZipArchive - als schlanker Fallback-`files/`-
     * Baum mit meta['fmt']='files') vor. Diese Routine raeumt den ALTEN, verschwenderischen Bestand:
     *   - Snapshots im VOR-2.5.9-Format (`files/`-Baum PLUS getrennter Wide-Index/CAS; erkennbar an
     *     fehlender snap.htpz UND fehlendem meta['fmt']) -> ganz weg (Rollback-Historie darf laut
     *     Vorgabe verworfen werden).
     *   - den GETEILTEN CAS-Ordner (<root>/cas) -> weg (nur vom Alt-Wide-Backup referenziert).
     * NEUE schlanke Snapshots (fmt='zip' ODER fmt='files') bleiben unangetastet - der Fallback-
     * `files/`-Baum ist KEIN Ballast (kein CAS, nur das schmale Ziel), sein Verwerfen wuerde einem
     * zip-losen Host frisch erzeugte Rollback-Punkte nehmen. Best-effort, fail-open.
     *
     * @return int  Anzahl entfernter Alt-Format-Snapshots
     */
    public static function migrateSlim()
    {
        $rootDir = self::backupRoot();

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

        $marker = $rootDir . '/' . self::SLIM_MARKER;

        if (is_file($marker)) {
            return 0; // bereits migriert -> No-Op
        }

        $removed = 0;

        foreach (self::listExtIds() as $eid) {
            $extDir = $rootDir . '/' . $eid;

            foreach ((array) @scandir($extDir) as $name) {
                if ($name === '.' || $name === '..' || !preg_match(self::SNAP_RE, $name)) {
                    continue;
                }

                $snapDir = $extDir . '/' . $name;

                // Zip-Snapshot -> behalten.
                if (is_file($snapDir . '/' . self::SNAP_ZIP)) {
                    continue;
                }

                // Neuer schlanker Fallback-`files/`-Snapshot (fmt='files') -> behalten.
                $meta = self::readMeta($eid, $name);

                if (is_array($meta) && isset($meta['fmt']) && $meta['fmt'] === 'files') {
                    continue;
                }

                // Alles andere (kein fmt-Feld = VOR-2.5.9-Wide/CAS-Format, oder markerlos-partiell) -> weg.
                if (self::rmTree($snapDir)) {
                    $removed++;
                }
            }
        }

        // Den geteilten CAS-Ordner freigeben (nur vom Alt-Wide-Backup referenziert).
        $cas = self::casDir();

        if ($cas !== '' && is_dir($cas)) {
            self::rmTree($cas);
        }

        // Marker setzen -> die Bereinigung laeuft nie erneut.
        if (@file_put_contents($marker, (string) time(), LOCK_EX) !== false) {
            @chmod($marker, 0600);
        }

        return $removed;
    }

    // ─────────────────────────────────────────────────────────────────
    //  Interne FS-Helfer
    // ─────────────────────────────────────────────────────────────────

    /**
     * Version fuer die Snapshot-ID saeubern (Whitelist, gekappt, nie leer).
     *
     * @param   string  $version  Rohwert
     *
     * @return string
     */
    public static function sanitizeVersion($version)
    {
        $v = preg_replace('~[^A-Za-z0-9._\-]~', '', (string) $version);
        $v = substr((string) $v, 0, 40);

        return $v === '' ? '0' : $v;
    }

    // ─────────────────────────────────────────────────────────────────
    //  Backup-Pack-Schicht: Backup-Dateiinhalte werden KOMPRIMIERT (gzip) abgelegt.
    //  Eine Gzip-Datei ist ein opaker Blob -> generische Fremd-Malware-Scanner
    //  (mySites.guru etc.) matchen keine Code-/Text-Muster mehr in den legitimen
    //  Sicherungen und flaggen sie nicht faelschlich als „gehackt". Gilt fuer den
    //  Snapshot-`files/`-Baum (Phase A) UND den CAS (Phase B). Voll rueckwaerts-
    //  kompatibel: Alt-Backups (Klartext, ohne PACK_EXT) bleiben restaurierbar.
    //  Streaming (gzopen/gzread) -> speichersicher auch bei grossen Dateien.
    // ─────────────────────────────────────────────────────────────────

    /** zlib-Streaming verfuegbar? Ohne -> Klartext-Fallback, das Backup faellt NIE aus. */
    private static function gzAvailable()
    {
        return function_exists('gzopen') && function_exists('gzread')
            && function_exists('gzwrite') && function_exists('gzeof');
    }

    /**
     * Original-(unkomprimierte-)Groesse einer gzip-Datei aus dem ISIZE-Trailer (letzte
     * 4 Byte, little-endian uint32) - OHNE Entpacken, fuer den Groessen-Vergleich in
     * verify(). Bei Dateien < 4 GB exakt (unsere Backup-Dateien sind klein). -1 bei Fehler.
     */
    private static function gzOrigSize($gzPath)
    {
        $fp = @fopen($gzPath, 'rb');

        if (!$fp) {
            return -1;
        }

        $ok = (@fseek($fp, -4, SEEK_END) === 0);
        $b  = $ok ? @fread($fp, 4) : false;
        @fclose($fp);

        if (!is_string($b) || strlen($b) !== 4) {
            return -1;
        }

        $u = @unpack('V', $b);

        return isset($u[1]) ? (int) $u[1] : -1;
    }

    /**
     * Die zu einem Basis-Pfad gehoerende Backup-Datei: bevorzugt komprimiert
     * ($base.PACK_EXT), ersatzweise Klartext ($base) fuer aeltere Backups. '' wenn keine.
     */
    private static function packedPath($base)
    {
        if (is_file($base . self::PACK_EXT)) {
            return $base . self::PACK_EXT;
        }

        if (is_file($base)) {
            return $base;
        }

        return '';
    }

    /** Original-Groesse der (evtl. gepackten) Backup-Datei zu $base; -1 wenn keine da. */
    private static function packedOrigSize($base)
    {
        if (is_file($base . self::PACK_EXT)) {
            return self::gzOrigSize($base . self::PACK_EXT);
        }

        if (is_file($base)) {
            return (int) @filesize($base);
        }

        return -1;
    }

    /**
     * Eine Datei KOMPRIMIERT ablegen: $srcAbs -> $dstBase.PACK_EXT (Streaming-gzip, atomar
     * via temp+rename, chmod). Ohne zlib Fallback auf Klartext $dstBase. Der Aufrufer fuehrt
     * Hash/Groesse stets ueber den ORIGINAL-Inhalt ($srcAbs). true bei Erfolg.
     */
    private static function packWrite($srcAbs, $dstBase, $fileMode)
    {
        if (self::gzAvailable()) {
            $dst = $dstBase . self::PACK_EXT;
            $tmp = $dst . '.' . getmypid() . '.tmp';
            $in  = @fopen($srcAbs, 'rb');
            $out = @gzopen($tmp, 'wb6');

            if ($in && $out) {
                $ok = true;

                while (!feof($in)) {
                    $chunk = @fread($in, 262144);

                    if ($chunk === false) { $ok = false; break; }
                    if ($chunk === '') break;
                    if (@gzwrite($out, $chunk) === false) { $ok = false; break; }
                }

                @fclose($in);

                // gzclose flusht den finalen deflate-Block + ISIZE-Trailer; ein Fehler dabei
                // (z. B. volle Platte erst beim Flush) darf NICHT als Erfolg durchgehen.
                if (!@gzclose($out)) {
                    $ok = false;
                }

                // ISIZE-Selbstpruefung: die im gzip-Trailer vermerkte Original-Groesse MUSS der
                // Quelle entsprechen. Faengt eine still trunkierte .htpgz ab, bevor sie committet
                // (und der Klartext-Zwilling geloescht) wird -> nie eine korrupte „gueltige" Sicherung.
                if ($ok) {
                    $orig = @filesize($srcAbs);

                    if ($orig === false || self::gzOrigSize($tmp) !== (int) $orig) {
                        $ok = false;
                    }
                }

                if ($ok) {
                    @chmod($tmp, $fileMode);

                    if (@rename($tmp, $dst)) {
                        @unlink($dstBase); // evtl. Alt-Klartext desselben Pfades raeumen

                        return true;
                    }
                }

                @unlink($tmp);
            } else {
                if ($in) @fclose($in);
                if ($out) @gzclose($out);
            }
            // faellt bewusst durch auf Klartext, falls das Packen scheitert
        }

        // Klartext-Fallback - ATOMAR (temp+rename), damit ein Abbruch mittendrin nie einen
        // trunkierten Blob am kanonischen Pfad hinterlaesst (den die CAS-Dedup sonst per blosser
        // Existenz als gueltig ansaehe).
        $ptmp = $dstBase . '.' . getmypid() . '.plain.tmp';

        if (@copy($srcAbs, $ptmp)) {
            @chmod($ptmp, $fileMode);

            if (@rename($ptmp, $dstBase)) {
                @unlink($dstBase . self::PACK_EXT); // evtl. Alt-gz desselben Pfades raeumen

                return true;
            }
        }

        @unlink($ptmp);

        return false;
    }

    /**
     * Eine (evtl. gepackte) Backup-Datei nach $dstAbs ENTPACKEN. Endet $srcPacked auf
     * PACK_EXT -> gzip-Streaming entpacken; sonst (Alt-Backup ohne Suffix / kein zlib)
     * direkte Kopie. Atomar via temp+rename, chmod. true bei Erfolg.
     */
    private static function unpackOne($srcPacked, $dstAbs, $fileMode)
    {
        $isPacked = (substr($srcPacked, -strlen(self::PACK_EXT)) === self::PACK_EXT);

        if ($isPacked) {
            // Ein .htpgz-Blob ist IMMER echtes gzip (packWrite vergibt den Suffix nur im
            // gz-Zweig). Ohne zlib laesst er sich NICHT entpacken - ein Roh-Copy wuerde die
            // KOMPRIMIERTEN Bytes als Live-Datei schreiben (stiller Datenverlust, faelschlich
            // als Erfolg gemeldet). Deshalb hier FAIL-CLOSED: der Aufrufer (rollback/restoreWide)
            // meldet den Fehler ehrlich, statt eine gzip-verseuchte Datei zu hinterlassen.
            if (!self::gzAvailable()) {
                return false;
            }

            // Soll-Groesse aus dem gzip-ISIZE-Trailer - fuer die Vollstaendigkeits-Pruefung
            // nach dem Entpacken (gzread meldet eine Trunkierung NICHT zuverlaessig, sondern
            // liefert oft nur Teildaten + stilles EOF).
            $expect = self::gzOrigSize($srcPacked);

            $tmp = $dstAbs . '.' . getmypid() . '.tmp';
            $in  = @gzopen($srcPacked, 'rb');
            $out = @fopen($tmp, 'wb');

            if ($in && $out) {
                $ok      = true;
                $written = 0;

                while (!gzeof($in)) {
                    $chunk = @gzread($in, 262144);

                    if ($chunk === false) { $ok = false; break; } // korrupter Blob (CRC/Deflate-Fehler)
                    if ($chunk === '') break;
                    $w = @fwrite($out, $chunk);
                    if ($w === false || $w < strlen($chunk)) { $ok = false; break; }
                    $written += $w;
                }

                @gzclose($in);

                if (!@fclose($out)) {
                    $ok = false;
                }

                // Vollstaendigkeit: die entpackte Groesse MUSS der Trailer-Sollgroesse entsprechen.
                // Faengt trunkierte/korrupte Blobs, die gzread nur teilweise liefert -> NIE einen
                // Teil-Restore als Erfolg melden (der Aufrufer rollt dann sichtbar zurueck).
                if ($ok && ($expect < 0 || $written !== $expect)) {
                    $ok = false;
                }

                if ($ok) {
                    @chmod($tmp, $fileMode);

                    if (@rename($tmp, $dstAbs)) {
                        return true;
                    }
                }

                @unlink($tmp);

                return false;
            }

            if ($in) @gzclose($in);
            if ($out) @fclose($out);

            return false;
        }

        // NUR echte Klartext-Alt-Backups (ohne Suffix): direkte Kopie (byte-korrekt mit/ohne zlib).
        if (@copy($srcPacked, $dstAbs)) {
            @chmod($dstAbs, $fileMode);

            return true;
        }

        return false;
    }

    /**
     * Datei ODER Verzeichnis nach $dst kopieren (rekursiv), mit laufender
     * Byte-Zaehlung gegen das Cap. Legt fehlende Zielordner an.
     *
     * @param   string  $src         Quelle (Datei/Ordner)
     * @param   string  $dst         Ziel
     * @param   int     $bytes       Laufende Gesamtgroesse (Referenz)
     * @param   string  $excludeAbs  Absoluter Pfad, der (samt Inhalt) NIE kopiert wird
     *                               (Selbst-Update: der data/-Ordner = die Backups selbst)
     *
     * @return boolean  false bei Kopierfehler ODER Cap-Ueberschreitung
     */
    private static function copyInto($src, $dst, &$bytes, $excludeAbs = '', $dirMode = 0700, $fileMode = 0600, $mode = 'raw')
    {
        // Ausschluss: data/-Ordner (Selbst-Update) bzw. erweiterungs-eigene
        // Backup-/Datenordner (z. B. Akeeba/EJB) nie mitsichern.
        if (!empty($excludeAbs) && self::isExcluded($src, $excludeAbs)) {
            return true;
        }

        // $mode:  'raw'    - Datei 1:1 kopieren (Alt-Verhalten, weiter genutzt fuer beliebige Kopien).
        //         'pack'   - Snapshot-ERSTELLUNG: Datei KOMPRIMIERT als $dst.PACK_EXT ablegen.
        //         'unpack' - RESTORE: Backup-Datei ENTPACKT nach $dst schreiben (der Verzeichnis-
        //                    Walk hat den PACK_EXT-Suffix am Ziel-Dateinamen bereits abgestreift).
        // $dirMode/$fileMode: Snapshot-ERSTELLUNG bewusst PRIVAT (0700/0600, Default) - andere
        // Nutzer auf Shared Hosts duerfen die Backups nicht lesen. Beim RESTORE in den LIVE-Baum
        // MUSS der Aufrufer normale Rechte uebergeben (0755/0644), sonst 403 auf CSS/JS/Fonts.
        if (is_file($src)) {
            $size   = (int) @filesize($src);
            $parent = dirname($dst);

            if (!is_dir($parent)) {
                if (!@mkdir($parent, $dirMode, true) && !is_dir($parent)) {
                    return false;
                }

                @chmod($parent, $dirMode); // exakt setzen (mkdir ist umask-abhaengig)
            }

            if ($mode === 'pack') {
                // Cap gegen die ORIGINAL-Groesse (die komprimierte Sicherung ist kleiner).
                if (($bytes + $size) > self::MAX_BYTES) {
                    return false;
                }

                if (!self::packWrite($src, $dst, $fileMode)) {
                    return false;
                }
            } elseif ($mode === 'unpack') {
                if (!self::unpackOne($src, $dst, $fileMode)) {
                    return false;
                }
            } else {
                if (($bytes + $size) > self::MAX_BYTES) {
                    return false;
                }

                if (!@copy($src, $dst)) {
                    return false;
                }

                @chmod($dst, $fileMode);
            }

            $bytes += $size;

            return true;
        }

        if (!is_dir($src)) {
            // Symlink oder Sonderdatei -> bewusst ueberspringen (kein Fehler).
            return true;
        }

        if (!is_dir($dst)) {
            if (!@mkdir($dst, $dirMode, true) && !is_dir($dst)) {
                return false;
            }

            @chmod($dst, $dirMode); // exakt setzen (mkdir ist umask-abhaengig)
        }

        $items = @scandir($src);

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

        $ext  = self::PACK_EXT;
        $elen = strlen($ext);

        foreach ($items as $name) {
            if ($name === '.' || $name === '..') {
                continue;
            }

            $childSrc = $src . '/' . $name;

            // Symlinks nie folgen (Sicherheit + verhindert Schleifen).
            if (is_link($childSrc)) {
                continue;
            }

            // Beim ENTPACKEN traegt die Backup-Datei den PACK_EXT-Suffix; der Live-Zielname
            // ist der Name OHNE Suffix. Verzeichnisse + Alt-Klartext (ohne Suffix) bleiben 1:1.
            if ($mode === 'unpack' && substr($name, -$elen) === $ext && is_file($childSrc)) {
                $childDst = $dst . '/' . substr($name, 0, -$elen);
            } else {
                $childDst = $dst . '/' . $name;
            }

            if (!self::copyInto($childSrc, $childDst, $bytes, $excludeAbs, $dirMode, $fileMode, $mode)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Liegt $path auf oder INNERHALB von $excludeAbs? (Selbst-Update-Schutz fuer
     * den data/-Ordner = die Backups selbst.)
     *
     * @param   string  $path        Zu pruefender Pfad
     * @param   string  $excludeAbs  Auszuschliessender Wurzelpfad
     *
     * @return boolean
     */
    private static function isExcluded($path, $excludes)
    {
        $p = rtrim(str_replace('\\', '/', $path), '/');

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

        // $excludes darf ein einzelner Pfad (String) ODER eine Liste sein
        // (Selbst-Update-data/ = String; erweiterungs-eigene Datenordner = Liste).
        foreach ((array) $excludes as $ex) {
            $e = rtrim(str_replace('\\', '/', (string) $ex), '/');

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

            if ($p === $e || strpos($p . '/', $e . '/') === 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * Datei/Verzeichnis rekursiv loeschen - chmod-gehaertet (ein vom Angreifer
     * oder restriktivem Host auf z. B. 0555 gesetzter Ordner blockiert sonst das
     * unlink/rmdir lautlos). Setzt das Schreibbit temporaer, raeumt auf.
     *
     * @param   string  $path        Zielpfad
     * @param   string  $excludeAbs  Pfad, der (samt Inhalt) NIE geloescht wird
     *                               (Selbst-Update: der data/-Ordner = die Backups)
     *
     * @return boolean
     */
    private static function rmTree($path, $excludeAbs = '')
    {
        // KRITISCH: ausgeschlossene Ordner (Selbst-Update-data/ ODER erweiterungs-
        // eigene Backup-/Datenordner, z. B. Akeeba/EJB) beim Restore NIE loeschen -
        // weder als Ganzes noch als Teilbaum. Sonst waeren die Backups weg.
        if (!empty($excludeAbs) && self::isExcluded($path, $excludeAbs)) {
            return true;
        }

        if (is_link($path)) {
            return @unlink($path) || @rmdir($path);
        }

        if (is_file($path)) {
            if (@unlink($path)) {
                return true;
            }

            @chmod(dirname($path), 0755);
            @chmod($path, 0644);

            return @unlink($path);
        }

        if (!is_dir($path)) {
            return true; // schon weg
        }

        // Enthaelt der Ordner den ausgeschlossenen Pfad, darf er selbst NICHT per
        // rmdir entfernt werden (er muss bestehen bleiben, damit data/ ueberlebt).
        $containsExcluded = (!empty($excludeAbs) && self::contains($path, $excludeAbs));

        @chmod($path, 0755);
        $items = @scandir($path);

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

                self::rmTree($path . '/' . $name, $excludeAbs);
            }
        }

        if ($containsExcluded) {
            return true; // Ordner bewusst behalten (haelt den data/-Ordner am Leben)
        }

        if (@rmdir($path)) {
            return true;
        }

        @chmod(dirname($path), 0755);

        return @rmdir($path);
    }

    /**
     * Liegt $excludeAbs INNERHALB von $dir (ist $dir ein Vorfahre)?
     *
     * @param   string  $dir         Ordner
     * @param   string  $excludeAbs  Auszuschliessender Pfad
     *
     * @return boolean
     */
    private static function contains($dir, $excludes)
    {
        $d = rtrim(str_replace('\\', '/', $dir), '/');

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

        foreach ((array) $excludes as $ex) {
            $e = rtrim(str_replace('\\', '/', (string) $ex), '/');

            if ($e !== '' && $e !== $d && strpos($e . '/', $d . '/') === 0) {
                return true;
            }
        }

        return false;
    }

    // ═════════════════════════════════════════════════════════════════
    //  §7: HTProtect-Selbst-Update - data/-sicher (Ouroboros-Schutz)
    // ═════════════════════════════════════════════════════════════════

    /**
     * Die festen, com_htprotect gehoerenden Pfade (Selbst-Update). Hartkodiert -
     * KEIN Nutzereingabe-Element; bewusst getrennt von ownedPaths(), das
     * com_htprotect generell sperrt (Schutz des Datenordners bei FREMD-Rollbacks).
     *
     * @return array
     */
    private static function selfPaths()
    {
        return array(
            array('root' => 'admin', 'rel' => 'components/com_htprotect'),
            array('root' => 'site',  'rel' => 'components/com_htprotect'),
            array('root' => 'site',  'rel' => 'media/com_htprotect'),
        );
    }

    /**
     * Nur die festen com_htprotect-Selbst-Pfade erlauben (Whitelist). Verhindert,
     * dass je ein anderer Pfad ueber den Self-Zweig laeuft.
     *
     * @param   string  $rel  Relativer Pfad
     *
     * @return boolean
     */
    public static function relSafeSelf($rel)
    {
        return in_array(trim((string) $rel, '/'), array('components/com_htprotect', 'media/com_htprotect'), true);
    }

    /**
     * Absoluter, vom Selbst-Update IMMER auszuschliessender Pfad: der Datenordner
     * (htp_state_dir = .../com_htprotect/data) - er enthaelt die Backups selbst.
     *
     * @return string
     */
    public static function selfExcludeAbs()
    {
        $d = function_exists('htp_state_dir') ? htp_state_dir() : '';

        return $d === '' ? '' : rtrim(str_replace('\\', '/', $d), '/');
    }

    /**
     * Der zur Laufzeit erzeugte backups/-Ordner der Komponente
     * (.../administrator/components/com_htprotect/backups). Er enthaelt AUSSCHLIESSLICH
     * regenerierbare Laufzeit-Artefakte: das Kern-Referenz-Manifest (coremanifest-*.json; die
     * grosse coreref-*.zip ~30 MB liegt ab 2.5.10 in /administrator/_htprotect_backups/coreref),
     * Helix-/SP-Page-Builder-/Kernscan-Sicherungen und die malscan-*.json-Laufzustaende. NICHTS
     * davon ist Erweiterungs-Code -> gehoert NIE in
     * eine Rollback-Sicherung (sonst waechst jede Self-Update-Sicherung um zig MB Cache).
     * Direkt aus dem Admin-Root abgeleitet (identisch zu HtpStorage::backupDir() =
     * dirname(HTP_CORE)/backups), aber ohne Abhaengigkeit von HTP_CORE/HtpStorage - so
     * auch im Standalone-Test bestimmbar.
     *
     * @return string  Absoluter Pfad oder '' (wenn Admin-Root unbestimmbar)
     */
    public static function selfBackupDirAbs()
    {
        $ar = function_exists('htp_admin_root') ? (string) htp_admin_root() : '';

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

        return rtrim(str_replace('\\', '/', $ar), '/') . '/components/com_htprotect/backups';
    }

    /**
     * Vollstaendige Ausschlussliste fuer den Selbst-Update-Zweig (Snapshot, Verify,
     * Restore - an ALLEN drei Stellen dieselbe Liste). Enthaelt:
     *   1. data/ (htp_state_dir) - PFLICHT-Anker: die Rollback-Sicherungen selbst UND die
     *      .app-Dashboard-Website-Backups (backup.php speichert unter htp_state_dir). Ohne
     *      diesen Ausschluss wuerde die Sicherung sich selbst mitsichern (Ouroboros).
     *   2. backups/ (selfBackupDirAbs) - der regenerierbare Laufzeit-Cache/Sicherungs-Ordner.
     * Gibt array() zurueck, wenn der data/-Anker unbestimmbar ist -> der Aufrufer bricht ab
     * (ohne Ouroboros-Schutz NICHT wagen).
     *
     * @return array  Liste absoluter Ausschlusspfade
     */
    public static function selfExcludeList()
    {
        $data = self::selfExcludeAbs();

        if ($data === '') {
            return array(); // ohne data/-Anker kein Ouroboros-Schutz -> Aufrufer bricht ab
        }

        $out = array($data);
        $bd  = self::selfBackupDirAbs();

        if ($bd !== '' && $bd !== $data && !in_array($bd, $out, true)) {
            $out[] = $bd;
        }

        return $out;
    }

    /**
     * Snapshot von HTProtect SELBST (Dateien, OHNE den data/-Ordner). Eigener Pfad,
     * weil die generische Engine com_htprotect sperrt. Schlaegt fehl, wenn der
     * data/-Ausschluss nicht bestimmbar ist (dann NICHT wagen). com_htprotect hat
     * keine eigenen DB-Tabellen -> kein DB-Dump.
     *
     * @param   int     $eid      Extension-ID von com_htprotect
     * @param   string  $version  Aktuelle Version
     *
     * @return string  Snapshot-ID oder ''
     */
    public static function snapshotSelf($eid, $version)
    {
        $eid = (int) $eid;

        if ($eid <= 0) {
            return '';
        }

        $excludeAbs = self::selfExcludeList();

        if (empty($excludeAbs)) {
            return ''; // ohne data/-Schutz NICHT wagen (sonst Ouroboros)
        }

        $rootDir = self::backupRoot();

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

        $snapId = date('YmdHis') . '-' . self::sanitizeVersion($version);

        if (!preg_match(self::SNAP_RE, $snapId)) {
            return '';
        }

        $snapDir = $rootDir . '/' . $eid . '/' . $snapId;

        // ── ZIP-Pfad: EINE snap.htpz (narrow-only; data/+backups/ via selfExcludeList AUS,
        //    Ouroboros-Schutz). Self war nie Teil des Wide-Scopes. ──
        if (class_exists('ZipArchive')) {
            $entries = self::writeZipSnapshot($snapDir, self::selfPaths(), $excludeAbs, true);

            if ($entries === null) {
                self::rmTree($snapDir);

                return '';
            }

            $fmt = 'zip';
        } else {
            // ── FALLBACK (kein ZipArchive): bisheriger narrow-`files/`-Baum ──
            $filesDir = $snapDir . '/files';

            if (!@mkdir($filesDir, 0700, true) && !is_dir($filesDir)) {
                return '';
            }

            $bytes   = 0;
            $entries = array();

            foreach (self::selfPaths() as $entry) {
                $root = $entry['root'];
                $rel  = $entry['rel'];

                if (!self::relSafeSelf($rel)) {
                    continue;
                }

                $base    = self::absBase($root);
                $srcAbs  = $base . '/' . $rel;
                $existed = false;

                if (self::within($srcAbs, $base) && (is_dir($srcAbs) || is_file($srcAbs))) {
                    $dstAbs = $filesDir . '/' . $root . '/' . $rel;

                    // data/- UND backups/-Ordner werden via $excludeAbs (selfExcludeList) NIE
                    // mitkopiert: Rollback-Sicherungen + .app-Dashboard-Backups (data/) sowie der
                    // regenerierbare coreref-Kern-Cache/Laufzeit-Sicherungen (backups/).
                    if (!self::copyInto($srcAbs, $dstAbs, $bytes, $excludeAbs, 0700, 0600, 'pack')) {
                        self::rmTree($snapDir);

                        return '';
                    }

                    $existed = true;
                }

                $entries[] = array('root' => $root, 'rel' => $rel, 'existed' => $existed);
            }

            $fmt = 'files';
        }

        $meta = array(
            'eid'       => $eid,
            'type'      => 'component',
            'element'   => 'com_htprotect',
            'folder'    => '',
            'client_id' => 1,
            'version'   => (string) $version,
            'ts'        => time(),
            'paths'     => $entries,
            'db'        => array('tables' => array(), 'skipped' => array()),
            'self'      => 1,
            'fmt'       => $fmt,
        );

        if (@file_put_contents($snapDir . '/meta.json', json_encode($meta)) === false) {
            self::rmTree($snapDir);

            return '';
        }

        @chmod($snapDir . '/meta.json', 0600);

        return $snapId;
    }

    /**
     * Raeumt BESTEHENDE Snapshots nachtraeglich auf: entfernt den zur Laufzeit erzeugten
     * backups/-Ordner (coreref-Kern-Cache ~30 MB + coremanifest + Helix/SPPB/Kernscan-
     * Sicherungen + malscan-Runtime), der VOR 2.5.7 versehentlich in Self-Update-Sicherungen
     * mitgesichert wurde (je Sicherung bis ~33 MB Ballast). Ab 2.5.7 wird er gar nicht mehr
     * mitgesichert (selfExcludeList); diese Routine entfernt den Altbestand. Fuer den Rollback
     * wird davon NICHTS gebraucht - reiner regenerierbarer Cache. Der Restore schliesst
     * backups/ ohnehin aus (selfExcludeList), d. h. ein so bereinigter Snapshot verhaelt sich
     * beim Zurueckrollen identisch. Idempotent, @-fail-safe. Beruehrt NUR den com_htprotect-
     * backups/-Unterbaum, nie Code, meta.json oder DB-Dumps.
     *
     * @return int  Anzahl entfernter backups/-Ordner
     */
    public static function sweepSnapshotBloat()
    {
        $rootDir = self::backupRoot();

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

        $cleaned = 0;

        foreach ((array) @scandir($rootDir) as $eid) {
            if ($eid === '.' || $eid === '..' || !ctype_digit((string) $eid)) {
                continue;
            }

            $eidDir = $rootDir . '/' . $eid;

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

            foreach ((array) @scandir($eidDir) as $snap) {
                if ($snap === '.' || $snap === '..' || !preg_match(self::SNAP_RE, $snap)) {
                    continue;
                }

                foreach (array('admin', 'site') as $r) {
                    $bd = $eidDir . '/' . $snap . '/files/' . $r . '/components/com_htprotect/backups';

                    if (is_dir($bd)) {
                        self::rmTree($bd);

                        if (!is_dir($bd)) {
                            $cleaned++;
                        }
                    }
                }
            }
        }

        return $cleaned;
    }

    // ═════════════════════════════════════════════════════════════════
    //  DB-Tabellen-Backup/Restore (Schritt 2) - injektionssicher
    // ═════════════════════════════════════════════════════════════════

    /**
     * Kandidaten-Tabellen-Basisnamen (OHNE #__-Praefix) einer Erweiterung (rein,
     * testbar): aus dem Element abgeleitet (com_xyz -> xyz) PLUS kuratierte Liste
     * der grossen Player, deren Tabellen-Praefix vom Element abweicht (SP Page
     * Builder, JCE, Akeeba, RSForm ...). Diese Basen werden in resolveTables() per
     * LIKE gegen information_schema validiert - hier wird NICHTS direkt benutzt.
     *
     * @param   string  $element  Element-Name (z. B. com_sppagebuilder, com_jce)
     *
     * @return array  Liste kleingeschriebener Basisnamen
     */
    public static function tableCandidates($element)
    {
        $element = strtolower((string) $element);

        if ($element === '' || preg_match('~[^a-z0-9_.\-]~', $element)) {
            return array();
        }

        $base = preg_replace('~^com_~', '', $element);
        $base = preg_replace('~[^a-z0-9_]~', '', (string) $base);

        $out = array();

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

        // Kuratierte Liste: Element => zusaetzliche Tabellen-Basis-Praefixe.
        $curated = array(
            'com_jce'           => array('wf_profiles'),
            'jce'               => array('wf_profiles'),
            'com_akeeba'        => array('ak_'),
            'com_akeebabackup'  => array('akeeba_', 'ak_'),
            'com_rsform'        => array('rsform_'),
            'com_acym'          => array('acym_'),
            'com_acymailing'    => array('acym_', 'acymailing_'),
            'com_dpcalendar'    => array('dpcalendar_'),
            'com_sppagebuilder' => array('sppagebuilder'),
            'com_easyblog'      => array('easyblog_'),
            'com_jdownloads'    => array('jdownloads_'),
            'com_hikashop'      => array('hikashop_'),
            'com_virtuemart'    => array('virtuemart_'),
            'com_kunena'        => array('kunena_'),
            'com_phocagallery'  => array('phocagallery_'),
        );

        if (isset($curated[$element])) {
            foreach ($curated[$element] as $b) {
                $b = strtolower((string) $b);

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

        return array_keys($out);
    }

    /**
     * Denylist der Joomla-Kern-Tabellen-Basisnamen (ohne Praefix) - werden NIE
     * gesichert/wiederhergestellt (z. B. #__users, #__menu, #__modules,
     * #__extensions). Defense in Depth, falls eine Heuristik zu breit greift.
     *
     * @return array
     */
    public static function coreTableBases()
    {
        return array(
            'users', 'user_usergroup_map', 'user_keys', 'user_notes', 'user_profiles',
            'usergroups', 'viewlevels', 'menu', 'menu_types', 'modules', 'modules_menu',
            'extensions', 'update_sites', 'update_sites_extensions', 'updates', 'schemas',
            'categories', 'content', 'content_frontpage', 'content_rating', 'content_types',
            'assets', 'session', 'languages', 'language_overrides', 'template_styles',
            'fields', 'fields_categories', 'fields_groups', 'fields_values',
            'workflows', 'workflow_associations', 'workflow_stages', 'workflow_transitions',
            'action_logs', 'action_logs_extensions', 'action_log_config', 'action_log_users',
            'tags', 'contentitem_tag_map', 'ucm_base', 'ucm_content', 'ucm_history',
            'banners', 'banner_clients', 'banner_tracks', 'contact_details', 'newsfeeds',
            'redirect_links', 'finder_links', 'overrider', 'postinstall_messages',
            'mail_templates', 'associations', 'history', 'privacy_requests', 'privacy_consents',
            'scheduler_tasks', 'webauthn_credentials', 'guidedtours', 'guidedtour_steps',
        );
    }

    /**
     * Ist $base eine Kern-Tabelle (Denylist)?
     *
     * @param   string  $base  Tabellen-Basisname (ohne Praefix)
     *
     * @return boolean
     */
    public static function isCoreTable($base)
    {
        return in_array(strtolower((string) $base), self::coreTableBases(), true);
    }

    /**
     * Bezeichner (Tabelle/Spalte) sicher backtick-quoten. Wird nur auf bereits
     * gegen IDENT_RE validierte Namen angewandt (zusaetzlich Backticks verdoppelt).
     *
     * @param   string  $name  Bezeichner
     *
     * @return string
     */
    public static function quoteIdent($name)
    {
        return '`' . str_replace('`', '``', (string) $name) . '`';
    }

    /**
     * '%', '_' und '\' fuer eine LIKE-Klausel maskieren.
     *
     * @param   string  $s  Rohstring
     *
     * @return string
     */
    private static function likeEscape($s)
    {
        return str_replace(array('\\', '%', '_'), array('\\\\', '\\%', '\\_'), (string) $s);
    }

    /**
     * Reale, der Erweiterung gehoerende Tabellennamen ermitteln - gegen
     * information_schema validiert (nur tatsaechlich existierende Tabellen der
     * AKTUELLEN DB, deren Praefix passt), Kern-Tabellen ausgeschlossen, Namen
     * strikt validiert. Keine SQL-Injektion: LIKE-Wert ist parametrisiert/maskiert,
     * Ergebnisnamen werden gegen IDENT_RE geprueft, bevor sie je in ein Statement
     * gehen.
     *
     * @param   string  $element  Element-Name
     *
     * @return array  reale Tabellennamen (inkl. Praefix)
     */
    public static function resolveTables($element)
    {
        $db = function_exists('htp_db') ? htp_db() : null;

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

        $cands = self::tableCandidates($element);

        if (empty($cands)) {
            return array();
        }

        $prefix = method_exists($db, 'getPrefix') ? (string) $db->getPrefix() : '';
        $found  = array();

        foreach ($cands as $base) {
            $like = self::likeEscape($prefix . $base) . '%';

            try {
                $query = $db->getQuery(true)
                    ->select($db->quoteName('TABLE_NAME'))
                    ->from($db->quoteName('information_schema.TABLES'))
                    ->where($db->quoteName('TABLE_SCHEMA') . ' = DATABASE()')
                    ->where($db->quoteName('TABLE_NAME') . ' LIKE ' . $db->quote($like));
                $db->setQuery($query, 0, 300);
                $rows = $db->loadColumn();
            } catch (Exception $e) {
                continue;
            } catch (Throwable $e) {
                continue;
            }

            foreach ((array) $rows as $t) {
                $t = (string) $t;

                // Strikte Validierung - nur unbedenkliche Bezeichner.
                if (!preg_match(self::IDENT_RE, $t)) {
                    continue;
                }

                $bare = ($prefix !== '' && strpos($t, $prefix) === 0) ? substr($t, strlen($prefix)) : $t;

                if (self::isCoreTable($bare)) {
                    continue;
                }

                $found[$t] = true;
            }
        }

        return array_keys($found);
    }

    /**
     * DB-Tabellen einer Erweiterung in den Snapshot sichern (Schritt 2). Nur
     * extension-eigene Tabellen (resolveTables), mit Zeilen-/Groessen-Cap; zu grosse
     * werden uebersprungen (dann greift bei Bedarf der Datei-Rollback allein).
     *
     * @param   int           $eid       Extension-ID
     * @param   string        $snapId    Snapshot-ID
     * @param   string|array  $elements  Element-Name oder Liste (Paket = Kinder)
     *
     * @return array ['tables' => [gesichert], 'skipped' => [uebersprungen]]
     */
    public static function dumpDb($eid, $snapId, $elements)
    {
        $res = array('tables' => array(), 'skipped' => array());
        $db  = function_exists('htp_db') ? htp_db() : null;

        if (!$db || (int) $eid <= 0 || !preg_match(self::SNAP_RE, (string) $snapId)) {
            return $res;
        }

        // Tabellen aller (Kind-)Elemente vereinigen (Paket = mehrere Elemente).
        $tmap = array();

        foreach ((is_array($elements) ? $elements : array($elements)) as $el) {
            foreach (self::resolveTables($el) as $t) {
                $tmap[$t] = true;
            }
        }

        $tables = array_keys($tmap);

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

        $rootDir = self::backupRoot();

        if ($rootDir === '') {
            return $res;
        }

        $snapDir = $rootDir . '/' . ((int) $eid) . '/' . $snapId;

        if (!is_dir($snapDir)) {
            return $res;
        }

        $dump  = array('tables' => array(), 'ts' => time());
        $bytes = 0;

        foreach ($tables as $t) {
            if (!preg_match(self::IDENT_RE, $t)) {
                continue;
            }

            $q = self::quoteIdent($t);

            try {
                $db->setQuery('SELECT COUNT(*) FROM ' . $q);
                $rowCount = (int) $db->loadResult();

                if ($rowCount > self::DB_MAX_ROWS) {
                    $res['skipped'][] = $t;
                    continue;
                }

                $db->setQuery('SHOW CREATE TABLE ' . $q);
                $createRow = $db->loadRow();
                $create    = (is_array($createRow) && isset($createRow[1])) ? (string) $createRow[1] : '';

                if ($create === '' || stripos($create, 'CREATE TABLE') === false) {
                    $res['skipped'][] = $t;
                    continue;
                }

                $db->setQuery('SELECT * FROM ' . $q);
                $data = $db->loadAssocList();
                $data = is_array($data) ? $data : array();
            } catch (Exception $e) {
                $res['skipped'][] = $t;
                continue;
            } catch (Throwable $e) {
                $res['skipped'][] = $t;
                continue;
            }

            $entry = array('create' => $create, 'rows' => $data);
            $size  = strlen((string) json_encode($entry));

            if (($bytes + $size) > self::DB_MAX_BYTES) {
                $res['skipped'][] = $t; // Gesamt-Cap erreicht
                continue;
            }

            $bytes += $size;
            $dump['tables'][$t] = $entry;
            $res['tables'][]    = $t;
        }

        if (!empty($dump['tables'])) {
            $json = json_encode($dump);

            if ($json !== false && @file_put_contents($snapDir . '/db.json', $json, LOCK_EX) !== false) {
                @chmod($snapDir . '/db.json', 0600);
            }
        }

        return $res;
    }

    /**
     * Hat ein Snapshot ein DB-Backup?
     *
     * @param   int     $eid     Extension-ID
     * @param   string  $snapId  Snapshot-ID
     *
     * @return boolean
     */
    public static function hasDbBackup($eid, $snapId)
    {
        $rootDir = self::backupRoot();

        if ($rootDir === '' || (int) $eid <= 0 || !preg_match(self::SNAP_RE, (string) $snapId)) {
            return false;
        }

        return is_file($rootDir . '/' . ((int) $eid) . '/' . $snapId . '/db.json');
    }

    /**
     * DB-Tabellen aus dem Snapshot wiederherstellen (Schritt 2). DROP + CREATE
     * (aus SHOW CREATE TABLE - vertrauenswuerdige DB-Ausgabe) + parametrisierte
     * Batch-INSERTs. Tabellen-/Spaltennamen strikt validiert + backtick-quotiert;
     * Werte ueber $db->quote() (keine SQL-Injektion).
     *
     * @param   int     $eid     Extension-ID
     * @param   string  $snapId  Snapshot-ID
     *
     * @return array ['ok' => bool, 'restored' => int]
     */
    public static function restoreDb($eid, $snapId)
    {
        $db = function_exists('htp_db') ? htp_db() : null;

        if (!$db || (int) $eid <= 0 || !preg_match(self::SNAP_RE, (string) $snapId)) {
            return array('ok' => false, 'restored' => 0);
        }

        $rootDir = self::backupRoot();

        if ($rootDir === '') {
            return array('ok' => false, 'restored' => 0);
        }

        $file = $rootDir . '/' . ((int) $eid) . '/' . $snapId . '/db.json';

        if (!is_file($file)) {
            return array('ok' => true, 'restored' => 0); // kein DB-Backup -> nichts zu tun
        }

        $dump = json_decode((string) @file_get_contents($file), true);

        if (!is_array($dump) || empty($dump['tables']) || !is_array($dump['tables'])) {
            return array('ok' => true, 'restored' => 0);
        }

        $ok = true;
        $n  = 0;

        foreach ($dump['tables'] as $name => $tbl) {
            $name = (string) $name;

            if (!preg_match(self::IDENT_RE, $name) || self::isCoreTable(self::stripPrefix($db, $name))) {
                $ok = false;
                continue;
            }

            $create = (is_array($tbl) && isset($tbl['create'])) ? (string) $tbl['create'] : '';
            $rows   = (is_array($tbl) && isset($tbl['rows']) && is_array($tbl['rows'])) ? $tbl['rows'] : array();

            if ($create === '' || stripos($create, 'CREATE TABLE') === false) {
                $ok = false;
                continue;
            }

            try {
                $db->setQuery('DROP TABLE IF EXISTS ' . self::quoteIdent($name))->execute();
                $db->setQuery($create)->execute();
                self::insertRows($db, $name, $rows);
                $n++;
            } catch (Exception $e) {
                $ok = false;
            } catch (Throwable $e) {
                $ok = false;
            }
        }

        return array('ok' => $ok, 'restored' => $n);
    }

    /**
     * Phase A: Den kompletten #__extensions-Registry-Zustand in den Snapshot sichern.
     * Klein (einige hundert Zeilen), erfasst aber Versionen UND extension_id-Zuordnung
     * ALLER Erweiterungen - damit laesst sich der Zustand nach einem Update
     * extension-agnostisch exakt zuruecksetzen, selbst wenn ein Framework sich
     * mehrteilig neu installiert und IDs wechseln (z. B. Astroid 639->641).
     *
     * @param   string  $snapDir  Snapshot-Verzeichnis
     *
     * @return boolean
     */
    private static function snapshotRegistry($snapDir)
    {
        $db = function_exists('htp_db') ? htp_db() : null;

        if (!$db || !is_dir($snapDir)) {
            return false;
        }

        try {
            $db->setQuery('SELECT * FROM ' . $db->quoteName('#__extensions'));
            $rows = $db->loadAssocList();

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

            // Update-Site-Verknuepfungen mitsichern: ein ID-Wechsel beim Update verwaist
            // sonst die Links (#__update_sites_extensions zeigt auf die tote alte ID) und
            // die Erweiterung haette nach einem Registry-Restore keinen Update-Pfad mehr.
            $usext = array();

            try {
                $db->setQuery('SELECT * FROM ' . $db->quoteName('#__update_sites_extensions'));
                $usext = $db->loadAssocList();
                $usext = is_array($usext) ? $usext : array();
            } catch (Exception $e) {
                $usext = array();
            } catch (Throwable $e) {
                $usext = array();
            }

            // manifest_cache + custom_data sind regenerierbare CACHES und der Loewenanteil des
            // Inhalts. Sie aggregieren die Metadaten ALLER Erweiterungen (base64/serialisiert/URLs/
            // HTML-Beschreibungen), an denen sich generische Fremd-Malware-Scanner (z. B.
            // mySites.guru) stoeren und die ganze Datei faelschlich als „gehackt" flaggen. Wir LEEREN
            // sie (setzen ''), statt sie zu ENTFERNEN: so bleibt der Spaltensatz jeder Zeile
            // identisch, und der Restore-REPLACE (der die Spalten dynamisch aus den Zeilen ableitet)
            // laeuft absolut unveraendert - nur mit leerem manifest_cache. Joomla baut den beim
            // Rollback ohnehin aus der wiederhergestellten manifest.xml neu auf. KEIN Verhalts-/
            // Rollback-Effekt, nur weniger flaggbarer Inhalt.
            foreach ($rows as &$__row) {
                if (is_array($__row)) {
                    if (array_key_exists('manifest_cache', $__row)) {
                        $__row['manifest_cache'] = '';
                    }
                    if (array_key_exists('custom_data', $__row)) {
                        $__row['custom_data'] = '';
                    }
                }
            }
            unset($__row);

            $json = json_encode(array('ts' => time(), 'rows' => $rows, 'usext' => $usext));

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

            // Komprimiert ablegen (registry.json.gz): eine Gzip-Datei ist ein opaker Datenblob -
            // Text-Signaturen greifen nicht, der Fremd-Scanner hat nichts mehr zu matchen. OHNE zlib
            // (selten) Fallback auf Klartext, damit der Backup-/Rollback-Pfad NIE ausfaellt. Beide
            // Namen liest der Restore (registryFile()) - alte .json-Snapshots bleiben gueltig.
            $ok = false;

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

                if ($gz !== false && @file_put_contents($snapDir . '/registry.json.gz', $gz, LOCK_EX) !== false) {
                    @chmod($snapDir . '/registry.json.gz', 0600);
                    @unlink($snapDir . '/registry.json'); // evtl. Alt-Klartext aus fruehrem Lauf raeumen
                    $ok = true;
                }
            }

            if (!$ok) {
                if (@file_put_contents($snapDir . '/registry.json', $json, LOCK_EX) === false) {
                    return false;
                }

                @chmod($snapDir . '/registry.json', 0600);
            }

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

    /**
     * Hat ein Snapshot einen Registry-Backup (Phase A)?
     *
     * @param   int     $eid     Extension-ID
     * @param   string  $snapId  Snapshot-ID
     *
     * @return boolean
     */
    /**
     * Die Registry-Datei eines Snapshot-Verzeichnisses: bevorzugt die komprimierte
     * `registry.json.gz`, ersatzweise das Alt-Klartext `registry.json` (aeltere Snapshots).
     * '' wenn keine vorhanden.
     *
     * @param   string  $dir  Snapshot-Verzeichnis
     *
     * @return string
     */
    private static function registryFile($dir)
    {
        if (is_file($dir . '/registry.json.gz')) {
            return $dir . '/registry.json.gz';
        }

        if (is_file($dir . '/registry.json')) {
            return $dir . '/registry.json';
        }

        return '';
    }

    public static function hasRegistry($eid, $snapId)
    {
        $rootDir = self::backupRoot();

        if ($rootDir === '' || (int) $eid <= 0 || !preg_match(self::SNAP_RE, (string) $snapId)) {
            return false;
        }

        return self::registryFile($rootDir . '/' . ((int) $eid) . '/' . $snapId) !== '';
    }

    /**
     * REINE POLICY (kein DB/FS, voll unit-testbar): plant den auf die ZIEL-Kaskade
     * begrenzten Registry-Restore. Ein Rollback darf NIEMALS fremde, nach dem Snapshot
     * installierte oder aktualisierte Erweiterungen loeschen, zurueckdatieren oder von
     * ihrer Update-Quelle trennen. Die Kaskade wird ueber die Kennung
     * (element|type|folder|client_id) bestimmt - so bleibt der ID-Wechsel-Revert der
     * Zielerweiterung (gleiche Kennung, neue ID; z. B. Astroid 639->641) voll erhalten,
     * waehrend unbeteiligte Erweiterungen unberuehrt bleiben. Umfasst bei einem Paket
     * (oder Paket-Kind) die ganze Paket-Kaskade (Paket + alle Kinder).
     *
     * @param   array  $snapRows  Snapshot-Zeilen aus #__extensions (assoc)
     * @param   array  $curRows   Aktuelle Zeilen, je mind. extension_id/element/type/folder/client_id
     * @param   int    $eid       Snapshot-Eigner (Ziel-Extension-ID)
     *
     * @return  array  ['keys'=>string[], 'deleteIds'=>int[], 'cascadeIds'=>int[], 'replaceIds'=>int[]]
     *                 keys       = Kaskaden-Kennungen
     *                 deleteIds  = NEU angelegte Kaskaden-IDs (nicht im Snapshot) -> entfernen
     *                 cascadeIds = aktuelle + Snapshot-IDs der Kaskade -> usext-DELETE-Scope
     *                 replaceIds = Snapshot-IDs der Kaskade -> REPLACE + usext-Reinsert
     */
    public static function registryRestorePlan($snapRows, $curRows, $eid)
    {
        $eid      = (int) $eid;
        $snapRows = is_array($snapRows) ? $snapRows : array();
        $curRows  = is_array($curRows) ? $curRows : array();

        $key = function ($r) {
            $el = isset($r['element'])   ? strtolower((string) $r['element'])   : '';
            $ty = isset($r['type'])      ? strtolower((string) $r['type'])      : '';
            $fo = isset($r['folder'])    ? strtolower((string) $r['folder'])    : '';
            $cl = isset($r['client_id']) ? (int) $r['client_id']                : 0;

            return $el . '|' . $ty . '|' . $fo . '|' . $cl;
        };

        // Ziel-Zeile im Snapshot finden (Kaskaden-Anker).
        $target = null;

        foreach ($snapRows as $r) {
            if (is_array($r) && isset($r['extension_id']) && (int) $r['extension_id'] === $eid) {
                $target = $r;
                break;
            }
        }

        $keys = array();

        if ($target !== null) {
            $keys[$key($target)] = true;

            // Paket-Anker: ist das Ziel selbst ein Paket -> seine ID; ist es ein Paket-Kind
            // -> dessen package_id. Dann die ganze Paket-Kaskade (Paket + alle Kinder).
            $pkg = 0;

            if (isset($target['type']) && strtolower((string) $target['type']) === 'package') {
                $pkg = $eid;
            } elseif (isset($target['package_id']) && (int) $target['package_id'] > 0) {
                $pkg = (int) $target['package_id'];
            }

            if ($pkg > 0) {
                foreach ($snapRows as $r) {
                    if (!is_array($r)) {
                        continue;
                    }

                    $rid  = isset($r['extension_id']) ? (int) $r['extension_id'] : 0;
                    $rpkg = isset($r['package_id'])    ? (int) $r['package_id']    : 0;

                    if ($rid === $pkg || $rpkg === $pkg) {
                        $keys[$key($r)] = true;
                    }
                }
            }
        }

        // Snapshot-IDs der Kaskade (REPLACE + usext-Reinsert) + alle Snapshot-IDs (Neu-Erkennung).
        $replaceIds = array();
        $snapIds    = array();

        foreach ($snapRows as $r) {
            if (!is_array($r) || !isset($r['extension_id'])) {
                continue;
            }

            $id           = (int) $r['extension_id'];
            $snapIds[$id] = true;

            if (isset($keys[$key($r)])) {
                $replaceIds[$id] = true;
            }
        }

        // Aktuelle Kaskaden-IDs (usext-Scope) + davon die NEU angelegten (ID-Wechsel) -> loeschen.
        $cascadeIds = $replaceIds;
        $deleteIds  = array();

        foreach ($curRows as $r) {
            if (!is_array($r) || !isset($r['extension_id']) || !isset($keys[$key($r)])) {
                continue;
            }

            $id              = (int) $r['extension_id'];
            $cascadeIds[$id] = true;

            if (!isset($snapIds[$id])) {
                $deleteIds[$id] = true;
            }
        }

        return array(
            'keys'       => array_keys($keys),
            'deleteIds'  => array_keys($deleteIds),
            'cascadeIds' => array_keys($cascadeIds),
            'replaceIds' => array_keys($replaceIds),
        );
    }

    /**
     * Phase A: Den #__extensions-Registry-Zustand aus dem Snapshot zuruecksetzen - strikt
     * begrenzt auf die ZIEL-Kaskade (registryRestorePlan): vom Update NEU angelegte
     * Kaskaden-Zeilen entfernen, die gesicherten Kaskaden-Zeilen per REPLACE zurueckspielen
     * (Versions- UND ID-Wechsel-Revert), die Kaskaden-Update-Site-Links neu setzen. Fremde,
     * nach dem Snapshot installierte/aktualisierte Erweiterungen bleiben UNBERUEHRT (kein
     * Loeschen, kein Zurueckdatieren, keine gekappte Update-Quelle). #__extensions ist
     * bewusst fest verdrahtet, Spaltennamen strikt validiert, Werte ueber $db->quote().
     * Best-effort, fail-open.
     *
     * @param   int     $eid     Extension-ID (Snapshot-Eigner)
     * @param   string  $snapId  Snapshot-ID
     *
     * @return boolean  true bei erfolgreichem Restore
     */
    public static function restoreRegistry($eid, $snapId)
    {
        $db = function_exists('htp_db') ? htp_db() : null;

        if (!$db || (int) $eid <= 0 || !preg_match(self::SNAP_RE, (string) $snapId)) {
            return false;
        }

        $rootDir = self::backupRoot();

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

        $file = self::registryFile($rootDir . '/' . ((int) $eid) . '/' . $snapId);

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

        $raw = (string) @file_get_contents($file);

        // Komprimierte Snapshots (registry.json.gz) dekomprimieren; Alt-Klartext (registry.json)
        // bleibt roh. Schlaegt das Dekomprimieren fehl -> sauber abbrechen (kein halber Restore).
        if (substr($file, -3) === '.gz') {
            $dec = function_exists('gzdecode') ? @gzdecode($raw) : false;

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

            $raw = $dec;
        }

        $data = json_decode($raw, true);

        if (!is_array($data) || empty($data['rows']) || !is_array($data['rows'])) {
            return false;
        }

        $rows  = $data['rows'];
        $first = reset($rows);

        if (!is_array($first) || !array_key_exists('extension_id', $first)) {
            return false;
        }

        // Spalten strikt validieren (kein dynamischer Identifier ohne Pruefung).
        $cols = array_keys($first);

        foreach ($cols as $c) {
            if (!preg_match(self::IDENT_RE, (string) $c)) {
                return false;
            }
        }

        try {
            $tbl = $db->quoteName('#__extensions');

            // Aktuellen Registry-Stand (nur die zur Kaskaden-Bestimmung noetigen Spalten)
            // lesen und den auf die ZIEL-Kaskade begrenzten Restore-Plan bilden. So wird
            // NIE eine fremde, nach dem Snapshot installierte/aktualisierte Erweiterung
            // geloescht, zurueckdatiert oder von ihrer Update-Quelle getrennt.
            $curRows = array();

            try {
                $db->setQuery(
                    'SELECT ' . $db->quoteName('extension_id') . ',' . $db->quoteName('element')
                    . ',' . $db->quoteName('type') . ',' . $db->quoteName('folder') . ',' . $db->quoteName('client_id')
                    . ' FROM ' . $tbl
                );
                $curRows = (array) $db->loadAssocList();
            } catch (Exception $e) {
                $curRows = array();
            } catch (Throwable $e) {
                $curRows = array();
            }

            $plan       = self::registryRestorePlan($rows, $curRows, $eid);
            $replaceSet = array();

            foreach ($plan['replaceIds'] as $rid) {
                $replaceSet[(int) $rid] = true;
            }

            // (a) NUR neu angelegte Kaskaden-Zeilen entfernen (z. B. die ID-gewechselte
            // Astroid-641). Stale-Snapshot-Schutz: unplausibel viele -> gar nicht loeschen.
            $del = $plan['deleteIds'];

            if (count($del) > 25) {
                $del = array();
            }

            if (!empty($del)) {
                $db->setQuery('DELETE FROM ' . $tbl . ' WHERE ' . $db->quoteName('extension_id')
                    . ' IN (' . implode(',', array_map('intval', $del)) . ')')->execute();
            }

            // (b) NUR die Kaskaden-Zeilen aus dem Snapshot per REPLACE zurueckspielen
            // (Versions- + ID-Wechsel-Revert der Zielerweiterung). Unbeteiligte Zeilen
            // behalten ihren aktuellen Stand.
            $colQ = array();

            foreach ($cols as $c) {
                $colQ[] = $db->quoteName((string) $c);
            }

            $head        = 'REPLACE INTO ' . $tbl . ' (' . implode(',', $colQ) . ') VALUES ';
            $replaceRows = array();

            foreach ($rows as $r) {
                if (is_array($r) && isset($r['extension_id']) && isset($replaceSet[(int) $r['extension_id']])) {
                    $replaceRows[] = $r;
                }
            }

            foreach (array_chunk($replaceRows, 50) as $chunk) {
                $tuples = array();

                foreach ($chunk as $r) {
                    $cells = array();

                    foreach ($cols as $c) {
                        $v       = array_key_exists($c, $r) ? $r[$c] : null;
                        $cells[] = ($v === null) ? 'NULL' : $db->quote((string) $v);
                    }

                    $tuples[] = '(' . implode(',', $cells) . ')';
                }

                if (!empty($tuples)) {
                    $db->setQuery($head . implode(',', $tuples))->execute();
                }
            }

            // (c) Update-Site-Verknuepfungen NUR fuer die Kaskade zuruecksetzen: aktuelle
            // Links der Kaskaden-IDs entfernen, die gesicherten Kaskaden-Links neu setzen.
            // Re-pointet die vom ID-Wechsel verwaisten Links; fremde Erweiterungen behalten
            // ihre Update-Quelle. Fehlt 'usext' (alte Snapshots) -> uebersprungen.
            if (isset($data['usext']) && is_array($data['usext']) && !empty($data['usext']) && !empty($plan['cascadeIds'])) {
                $ufirst = reset($data['usext']);

                if (is_array($ufirst)) {
                    $ucols = array_keys($ufirst);
                    $uok   = true;

                    foreach ($ucols as $c) {
                        if (!preg_match(self::IDENT_RE, (string) $c)) {
                            $uok = false;
                            break;
                        }
                    }

                    if ($uok && in_array('extension_id', $ucols, true)) {
                        $ut = $db->quoteName('#__update_sites_extensions');

                        $db->setQuery('DELETE FROM ' . $ut . ' WHERE ' . $db->quoteName('extension_id')
                            . ' IN (' . implode(',', array_map('intval', $plan['cascadeIds'])) . ')')->execute();

                        $ucolQ = array();

                        foreach ($ucols as $c) {
                            $ucolQ[] = $db->quoteName((string) $c);
                        }

                        $uhead     = 'INSERT INTO ' . $ut . ' (' . implode(',', $ucolQ) . ') VALUES ';
                        $usextKeep = array();

                        foreach ($data['usext'] as $r) {
                            if (is_array($r) && isset($r['extension_id']) && isset($replaceSet[(int) $r['extension_id']])) {
                                $usextKeep[] = $r;
                            }
                        }

                        foreach (array_chunk($usextKeep, 100) as $uchunk) {
                            $utuples = array();

                            foreach ($uchunk as $r) {
                                $ucells = array();

                                foreach ($ucols as $c) {
                                    $v        = array_key_exists($c, $r) ? $r[$c] : null;
                                    $ucells[] = ($v === null) ? 'NULL' : $db->quote((string) $v);
                                }

                                $utuples[] = '(' . implode(',', $ucells) . ')';
                            }

                            if (!empty($utuples)) {
                                $db->setQuery($uhead . implode(',', $utuples))->execute();
                            }
                        }
                    }
                }
            }

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

    // ─────────────────────────────────────────────────────────────────
    //  Phase B: extension-agnostischer Datei-Schutz (Content-Addressed Store)
    //  Sichert vor dem Update die Vereinigung der ownedPaths ALLER nicht-Kern-
    //  Erweiterungen dedupliziert in einen CAS + einen Index je Snapshot. Faengt
    //  Kaskaden, bei denen ein Framework fremde Erweiterungs-Dateien mit-ersetzt
    //  (z. B. Astroid: Library-Update ersetzt Plugin/Module/Captcha), OHNE die
    //  Erweiterung "kennen" zu muessen und ohne den schweren Joomla-Kern.
    // ─────────────────────────────────────────────────────────────────

    /** Wurzel des geteilten Content-Addressed Store. */
    public static function casDir()
    {
        $root = self::backupRoot();

        return $root === '' ? '' : $root . '/cas';
    }

    /** CAS-Pfad fuer einen Hash (geshardet nach den ersten 2 Hex-Zeichen). */
    private static function casPathFor($hash)
    {
        if (!preg_match(self::SHA256_RE, (string) $hash)) {
            return '';
        }

        $dir = self::casDir();

        return $dir === '' ? '' : $dir . '/' . substr($hash, 0, 2) . '/' . $hash;
    }

    /**
     * Die CAS-Blob-Datei zu einem Hash: bevorzugt komprimiert (<hash>.PACK_EXT), ersatzweise
     * Alt-Klartext (<hash>). '' wenn nicht vorhanden.
     */
    private static function casBlobFor($hash)
    {
        $base = self::casPathFor($hash);

        return $base === '' ? '' : self::packedPath($base);
    }

    /** Kern-Erweiterungen, die nie ins Wide-Backup gehoeren. */
    private static function coreElements()
    {
        // Joomla-Kern: Umbrella + ALLE Kernkomponenten + Kern-Libs. Vollstaendig gelistet, damit die
        // Oekosystem-Geschwister-Erfassung (ecosystemScope) niemals eine Kernkomponente ueber eine
        // zufaellige Vendor-Token-Kollision (z. B. Drittanbieter mod_content -> Token "content" <->
        // com_content) mit einsammelt. Der Kern wird ohnehin nie von Fremd-Updates veraendert - ein
        // Mit-Sichern waere Ballast, ein Mit-Restaurieren im Kollisionsfall ein Ueber-Rollback.
        return array(
            'joomla' => 1, 'files_joomla' => 1, 'pkg_joomla' => 1, 'lib_joomla' => 1,
            'lib_fof' => 1, 'lib_idna_convert' => 1, 'lib_phpass' => 1, 'lib_phputf8' => 1, 'lib_vendor' => 1,
            'com_actionlogs' => 1, 'com_admin' => 1, 'com_ajax' => 1, 'com_associations' => 1,
            'com_banners' => 1, 'com_cache' => 1, 'com_categories' => 1, 'com_checkin' => 1,
            'com_config' => 1, 'com_contact' => 1, 'com_content' => 1, 'com_cpanel' => 1,
            'com_fields' => 1, 'com_finder' => 1, 'com_guidedtours' => 1, 'com_installer' => 1,
            'com_joomlaupdate' => 1, 'com_languages' => 1, 'com_login' => 1, 'com_mailto' => 1,
            'com_mails' => 1, 'com_media' => 1, 'com_menus' => 1, 'com_messages' => 1,
            'com_modules' => 1, 'com_newsfeeds' => 1, 'com_plugins' => 1, 'com_postinstall' => 1,
            'com_privacy' => 1, 'com_redirect' => 1, 'com_scheduler' => 1, 'com_search' => 1,
            'com_tags' => 1, 'com_templates' => 1, 'com_users' => 1, 'com_workflow' => 1,
            'com_wrapper' => 1,
        );
    }

    /**
     * Wide-Restore: gesicherte Scope-Dateien, die jetzt fehlen oder abweichen, aus dem
     * CAS zuruecksetzen (diff-basiert - korrekte Dateien werden nicht angefasst). Faengt
     * die Datei-Kaskade fremder Erweiterungen (Astroid: Plugin/Module/Captcha).
     * Best-effort; ['ok'=>bool,'restored'=>int].
     *
     * @param   int     $eid     Snapshot-Eigner
     * @param   string  $snapId  Snapshot-ID
     *
     * @return array
     */
    public static function restoreWide($eid, $snapId)
    {
        $rootDir = self::backupRoot();

        if ($rootDir === '' || (int) $eid <= 0 || !preg_match(self::SNAP_RE, (string) $snapId)) {
            return array('ok' => false, 'restored' => 0);
        }

        $file = $rootDir . '/' . ((int) $eid) . '/' . $snapId . '/widefiles.json';

        if (!is_file($file)) {
            return array('ok' => true, 'restored' => 0, 'skipped' => true); // kein Wide-Backup
        }

        $data = json_decode((string) @file_get_contents($file), true);

        if (!is_array($data) || !isset($data['files']) || !is_array($data['files'])) {
            return array('ok' => false, 'restored' => 0);
        }

        $ok = true;
        $n  = 0;

        foreach ($data['files'] as $f) {
            if (!is_array($f) || !isset($f['root'], $f['rel'])) {
                continue;
            }

            $hash = isset($f['hash']) ? (string) $f['hash'] : '';

            if (!preg_match(self::SHA256_RE, $hash)) {
                continue; // war zu gross/uebersprungen -> nichts wiederherzustellen
            }

            $root = ($f['root'] === 'admin') ? 'admin' : 'site';
            $rel  = (string) $f['rel'];

            if (!self::relSafe($rel)) {
                continue;
            }

            $base = self::absBase($root);
            $abs  = $base . '/' . $rel;

            if (!self::within($abs, $base)) {
                continue;
            }

            // Schon korrekt? -> nicht anfassen (diff-basiert).
            if (is_file($abs) && @hash_file('sha256', $abs) === $hash) {
                continue;
            }

            // Blob bevorzugt komprimiert (<hash>.PACK_EXT), sonst Alt-Klartext.
            $src = self::casBlobFor($hash);

            if ($src === '') {
                $ok = false;
                continue;
            }

            $dir = dirname($abs);

            if (!is_dir($dir)) {
                @mkdir($dir, 0755, true);
            }

            // ENTPACKEN (bzw. Klartext-Kopie bei Alt-Blobs); unpackOne setzt die Rechte.
            $mode = isset($f['mode']) ? ((int) $f['mode'] & 0777) : 0;

            if (self::unpackOne($src, $abs, $mode ?: 0644)) {
                $n++;
            } else {
                $ok = false;
            }
        }

        return array('ok' => $ok, 'restored' => $n);
    }

    /**
     * CAS-Mark-Sweep: Inhalte loeschen, auf die kein widefiles.json mehr zeigt (nach
     * dem Pruning). Best-effort, fail-open.
     *
     * @return void
     */
    public static function casGc()
    {
        $dir  = self::casDir();
        $root = self::backupRoot();

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

        // 1) Alle noch referenzierten Hashes sammeln.
        $referenced = array();

        foreach (self::listExtIds() as $gid) {
            foreach (self::listSnapshots($gid) as $snap) {
                if (!isset($snap['id'])) {
                    continue;
                }

                $wf = $root . '/' . ((int) $gid) . '/' . $snap['id'] . '/widefiles.json';

                if (!is_file($wf)) {
                    continue;
                }

                $data = json_decode((string) @file_get_contents($wf), true);

                if (is_array($data) && isset($data['files']) && is_array($data['files'])) {
                    foreach ($data['files'] as $f) {
                        if (isset($f['hash']) && preg_match(self::SHA256_RE, (string) $f['hash'])) {
                            $referenced[(string) $f['hash']] = true;
                        }
                    }
                }
            }
        }

        // 2) CAS-Shards durchgehen, Unreferenzierte loeschen.
        $shards = @scandir($dir);

        if (!is_array($shards)) {
            return;
        }

        foreach ($shards as $shard) {
            if (!preg_match('~^[a-f0-9]{2}$~', (string) $shard)) {
                continue;
            }

            $sdir  = $dir . '/' . $shard;
            $items = @scandir($sdir);

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

            $elen = strlen(self::PACK_EXT);

            foreach ($items as $h) {
                // Blobs liegen komprimiert (<hash>.PACK_EXT) ODER als Alt-Klartext (<hash>) vor.
                // Fuer den Referenz-Abgleich zaehlt der reine Hash (= Name ohne PACK_EXT).
                $base = (substr((string) $h, -$elen) === self::PACK_EXT)
                    ? substr((string) $h, 0, -$elen)
                    : (string) $h;

                if (!preg_match(self::SHA256_RE, $base) || isset($referenced[$base])) {
                    continue;
                }

                @chmod($sdir . '/' . $h, 0600);
                @unlink($sdir . '/' . $h);
            }
        }
    }

    /**
     * Praefix von einem realen Tabellennamen abstreifen (fuer die Core-Pruefung).
     *
     * @param   object  $db    DB-Treiber
     * @param   string  $name  Tabellenname inkl. Praefix
     *
     * @return string
     */
    private static function stripPrefix($db, $name)
    {
        $prefix = method_exists($db, 'getPrefix') ? (string) $db->getPrefix() : '';

        return ($prefix !== '' && strpos($name, $prefix) === 0) ? substr($name, strlen($prefix)) : $name;
    }

    /**
     * Zeilen in eine Tabelle einfuegen - parametrisiert, in Batches. Spaltennamen
     * strikt validiert (sonst Abbruch), Werte ueber $db->quote().
     *
     * @param   object  $db     DB-Treiber
     * @param   string  $table  Validierter Tabellenname
     * @param   array   $rows   Assoziative Zeilen
     *
     * @return void
     */
    private static function insertRows($db, $table, $rows)
    {
        if (empty($rows)) {
            return;
        }

        $first = reset($rows);

        if (!is_array($first)) {
            return;
        }

        $cols = array_keys($first);
        $colQ = array();

        foreach ($cols as $c) {
            if (!preg_match(self::IDENT_RE, (string) $c)) {
                return; // verdaechtiger Spaltenname -> kompletten Insert abbrechen
            }

            $colQ[] = self::quoteIdent((string) $c);
        }

        $tableQ = self::quoteIdent($table);
        $head   = 'INSERT INTO ' . $tableQ . ' (' . implode(',', $colQ) . ') VALUES ';
        $batch  = array();

        foreach ($rows as $r) {
            if (!is_array($r)) {
                continue;
            }

            $vals = array();

            foreach ($cols as $c) {
                $v      = isset($r[$c]) ? $r[$c] : null;
                $vals[] = ($v === null) ? 'NULL' : $db->quote((string) $v);
            }

            $batch[] = '(' . implode(',', $vals) . ')';

            if (count($batch) >= 200) {
                $db->setQuery($head . implode(',', $batch))->execute();
                $batch = array();
            }
        }

        if (!empty($batch)) {
            $db->setQuery($head . implode(',', $batch))->execute();
        }
    }
}
