<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Kerndatei-Abgleich (Core-File-Integrity).
 *
 * Vergleicht die installierten Joomla-Kerndateien byteweise (SHA-256) gegen das
 * OFFIZIELLE Update-Paket der exakt erkannten Version - dasselbe Paket, das auch
 * Joomlas eigene Update-Komponente beim "Kerndateien neu installieren" verwendet.
 * Es traegt jede pflegbare Kern-Datei (Code deckungsgleich zum Voll-Paket), aber
 * keine Beispieldaten/Installer/Sample-Bilder; unsere Abweichungen entsprechen damit
 * 1:1 dem, was Joomla selbst zurueckschreiben wuerde. Abweichungen (geaenderte,
 * fehlende Kerndateien) lassen sich mit einem Klick aus dem gecachten Original
 * wiederherstellen.
 *
 * Ablauf als resumbare Phasen-Maschine (nie ein Timeout, jeder Schritt zeitbudgetiert):
 *   detect   -> Joomla-Version aus administrator/manifests/files/joomla.xml (+ Fallback)
 *   download -> offizielles Update-Paket resumbar (HTTP-Range) in backups/ streamen
 *   manifest -> Pfad -> {sha256,size}-Referenz aus dem ZIP bauen (einmalig, gecacht)
 *   compare  -> Kern-Dateien hashen und gegen das Manifest pruefen (chunked)
 *
 * Performance: Paket UND Manifest werden je Version genau EINMAL erzeugt und dann
 * wiederverwendet; der wiederholte Abgleich nutzt eine Groessen-Schnellbahn und
 * eine mtime/ctime-Delta-Basislinie (unveraenderte Dateien werden uebersprungen).
 *
 * Bootstrap-OPTIONAL: fehlt/beschaedigt diese Datei, bleibt die Engine vollstaendig -
 * nur die Karte "Kerndatei-Abgleich" meldet, dass sie nicht verfuegbar ist.
 */

defined('_JEXEC') or die;

class HtpCoreScan
{
    // WICHTIG: Bei jeder Aenderung der Referenz-QUELLE (Voll- -> Update-Paket) oder des
    // Manifest-FORMATS die Cache-Generation (-gN-) erhoehen. Sonst vergleicht ein Bestands-
    // system nach dem Update weiter gegen sein ALTES, versions-gleiches Manifest (z. B. noch
    // das Voll-Paket mit images/sampledata) - Fehlalarme trotz korrektem Code. Ein Generations-
    // wechsel macht den Alt-Cache unauffindbar -> naechster Lauf laedt frisch (gen 2 = Update-Paket).
    /** Referenz-Paket (ZIP, ~30 MB) je Version. Liegt ab 2.5.10 im zentralen, gehaerteten
     *  Sicherungsordner /administrator/_htprotect_backups/coreref (siehe refDir()) - "alles Grosse"
     *  gebuendelt + in fremden Backup-Tools mit EINEM Ordner ausschliessbar. */
    const REF_PREFIX = 'coreref-g2-';

    /** Pfad -> {s:sha256, z:size}-Manifest je Version. */
    const MANIFEST_PREFIX = 'coremanifest-g2-';

    /** Letztes abgeschlossenes Ergebnis (fuer die Erstanzeige der Karte). */
    const RESULT_FILE = 'corescan-result.json';

    /**
     * Nutzer-bestaetigte, checksum-gebundene Abweichungen (z. B. vom Webhoster eingespielte
     * Fremd-Patches wie Patchman). Kleiner Pfad->SHA-256-Store im gehaerteten backups/-Ordner
     * (KEIN Config-Store -> laeuft der Extension-Speicher nie voll). Aendert sich die Datei,
     * passt der Hash nicht mehr und die Abweichung erscheint automatisch wieder.
     */
    const ACK_FILE = 'corescan-ack.json';

    /** Obergrenze bestaetigter Pfade (haelt den Store winzig; ein Kern mit mehr ist ohnehin auffaellig). */
    const ACK_CAP = 300;

    /** Zeitbudget je Hash-Abschnitt (Manifest/Vergleich) - knapp unter dem 5-s-Poll. */
    const HASH_BUDGET = 4.5;

    /** Timeout je Download-Scheibe. curl bricht danach ab, die naechste Runde setzt per Range fort. */
    const DL_SLICE = 14;

    /** Nach so vielen Runden ohne Byte-Fortschritt gilt der Download als gescheitert. */
    const DL_STALL_MAX = 6;

    /** Obergrenze angezeigter/gespeicherter Abweichungen (ein derart kompromittierter Kern ist ohnehin Totalschaden). */
    const DEV_CAP = 1500;

    /** Groessste Datei, die noch gehasht wird (Kerndateien sind klein; schuetzt vor Ausreissern). */
    const FILE_MAX = 33554432; // 32 MB

    /** Beschaedigtes Referenz-Paket hoechstens so oft automatisch neu laden (Endlos-Schutz). */
    const HEAL_MAX = 1;

    /** Groesse fuer den Textdiff (groesser/binaer -> kein sinnvoller Diff). */
    const DIFF_MAX = 524288; // 512 KB

    /** Maximal angezeigte Diff-Zeilen je Richtung. */
    const DIFF_LINES = 400;

    /**
     * Zweit-Referenz-Nachpruefung: hoechstens so viele Funde gegen die NEUESTE Version
     * gegenpruefen. Offizielle Sicherheits-Hotfixes betreffen sehr wenige Dateien; ein
     * Kern mit >80 Abweichungen ist ohnehin auffaellig und bleibt (fuer den Rest) markiert.
     */
    const RECHECK_MAX = 80;

    /** Netz-Budget je Nachpruef-Schritt (knapp unter dem 5-s-Poll). */
    const RECHECK_BUDGET = 4.0;

    /** Timeout je einzelnem Datei-Abruf der neuesten Fassung. */
    const RECHECK_FETCH_TIMEOUT = 8;

    /**
     * Nach so vielen aufeinanderfolgenden ECHTEN Netzfehlern (HTTP-Code 0) die Nachpruefung
     * abbrechen - die neueste Fassung ist offenbar nicht erreichbar. Verhindert minutenlanges
     * Warten in Timeouts, wenn viele Funde vorliegen. Eine echte HTTP-Antwort (auch 404)
     * setzt den Zaehler zurueck. Abbruch = fail-safe: die Funde bleiben unveraendert.
     */
    const RECHECK_FAIL_MAX = 4;

    // ── Version erkennen ────────────────────────────────────────────────────

    /**
     * Installierte Joomla-Version authoritativ bestimmen: zuerst das XML-Manifest
     * (administrator/manifests/files/joomla.xml), dann htp_jversion() als Fallback.
     * Normalisiert auf "X.Y.Z" (das offizielle Paket wird exakt so benannt).
     *
     * @return string  z. B. "5.4.7" oder '' wenn unklar
     */
    public static function detectVersion()
    {
        $v   = '';
        $xml = self::appRoot() . '/administrator/manifests/files/joomla.xml';

        if (@is_file($xml)) {
            $raw = (string) @file_get_contents($xml, false, null, 0, 4096);

            if ($raw !== '' && preg_match('~<version>\s*([0-9]+\.[0-9]+\.[0-9]+)\s*</version>~i', $raw, $m)) {
                $v = $m[1];
            }
        }

        if ($v === '' && function_exists('htp_jversion')) {
            $jv = (string) htp_jversion();

            if (preg_match('~^([0-9]+\.[0-9]+\.[0-9]+)~', $jv, $m)) {
                $v = $m[1];
            }
        }

        return $v;
    }

