<?php

/**
 * HtpBackup — Voll-Backup-Engine (Dateien + Datenbank), gesteuert ausschliesslich remote ueber
 * htprotect.app (signierte backup.*-Befehle). Im Joomla-Backend UNSICHTBAR.
 *
 * DATENSPARSAMKEIT (ehrlich, nicht "Zero-Knowledge"): Der Heartbeat-Report (reportPublic) und die
 * Backup-Liste tragen nur Zaehler/Groessen + die opake Referenz, nie Inhalte. Die AUSSCHLUSS-ANALYSE
 * (backup.analyze) uebertraegt dagegen BEWUSST Klartext: die relativen Pfade der groessten
 * Verzeichnisse/Dateien und die echten Tabellennamen inklusive Praefix - anders koennte der
 * Betreiber im Dashboard nicht auswaehlen, was vom Backup ausgenommen wird. Diese Werte werden
 * dort angezeigt und gespeichert. Backup-INHALTE (Bytes) verlassen die Site nie.
 *
 * LEITSATZ (docs/BACKUP-DESIGN.md): PHP-nativ ist die Pflicht-Basis (laeuft auf JEDEM Billig-Host);
 * native Wege (mysqldump/tar) sind reine Optimierung mit deterministischem Fallback beim ERSTEN
 * echten Fehler. Diese Datei (Stufe 0) liefert die adaptive CAPABILITY-PROBE + das Job-/Befehls-
 * Geruest. Die schweren Engines (DB-Dump, Datei-Archiv, Analyse, Download, Restore) folgen in den
 * naechsten Stufen und haengen sich hier ein.
 *
 * @package  com_htprotect
 */

defined('_JEXEC') or defined('HTP_ENGINE') or die;

class HtpBackup
{
    /** Unterordner im State-Verzeichnis (ueberlebt Updates, web-geschuetzt). */
    const DIR = 'backup';

    /** Capability-Cache-Datei (HMAC-signiert, env-verschluesselt). */
    const CAPS_FILE = 'caps.json';

    /** Cache-TTL der Capability-Probe (7 Tage) - zusaetzlich zur env-Key-Invalidierung. */
    const CAPS_TTL = 604800;

    /**
     * Obergrenze fuer EIN INSERT-Statement im DB-Dump (~512 KB VALUES). Grosse/adaptive Batches oder
     * breite Zeilen wuerden sonst zu einem einzigen Riesen-INSERT fuehren, das am Reimport an
     * max_allowed_packet scheitert (auf billigen Hosts oft nur 1-4 MB). Wird die Grenze erreicht,
     * bricht der Dumper das INSERT um und beginnt ein neues - byte-identische Daten, nur mehr, kleinere
     * Statements. Der Restore-Reader fuehrt jede ';'-Zeile einzeln aus, das vertraegt er problemlos.
     */
    const DB_INSERT_CAP = 524288;

    /**
     * Ab wann ein manifest-loser Backup-Ordner als abgebrochene Waise gilt und geraeumt werden darf
     * (12 h). BEWUSST deutlich ueber dem Job-Gesamt-Cap (HtpLink::JOB_MAX = 6 h): ein noch laufendes
     * oder nur stockendes Backup (grosse DB auf langsamem Host) darf NIE geloescht werden. Zusammen
     * mit der Pruefung der JUENGSTEN Datei-mtime im Ordner (nicht der Ordner-mtime, die sich beim
     * Anhaengen an db.sql.gz nicht aendert) ist der Cleanup damit auch waehrend eines aktiven,
     * stundenlangen Backups gefahrlos - er kann im taeglichen Waechter-Sweep laufen.
     */
    const ORPHAN_TTL = 43200;

    /** Analyse-Baum-Cache (HMAC-signiert): EIN tiefer Walk, danach Drill-down ohne Neu-Walk. */
    const ANALYZE_FILE = 'analyze.json';

    /** Gueltigkeit des Analyse-Cache (10 Min) - danach neuer Wurzel-Scan noetig. */
    const ANALYZE_TTL = 600;

    /** Freier-Platz-Reserve: so viel bleibt beim Datei-Backup IMMER frei - die Platte wird nie
     *  bis zum letzten Byte vollgeschrieben (schuetzt den Host). Unterschreitet der Platz das,
     *  bricht das Backup SAUBER + FRUEH mit Grund 'disk_full' ab (statt spaet mit vagem zip_write). */
    const FILES_MIN_FREE = 104857600;   // 100 MB

    /** Der Schreib-Budget-Schutz wird NUR "scharf" geschaltet, wenn weniger als das frei ist -
     *  bei reichlich Platz (die grosse Mehrheit) laeuft die Datei-Schleife voellig ohne Zusatz-
     *  Pruefung (kein filesize()-Overhead) = volle Performance. Host-adaptiv. */
    const FILES_GUARD_ARM = 2147483648; // 2 GB