    /**
     * Download-URL des offiziellen Update-Pakets (Stable-Update). Genau das Paket,
     * das auch Joomlas eigene Update-Komponente beim "Kerndateien neu installieren"
     * nutzt - es traegt jede pflegbare Kern-Datei (Code deckungsgleich zum Voll-Paket),
     * aber KEINE Beispieldaten/Installer/Sample-Bilder. Dadurch entsprechen unsere
     * Abweichungen 1:1 dem, was Joomla selbst zurueckschreiben wuerde. Kanonische
     * GitHub-Release-Datei - Joomlas SSL-Zertifikat wird beim Download geprueft.
     *
     * @param   string  $version  X.Y.Z
     *
     * @return string
     */
    public static function packageUrl($version)
    {
        return 'https://github.com/joomla/joomla-cms/releases/download/'
            . rawurlencode($version) . '/Joomla_' . rawurlencode($version) . '-Stable-Update_Package.zip';
    }

    // ── Pfade (alles im web-geschuetzten, scan-ausgenommenen backups/) ──────

    private static function appRoot()
    {
        $r = function_exists('htp_root') ? htp_root() : (defined('JPATH_ROOT') ? JPATH_ROOT : '.');

        return rtrim(str_replace('\\', '/', (string) $r), '/');
    }

    private static function dir()
    {
        return class_exists('HtpStorage') ? HtpStorage::backupDir() : sys_get_temp_dir();
    }

    private static function safeVer($v)
    {
        return preg_replace('~[^0-9.]~', '', (string) $v);
    }

    /**
     * Ort der grossen Referenz-ZIP (~30 MB): der zentrale, gehaertete Sicherungsordner
     * /administrator/_htprotect_backups/coreref. Vorteile: in fremden Backup-Tools (Akeeba,
     * XCloner) ist EIN Ordner ausschliessbar, und das grosse, regenerierbare Artefakt liegt
     * gebuendelt bei den uebrigen grossen HTProtect-Sicherungen (Deny-all-.htaccess des Eltern-
     * Ordners deckt den Unterordner mit ab). Ein EIGENER Unterordner (coreref/) - so fasst die
     * Backup-Rotation/Aufraeumlogik die ZIP nie an (die kennt nur Backup-Ref-Ordner).
     * Fail-open: laesst sich der neue Ort nicht anlegen, bleibt es beim Alt-Ort dir() (backupDir);
     * die kleinen Dateien (Ergebnis/Manifest/Run-State) bleiben ohnehin immer in dir().
     */
    private static function refDir()
    {
        static $dir = null;

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

        $base = function_exists('htp_backup_root') ? (string) htp_backup_root() : '';
        $cand = $base !== '' ? rtrim(str_replace('\\', '/', $base), '/') . '/coreref' : '';

        if ($cand === '' || (!is_dir($cand) && !@mkdir($cand, 0700, true) && !is_dir($cand))) {
            $dir = self::dir(); // Fallback: Alt-Ort (nie am Speicherort scheitern)

            return $dir;
        }

        @chmod($cand, 0700);
        $dir = $cand;
        self::migrateCoreRef($dir); // etwaige Alt-ZIPs einmalig verlustsicher herholen

        return $dir;
    }

    /**
     * Einmalige, verlustsichere Migration: die grosse Referenz-ZIP (und ihre .part-Reste) vom
     * Alt-Ort dir() (com_htprotect/backups) an den neuen coreref/-Ort holen. Reine Cache-Dateien
     * (regenerierbar) - trotzdem verschieben statt neu laden spart ~30 MB Download je Site. Nutzt
     * htp_migrate_backup_item (rename, sonst copy+verify+delete); No-Op, sobald am Alt-Ort nichts liegt.
     */
    private static function migrateCoreRef($newDir)
    {
        $old = self::dir();

        if ($old === '' || $old === $newDir) {
            return;
        }

        foreach ((array) @glob($old . '/coreref-*.zip*') as $f) {
            if (!is_file($f)) {
                continue;
            }

            $dst = $newDir . '/' . basename($f);

            if (function_exists('htp_migrate_backup_item')) {
                htp_migrate_backup_item($f, $dst);
            } elseif (!file_exists($dst)) {
                @rename($f, $dst);
            }
        }
    }

    public static function refZipPath($v)
    {
        return self::refDir() . '/' . self::REF_PREFIX . self::safeVer($v) . '.zip';
    }

    public static function manifestPath($v)
    {
        return self::dir() . '/' . self::MANIFEST_PREFIX . self::safeVer($v) . '.json';
    }

    private static function statePath($id)
    {
        return self::dir() . '/corescan-' . $id . '.json';
    }

    private static function resultPath()
    {
        return self::dir() . '/' . self::RESULT_FILE;
    }

    private static function ackPath()
    {
        return self::dir() . '/' . self::ACK_FILE;
    }

    // ── Run-State-Persistenz (spiegelt den Malware-Scanner) ─────────────────

    public static function validId($id)
    {
        return (bool) preg_match('~^[a-f0-9]{16}$~', (string) $id);
    }

    public static function newId()
    {
        return substr(sha1(uniqid((string) mt_rand(), true) . microtime()), 0, 16);
    }

    public static function stateRead($id)
    {
        if (!self::validId($id)) {
            return null;
        }

        $d = @file_get_contents(self::statePath($id));
        $j = ($d !== false && $d !== '') ? json_decode($d, true) : null;

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

    public static function stateWrite($id, array $state)
    {
        if (!self::validId($id) || !class_exists('HtpStorage')) {
            return false;
        }

        return @HtpStorage::htpPut(self::statePath($id), (string) json_encode($state), LOCK_EX) !== false;
    }

    public static function stateDelete($id)
    {
        if (self::validId($id)) {
            @unlink(self::statePath($id));
        }
    }

    public static function stateCleanup($maxAge = 7200)
    {
        foreach ((array) @glob(self::dir() . '/corescan-*.json') as $f) {
            $b = basename($f);

            if ($b === self::RESULT_FILE) {
                continue;
            }

            if ((int) @filemtime($f) < time() - $maxAge) {
                @unlink($f);
            }
        }
    }

    // ── Start: neuen Lauf initialisieren ────────────────────────────────────

    /**
     * IMMER ein Voll-Abgleich: jede Kern-Datei wird gehasht. Bewusst KEIN Delta ueber
     * mtime/ctime - ein Integritaets-Scanner darf Zeitstempeln nicht vertrauen (die sind
     * ohne root manipulierbar). Der Kern-Dateisatz ist fest begrenzt und der Lauf laeuft
     * nur gelegentlich manuell -> der Aufwand ist tragbar, der Befund immer verlaesslich.
     *
     * @return array  Anfangs-Zustand
     */
    public static function begin()
    {
        $v = self::detectVersion();

        $state = array(
            'phase'    => $v === '' ? 'error' : 'download',
            'error'    => $v === '' ? 'noversion' : '',
            'version'  => $v,
            'refurl'   => '', // != '' -> Referenz-Download-URL (J3: aus konfigurierter Quelle)
            'started'  => time(),
            'dl_total' => 0,
            'dl_stall' => 0,
            'm_index'  => 0,
            'm_total'  => 0,
            'c_index'  => 0,
            'c_total'  => 0,
            'heal'     => 0,
            'counts'   => array('ok' => 0, 'changed' => 0, 'missing' => 0, 'patched' => 0, 'ignored' => 0),
            'dev'      => array(),
            // Zweit-Referenz-Nachpruefung (gegen die neueste Version derselben Major).
            'recheck'  => 0,
            'r_index'  => 0,
            'r_fail'   => 0,
            'latest_v' => '',
            'patched_paths' => array(),
        );

        if ($v === '') {
            return $state;
        }

        // Referenz bereits gecacht? -> Download/Manifest ueberspringen.
        if (@is_file(self::manifestPath($v)) && (int) @filesize(self::manifestPath($v)) > 0) {
            $state['phase'] = 'compare';
        } elseif (@is_file(self::refZipPath($v)) && (int) @filesize(self::refZipPath($v)) > 0) {
            $state['phase'] = 'manifest';
        }

        // Joomla 3 (Major 3): Referenz aus der KONFIGURIERTEN Quelle aufloesen - nur wenn wir
        // wirklich herunterladen (nicht gecacht). Bei einer EIGENEN Collection (customurl, z. B.
        // JoomlaWorks) ist es ein VOLL-Paket -> nur nutzbar, wenn seine Version == installierte
        // Version (sonst waeren "Abweichungen" reine Versions-Diffs = Fehlalarm; dann NICHT
        // abgleichen). Bei der OFFIZIELLEN Quelle bleibt refurl leer -> corescan nutzt weiter das
        // offizielle Update-Paket ueber packageUrl() (funktioniert fuer 3.10.x).
        if ($state['phase'] === 'download' && (int) explode('.', $v)[0] === 3 && class_exists('HtpCoreUpdateJ3')) {
            $ref = HtpCoreUpdateJ3::referencePackage();

            if (!empty($ref['custom'])) {
                if (!empty($ref['ok']) && $ref['url'] !== ''
                    && version_compare((string) $ref['version'], $v, '==')) {
                    $state['refurl'] = (string) $ref['url'];
                } else {
                    // Eigene Quelle gesetzt, aber kein zur installierten Version passendes Paket
                    // (Seite hinterher / Quelle unklar) -> NICHT abgleichen, kein Fehlalarm.
                    $state['phase'] = 'error';
                    $state['error'] = 'j3_noref';
                }
            }
        }

        return $state;
    }

    // ── Ein Schritt der Phasen-Maschine ─────────────────────────────────────

    /**
     * Fuehrt EINEN zeitbudgetierten Schritt aus und liefert die neu gefundenen
     * Abweichungen dieses Schritts + Fortschritt. Der Aufrufer persistiert $state.
     *
     * @param   array  $state  (Referenz)
     *
     * @return array  ['items'=>[], 'progress'=>0..1]
     */
    public static function step(&$state)
    {
        $phase = isset($state['phase']) ? (string) $state['phase'] : 'error';

        if ($phase === 'download') {
            return self::downloadStep($state);
        }

        if ($phase === 'manifest') {
            return self::manifestStep($state);
        }

        if ($phase === 'compare') {
            return self::compareStep($state);
        }

        if ($phase === 'recheck') {
            return self::recheckStep($state);
        }

        // done / error
        return array('items' => array(), 'progress' => 1.0);
    }

    public static function isDone($state)
    {
        $p = isset($state['phase']) ? $state['phase'] : 'error';

        return $p === 'done' || $p === 'error';
    }

    // ── Phase: Download (resumbar per HTTP-Range) ───────────────────────────

    private static function downloadStep(&$state)
    {
        $v    = $state['version'];
        $zip  = self::refZipPath($v);
        $part = $zip . '.part';

        if (@is_file($zip) && (int) @filesize($zip) > 0) {
            $state['phase'] = 'manifest';

            return array('items' => array(), 'progress' => 1.0);
        }

        if (!function_exists('curl_init')) {
            $state['phase'] = 'error';
            $state['error'] = 'nocurl';

            return array('items' => array(), 'progress' => 0.0);
        }

        $off = @is_file($part) ? (int) @filesize($part) : 0;
        $fh  = @fopen($part, $off > 0 ? 'ab' : 'wb');

        if (!$fh) {
            $state['phase'] = 'error';
            $state['error'] = 'nowrite';

            return array('items' => array(), 'progress' => 0.0);
        }

        $ctx = array('status' => 0, 'total' => (int) $state['dl_total'], 'off' => $off, 'trunc' => false, 'fh' => $fh);

        // Referenz-Download-URL: J3 kann eine aufgeloeste Quell-URL (state['refurl']) tragen;
        // sonst die offizielle Update-Paket-URL.
        $refUrl = (isset($state['refurl']) && $state['refurl'] !== '') ? (string) $state['refurl'] : self::packageUrl($v);

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $refUrl);
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
        curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 8);
        curl_setopt($ch, CURLOPT_TIMEOUT, self::DL_SLICE);
        curl_setopt($ch, CURLOPT_USERAGENT, 'HTProtect-Shield/2.0 (+https://website-bereinigung.de)');
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);

        if ($off > 0) {
            curl_setopt($ch, CURLOPT_RANGE, $off . '-');
        }

        if (defined('CURLPROTO_HTTPS')) {
            curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
            curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
        }

        curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($c, $h) use (&$ctx) {
            if (preg_match('~^HTTP/\S+\s+(\d{3})~', $h, $m)) {
                $ctx['status'] = (int) $m[1];
            } elseif (preg_match('~^Content-Range:\s*bytes\s+\d+-\d+/(\d+)~i', $h, $m)) {
                $ctx['total'] = (int) $m[1];
            } elseif ($ctx['off'] === 0 && preg_match('~^Content-Length:\s*(\d+)~i', $h, $m)) {
                $ctx['total'] = (int) $m[1];
            }

            return strlen($h);
        });

        curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($c, $data) use (&$ctx) {
            // Server ignorierte den Range (200 trotz Offset) -> Datei einmalig leeren.
            if (!$ctx['trunc'] && $ctx['off'] > 0 && $ctx['status'] === 200) {
                @ftruncate($ctx['fh'], 0);
                @rewind($ctx['fh']);
            }

            $ctx['trunc'] = true;

            return @fwrite($ctx['fh'], $data);
        });

        @curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        @fclose($fh);

        if ((int) $ctx['total'] > 0) {
            $state['dl_total'] = (int) $ctx['total'];
        }

        // Eindeutiger Fehler-Code (z. B. 404 = Version nicht als GitHub-Release) -> abbrechen.
        if ($code !== 0 && $code !== 200 && $code !== 206 && $code !== 416) {
            @unlink($part);
            $state['phase'] = 'error';
            $state['error'] = 'http' . $code;

            return array('items' => array(), 'progress' => 0.0);
        }

        clearstatcache();
        $now = @is_file($part) ? (int) @filesize($part) : 0;
        $tot = (int) $state['dl_total'];

        // Stillstand erkennen (keine tragfaehige Verbindung) statt endlos zu pollen.
        if ($now <= $off) {
            $state['dl_stall'] = (int) $state['dl_stall'] + 1;

            if ($state['dl_stall'] >= self::DL_STALL_MAX) {
                @unlink($part);
                $state['phase'] = 'error';
                $state['error'] = 'stalled';

                return array('items' => array(), 'progress' => 0.0);
            }
        } else {
            $state['dl_stall'] = 0;
        }

        if ($tot > 0 && $now >= $tot) {
            @rename($part, $zip);
            $state['phase'] = 'manifest';

            return array('items' => array(), 'progress' => 1.0);
        }

        return array('items' => array(), 'progress' => $tot > 0 ? round($now / $tot, 3) : 0.0);
    }

    // ── Phase: Manifest (SHA-256 je Kerndatei aus dem Paket, chunked) ────────

    private static function manifestStep(&$state)
    {
        $v  = $state['version'];
        $mf = self::manifestPath($v);

        if (@is_file($mf) && (int) @filesize($mf) > 0) {
            $state['phase'] = 'compare';

            return array('items' => array(), 'progress' => 1.0);
        }

        if (!class_exists('ZipArchive')) {
            $state['phase'] = 'error';
            $state['error'] = 'nozip';

            return array('items' => array(), 'progress' => 0.0);
        }

        $za = new ZipArchive();

        if (@$za->open(self::refZipPath($v)) !== true) {
            // Beschaedigtes/abgeschnittenes Paket: verwerfen und AUTOMATISCH einmal neu laden
            // (ersetzt den frueheren manuellen "neu abgleichen"-Knopf). Erst nach HEAL_MAX
            // erfolglosen Versuchen als Fehler melden - Endlosschleifen-Schutz.
            @unlink(self::refZipPath($v));
            @unlink(self::refZipPath($v) . '.part');
            @unlink($mf);
            @unlink($mf . '.part');

            if ((int) $state['heal'] < self::HEAL_MAX) {
                $state['heal']    = (int) $state['heal'] + 1;
                $state['m_index'] = 0;
                $state['phase']   = 'download';

                return array('items' => array(), 'progress' => 0.0);
            }

            $state['phase'] = 'error';
            $state['error'] = 'badzip';

            return array('items' => array(), 'progress' => 0.0);
        }

        $total = (int) $za->numFiles;
        $state['m_total'] = $total;
        $i     = (int) $state['m_index'];
        $part  = $mf . '.part';
        $man   = array();

        if ($i > 0) {
            $d   = @file_get_contents($part);
            $j   = ($d !== false && $d !== '') ? json_decode($d, true) : null;
            $man = is_array($j) ? $j : array();
        }

        $start = microtime(true);

        for (; $i < $total; $i++) {
            $st = $za->statIndex($i);

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

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

            // Verzeichnisse, den Installer (post-install weg), Ausreisser und Nicht-Kern-
            // Rauschen (Beispieldaten/Sprachen/.gz/Laufzeit) gar nicht erst ins Manifest.
            if ($name === '' || substr($name, -1) === '/' || strncmp($name, 'installation/', 13) === 0 || self::isExcludedPath($name)) {
                continue;
            }

            $size = isset($st['size']) ? (int) $st['size'] : 0;

            if ($size > self::FILE_MAX) {
                continue;
            }

            $content = @$za->getFromIndex($i, self::FILE_MAX);

            if (!is_string($content)) {
                continue;
            }

            $man[$name] = array('s' => hash('sha256', $content), 'z' => strlen($content));

            // Budget nach mind. einer Datei pruefen -> garantierter Fortschritt.
            if (($i & 63) === 0 && microtime(true) - $start >= self::HASH_BUDGET) {
                $i++;
                break;
            }
        }

        $za->close();

        if ($i >= $total) {
            // Fertig: atomar finalisieren.
            if (class_exists('HtpStorage')) {
                @HtpStorage::htpPut($part, (string) json_encode($man), LOCK_EX);
            } else {
                @file_put_contents($part, (string) json_encode($man), LOCK_EX);
            }

            @rename($part, $mf);
            $state['m_index'] = $total;
            $state['phase']   = 'compare';

            return array('items' => array(), 'progress' => 1.0);
        }

        if (class_exists('HtpStorage')) {
            @HtpStorage::htpPut($part, (string) json_encode($man), LOCK_EX);
        } else {
            @file_put_contents($part, (string) json_encode($man), LOCK_EX);
        }

        $state['m_index'] = $i;

        return array('items' => array(), 'progress' => $total > 0 ? round($i / $total, 3) : 0.0);
    }

    // ── Phase: Vergleich (jede Kern-Datei gegen Manifest, chunked, IMMER voll) ──

    private static function compareStep(&$state)
    {
        $man = self::loadManifest($state['version']);

        if ($man === null) {
            $state['phase'] = 'error';
            $state['error'] = 'nomanifest';

            return array('items' => array(), 'progress' => 0.0);
        }

        $keys = array_keys($man);
        $total = count($keys);
        $state['c_total'] = $total;
        $i     = (int) $state['c_index'];
        $root  = self::appRoot();
        $items = array();
        $start = microtime(true);
        // Nutzer-bestaetigte (checksum-gebundene) Abweichungen einmal je Abschnitt laden - z. B.
        // vom Webhoster eingespielte Fremd-Patches (Patchman). Passt der aktuelle Hash exakt, gilt
        // die Datei als bewusst akzeptiert; aendert sie sich, greift die Bestaetigung nicht mehr.
        $acks = self::ackMap();

        for (; $i < $total; $i++) {
            $rel = $keys[$i];
            $ref = $man[$rel];
            $abs = $root . '/' . $rel;

            if (($i & 127) === 0 && $i > (int) $state['c_index'] && microtime(true) - $start >= self::HASH_BUDGET) {
                break;
            }

            // Nicht-Kern-Rauschen (fuer Alt-Manifeste ohne Filter) auch hier ueberspringen.
            if (self::isExcludedPath($rel)) {
                continue;
            }

            if (!@is_file($abs)) {
                self::addDev($state, $items, $rel, 'missing', 0, 0);

                continue;
            }

            $sz = (int) @filesize($abs);

            // Immer hashen (kein Zeitstempel-Vertrauen): Klassifikation (Groessen-Schnellbahn
            // + SHA-256) in reiner, testbarer Methode.
            if (self::classifyFile($abs, (string) $ref['s'], (int) $ref['z']) === 'ok') {
                $state['counts']['ok'] = (int) $state['counts']['ok'] + 1;

                continue;
            }

            // Abweichung: den aktuellen Hash EINMAL bilden und fuer beide Freigabe-Wege nutzen.
            $curSha = (string) @hash_file('sha256', $abs);

            if ($curSha !== '' && class_exists('HtpCoreHotfix')
                && HtpCoreHotfix::isAcceptedHotfix((string) $state['version'], $rel, $curSha)) {
                // Gewollter HTProtect-Vorwaerts-Hotfix (byte-genau bekannte Fassung) -> KEIN
                // Fund. Wie ein offizieller Vorwaerts-Patch als 'ok' + 'patched' verbucht, damit
                // der Kerndatei-Abgleich nach dem Hotfix nicht faelschlich Alarm schlaegt.
                $state['counts']['ok']        = (int) $state['counts']['ok'] + 1;
                $state['counts']['patched']   = (int) $state['counts']['patched'] + 1;
                $state['patched_paths'][$rel] = 1;
            } elseif ($curSha !== '' && isset($acks[$rel]) && hash_equals((string) $acks[$rel], $curSha)) {
                // Nutzer-bestaetigter Fremd-Patch (z. B. Patchman) - genau an DIESEN Inhalt gebunden.
                // Als 'ok' + eigener 'ignored'-Zaehler (Transparenz) verbucht, nicht als Fund.
                $state['counts']['ok']      = (int) $state['counts']['ok'] + 1;
                $state['counts']['ignored'] = (int) $state['counts']['ignored'] + 1;
            } else {
                self::addDev($state, $items, $rel, 'changed', $sz, (int) @filemtime($abs));
            }
        }

        $state['c_index'] = $i;

        if ($i >= $total) {
            // Zweit-Referenz: geaenderte/fehlende Kern-Dateien gegen die NEUESTE Version
            // derselben Major nachpruefen, damit ein offizieller Vorwaerts-Sicherheitspatch
            // (Hotfix vor dem vollen Update eingespielt) NICHT als Manipulation erscheint.
            // Nur wenn es ueberhaupt Funde gibt (lazy: sauberer Kern = null Zusatzlast) und
            // eine echt neuere Version bekannt ist. Fehlschlaege dort sind fail-safe -> die
            // Funde bleiben unveraendert stehen (siehe recheckStep).
            $latest = self::latestForVersion($state['version']);

            if (empty($state['recheck']) && $latest !== '' && !empty($state['dev'])) {
                $state['recheck']  = 1;
                $state['latest_v'] = $latest;
                $state['r_index']  = 0;
                $state['phase']    = 'recheck';

                return array('items' => $items, 'progress' => 1.0);
            }

            $state['phase'] = 'done';
            self::finish($state);

            return array('items' => $items, 'progress' => 1.0);
        }

        return array('items' => $items, 'progress' => $total > 0 ? round($i / $total, 3) : 0.0);
    }

    private static function addDev(&$state, &$items, $rel, $kind, $size, $mtime)
    {
        if ($kind === 'missing') {
            $state['counts']['missing'] = (int) $state['counts']['missing'] + 1;
        } else {
            $state['counts']['changed'] = (int) $state['counts']['changed'] + 1;
        }

        if (count($state['dev']) >= self::DEV_CAP) {
            return;
        }

        $row = array('path' => $rel, 'kind' => $kind, 'size' => (int) $size, 'mtime' => (int) $mtime);
        $state['dev'][] = $row;
        $items[]        = $row;
    }

    // ── Phase: Nachpruefung gegen die NEUESTE Version (Zweit-Referenz) ────────

    /**
     * Jede zuvor als 'changed'/'missing' gemeldete Kern-Datei EINZELN gegen die neueste
     * Fassung derselben Joomla-Major pruefen - per rohem Einzeldatei-Abruf, NICHT durch
     * ein zweites Paket (leicht, nur die wenigen Funde). Trifft die installierte Datei die
     * neueste offizielle Fassung exakt (Hash) bzw. ist eine fehlende Datei auch dort nicht
     * mehr vorhanden, ist es ein offizieller Vorwaerts-Patch: raus aus der Fund-Liste, als
     * 'ok' gezaehlt (+ 'patched'-Zaehler fuer die Transparenz). Chunked & zeitbudgetiert.
     *
     * FAIL-SAFE: jeder Abruf-Fehler (Netz, anderer HTTP-Code, Hash-Abweichung) LAESST den
     * Fund unveraendert stehen - es wird NIE faelschlich entwarnt und NIE der Scan verworfen.
     */
    private static function recheckStep(&$state)
    {
        $latest = isset($state['latest_v']) ? (string) $state['latest_v'] : '';
        $dev    = isset($state['dev']) && is_array($state['dev']) ? $state['dev'] : array();
        // Nur eine begrenzte Zahl Funde nachpruefen (Hotfixes betreffen wenige Dateien).
        $total  = min(count($dev), self::RECHECK_MAX);
        $begin  = (int) (isset($state['r_index']) ? $state['r_index'] : 0);
        $i      = $begin;
        $fails  = (int) (isset($state['r_fail']) ? $state['r_fail'] : 0);
        $root   = self::appRoot();
        $start  = microtime(true);

        if ($latest === '' || $total === 0) {
            return self::finishRecheck($state);
        }

        for (; $i < $total; $i++) {
            // Netz-Budget nach mind. einem Abruf pruefen -> garantierter Fortschritt.
            if ($i > $begin && microtime(true) - $start >= self::RECHECK_BUDGET) {
                break;
            }

            $row = $dev[$i];

            if (!is_array($row) || empty($row['path'])) {
                continue;
            }

            $rel  = (string) $row['path'];
            $kind = isset($row['kind']) ? (string) $row['kind'] : '';

            if ($kind !== 'changed' && $kind !== 'missing') {
                continue;
            }

            $ref = self::fetchLatestFile($latest, $rel); // ['code'=>int, 'sha'=>string]

            // Schutzschalter: haeufen sich echte Netzfehler, ist die neueste Fassung nicht
            // erreichbar -> abbrechen (Funde bleiben). Eine echte HTTP-Antwort setzt zurueck.
            if ((int) $ref['code'] === 0) {
                $fails++;

                if ($fails >= self::RECHECK_FAIL_MAX) {
                    $state['r_index'] = $i + 1;
                    $state['r_fail']  = $fails;

                    return self::finishRecheck($state);
                }
            } else {
                $fails = 0;
            }

            if (self::isForwardPatched($root . '/' . $rel, $kind, $ref)) {
                $state['patched_paths'][$rel] = 1;
            }
        }

        $state['r_index'] = $i;
        $state['r_fail']  = $fails;

        if ($i >= $total) {
            return self::finishRecheck($state);
        }

        // Zwischenstand: Anzeige bleibt, die Reklassifikation erfolgt gesammelt am Ende.
        return array('items' => array(), 'progress' => $total > 0 ? round($i / $total, 3) : 1.0);
    }

    /**
     * Nachpruefung abschliessen: erkannte Vorwaerts-Patches aus der Fund-Liste entfernen,
     * von 'changed'/'missing' auf 'ok' umbuchen (+ 'patched'-Zaehler), Ergebnis finalisieren
     * und die betroffenen Pfade zurueckgeben, damit die (bereits gezeigten) Zeilen in der
     * Oberflaeche verschwinden.
     *
     * @return array  ['items'=>[], 'patched'=>[Pfade], 'progress'=>1.0]
     */
    private static function finishRecheck(&$state)
    {
        $patched = isset($state['patched_paths']) && is_array($state['patched_paths'])
            ? $state['patched_paths'] : array();

        if (!empty($patched)) {
            $dev  = isset($state['dev']) && is_array($state['dev']) ? $state['dev'] : array();
            $keep = array();

            foreach ($dev as $row) {
                $rel = is_array($row) && isset($row['path']) ? (string) $row['path'] : '';

                if ($rel !== '' && isset($patched[$rel])) {
                    $kind = isset($row['kind']) ? (string) $row['kind'] : 'changed';

                    if ($kind === 'missing') {
                        $state['counts']['missing'] = max(0, (int) $state['counts']['missing'] - 1);
                    } else {
                        $state['counts']['changed'] = max(0, (int) $state['counts']['changed'] - 1);
                    }

                    $state['counts']['ok']      = (int) $state['counts']['ok'] + 1;
                    $state['counts']['patched'] = (int) $state['counts']['patched'] + 1;

                    continue; // aus der Fund-Liste nehmen
                }

                $keep[] = $row;
            }

            $state['dev'] = $keep;
        }

        $state['phase'] = 'done';
        self::finish($state);

        return array(
            'items'    => array(),
            'patched'  => array_keys($patched),
            'progress' => 1.0,
        );
    }

    /**
     * Neueste bekannte Joomla-Version DERSELBEN Major wie $v (aus dem gepflegten
     * HtpDefs::joomlaLatest()-Cache). Leer, wenn keine bekannt oder nicht echt neuer als $v
     * - dann entfaellt die Nachpruefung.
     *
     * @param   string  $v  Installierte Version (X.Y.Z)
     *
     * @return string
     */
    public static function latestForVersion($v)
    {
        $v = trim((string) $v);

        if ($v === '' || !class_exists('HtpDefs') || !method_exists('HtpDefs', 'joomlaLatest')) {
            return '';
        }

        if (!preg_match('~^(\d+)\.~', $v, $m)) {
            return '';
        }

        $map = HtpDefs::joomlaLatest();

        if (!is_array($map) || !isset($m[1]) || !isset($map[(int) $m[1]])) {
            return '';
        }

        $latest = trim((string) $map[(int) $m[1]]);

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

        // Nur eine ECHT neuere Version derselben Major taugt als Zweit-Referenz. Wichtig:
        // NIE gegen die (semantisch) gleiche Version pruefen - sonst gaelte jede echte
        // Aenderung faelschlich als "Patch". Neben version_compare auch die kanonische
        // Gleichheit abfangen (5.4 == 5.4.0, vgl. canonVer-Fehlalarmklasse).
        $canonL = preg_replace('~(?:\.0+)+$~', '', $latest);
        $canonV = preg_replace('~(?:\.0+)+$~', '', $v);

        if ($canonL === $canonV || version_compare($latest, $v, '<=')) {
            return '';
        }

        return $latest;
    }

    /**
     * Eine einzelne Kern-Datei roh aus der neuesten Joomla-Fassung holen (GitHub-raw, exakt
     * am Versions-Tag). Liefert HTTP-Code + SHA-256 des Inhalts - KEIN Schreiben, kein
     * Paket-Cache, nur fuer den Vergleich. Jeder Fehler -> code 0 (der Aufrufer behaelt dann
     * den Fund). Nur https, nur normale relative Pfade.
     *
     * @param   string  $tag  Versions-Tag (= Version, z. B. "5.4.7")
     * @param   string  $rel  Kern-relativer Pfad
     *
     * @return array  ['code'=>int, 'sha'=>string]
     */
    private static function fetchLatestFile($tag, $rel)
    {
        $out = array('code' => 0, 'sha' => '');
        $tag = (string) $tag;
        $rel = (string) $rel;

        if (!function_exists('curl_init') || $tag === '' || $rel === '') {
            return $out;
        }

        // Kein Ausbruch, kein NUL, kein eigenes Schema.
        if (strpos($rel, '..') !== false || strpos($rel, "\0") !== false || preg_match('~^[a-z][a-z0-9+.\-]*://~i', $rel)) {
            return $out;
        }

        $seg = explode('/', $rel);

        foreach ($seg as $k => $s) {
            $seg[$k] = rawurlencode($s);
        }

        $url = 'https://raw.githubusercontent.com/joomla/joomla-cms/'
            . rawurlencode($tag) . '/' . implode('/', $seg);

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
        curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 6);
        curl_setopt($ch, CURLOPT_TIMEOUT, self::RECHECK_FETCH_TIMEOUT);
        curl_setopt($ch, CURLOPT_USERAGENT, 'HTProtect-Shield/2.0 (+https://website-bereinigung.de)');
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);

        if (defined('CURLPROTO_HTTPS')) {
            curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
            curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS);
        }

        $body = @curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        $out['code'] = $code;

        if ($code === 200 && is_string($body) && $body !== '' && strlen($body) <= self::FILE_MAX) {
            $out['sha'] = hash('sha256', $body);
        }

        return $out;
    }

    /**
     * Ist eine als 'changed'/'missing' gemeldete Datei in Wahrheit ein offizieller
     * Vorwaerts-Patch der neuesten Version? Rein & direkt testbar.
     *
     *   - 'changed': installierte Datei existiert UND ihr SHA-256 == der neuesten
     *     offiziellen Fassung (HTTP 200 mit passendem Hash).
     *   - 'missing':  die Datei ist auch in der neuesten Version nicht vorhanden
     *     (HTTP 404) -> upstream entfernt, das lokale Fehlen ist legitim.
     *
     * Jede Unsicherheit (Netzfehler, anderer HTTP-Code, Hash-Abweichung) -> FALSE.
     * So wird nie faelschlich entwarnt: nur ein EXAKTER Treffer zaehlt als Patch.
     *
     * @param   string  $abs   Absoluter Pfad der installierten Datei
     * @param   string  $kind  'changed' | 'missing'
     * @param   array   $ref   ['code'=>int HTTP, 'sha'=>string SHA-256 der neuesten Fassung]
     *
     * @return boolean
     */
    public static function isForwardPatched($abs, $kind, $ref)
    {
        $code = is_array($ref) && isset($ref['code']) ? (int) $ref['code'] : 0;
        $sha  = is_array($ref) && isset($ref['sha']) ? (string) $ref['sha'] : '';

        if ($kind === 'missing') {
            return $code === 404; // in der neuesten Version definitiv nicht mehr vorhanden
        }

        if ($kind === 'changed') {
            if ($code !== 200 || $sha === '' || !@is_file($abs)) {
                return false;
            }

            return @hash_file('sha256', $abs) === $sha;
        }

        return false;
    }

    private static function loadManifest($v)
    {
        $d = @file_get_contents(self::manifestPath($v));
        $j = ($d !== false && $d !== '') ? json_decode($d, true) : null;

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

    /**
     * Eine Datei gegen die Referenz (SHA-256 + Groesse) klassifizieren. Rein,
     * ohne Zustand - direkt testbar.
     *
     * @param   string  $abs      Absoluter Dateipfad
     * @param   string  $refSha   Erwarteter SHA-256
     * @param   int     $refSize  Erwartete Groesse
     *
     * @return string  'ok' | 'changed' | 'missing'
     */
    public static function classifyFile($abs, $refSha, $refSize)
    {
        if (!@is_file($abs)) {
            return 'missing';
        }

        if ((int) @filesize($abs) !== (int) $refSize) {
            return 'changed'; // andere Groesse -> sicher geaendert, kein Hash noetig
        }

        return (@hash_file('sha256', $abs) === (string) $refSha) ? 'ok' : 'changed';
    }

    /**
     * Rest-Rauschen aus dem Abgleich nehmen. Weil als Referenz das UPDATE-Paket dient,
     * fehlen Beispieldaten/Installer/Sample-Bilder bereits vollstaendig - dafuer ist
     * KEINE Ausnahme mehr noetig. Drei Kategorien traegt das Update-Paket aber weiterhin,
     * die einzelne Sites legitim NICHT (identisch) haben:
     *   - vorkomprimierte .gz-Varianten (werden je nach Deploy neu erzeugt/weggelassen;
     *     das Original .js/.css wird ohnehin geprueft),
     *   - optionale Vendor-UI-Sprachen unter /langs/ (z. B. TinyMCE bs/es/ne - viele
     *     Sites haben nur eine Teilmenge; sonst Fehlalarm "fehlt"),
     *   - das in Joomla 3.4 aus dem Kern ausgekoppelte Weblinks-Paket-Manifest: nur das
     *     Update-Paket traegt es (fuer Bestands-Sites), ein frischer Install hat es nicht.
     * Der sicherheits-relevante Kern-Code bleibt dadurch vollstaendig geprueft.
     *
     * @param   string  $rel  Kern-relativer Pfad
     *
     * @return boolean
     */
    public static function isExcludedPath($rel)
    {
        $rel = (string) $rel;

        if (substr($rel, -3) === '.gz') {
            return true; // vorkomprimierte Variante - das Original wird ohnehin geprueft
        }

        if ($rel === 'installation' || strpos($rel, 'installation/') === 0) {
            // Installer-Ordner: nur in VOLL-Paketen (z. B. das JoomlaWorks-J3-UTD-Paket);
            // eine installierte Site hat ihn nie. Ausschliessen -> "fehlt"-/"veraendert"-
            // Erkennung fuer ALLE echten Kern-Dateien bleibt voll. Auf J5-Update-Paketen
            // ist der Ordner ohnehin nicht enthalten -> hier wirkungslos.
            return true;
        }

        if (strpos($rel, '/langs/') !== false) {
            return true; // optionale Vendor-UI-Sprachen (z. B. TinyMCE)
        }

        if (strpos($rel, '/packages/pkg_weblinks') !== false) {
            return true; // J3-Altlast: in 3.4 ausgekoppeltes Weblinks-Paket-Manifest,
                         // nur im Update-Paket - frische Installs haben es nicht
        }

        return false;
    }

    /**
     * Zeilen-Diff einer GEAENDERTEN Kern-Datei gegen das Original (aus dem gecachten
     * Paket) - macht eine Injektion unmittelbar sichtbar. Nur Textdateien; gross/binaer
     * wird abgewiesen. Rein lesend.
     *
     * @param   string  $rel  Kern-relativer Pfad
     * @param   string  $v    abgeglichene Version
     *
     * @return array
     */
    public static function diffFile($rel, $v)
    {
        $rel = self::sanitizeRel($rel);

        if ($rel === '') {
            return array('ok' => false, 'reason' => 'badpath');
        }

        if (!class_exists('ZipArchive')) {
            return array('ok' => false, 'reason' => 'nozip');
        }

        $abs = self::appRoot() . '/' . $rel;
        $cur = @is_file($abs) ? (string) @file_get_contents($abs, false, null, 0, self::DIFF_MAX + 1) : '';

        $za = new ZipArchive();

        if (@$za->open(self::refZipPath($v)) !== true) {
            return array('ok' => false, 'reason' => 'norefcache');
        }

        $off = @$za->getFromName($rel, self::DIFF_MAX + 1);
        $za->close();

        if (!is_string($off)) {
            $off = '';
        }

        // Binaer oder zu gross -> kein sinnvoller Textdiff.
        if (strlen($cur) > self::DIFF_MAX || strlen($off) > self::DIFF_MAX
            || strpos($cur, "\0") !== false || strpos($off, "\0") !== false) {
            return array('ok' => true, 'binary' => true, 'added' => array(), 'removed' => array());
        }

        $curL = preg_split('~\r\n|\r|\n~', $cur);
        $offL = preg_split('~\r\n|\r|\n~', $off);

        // Zeilen im AKTUELLEN File, die im Original fehlen = hinzugefuegt/injiziert.
        // Umgekehrt = entfernt/veraendert. array_diff behaelt die Reihenfolge des ersten Arrays.
        return array(
            'ok'      => true,
            'binary'  => false,
            'added'   => array_slice(array_values(array_diff($curL, $offL)), 0, self::DIFF_LINES),
            'removed' => array_slice(array_values(array_diff($offL, $curL)), 0, self::DIFF_LINES),
        );
    }

    // ── Abschluss: Ergebnis schreiben ───────────────────────────────────────

    private static function finish(&$state)
    {
        // Stale Bestaetigungen (Datei inzwischen geaendert/aktualisiert) aufraeumen - haelt den
        // kleinen Ack-Store sauber. Guenstig: nur die wenigen bestaetigten Pfade werden geprueft.
        self::pruneAcks();

        $result = array(
            'ts'      => (int) $state['started'],
            'version' => (string) $state['version'],
            'counts'  => $state['counts'],
            'dev'     => $state['dev'],
        );

        if (class_exists('HtpStorage')) {
            @HtpStorage::htpPut(self::resultPath(), (string) json_encode($result), LOCK_EX);
        } else {
            @file_put_contents(self::resultPath(), (string) json_encode($result), LOCK_EX);
        }
    }

    public static function lastResult()
    {
        $d = @file_get_contents(self::resultPath());
        $j = ($d !== false && $d !== '') ? json_decode($d, true) : null;

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

    // ── Wiederherstellen (Original aus dem gecachten Paket) ─────────────────

    /**
     * Eine Kerndatei aus dem gecachten Original-Paket wiederherstellen. Schreibt
     * ueber die htpPut/htpChmod-Primitive (NICHT writeTo() - das wuerde auf 0444
     * haerten); danach normale 0644-Rechte und Hash-Gegenpruefung.
     *
     * @param   string  $rel  Kern-relativer Pfad (aus der Abweichungsliste)
     * @param   string  $v    Version, gegen die abgeglichen wurde
     *
     * @return array ['ok'=>bool, 'reason'=>string]
     */
    public static function restore($rel, $v)
    {
        $rel = self::sanitizeRel($rel);

        if ($rel === '') {
            return array('ok' => false, 'reason' => 'badpath');
        }

        $man = self::loadManifest($v);

        if ($man === null || !isset($man[$rel])) {
            return array('ok' => false, 'reason' => 'notinmanifest');
        }

        if (!class_exists('ZipArchive')) {
            return array('ok' => false, 'reason' => 'nozip');
        }

        $za = new ZipArchive();

        if (@$za->open(self::refZipPath($v)) !== true) {
            return array('ok' => false, 'reason' => 'norefcache');
        }

        $content = @$za->getFromName($rel, self::FILE_MAX);
        $za->close();

        if (!is_string($content) || hash('sha256', $content) !== $man[$rel]['s']) {
            return array('ok' => false, 'reason' => 'refmismatch');
        }

        $abs = self::appRoot() . '/' . $rel;
        $d   = dirname($abs);

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

        $dmode = @fileperms($d);

        if (@is_link($abs)) {
            return array('ok' => false, 'reason' => 'symlink'); // TOCTOU: nie durch einen Symlink schreiben
        }

        if (@is_file($abs) && !@is_writable($abs) && class_exists('HtpStorage')) {
            @HtpStorage::htpChmod($abs, 0644);
        }

        if (!@is_writable($d) && class_exists('HtpStorage')) {
            @HtpStorage::htpChmod($d, 0755);
        }

        $ok = class_exists('HtpStorage')
            ? (@HtpStorage::htpPut($abs, $content, LOCK_EX) !== false)
            : (@file_put_contents($abs, $content, LOCK_EX) !== false);

        if ($ok && class_exists('HtpStorage')) {
            @HtpStorage::htpChmod($abs, 0644);
        }

        if ($dmode !== false && class_exists('HtpStorage')) {
            @HtpStorage::htpChmod($d, $dmode & 0777); // Ordner-Rechte zuruecksetzen
        }

        if (!$ok) {
            return array('ok' => false, 'reason' => 'unwritable');
        }

        clearstatcache();

        if (@hash_file('sha256', $abs) !== $man[$rel]['s']) {
            return array('ok' => false, 'reason' => 'verify');
        }

        // Erledigte Datei aus dem gespeicherten Ergebnis + der Basislinie entfernen,
        // damit die Ampel/Karte sie nicht weiter als offen fuehrt.
        self::dropFromResult($rel);

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

    private static function sanitizeRel($rel)
    {
        $rel = str_replace('\\', '/', (string) $rel);
        $rel = ltrim($rel, '/');

        // Kein Pfad-Klettern, keine Null-Bytes, kein leerer Pfad.
        if ($rel === '' || strpos($rel, "\0") !== false || strpos($rel, '../') !== false || $rel === '..') {
            return '';
        }

        return $rel;
    }

    private static function dropFromResult($rel)
    {
        $res = self::lastResult();

        if ($res === null || !isset($res['dev']) || !is_array($res['dev'])) {
            return;
        }

        $dev = array();

        foreach ($res['dev'] as $d) {
            if (isset($d['path']) && (string) $d['path'] === (string) $rel) {
                continue;
            }

            $dev[] = $d;
        }

        $res['dev'] = $dev;

        if (class_exists('HtpStorage')) {
            @HtpStorage::htpPut(self::resultPath(), (string) json_encode($res), LOCK_EX);
        }
    }

    // ── Nutzer-bestaetigte, checksum-gebundene Abweichungen (Fremd-Patches, z. B. Patchman) ──

    /**
     * Bestaetigungs-Store lesen: rel-Pfad -> SHA-256. Nur wohlgeformte Eintraege. Klein & robust.
     *
     * @return array
     */
    private static function ackMap()
    {
        $d = @file_get_contents(self::ackPath());
        $j = ($d !== false && $d !== '') ? json_decode($d, true) : null;

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

        $out = array();

        foreach ($j as $rel => $sha) {
            if (is_string($rel) && $rel !== '' && is_string($sha) && preg_match('~^[a-f0-9]{64}$~', $sha)) {
                $out[$rel] = $sha;
            }
        }

        return $out;
    }

    /**
     * Bestaetigungs-Store schreiben (gedeckelt auf ACK_CAP). Leerer Store -> Datei entfernen.
     *
     * @return boolean
     */
    private static function ackWriteMap(array $map)
    {
        if (count($map) > self::ACK_CAP) {
            $map = array_slice($map, 0, self::ACK_CAP, true);
        }

        if (empty($map)) {
            @unlink(self::ackPath());

            return true;
        }

        if (class_exists('HtpStorage')) {
            return @HtpStorage::htpPut(self::ackPath(), (string) json_encode($map), LOCK_EX) !== false;
        }

        return @file_put_contents(self::ackPath(), (string) json_encode($map), LOCK_EX) !== false;
    }

    /**
     * Eine geaenderte Kern-Datei als bewusst akzeptiert markieren (an ihren AKTUELLEN Inhalt
     * gebunden). Danach zaehlt sie im Abgleich als 'ok' (+ 'ignored'-Zaehler); aendert sie sich,
     * greift die Bestaetigung nicht mehr und die Abweichung erscheint automatisch wieder.
     *
     * @param   string  $rel  Kern-relativer Pfad
     *
     * @return array ['ok'=>bool, 'reason'=>string]
     */
    public static function acknowledge($rel)
    {
        $rel = self::sanitizeRel($rel);

        if ($rel === '') {
            return array('ok' => false, 'reason' => 'badpath');
        }

        $abs = self::appRoot() . '/' . $rel;

        if (!@is_file($abs)) {
            return array('ok' => false, 'reason' => 'missing'); // nur vorhandene (geaenderte) Dateien
        }

        $sha = (string) @hash_file('sha256', $abs);

        if ($sha === '') {
            return array('ok' => false, 'reason' => 'nohash');
        }

        $map = self::ackMap();

        if (!isset($map[$rel]) && count($map) >= self::ACK_CAP) {
            return array('ok' => false, 'reason' => 'full');
        }

        $map[$rel] = $sha;
        self::ackWriteMap($map);
        // Sofortiges UI-Feedback ohne Neu-Abgleich: Zeile aus dem Ergebnis nehmen, changed->ignored.
        self::resultReclass($rel, 'ignore');

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

    /**
     * Eine Bestaetigung zuruecknehmen - die Datei wird ab sofort wieder als Abweichung gefuehrt.
     *
     * @param   string  $rel  Kern-relativer Pfad
     *
     * @return array ['ok'=>bool, 'reason'=>string]
     */
    public static function unacknowledge($rel)
    {
        $rel = self::sanitizeRel($rel);

        if ($rel === '') {
            return array('ok' => false, 'reason' => 'badpath');
        }

        $map = self::ackMap();

        if (isset($map[$rel])) {
            unset($map[$rel]);
            self::ackWriteMap($map);
        }

        self::resultReclass($rel, 'unignore');

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

    /**
     * Liste der aktuell bestaetigten Pfade (fuer die Oberflaeche: "wieder pruefen").
     *
     * @return array
     */
    public static function ackList()
    {
        return array_keys(self::ackMap());
    }

    /**
     * Stale Bestaetigungen entfernen: passt der aktuelle Datei-Hash nicht mehr zum bestaetigten
     * (Datei geaendert/aktualisiert/entfernt), ist die Bestaetigung wertlos -> raus. Haelt den
     * Store sauber und winzig. Wird am Ende jedes Abgleichs aufgerufen (wenige Eintraege).
     */
    private static function pruneAcks()
    {
        $map = self::ackMap();

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

        $root  = self::appRoot();
        $dirty = false;

        foreach ($map as $rel => $sha) {
            $abs = $root . '/' . $rel;

            if (!@is_file($abs) || !hash_equals((string) $sha, (string) @hash_file('sha256', $abs))) {
                unset($map[$rel]);
                $dirty = true;
            }
        }

        if ($dirty) {
            self::ackWriteMap($map);
        }
    }

    /**
     * Das gespeicherte Ergebnis nach einer Ignorieren-/Wieder-pruefen-Aktion sofort anpassen,
     * damit die Karte ohne Neu-Abgleich stimmt.
     *   'ignore'   -> Zeile entfernen, changed--, ok++, ignored++
     *   'unignore' -> Zeile (als 'changed') aufnehmen, ignored--, ok--, changed++
     *
     * @param   string  $rel   Kern-relativer Pfad
     * @param   string  $mode  'ignore' | 'unignore'
     */
    private static function resultReclass($rel, $mode)
    {
        $res = self::lastResult();

        if ($res === null) {
            return;
        }

        $dev = isset($res['dev']) && is_array($res['dev']) ? $res['dev'] : array();
        $c   = isset($res['counts']) && is_array($res['counts']) ? $res['counts'] : array();
        $c  += array('ok' => 0, 'changed' => 0, 'missing' => 0, 'patched' => 0, 'ignored' => 0);

        if ($mode === 'ignore') {
            $found = false;
            $keep  = array();

            foreach ($dev as $d) {
                if (isset($d['path']) && (string) $d['path'] === (string) $rel) {
                    $found = true;

                    continue;
                }

                $keep[] = $d;
            }

            if ($found) {
                $c['changed'] = max(0, (int) $c['changed'] - 1);
                $c['ok']      = (int) $c['ok'] + 1;
                $c['ignored'] = (int) $c['ignored'] + 1;
                $res['dev']   = $keep;
            }
        } else {
            $already = false;

            foreach ($dev as $d) {
                if (isset($d['path']) && (string) $d['path'] === (string) $rel) {
                    $already = true;

                    break;
                }
            }

            if (!$already) {
                $abs   = self::appRoot() . '/' . $rel;
                $dev[] = array(
                    'path'  => $rel,
                    'kind'  => 'changed',
                    'size'  => (int) @filesize($abs),
                    'mtime' => (int) @filemtime($abs),
                );
                $c['changed'] = (int) $c['changed'] + 1;
                $c['ok']      = max(0, (int) $c['ok'] - 1);
                $c['ignored'] = max(0, (int) $c['ignored'] - 1);
                $res['dev']   = $dev;
            }
        }

        $res['counts'] = $c;

        if (class_exists('HtpStorage')) {
            @HtpStorage::htpPut(self::resultPath(), (string) json_encode($res), LOCK_EX);
        } else {
            @file_put_contents(self::resultPath(), (string) json_encode($res), LOCK_EX);
        }
    }

    /**
     * Referenz-Cache (Paket + Manifest) alter/aller Versionen aufraeumen.
     *
     * @param   string  $keepVersion  diese Version behalten ('' = alles loeschen)
     *
     * @return void
     */
    public static function cleanCache($keepVersion = '')
    {
        $keepZip = $keepVersion !== '' ? basename(self::refZipPath($keepVersion)) : '';
        $keepMan = $keepVersion !== '' ? basename(self::manifestPath($keepVersion)) : '';

        // Basis-Praefix (ohne Generation) globben, damit auch Alt-Generationen (gen 1 =
        // Voll-Paket) mit aufgeraeumt werden - sonst blieben deren 30+ MB als Waisen liegen.
        $refDir = self::refDir();

        foreach ((array) @glob($refDir . '/coreref-*.zip*') as $f) {
            if (basename($f) !== $keepZip) {
                @unlink($f);
            }
        }

        // Alt-Ort (vor 2.5.10): etwaige zurueckgebliebene Referenz-ZIPs restlos wegraeumen - die
        // zu behaltende Version liegt jetzt in refDir(). Nur wenn der Ort wirklich verschieden ist
        // (sonst wuerde die keep-Version geloescht).
        if (($old = self::dir()) !== '' && $old !== $refDir) {
            foreach ((array) @glob($old . '/coreref-*.zip*') as $f) {
                @unlink($f);
            }
        }

        foreach ((array) @glob(self::dir() . '/coremanifest-*.json*') as $f) {
            if (basename($f) !== $keepMan) {
                @unlink($f);
            }
        }
    }
}