    // ─────────────────────────────────────────────────────────────────────────
    //  State-Verzeichnis
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Basis-Verzeichnis fuer Backups + Job-State (unter dem geschuetzten State-Dir). Legt es bei
     * Bedarf an. '' bei fehlendem State-Dir (dann degradiert der Aufrufer sauber).
     *
     * @return string  Pfad mit abschliessendem '/', oder ''
     */
    public static function dir()
    {
        // Neuer Ablageort /administrator/_htprotect_backups (gehaertet, leicht ausschliessbar);
        // htp_backup_root() faellt bei Bedarf auf den bisherigen State-Ordner zurueck und stoesst
        // die Migration bestehender Backups an. Alt-Fallback, falls compat noch ohne die Funktion.
        $base = function_exists('htp_backup_root')
            ? htp_backup_root()
            : (function_exists('htp_state_dir') ? htp_state_dir() : '');

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

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

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

        return is_dir($dir) ? $dir . '/' : '';
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Capability-Probe (adaptiv, real getestet, gecacht) — der Kern von robust+schnell
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Was kann dieser Host? Einmal pro Job real getestet (nicht vermutet), Ergebnis gecacht.
     * Cache invalidiert automatisch bei env-Aenderung (PHP-Version, disable_functions, open_basedir,
     * SAPI, uname, DB-Host) und nach TTL. HMAC-signiert: ein manipuliertes Cache-File kann keinen
     * unsicheren Pfad erzwingen. Fail-safe: bei jedem Zweifel die LANGSAME, robuste Faehigkeit.
     *
     * @param   array  $st  Link-State (fuer den HMAC-Schluessel = transport_secret)
     *
     * @return array {
     *   ziparchive:bool, phardata:bool, deflate_stream:bool, gz:bool,
     *   memory_limit:int(bytes,-1=unbegrenzt), disk_free:int(bytes), probed_at:int, env:string
     * }
     */
    public static function capabilities($st = array())
    {
        $key = is_array($st) && isset($st['transport_secret']) ? (string) $st['transport_secret'] : '';
        $env = self::envKey();

        $cached = self::capsRead($key);

        if (is_array($cached)
            && isset($cached['env']) && $cached['env'] === $env
            && isset($cached['probed_at']) && (time() - (int) $cached['probed_at']) < self::CAPS_TTL) {
            return $cached;
        }

        $caps = self::probeFresh();
        $caps['env']       = $env;
        $caps['probed_at'] = time();

        self::capsWrite($caps, $key);

        return $caps;
    }

    /**
     * Env-Fingerabdruck: aendert sich einer der Faktoren, ist der Capability-Cache ungueltig
     * (anderer Host / andere PHP-Konfig kann andere schnelle Wege haben oder verbieten).
     *
     * @return string  16 Hex
     */
    private static function envKey()
    {
        $parts = array(
            PHP_VERSION,
            (string) @ini_get('disable_functions'),
            (string) @ini_get('open_basedir'),
            PHP_SAPI,
            function_exists('php_uname') ? php_uname('a') : '',
            function_exists('htp_db') ? self::dbHost() : '',
        );

        return substr(hash('sha256', implode('|', $parts)), 0, 16);
    }

    /** DB-Host (nur fuer den env-Key; leise bei Fehler). */
    private static function dbHost()
    {
        try {
            $db = htp_db();

            if ($db && method_exists($db, 'getConnection')) {
                // Kein Zugriff auf Host im Treiber garantiert -> Klassenname als grober Marker.
                return get_class($db);
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return '';
    }

    /**
     * Die eigentliche Probe — rein PHP, PROZESS-FREI. HTProtect ruft BEWUSST keine externen
     * Prozesse / Shell-Kommandos auf (Security-Prinzip des Connectors + AV-Sauberkeit): ein natives
     * Dump-/Archiv-Binary waere zwar minimal schneller, wuerde aber die AV-/WAF-Tools der Kunden
     * triggern und widerspraeche dem prozess-freien Design. Der reine-PHP-Weg (EXTENDED-INSERTs,
     * gzip-Streaming, grosse Batches, Self-Trigger-Verkettung) ist die robuste UND schnelle Basis.
     *
     * @return array  Capability-Map (ohne env/probed_at)
     */
    private static function probeFresh()
    {
        return array(
            'ziparchive'     => self::testZipArchive(),
            'phardata'       => class_exists('PharData') && (int) @ini_get('phar.readonly') === 0,
            // deflate_init = inkrementelles Streaming (kein RAM-Peak) vs. nur gzencode (ganzer Puffer).
            'deflate_stream' => function_exists('deflate_init') && function_exists('deflate_add'),
            'gz'             => function_exists('gzopen') && function_exists('gzwrite'),
            'memory_limit'   => self::bytesIni((string) @ini_get('memory_limit')),
            'disk_free'      => self::diskFree(),
        );
    }

    /** ZipArchive wirklich nutzbar? (Klasse da != Feature da) - 1-Eintrag-Zip bauen/pruefen/loeschen. */
    private static function testZipArchive()
    {
        if (!class_exists('ZipArchive')) {
            return false;
        }

        $dir = self::dir();

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

        $probe = $dir . 'ziptest_' . substr(hash('sha256', uniqid('', true)), 0, 10) . '.zip';
        $ok    = false;

        try {
            $z = new ZipArchive();

            if ($z->open($probe, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
                $z->addFromString('t', 'x');
                $z->close();

                $v = new ZipArchive();

                if ($v->open($probe) === true) {
                    $ok = ($v->numFiles === 1);
                    $v->close();
                }
            }
        } catch (Exception $e) {
            $ok = false;
        } catch (Throwable $e) {
            $ok = false;
        }

        @unlink($probe);

        return $ok;
    }

    /** ini-Groesse ('128M', '1G', '-1') -> Bytes. -1 = unbegrenzt (durchgereicht). */
    private static function bytesIni($v)
    {
        $v = trim((string) $v);

        if ($v === '' || $v === '0') {
            return 0;
        }

        if ($v === '-1') {
            return -1;
        }

        $unit = strtolower(substr($v, -1));
        $num  = (int) $v;

        switch ($unit) {
            case 'g':
                return $num * 1073741824;
            case 'm':
                return $num * 1048576;
            case 'k':
                return $num * 1024;
            default:
                return (int) $v;
        }
    }

    /** Freier Platz im Backup-Zielverzeichnis in Bytes (0 bei Unbekannt). */
    private static function diskFree()
    {
        $dir = self::dir();

        if ($dir === '' || !function_exists('disk_free_space')) {
            return 0;
        }

        $b = @disk_free_space($dir);

        return is_numeric($b) ? (int) $b : 0;
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Capability-Cache (HMAC-signiert)
    // ─────────────────────────────────────────────────────────────────────────

    private static function capsRead($key)
    {
        $dir = self::dir();

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

        $file = $dir . self::CAPS_FILE;

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

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

        if (!is_array($j) || !isset($j['data']) || !isset($j['mac']) || !is_array($j['data'])) {
            return null;
        }

        $expect = hash_hmac('sha256', json_encode($j['data']), $key);

        // Manipuliertes/fremdes Cache-File ignorieren (kein unsicherer Pfad durch Tampering).
        if (!hash_equals($expect, (string) $j['mac'])) {
            return null;
        }

        return $j['data'];
    }

    private static function capsWrite($caps, $key)
    {
        $dir = self::dir();

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

        $payload = array(
            'data' => $caps,
            'mac'  => hash_hmac('sha256', json_encode($caps), $key),
        );

        @file_put_contents($dir . self::CAPS_FILE, json_encode($payload), LOCK_EX);
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Befehls-Einstiege (Dispatch aus HtpLink) — Stufe 0: Geruest.
    //  Die schweren Engines (Analyse/Dump/Archiv/Download/Restore) folgen in den
    //  naechsten Stufen und ersetzen die 'not_ready'-Stubs hier.
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Ein Backup starten (backup.run). Stufe 0: noch nicht scharf - meldet ehrlich 'not_ready',
     * bis DB- + Datei-Engine (Stufen 1/2) eingehaengt sind. KEIN halbes Backup.
     *
     * @param   array  &$st     Link-State
     * @param   string  $cid    Command-ID
     * @param   array   $params Befehls-Parameter (kind, exclude_dirs, exclude_tables, …)
     *
     * @return array
     */
    public static function start(&$st, $cid, $params)
    {
        if (!empty($st['job'])) {
            return array('ok' => false, 'code' => 'busy');
        }

        $dir = self::dir();

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

        if (!function_exists('htp_db') || !htp_db()) {
            return array('ok' => false, 'code' => 'no_db');
        }

        // Vor JEDEM Start (auch manuell) abgebrochene/fehlgeschlagene Ordner raeumen, damit sie sich
        // nie ansammeln. Sicher: der busy-Guard oben garantiert, dass gerade KEIN Backup laeuft, und
        // der neue Ordner existiert noch nicht.
        self::cleanFailedBackups(time());

        $kind = isset($params['kind']) ? (string) $params['kind'] : 'full';

        if (!in_array($kind, array('full', 'files', 'db', 'incr'), true)) {
            $kind = 'full';
        }

        // Inkrement (nur Dateien-Delta) braucht ein Eltern-Backup mit Datei-Zustand als Anker;
        // gibt es keines, wird automatisch ein Voll-Backup gemacht.
        $parentRef = ($kind === 'incr') ? self::latestBackupRef() : '';

        if ($kind === 'incr' && $parentRef === '') {
            $kind = 'full';
        }

        $id     = self::newId();                       // opake 24-Hex-Referenz = Handle fuer Dashboard/Download
        $host   = self::hostSlug();
        $stamp  = self::stampNow(time());
        $prefix = $host . '-' . $stamp;                // lesbarer Anfang (Site + Datum) fuer Ordner UND Dateien

        // EIN Ordner statt zwei: lesbarer Anfang + unerratbarer Zufalls-Suffix. Der Suffix ist ein
        // frischer Zufall (NICHT die Referenz) - so bleibt der Pfad selbst dann unerratbar, wenn die
        // per Heartbeat/Download bekannte Referenz einem Angreifer vorliegt und der Server die .htaccess
        // nicht auswertet. Die Referenz findet den Ordner ueber das Manifest (bkid) wieder. Die Artefakte
        // liegen direkt in diesem Ordner (kein Unter-Unterordner mehr noetig).
        $secret  = self::newSecret();
        $dirName = $prefix . '-' . ($secret !== '' ? $secret : $id);
        $bdir    = $dir . $dirName . '/';

        if (!@mkdir($bdir, 0700, true) && !is_dir($bdir)) {
            return array('ok' => false, 'code' => 'mkdir_failed');
        }

        self::protectDir($dir, $st);

        // Ausgeschlossene Tabellen/Verzeichnisse (Stufe 5 liefert opake IDs via analyze; sonst leer
        // + Standard-Ausschluesse). Adaptive Archiv-Engine EINMAL hier waehlen (Probe braucht $st).
        // Ausschluesse = Befehls-Parameter PLUS die dauerhafte Betreiber-Liste (bk_excl_*), damit
        // grosse Verzeichnisse/Tabellen bei JEDEM Backup (geplant + manuell) draussen bleiben.
        $exclTables = self::sanitizeIdents(array_merge(
            isset($params['exclude_tables']) ? (array) $params['exclude_tables'] : array(),
            self::currentExcludeTables($st)
        ));
        $exclDirs   = self::sanitizeRels(array_merge(
            isset($params['exclude_dirs']) ? (array) $params['exclude_dirs'] : array(),
            self::currentExcludeDirs($st)
        ));
        // Optionaler Schalter: einzelne Dateien > 100 MB ausschliessen (Befehl ODER dauerhafter State).
        $exclBig = array_key_exists('exclude_bigfiles', $params) ? !empty($params['exclude_bigfiles'])
            : !empty($st['bk_excl_bigfiles']);
        // Getrennte Aufbewahrung je Typ: Voll-/Datei-Backups (keep_full) und Datenbank-Backups
        // (keep_db) rotieren UNABHAENGIG - "behalten" ist damit je Typ eindeutig. Aus dem Befehl ODER
        // dem Zeitplan-State; das alte gemeinsame 'bk_keep' dient beiden als Fallback, sonst Defaults
        // (Voll 3 / DB 14).
        $legacyKeep = isset($st['bk_keep']) ? max(1, min(400, (int) $st['bk_keep'])) : 0;
        $keepFull = isset($params['keep_full']) ? max(1, min(400, (int) $params['keep_full']))
            : (isset($st['bk_keep_full']) ? max(1, min(400, (int) $st['bk_keep_full'])) : ($legacyKeep ?: 3));
        $keepDb = isset($params['keep_db']) ? max(1, min(400, (int) $params['keep_db']))
            : (isset($st['bk_keep_db']) ? max(1, min(400, (int) $st['bk_keep_db'])) : ($legacyKeep ?: 14));
        $thin = array_key_exists('thin', $params) ? (!empty($params['thin']) ? 1 : 0)
            : (!empty($st['bk_thin']) ? 1 : 0);

        $caps   = self::capabilities($st);
        $engine = !empty($caps['ziparchive']) ? 'zip' : (!empty($caps['gz']) ? 'targz' : 'tar');

        // Adaptive Batch-Groessen: mehr verfuegbarer RAM -> groessere Haeppchen (weniger Round-Trips
        // = schneller), auf knappen Hostern konservativ (speichersicher). -1 = unbegrenzt.
        $mem = (int) $caps['memory_limit'];

        if ($mem === -1 || $mem >= 268435456) {        // >= 256 MB
            $dbBatch = 2000;
            $filesBatch = 8000;
        } elseif ($mem >= 134217728) {                 // >= 128 MB
            $dbBatch = 1000;
            $filesBatch = 5000;
        } else {                                        // knapp
            $dbBatch = 500;
            $filesBatch = 2500;
        }

        // Speicher-Budget (ROHDATEN) je DB-Datenblock. Der feste $dbBatch (Zeilenzahl) ist auf 128-MB-
        // Hostern fuer Tabellen mit SEHR grossen Zeilen toedlich: 1000 Zeilen der Joomla-Smart-Search-
        // Tabelle finder_links (~42 KB/Zeile) = ~42 MB Rohdaten, per loadAssocList vervielfacht -> OOM,
        // der DB-Dump bleibt genau dort stehen (gemeldet an literatopia.de). Deshalb wird der Batch pro
        // Tabelle an ihrer AVG_ROW_LENGTH heruntergerechnet (dbAdaptiveBatch), sodass ein Datenblock nie
        // mehr als dieses Budget an Rohdaten laedt. ~4 % des Limits, 1..6 MB; unbegrenzt -> 6 MB.
        $dbMemBudget = ($mem > 0) ? max(1048576, min(6291456, (int) floor($mem * 0.04))) : 6291456;

        $st['job'] = array(
            'type'         => 'backup',
            'cmd_id'       => (string) $cid,
            'bkid'         => $id,
            'bkdir'        => $dirName,    // tatsaechlicher Ordnername (die Referenz steckt NICHT im Namen)
            'bkprefix'     => $prefix,     // Datei-Praefix <host>-<stamp> fuer die Artefakt-Namen
            'kind'         => $kind,
            // Inkrement + reines Dateien-Backup ueberspringen die DB (Inkr = nur Dateien-Delta).
            'phase'        => ($kind === 'files' || $kind === 'incr') ? 'files' : 'db',
            'parent_ref'   => $parentRef,   // Basis-Backup fuer das Inkrement ('' sonst)
            'started'      => time(),
            // Fortschritts-Zeitstempel: Basis der Haenger-Erkennung (HtpLink::jobStale).
            'advanced'     => time(),
            'progress'     => 0.0,
            'excl_tables'  => $exclTables,
            'excl_dirs'    => $exclDirs,
            'excl_bigfiles' => $exclBig ? 1 : 0,   // einzelne Dateien > 100 MB ausschliessen
            'keep_full'    => $keepFull,  // Aufbewahrung Voll-/Datei-Backups (eigener Pool)
            'keep_db'      => $keepDb,    // Aufbewahrung Datenbank-Backups (eigener Pool)
            'thin'         => $thin,   // gestaffelte Retention (Ausduennung alter Backups)
            // DB-Cursor:
            'db_tables'    => null,
            'db_idx'       => 0,
            'db_key'       => null,
            'db_struct'    => false,
            'db_rows'      => 0,
            'db_total'     => 0,     // geschaetzte Gesamt-Zeilen (fuer zeilenbasierten Fortschritt)
            'tables_n'     => 0,
            'db_batch'     => $dbBatch,      // Basis-Zeilenzahl je Datenblock (nach memory_limit)
            'db_membudget' => $dbMemBudget,  // Rohdaten-Budget je Datenblock -> OOM-Schutz bei Riesenzeilen
            'db_tbatch'    => null,          // pro Tabelle heruntergerechneter Batch (dbAdaptiveBatch)
            'db_extras'    => false,         // Views + Trigger (nach den Basis-Tabellen)
            // Datei-Cursor:
            'files_engine' => $engine,
            'files_built'  => false,
            'files_idx'    => 0,
            'files_n'      => 0,
            'files_batch'  => $filesBatch,   // adaptiv nach memory_limit
        );

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

    /** Dauerhaft ausgeschlossene Verzeichnisse (Betreiberwahl), aus dem Link-State. */
    private static function currentExcludeDirs($st)
    {
        return self::sanitizeRels(isset($st['bk_excl_dirs']) && is_array($st['bk_excl_dirs']) ? $st['bk_excl_dirs'] : array());
    }

    /** Dauerhaft ausgeschlossene DB-Tabellen (Betreiberwahl), aus dem Link-State. */
    private static function currentExcludeTables($st)
    {
        return self::sanitizeIdents(isset($st['bk_excl_tables']) && is_array($st['bk_excl_tables']) ? $st['bk_excl_tables'] : array());
    }

    /**
     * Dauerhafte Ausschluss-Liste setzen (backup.exclude). Gilt fuer ALLE kuenftigen Backups
     * (geplant + manuell). Streng sanitisiert (rel. Pfade ohne '..', Tabellen [A-Za-z0-9_]).
     *
     * @return array { ok:bool, code:string, dirs:int, tables:int }
     */
    public static function setExcludes(&$st, $params)
    {
        $dirs   = self::sanitizeRels(isset($params['dirs']) ? $params['dirs'] : array());
        $tables = self::sanitizeIdents(isset($params['tables']) ? $params['tables'] : array());

        $st['bk_excl_dirs']   = $dirs;
        $st['bk_excl_tables'] = $tables;
        $st['bk_excl_bigfiles'] = !empty($params['bigfiles']) ? 1 : 0;

        return array('ok' => true, 'code' => 'backup_excludes_set',
            'dirs' => count($dirs), 'tables' => count($tables), 'bigfiles' => $st['bk_excl_bigfiles']);
    }

    /**
     * Analyse (backup.analyze): die groessten Top-Level-Verzeichnisse + DB-Tabellen, damit der
     * Betreiber gezielt grosse Objekte vom Backup ausschliessen kann. Zeitbudgetiert (grosse Sites
     * liefern Teilergebnisse). Ergebnis kompakt im 'log'-Feld (Relay-Limit 2000 Zeichen):
     *   {"d":[[pfad,bytes,excl],...],"t":[[tabelle,bytes,excl],...],"p":partial}
     *
     * @return array { ok:bool, code:string, log:string }
     */
    public static function analyze(&$st, $cid, $params)
    {
        $root = self::siteRoot();

        if ($root === '' || !is_dir($root)) {
            return array('ok' => false, 'code' => 'no_root');
        }

        $deadline  = microtime(true) + 5.0;
        $exclDirs  = self::currentExcludeDirs($st);          // Betreiber-Wahl -> nur fuer die 'excl'-Markierung
        $sizeExcl  = self::defaultExcludeDirs();              // IMMER-Ausschluesse (inkl. Akeeba) -> aus der Groesse raus
        $skipBig   = !empty($st['bk_excl_bigfiles']);

        // Eigener Data-Ordner (dort liegen HTProtects Backups + State) - NIE in der Analyse zeigen,
        // sonst zaehlt "administrator" die eigenen Backups mit und waechst scheinbar staendig.
        $selfDir = function_exists('htp_state_dir') ? (string) htp_state_dir() : '';
        $selfRel = $selfDir !== '' ? self::relOf($root, rtrim(str_replace('\\', '/', $selfDir), '/')) : '';

        // Drill-down: optionaler Unter-Pfad (rel, ohne '..'). Muss ein existierendes Verzeichnis UNTER
        // dem Docroot sein (realpath-Containment gegen Symlink-Ausbruch) - sonst zurueck auf die Wurzel.
        $rels = self::sanitizeRels(isset($params['path']) ? array($params['path']) : array());
        $path = !empty($rels) ? (string) $rels[0] : '';
        $base = $path === '' ? $root : $root . '/' . $path;

        if ($path !== '') {
            $rp = @realpath($base);
            $rr = @realpath($root);

            if ($rp === false || $rr === false || strpos($rp . '/', $rr . '/') !== 0 || !is_dir($base)) {
                $path = '';
                $base = $root;
            }
        }

        $partial  = 0;
        $more     = 0;
        $big      = self::bigFileBytes();
        $key      = (is_array($st) && isset($st['transport_secret'])) ? (string) $st['transport_secret'] : '';
        $token    = '';

        // Optionaler Scan-Token: bindet Drill-down/Fortsetzung an genau den Baum/Lauf der App.
        $scan = (isset($params['scan']) && is_string($params['scan'])) ? preg_replace('~[^a-f0-9]~', '', $params['scan']) : '';

        if (strlen($scan) > 32) {
            $scan = substr($scan, 0, 32);
        }

        $cont = !empty($params['cont']); // Wurzel-Analyse fortsetzen (resumierbar)

        $dirs     = null;    // [[rel,bytes,exclFlag],...] - erst wenn eine Quelle geliefert hat
        $bigFiles = array(); // [[rel,bytes,alwaysFlag(0|2)],...]

        $cache = self::analyzeCacheRead($key);

        // ── Drill-down: Ebene direkt aus dem FERTIGEN Cache-Baum (kein Dateisystem-Zugriff). ──
        $freshTree = is_array($cache) && isset($cache['ts'], $cache['tree'], $cache['skipBig'])
            && is_array($cache['tree'])
            && (time() - (int) $cache['ts'] < self::ANALYZE_TTL)
            && (int) $cache['skipBig'] === ($skipBig ? 1 : 0);

        if ($freshTree && $path !== '' && $scan !== '' && isset($cache['token'])
            && hash_equals((string) $cache['token'], $scan)
            && array_key_exists($path, $cache['tree'])) {
            $token   = (string) $cache['token'];
            $partial = !empty($cache['partial']) ? 1 : 0;
            list($dirs, $bigFiles) = self::analyzeLevelFrom($cache, $path, $exclDirs);
        } elseif ($path === '') {
            // ── Wurzel: RESUMIERBARE Enumeration ── vollstaendig (nichts entgeht), NIE Abbruch (jedes
            // Haeppchen bleibt sicher unter der PHP-Zeitgrenze), mit Fortschritt. Ist der Baum in einem
            // Haeppchen fertig (Normalfall kleiner/mittlerer Sites), wird sofort der KOMPLETTE Cache
            // geschrieben und die Wurzel-Ebene geliefert; sonst sichern wir den Stand und die App ruft
            // mit cont weiter (Fortschrittsanzeige dazwischen).
            $enumFresh = is_array($cache) && isset($cache['ts'], $cache['enum'], $cache['skipBig'], $cache['token'])
                && is_array($cache['enum'])
                && (time() - (int) $cache['ts'] < self::ANALYZE_TTL)
                && (int) $cache['skipBig'] === ($skipBig ? 1 : 0);

            if ($cont && $enumFresh && $scan !== '' && hash_equals((string) $cache['token'], $scan)) {
                $token = (string) $cache['token'];
                $enum  = $cache['enum'];
            } else {
                $token = bin2hex(random_bytes(6));
                $enum  = array('stack' => array(''), 'dirs' => array(), 'prog' => 0, 'bf' => 0, 'cap' => 0);
            }

            // Chunk-Budget bewusst kurz + sicher unter jeder verntuenftigen max_execution_time; laesst
            // Luft fuer das Sichern des Enum-Stands (I/O). Grosse Sites brauchen einfach mehr Haeppchen.
            $enumDeadline = microtime(true) + 4.0;
            self::enumChunk($enum, $root, $sizeExcl, $skipBig, $selfRel, $big, $enumDeadline);

            if (empty($enum['stack'])) {
                // FERTIG: kumulieren -> kompletten Cache schreiben -> Wurzel-Ebene daraus liefern.
                list($tree, $filesMap) = self::enumRollup($enum);
                $partial = !empty($enum['cap']) ? 1 : 0; // 'p' NUR noch im extremen Deckel-/Monster-Ordner-Fall
                $done = array(
                    'ts'      => time(),
                    'token'   => $token,
                    'skipBig' => $skipBig ? 1 : 0,
                    'partial' => $partial,
                    'tree'    => $tree,
                    'files'   => $filesMap,
                );
                self::analyzeCacheWrite($done, $key);
                list($dirs, $bigFiles) = self::analyzeLevelFrom($done, '', $exclDirs);
            } else {
                // NOCH NICHT FERTIG: Enum-Stand sichern, nur Fortschritt zurueck. Kein Ergebnis-Trimmen
                // noetig (leere Listen) -> direkt zurueck.
                self::analyzeCacheWrite(array(
                    'ts'      => time(),
                    'token'   => $token,
                    'skipBig' => $skipBig ? 1 : 0,
                    'enum'    => $enum,
                    'done'    => 0,
                ), $key);

                $prog = (int) (isset($enum['prog']) ? $enum['prog'] : 0);
                $log  = (string) json_encode(
                    array('d' => array(), 't' => array(), 'f' => array(), 'p' => 1, 'more' => 1, 'prog' => $prog, 'cur' => '', 's' => $token),
                    JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE
                );

                return array('ok' => true, 'code' => 'backup_analyzing', 'log' => $log);
            }
        }

        // Fallback (Drill-down ohne frischen Cache, oder State-Dir nicht schreibbar): heutiges
        // Einzel-Ebenen-Verhalten - immer korrekt, nur ohne den Cache-Turbo. Fail-open.
        if ($dirs === null) {
            list($dirs, $bigFiles) = self::analyzeLevelLive($root, $base, $path, $deadline, $exclDirs, $sizeExcl, $skipBig, $selfRel, $big, $partial);
        }

        $bySize = function ($a, $b) {
            return ($b[1] < $a[1]) ? -1 : (($b[1] > $a[1]) ? 1 : 0);
        };

        usort($dirs, $bySize);
        $dirs = array_slice($dirs, 0, 12);

        // Grosse Einzeldateien: sortieren, Top-12. VOLLER Pfad (fuer die exakte Abwahl); das Frontend
        // kuerzt die Anzeige per CSS. Flag: 2 = immer ausgeschlossen (Akeeba/Archiv, nicht abwaehlbar),
        // 1 = vom Betreiber ausgeschlossen, 0 = im Backup.
        usort($bigFiles, $bySize);
        $files = array();

        foreach (array_slice($bigFiles, 0, 12) as $bf) {
            $fp   = (string) $bf[0];
            $flag = (isset($bf[2]) && (int) $bf[2] === 2) ? 2 : (in_array($fp, $exclDirs, true) ? 1 : 0);
            $files[] = array($fp, (int) $bf[1], $flag);
        }

        // Tabellen nur auf der obersten Ebene (beim Drill-down in ein Verzeichnis irrelevant).
        $tables = $path === '' ? self::largestTables(12, self::currentExcludeTables($st)) : array();

        // Unter das Log-Limit halten: reportResult kappt BYTE-basiert (substr .. 2000) -> deshalb
        // auch hier BYTE-basiert trimmen (strlen), sonst schneidet der Connector eine Nicht-ASCII-
        // Datei mitten in der UTF-8-Sequenz ab = kaputtes JSON. UNESCAPED_UNICODE haelt es kompakt
        // (3 Byte/CJK statt 6 fuer \uXXXX). Lange Datei-Pfade zuerst, dann Tabellen/Dirs bis Boden 4+4.
        // INVALID_UTF8_SUBSTITUTE: ein nicht-UTF-8-Dateiname (z. B. Latin-1-Umlaut aus readdir) wuerde
        // json_encode sonst false liefern -> leeres Log -> Analyse faellt still aus. Wird ersetzt (U+FFFD).
        $build = function () use (&$dirs, &$tables, &$files, $partial, $path, $token, $more) {
            return (string) json_encode(array('d' => $dirs, 't' => $tables, 'f' => $files, 'p' => $partial, 'more' => $more, 'cur' => $path, 's' => $token), JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
        };
        $log = $build();

        // Bis unter das Byte-Limit trimmen (bis notfalls 1 Verzeichnis) - so bleibt das JSON immer
        // gueltig, auch bei langen Drill-Pfaden im 'cur'-Feld.
        while (strlen($log) > 1900 && (count($files) > 0 || count($tables) > 0 || count($dirs) > 1)) {
            if (count($files) > 0) {
                array_pop($files);
            } elseif (count($tables) > 0) {
                array_pop($tables);
            } else {
                array_pop($dirs);
            }

            $log = $build();
        }

        return array('ok' => true, 'code' => 'backup_analyzed', 'log' => $log);
    }

    /**
     * EIN Haeppchen der resumierbaren Enumeration: verarbeitet Ordner vom Stapel bis zum Zeitbudget,
     * erfasst je Ordner die DIREKTEN Datei-Bytes (Groessen-Semantik BYTE-identisch zu dirSizeBudget:
     * Symlinks aus; self/cache/tmp/logs weder gelistet noch betreten noch gezaehlt; Akeeba/Archive +
     * - bei $skipBig - grosse Einzeldateien NICHT in die Summe, aber als grosse Dateien gesammelt) und
     * schiebt die Unterordner auf den Stapel. Die Post-Order-Summen macht spaeter enumRollup (im
     * Speicher, einmalig). NIE Abbruch: laeuft der Ordnerstapel nicht leer, kehrt die Funktion zurueck
     * und der Aufrufer sichert den Stand fuer die naechste Runde. Deckel (80000 Ordner) + Monster-
     * Ordner-Zeitbox (setzen 'cap' -> Ergebnis wird dann als Teilergebnis markiert), damit auch absurde
     * Baeume nie haengen.
     *
     * $enum = { stack:[rel,...], dirs:{ rel:{d:bytes,c:[childRel,...],f:[[rel,bytes,flag],...]} }, prog:int, bf:int, cap:0|1 }
     */
    private static function enumChunk(&$enum, $root, $sizeExcl, $skipBig, $selfRel, $big, $deadline)
    {
        $junk = array('cache' => true, 'tmp' => true, 'logs' => true);
        $cap  = 80000; // Gesamt-Ordner-Deckel (Sicherheitsnetz gegen OOM/Endlos)

        while (!empty($enum['stack'])) {
            if (microtime(true) > $deadline) {
                return; // Haeppchen voll -> Aufrufer sichert + App setzt fort
            }

            $rel = (string) array_pop($enum['stack']);

            if (isset($enum['dirs'][$rel])) {
                continue; // schon erfasst (Doppel-Push-Schutz)
            }

            $base = $rel === '' ? $root : $root . '/' . $rel;
            $dh   = @opendir($base);

            if ($dh === false) {
                $enum['dirs'][$rel] = array('d' => 0, 'c' => array(), 'f' => array());
                $enum['prog'] = count($enum['dirs']);
                continue;
            }

            $direct   = 0;
            $children = array();
            $bigF     = array();
            $seen     = 0;

            while (($f = readdir($dh)) !== false) {
                // Monster-Ordner (Millionen Eintraege) zeitboxen: NICHT abbrechen, aber diesen einen
                // Ordner nur bis zum Budget lesen -> seine Groesse dann approximativ ('cap').
                if ((++$seen % 1024) === 0 && microtime(true) > $deadline) {
                    $enum['cap'] = 1;
                    break;
                }

                if ($f === '.' || $f === '..') {
                    continue;
                }

                $p = $base . '/' . $f;

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

                $childRel = $rel === '' ? $f : $rel . '/' . $f;

                if (is_dir($p)) {
                    $lf = strtolower($f);

                    if (($selfRel !== '' && ($childRel === $selfRel || strpos($childRel, $selfRel . '/') === 0))
                        || isset($junk[$lf])) {
                        continue;
                    }

                    $children[] = $childRel;

                    if ((count($enum['dirs']) + count($enum['stack'])) < $cap) {
                        $enum['stack'][] = $childRel;
                    } else {
                        $enum['cap'] = 1; // Deckel -> tiefer nicht mehr; dieser Ast approximativ
                    }
                } else {
                    $fsz    = (int) @filesize($p);
                    $always = (self::relExcluded($childRel, $sizeExcl) || self::isBackupArchive($childRel)) ? 2 : 0;

                    if ($fsz > $big && (int) $enum['bf'] < 3000) {
                        $bigF[] = array($childRel, $fsz, $always);
                        $enum['bf'] = (int) $enum['bf'] + 1;
                    }

                    if (!$always && !($skipBig && $fsz > $big)) {
                        $direct += $fsz;
                    }
                }
            }

            closedir($dh);
            $enum['dirs'][$rel] = array('d' => $direct, 'c' => $children, 'f' => $bigF);
            $enum['prog'] = count($enum['dirs']);
        }
    }

    /**
     * Nach abgeschlossener Enumeration: die kumulierten (Teilbaum-)Groessen bottom-up berechnen und in
     * die Cache-Form bringen (pro Ebene Top-40 Unterordner + Top-20 grosse Dateien; nur Ordner MIT
     * Unterordnern kommen als Baum-Key rein - Blaetter fallen in den Live-Fallback). Alles im Speicher,
     * einmalig. Ergebnis-Form ist byte-identisch zu dem, was der fruehere One-Shot-Walk erzeugte.
     *
     * @return array [ tree{ rel:[[childRel,cumBytes],...] }, files{ rel:[[rel,bytes,flag],...] } ]
     */
    private static function enumRollup($enum)
    {
        $dirs = (isset($enum['dirs']) && is_array($enum['dirs'])) ? $enum['dirs'] : array();
        $cum  = array();

        foreach (array_keys($dirs) as $rel) {
            self::analyzeCumSize((string) $rel, $dirs, $cum);
        }

        $bySize = function ($a, $b) {
            return ($b[1] < $a[1]) ? -1 : (($b[1] > $a[1]) ? 1 : 0);
        };

        $tree  = array();
        $files = array();

        foreach ($dirs as $rel => $node) {
            $children = (isset($node['c']) && is_array($node['c'])) ? $node['c'] : array();

            if (!empty($children)) {
                $ch = array();

                foreach ($children as $c) {
                    $ch[] = array((string) $c, isset($cum[$c]) ? (int) $cum[$c] : 0);
                }

                usort($ch, $bySize);
                $tree[$rel] = array_slice($ch, 0, 40);
            }

            $bf = (isset($node['f']) && is_array($node['f'])) ? $node['f'] : array();

            if (!empty($bf)) {
                usort($bf, $bySize);
                $files[$rel] = array_slice($bf, 0, 20);
            }
        }

        return array($tree, $files);
    }

    /** Kumulierte Teilbaum-Groesse eines Ordners (memoisiert, rekursiv ueber die enum-Kinderliste). */
    private static function analyzeCumSize($rel, &$dirs, &$cum)
    {
        if (isset($cum[$rel])) {
            return (int) $cum[$rel];
        }

        $cum[$rel] = 0; // Rekursions-/Zyklen-Schutz (bei einem Baum nie relevant)
        $node = isset($dirs[$rel]) ? $dirs[$rel] : null;

        if (!is_array($node)) {
            return 0;
        }

        $total    = isset($node['d']) ? (int) $node['d'] : 0;
        $children = (isset($node['c']) && is_array($node['c'])) ? $node['c'] : array();

        foreach ($children as $c) {
            $total += self::analyzeCumSize((string) $c, $dirs, $cum);
        }

        $cum[$rel] = $total;

        return $total;
    }

    /**
     * Eine Ebene ($path) aus dem Cache-Baum in die Response-Form bringen. Die 'excl'-Markierung (0/1)
     * kommt LIVE aus der aktuellen Betreiberwahl ($exclDirs), nicht aus dem Cache - so bleibt der Cache
     * unabhaengig von zwischenzeitlichen Ausschluss-Aenderungen gueltig.
     *
     * @return array [ dirs[[rel,bytes,exclFlag]], bigFiles[[rel,bytes,alwaysFlag]] ]
     */
    private static function analyzeLevelFrom($cache, $path, $exclDirs)
    {
        $dirs   = array();
        $childs = (isset($cache['tree'][$path]) && is_array($cache['tree'][$path])) ? $cache['tree'][$path] : array();

        foreach ($childs as $ch) {
            if (!is_array($ch) || !isset($ch[0], $ch[1])) {
                continue;
            }

            $rel    = (string) $ch[0];
            $dirs[] = array($rel, (int) $ch[1], in_array($rel, $exclDirs, true) ? 1 : 0);
        }

        $bigFiles = array();
        $fl       = (isset($cache['files'][$path]) && is_array($cache['files'][$path])) ? $cache['files'][$path] : array();

        foreach ($fl as $bf) {
            if (!is_array($bf) || !isset($bf[0], $bf[1])) {
                continue;
            }

            $always     = (isset($bf[2]) && (int) $bf[2] === 2) ? 2 : 0;
            $bigFiles[] = array((string) $bf[0], (int) $bf[1], $always);
        }

        return array($dirs, $bigFiles);
    }

    /**
     * Live-Analyse EINER Ebene (heutiges Verhalten) - Fallback, wenn kein frischer Cache vorliegt.
     * Fuer jeden unmittelbaren Unterordner ein budgetierter dirSizeBudget-Durchlauf.
     *
     * @return array [ dirs[[rel,bytes,exclFlag]], bigFiles[[rel,bytes,alwaysFlag]] ]
     */
    private static function analyzeLevelLive($root, $base, $path, $deadline, $exclDirs, $sizeExcl, $skipBig, $selfRel, $big, &$partial)
    {
        $dirs     = array();
        $bigFiles = array();
        $dh       = @opendir($base);

        if ($dh !== false) {
            $seen = 0;

            while (($f = readdir($dh)) !== false) {
                if ((++$seen % 256) === 0 && microtime(true) > $deadline) {
                    $partial = 1;
                    break;
                }

                if ($f === '.' || $f === '..') {
                    continue;
                }

                if (count($dirs) >= 3000) {
                    $partial = 1;
                    break;
                }

                $p   = $base . '/' . $f;
                $rel = $path === '' ? $f : $path . '/' . $f;

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

                if (is_dir($p)) {
                    $lf = strtolower($f);

                    if (($selfRel !== '' && ($rel === $selfRel || strpos($rel, $selfRel . '/') === 0))
                        || $lf === 'cache' || $lf === 'tmp' || $lf === 'logs') {
                        continue;
                    }

                    $sz     = self::dirSizeBudget($root, $p, $deadline, $partial, $sizeExcl, $skipBig, $bigFiles, $selfRel);
                    $dirs[] = array($rel, $sz, in_array($rel, $exclDirs, true) ? 1 : 0);
                } else {
                    $fsz = (int) @filesize($p);

                    if ($fsz > $big && count($bigFiles) < 500) {
                        $bigFiles[] = array($rel, $fsz, self::isBackupArchive($rel) ? 2 : 0);
                    }
                }
            }

            closedir($dh);
        }

        return array($dirs, $bigFiles);
    }

    /** Analyse-Baum-Cache lesen (HMAC-geprueft; fremdes/manipuliertes File wird ignoriert). */
    private static function analyzeCacheRead($key)
    {
        $dir = self::dir();

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

        $file = $dir . self::ANALYZE_FILE;

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

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

        if (!is_array($j) || !isset($j['data']) || !isset($j['mac']) || !is_array($j['data'])) {
            return null;
        }

        $expect = hash_hmac('sha256', json_encode($j['data']), $key);

        if (!hash_equals($expect, (string) $j['mac'])) {
            return null;
        }

        return $j['data'];
    }

    /** Analyse-Baum-Cache schreiben (HMAC-signiert). Fehlerhaftes Schreiben degradiert still zum Live-Walk. */
    private static function analyzeCacheWrite($data, $key)
    {
        $dir = self::dir();

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

        $payload = array(
            'data' => $data,
            'mac'  => hash_hmac('sha256', json_encode($data), $key),
        );

        @file_put_contents($dir . self::ANALYZE_FILE, json_encode($payload), LOCK_EX);
    }

    /**
     * Verzeichnisgroesse (rekursiv, zeitbudgetiert) = echter Backup-Umfang: ueberspringt die
     * IMMER-Ausschluesse ($excl, inkl. Akeeba-Ablagen), .jpa/.jps/.j01-Archive und - falls $skipBig -
     * einzelne Dateien > 100 MB. So spiegeln die angezeigten Groessen, was wirklich gesichert wird.
     * Setzt $partial=1, wenn die Frist zuschlaegt.
     */
    private static function dirSizeBudget($root, $dir, $deadline, &$partial, $excl, $skipBig, &$bigFiles, $selfRel = '')
    {
        $big   = self::bigFileBytes();
        $junk  = array('cache' => true, 'tmp' => true, 'logs' => true); // transient - Descent nicht wert
        $total = 0;
        $stack = array($dir);

        while (!empty($stack)) {
            if (microtime(true) > $deadline) {
                $partial = 1;
                break;
            }

            $d  = array_pop($stack);
            $dh = @opendir($d);

            if ($dh === false) {
                continue;
            }

            // Streamend lesen (readdir) statt scandir: ein einzelner Flach-Ordner mit Millionen
            // Eintraegen wuerde sonst als Array den Speicher sprengen. Deadline IM Loop pruefen.
            $seen = 0;

            while (($f = readdir($dh)) !== false) {
                if ((++$seen % 512) === 0 && microtime(true) > $deadline) {
                    $partial = 1;
                    break;
                }

                if ($f === '.' || $f === '..') {
                    continue;
                }

                $p = $d . '/' . $f;

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

                $rel      = self::relOf($root, $p);
                $excluded = ($rel !== '' && self::relExcluded($rel, $excl));

                if (is_dir($p)) {
                    // AUCH in ausgeschlossene Ordner absteigen, um grosse Dateien (z.B. das Akeeba-ZIP)
                    // in der Liste zeigen zu koennen - NICHT aber in den EIGENEN Data-Ordner (dort liegen
                    // HTProtects eigene Backups; die gehoeren nie in die Anzeige) und nicht in Junk.
                    $isSelf = ($selfRel !== '' && $rel !== '' && ($rel === $selfRel || strpos($rel, $selfRel . '/') === 0));

                    if (!$isSelf && !isset($junk[strtolower($f)])) {
                        $stack[] = $p;
                    }
                } else {
                    $sz     = (int) @filesize($p);
                    $always = ($excluded || ($rel !== '' && self::isBackupArchive($rel))) ? 2 : 0; // Akeeba/Archiv = immer raus

                    // ALLE Dateien > Schwelle sammeln (mit Flag), damit die Liste vollstaendig ist -
                    // inkl. der ohnehin ausgeschlossenen (Akeeba-ZIP), damit die Herkunft grosser
                    // Verzeichnisse sichtbar wird.
                    if ($sz > $big && count($bigFiles) < 500) {
                        $bigFiles[] = array($rel, $sz, $always);
                    }

                    // Groesse: nur zaehlen, was WIRKLICH ins Backup kommt.
                    if (!$always && !($skipBig && $sz > $big)) {
                        $total += $sz;
                    }
                }
            }

            closedir($dh);

            if ($partial) {
                break;
            }
        }

        return $total;
    }

    /** Groesste DB-Tabellen (Daten+Index) via information_schema. Fail-open: [] bei Fehler. */
    private static function largestTables($limit, $exclTables)
    {
        $out = array();

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

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

            $db->setQuery(
                'SELECT TABLE_NAME AS n, (DATA_LENGTH + INDEX_LENGTH) AS sz'
                . ' FROM information_schema.TABLES'
                . ' WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = ' . $db->quote('BASE TABLE')
                . ' ORDER BY sz DESC LIMIT ' . (int) max(1, min(50, $limit))
            );
            $rows = $db->loadObjectList();

            foreach ((array) $rows as $r) {
                $name = isset($r->n) ? (string) $r->n : '';

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

                $out[] = array(mb_strcut($name, 0, 48), (int) (isset($r->sz) ? $r->sz : 0), in_array($name, (array) $exclTables, true) ? 1 : 0);
            }
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }

        return $out;
    }

    /**
     * Einen laufenden Backup-Job im Zeitbudget voranbringen (aus HtpLink::advanceJob).
     *
     * @return array { job:array, done:bool, ok:bool, code:string, progress:float, report?:array }
     */
    public static function advance($job, $deadline, $cfg)
    {
        // Wiederherstellung laeuft in derselben Job-Maschine, aber eigenem Pfad: sie loest ihre Ordner
        // SELBST ueber die Referenz auf (bkPath) und braucht kein erzeugungsseitiges $bdir. Darum VOR
        // dem Backup-Ordner-Guard abzweigen - ein Restore-Job hat kein 'bkdir', und der Ziel-Ordner
        // heisst im Neu-Layout NICHT wie die Referenz (sonst schluege der Guard fehl -> Restore kaputt).
        if (isset($job['op']) && $job['op'] === 'restore') {
            return self::advanceRestore($job, $deadline, $cfg);
        }

        $dir  = self::dir();
        // Ordnername steht im Job (neu: <host>-<stamp>-<zufall>). Alt-Jobs (vor dem Update angelegt)
        // kennen kein 'bkdir' -> Fallback auf die Referenz als Ordnername (bisheriges Layout).
        $bkdir = isset($job['bkdir']) && $job['bkdir'] !== '' ? (string) $job['bkdir']
            : (isset($job['bkid']) ? (string) $job['bkid'] : '');
        $bdir  = ($dir !== '' && $bkdir !== '') ? $dir . $bkdir . '/' : '';

        if ($bdir === '' || !is_dir($bdir)) {
            return array('job' => $job, 'done' => true, 'ok' => false, 'code' => 'backup_lost', 'progress' => 0.0);
        }

        try {
            if ($job['phase'] === 'db') {
                $job = self::dbStep($job, $bdir, $deadline);

                if (!empty($job['_db_done'])) {
                    unset($job['_db_done']);
                    $job['phase'] = ($job['kind'] === 'db') ? 'finalize' : 'files';
                }
            }

            if ($job['phase'] === 'files') {
                $job = self::filesStep($job, $bdir, $deadline);

                if (!empty($job['_files_done'])) {
                    unset($job['_files_done']);
                    $job['phase'] = 'finalize';
                }
            }

            if ($job['phase'] === 'finalize') {
                $meta = self::finalize($job, $bdir);

                // Rotation: NUR nach einem erfolgreichen neuen Backup alte wegraeumen (nie das
                // letzte gueltige vorher loeschen). Behaelt die N neuesten.
                if (!empty($meta['ok'])) {
                    self::rotate(
                        isset($job['keep_full']) ? (int) $job['keep_full'] : 3,
                        isset($job['keep_db']) ? (int) $job['keep_db'] : 14,
                        !empty($job['thin'])
                    );
                }

                return array('job' => $job, 'done' => true, 'ok' => !empty($meta['ok']),
                    'code' => !empty($meta['ok']) ? 'backup_done' : 'backup_failed', 'progress' => 1.0, 'report' => $meta);
            }
        } catch (Exception $e) {
            return array('job' => $job, 'done' => true, 'ok' => false, 'code' => 'backup_error', 'progress' => (float) $job['progress']);
        } catch (Throwable $e) {
            return array('job' => $job, 'done' => true, 'ok' => false, 'code' => 'backup_error', 'progress' => (float) $job['progress']);
        }

        return array('job' => $job, 'done' => false, 'ok' => true, 'code' => 'backup_running', 'progress' => (float) $job['progress']);
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Restore (Wiederherstellung): Kette Basis-Voll -> Inkremente + DB-Reimport.
    //  RISKANT (ueberschreibt die Live-Site). Resumierbar, prozess-frei, kettensicher.
    // ─────────────────────────────────────────────────────────────────────────

    /** Ketten-Refs (Basis-Voll zuerst -> ... -> Ziel); leer wenn ein Glied fehlt/kaputt ist. */
    private static function restoreChain($ref)
    {
        $chain = array();
        $cur   = (string) $ref;
        $guard = 0;

        while ($cur !== '' && preg_match('~^[a-f0-9]{24}$~', $cur) && $guard < 1000) {
            $bp = self::bkPath($cur);
            $mf = ($bp !== '') ? $bp . 'HTP-MANIFEST.json' : '';

            if ($mf === '' || !is_file($mf)) {
                return array(); // Kette gebrochen
            }

            $m = json_decode((string) @file_get_contents($mf), true);

            if (!is_array($m) || empty($m['ok'])) {
                return array();
            }

            array_unshift($chain, $cur); // aelteste vorne
            $cur = isset($m['parent']) ? (string) $m['parent'] : '';
            $guard++;
        }

        return $chain;
    }

    /** DB-Quelle: neuestes fertiges full/db-Backup mit db.sql.gz und ts <= Ziel-ts; '' wenn keins. */
    private static function restoreDbRef($targetTs)
    {
        foreach (self::listBackups(1000) as $b) { // neueste zuerst
            if (($b['kind'] === 'full' || $b['kind'] === 'db') && (int) $b['ts'] <= (int) $targetTs) {
                $bp = self::bkPath($b['ref']);

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

                $m = json_decode((string) @file_get_contents($bp . 'HTP-MANIFEST.json'), true);

                if (self::dbArtPath($bp, $m) !== '') {
                    return (string) $b['ref'];
                }
            }
        }

        return '';
    }

    /**
     * Restore starten: Kette + DB-Quelle aufloesen, Job seed'en. Der Aufrufer sollte VORHER ein
     * Sicherungs-Backup des Ist-Zustands angestossen haben (Rueckweg).
     */
    public static function startRestore(&$st, $cid, $params)
    {
        if (!empty($st['job'])) {
            return array('ok' => false, 'code' => 'busy');
        }

        if (self::dir() === '') {
            return array('ok' => false, 'code' => 'no_state_dir');
        }

        $ref = isset($params['ref']) ? (string) $params['ref'] : '';

        if (!preg_match('~^[a-f0-9]{24}$~', $ref)) {
            return array('ok' => false, 'code' => 'bad_ref');
        }

        $bp = self::bkPath($ref);
        $mf = ($bp !== '') ? $bp . 'HTP-MANIFEST.json' : '';

        if ($mf === '' || !is_file($mf)) {
            return array('ok' => false, 'code' => 'not_found');
        }

        $m = json_decode((string) @file_get_contents($mf), true);

        if (!is_array($m) || empty($m['ok'])) {
            return array('ok' => false, 'code' => 'incomplete');
        }

        $kind  = (string) (isset($m['kind']) ? $m['kind'] : 'full');
        $chain = ($kind === 'db') ? array() : self::restoreChain($ref); // DB-only: keine Datei-Kette

        if ($kind !== 'db' && empty($chain)) {
            return array('ok' => false, 'code' => 'chain_broken');
        }

        $dbRef = self::restoreDbRef((int) (isset($m['ts']) ? $m['ts'] : time()));

        // Wird DIE DB erwartet (alles ausser reinem Dateien-Backup), muss eine DB-Quelle da sein -
        // sonst wuerden Dateien auf einen alten Stand gesetzt, die DB aber unveraendert bleiben
        // (Schema-Mismatch = weisse Seite). Dann lieber SICHER scheitern statt still inkonsistent.
        $dbExpected = ($kind !== 'files');

        if ($dbExpected && $dbRef === '') {
            return array('ok' => false, 'code' => 'no_db_source');
        }

        $st['job'] = array(
            'type'       => 'backup',
            'op'         => 'restore',
            'cmd_id'     => (string) $cid,
            'bkid'       => $ref,
            'kind'       => 'restore',
            'phase'      => empty($chain) ? 'db' : 'files',
            'chain'      => $chain,
            'db_ref'     => $dbRef,
            'db_expected'=> $dbExpected ? 1 : 0,
            'started'    => time(),
            // Fortschritts-Zeitstempel: Basis der Haenger-Erkennung (HtpLink::jobStale).
            'advanced'     => time(),
            'progress'   => 0.0,
            'r_idx'      => 0,     // Ketten-Index
            'r_zip'      => 0,     // Eintrags-Index im aktuellen Archiv
            'r_del'      => false, // Loeschungen des aktuellen Glieds schon angewandt?
            'r_files'    => 0,     // extrahierte Dateien gesamt
            'r_files_failed' => 0, // fehlgeschlagene Extraktionen
            'r_db_off'   => 0,     // Uncompressed-Offset im db.sql.gz (resumable)
            'r_db_n'     => 0,     // erfolgreich ausgefuehrte Statements
            'r_db_fail'  => 0,     // fehlgeschlagene Statements
            'r_delim'    => ';',   // aktueller SQL-Delimiter (resume-fest, Trigger)
            'r_db_applied' => 0,   // wurde DB tatsaechlich eingespielt?
            // Restore-Modus: 'merge' (Default, wie bisher: nur drueberlegen) oder 'clean'
            // (Komplett-Restore: Webspace + DB nach dem Zurueckspielen auf GENAU den Backup-Stand
            // bringen - alles spaeter Hinzugekommene entfernen; HTProtect + Backups bleiben immer).
            'mode'       => (isset($params['mode']) && (string) $params['mode'] === 'clean') ? 'clean' : 'merge',
            'p_step'     => 'index', // Unterphase des Clean-Prunes: index -> files -> db
            'p_idx'      => 0,       // Ketten-Index beim Aufbau des Keep-Index
            'r_db_tables'=> array(), // beim DB-Restore gesehene Tabellennamen (= Backup-Soll fuers DB-Prune)
            'r_pruned'   => 0,       // im Clean-Modus geloeschte Dateien
            'r_dropped'  => 0,       // im Clean-Modus verworfene Fremd-Tabellen
        );

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

    /** Restore-Job im Zeitbudget voranbringen. */
    private static function advanceRestore($job, $deadline, $cfg)
    {
        try {
            if ($job['phase'] === 'files') {
                $job = self::restoreFilesStep($job, $deadline);

                if (!empty($job['_files_done'])) {
                    unset($job['_files_done']);
                    // Nach einem HARTEN Datei-Fehler die DB NICHT reimportieren (sonst teils alte
                    // Dateien + komplett ersetzte DB = noch kaputter). Direkt zum Abschluss (ok=false).
                    $job['phase'] = empty($job['r_err']) ? 'db' : 'finalize';
                }
            }

            if ($job['phase'] === 'db') {
                $job = self::restoreDbStep($job, $deadline);

                if (!empty($job['_db_done'])) {
                    unset($job['_db_done']);
                    // Komplett-Restore ('clean'): erst NACH erfolgreichem Datei+DB-Restore aufraeumen
                    // (Fremd-Dateien/-Tabellen entfernen). Bei einem harten Fehler NIE loeschen -> direkt
                    // zum Abschluss (kein Aufraeumen auf einem halb-restaurierten Stand).
                    $job['phase'] = (empty($job['r_err']) && (string) $job['mode'] === 'clean') ? 'prune' : 'finalize';
                }
            }

            if ($job['phase'] === 'prune') {
                $job = self::restorePruneStep($job, $deadline);

                if (!empty($job['_prune_done'])) {
                    unset($job['_prune_done']);
                    $job['phase'] = 'finalize';
                }
            }

            if ($job['phase'] === 'finalize') {
                // Erfolg NUR, wenn kein harter Fehler UND kein Statement/Extrakt scheiterte UND -
                // falls eine DB erwartet war - sie auch wirklich eingespielt wurde. Sonst waere ein
                // teilweiser/leerer Restore faelschlich als Erfolg quittiert (Betreiber verwirft dann
                // sein Vor-Restore-Backup) - genau das, wovor ein Restore schuetzen soll.
                $dbApplied = !empty($job['r_db_applied']);
                $ok = empty($job['r_err'])
                    && (int) $job['r_files_failed'] === 0
                    && (int) $job['r_db_fail'] === 0
                    && (empty($job['db_expected']) || $dbApplied);

                $code = $ok ? 'restore_done'
                    : (!empty($job['r_err']) ? ('restore_failed:' . (string) $job['r_err'])
                        : ((int) $job['r_db_fail'] > 0 ? ('restore_failed:db_stmt(' . (int) $job['r_db_fail'] . ')')
                            : ((int) $job['r_files_failed'] > 0 ? ('restore_failed:extract(' . (int) $job['r_files_failed'] . ')')
                                : 'restore_failed:db_missing')));

                return array('job' => $job, 'done' => true, 'ok' => $ok, 'code' => $code, 'progress' => 1.0,
                    'report' => array('ts' => time(), 'ok' => $ok, 'restored' => (string) $job['bkid'],
                        'mode' => (string) (isset($job['mode']) ? $job['mode'] : 'merge'),
                        'files' => (int) $job['r_files'], 'files_failed' => (int) $job['r_files_failed'],
                        'db_rows' => (int) $job['r_db_n'], 'db_failed' => (int) $job['r_db_fail'],
                        'pruned' => (int) (isset($job['r_pruned']) ? $job['r_pruned'] : 0),
                        'dropped' => (int) (isset($job['r_dropped']) ? $job['r_dropped'] : 0),
                        'db_applied' => $dbApplied ? 1 : 0));
            }
        } catch (Exception $e) {
            return array('job' => $job, 'done' => true, 'ok' => false, 'code' => 'restore_error', 'progress' => (float) $job['progress']);
        } catch (Throwable $e) {
            return array('job' => $job, 'done' => true, 'ok' => false, 'code' => 'restore_error', 'progress' => (float) $job['progress']);
        }

        return array('job' => $job, 'done' => false, 'ok' => true, 'code' => 'restore_running', 'progress' => (float) $job['progress']);
    }

    /** Relativer Pfad sicher? Kein absoluter/Laufwerks-Pfad, kein "."/".."-SEGMENT (Traversal). */
    private static function pathIsSafe($p)
    {
        $p = (string) $p;

        if ($p === '' || $p[0] === '/' || $p[0] === '\\' || preg_match('~^[A-Za-z]:~', $p)) {
            return false;
        }

        foreach (preg_split('~[/\\\\]+~', $p) as $seg) {
            if ($seg === '..' || $seg === '.') {
                return false;
            }
        }

        return true;
    }

    /** Symlinks auf dem Verzeichnis-Weg eines Ziel-Eintrags entfernen (kein Schreiben aus dem Docroot). */
    private static function defuseSymlinkParents($root, $entry)
    {
        $parts = explode('/', str_replace('\\', '/', (string) $entry));
        array_pop($parts); // Dateiname weg -> nur die Verzeichnis-Kette
        $cur = rtrim($root, '/');

        foreach ($parts as $seg) {
            if ($seg === '') {
                continue;
            }

            $cur .= '/' . $seg;

            if (is_link($cur)) {
                @unlink($cur); // Symlink weg -> extractTo legt ein echtes Verzeichnis an
            }
        }
    }

    /** Datei-Kette extrahieren (Basis-Voll -> Inkremente) + je Glied dessen Loeschungen anwenden. */
    private static function restoreFilesStep($job, $deadline)
    {
        $root  = self::siteRoot();
        $chain = is_array($job['chain']) ? $job['chain'] : array();
        $n     = count($chain);

        if ($root === '' || $n === 0) {
            $job['_files_done'] = true;

            return $job;
        }

        while ($job['r_idx'] < $n && microtime(true) < $deadline) {
            $ref  = (string) $chain[$job['r_idx']];
            $bdir = self::bkPath($ref); // Ordner ueber die Referenz aufloesen (Name != Referenz)
            $adir = ($bdir !== '') ? self::artDir($bdir) : '';

            // Manifest-Dateizahl + Artefakt-Name zuerst: ein leeres Inkrement (0 geaenderte Dateien)
            // hat KEIN Archiv - das ist gueltig (nur Loeschungen anwenden). Sonst muss eins da sein.
            $gm     = ($bdir !== '') ? @json_decode((string) @file_get_contents($bdir . 'HTP-MANIFEST.json'), true) : null;
            $gfiles = is_array($gm) ? (int) (isset($gm['files']) ? $gm['files'] : 0) : 0;

            // Datei-Archiv ueber das Manifest (Praefix-Name) bzw. Alt-Namen; Typ aus der Endung.
            $arch   = ($bdir !== '') ? self::filesArtPath($bdir, $gm) : '';
            $zip    = ($arch !== '' && substr($arch, -4) === '.zip') ? $arch : '';
            $hasZip = $zip !== '' && class_exists('ZipArchive');
            $hasTar = $arch !== '' && (substr($arch, -7) === '.tar.gz' || substr($arch, -4) === '.tar');

            if (!$hasZip) {
                if (!$hasTar && $gfiles === 0) {
                    // Leeres Inkrement -> nur Loeschungen, dann naechstes Glied.
                    if (empty($job['r_del'])) {
                        self::applyDeletions($adir, $root);
                        $job['r_del'] = true;
                    }

                    $job['r_idx']++;
                    $job['r_zip'] = 0;
                    $job['r_del'] = false;
                    continue;
                }

                // tar(.gz) kann der Restore (v1) noch nicht auspacken, bzw. das Archiv fehlt ganz:
                // LAUT scheitern - niemals die Dateien still ueberspringen (sonst alte Dateien + neue
                // DB = kaputte Site, als 'ok' gemeldet). tar-Restore folgt.
                $job['r_err'] = $hasTar ? ('tar_restore_unsupported:' . $ref)
                    : ($zip !== '' ? ('zip_unavailable:' . $ref) : ('archive_missing:' . $ref));
                $job['_files_done'] = true;

                return $job;
            }

            $z = new ZipArchive();

            if ($z->open($zip) !== true) {
                $job['r_err'] = 'zip_open:' . $ref;
                $job['_files_done'] = true;

                return $job;
            }

            $total = $z->numFiles;

            while ($job['r_zip'] < $total && microtime(true) < $deadline) {
                $entry = $z->getNameIndex($job['r_zip']);

                if ($entry !== false && $entry !== '') {
                    // Nur echtes Pfad-Traversal (".." als Segment, absolute/Laufwerk-Pfade) verbieten -
                    // ".." mitten im Dateinamen (z. B. "bild..jpg") ist legitim. Ein unsicherer Eintrag
                    // in einem (unserem) Archiv = korrupt/manipuliert -> HART scheitern, nie still weiter.
                    if (!self::pathIsSafe($entry)) {
                        $job['r_files_failed'] = (int) $job['r_files_failed'] + 1;
                        $job['r_err'] = 'unsafe_entry:' . $ref;
                        $z->close();
                        $job['_files_done'] = true;

                        return $job;
                    }

                    // Symlink-Eltern entschaerfen: zeigt auf dem Weg zum Ziel ein Verzeichnis-Symlink
                    // aus dem Docroot heraus (typisch auf einer kompromittierten Site), wuerde extractTo
                    // dorthin schreiben. Symlink entfernen -> extractTo legt ein echtes Verzeichnis an.
                    self::defuseSymlinkParents($root, $entry);

                    $ok     = $z->extractTo($root, array($entry));
                    $target = rtrim($root, '/') . '/' . $entry;

                    // Zieldatei kann schreibgeschuetzt sein (z. B. HTProtects eigene 0444-.htaccess) -
                    // ein Restore MUSS sie ueberschreiben duerfen: beschreibbar machen + erneut, sonst
                    // ersetzen. Bleibt es fehl (Platte voll, Rechte) -> HART abbrechen, nicht still weiter.
                    if (!$ok && is_file($target) && !is_link($target)) {
                        @chmod($target, 0644);
                        $ok = $z->extractTo($root, array($entry));

                        if (!$ok) {
                            @unlink($target);
                            $ok = $z->extractTo($root, array($entry));
                        }
                    }

                    if ($ok) {
                        $job['r_files']++;
                    } else {
                        $job['r_files_failed'] = (int) $job['r_files_failed'] + 1;
                        $job['r_err'] = 'extract:' . $ref . ':' . $entry;
                        $z->close();
                        $job['_files_done'] = true;

                        return $job; // Datei-Restore ist unvollstaendig -> abbrechen, nie 'ok' melden
                    }
                }

                $job['r_zip']++;
            }

            $z->close();

            if ($job['r_zip'] >= $total) {
                // Archiv fertig -> Loeschungen dieses Glieds anwenden -> naechstes Glied.
                self::applyDeletions($adir, $root);
                $job['r_idx']++;
                $job['r_zip'] = 0;
                $job['r_del'] = false;
            }
        }

        // Feiner Fortschritt: NICHT nur das fertige Kettenglied zaehlen (dann stuende der Balken bei
        // einer 2er-Kette minutenlang auf 0/35/70 %), sondern die Position INNERHALB des laufenden
        // Archivs interpolieren. Monoton (nie zurueckspringend) und auf 0.7 gedeckelt - die
        // restlichen 30 % gehoeren der Datenbank-Phase.
        $within = (isset($total) && (int) $total > 0)
            ? min(1.0, (float) $job['r_zip'] / (float) $total)
            : 0.0;

        $done = $n > 0 ? min(1.0, ((float) $job['r_idx'] + $within) / (float) $n) : 1.0;

        $job['progress'] = round(max((float) $job['progress'], min(0.7, 0.7 * $done)), 3);

        if ($job['r_idx'] >= $n) {
            $job['_files_done'] = true;
        }

        return $job;
    }

    /** Loeschungen eines (Inkrement-)Glieds anwenden: die in deletions.txt gelisteten Dateien weg. */
    private static function applyDeletions($bdir, $root)
    {
        $df = $bdir . 'deletions.txt';

        if (!is_file($df)) {
            return;
        }

        $fh = @fopen($df, 'rb');

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

        while (($line = fgets($fh)) !== false) {
            $rel = trim($line);

            if (!self::pathIsSafe($rel)) {
                continue; // Traversal/absolut -> nie loeschen; ".." im Dateinamen ist ok
            }

            $p = rtrim($root, '/') . '/' . $rel;

            if (is_file($p) && !is_link($p)) {
                @unlink($p);
            }
        }

        fclose($fh);
    }

    /**
     * DB-Reimport: db.sql.gz der Quelle streamen + Statements ausfuehren, resumierbar via
     * Uncompressed-Offset. Sicheres zeilenbasiertes Splitten (unser Dump escapt Newlines -> INSERTs
     * sind Einzelzeilen), mit DELIMITER-Handling fuer Trigger. Keine DB-Quelle -> DB unveraendert.
     */
    private static function restoreDbStep($job, $deadline)
    {
        $dbRef = (string) $job['db_ref'];

        if ($dbRef === '' || !function_exists('htp_db') || !htp_db()) {
            $job['_db_done'] = true;   // nur bei reinem Dateien-Backup zulaessig (db_expected=0)

            return $job;
        }

        // DB-Dump-Pfad ueber die Referenz (Ordner + Manifest-Name; Alt: db.sql.gz).
        $dbp = self::bkPath($dbRef);
        $dbm = ($dbp !== '') ? json_decode((string) @file_get_contents($dbp . 'HTP-MANIFEST.json'), true) : null;
        $gzf = self::dbArtPath($dbp, $dbm);

        if ($gzf === '' || !is_file($gzf)) {
            $job['r_err'] = 'db_missing';
            $job['_db_done'] = true;

            return $job;
        }

        $db = htp_db();

        // Session-Umfeld bei JEDEM Tick neu setzen: ein Resume-Tick ist eine NEUE DB-Verbindung, und
        // gzseek springt hinter den Dump-Header - ohne dies waeren FK-Checks/Strict-Mode/Charset auf
        // Server-Defaults (0000-00-00-Daten, FK-Bezuege, 4-Byte-utf8mb4 wuerden scheitern). Diese
        // Variablen sind verbindungsgebunden; die '/*!... SET NAMES */'-Zeile im Dump wird zudem als
        // Kommentar uebersprungen - darum hier explizit.
        foreach (array('SET NAMES utf8mb4', 'SET FOREIGN_KEY_CHECKS=0',
            "SET SQL_MODE='NO_AUTO_VALUE_ON_ZERO'", "SET TIME_ZONE='+00:00'") as $pragma) {
            try {
                $db->setQuery($pragma);
                $db->execute();
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }
        }

        $gz = @gzopen($gzf, 'rb');

        if ($gz === false) {
            $job['r_err'] = 'db_open';
            $job['_db_done'] = true;

            return $job;
        }

        if ((int) $job['r_db_off'] > 0) {
            @gzseek($gz, (int) $job['r_db_off']);
        }

        $delim = (isset($job['r_delim']) && $job['r_delim'] !== '') ? (string) $job['r_delim'] : ';';
        $buf   = '';  // akkumuliert eine VOLLSTAENDIGE logische Zeile (ueber gzgets-Chunk-Grenzen)

        while (!gzeof($gz) && microtime(true) < $deadline) {
            $chunk = gzgets($gz, 1048576);

            if ($chunk === false) {
                break;
            }

            $buf .= $chunk;

            // gzgets kappt lange Zeilen am Laengenlimit -> eine Mehr-MB-INSERT-Zeile kommt in Stuecken.
            // NIE auf einem gekappten Chunk splitten (ein ';' in einem langen Wert wuerde sonst das
            // Statement zerreissen): erst weiterlesen, bis die Zeile mit "\n" endet (oder EOF).
            if (substr($chunk, -1) !== "\n" && !gzeof($gz)) {
                continue;
            }

            $trim = rtrim($buf, "\r\n");

            // DELIMITER-Direktive (Trigger): Terminator umschalten - RESUME-FEST im Job persistiert.
            if (preg_match('~^DELIMITER\s+(\S+)~i', $trim, $mm)) {
                $delim           = $mm[1];
                $job['r_delim']  = $delim;
                $buf             = '';
                $job['r_db_off'] = gztell($gz);
                continue;
            }

            // Kommentar-/Leerzeilen ueberspringen.
            if ($trim === '' || strpos($trim, '--') === 0 || strpos($trim, '/*') === 0) {
                $buf             = '';
                $job['r_db_off'] = gztell($gz);
                continue;
            }

            // Statement erst, wenn die VOLLSTAENDIGE Zeile mit dem aktuellen Delimiter endet.
            if ($delim !== '' && substr($trim, -strlen($delim)) === $delim) {
                $stmt = trim(substr($trim, 0, -strlen($delim)));

                if ($stmt !== '') {
                    try {
                        $db->setQuery($stmt);
                        $db->execute();
                        $job['r_db_n']++;

                        // "DB wurde eingespielt" NUR bei echten Daten-/Struktur-Statements werten -
                        // nicht bei SET-Header (sonst wuerde ein header-only/kaputter Dump als
                        // erfolgreich gelten und das db_applied-Sicherheitsgate aushebeln).
                        if (stripos($stmt, 'SET ') !== 0) {
                            $job['r_db_applied'] = 1;
                        }

                        // Clean-Modus: die im Backup enthaltenen Tabellen merken (= Soll-Bestand). Was
                        // danach in der DB steht und NICHT hier gelistet ist, gilt als Fremd-Tabelle
                        // (spaeter dazugekommen / eingeschleust) und wird beim DB-Prune verworfen.
                        if ((isset($job['mode']) && (string) $job['mode'] === 'clean') && stripos($stmt, 'CREATE TABLE') === 0
                            && preg_match('~^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?([A-Za-z0-9_$]+)[`"]?~i', $stmt, $tm)) {
                            $job['r_db_tables'][$tm[1]] = 1;
                        }
                    } catch (Exception $e) {
                        $job['r_db_fail'] = (int) $job['r_db_fail'] + 1;
                        $job['r_err']     = 'db_stmt:' . substr($stmt, 0, 60);
                    } catch (Throwable $e) {
                        $job['r_db_fail'] = (int) $job['r_db_fail'] + 1;
                        $job['r_err']     = 'db_stmt:' . substr($stmt, 0, 60);
                    }

                    // Harter Fehler -> ABBRECHEN, Offset NICHT vorruecken (sonst ueberspringt Resume
                    // das Statement). Ein DROP-ohne-CREATE darf NIE als Erfolg gelten.
                    if (!empty($job['r_err'])) {
                        gzclose($gz);
                        $job['_db_done'] = true;

                        return $job;
                    }
                }

                $buf             = '';
                $job['r_db_off'] = gztell($gz); // Commit-Grenze NUR nach Erfolg
            }
            // sonst: vollstaendige, aber noch nicht terminierte Zeile (mehrzeiliges CREATE/Trigger)
            // -> buf behaelt sie, r_db_off bleibt am Statement-Anfang (resume-sicher).
        }

        $eof = gzeof($gz);
        gzclose($gz);

        // Feiner Fortschritt statt nur zwei Stufen (vorher: immer 85 %, am Ende 100 % - der Balken
        // stand dazwischen minutenlang still). gztell liefert den UNKOMPRIMIERTEN Offset; als Bezug
        // dient die geschaetzte Gesamtgroesse (SQL-Dumps komprimieren typischerweise etwa 4:1). Die
        // Schaetzung wird EINMAL festgehalten, damit der Wert ueber alle Ticks stabil bleibt, und
        // der Fortschritt ist monoton - er springt also nie zurueck, auch wenn die Schaetzung
        // danebenliegt. Vor dem Ende wird auf 0.99 gedeckelt, 1.0 gibt es nur bei echtem EOF.
        if (empty($job['r_db_total'])) {
            $job['r_db_total'] = max(1, 4 * (int) @filesize($gzf));
        }

        $frac = min(1.0, (float) $job['r_db_off'] / (float) $job['r_db_total']);
        $next = $eof ? 1.0 : min(0.99, 0.7 + 0.29 * $frac);

        $job['progress'] = round(max((float) $job['progress'], $next), 3);

        if ($eof) {
            $job['_db_done'] = true;
        }

        return $job;
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Komplett-Restore ('clean'): nach dem Zurueckspielen Webspace + DB auf GENAU den Backup-Stand
    //  bringen - alles spaeter Hinzugekommene (auch von einem Angriff eingeschleuste Dateien/Tabellen)
    //  entfernen. HTProtect selbst, seine Backups und sein Datenordner bleiben IMMER. Sicherheits-
    //  Grundsatz: bei leerem/unklarem Soll-Bestand wird NICHTS geloescht (nie die Site leeren).
    // ─────────────────────────────────────────────────────────────────────────

    /** Ablage des Keep-Index (Soll-Dateiliste) - im gehaerteten, NIE geprunten Backup-Ordner. */
    private static function keepIdxFile()
    {
        $d = self::dir();

        return ($d !== '') ? rtrim($d, '/') . '/restore-keep.idx' : '';
    }

    /**
     * Pfade (relativ zum Docroot), die der Clean-Prune NIEMALS loescht: HTProtect komplett (Komponente
     * + die drei Plugins) + Datenordner + ALLE Backup-Ablagen (eigene + Drittanbieter) + fluechtige
     * Joomla-Ordner (cache/tmp/logs bleiben bestehen). Prefix-Vergleich (Ordner inklusive Inhalt).
     */
    private static function prunePrefixes()
    {
        $p   = self::defaultExcludeDirs(); // cache/tmp/logs + htprotect data + eigene/fremde Backup-Ordner
        $p[] = 'administrator/components/com_htprotect'; // HTProtect-Komponente KOMPLETT
        $p[] = 'plugins/system/htprotectwatch';          // Waechter-Plugin
        $p[] = 'plugins/quickicon/htprotect';            // Quickicon-Kachel
        $p[] = 'plugins/task/htpautoupdate';             // Task-Auto-Update

        return $p;
    }

    /** Liegt $rel exakt auf oder unter einem der geschuetzten Prefixe? */
    private static function pruneProtected($rel, $prefixes)
    {
        $rel = ltrim(str_replace('\\', '/', (string) $rel), '/');

        foreach ($prefixes as $pre) {
            $pre = trim((string) $pre, '/');

            if ($pre !== '' && ($rel === $pre || strpos($rel, $pre . '/') === 0)) {
                return true;
            }
        }

        return false;
    }

    /** Clean-Prune-Schritt: index (Soll-Liste bauen) -> files (Fremd-Dateien weg) -> db (Fremd-Tabellen weg). */
    private static function restorePruneStep($job, $deadline)
    {
        $step = isset($job['p_step']) ? (string) $job['p_step'] : 'index';

        // Reines DB-Backup (keine Datei-Kette) -> Datei-Schritte ueberspringen.
        if (($step === 'index' || $step === 'files') && empty($job['chain'])) {
            $job['p_step'] = 'db';
            $step = 'db';
        }

        // ── 1) Keep-Index: UNION aller Datei-Eintraege der Backup-Kette (Superset). Ein zuviel
        //       aufgenommener, real nicht existenter Pfad fuehrt nur dazu, dass NICHT geloescht wird
        //       (sicher). Am Ende MUSS der Index gefuellt sein, sonst KEIN Prune.
        if ($step === 'index') {
            $idxFile = self::keepIdxFile();

            if ($idxFile === '') {
                $job['r_err'] = 'prune_no_dir';
                $job['_prune_done'] = true;

                return $job;
            }

            $chain = is_array($job['chain']) ? $job['chain'] : array();
            $n     = count($chain);

            if ((int) $job['p_idx'] === 0) {
                @file_put_contents($idxFile, ''); // frisch anlegen
            }

            while ((int) $job['p_idx'] < $n && microtime(true) < $deadline) {
                $ref  = (string) $chain[(int) $job['p_idx']];
                $bdir = self::bkPath($ref);
                $gm   = ($bdir !== '') ? @json_decode((string) @file_get_contents($bdir . 'HTP-MANIFEST.json'), true) : null;
                $arch = ($bdir !== '') ? self::filesArtPath($bdir, $gm) : '';

                if ($arch !== '' && substr($arch, -4) === '.zip' && class_exists('ZipArchive')) {
                    $z = new ZipArchive();

                    if ($z->open($arch) === true) {
                        $buf = '';

                        for ($i = 0; $i < $z->numFiles; $i++) {
                            $e = $z->getNameIndex($i);

                            if ($e !== false && $e !== '' && substr($e, -1) !== '/') {
                                $buf .= ltrim(str_replace('\\', '/', (string) $e), '/') . "\n";
                            }
                        }

                        @file_put_contents($idxFile, $buf, FILE_APPEND);
                        $z->close();
                    }
                }

                $job['p_idx'] = (int) $job['p_idx'] + 1;
            }

            if ((int) $job['p_idx'] >= $n) {
                // Sicherheits-Gate: leerer Index -> NIE loeschen (sonst leerten wir die Website).
                if ((int) @filesize($idxFile) < 1) {
                    $job['r_err'] = 'prune_index_empty';
                    $job['_prune_done'] = true;

                    return $job;
                }

                $job['p_step'] = 'files';
            }

            return $job;
        }

        // ── 2) Datei-Prune: rekursiver Durchlauf; Fremd-Dateien (nicht im Keep-Index, nicht geschuetzt)
        //       loeschen. Konvergiert OHNE Cursor: geloeschte Dateien tauchen nicht wieder auf; ein
        //       VOLLSTAENDIGER Durchlauf ohne Fund => fertig. RecursiveDirectoryIterator folgt Symlinks
        //       standardmaessig NICHT in die Rekursion (hasChildren(false)); zusaetzlich Containment.
        if ($step === 'files') {
            $root    = self::siteRoot();
            $idxFile = self::keepIdxFile();

            if ($root === '' || $idxFile === '' || !is_file($idxFile)) {
                $job['r_err'] = 'prune_no_index';
                $job['_prune_done'] = true;

                return $job;
            }

            $keep = array();
            $fh   = @fopen($idxFile, 'rb');

            if ($fh) {
                while (($ln = fgets($fh)) !== false) {
                    $ln = rtrim($ln, "\r\n");

                    if ($ln !== '') {
                        $keep[$ln] = 1;
                    }
                }

                fclose($fh);
            }

            if (empty($keep)) {
                $job['r_err'] = 'prune_index_empty';
                $job['_prune_done'] = true;

                return $job;
            }

            $prefixes  = self::prunePrefixes();
            $deleted   = 0;
            $completed = true;

            try {
                $it = new RecursiveIteratorIterator(
                    new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
                    RecursiveIteratorIterator::CHILD_FIRST
                );

                foreach ($it as $f) {
                    if (microtime(true) >= $deadline) {
                        $completed = false;
                        break;
                    }

                    $full = str_replace('\\', '/', $f->getPathname());
                    $rel  = ltrim(substr($full, strlen($root)), '/');

                    if ($rel === '' || !self::pathIsSafe($rel) || self::pruneProtected($rel, $prefixes)) {
                        continue;
                    }

                    // Symlinks NIE anfassen: im Public-Folder-Modus verlinkt der Webroot (public/) die
                    // Ordner images/media/files auf den App-Stamm - ein Loeschen wuerde die Seite brechen.
                    // (Ein eingeschleuster Symlink fuehrt ohnehin keinen Code aus -> harmlos.)
                    if (@is_link($full)) {
                        continue;
                    }

                    // Containment: echte Pfade MUESSEN im Site-Stamm liegen (Symlink-Ausbruch verhindern).
                    $real = @realpath($full);
                    $real = ($real === false) ? $full : str_replace('\\', '/', $real);

                    if ($real !== $root && strpos($real, $root . '/') !== 0) {
                        continue;
                    }

                    if ($f->isDir()) {
                        // Fremdes, jetzt leeres Verzeichnis (CHILD_FIRST) entfernen.
                        if (!isset($keep[$rel])) {
                            @rmdir($full);
                        }

                        continue;
                    }

                    // Fremde Datei loeschen.
                    if (!isset($keep[$rel])) {
                        if (@unlink($full) || (@chmod($full, 0644) && @unlink($full))) {
                            $deleted++;
                        }
                    }
                }
            } catch (Exception $e) {
                // fail-open: der Restore selbst war erfolgreich; das Aufraeumen ist best-effort.
            } catch (Throwable $e) {
            }

            $job['r_pruned'] = (int) $job['r_pruned'] + $deleted;

            // Nur weiter, wenn ein VOLLSTAENDIGER Durchlauf nichts mehr fand (sonst naechster Tick).
            if ($completed && $deleted === 0) {
                $job['p_step'] = 'db';
            }

            return $job;
        }

        // ── 3) DB-Prune: Fremd-Tabellen verwerfen (nicht im Backup, nicht bewusst ausgeschlossen,
        //       nicht HTProtects eigene Ablage). Ein-Schuss (Table-Drops sind schnell).
        if ($step === 'db') {
            self::pruneDbTables($job);
            $job['_prune_done'] = true;

            return $job;
        }

        $job['_prune_done'] = true;

        return $job;
    }

    /** Fremd-Tabellen (nach dem DB-Restore uebrig) verwerfen. Streng abgesichert gegen Datenverlust. */
    private static function pruneDbTables(&$job)
    {
        if (!function_exists('htp_db') || !htp_db() || empty($job['r_db_applied'])) {
            return; // ohne eingespielte DB kein verlaesslicher Soll-Bestand -> nichts droppen
        }

        $backupTables = isset($job['r_db_tables']) && is_array($job['r_db_tables']) ? $job['r_db_tables'] : array();

        // SICHERHEIT: leerer Soll-Bestand -> NICHTS droppen (sonst leerten wir die ganze DB).
        if (empty($backupTables)) {
            return;
        }

        $db = htp_db();

        // Vom Betreiber bewusst NICHT gesicherte Tabellen (Backup-Manifest) sind legitim -> behalten.
        $keepExtra = array();
        $dbRef     = (string) $job['db_ref'];
        $bp        = ($dbRef !== '') ? self::bkPath($dbRef) : '';

        if ($bp !== '') {
            $m = @json_decode((string) @file_get_contents($bp . 'HTP-MANIFEST.json'), true);

            if (is_array($m) && !empty($m['excl_tables']) && is_array($m['excl_tables'])) {
                foreach ($m['excl_tables'] as $t) {
                    $keepExtra[(string) $t] = 1;
                }
            }
        }

        try {
            $db->setQuery('SELECT TABLE_NAME FROM information_schema.TABLES'
                . ' WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = ' . $db->quote('BASE TABLE'));
            $now = (array) $db->loadColumn();
        } catch (Exception $e) {
            return;
        } catch (Throwable $e) {
            return;
        }

        try {
            $db->setQuery('SET FOREIGN_KEY_CHECKS=0');
            $db->execute();
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        $dropped = 0;

        foreach ($now as $t) {
            $t = (string) $t;

            if ($t === '' || !preg_match('~^[A-Za-z0-9_$]+$~', $t)) {
                continue;
            }
            if (isset($backupTables[$t]) || isset($keepExtra[$t]) || preg_match('~htprotect_store$~i', $t)) {
                continue; // im Backup / bewusst ausgeschlossen / HTProtects eigene Ablage -> behalten
            }

            try {
                $db->setQuery('DROP TABLE IF EXISTS `' . str_replace('`', '``', $t) . '`');
                $db->execute();
                $dropped++;
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }
        }

        try {
            $db->setQuery('SET FOREIGN_KEY_CHECKS=1');
            $db->execute();
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        $job['r_dropped'] = (int) $job['r_dropped'] + $dropped;
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  DB-Dump-Engine (rein PHP, speichersicher via Keyset-Pagination, resumierbar)
    // ─────────────────────────────────────────────────────────────────────────

    /** IDs/Namen saeubern (nur IDENT-konform, eindeutig, gedeckelt). */
    private static function sanitizeIdents($arr)
    {
        $out = array();

        foreach ((array) $arr as $x) {
            $x = is_string($x) ? trim($x) : '';

            if ($x !== '' && preg_match('~^[A-Za-z0-9_]+$~', $x)) {
                $out[$x] = true;
            }
        }

        return array_slice(array_keys($out), 0, 2000);
    }

    /** Neue, nicht ratbare Backup-/Artefakt-ID (24 Hex). */
    private static function newId()
    {
        if (function_exists('htp_random_bytes')) {
            return bin2hex(htp_random_bytes(12));
        }

        try {
            if (function_exists('random_bytes')) {
                return bin2hex(random_bytes(12));
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return substr(hash('sha256', uniqid((string) mt_rand(), true)), 0, 24);
    }

    /** Zufaelliges Pfad-Segment fuer die Artefakt-Ablage (16 Hex - nie mit einer 24-Hex-Referenz verwechselbar). */
    private static function newSecret()
    {
        if (function_exists('htp_random_bytes')) {
            return bin2hex(htp_random_bytes(8));
        }

        try {
            if (function_exists('random_bytes')) {
                return bin2hex(random_bytes(8));
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return substr(hash('sha256', uniqid((string) mt_rand(), true)), 0, 16);
    }

    /** Sanitisierter Site-Host fuer lesbare Backup-Namen (z. B. myj6test.wb-dev.de); leer -> 'site'. */
    private static function hostSlug()
    {
        $h = '';

        if (function_exists('htp_site_url')) {
            $p = @parse_url((string) htp_site_url(), PHP_URL_HOST);

            if (is_string($p)) {
                $h = $p;
            }
        }

        if ($h === '' && isset($_SERVER['HTTP_HOST'])) {
            $h = (string) $_SERVER['HTTP_HOST'];
        }

        $h = strtolower(preg_replace('~[^a-z0-9.-]+~i', '-', $h));
        $h = trim($h, '.-');

        if (strlen($h) > 60) {
            $h = rtrim(substr($h, 0, 60), '.-');
        }

        return $h !== '' ? $h : 'site';
    }

    /** Kompakter, lesbarer Zeitstempel fuer Backup-Namen: JJJJMMTT-HHMMSS (Ortszeit der Website). */
    private static function stampNow($ts)
    {
        return date('Ymd-His', (int) $ts);
    }

    /** Datei-Praefix eines laufenden Backups (Host + Zeitstempel), aus dem Job. '' bei Alt-Jobs. */
    private static function artPrefix($job)
    {
        return (is_array($job) && isset($job['bkprefix']) && $job['bkprefix'] !== '')
            ? ((string) $job['bkprefix'] . '-') : '';
    }

    /** Name des DB-Dumps eines laufenden Backups: <host>-<stamp>-db.sql.gz (neu) bzw. db.sql.gz (Alt-Job). */
    private static function dbArtName($job)
    {
        return self::artPrefix($job) . 'db.sql.gz';
    }

    /** Name des Datei-Archivs eines laufenden Backups je Engine (zip/tar/targz), mit Praefix. */
    private static function filesArtName($job)
    {
        $eng = (is_array($job) && isset($job['files_engine'])) ? (string) $job['files_engine'] : 'zip';
        $ext = ($eng === 'zip') ? 'files.zip' : ('files.tar' . ($eng === 'targz' ? '.gz' : ''));

        return self::artPrefix($job) . $ext;
    }

    /**
     * Backup-Ordner (mit '/') zu einer 24-Hex-Referenz aufloesen. Neu-Layout: der Ordner heisst
     * <host>-<stamp>-<zufall> und traegt die Referenz nur im Manifest (bkid); Alt-Layout: der Ordner
     * HEISST die Referenz. Aufloesung ueber die Manifeste (einmal je Request gescannt + gecacht).
     * Liefert '' wenn nicht gefunden. Weil der Zufalls-Suffix des Ordnernamens NIE in einem
     * Heartbeat/URL auftaucht, bleibt die Ablage auch bei bekannter Referenz + .htaccess-ignorierendem
     * Server unerratbar (der Grund fuer die frueher zweistufige Ablage - jetzt in EINEM Namen vereint).
     */
    private static function bkPath($ref)
    {
        $ref = (string) $ref;

        if (!preg_match('~^[a-f0-9]{24}$~', $ref)) {
            return '';
        }

        static $map = null;

        if ($map === null) {
            $map  = array();
            $base = self::dir();

            if ($base !== '') {
                foreach ((array) @glob($base . '*', GLOB_ONLYDIR) as $d) {
                    $mf = $d . '/HTP-MANIFEST.json';

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

                    $m   = json_decode((string) @file_get_contents($mf), true);
                    // Ref aus dem Manifest (bkid); Alt-Fallback: der Ordnername IST die Referenz.
                    $bid = (is_array($m) && isset($m['bkid'])) ? (string) $m['bkid'] : basename($d);

                    if (preg_match('~^[a-f0-9]{24}$~', $bid) && !isset($map[$bid])) {
                        $map[$bid] = rtrim(str_replace('\\', '/', $d), '/') . '/';
                    }
                }
            }
        }

        return isset($map[$ref]) ? $map[$ref] : '';
    }

    /** Pfad des DB-Dumps eines abgeschlossenen Backups (Manifest kennt den Namen; Alt: db.sql.gz). '' wenn keiner. */
    private static function dbArtPath($bdir, $manifest)
    {
        if ((string) $bdir === '') {
            return '';
        }

        $adir = self::artDir($bdir);

        if (is_array($manifest) && !empty($manifest['db_file'])) {
            $p = $adir . basename((string) $manifest['db_file']);

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

        return is_file($adir . 'db.sql.gz') ? $adir . 'db.sql.gz' : '';
    }

    /** Pfad des Datei-Archivs eines abgeschlossenen Backups (Manifest kennt den Namen; Alt: files.zip/tar[.gz]). '' wenn keins. */
    private static function filesArtPath($bdir, $manifest)
    {
        if ((string) $bdir === '') {
            return '';
        }

        $adir = self::artDir($bdir);

        if (is_array($manifest) && !empty($manifest['files_file'])) {
            $p = $adir . basename((string) $manifest['files_file']);

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

        foreach (array('files.zip', 'files.tar.gz', 'files.tar') as $n) {
            if (is_file($adir . $n)) {
                return $adir . $n;
            }
        }

        return '';
    }

    /**
     * Ablage-Ordner der Artefakte eines Backups. Neue Backups legen sie in einem zufaelligen
     * Unterordner ab (16 Hex), aeltere liegen direkt unter der Referenz - BEIDE Layouts werden
     * gefunden, damit vorhandene Backups wiederherstellbar bleiben. Das Segment wird ausschliesslich
     * lokal ermittelt (Verzeichnis lesen); ueber das Web ist es nicht auffindbar.
     *
     * @param   string  $bdir  Backup-Ordner (mit abschliessendem '/')
     *
     * @return string  Pfad mit abschliessendem '/'
     */
    private static function artDir($bdir)
    {
        $bdir  = rtrim(str_replace('\\', '/', (string) $bdir), '/') . '/';
        $items = @scandir($bdir);

        if (is_array($items)) {
            foreach ($items as $s) {
                if ($s !== '.' && $s !== '..' && preg_match('~^[a-f0-9]{16}$~', $s) && is_dir($bdir . $s)) {
                    return $bdir . $s . '/';
                }
            }
        }

        return $bdir; // Altbestand: Artefakte direkt unter der Referenz
    }

    /** Backup-Basisordner vor Web-Zugriff schuetzen (einmalig; bei nginx ist der Ort massgeblich). */
    private static function protectDir($dir, &$st)
    {
        if (!is_file($dir . '.htaccess')) {
            @file_put_contents($dir . '.htaccess', "Require all denied\n<IfModule !mod_authz_core.c>\nOrder allow,deny\nDeny from all\n</IfModule>\n");
        }

        if (!is_file($dir . 'index.html')) {
            @file_put_contents($dir . 'index.html', '');
        }

        if (is_array($st)) {
            $st = self::webCheck($dir, $st);
        }
    }

    /**
     * Wird der Backup-Ordner vom Server wirklich gesperrt? Echter Test statt Annahme: eine harmlose
     * Marker-Datei ablegen, ueber das Web abrufen, wieder loeschen. Kommt sie zurueck, wertet der
     * Server die .htaccess nicht aus - das wird im Zustand vermerkt, damit die Anzeige warnen kann.
     *
     * NEBENFUNKTION, bewusst nicht fail-closed: hoechstens einmal pro Woche, kurzer Zeitablauf, und
     * jedes unklare Ergebnis (kein HTTP-Client, Netzfehler, Ordner ausserhalb des Web-Bereichs) gilt
     * als unauffaellig. Das Backup laeuft in jedem Fall weiter.
     *
     * @param   string  $dir  Backup-Basisordner (mit abschliessendem '/')
     * @param   array   $st   Link-Zustand
     *
     * @return array  Ergaenzter Link-Zustand
     */
    private static function webCheck($dir, $st)
    {
        $last = isset($st['bk_web_ts']) ? (int) $st['bk_web_ts'] : 0;

        if ($last > 0 && (time() - $last) < 604800) {
            return $st; // Ergebnis ist frisch genug
        }

        if (!class_exists('HtpProbe') || !HtpProbe::available() || !function_exists('htp_site_url')) {
            return $st;
        }

        $rel = self::relOf(self::docroot(), rtrim($dir, '/'));

        if ($rel === '') {
            return $st; // Ablage liegt ausserhalb des Web-Bereichs - nichts zu testen
        }

        $name   = 'webcheck-' . self::newSecret() . '.txt';
        $marker = 'htprotect-webcheck';

        if (@file_put_contents($dir . $name, $marker . "\n") === false) {
            return $st;
        }

        $res = HtpProbe::fetch(rtrim(htp_site_url(), '/') . '/' . $rel . '/' . $name, true, null, 4);
        @unlink($dir . $name);

        $status = isset($res['status']) ? (int) $res['status'] : 0;
        $body   = isset($res['body']) ? (string) $res['body'] : '';

        // NUR ein echter Treffer zaehlt (2xx MIT dem Marker im Inhalt) - eine Fehlerseite mit
        // Status 200 loest also keine Fehlwarnung aus.
        $st['bk_web_ts']      = time();
        $st['bk_web_exposed'] = ($status >= 200 && $status < 300 && strpos($body, $marker) !== false) ? 1 : 0;

        return $st;
    }

    /** Bezeichner mit Backticks quoten (Backticks verdoppelt). Nur IDENT-validierte Namen uebergeben. */
    private static function qi($name)
    {
        return '`' . str_replace('`', '``', (string) $name) . '`';
    }

    /** SQL-Kopf (einmal je Dump): sicheres Reimport-Umfeld. */
    private static function dbHeader()
    {
        return "-- HTProtect DB-Backup\n"
            . "/*!40101 SET NAMES utf8mb4 */;\n"
            . "SET FOREIGN_KEY_CHECKS=0;\n"
            . "SET SQL_MODE='NO_AUTO_VALUE_ON_ZERO';\n"
            . "SET TIME_ZONE='+00:00';\n\n";
    }

    /** Basis-Tabellen (keine Views) der aktuellen DB. */
    private static function dbTableList($db)
    {
        $list = array();

        try {
            $db->setQuery('SHOW FULL TABLES WHERE Table_type = ' . $db->quote('BASE TABLE'));
            $rows = $db->loadRowList();

            foreach ((is_array($rows) ? $rows : array()) as $r) {
                $t = isset($r[0]) ? (string) $r[0] : '';

                if ($t !== '' && preg_match('~^[A-Za-z0-9_]+$~', $t)) {
                    $list[] = $t;
                }
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return $list;
    }

    /** DROP + CREATE einer Tabelle (aus SHOW CREATE TABLE - vertrauenswuerdige DB-Ausgabe). */
    private static function dbTableStruct($db, $t)
    {
        $q   = self::qi($t);
        $out = "\n-- Tabelle " . $t . "\nDROP TABLE IF EXISTS " . $q . ";\n";

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

            if ($create !== '') {
                $out .= $create . ";\n";
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return $out;
    }

    /** Einspaltiger Primaerschluessel einer Tabelle (fuer Keyset), sonst '' (dann Offset-Fallback). */
    private static function dbPrimaryKey($db, $t)
    {
        try {
            $db->setQuery('SHOW KEYS FROM ' . self::qi($t) . ' WHERE Key_name = ' . $db->quote('PRIMARY'));
            $keys = $db->loadAssocList();

            if (is_array($keys) && count($keys) === 1 && isset($keys[0]['Column_name'])) {
                $col = (string) $keys[0]['Column_name'];

                if (preg_match('~^[A-Za-z0-9_]+$~', $col)) {
                    return $col;
                }
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return '';
    }

    /** Einen Wert fuer INSERT kodieren: NULL / 0x-hex bei Binaer / sonst $db->quote (kein Injektion). */
    private static function dbVal($db, $v)
    {
        if ($v === null) {
            return 'NULL';
        }

        $v = (string) $v;

        // Binaer/Nicht-UTF8 -> hex (verlustfrei, reimport-sicher; wie --hex-blob).
        $utf8 = function_exists('mb_check_encoding')
            ? mb_check_encoding($v, 'UTF-8')
            : (preg_match('~[\x00-\x08\x0B\x0C\x0E-\x1F]~', $v) === 0);

        if (!$utf8) {
            return $v === '' ? "''" : '0x' . bin2hex($v);
        }

        return $db->quote($v);
    }

    /**
     * Batch-Zeilenzahl fuer den Daten-Dump EINER Tabelle, gedeckelt auf $budget ROHDATEN-Bytes.
     * Tabellen mit sehr grossen Zeilen (Joomla-Smart-Search finder_links ~42 KB/Zeile, Cache-/Log-
     * Tabellen mit BLOB/TEXT) wuerden mit dem festen $baseBatch (z. B. 1000) via loadAssocList auf
     * 128-MB-Hostern den Speicher sprengen (OOM) und den DB-Dump genau dort einfrieren (gemeldet an
     * literatopia.de). AVG_ROW_LENGTH aus information_schema (grobe Schaetzung genuegt); unbekannt ->
     * $baseBatch (keine Verschlechterung). Nie unter 4, nie ueber $baseBatch.
     *
     * @return int
     */
    private static function dbAdaptiveBatch($db, $t, $baseBatch, $budget)
    {
        $baseBatch = max(1, (int) $baseBatch);
        $budget    = max(262144, (int) $budget); // >= 256 KB Rohdaten je Block
        $avg       = 0;

        try {
            $db->setQuery(
                'SELECT AVG_ROW_LENGTH FROM information_schema.TABLES'
                . ' WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ' . $db->quote($t)
            );
            $avg = (int) $db->loadResult();
        } catch (Exception $e) {
            $avg = 0;
        } catch (Throwable $e) {
            $avg = 0;
        }

        if ($avg <= 0) {
            return $baseBatch; // unbekannt -> Basis; grosse Zeilen liefern praktisch immer einen Wert
        }

        $b = (int) floor($budget / max($avg, 512));

        return max(4, min($baseBatch, $b));
    }

    /**
     * Datenschritt einer Tabelle: Keyset- (oder Offset-)paginierte EXTENDED-INSERTs in den gz-Stream,
     * speichersicher (nie ganze Tabelle im RAM), bis Tabelle fertig ODER Deadline.
     *
     * @return array { key:mixed, rows:int, done:bool }
     */
    private static function dbTableDataChunk($db, $t, $cursor, $pk, $gz, $deadline, $batch = 500)
    {
        $q     = self::qi($t);
        // Untergrenze bewusst NIEDRIG (2): der Aufrufer liefert einen pro Tabelle an die Zeilengroesse
        // angepassten Batch (dbAdaptiveBatch) - eine hohe Untergrenze wuerde den OOM-Schutz bei
        // Riesenzeilen (finder_links & Co.) wieder aushebeln.
        $batch = max(2, (int) $batch);
        $rows  = 0;
        $key   = $cursor;

        while (microtime(true) < $deadline) {
            if ($pk !== '') {
                $where = ($key === null) ? '' : (' WHERE ' . self::qi($pk) . ' > ' . $db->quote((string) $key));
                $db->setQuery('SELECT * FROM ' . $q . $where . ' ORDER BY ' . self::qi($pk) . ' LIMIT ' . $batch);
            } else {
                $off = (int) ($key === null ? 0 : $key);
                $db->setQuery('SELECT * FROM ' . $q . ' LIMIT ' . $off . ',' . $batch);
            }

            $data = $db->loadAssocList();

            if (!is_array($data) || count($data) === 0) {
                return array('key' => $key, 'rows' => $rows, 'done' => true);
            }

            $cols = array_keys($data[0]);

            foreach ($cols as $c) {
                if (!preg_match('~^[A-Za-z0-9_]+$~', (string) $c)) {
                    // Ungewoehnlicher Spaltenname -> Daten dieser Tabelle sicherheitshalber auslassen.
                    return array('key' => $key, 'rows' => $rows, 'done' => true);
                }
            }

            $colList = array();

            foreach ($cols as $c) {
                $colList[] = self::qi($c);
            }

            $prefix = 'INSERT INTO ' . $q . ' (' . implode(',', $colList) . ') VALUES ';
            $vals   = array();
            $vlen   = 0;

            foreach ($data as $row) {
                $cells = array();

                foreach ($cols as $c) {
                    $cells[] = self::dbVal($db, $row[$c]);
                }

                $tuple  = '(' . implode(',', $cells) . ')';
                $vals[] = $tuple;
                $vlen  += strlen($tuple) + 1;

                // Statement bei ~512 KB umbrechen (reimport-sicher gegen max_allowed_packet). Eine
                // einzelne, groessere Zeile bleibt zwangslaeufig ein eigenes INSERT (nicht teilbar).
                if ($vlen >= self::DB_INSERT_CAP) {
                    gzwrite($gz, $prefix . implode(',', $vals) . ";\n");
                    $vals = array();
                    $vlen = 0;
                }
            }

            if (!empty($vals)) {
                gzwrite($gz, $prefix . implode(',', $vals) . ";\n");
            }

            $rows += count($data);

            if ($pk !== '') {
                $last = $data[count($data) - 1];
                $key  = $last[$pk];
            } else {
                $key = (int) ($key === null ? 0 : $key) + count($data);
            }

            if (count($data) < $batch) {
                return array('key' => $key, 'rows' => $rows, 'done' => true);
            }
        }

        return array('key' => $key, 'rows' => $rows, 'done' => false);
    }

    /**
     * Globaler, MONOTONER Backup-Fortschritt (0..1) aus dem phasen-lokalen Anteil $frac (0..1).
     *
     * Ein Voll-Backup besteht aus DB + Dateien - ohne Aufteilung fuellte der Balken erst bei der
     * DB auf 100 % und fiel dann fuer die Dateien auf 0 zurueck. Deshalb feste, nie ueberlappende
     * Teilbereiche: Voll-Backup -> DB = 0..0.5, Dateien = 0.5..0.99; reines DB-/Dateien-/Inkrement-
     * Backup -> 0..0.99. Die 100 % (1.0) setzt ausschliesslich das finalize. Nie rueckwaerts (max
     * mit dem bisherigen Wert), damit eine ungenaue Schaetzung den Balken nie zurueckspringen laesst.
     *
     * @return float auf 3 Stellen gerundet
     */
    private static function progAt($job, $phase, $frac)
    {
        $frac = max(0.0, min(1.0, (float) $frac));
        $kind = isset($job['kind']) ? (string) $job['kind'] : 'full';

        if ($kind === 'full') {
            $g = ($phase === 'db') ? (0.5 * $frac) : (0.5 + 0.49 * $frac);
        } else {
            $g = 0.99 * $frac;
        }

        $prev = isset($job['progress']) ? (float) $job['progress'] : 0.0;

        return round(max($prev, min(0.99, $g)), 3);
    }

    /** Ein DB-Step im Zeitbudget: Struktur + Daten mehrerer Tabellen, resumierbar ueber Ticks. */
    private static function dbStep($job, $bdir, $deadline)
    {
        $db  = htp_db();
        $gzf = self::artDir($bdir) . self::dbArtName($job);

        // Backslash-Escaping fuer die Dump-Session sicherstellen: bei NO_BACKSLASH_ESCAPES wuerde
        // $db->quote Newlines NICHT als \n escapen -> mehrzeilige INSERTs -> der zeilenbasierte
        // Restore-Reader koennte sie zerreissen. Session-scoped, nur waehrend des Dumps.
        try {
            $db->setQuery("SET SESSION sql_mode = REPLACE(@@SESSION.sql_mode, 'NO_BACKSLASH_ESCAPES', '')");
            $db->execute();
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        // Erste Runde: Tabellenliste + Header (neuer File).
        if ($job['db_tables'] === null) {
            $job['db_tables'] = self::dbTableList($db);
            $job['db_idx']    = 0;
            $job['db_key']    = null;
            $job['db_struct'] = false;
            // Gesamtzeilen einmalig schaetzen (information_schema, ohne ausgeschlossene Tabellen) ->
            // zeilenbasierter Fortschritt, der auch INNERHALB einer riesigen Tabelle weiterlaeuft.
            $job['db_total']  = self::estimateDbRows($db, $job['db_tables'], is_array($job['excl_tables']) ? $job['excl_tables'] : array());

            $gz = @gzopen($gzf, 'wb1');

            if ($gz === false) {
                $job['_db_done'] = true;

                return $job;
            }

            gzwrite($gz, self::dbHeader());
            gzclose($gz);
        }

        $tables = is_array($job['db_tables']) ? $job['db_tables'] : array();
        $n      = count($tables);

        if ($n === 0 || $job['db_idx'] >= $n) {
            // Nach allen Basis-Tabellen einmalig Views + Trigger anhaengen.
            if (empty($job['db_extras'])) {
                $gzx = @gzopen($gzf, 'ab1');

                if ($gzx !== false) {
                    gzwrite($gzx, self::dbExtras($db));
                    gzclose($gzx);
                }

                $job['db_extras'] = true;
            }

            $job['_db_done'] = true;
            // DB fertig: Voll-Backup -> 0.5 (Dateien folgen), reines DB-Backup -> ~0.99 (finalize=1.0).
            $job['progress'] = self::progAt($job, 'db', 1.0);

            return $job;
        }

        $gz = @gzopen($gzf, 'ab1');

        if ($gz === false) {
            $job['_db_done'] = true;

            return $job;
        }

        while ($job['db_idx'] < $n && microtime(true) < $deadline) {
            $t = (string) $tables[$job['db_idx']];

            if (!preg_match('~^[A-Za-z0-9_]+$~', $t)) {
                $job['db_idx']++;
                $job['db_key']    = null;
                $job['db_struct'] = false;
                continue;
            }

            $excluded = in_array($t, is_array($job['excl_tables']) ? $job['excl_tables'] : array(), true);

            if (empty($job['db_struct'])) {
                gzwrite($gz, self::dbTableStruct($db, $t));
                $job['db_struct'] = true;
                $job['tables_n']  = (int) $job['tables_n'] + 1;
                $job['db_tbatch'] = null; // Batch fuer DIESE Tabelle neu bestimmen (Resume behaelt ihn)

                if ($excluded) {
                    gzwrite($gz, "-- (nur Struktur, ausgeschlossen)\n");
                    $job['db_idx']++;
                    $job['db_key']    = null;
                    $job['db_struct'] = false;
                    continue;
                }
            }

            // Adaptiver Batch je Tabelle: bei sehr grossen Zeilen (z. B. finder_links ~42 KB) drastisch
            // kleiner, damit ein Datenblock das Rohdaten-Budget nie sprengt (OOM-Schutz). Einmal je
            // Tabelle bestimmt; ueber Ticks im Job gehalten (Resume rechnet nicht neu).
            if (empty($job['db_tbatch'])) {
                $job['db_tbatch'] = self::dbAdaptiveBatch(
                    $db, $t,
                    isset($job['db_batch']) ? (int) $job['db_batch'] : 500,
                    isset($job['db_membudget']) ? (int) $job['db_membudget'] : 6291456
                );
            }

            $pk = self::dbPrimaryKey($db, $t);
            $r  = self::dbTableDataChunk($db, $t, $job['db_key'], $pk, $gz, $deadline, (int) $job['db_tbatch']);
            $job['db_key']  = $r['key'];
            $job['db_rows'] = (int) $job['db_rows'] + (int) $r['rows'];

            if (!empty($r['done'])) {
                $job['db_idx']++;
                $job['db_key']    = null;
                $job['db_struct'] = false;
            }
        }

        gzclose($gz);

        // Zeilenbasiert (glatt, auch INNERHALB einer einzelnen Riesentabelle: db_rows waechst je
        // Batch), sonst tabellenbasiert. Die Schaetzung ist grob -> progAt deckelt auf 0.99, mappt
        // beim Voll-Backup in 0..0.5 und ist MONOTON (springt nie zurueck). estimateDbRows liefert
        // dank DATA_LENGTH-Fallback praktisch immer >0, solange Daten da sind -> kein Stillstand bei
        // db_idx/n auf einer Riesentabelle.
        $total = isset($job['db_total']) ? (int) $job['db_total'] : 0;
        $frac  = ($total > 0)
            ? ((float) $job['db_rows'] / $total)
            : ($n > 0 ? ((float) $job['db_idx'] / $n) : 0.99);
        $job['progress'] = self::progAt($job, 'db', $frac);

        // Abschluss (inkl. Views/Trigger) laeuft ueber die obere Schranke im naechsten Tick.
        return $job;
    }

    /**
     * Gesamt-Zeilenzahl der zu sichernden Tabellen schaetzen (information_schema.TABLE_ROWS, ohne
     * ausgeschlossene). Nur fuer die Fortschrittsanzeige - bewusst grob + fail-open (0 = Fallback
     * auf tabellenbasierten Fortschritt). TABLE_ROWS ist bei InnoDB eine Schaetzung; das genuegt.
     *
     * @return int
     */
    private static function estimateDbRows($db, $tables, $excl)
    {
        if (!is_array($tables) || empty($tables)) {
            return 0;
        }

        $want = array();

        foreach ($tables as $t) {
            $t = (string) $t;

            if (preg_match('~^[A-Za-z0-9_]+$~', $t) && !in_array($t, (array) $excl, true)) {
                $want[$t] = true;
            }
        }

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

        $total = 0;

        try {
            $db->setQuery(
                'SELECT TABLE_NAME AS n, TABLE_ROWS AS r, DATA_LENGTH AS d FROM information_schema.TABLES'
                . ' WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = ' . $db->quote('BASE TABLE')
            );

            foreach ((array) $db->loadObjectList() as $row) {
                $name = isset($row->n) ? (string) $row->n : '';

                if (!isset($want[$name])) {
                    continue;
                }

                $rows = max(0, (int) (isset($row->r) ? $row->r : 0));
                $data = max(0, (int) (isset($row->d) ? $row->d : 0));

                // TABLE_ROWS ist bei InnoDB manchmal 0, obwohl Daten da sind (nie ANALYZE gelaufen).
                // Dann aus der Datengroesse abschaetzen (~80 Bytes/Zeile grob) -> die Gesamtzahl
                // bleibt >0, der zeilenbasierte Fortschritt laeuft weiter statt bei db_idx/n
                // (Tabellenindex) auf einer Riesentabelle stehenzubleiben.
                if ($rows <= 0 && $data > 0) {
                    $rows = (int) ceil($data / 80);
                }

                $total += $rows;
            }
        } catch (Exception $e) {
            return 0;
        } catch (Throwable $e) {
            return 0;
        }

        return $total;
    }

    /**
     * Views + Trigger der DB (nach den Basis-Tabellen, einmal). Vertrauenswuerdige DB-Ausgabe;
     * DEFINER entfernt (portabel), Trigger mit DELIMITER-Wrapper fuer den Reimport. Fehlertolerant -
     * fehlende Rechte/Features fuehren nie zum Abbruch, nur zu weniger Ausgabe.
     */
    private static function dbExtras($db)
    {
        $out = '';

        // Views: DROP + CREATE VIEW, DEFINER strippen.
        try {
            $db->setQuery('SHOW FULL TABLES WHERE Table_type = ' . $db->quote('VIEW'));
            $rows = $db->loadRowList();

            foreach ((is_array($rows) ? $rows : array()) as $r) {
                $v = isset($r[0]) ? (string) $r[0] : '';

                if ($v === '' || !preg_match('~^[A-Za-z0-9_]+$~', $v)) {
                    continue;
                }

                $sql = '';

                try {
                    $db->setQuery('SHOW CREATE VIEW ' . self::qi($v));
                    $cr  = $db->loadRow();
                    $sql = (is_array($cr) && isset($cr[1])) ? (string) $cr[1] : '';
                } catch (Exception $e) {
                } catch (Throwable $e) {
                }

                if ($sql !== '') {
                    $out .= "\n-- View " . $v . "\nDROP VIEW IF EXISTS " . self::qi($v) . ";\n" . self::stripDefiner($sql) . ";\n";
                }
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        // Trigger: DROP + CREATE TRIGGER mit DELIMITER-Wrapper.
        try {
            $db->setQuery('SHOW TRIGGERS');
            $trg = $db->loadAssocList();

            foreach ((is_array($trg) ? $trg : array()) as $row) {
                $name = isset($row['Trigger']) ? (string) $row['Trigger'] : '';

                if ($name === '' || !preg_match('~^[A-Za-z0-9_]+$~', $name)) {
                    continue;
                }

                $sql = '';

                try {
                    $db->setQuery('SHOW CREATE TRIGGER ' . self::qi($name));
                    $cr  = $db->loadAssoc();
                    $sql = (is_array($cr) && isset($cr['SQL Original Statement'])) ? (string) $cr['SQL Original Statement'] : '';
                } catch (Exception $e) {
                } catch (Throwable $e) {
                }

                if ($sql !== '') {
                    $out .= "\n-- Trigger " . $name . "\nDROP TRIGGER IF EXISTS " . self::qi($name)
                        . ";\nDELIMITER ;;\n" . self::stripDefiner($sql) . ";;\nDELIMITER ;\n";
                }
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return $out;
    }

    /** DEFINER=`user`@`host` aus einer CREATE-Anweisung entfernen (portabler Reimport). */
    private static function stripDefiner($sql)
    {
        return (string) preg_replace('~DEFINER\s*=\s*`(?:[^`]|``)*`@`(?:[^`]|``)*`\s*~i', '', (string) $sql);
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Datei-Archiv-Engine (rein PHP, resumierbar, ZipArchive -> pure-PHP-TAR-Fallback)
    // ─────────────────────────────────────────────────────────────────────────

    /** WEB-Docroot der Website (JPATH_PUBLIC via htp_docroot, sonst JPATH_ROOT), normalisiert ohne '/'.
     *  NUR fuer die Web-Expositions-Pruefung (webCheck) - fuers Datei-Backup/-Restore siehe siteRoot(). */
    private static function docroot()
    {
        if (function_exists('htp_docroot')) {
            $d = htp_docroot();

            if (is_string($d) && $d !== '') {
                return rtrim(str_replace('\\', '/', $d), '/');
            }
        }

        if (defined('JPATH_ROOT')) {
            return rtrim(str_replace('\\', '/', JPATH_ROOT), '/');
        }

        return '';
    }

    /** VOLLSTAENDIGER Site-Stamm (App-Root via htp_root) fuers Datei-Backup/-Restore/-Analyse. Im
     *  Public-Folder-Modus ENTHAELT er den public/-Webroot als Unterordner -> ein Backup erfasst
     *  den gesamten Code (administrator, components, libraries, vendor, configuration.php ...) UND
     *  die Uploads, und ein Restore legt alles am richtigen Ort zurueck. Wuerde hier htp_docroot()
     *  (=public/ im PF) stehen, fehlte der gesamte Code oberhalb des Webroots. Klassisch identisch
     *  mit docroot() (htp_root()==htp_docroot()) -> keine Aenderung fuer normale Installs. */
    private static function siteRoot()
    {
        if (function_exists('htp_root')) {
            $r = htp_root();

            if (is_string($r) && $r !== '') {
                return rtrim(str_replace('\\', '/', $r), '/');
            }
        }

        if (defined('JPATH_ROOT')) {
            return rtrim(str_replace('\\', '/', JPATH_ROOT), '/');
        }

        return '';
    }

    /** Relative Pfade saeubern (kein '..', kein fuehrender '/', konservatives Zeichenset, gedeckelt). */
    private static function sanitizeRels($arr)
    {
        $out = array();

        foreach ((array) $arr as $x) {
            $x = is_string($x) ? trim(str_replace('\\', '/', $x)) : '';
            $x = trim($x, '/');

            if ($x === '' || strpos($x, "\0") !== false || preg_match('~[\x00-\x1f]~', $x)) {
                continue; // leer / Null-Byte / Steuerzeichen (letztere wuerden das Zeilenformat brechen)
            }

            // Kein Traversal: '..'/'.' NUR als ganzes Pfad-Segment verbieten (Datei "bild..jpg" bleibt
            // erlaubt). Leerzeichen, Klammern, Umlaute, CJK etc. sind zugelassen - grosse Mediendateien
            // heissen oft so, sonst wuerde ihre Abwahl still fehlschlagen.
            $bad = false;

            foreach (explode('/', $x) as $seg) {
                if ($seg === '..' || $seg === '.') {
                    $bad = true;
                    break;
                }
            }

            if (!$bad) {
                $out[$x] = true;
            }
        }

        return array_slice(array_keys($out), 0, 2000);
    }

    /** Standard-Ausschluesse (fluechtige/generierte Ordner). */
    private static function defaultExcludeDirs()
    {
        return array(
            'cache', 'administrator/cache', 'tmp', 'logs', 'administrator/logs',
            // HTProtect-eigener Datenordner (Backups, Auto-Update-Snapshots, CAS-Store, Caches) NIE
            // ins User-Backup - sonst sicherte das Backup seine eigenen (u.U. riesigen) Backups mit.
            // Zusaetzlich zum selfDir-Ausschluss (= htp_state_dir); hier hart benannt fuer den Fall,
            // dass der State-Ordner via Symlink o.ae. an anderer Stelle auftaucht.
            'administrator/components/com_htprotect/data',
            // Neuer, gemeinsamer Ablageort ALLER grossen Sicherungen (.app-Backups + Auto-Update-
            // Rollback-Snapshots) - liegt AUSSERHALB des State-Ordners, deshalb hier eigens raus.
            'administrator/_htprotect_backups',
            // Drittanbieter-Backup-Erweiterungen: deren eigene, oft sehr grosse Archiv-Ablagen NIE
            // mitsichern (die Archive selbst faengt zusaetzlich isBackupArchive per Endung ab; diese
            // Verzeichnisse decken auch Nicht-Archiv-Beiwerk wie XCloners .tar/Logs/DB-Dumps ab).
            'administrator/components/com_akeebabackup/backup',              // Akeeba Backup (Joomla 4+)
            'administrator/components/com_akeeba/backup',                    // Akeeba Backup (alt)
            'administrator/components/com_joomlapack/backups',              // JoomlaPack (Alt-Akeeba)
            'administrator/components/com_xcloner-backupandrestore/backups', // XCloner Backup & Restore
            'administrator/backups',                                        // XCloner-/generische Standard-Ausgabe
            'backups',                                                      // haeufige Wurzel-Ablage (Akeeba-Custom / XCloner / manuell)
        );
    }

    /**
     * NUR eindeutige Backup-Archiv-Endungen, die es als legitimen Nutzer-Inhalt praktisch nicht gibt:
     * Akeeba .jpa/.jps + Split-Teile .j01.. . Solche Dateien nie ins Backup aufnehmen - auch in
     * einem nicht erkannten Custom-Ausgabeordner.
     *
     * BEWUSST NICHT dabei: das GENERISCHE .zip/.zipx/.z0N. Ein pauschaler .zip-Ausschluss wuerfe
     * legitime .zip-Downloads/-Inhalte einer Website aus dem Backup = DATENVERLUST beim Zurueckspielen.
     * Ein Backup-.zip liegt praktisch immer in einem Backup-Ordner - und den faengt die Verzeichnis-
     * Ausschlussliste (defaultExcludeDirs / relExcluded) bereits komplett ab. Genau dort - nur in
     * bekannten Backup-Ordnern - wird .zip also ausgeschlossen, sonst nirgends.
     */
    private static function isBackupArchive($rel)
    {
        return (bool) preg_match('~\.(jpa|jps|j[0-9]{2})$~i', (string) $rel);
    }

    /** Schwelle fuer "grosse Einzeldatei" (optionaler Betreiber-Ausschluss): 50 MB. */
    private static function bigFileBytes()
    {
        return 52428800; // 50 * 1024 * 1024
    }

    /** Bereits komprimierte Endungen -> im Zip nur STORE (keine Recompression, schneller). */
    private static function isPrecompressed($rel)
    {
        return (bool) preg_match('~\.(jpe?g|png|gif|webp|zip|gz|tgz|bz2|xz|7z|rar|mp4|m4v|mov|webm|mp3|ogg|woff2?|pdf|ico)$~i', $rel);
    }

    /** Relativpfad ab Docroot; '' wenn ausserhalb. */
    private static function relOf($root, $full)
    {
        $full = str_replace('\\', '/', $full);
        $rp   = rtrim($root, '/') . '/';

        if (strpos($full, $rp) === 0) {
            return substr($full, strlen($rp));
        }

        return '';
    }

    /** Ist der Relativpfad ausgeschlossen? (Verzeichnis-Praefix-Match.) */
    private static function relExcluded($rel, $excl)
    {
        foreach ($excl as $e) {
            if ($e !== '' && ($rel === $e || strpos($rel, $e . '/') === 0)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Dateiliste EINMAL bauen, streamend nach filelist.txt: Symlinks aus, ausgeschlossene
     * Verzeichnisse werden NICHT betreten (kein Abstieg), Backup-Ordner selbst nie eingepackt.
     *
     * Schreibt IMMER den vollstaendigen Datei-Zustand (relpfad\tmtime:size) nach $stateFile - das
     * ist der Anker fuer das naechste Inkrement. Ist $parentState (relpfad->"mtime:size") gesetzt
     * (INKREMENT), landen nur NEUE/GEAENDERTE Dateien in filelist.txt und die im Eltern-Zustand
     * vorhandenen, jetzt fehlenden Pfade als Loeschungen in $delFile.
     *
     * @return int  Anzahl der ins Archiv aufzunehmenden Dateien (Voll: alle; Inkr: nur Delta)
     */
    private static function buildFileList($root, $selfDir, $listFile, $exclDirs, $parentState = null, $stateFile = null, $delFile = null, $skipBig = false)
    {
        $excl    = array_merge(self::defaultExcludeDirs(), is_array($exclDirs) ? $exclDirs : array());
        $bigCap  = $skipBig ? self::bigFileBytes() : 0;
        $selfRel = self::relOf($root, rtrim($selfDir, '/'));

        if ($selfRel !== '') {
            $excl[] = $selfRel;
        }

        // Den neuen Sicherungs-Ort (htp_backup_root = administrator/_htprotect_backups) IMMER
        // ausschliessen - auch bei abweichendem /administrator-Pfad, wo der statische Relativpfad
        // aus defaultExcludeDirs nicht greifen wuerde. Sonst sicherte das Backup seine eigenen
        // (u.U. riesigen) Sicherungen mit.
        if (function_exists('htp_backup_root')) {
            $bkRel = self::relOf($root, rtrim(str_replace('\\', '/', (string) htp_backup_root()), '/'));

            if ($bkRel !== '') {
                $excl[] = $bkRel;
            }
        }

        $fh = @fopen($listFile, 'wb');

        if ($fh === false) {
            return 0;
        }

        $incr       = is_array($parentState);
        $sfh        = $stateFile ? @fopen($stateFile, 'wb') : null;
        $seen       = array();  // fuer Loeschungs-Erkennung (nur Inkrement)
        $count      = 0;
        $incomplete = false;    // unlesbares Verzeichnis o.ae. -> Walk unvollstaendig
        $walkOk     = false;    // wurde der Datei-Walk sauber zu Ende gefuehrt?

        try {
            $dirIt  = new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS);
            $filter = new RecursiveCallbackFilterIterator($dirIt, function ($current) use ($root, $excl, &$incomplete) {
                if ($current->isLink()) {
                    return false; // Symlinks nie folgen (Ausbruch/Schleifen)
                }

                $rel = self::relOf($root, $current->getPathname());

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

                if (self::relExcluded($rel, $excl)) {
                    return false;
                }

                // Unlesbares Verzeichnis NICHT betreten - sonst wuerfe getChildren() und braeche den
                // ganzen Walk ab. Stattdessen ueberspringen + den Lauf als unvollstaendig markieren,
                // damit KEINE Loeschungen erzeugt werden (sonst raeumt der Restore vorhandene Dateien).
                if ($current->isDir() && !$current->isReadable()) {
                    $incomplete = true;

                    return false;
                }

                return true;
            });
            $it = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::LEAVES_ONLY);

            foreach ($it as $f) {
                if (!$f->isFile() || $f->isLink()) {
                    continue;
                }

                $rel = self::relOf($root, $f->getPathname());

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

                $fsize = (int) @$f->getSize();

                // Backup-Archive (.jpa/.jps/.j01..) NIE mitsichern; optional grosse Einzeldateien.
                // WICHTIG (Inkrement): als 'gesehen' markieren, BEVOR wir ueberspringen - die Datei ist
                // physisch da, nur vom Backup ausgeschlossen. Sonst gilt sie beim Delta faelschlich als
                // geloescht und wird beim Restore aus dem LIVE-Docroot entfernt (Datenverlust).
                if (self::isBackupArchive($rel) || ($bigCap > 0 && $fsize > $bigCap)) {
                    if ($incr) {
                        $seen[$rel] = true;
                    }

                    continue;
                }

                $sig = ((int) @$f->getMTime()) . ':' . $fsize;

                if ($sfh) {
                    fwrite($sfh, $rel . "\t" . $sig . "\n"); // vollstaendiger Zustand (Anker fuers naechste Inkrement)
                }

                if ($incr) {
                    $seen[$rel] = true;

                    if (isset($parentState[$rel]) && $parentState[$rel] === $sig) {
                        continue; // unveraendert -> NICHT ins Delta-Archiv
                    }
                }

                fwrite($fh, $rel . "\n");
                $count++;
            }

            $walkOk = true; // foreach sauber durchgelaufen (kein Abbruch durch Exception)
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        fclose($fh);

        if ($sfh) {
            fclose($sfh);
        }

        // Loeschungen (nur Inkrement): Eltern-Pfade, die jetzt WIRKLICH fehlen. Ein jetzt
        // ausgeschlossener (nicht durchlaufener) Ordner/Archiv-Pfad ist KEINE Loeschung - die Datei
        // existiert live weiter; sonst wuerde der Restore sie faelschlich entfernen (Datenverlust).
        // NUR wenn der Walk vollstaendig war: bei Abbruch/unlesbarem Ordner ist $seen unvollstaendig,
        // dann wuerden vorhandene Dateien faelschlich als geloescht gelten -> gar keine Loeschungen.
        if ($incr && $delFile && $walkOk && !$incomplete) {
            $dfh = @fopen($delFile, 'wb');

            if ($dfh !== false) {
                foreach ($parentState as $rp => $_) {
                    if (isset($seen[$rp]) || self::relExcluded($rp, $excl) || self::isBackupArchive($rp)) {
                        continue;
                    }

                    // DEFINITIVER Test: nur als geloescht vermerken, wenn die Datei WIRKLICH weg ist.
                    // Faengt generisch alle "nicht gesehen, aber vorhanden"-Faelle ab (Ordner jetzt
                    // Symlink, uebersprungen o.ae.) - sonst wuerde der Restore vorhandene Dateien loeschen.
                    if (@file_exists($root . '/' . $rp)) {
                        continue;
                    }

                    fwrite($dfh, $rp . "\n");
                }

                fclose($dfh);
            }
        }

        return $count;
    }

    /** Datei-Zustand eines Backups laden (relpfad -> "mtime:size"). Leeres Array, wenn keiner. */
    private static function loadFileState($bdir)
    {
        $f = self::artDir($bdir) . 'filestate.txt';

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

        $out = array();
        $fh  = @fopen($f, 'rb');

        if ($fh === false) {
            return array();
        }

        while (($line = fgets($fh)) !== false) {
            $line = rtrim($line, "\r\n");
            $tab  = strrpos($line, "\t");

            if ($tab !== false) {
                $out[substr($line, 0, $tab)] = substr($line, $tab + 1);
            }
        }

        fclose($fh);

        return $out;
    }

    /** Datei-Step: Liste bauen (einmal) + Archiv-Engine im Zeitbudget voranbringen. */
    private static function filesStep($job, $bdir, $deadline)
    {
        $root = self::siteRoot();

        if ($root === '') {
            $job['_files_done'] = true;

            return $job;
        }

        // Alle Arbeits-/Artefaktdateien liegen im (geheimen) Ablage-Ordner des Backups.
        $adir     = self::artDir($bdir);
        $listFile = $adir . 'filelist.txt';

        if (empty($job['files_built'])) {
            // Den GANZEN HTProtect-data/-Ordner ausschliessen: (a) enthaelt die Backups selbst
            // (sonst waechst jedes Backup ums vorige) und (b) den Connector-State mit GEHEIMNISSEN
            // (htprotect_link.json: site_priv/transport_secret) - die duerfen NIE in ein
            // herunterladbares Backup. Ein wiederhergestellter Standort koppelt HTProtect neu.
            $selfDir     = function_exists('htp_state_dir') ? htp_state_dir() : $bdir;
            $stateFile   = $adir . 'filestate.txt';  // vollstaendiger Zustand (Anker fuers naechste Inkrement)
            $parentState = null;
            $delFile     = null;

            // Inkrement: nur geaenderte/neue Dateien gegen den Zustand des Eltern-Backups (Ordner ueber
            // die Referenz aufloesen - der Name ist nicht mehr die Referenz).
            if (($job['kind'] === 'incr') && !empty($job['parent_ref'])) {
                $pdir        = self::bkPath((string) $job['parent_ref']);
                $parentState = ($pdir !== '') ? self::loadFileState($pdir) : null;
                $delFile     = $adir . 'deletions.txt';
            }

            $job['files_n']     = self::buildFileList($root, $selfDir, $listFile, isset($job['excl_dirs']) ? $job['excl_dirs'] : array(), $parentState, $stateFile, $delFile, !empty($job['excl_bigfiles']));
            $job['files_idx']   = 0;
            $job['files_built'] = true;
        }

        if ((int) $job['files_n'] === 0) {
            $job['_files_done'] = true;

            return $job;
        }

        // Disk-Grobschutz VOR dem Oeffnen: zu wenig frei -> gar nicht erst anfangen (schnell + klar).
        $free = self::freeBytes($adir);

        if ($free >= 0 && $free < self::FILES_MIN_FREE) {
            $job['files_err']      = 'disk_full';
            $job['files_err_free'] = $free;
            $job['_files_done']    = true;

            return $job;
        }

        if ($job['files_engine'] === 'zip') {
            return self::filesZipStep($job, $adir, $root, $listFile, $deadline);
        }

        return self::filesTarStep($job, $adir, $root, $listFile, $deadline, $job['files_engine'] === 'targz');
    }

    /** ZipArchive-Step: N Dateien addFile (STORE fuer schon komprimierte); close() flusht + gibt Handles frei.
     *  HOST-ADAPTIVER Schreib-Budget-Schutz (nur bei knappem Platz scharf): ZipArchive puffert die
     *  Additionen und schreibt sie ERST beim close() - darum begrenzen wir bei wenig freiem Platz die
     *  diesem Batch hinzugefuegte ROH-Datenmenge auf "frei minus Reserve", damit close() sicher passt
     *  und die Platte NIE volllaeuft. Bei reichlich Platz laeuft die Schleife unveraendert (keine
     *  filesize()-Pruefung) = volle Performance. */
    private static function filesZipStep($job, $adir, $root, $listFile, $deadline)
    {
        $part  = $adir . self::filesArtName($job) . '.part';
        $lines = @file($listFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

        if (!is_array($lines)) {
            $job['_files_done'] = true;

            return $job;
        }

        $n = count($lines);
        $z = new ZipArchive();

        // CREATE oeffnet vorhandenes Teil-Archiv (Resume) oder legt neu an.
        if ($z->open($part, ZipArchive::CREATE) !== true) {
            $job['files_err']      = 'zip_open';
            $job['files_err_free'] = self::freeBytes($adir);
            $job['_files_done']    = true;

            return $job;
        }

        // Budget-Schutz nur "scharf" bei knappem Platz (host-adaptiv, sonst 0 Overhead).
        $freeNow = self::freeBytes($adir);
        $guard   = ($freeNow >= 0 && $freeNow < self::FILES_GUARD_ARM);
        $budget  = $guard ? max(0, $freeNow - self::FILES_MIN_FREE) : PHP_INT_MAX;

        $added   = 0;
        $pending = 0;
        $batch   = isset($job['files_batch']) ? max(500, (int) $job['files_batch']) : 3000;

        while ($job['files_idx'] < $n && $added < $batch && microtime(true) < $deadline) {
            $rel  = (string) $lines[$job['files_idx']];
            $full = $root . '/' . $rel;

            if ($rel !== '' && @is_file($full) && !@is_link($full)) {
                if ($guard) {
                    $fsz = (int) @filesize($full);

                    // Sprengt diese Datei das Schreib-Budget dieses close()? Passt sie selbst mit
                    // voller Reserve GAR NICHT (und es wurde noch nichts geschrieben) -> Platte echt
                    // zu voll -> sauber abbrechen. Sonst nur den Batch beenden (naechster Tick weiter).
                    if ($pending + $fsz > $budget) {
                        if ($added === 0 && $fsz > $budget) {
                            @$z->close();
                            $job['files_err']      = 'disk_full';
                            $job['files_err_free'] = $freeNow;
                            $job['_files_done']    = true;

                            return $job;
                        }

                        break;
                    }

                    $pending += $fsz;
                }

                if ($z->addFile($full, $rel) && self::isPrecompressed($rel)) {
                    @$z->setCompressionName($rel, ZipArchive::CM_STORE);
                }
            }

            $job['files_idx']++;
            $added++;
        }

        if ($z->close() !== true) {
            $job['files_err']      = 'zip_write'; // z. B. Disk-Quota
            $job['files_err_free'] = self::freeBytes($adir);
            $job['_files_done']    = true;

            return $job;
        }

        $job['progress'] = self::progAt($job, 'files', $n > 0 ? ((float) $job['files_idx'] / $n) : 1.0);

        if ($job['files_idx'] >= $n) {
            $job['_files_done'] = true;
        }

        return $job;
    }

    /** Freier Platz (Bytes) auf dem Volume von $dir, oder -1 wenn nicht ermittelbar. */
    private static function freeBytes($dir)
    {
        if (!function_exists('disk_free_space')) {
            return -1;
        }

        $f = @disk_free_space($dir);

        return is_numeric($f) ? (int) $f : -1;
    }

    /** Pure-PHP-TAR-Step (Fallback ohne ZipArchive): ustar-Header + Daten gestreamt in .tar(.gz). */
    private static function filesTarStep($job, $adir, $root, $listFile, $deadline, $gz)
    {
        $part  = $adir . self::filesArtName($job) . '.part';
        $lines = @file($listFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

        if (!is_array($lines)) {
            $job['_files_done'] = true;

            return $job;
        }

        $n  = count($lines);
        $fh = $gz ? @gzopen($part, 'ab1') : @fopen($part, 'ab');

        if ($fh === false) {
            $job['files_err']      = 'tar_open';
            $job['files_err_free'] = self::freeBytes($adir);
            $job['_files_done']    = true;

            return $job;
        }

        // Disk-Schutz (host-adaptiv): tar schreibt SOFORT -> vor jeder Datei den freien Platz pruefen
        // und sauber mit 'disk_full' stoppen, bevor die Platte volllaeuft. Nur bei knappem Platz scharf.
        $tarFree  = self::freeBytes($adir);
        $tarGuard = ($tarFree >= 0 && $tarFree < self::FILES_GUARD_ARM);

        while ($job['files_idx'] < $n && microtime(true) < $deadline) {
            if ($tarGuard) {
                $fr = self::freeBytes($adir);

                if ($fr >= 0 && $fr < self::FILES_MIN_FREE) {
                    if ($gz) {
                        gzclose($fh);
                    } else {
                        fclose($fh);
                    }

                    $job['files_err']      = 'disk_full';
                    $job['files_err_free'] = $fr;
                    $job['_files_done']    = true;

                    return $job;
                }
            }

            $rel  = (string) $lines[$job['files_idx']];
            $full = $root . '/' . $rel;

            if ($rel !== '' && strlen($rel) <= 255 && @is_file($full) && !@is_link($full)) {
                $size = (int) @filesize($full);
                $hdr  = self::tarHeader($rel, $size, (int) @filemtime($full));

                if ($hdr !== '') {
                    if ($gz) {
                        gzwrite($fh, $hdr);
                    } else {
                        fwrite($fh, $hdr);
                    }

                    $in = @fopen($full, 'rb');

                    if ($in !== false) {
                        while (!feof($in)) {
                            $buf = fread($in, 262144);

                            if ($buf === false) {
                                break;
                            }

                            if ($gz) {
                                gzwrite($fh, $buf);
                            } else {
                                fwrite($fh, $buf);
                            }
                        }

                        fclose($in);
                    }

                    $pad = (512 - ($size % 512)) % 512;

                    if ($pad > 0) {
                        if ($gz) {
                            gzwrite($fh, str_repeat("\0", $pad));
                        } else {
                            fwrite($fh, str_repeat("\0", $pad));
                        }
                    }
                }
            }

            $job['files_idx']++;
        }

        if ($gz) {
            gzclose($fh);
        } else {
            fclose($fh);
        }

        $job['progress'] = self::progAt($job, 'files', $n > 0 ? ((float) $job['files_idx'] / $n) : 1.0);

        if ($job['files_idx'] >= $n) {
            $job['_files_done'] = true;
        }

        return $job;
    }

    /** ustar-512-Byte-Header fuer eine regulaere Datei (Name bis 255 via prefix/name-Split; sonst ''). */
    private static function tarHeader($rel, $size, $mtime)
    {
        $name   = $rel;
        $prefix = '';

        if (strlen($rel) > 100) {
            $split = strrpos(substr($rel, 0, 155), '/');

            if ($split === false || (strlen($rel) - $split - 1) > 100) {
                return ''; // zu lang fuer ustar -> ueberspringen (selten)
            }

            $prefix = substr($rel, 0, $split);
            $name   = substr($rel, $split + 1);
        }

        $header = str_pad(substr($name, 0, 100), 100, "\0")                          // name [100]
            . str_pad(sprintf('%07o', 0644), 8, "\0")                                 // mode [8]
            . str_pad(sprintf('%07o', 0), 8, "\0")                                    // uid [8]
            . str_pad(sprintf('%07o', 0), 8, "\0")                                    // gid [8]
            . str_pad(sprintf('%011o', $size), 12, "\0")                              // size [12]
            . str_pad(sprintf('%011o', $mtime > 0 ? $mtime : time()), 12, "\0")       // mtime [12]
            . '        '                                                              // chksum-Platzhalter [8]
            . '0'                                                                     // typeflag [1]
            . str_pad('', 100, "\0")                                                  // linkname [100]
            . "ustar\0" . '00'                                                        // magic[6] + version[2]
            . str_pad('', 32, "\0") . str_pad('', 32, "\0")                           // uname[32] + gname[32]
            . str_pad('', 8, "\0") . str_pad('', 8, "\0")                             // devmajor[8] + devminor[8]
            . str_pad(substr($prefix, 0, 155), 155, "\0")                             // prefix [155]
            . str_pad('', 12, "\0");                                                  // pad [12]

        // Pruefsumme = Summe aller 512 Bytes (mit 8 Space-Platzhaltern), oktal an Offset 148.
        $sum = 0;

        for ($i = 0; $i < 512; $i++) {
            $sum += ord($header[$i]);
        }

        $chk = sprintf('%06o', $sum) . "\0 ";

        return substr($header, 0, 148) . $chk . substr($header, 156);
    }

    /**
     * Abschluss: Manifest schreiben (Gueltigkeitsbeweis) + Groesse. Liefert die Zero-Knowledge-
     * Zaehler fuer den Heartbeat-Report (last_backup): nur ts/ok/size/dur/kind, nie Pfade/Bytes.
     *
     * @return array
     */
    private static function finalize($job, $bdir)
    {
        $kind  = (string) $job['kind'];
        $size  = 0;
        $dbOk  = true;
        $filOk = true;
        // Artefakte liegen direkt im Backup-Ordner (neu) bzw. im geheimen Unterordner (Altbestand);
        // artDir() liefert beides. Die tatsaechlichen Datei-Namen (mit <host>-<stamp>-Praefix) wandern
        // ins Manifest, damit Download/Restore sie ohne Raten wiederfinden.
        $adir      = self::artDir($bdir);
        $dbName    = '';
        $filesName = '';

        // DB-Teil.
        if ($kind === 'db' || $kind === 'full') {
            $dbName = self::dbArtName($job);
            $gzf    = $adir . $dbName;
            $dbOk   = (is_file($gzf) && (int) @filesize($gzf) > 20);

            if ($dbOk) {
                $size += (int) @filesize($gzf);
            } else {
                $dbName = '';
            }
        }

        // Datei-Teil: Teil-Archiv atomar -> final umbenennen (sonst gilt ein Timeout-Torso als gueltig).
        // Inkrement = Dateien-Delta -> gleicher Datei-Pfad, nur mit weniger Inhalt.
        if ($kind === 'files' || $kind === 'full' || $kind === 'incr') {
            // Leeres Inkrement (0 geaenderte Dateien) ist GUELTIG - es gibt schlicht kein Archiv,
            // nur (evtl.) Loeschungen. Nicht als Fehler werten (sonst broeche die Restore-Kette).
            if ($kind === 'incr' && (int) $job['files_n'] === 0 && empty($job['files_err'])) {
                $filOk = true;
            } else {
                $pn   = self::filesArtName($job);
                $part = $adir . $pn . '.part';
                $fin  = $adir . $pn;

                if (empty($job['files_err']) && is_file($part) && (int) @filesize($part) > 0) {
                    @rename($part, $fin);
                    $filOk = is_file($fin);

                    if ($filOk) {
                        $size += (int) @filesize($fin);
                        $filesName = $pn;
                    }
                } else {
                    $filOk = false;
                }
            }
        }

        $ok = $dbOk && $filOk;

        $manifest = array(
            'ok'       => $ok,
            'kind'     => $kind,
            'ts'       => time(),
            'started'  => (int) $job['started'],
            'tables'   => (int) $job['tables_n'],
            'db_rows'  => (int) $job['db_rows'],
            'files'    => (int) $job['files_n'],
            'size'     => $size,
            'bkid'     => (string) $job['bkid'],
            // Tatsaechliche Artefakt-Dateinamen (mit lesbarem Praefix) - Download/Restore lesen sie hier.
            'db_file'    => $dbName,
            'files_file' => $filesName,
            // Basis-Backup des Inkrements (leer bei Voll/DB) - Restore braucht die Kette.
            'parent'   => isset($job['parent_ref']) ? (string) $job['parent_ref'] : '',
            'error'    => empty($job['files_err']) ? '' : (string) $job['files_err'],
        );
        @file_put_contents($bdir . 'HTP-MANIFEST.json', json_encode($manifest), LOCK_EX);

        return array(
            'ok'   => $ok,
            'ts'   => time(),
            'size' => $size,
            'dur'  => max(0, time() - (int) $job['started']),
            'kind' => (string) $job['kind'],
            'ref'  => (string) $job['bkid'],
            // Fehlergrund der Datei-Phase (disk_full|zip_write|zip_open|tar_open) + freier Platz beim
            // Abbruch -> wird ueber Heartbeat ans Dashboard gereicht, damit ein Fehlschlag nie
            // "mysterioes" ist, sondern klar benannt wird ("Datentraeger voll, nur X frei").
            'error'      => empty($job['files_err']) ? '' : (string) $job['files_err'],
            'error_free' => isset($job['files_err_free']) ? (int) $job['files_err_free'] : 0,
        );
    }

    /**
     * Alte Backups aufraeumen: die N NEUESTEN (nach Manifest-Zeit) behalten, aeltere loeschen.
     * Nur abgeschlossene (mit Manifest) zaehlen. Streng auf den Backup-Ordner begrenzt.
     *
     * @param   int  $keep  Anzahl zu behaltender Backups
     *
     * @return void
     */
    private static function rotate($keepFull, $keepDb, $thin = false)
    {
        $keepFull = max(1, (int) $keepFull);
        $keepDb   = max(1, (int) $keepDb);
        $base     = self::dir();

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

        $list = self::listBackups(1000); // neueste zuerst, inkl. parent-Kette

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

        $now     = time();
        $parent  = array();
        $keepSet = array();

        // ZWEI getrennte Aufbewahrungs-Pools: "full" = Voll-/Datei-Backups (full/files + Alt-
        // Inkremente), "db" = reine Datenbank-Backups. Jeder behaelt UNABHAENGIG seine N neuesten
        // ERFOLGREICHEN -> "behalten" ist je Typ eindeutig (kein Vermischen mehr). Fehlgeschlagene
        // (unwiederherstellbare) zaehlen nie zu den Slots.
        $keptFull = 0;
        $keptDb   = 0;

        foreach ($list as $b) {
            $parent[$b['ref']] = isset($b['parent']) ? (string) $b['parent'] : '';

            if (empty($b['ok'])) {
                continue;
            }

            if ((isset($b['kind']) ? $b['kind'] : 'full') === 'db') {
                if ($keptDb < $keepDb) {
                    $keepSet[$b['ref']] = true;
                    $keptDb++;
                }
            } elseif ($keptFull < $keepFull) {
                $keepSet[$b['ref']] = true;
                $keptFull++;
            }
        }

        // Gestaffelte Ausduennung der aelteren - PRO POOL getrennt (Bucket mit Pool-Praefix): < 1 Tag
        // alle behalten, dann 1/Tag (bis 1 Woche), 1/Woche (bis 1 Monat), danach 1/Monat - je das
        // NEUESTE im Zeitfenster.
        if ($thin) {
            $buckets = array();

            foreach ($list as $b) {
                if (isset($keepSet[$b['ref']]) || empty($b['ok'])) {
                    continue;
                }

                $pool = ((isset($b['kind']) ? $b['kind'] : 'full') === 'db') ? 'db' : 'full';
                $age  = $now - (int) $b['ts'];

                if ($age < 86400) {
                    $keepSet[$b['ref']] = true; // < 1 Tag: alle
                    continue;
                }

                if ($age < 604800) {
                    $period = 86400;   $tier = 'd';   // < 1 Woche: 1/Tag
                } elseif ($age < 2592000) {
                    $period = 604800;  $tier = 'w';   // < 1 Monat: 1/Woche
                } else {
                    $period = 2592000; $tier = 'm';   // sonst: 1/Monat
                }

                $bucket = $pool . $tier . (int) floor((int) $b['ts'] / $period);

                if (!isset($buckets[$bucket])) {
                    $buckets[$bucket]   = true;
                    $keepSet[$b['ref']] = true; // neuestes im Fenster (Liste = neueste zuerst)
                }
            }
        }

        // Kette schuetzen: fuer jedes behaltene Backup die ganze Eltern-Kette mitbehalten - sonst
        // waere ein behaltenes Inkrement ohne seine Basis unwiederherstellbar.
        foreach (array_keys($keepSet) as $ref) {
            $p     = isset($parent[$ref]) ? $parent[$ref] : '';
            $guard = 0;

            while ($p !== '' && isset($parent[$p]) && !isset($keepSet[$p]) && $guard < 1000) {
                $keepSet[$p] = true;
                $p = $parent[$p];
                $guard++;
            }
        }

        // Alles ausserhalb der Behalten-Menge loeschen (Ordner ueber die Referenz aufloesen - der
        // Name ist nicht mehr die Referenz).
        foreach ($list as $b) {
            if (!isset($keepSet[$b['ref']])) {
                $bp = self::bkPath($b['ref']);

                if ($bp !== '') {
                    self::rmTree(rtrim($bp, '/'));
                }
            }
        }
    }

    /** Rekursiv loeschen - STRENG nur ein Unterordner des Backup-Basisordners (kein Ausbruch). */
    private static function rmTree($dir)
    {
        $base = self::dir();
        $dir  = rtrim(str_replace('\\', '/', $dir), '/');

        if ($base === '' || strpos($dir . '/', $base) !== 0 || $dir === rtrim($base, '/')) {
            return;
        }

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

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

            if (is_dir($p) && !is_link($p)) {
                self::rmTree($p);
            } else {
                @unlink($p);
            }
        }

        @rmdir($dir);
    }

    /**
     * Fehlgeschlagene / verwaiste Backup-Ordner aufraeumen - damit sie sich nicht unbegrenzt haeufen
     * (Speicherfresser). rotate() raeumt nur ERFOLGREICHE Backups (nach einem gelungenen Lauf); diese
     * Routine kuemmert sich um den Rest und laeuft VOR jedem Backup-Start UND taeglich im Waechter,
     * sodass Waisen auch auf Sites ohne Backup-Zeitplan verschwinden.
     *
     * Loescht: (a) finalisierte Fehlschlaege (Manifest mit ok=false - unwiederherstellbar) und
     * (b) manifest-lose Ordner, in denen die JUENGSTE Datei aelter als ORPHAN_TTL (12 h) ist
     * (abgebrochener Lauf). Massgeblich ist die juengste DATEI-mtime, NICHT die Ordner-mtime: ein
     * laufender Dump haengt fortlaufend an db.sql.gz an (Ordner-mtime aendert sich dabei nicht) -
     * so wird ein noch laufendes oder nur stockendes, stundenlanges Backup NIE faelschlich geloescht.
     * ORPHAN_TTL liegt bewusst ueber dem 6-h-Job-Cap; ein echtes Backup ist bis dahin laengst fertig
     * oder von der Notbremse beendet.
     *
     * @param   int  $now  Aktuelle Zeit
     *
     * @return  void
     */
    public static function cleanFailedBackups($now)
    {
        $base = self::dir();

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

        $dirs = @glob($base . '*', GLOB_ONLYDIR);

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

        foreach ($dirs as $d) {
            $mf = $d . '/HTP-MANIFEST.json';

            // Ein Manifest schreiben NUR wir -> ein solcher Ordner gehoert sicher uns. Ist er
            // finalisiert-aber-fehlgeschlagen (ok=false), ist er unwiederherstellbar -> weg.
            if (is_file($mf)) {
                $m = json_decode((string) @file_get_contents($mf), true);

                if (is_array($m) && empty($m['ok'])) {
                    self::rmTree($d);
                }

                continue;
            }

            // Kein Manifest -> nur EIGENE (partielle) Backup-Ordner betrachten: der Name endet auf den
            // Zufalls-Suffix (Neu-Layout: -<16/24 Hex>) bzw. IST die 24-Hex-Referenz (Alt-Layout). So
            // wird ein fremder Ordner nie angefasst.
            if (!preg_match('~(?:^[a-f0-9]{24}|-[a-f0-9]{16}|-[a-f0-9]{24})$~', basename($d))) {
                continue;
            }

            // Nur raeumen, wenn im Ordner seit > ORPHAN_TTL NICHTS mehr geschrieben wurde (juengste
            // Datei-mtime). Schuetzt jeden aktiven/stockenden Lauf zuverlaessig.
            $young = self::youngestMtime($d);

            if ($young > 0 && ((int) $now - $young) > self::ORPHAN_TTL) {
                self::rmTree($d);
            }
        }
    }

    /**
     * Juengste (groesste) Datei-mtime im Backup-Ordner-Baum. Bewusst flach + guenstig: ein
     * Backup-Ordner enthaelt nur wenige Dateien (Manifest + Artefakt-Unterordner mit db.sql.gz /
     * files.*). Liefert 0, wenn keine Datei gefunden wird (leerer Ordner) -> Aufrufer laesst ihn
     * dann in Ruhe (0 Bytes, harmlos). Bricht nie aus dem Ordner aus.
     *
     * @return int  Unix-mtime oder 0
     */
    private static function youngestMtime($dir)
    {
        $dir  = rtrim(str_replace('\\', '/', (string) $dir), '/');
        $best = 0;
        $guard = 0;

        foreach ((array) @scandir($dir) as $f) {
            if ($f === '.' || $f === '..' || $guard++ > 5000) {
                continue;
            }

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

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

            if (is_dir($p)) {
                $sub  = self::youngestMtime($p);
                $best = max($best, $sub);
            } else {
                $best = max($best, (int) @filemtime($p));
            }
        }

        return $best;
    }

    /**
     * Ein Backup (und seine abhaengigen Inkremente) endgueltig loeschen (backup.delete). Kaskade:
     * loescht ausser $ref auch jedes Backup, dessen Eltern-Kette durch $ref laeuft - sonst blieben
     * verwaiste, ohne ihre Basis unwiederherstellbare Inkremente zurueck. Ref-Format streng gehuetet
     * (24 Hex) + rmTree bricht ohnehin nicht aus dem Backup-Basisordner aus.
     *
     * @param   string  $ref  Backup-Referenz (24-Hex-Ordnername)
     *
     * @return  array { ok:bool, code:string, deleted?:int }
     */
    public static function deleteBackup($ref)
    {
        $ref  = (string) $ref;
        $base = self::dir();

        if (!preg_match('~^[a-f0-9]{24}$~', $ref)) {
            return array('ok' => false, 'code' => 'bad_ref');
        }

        if ($base === '' || self::bkPath($ref) === '') {
            return array('ok' => false, 'code' => 'not_found');
        }

        // Eltern-Karte aller Backups, um abhaengige Inkremente mitzunehmen.
        $parent = array();

        foreach (self::listBackups(1000) as $b) {
            $parent[$b['ref']] = isset($b['parent']) ? (string) $b['parent'] : '';
        }

        $del     = array($ref => true);
        $changed = true;
        $guard   = 0;

        while ($changed && $guard++ < 5000) {
            $changed = false;

            foreach ($parent as $r => $p) {
                if (!isset($del[$r]) && $p !== '' && isset($del[$p])) {
                    $del[$r] = true;
                    $changed = true;
                }
            }
        }

        $n = 0;

        foreach (array_keys($del) as $r) {
            $bp = preg_match('~^[a-f0-9]{24}$~', $r) ? self::bkPath($r) : '';

            if ($bp !== '') {
                self::rmTree(rtrim($bp, '/'));
                $n++;
            }
        }

        return array('ok' => true, 'code' => 'backup_deleted', 'deleted' => $n);
    }

    /**
     * Backup-Report FUER DEN HEARTBEAT (last_backup) - Zero-Knowledge: nur Zaehler, nie Pfade/Bytes.
     * HtpLink ruft das beim Bauen der Heartbeat-Nutzlast auf (wie scanReportPublic).
     *
     * @param   array  $st  Link-State
     *
     * @return array|null
     */
    public static function reportPublic($st)
    {
        if (empty($st['backup_report']) || !is_array($st['backup_report'])) {
            return null;
        }

        $r = $st['backup_report'];

        return array(
            'ts'   => (int) (isset($r['ts']) ? $r['ts'] : 0),
            'ok'   => !empty($r['ok']),
            'size' => (int) (isset($r['size']) ? $r['size'] : 0),
            'dur'  => (int) (isset($r['dur']) ? $r['dur'] : 0),
            'kind' => (string) (isset($r['kind']) ? $r['kind'] : 'full'),
            // Opaker Backup-Bezeichner (24-hex, KEIN Pfad/Name) - der Operator-Browser braucht ihn
            // fuer die signierte Direkt-Download-URL. Zero-Knowledge-vertraeglich.
            'ref'  => preg_match('~^[a-f0-9]{24}$~', (string) (isset($r['ref']) ? $r['ref'] : '')) ? (string) $r['ref'] : '',
            // Zusatzfeld (additiv): 1 = der Erreichbarkeitstest hat die Backup-Ablage oeffentlich
            // abrufbar gefunden (der Server wertet die Schutzdatei nicht aus). Aeltere Gegenstellen
            // ignorieren das Feld einfach.
            'web_exposed' => !empty($st['bk_web_exposed']) ? 1 : 0,
            // Fehlergrund + freier Platz beim Abbruch (additiv) - fuer die klare Dashboard-Meldung.
            'error'      => (string) (isset($r['error']) ? $r['error'] : ''),
            'error_free' => (int) (isset($r['error_free']) ? $r['error_free'] : 0),
        );
    }

    /**
     * Absoluter Pfad der Backup-Datei fuer einen Teil ('files'|'db') eines ABGESCHLOSSENEN Backups
     * (Manifest vorhanden). Streng validierter Bezeichner (24-hex) -> kein Path-Traversal. Gibt ''
     * zurueck, wenn ungueltig/nicht vorhanden. Nur zum Ausliefern durch den signierten dl-Endpunkt.
     *
     * @return string
     */
    public static function backupFilePath($ref, $part)
    {
        if (!preg_match('~^[a-f0-9]{24}$~', (string) $ref)) {
            return '';
        }

        // Ordner ueber die Referenz aufloesen (Name = <host>-<stamp>-<zufall>, Referenz im Manifest).
        $bdir = self::bkPath($ref);
        $mf   = ($bdir !== '') ? $bdir . 'HTP-MANIFEST.json' : '';

        // Nur fertige Backups (mit Manifest) ausliefern.
        if ($mf === '' || !is_file($mf)) {
            return '';
        }

        $m = json_decode((string) @file_get_contents($mf), true);

        // Artefakt-Namen stehen im Manifest (mit Praefix); Alt-Backups ueber die fixen Namen.
        if ($part === 'db') {
            return self::dbArtPath($bdir, $m);
        }

        if ($part === 'files') {
            return self::filesArtPath($bdir, $m);
        }

        return '';
    }

    /**
     * Liste der vorhandenen (abgeschlossenen) Backups fuer den Heartbeat - Zero-Knowledge: nur
     * {ref, ts, ok, size, kind} je Backup, nie Pfade/Namen. Neueste zuerst, gedeckelt. Der
     * Operator-Browser braucht die refs, um auch AELTERE Backups signiert direkt herunterzuladen.
     *
     * @param   int  $cap  Maximale Anzahl
     *
     * @return array
     */
    public static function listBackups($cap = 30)
    {
        $base = self::dir();

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

        $dirs = @glob($base . '*', GLOB_ONLYDIR);

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

        $out = array();

        foreach ($dirs as $d) {
            $mf = $d . '/HTP-MANIFEST.json';

            if (!is_file($mf)) {
                continue; // nur fertige Backups (der Ordnername ist NICHT mehr die Referenz)
            }

            $m = json_decode((string) @file_get_contents($mf), true);

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

            // Referenz aus dem Manifest (bkid); Alt-Fallback: der Ordnername IST die Referenz.
            $ref = (isset($m['bkid']) && $m['bkid'] !== '') ? (string) $m['bkid'] : basename($d);

            if (!preg_match('~^[a-f0-9]{24}$~', $ref)) {
                continue;
            }

            $out[] = array(
                'ref'    => $ref,
                'ts'     => (int) (isset($m['ts']) ? $m['ts'] : @filemtime($mf)),
                'ok'     => !empty($m['ok']),
                'size'   => (int) (isset($m['size']) ? $m['size'] : 0),
                'kind'   => (string) (isset($m['kind']) ? $m['kind'] : 'full'),
                'parent' => (isset($m['parent']) && preg_match('~^[a-f0-9]{24}$~', (string) $m['parent'])) ? (string) $m['parent'] : '',
            );
        }

        usort($out, function ($a, $b) {
            return $b['ts'] - $a['ts']; // neueste zuerst
        });

        if (count($out) > (int) $cap) {
            $out = array_slice($out, 0, (int) $cap);
        }

        return $out;
    }

    /** Ref des neuesten Backups MIT Datei-Zustand (Voll/Inkr) als Anker fuer ein Inkrement; '' wenn keines. */
    private static function latestBackupRef()
    {
        foreach (self::listBackups(30) as $b) { // schon neueste zuerst
            if (isset($b['kind']) && ($b['kind'] === 'full' || $b['kind'] === 'incr')) {
                return (string) $b['ref'];
            }
        }

        return '';
    }
}
