<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Erweiterungs-Auto-Update mit Sicherheitsnetz (s. docs/plan-auto-updates.md).
 * Eigenstaendiges Modul: Orchestrierung (tick/dispatchTask/viewData) + REINE,
 * statisch-testbare Policy (Versions-Gate, Eligibility, Quellen-Wechsel-Sperre,
 * Rollback-Entscheid, Flatter-Schutz, Sicherheits-Gate). Datei-Backup/-Rollback
 * liegt isoliert in autoupdate_backup.php (HtpUpdateBackup), die Darstellung in
 * autoupdate_ui.php (HtpAutoUpdateUi).
 *
 * SICHERHEIT (plan Abschnitt 9): null neue unauthentifizierte Flaeche - laeuft
 * nur im gedrosselten Hintergrund-Tick und in Super-User-/CSRF-gesicherten
 * Backend-Tasks. Kein SSRF (nur in #__update_sites registrierte, HTTPS-Quellen;
 * nie eine URL aus dem Request). Quellen-Wechsel-Sperre. Default AUS. Fail-open.
 * Installation ausschliesslich ueber Joomlas Updater/UpdateModel.
 */

defined('_JEXEC') or die;

class HtpAutoUpdate
{
    /** Ab dieser Joomla-Version erscheint das Feature (Nutzer-Vorgabe). */
    const MIN_JVERSION = '5.4.0';

    /** Laufzeit-Dateien im geschuetzten State-Ordner. */
    const STATE_FILE  = 'htprotect_autoupdate.json';
    const LOCK_FILE   = 'htprotect_autoupdate.lock';

    // Synthetische System-Identitaet fuer den headless-Install (elevateIdentity): eine FESTE, nicht
    // existente Benutzer-ID (passt in signed/unsigned int) + root_user-God-Mode. So braucht der Install
    // kein reales Super-User-Konto - ein etwaiger Joomla-Aktionsprotokoll-Eintrag traegt eine ID, die
    // KEINEM Konto entspricht (-> "System"/leer, konstant), statt einen verwirrenden Menschen zu nennen.
    const SYSTEM_UID  = 2147483647;
    /** Breadcrumb der Plugin-Notfall-Wiederherstellung (§7) -> Engine protokolliert/meldet. */
    const HEALED_FILE = 'htprotect_autoupdate.healed';

    /**
     * FEATURE-FLAG: Sichtbarkeit UND Aktivierung des Erweiterungs-Auto-Updates.
     * FALSE = komplett DORMANT (UI ausgeblendet + Scheduler pausiert + runStep no-op),
     * weil noch in Arbeit/ungetestet. Der GESAMTE Code bleibt erhalten - zum
     * Wiedereinblenden/Aktivieren einfach auf true setzen.
     */
    const FEATURE_VISIBLE = true;

    /** Default-Intervall des Hintergrund-Laufs (taeglich). */
    const DEFAULT_INTERVAL = 86400;

    /**
     * Mindestabstand im 'instant'-Modus (10 Min). MUSS UNTER dem Idle-Takt des Auto-Update-
     * Ticks liegen (plg_htprotectwatch::htpAutoUpdateTick, 840 s = 14 Min) - sonst verhungert
     * ein ausgewaehltes NICHT-Sicherheits-Update bei Dauer-Traffic: der (vom Master entkoppelte)
     * Selbst-Update-Tick setzt bei JEDEM Lauf last_run neu, und bei INSTANT_MIN >= Tick-Takt
     * erreicht (now - last_run) nie die Schwelle -> scheduleDue('instant') bleibt ewig false.
     * (Galt auf ALLEN Joomla-Versionen; heilte nur in Traffic-Luecken > 15 Min.) Sicherheits-
     * updates laufen davon UNABHAENGIG immer sofort (criticalPending, kein scheduleDue). */
    const INSTANT_MIN = 600;

    /**
     * Mindestabstand zwischen zwei FRISCHEN Abrufen bei den Fremd-Update-Servern
     * (6 h). Anbieter-Schonung (Kubik-Rubik/V. Vogel-Hinweis): auch im 'instant'-
     * Modus werden fremde Update-Server hoechstens ~4x/Tag kontaktiert statt alle
     * 15 Min; dazwischen nutzt HTProtect Joomlas Cache. HTProtect SELBST und
     * Sicherheitsupdates bleiben davon unberuehrt (sofort). + siteJitter() gegen
     * synchronen Thundering Herd.
     */
    const PROVIDER_CHECK_MIN = 21600;

    /**
     * Mindestabstand fuer den SELBST-Check gegen den EIGENEN Update-Server (wb-dev):
     * 3 h (reicht locker). Trifft nur die eigene, statische htprotect.xml - also KEINE
     * Fremd-Last -, aber wir sparen den fruehen 15-Min-Uncached-Takt. HTProtect-Selbst-
     * Updates greifen so binnen <=3h statt 15 Min (bewusst gewaehlt; server-schonend).
     */
    const SELF_CHECK_MIN = 10800;

    /**
     * ANTI-STARVATION-NOTBREMSE fuer den HTProtect-Selbst-Update: Der Self-Update laeuft
     * normal ZULETZT (nur wenn keine Dritt-Ziele mehr anstehen). Klemmt ein Dritt-Ziel
     * aber DAUERHAFT (z. B. eine Backup-Phase, die den Snapshot-Deckel reisst und nie
     * aufgibt), waere die Queue nie leer -> der sicherheitsrelevante Self-Update wuerde
     * unbegrenzt verhungern. Haengt der Self-Update laenger als dieses Fenster an einer
     * klemmenden Queue, bricht er trotzdem durch. 3 h = klemmt genug lange sicher fest,
     * aber ein gesunder Lauf raeumt die Queue laengst vorher ab (dann greift es nie).
     */
    const SELF_STARVE_WINDOW = 10800;

    /** Sprachpaket-Auto-Update (NUR die zusaetzlichen HTProtect-Backend-Sprachen, entkoppelt vom
     *  HTProtect-Selbst-Update oben): hoechstens 1x/24h gegen den signierten Katalog pruefen -
     *  maximal update-server- und ressourcenschonend. Sprachpakete aendern sich selten; ein
     *  taeglicher Blick reicht voellig. Eigener Zeitstempel (last_langpack_check), damit die
     *  3h-Frist des Kern-Self-Updates unberuehrt bleibt. */
    const LANGPACK_CHECK_MIN = 86400;

    /** Update-Quellen-Wächter: hoechstens 1x/Tag alle deaktivierten Update-Quellen
     *  wieder aktivieren (Joomla-Kern schaltet unerreichbare Quellen bei temporaeren
     *  Haengern selbsttaetig ab und laesst sie aus). Eigene Kadenz, laeuft UNABHAENGIG
     *  vom Auto-Update-Schalter. + siteJitter() gegen synchrone Last. */
    const KEEPALIVE_CHECK = 86400;

    /** Nach so vielen vergeblichen Reaktivierungs-Runden eine FREMDE Quelle in Ruhe
     *  lassen (chronisch unerreichbar ODER vom Admin bewusst deaktiviert) - dann einmal
     *  melden. Die EIGENE Quelle (com_htprotect) ist davon ausgenommen (immer aktiv). */
    const KEEPALIVE_MAX_TRIES = 3;

    /** Notify-Modus: gleiche ausstehende Liste hoechstens alle 14 Tage erneut mailen. */
    const NOTIFY_REMIND = 1209600;

    /** JED-Bewertungs-Einladung in der Sicherheitsupdate-Erfolgs-Mail: hoechstens alle
     *  90 Tage (Anti-Spam) und NUR bei echtem Erfolg (kein Rollback/Malware-Verdacht). */
    const JED_MAIL_MIN_INTERVAL = 7776000;

    /** Benachrichtigungs-Check: eigene, schonende Kadenz (alle 6 h) - unabhaengig vom
     *  Auto-Update-Zeitplan, damit der Notify die State-Machine nicht stoert. */
    const NOTIFY_CHECK = 21600;

    /** Rueckruf-Check: bereits installierte, zentral gesperrte Versionen hoechstens
     *  alle 6 h pruefen (eigene Kadenz, NUR Hinweis - kein Eingriff). */
    const RECALL_CHECK = 21600;

    /** Rueckruf-Erinnerung: dieselbe installierte Sperr-Lage hoechstens alle 14 Tage
     *  erneut mailen (Anti-Spam; eine bereits gemeldete Version nervt nicht weiter). */
    const RECALL_REMIND = 1209600;

    /** Angreifbarkeit hoechstens so oft neu bewerten (1 DB-Query), sonst gecacht. */
    const CRIT_RECHECK = 3600; // 1 Stunde
    const SECFLOOR_RECHECK = 300; // 5 Min: kuerzere Kritisch-Kadenz, wenn der Sicherheits-Boden (gemanagt + Master aus) die EINZIGE Sicherung ist

    /** Nach so vielen Fehlschlaegen wird eine Erweiterung ausgesetzt (Flatter-Schutz). */
    const MAX_FAILS = 3;

    /** Veraltete Re-Entrancy-Sperre nach so vielen Sekunden freigeben. */
    const LOCK_STALE = 900;

    /** Ab welchem Alter ein Self-Update-In-Progress-Marker als VERWAIST gilt (kein laufendes
     *  Update mehr): dann darf die Versions-Selbstheilung wieder greifen. Ein echtes Self-Update
     *  ist in Sekunden durch; der Notfall-Restore raeumt nach SELFHEAL_DELAY (3 Min) auf -> 10 Min
     *  ist sicher jenseits jedes echten Laufs, blockiert die Heilung aber nicht dauerhaft. */
    const MARKER_STALE = 600;

    /** Self-Trigger-Token: Fensterbreite in Sekunden. Ein abgefangenes Token ist
     *  nach hoechstens 2 Fenstern (aktuell+vorheriges) wertlos - der langlebige
     *  trigger_key verlaesst den Server dabei nie (nur das abgeleitete HMAC). */
    const TRIGGER_STEP = 60;

    /** Kontext-Bindung des Trigger-Tokens (verhindert Zweckentfremdung des HMAC). */
    const TRIGGER_CTX = 'htpautoupdate-trigger-v1';

    /** Maximale Anzahl Log-Eintraege. */
    const HISTORY_CAP = 60;

    /** Cooldown nach einem Download-Key-/Quellen-Problem (kein Dauer-Retry, kein
     *  Verhungern anderer Updates). */
    const KEY_COOLDOWN = 259200; // 3 Tage

    /** Verkuerzter Cooldown fuer VERWUNDBARE (Sicherheits-)Ziele: ein transienter
     *  Download-/Quellen-Blip darf einen Sicherheits-Patch nicht 3 Tage still liegen
     *  lassen -> naechster kritischer Tick (~1h) versucht erneut, ohne 15-Min-Haemmern. */
    const KEY_COOLDOWN_VULN = 3600; // 1 Stunde

    /** Deckel fuer den wachsenden Key-/Quellen-Backoff (verwundbar): 1h -> 2h -> 4h -> 8h
     *  -> 12h (Deckel). Bei NICHT-verwundbaren Zielen ist der Deckel = max(Grundtakt,
     *  Deckel), sinkt also nie unter den 3-Tage-Grundtakt. */
    const KEY_BACKOFF_CAP = 43200; // 12 Stunden

    /** Nach so vielen erfolglosen Key-/Quellen-Versuchen wird das Auto-Update ENDGUELTIG
     *  aufgegeben ('manuell erforderlich') - danach kein stuendliches Haemmern, keine
     *  Log-Flut mehr. Eine NEUE Zielversion (oder ein erfolgreiches Update) setzt den
     *  Zaehler zurueck. Verwundbare Ziele bekommen mehr Versuche (Sicherheit), bleiben aber
     *  endlich; die Vuln-Warnung (Ampel + Erinnerungs-Mail) laeuft davon UNBERUEHRT weiter. */
    const KEY_TRIES_VULN = 8; // ~2 Tage mit Backoff
    const KEY_TRIES_STD  = 3; // nicht-verwundbar: ~9 Tage (3-Tage-Grundtakt)

    /** Malware-Block: Wartezeit bis zum erneuten Versuch eines wegen Inhalts-Pruefung
     *  geblockten Updates (so zieht ein spaeter korrigiertes Signatur-/fp-Update beim
     *  naechsten Lauf durch). Defs frischen alle 6 h -> 1 Tag gibt dem Fix Zeit. */
    const MALSCAN_COOLDOWN = 86400; // 1 Tag

    /** Nach so vielen Blocks DERSELBEN Version endgueltig aussetzen (echte Malware bzw.
     *  unkorrigierter Fehlalarm). Eine NEUE Version bekommt immer eine frische Chance. */
    const MALSCAN_MAX_BLOCKS = 7;

    // ═════════════════════════════════════════════════════════════════
    //  REINE POLICY (statisch, ohne FS/DB/HTTP - vollstaendig unit-testbar)
    // ═════════════════════════════════════════════════════════════════

    /**
     * Versions-Gate: erscheint das Feature auf dieser Joomla-Version? (>= 5.4)
     *
     * @param   string  $jversion  Joomla-Version (z. B. '5.4.0')
     *
     * @return boolean
     */
    public static function versionGateOk($jversion)
    {
        $jversion = (string) $jversion;

        if ($jversion === '' || !preg_match('~^\d+\.\d+~', $jversion)) {
            return false;
        }

        return version_compare($jversion, self::MIN_JVERSION, '>=');
    }

    /**
     * Ist die Quelle eine HTTPS-URL? (http wird uebersprungen - kein Klartext-Kanal)
     *
     * @param   string  $url  Quelle
     *
     * @return boolean
     */
    public static function isHttps($url)
    {
        return strtolower((string) parse_url((string) $url, PHP_URL_SCHEME)) === 'https';
    }

    /**
     * Host einer URL (kleingeschrieben) - fuer die Quellen-Wechsel-Erkennung.
     *
     * @param   string  $url  URL
     *
     * @return string
     */
    public static function hostOf($url)
    {
        $host = parse_url((string) $url, PHP_URL_HOST);

        return is_string($host) ? strtolower($host) : '';
    }

    /**
     * Hat sich der Update-Host einer Erweiterung gegenueber dem zuletzt
     * gesehenen plötzlich geaendert? (Schutz vor untergeschobener Update-Site.)
     * Ein leerer bekannter Host ist KEIN Wechsel (Erstkontakt -> wird gemerkt).
     *
     * @param   string  $known    Zuletzt gesehener Host ('' = unbekannt)
     * @param   string  $current  Aktueller Host
     *
     * @return boolean
     */
    public static function sourceChanged($known, $current)
    {
        $known   = strtolower((string) $known);
        $current = strtolower((string) $current);

        return $known !== '' && $current !== '' && $known !== $current;
    }

    /**
     * Stabiler Schluessel einer Erweiterung (ueberlebt Reinstall, im Gegensatz zur
     * numerischen extension_id) - fuer Ausschluss-/Aussetz-/Host-Listen.
     *
     * @param   string  $type     Erweiterungstyp
     * @param   string  $element  Element-Name
     * @param   string  $folder   Plugin-Gruppe oder ''
     *
     * @return string
     */
    public static function extKey($type, $element, $folder)
    {
        return strtolower((string) $type) . '|' . strtolower((string) $element) . '|' . strtolower((string) $folder);
    }

    /**
     * Ist der Hintergrund-Lauf faellig?
     *
     * @param   int  $lastRun   Letzter Lauf (Unix-Zeit)
     * @param   int  $now       Jetzt
     * @param   int  $interval  Intervall in Sekunden
     *
     * @return boolean
     */
    public static function intervalDue($lastRun, $now, $interval)
    {
        $interval = max(3600, (int) $interval);

        return ((int) $now - (int) $lastRun) >= $interval;
    }

    /**
     * Ist der NORMALE Update-Lauf laut gewaehltem Zeitplan faellig? (rein, testbar)
     * 'instant' = jeder 15-Min-Tick; 'daily' = 1x taeglich zur Stunde; 'weekly' =
     * 1x woechentlich an Wochentag+Stunde. Stunde/Wochentag gelten in der Joomla-
     * Zeitzone (DST-sicher via DateTime). ANGREIFBARE Updates laufen unabhaengig
     * davon immer sofort (s. criticalPending) - das ist NICHT hier abgebildet.
     *
     * @param   string  $schedule  'instant'|'daily'|'weekly'
     * @param   int     $hour      0-23 (Joomla-Zeitzone)
     * @param   int     $weekday   1=Mo .. 7=So (ISO/PHP 'N')
     * @param   int     $lastRun   Letzter Lauf (Unix)
     * @param   int     $now       Jetzt (Unix)
     * @param   string  $tz        Zeitzone (Default: Joomla-Zeitzone)
     *
     * @return boolean
     */
    public static function scheduleDue($schedule, $hour, $weekday, $lastRun, $now, $tz = null)
    {
        $now     = (int) $now;
        $lastRun = (int) $lastRun;

        if ($schedule === 'instant') {
            // Jeder Tick (15 Min Mindestabstand, gespiegelt am Waechter-Intervall).
            return ($now - $lastRun) >= self::INSTANT_MIN;
        }

        if ($tz === null) {
            $tz = function_exists('htp_joomla_tz') ? htp_joomla_tz() : 'UTC';
        }

        $hour = (int) $hour;

        if ($hour < 0 || $hour > 23) {
            $hour = 3;
        }

        if ($schedule === 'weekly') {
            $weekday = (int) $weekday;

            if ($weekday < 1 || $weekday > 7) {
                $weekday = 1;
            }

            $slot = self::lastWeeklySlot($weekday, $hour, $now, $tz);
        } else {
            // 'daily' (Default)
            $slot = self::lastDailySlot($hour, $now, $tz);
        }

        // Faellig, wenn der letzte planmaessige Slot seit dem letzten Lauf erreicht
        // wurde (und tatsaechlich in der Vergangenheit/jetzt liegt).
        return $slot > 0 && $now >= $slot && $lastRun < $slot;
    }

    /**
     * Unix-Zeit des letzten taeglichen Slots (heute/gestern um $hour) <= $now,
     * gerechnet in der Zeitzone $tz. DST-sicher (DateTime modify). 0 bei Fehler.
     *
     * @param   int     $hour  0-23
     * @param   int     $now   Jetzt (Unix)
     * @param   string  $tz    Zeitzone
     *
     * @return int
     */
    public static function lastDailySlot($hour, $now, $tz)
    {
        try {
            $d = new DateTime('@' . (int) $now);
            $d->setTimezone(new DateTimeZone($tz));
            $d->setTime((int) $hour, 0, 0);

            if ($d->getTimestamp() > (int) $now) {
                $d->modify('-1 day'); // Slot heute noch nicht erreicht -> gestern
            }

            return $d->getTimestamp();
        } catch (Exception $e) {
            return 0;
        }
    }

    /**
     * Unix-Zeit des letzten woechentlichen Slots (Wochentag $weekday um $hour)
     * <= $now, in der Zeitzone $tz. DST-sicher. 0 bei Fehler.
     *
     * @param   int     $weekday  1=Mo .. 7=So
     * @param   int     $hour     0-23
     * @param   int     $now      Jetzt (Unix)
     * @param   string  $tz       Zeitzone
     *
     * @return int
     */
    public static function lastWeeklySlot($weekday, $hour, $now, $tz)
    {
        try {
            $d = new DateTime('@' . (int) $now);
            $d->setTimezone(new DateTimeZone($tz));
            $d->setTime((int) $hour, 0, 0);

            $back = ((int) $d->format('N') - (int) $weekday + 7) % 7;

            if ($back > 0) {
                $d->modify('-' . $back . ' day');
            }

            if ($d->getTimestamp() > (int) $now) {
                $d->modify('-7 day'); // dieser Wochenslot noch nicht erreicht
            }

            return $d->getTimestamp();
        } catch (Exception $e) {
            return 0;
        }
    }

    /**
     * Zeitplan-Konfiguration normalisiert lesen ['schedule','hour','weekday'].
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array
     */
    public static function scheduleCfg($cfg)
    {
        $schedule = isset($cfg['autoupdate_schedule']) ? (string) $cfg['autoupdate_schedule'] : 'weekly';

        if (!in_array($schedule, array('instant', 'daily', 'weekly'), true)) {
            $schedule = 'weekly';
        }

        $hour = isset($cfg['autoupdate_hour']) ? (int) $cfg['autoupdate_hour'] : 21;

        if ($hour < 0 || $hour > 23) {
            $hour = 21;
        }

        $weekday = isset($cfg['autoupdate_weekday']) ? (int) $cfg['autoupdate_weekday'] : 5;

        if ($weekday < 1 || $weekday > 7) {
            $weekday = 5;
        }

        return array('schedule' => $schedule, 'hour' => $hour, 'weekday' => $weekday);
    }

    /**
     * Bequemer Zeitplan-Faelligkeits-Check aus der Konfiguration. Referenz ist bei
     * 'instant' der letzte Lauf (15-Min-Abstand), sonst der zuletzt VOLLSTAENDIG
     * abgearbeitete Slot ($schedDone) - so kann ein geplanter Lauf mehrere Updates
     * am selben Tag/Slot in 15-Min-Schritten abarbeiten (Burst), statt 1 pro Tag.
     *
     * @param   array  $cfg        Konfiguration
     * @param   int    $lastRun    Letzter Lauf (Unix; fuer 'instant')
     * @param   int    $schedDone  Zuletzt abgeschlossener Slot (Unix; daily/weekly)
     * @param   int    $now        Jetzt (Unix)
     *
     * @return boolean
     */
    public static function scheduleDueCfg($cfg, $lastRun, $schedDone, $now)
    {
        $s   = self::scheduleCfg($cfg);
        $ref = ($s['schedule'] === 'instant') ? (int) $lastRun : (int) $schedDone;

        return self::scheduleDue($s['schedule'], $s['hour'], $s['weekday'], $ref, $now);
    }

    /**
     * Aktuell geltender Plan-Slot (Unix) fuer daily/weekly; 0 bei 'instant'. Dient
     * dazu, nach abgearbeitetem Burst sched_done = Slot zu setzen.
     *
     * @param   array  $cfg  Konfiguration
     * @param   int    $now  Jetzt (Unix)
     *
     * @return int
     */
    public static function currentSlot($cfg, $now)
    {
        $s  = self::scheduleCfg($cfg);
        $tz = function_exists('htp_joomla_tz') ? htp_joomla_tz() : 'UTC';

        if ($s['schedule'] === 'weekly') {
            return self::lastWeeklySlot($s['weekday'], $s['hour'], $now, $tz);
        }

        if ($s['schedule'] === 'daily') {
            return self::lastDailySlot($s['hour'], $now, $tz);
        }

        return 0; // instant
    }

    /**
     * Naechster geplanter Lauf (Unix, in der Zukunft) fuer daily/weekly; 0 bei
     * 'instant' (laufend). Nur fuer die Anzeige.
     *
     * @param   array  $cfg  Konfiguration
     * @param   int    $now  Jetzt (Unix)
     *
     * @return int
     */
    public static function nextSlot($cfg, $now)
    {
        $now = (int) $now;
        $s   = self::scheduleCfg($cfg);

        if ($s['schedule'] === 'instant') {
            return 0;
        }

        $tz = function_exists('htp_joomla_tz') ? htp_joomla_tz() : 'UTC';

        try {
            $d = new DateTime('@' . $now);
            $d->setTimezone(new DateTimeZone($tz));
            $d->setTime($s['hour'], 0, 0);

            if ($s['schedule'] === 'weekly') {
                $fwd = ($s['weekday'] - (int) $d->format('N') + 7) % 7;

                if ($fwd > 0) {
                    $d->modify('+' . $fwd . ' day');
                }

                if ($d->getTimestamp() <= $now) {
                    $d->modify('+7 day');
                }
            } elseif ($d->getTimestamp() <= $now) {
                $d->modify('+1 day');
            }

            return $d->getTimestamp();
        } catch (Exception $e) {
            return 0;
        }
    }

    /**
     * Naechster vom Joomla-Aufgabenplaner geplanter Lauf der Auto-Update-Aufgabe:
     * das echte `next_execution` aus #__scheduler_tasks (UTC) als Unix-Timestamp.
     * So kann die UI bei 'instant' die exakte naechste Pruefzeit zeigen statt nur
     * "laufend". 0 = keine aktive Aufgabenzeile (Feature aus / reiner externer Cron /
     * J < 5.4 ohne Scheduler).
     *
     * @return int  Unix-Timestamp oder 0
     */
    public static function schedulerNextRun()
    {
        // Nur ab 5.4 gibt es #__scheduler_tasks; auf J3 (Legacy-Pfad, Wächter-Herzschlag)
        // existiert die Tabelle nicht -> gar nicht erst abfragen (spart eine Fehl-Query je
        // Seitenaufruf). Der J3-Anstoß ist ohnehin besucher-/wächter-getrieben, nicht geplant.
        if (!self::menuEligible()) {
            return 0;
        }

        $db = htp_db();

        if (!$db) {
            return 0;
        }

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName('next_execution'))
                ->from($db->quoteName('#__scheduler_tasks'))
                ->where($db->quoteName('type') . ' = ' . $db->quote(self::TASK_ROUTINE))
                ->where($db->quoteName('state') . ' = 1');
            $db->setQuery($q, 0, 1);
            $val = (string) $db->loadResult();

            if ($val === '' || strpos($val, '0000-00-00') === 0) {
                return 0;
            }

            // Der Scheduler speichert UTC -> in Unix-Timestamp wandeln.
            $ts = strtotime($val . ' UTC');

            return $ts ? (int) $ts : 0;
        } catch (Exception $e) {
            return 0;
        } catch (Throwable $e) {
            return 0;
        }
    }

    /** @var boolean|null Prozess-Cache fuer isManaged() (ein Tick = ein Request). */
    private static $_managed = null;

    /**
     * Ist diese Site ans HTProtect-Dashboard angebunden (GEMANAGT)? Nur dann greift der
     * Sicherheits-Boden (angreifbare Erweiterungen laufen automatisch, auch wenn der lokale
     * Sicherheitsupdate-Master ausgeschaltet ist - die Verwaltung liegt beim Betreiber im
     * Dashboard). Bewusst am KRYPTOGRAPHISCHEN Pairing im Link-State-File festgemacht
     * (paired + site_id + transport_secret), NICHT nur am trivial resetbaren Config-Bool
     * link_paired - ein lokaler Config-Reset darf den Boden nicht heimlich abschalten. Config-Bool
     * bleibt Fallback (aeltere Link-Version ohne loadState). Memoized (ein Tick).
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return boolean
     */
    public static function isManaged($cfg)
    {
        if (self::$_managed !== null) {
            return self::$_managed;
        }

        $managed = false;

        if (class_exists('HtpLink')) {
            try {
                if (method_exists('HtpLink', 'loadState')) {
                    $st = HtpLink::loadState();

                    if (is_array($st) && !empty($st['paired']) && !empty($st['site_id'])
                        && !empty($st['transport_secret'])) {
                        $managed = true;
                    }
                }

                // Fallback (kein State-File-Zugriff): der Config-Bool.
                if (!$managed && method_exists('HtpLink', 'isPaired')) {
                    $managed = (bool) HtpLink::isPaired($cfg);
                }
            } catch (Exception $e) {
                // fail-safe: unten Config-Bool
            } catch (Throwable $e) {
                // fail-safe
            }
        }

        if (!$managed) {
            $managed = !empty($cfg['link_paired']);
        }

        self::$_managed = $managed;

        return $managed;
    }

    /**
     * Steht eine Erweiterung auf "Nie" (autoupdate_never)? REINE Wahrheit fuer den Opt-out unterhalb des
     * Sicherheits-Bodens - MUSS Zeichen fuer Zeichen dieselbe Regel sein wie die inline-Aufloesung in
     * collectInfos (dort map-basiert aus Performance-Gruenden). Reihenfolge (staerkster zuerst):
     *   - HTProtect selbst NIE "Nie" (Sicherheits-/Fix-Verteilung laeuft immer)
     *   - explizit in autoupdate_never -> ja
     *   - explizit include/exclude -> NEIN (bewusste Wahl schlaegt jeden Default)
     *   - unlisted: Template-Typ -> ja (Template-Default = "Nie"); sonst globaler Default 'never' -> ja
     * Genutzt von criticalPending (Sicherheits-Boden darf ein "Nie"-Ext nicht als Auto-Pflicht sehen).
     *
     * @return boolean
     */
    public static function isNeverExt($cfg, $type, $element, $folder)
    {
        $type    = strtolower((string) $type);
        $element = strtolower((string) $element);

        if ($element === 'com_htprotect') {
            return false;
        }

        $key = self::extKey($type, $element, $folder);
        $in  = function ($cfg, $listKey, $key) {
            $list = isset($cfg[$listKey]) && is_array($cfg[$listKey]) ? $cfg[$listKey] : array();
            foreach ($list as $k) {
                if (strtolower((string) $k) === $key) {
                    return true;
                }
            }
            return false;
        };

        if ($in($cfg, 'autoupdate_never', $key)) {
            return true;
        }
        if ($in($cfg, 'autoupdate_include', $key) || $in($cfg, 'autoupdate_exclude', $key)) {
            return false;
        }
        if ($type === 'template') {
            return true;
        }

        $def = isset($cfg['autoupdate_default']) ? (string) $cfg['autoupdate_default'] : 'security';

        return ($def === 'never');
    }

    /**
     * Die VORGABE-Stufe einer Erweiterung, wenn sie NICHT explizit gewaehlt ist (intent 'inherit'):
     * Template-Typ -> 'never' (Template-Default); globaler Default 'all' -> 'all', 'never' -> 'never';
     * sonst 'security'. Der Typ steckt im Ext-Key (type|element|folder). Rein. Basis fuer den 3-Stufen-
     * Schalter: die im Backend angezeigte Ausgangsstufe UND das "auf Vorgabe zuruecksetzen = inherit".
     *
     * @return string 'never'|'security'|'all'
     */
    public static function defaultStageForKey($cfg, $key)
    {
        $parts = explode('|', strtolower((string) $key));
        $type  = isset($parts[0]) ? $parts[0] : '';

        if ($type === 'template') {
            return 'never';
        }

        $def = isset($cfg['autoupdate_default']) ? (string) $cfg['autoupdate_default'] : 'security';

        if ($def === 'all') {
            return 'all';
        }
        if ($def === 'never') {
            return 'never';
        }

        return 'security';
    }

    /**
     * Die AKTUELL effektive 3-Stufe einer Erweiterung ('never'|'security'|'all') aus Intent (Wahrheit) +
     * Flach-Listen (Fallback) + Vorgabe. Genutzt vom Backend-Save, um nur ECHTE Aenderungen als Override
     * in den Intent zu schreiben (keine Register-Blaehung), und von der UI fuer die Ausgangsstellung des
     * Schalters. 'on'=all, 'off'=security (Boden), 'never'=nie, 'inherit'/unlisted -> defaultStageForKey.
     * Rein.
     *
     * @return string 'never'|'security'|'all'
     */
    public static function stageForKey($cfg, $intent, $key)
    {
        $key = strtolower((string) $key);

        // 1) Explizite Intent-Wahl = Wahrheit.
        if (class_exists('HtpAuIntent') && is_array($intent)) {
            $norm = HtpAuIntent::normalize($intent);

            if (isset($norm['ext'][$key]['val'])) {
                $v = (string) $norm['ext'][$key]['val'];

                if ($v === 'on')    { return 'all'; }
                if ($v === 'off')   { return 'security'; }
                if ($v === 'never') { return 'never'; }
                // 'inherit' faellt auf den Default durch
            }
        }

        // 2) Flach-Listen (falls Intent fehlt/leer) - never schlaegt include/exclude.
        $inList = function ($cfg, $listKey, $key) {
            $list = isset($cfg[$listKey]) && is_array($cfg[$listKey]) ? $cfg[$listKey] : array();
            foreach ($list as $k) {
                if (strtolower((string) $k) === $key) {
                    return true;
                }
            }
            return false;
        };

        if ($inList($cfg, 'autoupdate_never', $key))   { return 'never'; }
        if ($inList($cfg, 'autoupdate_include', $key)) { return 'all'; }
        if ($inList($cfg, 'autoupdate_exclude', $key)) { return 'security'; }

        // 3) Vorgabe (Typ/Fleet-Default).
        return self::defaultStageForKey($cfg, $key);
    }

    /**
     * Gibt es eine ANGREIFBARE installierte Erweiterung, die der Sicherheits-Boden auch AUTOMATISCH
     * schliessen wuerde? (Kritisch-Fast-Path: dann laeuft der Tick unabhaengig vom Zeitplan, um die Luecke
     * moeglichst sofort zu schliessen.) Lokal (checkExtensions + eine Typ-/Ordner-Query, kein Netzwerk).
     * Der Joomla-Kern (separates Update) zaehlt NICHT. Auf "Nie" gestellte Erweiterungen zaehlen NICHT:
     * sie sind ein bewusster Opt-out - die Luecke wird weiter GEMELDET (Inventar/Ampel) + vom WAF
     * geschuetzt, aber NICHT automatisch geschlossen; sie darf daher den Boden nicht dauerhaft triggern.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return boolean
     */
    public static function criticalPending($cfg)
    {
        // Sicherheits-Boden: bei GEMANAGTER (dashboard-verbundener) Site laufen Sicherheitsupdates
        // IMMER - auch bei lokal ausgeschaltetem Master. Sicherheit ist fuer verbundene Sites eine
        // GARANTIE, kein Regler (Design-Entscheidung: das Dashboard bietet kein "alles aus" mehr an;
        // Standard ist nur 'nur Sicherheit' | 'alle'). Nicht-gemanagt: unveraendert (Master aus =>
        // keine Kritisch-Pruefung; Standalone kann Sicherheit lokal abschalten).
        if ((empty($cfg['autoupdate_ext']) && !self::isManaged($cfg)) || !class_exists('HtpDefs')) {
            return false;
        }

        try {
            $vuln = HtpDefs::checkExtensions();
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }

        if (!is_array($vuln)) {
            return false;
        }

        $vulnEls = array();

        foreach ($vuln as $v) {
            $element = isset($v['element']) ? strtolower((string) $v['element']) : '';

            if ($element === '' || in_array($element, array('joomla', 'files_joomla', 'pkg_joomla'), true)) {
                continue;
            }

            $vulnEls[$element] = true;
        }

        if (empty($vulnEls)) {
            return false;
        }

        // Typ/Ordner der verwundbaren Erweiterungen aufloesen, um "Nie" korrekt zu erkennen. Eine auf
        // "Nie" gestellte Erweiterung ist ein bewusster Opt-out und darf den Sicherheits-Boden NICHT
        // dauerhaft triggern (sonst liefe die Maschinerie jeden Recheck ins Leere). FAIL-SAFE: koennen wir
        // Typ/Ordner nicht aufloesen (DB-Fehler) oder finden eine verwundbare Erweiterung nicht in
        // #__extensions wieder (z. B. Paket-Kind ueber package_id), gilt sie als abdeckbar -> Boden greift.
        // Lieber einmal zu viel laufen als eine echte Luecke uebersehen.
        $db = htp_db();

        if (!$db) {
            return true;
        }

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName(array('type', 'element', 'folder')))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('element') . ' IN ('
                    . implode(',', array_map(array($db, 'quote'), array_keys($vulnEls))) . ')');
            $db->setQuery($query);
            $rows = $db->loadAssocList();
        } catch (Exception $e) {
            return true;
        } catch (Throwable $e) {
            return true;
        }

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

        $sawEl = array();

        foreach ($rows as $r) {
            $el = strtolower((string) (isset($r['element']) ? $r['element'] : ''));

            if ($el === '' || !isset($vulnEls[$el])) {
                continue;
            }

            $sawEl[$el] = true;

            if (!self::isNeverExt($cfg, isset($r['type']) ? $r['type'] : '', $el, isset($r['folder']) ? $r['folder'] : '')) {
                return true; // eine verwundbare, NICHT auf "Nie" gestellte Erweiterung -> Boden schliesst sie
            }
        }

        // Verwundbare Erweiterung, die wir in #__extensions nicht wiederfinden (Paket-Kind o. ae.):
        // fail-safe als abdeckbar behandeln.
        foreach ($vulnEls as $el => $_ignored) {
            if (!isset($sawEl[$el])) {
                return true;
            }
        }

        // Alle verwundbaren Erweiterungen stehen bewusst auf "Nie" -> kein Auto-Schliess-Auftrag.
        return false;
    }

    /**
     * SICHERHEITS-GATE (rein): darf gerade ueberhaupt automatisch aktualisiert
     * werden? Blockiert bei Notfall-Sperre und bei einer AKTIVEN Kompromittierungs-
     * Spur (Angriff/Defacement/Konten-Anomalie/Kerndatei). Eine blosse VERWUNDBARE
     * Erweiterung (reason 'vuln') blockiert NICHT - das Update ist ja gerade die
     * Heilung. Liefert den Block-Grund ('' = frei).
     *
     * @param   array       $cfg       Konfiguration (emergency_lock)
     * @param   array|null  $snapshot  Bedrohungs-Schnappschuss (HtpStatus::readSnapshot)
     *
     * @return string  ''|'emergency'|'attack'
     */
    public static function safetyBlock($cfg, $snapshot)
    {
        if (!empty($cfg['emergency_lock'])) {
            return 'emergency';
        }

        if (is_array($snapshot) && !empty($snapshot['threat'])) {
            $reason = isset($snapshot['reason']) ? (string) $snapshot['reason'] : 'attack';

            // 'vuln' ist KEIN Verdacht auf Kompromittierung -> kein Block.
            if ($reason !== 'vuln') {
                return 'attack';
            }
        }

        return '';
    }

    /**
     * Eligibility einer einzelnen Erweiterung (rein). Bewertet einen vorbereiteten
     * Info-Datensatz gegen Ausschluss-/Aussetz-/Quellen-Regeln. Liefert einen der
     * Status:
     *   'self'        - HTProtect selbst (Selbst-Update = Schritt 2, hier nie)
     *   'unsupported' - Typ ohne sauberes Datei-Layout (file/core)
     *   'off'         - nicht-angreifbar + pro Erweiterung NICHT ausgewaehlt (Opt-in aus)
     *   'suspended'   - nach wiederholten Fehlschlaegen ausgesetzt (Flatter-Schutz)
     *   'no_site'     - keine (aktive) Update-Quelle registriert
     *   'host_changed'- Update-Host hat sich geaendert -> gesperrt (melden)
     *   'key'         - Download/Install-No-Op -> transienter Backoff, wird wiederholt
     *   'key_manual'  - nach wiederholten No-Ops endgueltig aufgegeben -> manuell erforderlich
     *   'ready'       - aktualisierbar (sofern ein Update vorliegt)
     *
     * @param   array  $info  Erwartet: type,element,folder,site_url,site_enabled,
     *                        excluded,suspended,known_host
     *
     * @return string
     */
    public static function eligibility($info)
    {
        $type    = isset($info['type']) ? strtolower((string) $info['type']) : '';
        $element = isset($info['element']) ? strtolower((string) $info['element']) : '';

        // Security-Ausnahme fuer die HALTE-Gates (Major-Sprung, radar-'held'): sie greift nur, wenn das
        // angebotene Update die Luecke WIRKLICH schliesst ('vuln_fix_reachable'). Rueckfall auf
        // 'vulnerable', falls ein Aufrufer das Feld nicht liefert (Alt-/Minimal-Info, Tests) -> dann
        // gilt exakt das bisherige Verhalten (keine Regression).
        $secOverride = array_key_exists('vuln_fix_reachable', $info)
            ? !empty($info['vuln_fix_reachable'])
            : !empty($info['vulnerable']);

        // HTProtect selbst (com_htprotect) wird NORMAL bewertet (Quellen-Sicherheit:
        // HTTPS/Host-Wechsel gelten auch fuers Selbst-Update). Es wird nur ueber das
        // 'self'-Flag des Info-Datensatzes aus dem normalen Pick herausgehalten und
        // ZULETZT (eigener, data/-sicherer Pfad) aktualisiert - s. selfUpdateCandidate.

        // Joomla-Kern (kommt ohnehin nicht ueber #__updates-Erweiterungen, aber
        // defensiv): nie hier.
        if ($element === 'joomla' || $element === 'files_joomla' || $element === 'pkg_joomla') {
            return 'unsupported';
        }

        if (!HtpUpdateBackup::supportedType($type)) {
            return 'unsupported';
        }

        // "Nie" (autoupdate_never): bewusster Opt-out UNTERHALB des Sicherheits-Bodens. Steht BEWUSST VOR
        // dem Verwundbar-Bypass - eine "Nie"-Erweiterung wird gar nicht automatisch aktualisiert, auch kein
        // Sicherheitsupdate. Sie wird aber weiter als verwundbar GEMELDET (Ampel/Liste) und vom WAF
        // geschuetzt; nur der Auto-Install unterbleibt (der Operator uebernimmt bewusst die manuelle
        // Verantwortung). Ueberstimmt auch die Grace-/Major-/Malscan-Gates - es laeuft schlicht nichts.
        // AUSNAHME (Verteidigung): HTProtect selbst ist NIE "Nie" - sein Selbst-Update (auch Sicherheits-/
        // Fix-Releases) laeuft immer ueber den eigenen, entkoppelten Pfad. collectInfos setzt never fuer
        // com_htprotect ohnehin nicht mehr; dieser Guard haelt die Invariante auch bei fremd gesetztem Flag.
        if (!empty($info['never']) && $element !== 'com_htprotect') {
            return 'never';
        }

        // Angreifbare Erweiterungen laufen IMMER (Sicherheits-Priuoritaet). Alle
        // anderen nur, wenn pro Erweiterung AUSGEWAEHLT (Opt-in, Default aus).
        if (empty($info['vulnerable']) && empty($info['included'])) {
            return 'off';
        }

        // Zentrale Sperrliste (Rueckruf): das ANGEBOTENE Update wurde als hoch-
        // problematisch markiert -> NIE installieren. Ueberstimmt bewusst auch die
        // Sicherheits-Prioritaet (ein kompromittiertes Security-Update zu installieren
        // ist schlimmer als die offene Luecke; radar ruft kein legitimes Update zurueck).
        // Rein praeventiv: greift NICHT in die laufende Seite ein (kein Rollback).
        if (!empty($info['recall_avail'])) {
            return 'recalled';
        }

        // Grosser Versionssprung (SemVer-Major, z. B. 3.x -> 4.x) bei aktiviertem Schalter
        // zurueckhalten - NUR fuer NICHT-Sicherheitsupdates. Solche Updates enthalten oft
        // grundlegende Aenderungen (geaenderte Mindest-Joomla/PHP-Version, DB-Migrationen,
        // API-/Template-Brueche); sie bleiben sichtbar/installierbar (Ampel + Liste), werden
        // aber nicht automatisch installiert. Ein Lueckenschluss (vulnerable) hat Vorrang
        // und laeuft weiter (Variante A) - ABER nur, wenn das angebotene Update die Luecke
        // wirklich schliesst ($secOverride). Ein feed-verzoegertes Major-Update UNTERHALB der
        // Fix-Version wird nicht erzwungen: es braechte kein Sicherheitsplus, waere aber das
        // riskanteste Update (Breaking-Changes/DB-Migration). Rein praeventiv, kein Eingriff.
        if (!empty($info['major_hold']) && !$secOverride) {
            return 'major_hold';
        }

        // Vorsorge-Sperre: das Update schlug gerade bei mehreren Seiten im Malware-Scan
        // an -> radar haelt es vorsorglich STILL zurueck, bis es geprueft/freigegeben ist.
        // Nur fuer NICHT-Sicherheitsupdates (ein Lueckenschluss hat Vorrang). Transient:
        // sobald die Sperre faellt, laeuft das Update beim naechsten Lauf normal weiter
        // (kein State, keine Mail - der Sinn ist Ruhe statt kollektiver Panik). Auch hier
        // ueberstimmt die Sicherheit nur, wenn das Update die Luecke wirklich schliesst
        // ($secOverride) - ein radar-'held' Update ohne Fix-Wirkung bleibt zurueckgehalten.
        if (!empty($info['held']) && !$secOverride) {
            return 'held';
        }

        if (!empty($info['suspended'])) {
            return 'suspended';
        }

        // Malware-Block (Inhalts-Pruefung): versions-bewusst. 'cooldown' = wartet auf
        // den naechsten Versuch (ggf. mit korrigierter Signatur), 'final' = nach
        // hartnaeckigen Blocks endgueltig ausgesetzt. Neue Version ('stale') -> frei.
        $malGate = self::malBlockGate(
            isset($info['mal_block']) ? $info['mal_block'] : null,
            isset($info['available']) ? $info['available'] : '',
            time()
        );

        if ($malGate === 'final') {
            return 'mal_blocked';
        }

        if ($malGate === 'cooldown') {
            return 'mal_cooldown';
        }

        $siteUrl = isset($info['site_url']) ? (string) $info['site_url'] : '';

        if ($siteUrl === '' || empty($info['site_enabled'])) {
            return 'no_site';
        }

        // Hinweis: HTTP-Quellen werden bewusst wie jede andere Update-Quelle behandelt
        // (Paritaet mit Joomlas eigenem Updater, der HTTP-Update-Sites ebenfalls nicht
        // blockt). Kein Sonder-Skip, keine "kein SSL"-Anzeige. Der Host-Wechsel-Schutz
        // (unten) und die Inhalts-/Malware-Pruefung nach dem Install bleiben aktiv.

        $known   = isset($info['known_host']) ? (string) $info['known_host'] : '';
        $current = self::hostOf($siteUrl);

        if (self::sourceChanged($known, $current)) {
            return 'host_changed';
        }

        // Letztes Gate: nach einem fehlgeschlagenen Download/Install laeuft ein kurzer
        // Cooldown, damit der Tick nicht jeden Lauf dieselbe (nicht ladbare) Erweiterung
        // waehlt. NUR zeigen, solange ueberhaupt ein Update offen ist - sonst zeigte eine
        // inzwischen aktuelle Seite faelschlich weiter diesen Status (bis zum Ablauf des
        // Cooldowns). Sobald aktuell -> faellt durch auf 'ready' -> "Aktuell" (gruen).
        // Endgueltig aufgegeben -> 'manuell erforderlich' (Terminal). Vorrang vor dem
        // transienten key-Cooldown: haelt das Ziel dauerhaft aus der Auto-Auswahl, bis eine
        // NEUE Version angeboten wird (re-armt via key_manual-Gating) oder der Nutzer manuell
        // aktualisiert. Die Vuln-Warnung (Ampel + Erinnerung) laeuft davon unberuehrt weiter.
        if (!empty($info['key_manual']) && !empty($info['has_update'])) {
            return 'key_manual';
        }

        if (!empty($info['key_cooldown']) && !empty($info['has_update'])) {
            return 'key';
        }

        return 'ready';
    }

    /**
     * Aus einer Liste bewerteter Infos genau EINE aktualisierbare Erweiterung
     * waehlen (eine pro Lauf): der erste 'ready'-Eintrag, der ein verfuegbares
     * Update hat. Rein.
     *
     * @param   array  $infos  Liste der Info-Datensaetze (mit 'status','has_update')
     *
     * @return array|null  gewaehlter Datensatz oder null
     */
    public static function pickCandidate($infos, $vulnOnly = false)
    {
        if (!is_array($infos)) {
            return null;
        }

        foreach ($infos as $info) {
            // HTProtect selbst wird hier ausgelassen (eigener, data/-sicherer Pfad,
            // ZULETZT im Lauf - s. selfUpdateCandidate).
            if (!empty($info['self'])) {
                continue;
            }

            // Schalter 1 ohne Schalter 2: NUR angreifbare Erweiterungen aktualisieren.
            if ($vulnOnly && empty($info['vulnerable'])) {
                continue;
            }

            if (isset($info['status']) && $info['status'] === 'ready' && !empty($info['has_update'])) {
                return $info;
            }
        }

        return null;
    }

    /**
     * Den HTProtect-Selbst-Update-Kandidaten waehlen (rein): der com_htprotect-Info
     * mit Status 'ready' und verfuegbarem Update. Wird nur aufgerufen, wenn KEIN
     * Drittanbieter-Update mehr ansteht (Selbst-Update zuletzt, plan 7).
     *
     * @param   array  $infos  Info-Datensaetze
     *
     * @return array|null
     */
    public static function selfUpdateCandidate($infos)
    {
        if (!is_array($infos)) {
            return null;
        }

        foreach ($infos as $info) {
            if (!empty($info['self']) && isset($info['status'])
                && $info['status'] === 'ready' && !empty($info['has_update'])) {
                return $info;
            }
        }

        return null;
    }

    /**
     * Soll HTProtect sich SELBST sofort aktuell halten? = der HTProtect-Selbst-Schalter
     * (autoupdate_self) ist an - BEWUSST ENTKOPPELT vom Sicherheitsupdate-Master
     * (autoupdate_ext). Der Selbst-Update laeuft also auch, wenn die Fremd-Auto-Updates
     * AUS sind (der Nutzer will vielleicht nur seine EIGENE Schutz-Software immer aktuell
     * halten, ohne dass HTProtect an Dritt-Erweiterungen ruehrt). Dann laeuft der
     * Selbst-Update bei JEDEM 15-Min-Tick, unabhaengig von Zeitplan/Karenz/Schalter-2
     * (wie ein Sicherheitsupdate). Rein/testbar.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return boolean
     */
    public static function selfImmediate($cfg)
    {
        return is_array($cfg) && !empty($cfg['autoupdate_self']);
    }

    /**
     * 1-Klick (Uebersicht): HTProtect selbst automatisch aktuell halten. Setzt NUR den
     * Selbst-Schalter (= sofort/alle 15 Min, ohne Karenz). Der Sicherheitsupdate-Master
     * bleibt BEWUSST unberuehrt: der Selbst-Update ist von ihm entkoppelt, sonst wuerde
     * dieser 1-Klick ungewollt auch die Fremd-Auto-Updates mitschalten. Speichert und
     * legt/aktiviert die Scheduler-Task-Zeile an (>= J5.4, best effort). Aendert KEINE
     * anderen Auto-Update-Optionen (Master/Zeitplan/Karenz/Auswahl bleiben).
     *
     * @param   array  $cfg  Konfiguration (Referenz)
     *
     * @return  boolean  true, wenn danach der Selbst-Sofort-Modus aktiv ist
     */
    public static function enableSelfImmediate(&$cfg)
    {
        if (!is_array($cfg)) {
            return false;
        }

        $cfg['autoupdate_self'] = 1; // HTProtect-Selbst-Sofort-Pfad (entkoppelt vom Master)

        HtpConfig::save($cfg);
        self::syncTaskPlugin($cfg); // Scheduler-Task anlegen/aktivieren (>= J5.4)

        return self::selfImmediate($cfg);
    }

    /**
     * Gegenstueck zu enableSelfImmediate: den HTProtect-Selbst-Sofort-Modus wieder
     * abschalten. Laesst den Master-Schalter (Sicherheitsupdates anderer Erweiterungen)
     * bewusst UNBERUEHRT - es wird nur autoupdate_self zurueckgenommen.
     *
     * @param   array  $cfg  Konfiguration (Referenz)
     *
     * @return  boolean  true, wenn der Selbst-Sofort-Modus danach AUS ist
     */
    public static function disableSelfImmediate(&$cfg)
    {
        if (!is_array($cfg)) {
            return false;
        }

        $cfg['autoupdate_self'] = 0;

        HtpConfig::save($cfg);
        self::syncTaskPlugin($cfg); // Scheduler-Task an neuen Stand anpassen

        return !self::selfImmediate($cfg);
    }

    /**
     * Rollback-Entscheid (rein): muss zurueckgerollt werden?
     *
     * @param   boolean  $installOk  Hat die Installation sauber durchgelaufen?
     * @param   boolean  $probeOk    War die Health-Probe danach gruen?
     *
     * @return boolean
     */
    public static function rollbackNeeded($installOk, $probeOk)
    {
        return !$installOk || !$probeOk;
    }

    /**
     * Install-Ergebnis bewerten (rein, plan-Korrektheits-Pflicht). UpdateModel::
     * update() wirft bei Fehlschlag KEINE Exception, sondern setzt nur einen
     * Zustand -> Erfolg/Misserfolg verlaesslich aus mehreren Signalen ableiten:
     *   - 'success' : Joomla meldete Erfolg ODER das Ziel-Update ist aus #__updates
     *                 verschwunden (schon angewandt / Race) ODER die Zielversion ist
     *                 erreicht - UND die Version ist tatsaechlich gestiegen/am Ziel -
     *                 UND die Health-Probe ist gruen.
     *   - 'key'     : Joomla meldete KEINEN Erfolg, Version unveraendert, Update noch
     *                 offen, Seite gesund -> Dateien faktisch unveraendert
     *                 (Download-Key fehlt/abgelaufen) -> uebersprungen, KEIN Fehlschlag.
     *   - 'fail'    : alles andere (result false mit Aenderung / Version nicht
     *                 gestiegen mit roter Probe / Health rot) -> Rollback + Flatter.
     *
     * @param   boolean  $result        $model->getState('result') (true = Joomla-Erfolg)
     * @param   boolean  $gone          Ziel-Update nach dem Lauf NICHT mehr in #__updates?
     * @param   string   $fromVer       Version vor dem Update (oder '')
     * @param   string   $toVer         Erwartete Zielversion
     * @param   string   $diskAfter     Tatsaechliche On-Disk-Version nach dem Update
     * @param   string   $healthReason  Health-Delta-Grund ('' = gruen/nicht messbar)
     *
     * @return string  'success'|'key'|'fail'
     */
    public static function installOutcome($result, $gone, $fromVer, $toVer, $diskAfter, $healthReason)
    {
        $healthBad = is_string($healthReason) && $healthReason !== '';
        $diskAfter = (string) $diskAfter;
        $fromVer   = (string) $fromVer;
        $toVer     = (string) $toVer;

        $rose    = ($diskAfter !== '' && $fromVer !== '' && version_compare($diskAfter, $fromVer, '>'));
        $reached = ($diskAfter !== '' && $toVer !== '' && version_compare($diskAfter, $toVer, '>='));
        $positive = ($result === true) || (bool) $gone || $reached;

        // Erfolg: positives Signal UND Version wirklich gestiegen/am Ziel UND gesund.
        if (!$healthBad && ($rose || $reached) && $positive) {
            return 'success';
        }

        // Reines Key-/Quellenproblem: nichts angewandt, Update noch offen, Seite ok.
        if (!$healthBad && !$rose && !$reached && $result !== true && !$gone) {
            return 'key';
        }

        return 'fail';
    }

    /**
     * On-Disk-Version nach dem Install ROBUST bestaetigen. Direkt nach installUpdate kann der
     * Manifest-Read die frische Version knapp verpassen: Joomla meldet bereits 'gone'/'result'
     * (Update angewandt), die Komponenten-.xml liest aber im selben Request kurz noch die ALTE
     * Version - ein Stat-Cache-/Datei-Settle-Rennen (bei Self-Update zusaetzlich der Ouroboros:
     * der laufende Prozess haelt den alten Code, liest die neue .xml). Ergebnis war ein falsches
     * "Zielversion nicht erreicht" -> unnoetiger Rollback + Retry erst beim naechsten Tick
     * (beobachtet: backhausmahl.de 2.6.15->2.6.16, Seite die ganze Zeit gesund).
     *
     * Fix: den Read mit clearstatcache in einer kurzen Schleife wiederholen, bis die Zielversion
     * bestaetigt ist ODER das kleine Zeitbudget aus ist. KEIN Masking: ein ECHTER Fehlschlag
     * (Datei nie ersetzt) erreicht das Ziel nie -> Schleife laeuft aus, liefert die alte Version,
     * der Rollback greift unveraendert. Der Normalfall (Version sofort sichtbar) kehrt beim ERSTEN
     * Read zurueck und schlaeft nie -> keine Nebenwirkung auf gesunde Updates.
     *
     * @param   string   $type      Erweiterungstyp
     * @param   string   $element   Element
     * @param   string   $folder    Plugin-Ordner ('' sonst)
     * @param   int      $clientId  0 Site / 1 Admin
     * @param   string   $target    Zielversion, die bestaetigt werden soll
     *
     * @return string  Bestaetigte On-Disk-Version (>= target, sobald sichtbar; sonst letzter Read)
     */
    private static function confirmOnDiskVersion($type, $element, $folder, $clientId, $target)
    {
        if (!class_exists('HtpDefs')) {
            return '';
        }

        $target = (string) $target;
        $ver    = '';

        // Bis zu 5 Versuche ueber ~0,8 s: schnell (0 Sleep) bei Erfolg, kurz bei echtem Fehlschlag.
        for ($i = 0; $i < 5; $i++) {
            clearstatcache(true);
            $ver = (string) HtpDefs::onDiskVersion($type, $element, $folder, (int) $clientId);

            if ($ver !== '' && $target !== '' && version_compare($ver, $target, '>=')) {
                return $ver; // Zielversion bestaetigt -> fertig, kein Settle noetig
            }

            if ($i < 4 && function_exists('usleep')) {
                usleep(200000); // 200 ms Settle (nur im seltenen Noch-nicht-sichtbar-Fall)
            }
        }

        return $ver; // Ziel nie erreicht -> letzter Read; echter Fehlschlag bleibt Fehlschlag
    }

    /**
     * Flatter-Schutz (rein): nach $fails Fehlschlaegen aussetzen?
     *
     * @param   int  $fails  Bisherige Fehlschlaege
     * @param   int  $max    Schwelle (Default MAX_FAILS)
     *
     * @return boolean
     */
    public static function shouldSuspend($fails, $max = self::MAX_FAILS)
    {
        return (int) $fails >= max(1, (int) $max);
    }

    /**
     * Wachsender Cooldown fuer wiederholte Key-/Quellen-Fehlversuche (rein, testbar). Der
     * Grundtakt (1h verwundbar / 3 Tage sonst) verdoppelt sich je Versuch, gedeckelt bei
     * KEY_BACKOFF_CAP - so haemmert ein dauerhaft nicht ladbares Ziel nicht mehr stuendlich.
     * Der Deckel liegt nie UNTER dem Grundtakt (nicht-verwundbare bleiben langsam).
     *
     * @param   int      $n           Bisherige Fehlversuche (>=1)
     * @param   boolean  $vulnerable  Sicherheits-Ziel?
     *
     * @return int  Cooldown in Sekunden
     */
    public static function keyBackoffSecs($n, $vulnerable)
    {
        $base = self::keyCooldownSecs(!empty($vulnerable));
        $cap  = max($base, self::KEY_BACKOFF_CAP);
        $mult = pow(2, max(0, (int) $n - 1));

        return (int) min($base * $mult, $cap);
    }

    /**
     * Endgueltig aufgeben (rein, testbar)? Nach so vielen erfolglosen Key-/Quellen-
     * Versuchen wird das Auto-Update auf 'manuell erforderlich' gestellt. Verwundbare Ziele
     * bekommen mehr Versuche als nicht-verwundbare.
     *
     * @param   int      $n           Bisherige Fehlversuche
     * @param   boolean  $vulnerable  Sicherheits-Ziel?
     *
     * @return boolean  true = aufgeben
     */
    public static function keyGiveUp($n, $vulnerable)
    {
        $max = !empty($vulnerable) ? self::KEY_TRIES_VULN : self::KEY_TRIES_STD;

        return (int) $n >= max(1, (int) $max);
    }

    /**
     * Einen Key-/Quellen-Fehlversuch verbuchen: Zaehler hoch, Backoff-Cooldown setzen,
     * ggf. endgueltig aufgeben ('manuell erforderlich'). Eine NEUE Zielversion setzt den
     * Zaehler frisch (fresh urgency). Loggt genau eine Zeile - einen verdichteten 'skip'
     * (solange weiter versucht wird) ODER die einmalige Terminal-Zeile 'manual' beim
     * Aufgeben. KEINE Mail (bewusst: die Vuln-Warnung erinnert ohnehin; nicht-verwundbare
     * sind nicht mail-wuerdig). Rein zustands-transformierend + testbar.
     *
     * @param   array    $state       Auto-Update-State
     * @param   string   $key         Erweiterungs-Key
     * @param   int      $eid         extension_id
     * @param   array    $t           Ziel (name/from/to)
     * @param   boolean  $vulnerable  Sicherheits-Ziel?
     *
     * @return array  neuer State
     */
    public static function registerKeyFail($state, $key, $eid, $t, $vulnerable)
    {
        $to    = (string) (isset($t['to']) ? $t['to'] : '');
        $from  = (string) (isset($t['from']) ? $t['from'] : '');
        $name  = (string) (isset($t['name']) ? $t['name'] : '');
        $prior = (isset($state['key_fails'][$key]) && is_array($state['key_fails'][$key])) ? $state['key_fails'][$key] : null;

        // Neue Zielversion (oder erstmals) -> Zaehler + Fenster frisch. Sonst weiterzaehlen.
        $same  = $prior !== null && (string) (isset($prior['to']) ? $prior['to'] : '') === $to;
        $n     = ($same ? (int) $prior['n'] : 0) + 1;
        $first = ($same && isset($prior['first'])) ? (int) $prior['first'] : time();
        $give  = self::keyGiveUp($n, $vulnerable);

        $state['key_fails'][$key] = array('n' => $n, 'first' => $first, 'to' => $to, 'done' => $give ? 1 : 0);

        if ($give) {
            // Endgueltig aufgegeben -> EINE neutrale Terminal-Zeile. eligibility haelt das
            // Ziel danach via 'key_manual' aus der Auswahl (kein weiterer Versuch/Log). WICHTIG:
            // KEINEN transienten Cooldown stehen lassen - sonst haengt eine SPAETER angebotene
            // NEUE Version (die key_manual re-armt, weil 'to' abweicht) noch am Rest-Backoff
            // (bis 12h/3 Tage) und wuerde verzoegert statt sofort versucht.
            unset($state['key_cooldown'][$key]);

            return self::logResult($state, $key, $eid, $name, $from, $to, 'manual', 'giveup');
        }

        // Weiter versuchen -> wachsender Backoff-Cooldown bis zum naechsten Tick.
        $state['key_cooldown'][$key] = time() + self::keyBackoffSecs($n, $vulnerable);

        return self::logSkip($state, $key, $eid, $name, $from, $to, 'key');
    }

    /**
     * Malware-Block: was wird aus dem mal_block-Eintrag beim (erneuten) Fund GENAU
     * dieser Version? Versions-bewusst (andere Version -> Zaehler frisch), Cooldown bis
     * zum Retry, Aufgeben nach $maxBlocks. Rein/testbar.
     *
     * @return array ['entry'=>neuerEintrag, 'mail'=>bool (nur Erst-Block+Aufgeben), 'final'=>bool]
     */
    public static function malBlockOnHit($prev, $availableVer, $now, $cooldown, $maxBlocks, $files, $reason)
    {
        $availableVer = (string) $availableVer;
        $sameVer = is_array($prev) && isset($prev['ver']) && (string) $prev['ver'] === $availableVer;
        $count   = ($sameVer && isset($prev['count'])) ? (int) $prev['count'] + 1 : 1;
        $final   = $count >= max(1, (int) $maxBlocks);

        return array(
            'entry' => array(
                'ver'    => $availableVer,
                'count'  => $count,
                'until'  => $final ? 0 : ((int) $now + (int) $cooldown),
                'final'  => $final ? 1 : 0,
                'files'  => array_slice(array_values((array) $files), 0, 6),
                'reason' => (string) $reason,
                'ts'     => (int) $now,
            ),
            'mail'  => ($count === 1 || $final),
            'final' => $final,
        );
    }

    /**
     * Gate fuer einen mal_block-Eintrag gegen die AKTUELL verfuegbare Version:
     * 'none' (kein Block) | 'stale' (andere Version -> Block irrelevant, frische Chance)
     * | 'final' (aufgegeben) | 'cooldown' (warten) | 'retry' (Cooldown abgelaufen). Rein.
     */
    public static function malBlockGate($entry, $availableVer, $now)
    {
        if (!is_array($entry) || empty($entry['ver'])) {
            return 'none';
        }

        if ((string) $entry['ver'] !== (string) $availableVer) {
            return 'stale';
        }

        if (!empty($entry['final'])) {
            return 'final';
        }

        return ((int) $now < (int) $entry['until']) ? 'cooldown' : 'retry';
    }

    /**
     * Ist die Zielversion STABLE (kein alpha/beta/rc/dev/...)? (plan 17.2 -
     * Integritaet billig absichern; Joomlas Updater filtert ueber minimum_stability
     * bereits, das hier ist Defense in Depth gegen falsch deklarierte Pakete.)
     *
     * @param   string  $v  Versionsstring
     *
     * @return boolean
     */
    public static function isStableVersion($v)
    {
        $v = strtolower(trim((string) $v));

        if ($v === '' || !preg_match('~^\d~', $v)) {
            return false;
        }

        // Pre-Release-Marker (Versionsstrings sind ansonsten rein numerisch -> ein
        // Wort-Token kann nur eine Vorab-Kennung sein; daher ueberall im String).
        if (preg_match('~(alpha|beta|rc|dev|preview|snapshot|nightly|unstable|\bpre\b)~', $v)) {
            return false;
        }

        // Angehaengte Pre-Release-Kennung wie 1.2.3a1 / 1.2b2 / 1.2.3rc1.
        if (preg_match('~\d(?:a|b|rc)\d~', $v)) {
            return false;
        }

        return true;
    }

    /**
     * Darf von $from auf $to aktualisiert werden? (rein) Nur HOEHERE, STABLE
     * Version (Anti-Downgrade, plan 17.2). Unbekannte Ist-Version ('') zaehlt als
     * aktualisierbar, solange die Zielversion stable ist.
     *
     * @param   string  $from  Installierte Version (oder '')
     * @param   string  $to    Verfuegbare Version
     *
     * @return boolean
     */
    public static function isUpgrade($from, $to)
    {
        $to = (string) $to;

        if ($to === '' || !self::isStableVersion($to)) {
            return false;
        }

        $from = (string) $from;

        return $from === '' || version_compare($to, $from, '>');
    }

    /**
     * REINE Pruefung (testbar): SCHLIESST die angebotene Version die bekannte Luecke - erreicht sie
     * also mindestens die Fix-Version ('below' aus dem Feed)? FAIL-OPEN im Sinne der Sicherheit:
     * jede Unklarheit (leeres/unbekanntes below, unklare Version) gilt als "Fix erreicht" (true),
     * damit ein echtes Sicherheitsupdate NIE faelschlich zurueckgehalten wird. Nur wenn BEIDE
     * Versionen eindeutig stable sind UND available < below liegt, gilt der Fix als NICHT erreicht.
     * Zweck: die Security-Ausnahme (die einen Major-Sprung oder ein radar-'held'-Update ueberstimmt)
     * darf nur fuer ein Update greifen, das die Luecke wirklich schliesst - nicht fuer ein
     * feed-verzoegertes Teil-Update unterhalb des Fixes.
     *
     * @param   string  $available  Angebotene Version
     * @param   string  $below      Fix-Version (Luecke geschlossen ab dieser Version)
     *
     * @return boolean  true = Fix erreicht (oder unklar -> Sicherheit hat Vorrang)
     */
    public static function fixReachable($available, $below)
    {
        $below = trim((string) $below);

        if ($below === '' || !self::isStableVersion($available) || !self::isStableVersion($below)) {
            return true; // fail-open: im Zweifel als Fix behandeln (Sicherheit nicht schwaechen)
        }

        return version_compare($available, $below, '>=');
    }

    /**
     * REINE Erkennung eines GROSSEN Versionssprungs (SemVer-Major, z. B. 3.9 -> 4.0),
     * testbar. Liefert true NUR, wenn die fuehrende Major-Ganzzahl ZWEIFELSFREI gestiegen
     * ist. FAIL-OPEN: alles Uneindeutige (kein parsbarer fuehrender Zahl-Block, CalVer-
     * Jahre wie 2023->2024, unbekannte/kleinere/gleiche Major) -> false, damit ein normales
     * Minor/Patch-Update NIE faelschlich zurueckgehalten wird. So blockt der Schalter
     * verlaesslich nur die klaren 3.x->4.x-Faelle.
     *
     * @param   string  $from  Installierte Version
     * @param   string  $to    Verfuegbare Version
     *
     * @return boolean  true = klarer Major-Sprung
     */
    public static function isMajorJump($from, $to)
    {
        $a = self::majorOf($from);
        $b = self::majorOf($to);

        if ($a === null || $b === null) {
            return false; // nicht eindeutig -> nicht zurueckhalten
        }

        return $b > $a;
    }

    /**
     * Fuehrende Major-Ganzzahl einer Versionsangabe (rein). null, wenn nicht eindeutig als
     * "normale" SemVer-Major deutbar: kein fuehrender Zahl-Block (Datum wie 20240115,
     * Buchstaben) ODER >= 1000 (CalVer-Jahr - kein Breaking-Change-Signal). Ein optionales
     * fuehrendes 'v' (v3.2.1) wird entfernt.
     *
     * @param   string  $ver
     *
     * @return int|null
     */
    private static function majorOf($ver)
    {
        $ver = trim((string) $ver);

        if ($ver !== '' && ($ver[0] === 'v' || $ver[0] === 'V')) {
            $ver = substr($ver, 1);
        }

        // Fuehrender Zahl-Block, gefolgt von einem Nicht-Ziffer-Zeichen ODER Ende. So faellt
        // ein reines Datum (20240115) ganz durch (nach 4 Ziffern folgt eine Ziffer -> kein
        // Match), waehrend 3, 3.2, 3.2.1, 4.0.0-rc1 sauber die Major liefern.
        if (!preg_match('~^(\d{1,4})(?:\D|$)~', $ver, $m)) {
            return null;
        }

        $major = (int) $m[1];

        // >= 1000 -> mit hoher Wahrscheinlichkeit CalVer (Jahr) -> KEIN SemVer-Major-Signal.
        return ($major >= 1000) ? null : $major;
    }

    /**
     * Health-Delta-Auswertung (rein, plan 17.3): vergleicht die VOR- und NACH-
     * Messung (Front + Back) und liefert den Regressions-Grund ('' = keine
     * Regression). Vergleicht Deltas statt absoluter Schwellen:
     *   - Startseite war erreichbar (2xx/3xx) und antwortet jetzt ERREICHT-aber-schlecht -> 'home_status'
     *   - Startseiten-Ausgabe ist drastisch geschrumpft (< 35 % einer zuvor
     *     substanziellen Seite) -> 'home_shrunk' (White-Screen)
     *   - neuer Fatal/Parse-Error, der vorher nicht da war -> 'home_fatal'
     *   - Backend war erreichbar und antwortet jetzt ERREICHT-aber-schlecht -> 'admin_status'
     *
     * WICHTIG (Fehl-Rollback-Schutz): ein TRANSPORT-Fehler (`status === 0` = Connect-/SSL-/DNS-Fehler
     * oder Timeout, s. HtpProbe::fetch) ist „nicht messbar", NICHT „Update kaputt". Ein PHP-Update
     * (Erweiterung ODER HTProtect selbst) fasst weder SSL noch den Webserver an; ein echter Fatal
     * liefert 500 (HTTP-Antwort DA), keinen Connect-Abbruch. Darum loesen home_status/admin_status
     * nur aus, wenn die Nach-Probe die Seite ERREICHT hat (status !== 0). So rollen SSL-/Verbindungs-
     * Blitzer (z. B. Strato/IONOS) und Timeouts langsamer Seiten kein gesundes Update mehr zurueck;
     * ein wirklich unerreichbarer Bruch faengt der Crash-Fenster-Schutz (Waechter-Plugin) ab.
     *
     * @param   array  $before  ['home_status','home_len','home_fatal','admin_status']
     * @param   array  $after   gleiche Form
     *
     * @return string  Regressionsgrund oder ''
     */
    public static function healthRegressed($before, $after)
    {
        $bh = isset($before['home_status']) ? (int) $before['home_status'] : 0;
        $ah = isset($after['home_status']) ? (int) $after['home_status'] : 0;

        $beforeHomeOk = $bh >= 200 && $bh < 400;
        $afterHomeOk  = $ah >= 200 && $ah < 400;

        // NUR wenn die Nach-Probe die Seite ERREICHT hat (echte HTTP-Antwort). status 0 = Transport-
        // Fehler (Connect/SSL/Timeout) -> ergebnislos, KEIN Rollback (Redirect-Loop ist 599 != 0 und
        // zaehlt weiter als erreicht-aber-schlecht).
        if ($beforeHomeOk && !$afterHomeOk && $ah !== 0) {
            return 'home_status';
        }

        if ($beforeHomeOk && $afterHomeOk) {
            $bl = isset($before['home_len']) ? (int) $before['home_len'] : 0;
            $al = isset($after['home_len']) ? (int) $after['home_len'] : 0;

            if ($bl >= 2048 && $al < (int) floor($bl * 0.35)) {
                return 'home_shrunk';
            }
        }

        if (empty($before['home_fatal']) && !empty($after['home_fatal'])) {
            return 'home_fatal';
        }

        $ba = isset($before['admin_status']) ? (int) $before['admin_status'] : 0;
        $aa = isset($after['admin_status']) ? (int) $after['admin_status'] : 0;

        $beforeAdminOk = ($ba >= 200 && $ba < 400) || $ba === 401;
        $afterAdminOk  = ($aa >= 200 && $aa < 400) || $aa === 401;

        // Wie oben: nur bei ERREICHTEM Backend (status 0 = Transport-Fehler -> ergebnislos, kein Rollback).
        if ($beforeAdminOk && !$afterAdminOk && $aa !== 0) {
            return 'admin_status';
        }

        return '';
    }

    /**
     * BESTAETIGTE Health-Regression per MEHRHEIT. `healthRegressed` bewertet EINE Nach-Probe; direkt
     * nach einem Update (frisch invalidierter Opcache, Dienst waermt auf) kann die ERSTE Antwort
     * transient blitzen (kurzer 5xx/599, langsame Erst-Antwort, halber Body aus kaltem Cache) - das
     * darf ein kerngesundes Update NICHT zurueckrollen. Darum wird eine gemeldete Regression ueber
     * weitere FRISCHE Proben geprueft und per MEHRHEIT entschieden:
     *   - DAUERHAFTER Defekt (weisser Bildschirm/Fatal/500/Redirect-Loop) -> jede Probe schlecht ->
     *     Mehrheit schlecht -> Rollback (zuverlaessig erhalten).
     *   - EINZELNER transienter Aussetzer -> die restlichen (gesunden/nicht-messbaren) Proben ueber-
     *     stimmen ihn -> Minderheit schlecht -> KEIN Rollback.
     *   - FLATTERNDER echter Defekt (~50 % kaputt) -> von der Mehrheit ehrlicher erwischt als von
     *     „schon eine gute Probe hebt alles auf".
     * Transport-Fehler (status 0 = Connect/SSL/Timeout) liefern via healthRegressed '' und zaehlen als
     * NICHT-schlecht -> SSL-/Verbindungs-/Timeout-Blitzer (z. B. Strato/IONOS) und langsame Seiten
     * koennen die Mehrheit nie kippen. Der Re-Probe laeuft NUR im (seltenen) Regressionsfall - der
     * gesunde Normalpfad kostet nichts.
     *
     * @param   array  $cfg         Konfiguration (fuer captureHealth)
     * @param   array  $before      Baseline VOR dem Update
     * @param   array  $after       Erste Probe NACH dem Update (bereits regressiv, sonst nicht aufgerufen)
     * @param   int    $extraTries  Zusaetzliche Bestaetigungs-Proben (Default 2 -> bis zu 3 gesamt)
     *
     * @return string  Regressions-Grund, wenn die MEHRHEIT regressiv blieb; '' sonst
     */
    public static function confirmedRegression($cfg, $before, $after, $extraTries = 2)
    {
        $reason = self::healthRegressed($before, $after);

        if ($reason === '') {
            return ''; // erste Probe gesund/nicht messbar -> kein Rollback, kein Re-Probe noetig
        }

        $total = 1; // die erste, bereits regressive Probe
        $bad   = 1;

        for ($i = 0; $i < (int) $extraTries; $i++) {
            $again = self::captureHealth($cfg);
            $total++;

            // Nicht messbar (kein HTTP-Client/keine URL) -> als NICHT-schlecht werten (fail-open,
            // wie schon die Ausgangs-Logik bei fehlender Probe); regressiv -> mitzaehlen.
            if (is_array($again) && self::healthRegressed($before, $again) !== '') {
                $bad++;
            }
        }

        // Rollback nur, wenn die MEHRHEIT der Proben regressiv blieb (ein einzelner Aussetzer wird
        // ueberstimmt; ein durchgaengiger Defekt bleibt Mehrheit).
        return ($bad * 2 > $total) ? $reason : '';
    }

    /**
     * Ausschluss-Liste neu berechnen (rein): aus dem bisherigen Stand, den im
     * Formular angezeigten Schluesseln und den aktuell angehakten ("Auto-Update
     * AN"). Schluessel, die im Formular standen aber NICHT angehakt waren, kommen
     * in den Ausschluss; angehakte werden entfernt. Schluessel, die gar nicht
     * angezeigt wurden, bleiben unveraendert (Liste schrumpft nicht versehentlich).
     *
     * @param   array  $prev      Bisherige Ausschluss-Schluessel
     * @param   array  $rendered  Im Formular angezeigte Schluessel
     * @param   array  $checked   Angehakte Schluessel (Auto-Update AN)
     *
     * @return array  neue, deduplizierte Ausschluss-Liste
     */
    public static function mergeIncludes($prev, $rendered, $checked)
    {
        $prev      = is_array($prev) ? $prev : array();
        $checkMap  = array();
        $renderMap = array();

        foreach ((is_array($checked) ? $checked : array()) as $k) {
            $checkMap[(string) $k] = true;
        }

        foreach ((is_array($rendered) ? $rendered : array()) as $k) {
            $renderMap[(string) $k] = true;
        }

        $set = array();

        // Bisherige Auswahl beibehalten - AUSSER sie war angezeigt und jetzt NICHT
        // angehakt (= bewusst abgewaehlt). Nicht angezeigte Auswahlen bleiben erhalten.
        foreach ($prev as $k) {
            $k = (string) $k;

            if (isset($renderMap[$k]) && !isset($checkMap[$k])) {
                continue; // abgewaehlt
            }

            $set[$k] = true;
        }

        // Neu angehakte aufnehmen (nur was tatsaechlich angezeigt war).
        foreach ($checkMap as $k => $unused) {
            if (isset($renderMap[$k])) {
                $set[$k] = true; // angehakt -> Auto-Update AN (eingeschlossen)
            }
        }

        return array_keys($set);
    }

    /**
     * Einen Log-Eintrag vorne anfuegen und die Historie kappen (rein).
     *
     * @param   array  $history  Bisherige Historie
     * @param   array  $entry    Neuer Eintrag
     * @param   int    $cap      Maximalanzahl
     *
     * @return array
     */
    public static function pushHistory($history, $entry, $cap = self::HISTORY_CAP)
    {
        $history = is_array($history) ? $history : array();

        // Identische, unmittelbar aufeinanderfolgende SKIPs (gleiche Erweiterung, gleiche
        // Version, gleicher Grund) NICHT stapeln, sondern den neuesten Eintrag verdichten:
        // Zeitstempel auf jetzt, Zaehler 'n' hoch. Verhindert, dass ein dauerhaft
        // uebersprungenes Ziel (nicht ladbare Quelle) die 60er-Historie flutet und echte
        // Eintraege (erfolgreiche Updates) verdraengt. Nur 'skip' wird verdichtet - jeder
        // Erfolg/Rollback/Terminal-Zustand bleibt eine eigene Zeile.
        if (isset($entry['result']) && $entry['result'] === 'skip'
            && !empty($history) && is_array($history[0])
            && isset($history[0]['result']) && $history[0]['result'] === 'skip'
            && self::sameSkip($history[0], $entry)) {
            $history[0]['t'] = isset($entry['t']) ? $entry['t'] : (isset($history[0]['t']) ? $history[0]['t'] : time());
            $history[0]['n'] = (isset($history[0]['n']) ? (int) $history[0]['n'] : 1) + 1;

            return array_slice($history, 0, max(1, (int) $cap));
        }

        array_unshift($history, $entry);

        return array_slice($history, 0, max(1, (int) $cap));
    }

    /**
     * Einen fertigen Eintrag ins persistente Aktivitaetsprotokoll anhaengen - fuer autonome
     * Aktionen AUSSERHALB der Update-State-Machine (z. B. HtpCoreHotfix). Laedt/speichert den
     * Autoupdate-State ueber dieselben Bausteine wie der Scheduler. Leichte Entdopplung: ein
     * identischer juengster Eintrag (gleicher key/result/to) wird nicht erneut gestapelt - so
     * kann ein zweiter, gleichzeitiger Tick keine Doppel-Zeile erzeugen. Fail-open (still).
     *
     * @param   array  $entry  Historien-Eintrag (t,key,eid,name,from,to,result,note)
     *
     * @return boolean  true, wenn geschrieben
     */
    public static function appendHistory($entry)
    {
        if (!is_array($entry)) {
            return false;
        }

        $dir = function_exists('htp_state_dir') ? htp_state_dir() : '';

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

        try {
            $file  = rtrim(str_replace('\\', '/', $dir), '/') . '/' . self::STATE_FILE;
            $state = self::loadState($file);
            $hist  = isset($state['history']) && is_array($state['history']) ? $state['history'] : array();

            // Entdopplung gegen den juengsten Eintrag (key+result+to identisch).
            if (!empty($hist) && is_array($hist[0])
                && isset($hist[0]['key'], $entry['key']) && $hist[0]['key'] === $entry['key']
                && isset($hist[0]['result'], $entry['result']) && $hist[0]['result'] === $entry['result']
                && isset($hist[0]['to'], $entry['to']) && $hist[0]['to'] === $entry['to']) {
                return false;
            }

            $state['history'] = self::pushHistory($hist, $entry);
            self::saveState($file, $state);

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

    /**
     * Sind zwei Skip-Eintraege "derselbe" (fuer die Verdichtung)? Gleiche Erweiterung
     * (key), gleicher Versionssprung (from/to) und gleicher Grund (note). Rein.
     *
     * @param   array  $a  Eintrag A
     * @param   array  $b  Eintrag B
     *
     * @return boolean
     */
    private static function sameSkip($a, $b)
    {
        foreach (array('key', 'from', 'to', 'note') as $f) {
            if ((string) (isset($a[$f]) ? $a[$f] : '') !== (string) (isset($b[$f]) ? $b[$f] : '')) {
                return false;
            }
        }

        return true;
    }

    // ═════════════════════════════════════════════════════════════════
    //  Side-Effects: Gate-Wrapper
    // ═════════════════════════════════════════════════════════════════

    /**
     * Soll der Menuepunkt/das Feature erscheinen? (Joomla >= 5.4)
     *
     * @return boolean
     */
    public static function menuEligible()
    {
        return function_exists('htp_jversion') && self::versionGateOk(htp_jversion());
    }

    /**
     * Darf die AUSFUEHRUNGS-Engine (State-Machine + Install) auf dieser Joomla-Version
     * laufen? = der 5.4+-Pfad (menuEligible) ODER der separate Joomla-3-Legacy-Pfad
     * (ausgelagert in HtpAutoUpdateLegacy). Bewusst GETRENNT von menuEligible(): die
     * 5.4+-UI-Sichtbarkeit und die Task-Scheduler-Provisionierung (syncTaskPlugin) bleiben
     * exakt wie bisher (nur >= 5.4) - J3 bekommt die Funktion headless, ohne com_scheduler.
     * J4-<5.4 bleibt gesperrt (weder 5.4+ noch J3) -> dort erscheint der Upgrade-Hinweis.
     * Defensiv per class_exists: fehlt die Legacy-Datei, verhaelt sich alles wie zuvor (5.4+).
     *
     * @return boolean
     */
    public static function execEligible()
    {
        if (self::menuEligible()) {
            return true; // 5.4+ (unveraendert)
        }

        return class_exists('HtpAutoUpdateLegacy') && HtpAutoUpdateLegacy::isJ3();
    }

    /**
     * Joomla 4.0 bis < 5.4: Auto-Update ist hier NICHT verfuegbar (kein com_scheduler wie ab
     * 5.4; der J3-Legacy-Pfad greift nur auf J3). Fuer diese Spanne EMPFIEHLT die UI ein Upgrade
     * auf Joomla 5.4+, damit das Feature nutzbar wird. Rein informativ (Hinweis auf der
     * Backups-Seite) - kein Eingriff. J2.5 und J3 (Legacy-Pfad) sowie 5.4+ sind ausgenommen.
     *
     * @return boolean
     */
    public static function upgradeRecommended($jversion = null)
    {
        if ($jversion === null) {
            $jversion = function_exists('htp_jversion') ? htp_jversion() : '';
        }

        $jversion = (string) $jversion;

        if ($jversion === '' || !preg_match('~^\d+\.\d+~', $jversion)) {
            return false;
        }

        return version_compare($jversion, '4.0', '>=') && version_compare($jversion, self::MIN_JVERSION, '<');
    }

    /**
     * Ist das Auto-Update-Feature sichtbar/aktiv? FALSE = dormant (in Arbeit). Gated
     * die UI (Anzeige), die Scheduler-Aktivierung (syncTaskPlugin) und die Ausfuehrung
     * (runStep). Code bleibt erhalten - zentral ueber FEATURE_VISIBLE umschaltbar.
     *
     * @return boolean
     */
    public static function featureVisible()
    {
        return self::FEATURE_VISIBLE;
    }

    /**
     * Konfiguriertes Intervall (Sekunden), gegen Unfug abgesichert.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return int
     */
    public static function interval($cfg)
    {
        $iv = isset($cfg['autoupdate_interval']) ? (int) $cfg['autoupdate_interval'] : self::DEFAULT_INTERVAL;

        return max(3600, $iv);
    }

    // ═════════════════════════════════════════════════════════════════
    //  Side-Effects: Hintergrund-Tick (eine Zeile in htprotectwatch.php)
    // ═════════════════════════════════════════════════════════════════

    // ═════════════════════════════════════════════════════════════════
    //  SCHEDULER-STATE-MACHINE (J4.1+ com_scheduler) - resumebar, timeout-fest.
    //  Reiner Kern (jobNextPhase/jobCrashAction) ist OHNE Joomla testbar.
    // ═════════════════════════════════════════════════════════════════

    /** Nach so vielen Fehlversuchen pro Ziel: als "needs-manual" parken (kein Endlos-Retry). */
    const JOB_ATTEMPT_CAP = 2;

    /** Zeitreserve am Ende eines Laufs (s), Fallback-Budget (s). */
    const STEP_RESERVE  = 6;
    const STEP_FALLBACK = 30;

    /**
     * REINE Phasen-Uebergangslogik (testbar, kein Joomla). Bestimmt aus aktueller
     * Phase + Phasen-Ergebnis die naechste Phase / den naechsten Ziel-Index.
     * Phasen: PLAN -> BACKUP -> UPDATE -> VERIFY -> (FINALIZE | ROLLBACK).
     *   BACKUP-Ergebnis: 'ok' (-> UPDATE) | 'skip' (Backup/Downgrade -> naechstes Ziel)
     *   VERIFY-Ergebnis: 'success' (-> FINALIZE) | 'fail' (-> ROLLBACK, STOPP)
     *                    | 'key'|'skip' (-> naechstes Ziel)
     *   FINALIZE        -> naechstes Ziel
     *   ROLLBACK        -> Job STOPP (nicht mit Folgezielen weiter; sicherste Wahl)
     *
     * @param   array   $job     mind. ['phase','idx','targets']
     * @param   string  $result  Ergebnis der aktuellen Phase
     *
     * @return array  ['phase'=>string, 'idx'=>int, 'done'=>bool, 'stopped'=>bool]
     */
    public static function jobNextPhase($job, $result)
    {
        $phase = isset($job['phase']) ? (string) $job['phase'] : '';
        $idx   = isset($job['idx']) ? (int) $job['idx'] : 0;
        $count = isset($job['targets']) && is_array($job['targets']) ? count($job['targets']) : 0;

        $next = ($idx + 1) < $count
            ? array('phase' => 'BACKUP', 'idx' => $idx + 1, 'done' => false, 'stopped' => false)
            : array('phase' => '', 'idx' => $idx + 1, 'done' => true, 'stopped' => false);

        switch ($phase) {
            case 'PLAN':
                return $count > 0
                    ? array('phase' => 'BACKUP', 'idx' => 0, 'done' => false, 'stopped' => false)
                    : array('phase' => '', 'idx' => 0, 'done' => true, 'stopped' => false);

            case 'BACKUP':
                return $result === 'ok'
                    ? array('phase' => 'UPDATE', 'idx' => $idx, 'done' => false, 'stopped' => false)
                    : $next;

            case 'UPDATE':
                // 'skip' (Ziel VOR dem Install verworfen - z. B. eine bei frisch
                // aufgeloester update_id eingewechselte gesperrte/Downgrade-Version) ->
                // naechstes Ziel; sonst normal weiter zu VERIFY.
                return $result === 'skip'
                    ? $next
                    : array('phase' => 'VERIFY', 'idx' => $idx, 'done' => false, 'stopped' => false);

            case 'VERIFY':
                if ($result === 'success') {
                    return array('phase' => 'FINALIZE', 'idx' => $idx, 'done' => false, 'stopped' => false);
                }

                if ($result === 'fail') {
                    return array('phase' => 'ROLLBACK', 'idx' => $idx, 'done' => false, 'stopped' => false);
                }

                return $next; // 'key' | 'skip'

            case 'FINALIZE':
                return $next;

            case 'ROLLBACK':
                // Nach Rollback Job bewusst STOPPEN (nicht mit Folgezielen weiter).
                return array('phase' => '', 'idx' => $idx, 'done' => true, 'stopped' => true);
        }

        return array('phase' => '', 'idx' => $idx, 'done' => true, 'stopped' => false);
    }

    /**
     * REINE Crash-Recovery-Entscheidung (testbar). Liegt beim Lauf-Start ein
     * In-Progress-Marker (eine Phase wurde begonnen, aber nicht sauber beendet) +
     * der Lock ist frei -> der vorige Lauf ist abgestuerzt. UPDATE-Marker = IMMER
     * Absturz (Datei-/DB-Stand evtl. halb) -> ROLLBACK. BACKUP-Marker bei Watchdog
     * -> Teil-Backup verwerfen. Sonst keine Aktion.
     *
     * @param   array|null  $marker    ['phase','started','attempt'] oder leer
     * @param   int         $now       Jetzt (Unix)
     * @param   int         $watchdog  Sekunden bis "tot"
     *
     * @return string  'rollback' | 'discard' | 'none'
     */
    public static function jobCrashAction($marker, $now, $watchdog)
    {
        if (!is_array($marker) || empty($marker['phase'])) {
            return 'none';
        }

        $phase   = (string) $marker['phase'];
        $started = isset($marker['started']) ? (int) $marker['started'] : 0;
        $stale   = ((int) $now - $started) > (int) $watchdog;

        if ($phase === 'UPDATE') {
            return 'rollback'; // Install evtl. halb durch -> sicher zurueckrollen
        }

        if ($phase === 'BACKUP' && $stale) {
            return 'discard'; // Teil-Backup verwerfen, Ziel erneut/parken
        }

        return $stale ? 'discard' : 'none';
    }

    /**
     * REINE Stall-Zaehl-Logik (testbar): die Nach-Install-Phasen VERIFY/FINALIZE tragen
     * keinen Crash-Marker. Wirft eine davon WIEDERHOLT (Health-Probe/Malware-Scan), bliebe
     * die Phase sonst unbegrenzt haengen (Queue blockiert, Rollback nie erreicht). Dieser
     * durable Zaehler (vor der riskanten Phase gespeichert -> ueberlebt einen Throw) begrenzt
     * die Versuche. Wechselt die Phase, faengt der Zaehler frisch an.
     *
     * @param   array|null  $stall  Vorheriger Zaehler ['phase','n'] oder leer
     * @param   string      $phase  Aktuelle Phase
     * @param   int         $cap    JOB_ATTEMPT_CAP
     *
     * @return array  ['phase'=>string, 'n'=>int, 'giveUp'=>bool]
     */
    public static function jobStallNext($stall, $phase, $cap)
    {
        $stall = is_array($stall) ? $stall : array('phase' => '', 'n' => 0);
        $prev  = (isset($stall['phase']) && $stall['phase'] === $phase) ? (int) $stall['n'] : 0;
        $n     = $prev + 1;

        return array('phase' => $phase, 'n' => $n, 'giveUp' => ($n > (int) $cap));
    }

    /**
     * REINE Cooldown-Wahl (testbar): verwundbare (Sicherheits-)Ziele bekommen nach einem
     * 'key'-Fehlschlag (transienter Download-/Quellen-Fehler) nur den kurzen Cooldown, damit
     * der Sicherheits-Patch nicht 3 Tage still liegen bleibt; alles andere den langen.
     *
     * @param   bool  $vulnerable  Ist das Ziel als verwundbar (Sicherheitsupdate) markiert?
     *
     * @return int  Cooldown in Sekunden
     */
    public static function keyCooldownSecs($vulnerable)
    {
        return !empty($vulnerable) ? self::KEY_COOLDOWN_VULN : self::KEY_COOLDOWN;
    }

    /**
     * Soft-Zeitbudget pro Lauf (Sekunden bis Yield) aus max_execution_time minus
     * Reserve; Fallback 30s. 0 (unbegrenzt, CLI) wird auf ein grosszuegiges Budget
     * gedeckelt, damit ein Lauf trotzdem regelmaessig yieldet.
     *
     * @return int
     */
    public static function stepBudget()
    {
        $max = (int) @ini_get('max_execution_time');

        if ($max <= 0) {
            $max = 120; // CLI/0 = unbegrenzt -> trotzdem regelmaessig yielden
        }

        $budget = $max - self::STEP_RESERVE;

        if ($budget < 10) {
            $budget = ($max >= self::STEP_FALLBACK) ? self::STEP_FALLBACK - self::STEP_RESERVE : max(8, $max - 2);
        }

        return $budget;
    }

    /**
     * @deprecated ABGELOEST durch das Scheduler-Modell (runStep). Der alte FPM-Detach-
     * Ausfuehrungspfad wurde entfernt; der Waechter ruft tick() nicht mehr. Bleibt nur
     * als fail-open-Bruecke fuer evtl. Altaufrufe (z. B. ein veralteter, noch im opcache
     * liegender Waechter) und fuehrt jetzt EINEN Scheduler-Schritt aus.
     *
     * @param   array  $cfg  Konfiguration (ignoriert)
     *
     * @return void
     */
    public static function tick($cfg)
    {
        try {
            self::runStep('legacy');
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still (PHP 7+)
        }
    }

    /**
     * Alle faelligen Update-Kandidaten geordnet (ANGREIFBARE zuerst, dann der Rest)
     * - rein/testbar. Im 'instant'/Plan-Modus wird der Rest mitgenommen, sonst nur
     * angreifbare ($vulnOnly).
     *
     * @param   array    $infos     Info-Datensaetze (collectInfos)
     * @param   boolean  $vulnOnly  nur angreifbare
     *
     * @return array  Liste der Kandidaten-Infos
     */
    public static function pickAllCandidates($infos, $vulnOnly)
    {
        $out = array();

        if (!is_array($infos)) {
            return $out;
        }

        foreach (array(true, false) as $vulnPass) {
            foreach ($infos as $info) {
                if (!empty($info['self'])) {
                    continue; // HTProtect selbst: eigener data/-sicherer Pfad
                }

                if ($vulnOnly && empty($info['vulnerable'])) {
                    continue;
                }

                if ((bool) $vulnPass !== !empty($info['vulnerable'])) {
                    continue; // erst angreifbare, dann der Rest
                }

                if (isset($info['status']) && $info['status'] === 'ready' && !empty($info['has_update'])) {
                    $out[] = $info;
                }
            }
        }

        return $out;
    }

    /**
     * Karenz-Zeit (Soak): Ist die Erstsichtung eines Updates lange genug her, dass es
     * automatisch installiert werden darf? 0 Tage = sofort. Rein/testbar.
     *
     * @param   int  $firstSeen  Unix-Zeitpunkt der Erstsichtung (key@version)
     * @param   int  $now        Jetzt (Unix)
     * @param   int  $graceDays  Karenz in Tagen
     *
     * @return boolean
     */
    public static function graceElapsed($firstSeen, $now, $graceDays)
    {
        $graceDays = (int) $graceDays;

        if ($graceDays <= 0) {
            return true;
        }

        return ((int) $now - (int) $firstSeen) >= $graceDays * 86400;
    }

    /**
     * First-seen-Karte fuer die Karenz pflegen: aktuell ausstehende Schluessel
     * (key@version) behalten ihren Erstsichtungs-Zeitpunkt (oder bekommen jetzt einen);
     * transient verschwundene (Finder-Aussetzer) bleiben erhalten, solange sie juenger
     * als $keepAge sind - so setzt ein flatterhafter Update-Server den Soak-Timer nicht
     * faelschlich zurueck. Rein/testbar.
     *
     * @param   array  $prev         Bisherige Karte (key => ts)
     * @param   array  $pendingKeys  Aktuell ausstehende Schluessel (key => true)
     * @param   int    $now          Jetzt (Unix)
     * @param   int    $keepAge      Aufbewahrung verschwundener Eintraege (Sekunden)
     *
     * @return array  Neue Karte
     */
    public static function mergeSeen($prev, $pendingKeys, $now, $keepAge)
    {
        $prev = is_array($prev) ? $prev : array();
        $now  = (int) $now;
        $seen = array();

        foreach ((array) $pendingKeys as $k => $one) {
            $seen[$k] = isset($prev[$k]) ? (int) $prev[$k] : $now;
        }

        foreach ($prev as $k => $ts) {
            if (!isset($seen[$k]) && ($now - (int) $ts) < (int) $keepAge) {
                $seen[$k] = (int) $ts; // transient weg -> behalten (Re-Soak vermeiden)
            }
        }

        return $seen;
    }

    /**
     * SCHEDULER-EINSTIEG: ein Schritt der State-Machine. Vom Task-Plugin
     * (plg_task_htpautoupdate) aufgerufen. Fuehrt pro Lauf so viel wie sicher ins
     * Zeitbudget passt, persistiert den Job, gibt 0 (=OK) zurueck. KEIN FPM noetig.
     *
     * @param   string  $context  ignoriert (Plugin-Routine-Id wird dort geprueft)
     *
     * @return int  0 = OK
     */
    public static function runStep($context = '')
    {
        try {
            // Versions-Selbstheilung HEADLESS: laeuft bei JEDEM Task-Lauf (Scheduler + Cron,
            // alle 15 Min) - so wird ein nach einem Self-Update steckengebliebener Versions-
            // stand (Komponente/Task-Plugin) korrigiert, OHNE dass jemand das Backend oeffnen
            // muss. Billig (Versions-Vergleich; schreibt nur bei echter Abweichung), fail-open.
            if (class_exists('HtpApp')) {
                HtpApp::selfHealVersions();
                // Schutzschild-Regelwerk headless aktuell halten (ohne Backend-Login),
                // sobald HTProtect es verbessert hat. Selbsttest + Auto-Rollback; schreibt
                // nur bei verfuegbarem HTTP-Probe. Billig (nur bei echter Aenderung), fail-open.
                HtpApp::selfHealShield();
            }

            // Bekannte Joomla-KERN-Regressionen headless vorab beheben (byte-genau, hash-getort,
            // reversibel; siehe HtpCoreHotfix). Billig: billiges Versions-Gate zuerst - nur die
            // exakt betroffenen Versionen (aktuell 5.4.8/6.1.3) tun ueberhaupt etwas, und auch
            // dort nur EINMAL. Faengt auch den Fall "betroffene Version erst spaeter installiert".
            if (class_exists('HtpCoreHotfix')) {
                HtpCoreHotfix::tick();
            }

            // Fernverwaltungs-Herzschlag (Cron-Garantie: feuert auch auf Zero-Traffic-Sites
            // ueber Joomlas Scheduler/Cron). KOMPLETT INERT, wenn nicht gekoppelt - das
            // billige Config-Gate in HtpLink::tick() kehrt fuer Einzelnutzer sofort zurueck.
            if (class_exists('HtpLink')) {
                HtpLink::tick();
            }

            // JoomShaper-J3-Luecken (Helix Ultimate/Helix3) headless schliessen - GEKOPPELT an den
            // Sicherheits-Auto-Update-Master (autoupdate_ext), self-gedrosselt, nur Joomla 3.10,
            // absolut fail-safe. Inert auf J4+/ohne verwundbares Helix. BEWUSST NACH dem Heartbeat,
            // damit ein (seltener) Deploy-Lauf den Herzschlag dieses Ticks nie verzoegert/verschluckt.
            if (class_exists('HtpJoomShaperPatch')) {
                HtpJoomShaperPatch::autoTick();
            }

            self::runStepInner();
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open
        }

        return 0;
    }

    /**
     * Gezielter Einzel-Install GENAU EINER Erweiterung (Remote-Befehl update.run {key}). Nutzt die
     * volle phasige Sicherheits-Engine (Backup -> Update -> Verify -> Auto-Rollback) - nur mit einem
     * einzelnen, erzwungenen Ziel. Ruft runStepInner DIREKT mit dem forceKey (kein erneuter
     * HtpLink::tick, keine Selbstheilung); harte Sperren + das Sicherheits-Gate bleiben respektiert.
     *
     * @param   string  $key  ext_key = type|element|folder (lowercase)
     *
     * @return array{ok:bool, code:string}
     */
    public static function runOne($key)
    {
        $key = is_string($key) ? strtolower(trim($key)) : '';

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

        // ROBUST (remote-Einzel-Install): NICHT blind "ok" melden, sondern die TATSAECHLICHE
        // On-Disk-Version vorher/nachher vergleichen und einen Schluessel-/Quellen-Fehlschlag
        // erkennen. So sieht das Dashboard ehrlich, ob das Update wirklich griff - und WARUM
        // nicht (z. B. fehlender Download-Schluessel bei Akeeba/Admin Tools). Alles fail-open:
        // schlaegt eine Messung fehl, faellt es aufs bisherige "laeuft (evtl. noch)" zurueck.
        $before   = self::installedVersionByKey($key);
        $kfBefore = self::keyFailCount($key);

        try {
            self::runStepInner($key);
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open
        }

        $after = self::installedVersionByKey($key);

        // Erfolg: Version ist wirklich gestiegen.
        if ($before !== '' && $after !== '' && version_compare($after, $before, '>')) {
            return array('ok' => true, 'code' => 'update_one:' . $key . ':' . $after);
        }

        // Keine Aenderung + NEUER Key-/Quellen-Fehlschlag diesen Lauf -> ehrlicher Fehler mit
        // spezifischem Grund (Download-Schluessel/Quelle). installOutcome() liefert 'key', wenn
        // nichts angewandt wurde, das Update offen blieb und die Seite gesund ist.
        if (self::keyFailCount($key) > $kfBefore) {
            return array('ok' => false, 'code' => 'update_one_key:' . $key);
        }

        // Sonst: der Einzel-Install ist ein RESUMIERBARER Job (mehrere Ticks) - noch nicht fertig
        // ist KEIN Fehler. Neutral melden; das Dashboard pollt das Inventar bis has_update faellt.
        return array('ok' => true, 'code' => 'update_one_pending:' . $key);
    }

    /**
     * Installierte Version einer Erweiterung anhand ihres ext_key (type|element|folder) aus
     * #__extensions.manifest_cache lesen. Client-agnostisch (eine Zeile pro Erweiterung),
     * fail-safe (''). Fuer die Vorher/Nachher-Erfolgspruefung im remote-Einzel-Install.
     *
     * @param   string  $key  ext_key (type|element|folder)
     *
     * @return string  Version oder ''
     */
    private static function installedVersionByKey($key)
    {
        $parts   = explode('|', (string) $key);
        $type    = isset($parts[0]) ? $parts[0] : '';
        $element = isset($parts[1]) ? $parts[1] : '';
        $folder  = isset($parts[2]) ? $parts[2] : '';

        if ($type === '' || $element === '' || !function_exists('htp_db')) {
            return '';
        }

        $db = htp_db();

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

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName('manifest_cache'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote($type))
                ->where($db->quoteName('element') . ' = ' . $db->quote($element));

            if ($folder !== '') {
                $q->where($db->quoteName('folder') . ' = ' . $db->quote($folder));
            }

            $db->setQuery($q, 0, 1);
            $mc = (string) $db->loadResult();

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

            $m = json_decode($mc, true);

            return (is_array($m) && isset($m['version'])) ? (string) $m['version'] : '';
        } catch (Exception $e) {
            return '';
        } catch (Throwable $e) {
            return '';
        }
    }

    /**
     * Aktueller Key-/Quellen-Fehlschlag-Zaehler dieser Erweiterung aus dem Auto-Update-State.
     * Steigt er ueber einen runOne-Lauf, hat der Connector gerade ein Download-/Schluessel-
     * Problem verbucht (registerKeyFail). fail-safe (0).
     *
     * @param   string  $key  ext_key
     *
     * @return int
     */
    private static function keyFailCount($key)
    {
        $dir = function_exists('htp_state_dir') ? htp_state_dir() : '';

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

        $file = rtrim(str_replace('\\', '/', $dir), '/') . '/' . self::STATE_FILE;
        $st   = self::loadState($file);

        return (isset($st['key_fails'][$key]['n'])) ? (int) $st['key_fails'][$key]['n'] : 0;
    }

    /**
     * Geheimer Trigger-Schluessel (einmalig erzeugt + im State gespeichert). Autorisiert
     * den serverseitigen com_ajax-Self-Trigger des Waechters - so kann niemand sonst von
     * aussen Installs/DoS ausloesen.
     *
     * @return string  32 Hex-Zeichen, '' bei Fehler
     */
    public static function triggerKey()
    {
        $dir = function_exists('htp_state_dir') ? htp_state_dir() : '';

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

        $file  = rtrim(str_replace('\\', '/', $dir), '/') . '/' . self::STATE_FILE;
        $state = self::loadState($file);

        if (empty($state['trigger_key']) || !is_string($state['trigger_key'])
            || !preg_match('~^[a-f0-9]{32}$~', $state['trigger_key'])) {
            $rnd = null;

            try {
                if (function_exists('random_bytes')) {
                    $rnd = random_bytes(16);
                }
            } catch (Exception $e) {
                $rnd = null;
            } catch (Throwable $e) {
                $rnd = null;
            }

            if ($rnd === null) {
                $rnd = md5(uniqid((string) mt_rand(), true), true);
            }

            $state['trigger_key'] = bin2hex($rnd);
            self::saveState($file, $state);
        }

        return (string) $state['trigger_key'];
    }

    /**
     * Vertrauenswuerdige Basis-URL fuer den Self-Trigger - HOST-POISONING-FEST:
     * bevorzugt die admin-gesetzte live_site, dann die gespeicherte unsub_base (beide
     * NICHT request-abgeleitet, also nicht ueber einen manipulierten Host-Header
     * vergiftbar). NUR wenn KEINE vertrauenswuerdige Basis existiert, faellt es auf
     * das bisherige (request-abgeleitete) htp_site_url() zurueck - damit der Trigger
     * auf Seiten ohne live_site/Speicher weiterhin zuverlaessig laeuft (kein Regress).
     *
     * @return string  Basis-URL mit abschliessendem '/', '' wenn gar keine ermittelbar
     */
    public static function triggerBase()
    {
        $stored = '';

        if (class_exists('HtpConfig')) {
            $cfg    = HtpConfig::load();
            $stored = isset($cfg['unsub_base']) ? (string) $cfg['unsub_base'] : '';
        }

        // NUR eine vertrauenswuerdige Basis verwenden (Admin-live_site ODER die im Backend
        // erfasste unsub_base, via htp_trusted_base($stored, strict=true)). KEIN Rueckfall
        // auf htp_site_url() = die request-abgeleitete (Host-Header-)URL: sonst koennte ein
        // vergifteter Host-Header den serverseitigen Self-POST auf ein Angreifer-Ziel lenken
        // (latente blinde SSRF). Ohne vertrauenswuerdige Basis '' -> der Aufrufer bricht ab
        // (htprotectwatch: kein http(s):// -> return) und Cron/FFR-Inline uebernehmen.
        return function_exists('htp_trusted_base') ? (string) htp_trusted_base($stored, true) : '';
    }

    /**
     * Kurzlebiges Self-Trigger-Token fuer GENAU dieses Zeitfenster. Wird vom Waechter
     * im POST-Body mitgeschickt (NICHT im URL-Query -> landet in keinem Access-Log).
     * Abgeleitet per HMAC aus dem langlebigen trigger_key, der selbst nie uebertragen
     * wird. Rein/testbar ueber $now.
     *
     * @param   int|null  $now  Zeitbasis (Default time())
     *
     * @return string  base64url-Token, '' bei fehlendem Schluessel
     */
    public static function triggerToken($now = null)
    {
        $key = self::triggerKey();

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

        $now = ($now === null) ? time() : (int) $now;

        return self::triggerTokenForWindow($key, intdiv($now, self::TRIGGER_STEP));
    }

    /**
     * Steht eine Warnung an, dass der interne HTTPS-Self-Trigger das eigene Zertifikat
     * nicht verifizieren konnte (Marker vom Waechter gesetzt)? Fuer den Backend-Hinweis.
     *
     * @return boolean
     */
    public static function triggerTlsWarning()
    {
        $dir = function_exists('htp_state_dir') ? htp_state_dir() : '';

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

        return is_file(rtrim(str_replace('\\', '/', $dir), '/') . '/htprotect_trigger_tls.warn');
    }

    /**
     * Steht eine Warnung an, dass der HTTP-Self-Trigger die eigene Seite nicht erreicht
     * (Loopback/Hairpinning blockiert)? Dann laeuft Auto-Update nur ueber einen echten
     * Cronjob - der Admin soll das wissen.
     *
     * @return boolean
     */
    public static function triggerReachWarning()
    {
        $dir = function_exists('htp_state_dir') ? htp_state_dir() : '';

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

        return is_file(rtrim(str_replace('\\', '/', $dir), '/') . '/htprotect_trigger_reach.warn');
    }

    /** HMAC-Token eines konkreten Zeitfensters (base64url). Rein. */
    private static function triggerTokenForWindow($key, $window)
    {
        $mac = hash_hmac('sha256', self::TRIGGER_CTX . '|' . (int) $window, (string) $key, true);

        return rtrim(strtr(base64_encode($mac), '+/', '-_'), '=');
    }

    /**
     * Trigger-Token pruefen: gueltig fuer das aktuelle ODER das vorherige Fenster
     * (deckt den Minutengrenzfall + die wenige Sekunden Laufzeit ab). Konstante Zeit
     * (beide Fenster werden immer berechnet, kein frueher Abbruch). Rein/testbar.
     *
     * @param   string    $token  uebergebenes Token
     * @param   int|null  $now    Zeitbasis (Default time())
     *
     * @return boolean
     */
    public static function verifyTriggerToken($token, $now = null)
    {
        if (!is_string($token) || $token === '') {
            return false;
        }

        $key = self::triggerKey();

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

        $now = ($now === null) ? time() : (int) $now;
        $win = intdiv($now, self::TRIGGER_STEP);
        $ok  = false;

        foreach (array($win, $win - 1) as $w) {
            if (hash_equals(self::triggerTokenForWindow($key, $w), $token)) {
                $ok = true;
            }
        }

        return $ok;
    }

    /**
     * com_ajax-Einstieg: vom serverseitigen Waechter-Self-Trigger aufgerufen. Laeuft im
     * FRISCHEN Request VOR der Response (voller App-Kontext) - anders als onAfterRespond,
     * wo der Joomla-Installer einer Admin-Komponente nicht sauber durchlaeuft.
     * ignore_user_abort haelt den Install am Leben, falls der Aufrufer (kurzer Timeout)
     * abbricht. Token-geschuetzt (kurzlebiges HMAC, hash_equals).
     *
     * @param   string  $token  uebergebenes Trigger-Token (kurzlebig, aus dem POST-Body)
     *
     * @return void
     */
    /**
     * INLINE-Lauf (FPM): der Waechter ruft das direkt im selben, voll gebooteten
     * Request auf - NACH fastcgi_finish_request() (Besucher schon bedient). Kein
     * Netzwerk, kein oeffentlicher Endpunkt, kein Token noetig (in-process, vertraut).
     * Bewiesen: Joomlas Installer laeuft in diesem Post-Response/FFR-Kontext sauber.
     *
     * @return void
     */
    public static function runInline()
    {
        try {
            if (!self::execEligible() || !self::featureVisible()) {
                return;
            }

            if (function_exists('ignore_user_abort')) {
                @ignore_user_abort(true);
            }

            @set_time_limit(0);
            self::runStep('inline');
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open
        }
    }

    public static function handleTrigger($token)
    {
        try {
            if (!self::execEligible() || !self::featureVisible()) {
                return;
            }

            if (!self::verifyTriggerToken($token)) {
                return; // nicht autorisiert (ungueltiges/abgelaufenes Token)
            }

            if (function_exists('ignore_user_abort')) {
                @ignore_user_abort(true);
            }

            @set_time_limit(0);

            self::runStep('trigger');
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open
        }
    }

    /**
     * NUR-BENACHRICHTIGEN-Modus: faellig? -> ausstehende Updates suchen und (bei
     * Aenderung der Liste oder periodisch erneut) per Mail melden. Installiert NICHTS.
     * Schedule-Faelligkeit + last_run/sched_done laufen exakt wie beim Auto-Update.
     *
     * @param   array  $cfg    Konfiguration
     * @param   array  $state  Laufzeit-State (per Referenz)
     * @param   int    $now    Jetzt (Unix)
     *
     * @return void
     */
    private static function runNotify($cfg, &$state, $now)
    {
        // Eigene, schonende Kadenz (alle 6 h) - bewusst NICHT der Auto-Update-Zeitplan
        // und OHNE last_run/sched_done anzufassen, damit der Benachrichtigungs-Check
        // unabhaengig neben der Auto-Update-State-Machine laeuft.
        $checked = isset($state['notify_checked']) ? (int) $state['notify_checked'] : 0;

        if (($now - $checked) < self::NOTIFY_CHECK) {
            return; // noch nicht faellig
        }

        $state['notify_checked'] = $now;

        // Updates sanft suchen (kein purge -> #__updates wird nie geleert).
        self::joomlaFindUpdatesGentle();

        // DER REST: ausstehende Updates, die Auto-Update NICHT automatisch installiert.
        // Sicherheitsupdates (bei Auto-Update an) + ausgewaehlte (im "alle"-Modus) laufen
        // ueber den Auto-Update-Pfad und werden dort gemeldet - hier nur "dein Teil".
        // Auto-Update ganz aus -> nichts ist abgedeckt -> alle Ausstehenden landen hier.
        $extOn = !empty($cfg['autoupdate_ext']);
        $allOn = !empty($cfg['autoupdate_all']);

        $pending = array();

        foreach (self::collectInfos($cfg, $state) as $info) {
            if (empty($info['has_update']) || !empty($info['self'])) {
                continue;
            }

            // Zentral gesperrtes Update NICHT zum manuellen Install vorschlagen - es
            // ist als hochproblematisch markiert. Der Rueckruf-Hinweis (runRecall)
            // meldet getrennt, falls die installierte Version betroffen ist.
            if (!empty($info['recall_avail'])) {
                continue;
            }

            // Vorsorglich (still) zurueckgestelltes Update ebenfalls nicht vorschlagen -
            // die Pruefung laeuft; es soll bewusst KEINE Unruhe stiften.
            if (!empty($info['held']) && empty($info['vulnerable'])) {
                continue;
            }

            if ($extOn && !empty($info['vulnerable'])) {
                continue; // Sicherheitsupdate - laeuft automatisch
            }

            if ($extOn && $allOn && !empty($info['included'])) {
                continue; // ausgewaehlt im "alle"-Modus - laeuft automatisch
            }

            $pending[] = $info;
        }

        if (empty($pending)) {
            $state['notify_sig'] = ''; // nichts offen -> naechstes Update meldet wieder
            return;
        }

        // Signatur der aktuellen Menge (Element @ Zielversion).
        $keys = array();

        foreach ($pending as $p) {
            $keys[] = strtolower((string) $p['element']) . '@' . (string) $p['available'];
        }

        sort($keys);
        $sig = implode(',', $keys);

        $lastSig  = isset($state['notify_sig']) ? (string) $state['notify_sig'] : '';
        $lastMail = isset($state['notify_mailed']) ? (int) $state['notify_mailed'] : 0;

        // Mailen bei NEUER/geaenderter Liste oder wenn die Erinnerung faellig ist.
        if ($sig !== $lastSig || ($now - $lastMail) >= self::NOTIFY_REMIND) {
            self::mailPending($cfg, $pending);
            $state['notify_sig']    = $sig;
            $state['notify_mailed'] = $now;
        }
    }

    /**
     * Mail mit der Liste der NICHT automatisch installierten ausstehenden Extension-
     * Updates ("der Rest"; Sicherheitsupdates dort markiert, falls Auto-Update aus ist).
     * Eine Mail je Lauf, ueber die uebliche sendNotify-Strecke.
     *
     * @param   array  $cfg      Konfiguration
     * @param   array  $pending  Liste der Infos mit has_update
     *
     * @return void
     */
    private static function mailPending($cfg, $pending)
    {
        $lines = array(htp_t('COM_HTPROTECT_AU_NOTIFY_INTRO'), '');

        foreach ($pending as $p) {
            $from    = ((string) $p['installed'] !== '') ? (string) $p['installed'] : '?';
            $sec     = !empty($p['vulnerable']) ? '  [' . htp_t('COM_HTPROTECT_AU_NOTIFY_SEC') . ']' : '';
            $lines[] = sprintf('- %s: %s &rarr; %s%s', (string) $p['name'], $from, (string) $p['available'], $sec);
        }

        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_AU_NOTIFY_FOOT');

        // Deeplink zur Joomla-Update-Seite als Button. Vertrauenswuerdige Basis
        // (host-poisoning-fest); nur wenn vorhanden.
        $base = function_exists('htp_trusted_base')
            ? htp_trusted_base(isset($cfg['unsub_base']) ? $cfg['unsub_base'] : '', true) : '';

        $actions = array();

        if ($base !== '' && $base !== '/') {
            $actions[] = array('label' => htp_t('COM_HTPROTECT_AU_MAIL_BTN_UPDATES'), 'url' => rtrim($base, '/') . '/administrator/index.php?option=com_installer&view=update', 'variant' => 'primary');
        }

        self::sendNotify($cfg, 'COM_HTPROTECT_AU_NOTIFY_SUBJECT', $lines, array('actions' => $actions));
    }

    /**
     * RUECKRUF-HINWEIS (nur melden, kein Eingriff): ist eine bereits INSTALLIERTE
     * Version zentral als hochproblematisch gesperrt (recall_inst), wird der Admin
     * EINMAL je Element@Version informiert (mit Grund + Backend-Link zum manuellen
     * Zurueckrollen/Pruefen). HTProtect rollt NICHTS automatisch oder ferngesteuert
     * zurueck - die Entscheidung bleibt beim Admin. Eigene 6-h-Kadenz, fasst die
     * Auto-Update-State-Machine NICHT an. Anti-Spam: pro Lage 1 Mail, Erinnerung 14 Tage.
     *
     * @param   array  $cfg    Konfiguration
     * @param   array  $state  Laufzeit-State (per Referenz)
     * @param   int    $now    Jetzt (Unix)
     *
     * @return void
     */
    private static function runRecall($cfg, &$state, $now)
    {
        $checked = isset($state['recall_checked']) ? (int) $state['recall_checked'] : 0;

        if (($now - $checked) < self::RECALL_CHECK) {
            return; // noch nicht faellig
        }

        $state['recall_checked'] = $now;

        $notified = isset($state['recall_notified']) && is_array($state['recall_notified']) ? $state['recall_notified'] : array();
        $hits     = array();
        $seenKeys = array();

        foreach (self::collectInfos($cfg, $state) as $info) {
            if (empty($info['recall_inst']) || !empty($info['self'])) {
                continue;
            }

            $el  = strtolower((string) $info['element']);
            $ver = (string) $info['installed'];
            $sk  = $el . '@' . $ver;
            $seenKeys[$sk] = true;
            $hits[$sk]     = $info;
        }

        // Aufgeloeste Lagen aus der Merk-Liste entfernen (Version wurde gewechselt/
        // weg-aktualisiert) -> bei erneutem Auftreten meldet es wieder.
        foreach (array_keys($notified) as $sk) {
            if (!isset($seenKeys[$sk])) {
                unset($notified[$sk]);
            }
        }

        if (empty($hits)) {
            $state['recall_notified'] = $notified;
            return;
        }

        // Nur die noch nicht (oder lange nicht) gemeldeten Lagen mailen.
        $toMail = array();

        foreach ($hits as $sk => $info) {
            $last = isset($notified[$sk]) ? (int) $notified[$sk] : 0;

            if ($last === 0 || ($now - $last) >= self::RECALL_REMIND) {
                $toMail[]      = $info;
                $notified[$sk] = $now;
            }
        }

        $state['recall_notified'] = $notified;

        if (!empty($toMail)) {
            self::mailRecall($cfg, $toMail);
        }
    }

    /**
     * Mail: bereits installierte, zentral gesperrte Versionen - mit Grund + Hinweis,
     * dass der Admin selbst entscheidet (manuell pruefen/zurueckrollen). Eine Mail
     * je Lauf ueber die uebliche sendNotify-Strecke.
     *
     * @param   array  $cfg   Konfiguration
     * @param   array  $hits  Infos mit recall_inst
     *
     * @return void
     */
    private static function mailRecall($cfg, $hits)
    {
        $en      = (strncasecmp((string) self::notifyLangTag($cfg), 'en', 2) === 0);
        $lines   = array(htp_t('COM_HTPROTECT_AU_RECALL_INTRO'), '');

        foreach ($hits as $info) {
            $name = (string) $info['name'];
            $ver  = ((string) $info['installed'] !== '') ? (string) $info['installed'] : '?';
            $rc   = is_array($info['recall_inst']) ? $info['recall_inst'] : array();
            $why  = self::recallReason($rc, $en);
            $tail = ($why !== '') ? ' - ' . $why : '';
            $lines[] = sprintf('- %s: %s%s', $name, $ver, $tail);
        }

        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_AU_RECALL_FOOT');

        $base = function_exists('htp_trusted_base')
            ? htp_trusted_base(isset($cfg['unsub_base']) ? $cfg['unsub_base'] : '', true) : '';

        $actions = array();

        if ($base !== '' && $base !== '/') {
            $actions[] = array('label' => htp_t('COM_HTPROTECT_AU_MAIL_BTN_MANAGE'), 'url' => rtrim($base, '/') . '/administrator/index.php?option=com_installer&view=manage', 'variant' => 'ghost');
        }

        self::sendNotify($cfg, 'COM_HTPROTECT_AU_RECALL_SUBJECT', $lines, array('actions' => $actions));
    }

    /**
     * Den lokalisierten Rueckruf-Grund aus einem Eintrag waehlen (rein). Bevorzugt
     * die Sprache der Mail; faellt sonst auf das jeweils andere Feld zurueck.
     *
     * @param   array    $rc  Rueckruf-Eintrag (reason/reason_en)
     * @param   boolean  $en  Mail auf Englisch?
     *
     * @return string
     */
    private static function recallReason($rc, $en)
    {
        $de = isset($rc['reason']) ? trim((string) $rc['reason']) : '';
        $eng = isset($rc['reason_en']) ? trim((string) $rc['reason_en']) : '';

        if ($en) {
            return $eng !== '' ? $eng : $de;
        }

        return $de !== '' ? $de : $eng;
    }

    private static function runStepInner($forceKey = '')
    {
        if (!self::execEligible()) {
            return; // gated: weder 5.4+ (menuEligible) noch Joomla-3-Legacy -> J4-<5.4 gesperrt
        }

        // featureVisible-Gate (Feature dormant/in Arbeit) NUR fuer den regulaeren Lauf; ein
        // gezielter, fleet-ausgeloester Einzel-Install (update.run {key}) laeuft unabhaengig davon.
        if (!self::featureVisible() && $forceKey === '') {
            return;
        }

        $cfg = HtpConfig::load();

        if (empty($cfg['autoupdate_ext']) && empty($cfg['autoupdate_notify'])
            && empty($cfg['autoupdate_self']) && $forceKey === '' && !self::isManaged($cfg)) {
            return; // Feature ganz aus (weder Fremd-Auto-Update noch Nur-Benachrichtigen noch Selbst-Update)
            // GEMANAGT: NICHT hier abbrechen - der Sicherheits-Boden (unten) muss offene Luecken pruefen
            // und ggf. schliessen, auch wenn alle lokalen Schalter aus sind.
        }

        // Sprache im Scheduler-/CLI-Kontext laden, BEVOR irgendein Mail-Helfer seine
        // Textzeilen mit htp_t() baut. Im CLI ist die aktive Sprache en-GB; die Mail
        // soll aber der Backend-/Standard-Sprache folgen -> gezielt laden (s.
        // loadNotifyLang). Sonst kaemen rohe Schluessel ODER die falsche Sprache.
        self::loadNotifyLang($cfg);

        $dir = function_exists('htp_state_dir') ? htp_state_dir() : '';

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

        $base      = rtrim(str_replace('\\', '/', $dir), '/');
        $stateFile = $base . '/' . self::STATE_FILE;
        $lockFile  = $base . '/' . self::LOCK_FILE;
        $now       = time();

        // §7-Notfall-Wiederherstellungs-Breadcrumb verbuchen (unabhaengig vom Job).
        self::processHealed($base, $stateFile, $cfg);

        // Re-Entrancy-Lock (kein Doppellauf). Veraltete Sperre nach LOCK_STALE frei.
        $fp = @fopen($lockFile, 'x');

        if ($fp === false) {
            if (is_file($lockFile) && ($now - (int) @filemtime($lockFile)) > self::LOCK_STALE) {
                @unlink($lockFile);
            }

            return; // anderer Lauf aktiv (oder gerade stale-released)
        }

        @fwrite($fp, (string) $now);
        @fclose($fp);

        try {
            $state = self::loadState($stateFile);

            // ERGAENZUNG (kein Entweder-oder): ueber ausstehende Updates mailen, die
            // Auto-Update NICHT automatisch installiert ("der Rest"). Eigene 6-h-Kadenz,
            // fasst last_run/sched_done NICHT an -> laeuft NEBEN dem Auto-Update.
            if (!empty($cfg['autoupdate_notify'])) {
                self::runNotify($cfg, $state, $now);
            }

            // Rueckruf-Hinweis (nur melden, kein Eingriff): ist eine bereits installierte
            // Version zentral gesperrt, den Admin informieren. Eigene 6-h-Kadenz; laeuft
            // auch im reinen Benachrichtigungs-Modus (Master-Schalter aus).
            self::runRecall($cfg, $state, $now);

            // SICHERHEITS-BODEN fuer GEMANAGTE Sites: eine offene Luecke wird automatisch geschlossen,
            // auch wenn der lokale Sicherheitsupdate-Master (autoupdate_ext) AUS ist - die Verwaltung
            // liegt beim Betreiber im Dashboard. Nur ANGREIFBARE laufen ueber den Boden (execPlan
            // erzwingt vulnOnly, siehe unten); Nicht-Sicherheits-Auto bleibt aus (keine Hintertuer).
            // Bei Master-aus ist der Boden die EINZIGE Sicherung -> crit_pending haeufiger neu bewerten
            // (SECFLOOR_RECHECK statt CRIT_RECHECK), damit eine frische Luecke nicht bis zu 1 h wartet.
            $managed  = self::isManaged($cfg);
            $secFloor = false;

            if ($managed && empty($cfg['autoupdate_ext']) && $forceKey === '') {
                if (($now - (isset($state['crit_checked']) ? (int) $state['crit_checked'] : 0)) >= self::SECFLOOR_RECHECK) {
                    $state['crit_pending'] = self::criticalPending($cfg);
                    $state['crit_checked'] = $now;
                }

                $secFloor = !empty($state['crit_pending']);
            }

            // Kein Fremd-Auto-Update (Master aus) UND kein Selbst-Update UND kein Einzel-Install ->
            // hier Schluss (nur die Benachrichtigung war relevant). AUSNAHME: gemanagte Site mit offener
            // Luecke (Sicherheits-Boden) -> WEITER in die Job-Maschine, um die Luecke zu schliessen.
            // Der HTProtect-SELBST-Update ist ohnehin vom Master entkoppelt (eigener Schalter).
            // WICHTIG: NUR fruehzeitig zurueckkehren, wenn KEIN Job in Arbeit ist. Ein bereits laufender
            // Job (z. B. ein Sicherheits-Update in den Nach-Install-Phasen VERIFY/FINALIZE) muss die
            // Job-Maschine + Gate C sauber abschliessen, sonst verwaist er (kein VERIFY/Backup-Prune,
            // Risiko eines verspaeteten Fehl-Rollbacks). Gate C beendet nicht-verwundbare Ziele bei
            // Master-aus weiterhin sauber via finishJob.
            if (empty($cfg['autoupdate_ext']) && empty($cfg['autoupdate_self']) && $forceKey === '' && !$secFloor
                && empty($state['job']['phase'])) {
                self::saveState($stateFile, $state);
                @unlink($lockFile);

                return;
            }

            // Vorsorge-Sperre von radar frisch halten (throttled 30 Min, fail-soft,
            // TLS-verifiziert) - VOR den Install-Entscheidungen, damit eine frische
            // Sperre greift. Nur wenn Auto-Update aktiv ist (sonst kein Install).
            if (class_exists('HtpDefs') && method_exists('HtpDefs', 'refreshHoldList')) {
                try {
                    HtpDefs::refreshHoldList(false);
                } catch (Exception $e) {
                    // fail-open: keine Sperre
                } catch (Throwable $e) {
                    // fail-open
                }
            }

            $deadline = $now + self::stepBudget();
            $job      = isset($state['job']) && is_array($state['job']) ? $state['job'] : null;

            // 1) CRASH-RECOVERY: liegt ein In-Progress-Marker? -> der vorige Lauf
            //    starb mitten in einer riskanten Phase. Eine Recovery-Aktion pro Lauf.
            if ($job && !empty($job['marker'])) {
                $action = self::jobCrashAction($job['marker'], $now, self::LOCK_STALE);

                if ($action === 'rollback') {
                    self::execRollback($cfg, $state, true); // UPDATE-Crash -> Ziel zurueckrollen, Job STOPP
                } elseif ($action === 'discard') {
                    self::execDiscardBackup($cfg, $state);  // BACKUP-Crash -> Teil-Backup verwerfen
                } else {
                    $state['job']['marker'] = null; // (noch) nicht handlungsreif
                }

                self::saveState($stateFile, $state);
                @unlink($lockFile);

                return;
            }

            // 1b) Gezielter Einzel-Install (update.run {key}): eigenen Ein-Ziel-Job seed'en, BEVOR
            //     die regulaere Faelligkeits-Logik greift (die wird dann uebersprungen, weil $job
            //     gesetzt ist). Faelligkeit/Zeitplan wird uebersprungen; das Sicherheits-Gate
            //     (kein Update unter aktivem Verdacht) bleibt RESPEKTIERT.
            if ($forceKey !== '' && (!$job || empty($job['phase']))) {
                $snap = class_exists('HtpStatus') ? HtpStatus::readSnapshot() : null;

                if (self::safetyBlock($cfg, $snap) !== '') {
                    self::saveState($stateFile, $state);
                    @unlink($lockFile);

                    return;
                }

                $job = $state['job'] = array(
                    'phase'     => 'PLAN',
                    'idx'       => 0,
                    'targets'   => array(),
                    'cur'       => array(),
                    'marker'    => null,
                    'vulnOnly'  => false,
                    'sched'     => false,
                    'fresh'     => true,
                    'force_key' => $forceKey,
                    'started'   => $now,
                );
            }

            // 2) Kein aktiver Job -> ggf. einen starten (Faelligkeit + Sicherheits-Gate).
            if (!$job || empty($job['phase'])) {
                $lastRun   = isset($state['last_run']) ? (int) $state['last_run'] : 0;
                $schedDone = isset($state['sched_done']) ? (int) $state['sched_done'] : 0;

                if (($now - (isset($state['crit_checked']) ? (int) $state['crit_checked'] : 0)) >= self::CRIT_RECHECK) {
                    $state['crit_pending'] = self::criticalPending($cfg);
                    $state['crit_checked'] = $now;
                }

                $scheduleDue = self::scheduleDueCfg($cfg, $lastRun, $schedDone, $now);
                $critical    = !empty($state['crit_pending']);
                $selfNow     = self::selfImmediate($cfg); // HTProtect-Selbst-Update: jeder Tick

                // $secFloor (gemanagt + Master aus + offene Luecke) impliziert bereits $critical; der
                // Term steht hier explizit, falls crit_pending zwischenzeitlich anders bewertet wurde.
                if (!$scheduleDue && !$critical && !$selfNow && !$secFloor) {
                    self::saveState($stateFile, $state);
                    @unlink($lockFile);

                    return; // nichts faellig
                }

                $snapshot = class_exists('HtpStatus') ? HtpStatus::readSnapshot() : null;

                if (self::safetyBlock($cfg, $snapshot) !== '') {
                    // Kein Update unter aktivem Verdacht. last_run vorruecken.
                    $state['last_run'] = $now;
                    self::saveState($stateFile, $state);
                    @unlink($lockFile);

                    return;
                }

                // Anbieter-Schonung: braucht dieser Lauf einen FRISCHEN Fremd-Abruf?
                // Nur wenn ein Sicherheitsupdate ansteht ODER seit dem letzten frischen
                // Voll-Check >=6h (+Jitter) vergangen sind. Sonst laeuft execPlan ueber
                // Joomlas Cache (keine Multi-Requests). last_full_check wird beim
                // tatsaechlichen frischen Abruf in execPlan gesetzt.
                $lastFull = isset($state['last_full_check']) ? (int) $state['last_full_check'] : 0;
                $freshDue = $critical || (($now - $lastFull) >= self::providerCheckFloor());

                $state['job'] = array(
                    'phase'    => 'PLAN',
                    'idx'      => 0,
                    'targets'  => array(),
                    'cur'      => array(),
                    'marker'   => null,
                    'vulnOnly' => (!$scheduleDue || empty($cfg['autoupdate_all'])),
                    'sched'    => $scheduleDue,
                    'fresh'    => $freshDue,
                    'started'  => $now,
                );
            }

            // 3) PHASEN im Budget abarbeiten. Billige Phasen verketten sich; VOR UPDATE
            //    wird IMMER geyieldet (UPDATE startet frisch mit vollem Budget); nach
            //    UPDATE ebenfalls (es ist die schwerste Phase).
            $guard = 0;

            while (isset($state['job']) && !empty($state['job']['phase']) && time() < $deadline && $guard < 60) {
                $guard++;
                $phase = $state['job']['phase'];

                // Sicherheitsupdate-Master waehrend eines laufenden FREMD-Batches ausgeschaltet?
                // Dann KEIN weiteres Fremd-Ziel mehr sichern/installieren: der vom Master
                // ENTKOPPELTE HTProtect-Selbst-Update (autoupdate_self) haelt den Lauf zwar am
                // Leben, darf aber nie Dritt-Installs nachziehen - sonst liefe ein beim Umschalten
                // bereits in-flight Batch trotz Master-AUS weiter (der persistierte Job umgeht das
                // PLAN-Gate in execPlan). Nur die VORWAERTS-Install-Phasen BACKUP/UPDATE werden
                // gestoppt; ein bereits installiertes Ziel schliesst seine Nach-Phasen (VERIFY/
                // FINALIZE/ROLLBACK) noch sauber ab. Batch sauber beenden (kein Install, keine
                // Fehl-Strafe, ehrliche Sammel-Mail der bereits erledigten Ziele) -> der naechste
                // Tick fuehrt den Selbst-Update aus. Gezielter Einzel-Install (forceKey, Operator/
                // Fleet) bleibt bewusst UNBERUEHRT.
                // AUSNAHME Sicherheits-Boden: ist die Site gemanagt UND das AKTUELLE Ziel angreifbar,
                // NICHT abwuergen - der Boden schliesst die Luecke auch bei Master-aus (nur Vuln-Ziele;
                // ein nicht-verwundbares Ziel wird bei Master-aus weiterhin gestoppt, keine Hintertuer).
                $jidxCur = isset($state['job']['idx']) ? (int) $state['job']['idx'] : 0;
                $curVuln = !empty($state['job']['targets'][$jidxCur]['vulnerable']);

                if (($phase === 'BACKUP' || $phase === 'UPDATE')
                    && empty($cfg['autoupdate_ext'])
                    && empty($state['job']['force_key'])
                    && !($managed && $curVuln)) {
                    self::finishJob($cfg, $state, true);

                    break;
                }

                // DURABLE Stall-Schutz fuer die markerlosen Nach-Install-Phasen: den Zaehler
                // VOR dem Eintritt persistieren (ueberlebt einen Throw in execVerify/
                // execFinalize) und nach JOB_ATTEMPT_CAP sicher aufgeben - VERIFY -> ROLLBACK
                // (nicht verifizierbarer Install zurueck), FINALIZE -> Ziel abschliessen.
                // Andere Phasen (BACKUP/UPDATE) sind ueber ihren Crash-Marker abgesichert.
                if ($phase === 'VERIFY' || $phase === 'FINALIZE') {
                    $stall = self::jobStallNext(
                        isset($state['job']['stall']) ? $state['job']['stall'] : null,
                        $phase,
                        self::JOB_ATTEMPT_CAP
                    );
                    $state['job']['stall'] = array('phase' => $stall['phase'], 'n' => $stall['n']);
                    self::saveState($stateFile, $state); // DURABEL vor der riskanten Phase

                    if ($stall['giveUp']) {
                        unset($state['job']['stall']);
                        self::applyTransition($cfg, $state, ($phase === 'VERIFY') ? 'fail' : '');

                        continue; // mit der Folgephase (ROLLBACK bzw. naechstes Ziel) weiter
                    }
                } elseif (isset($state['job']['stall'])) {
                    unset($state['job']['stall']); // andere Phase erreicht -> Zaehler zuruecksetzen
                }

                if ($phase === 'PLAN') {
                    self::execPlan($cfg, $state);
                } elseif ($phase === 'BACKUP') {
                    self::execBackup($cfg, $state, $stateFile);

                    // YIELD vor UPDATE nur im regulaeren BATCH-Lauf (dort soll die schwerste Phase mit
                    // vollem Budget frisch starten). Beim GEZIELTEN Einzel-Install (force_key, z. B.
                    // Operator/Fleet auf EIN kleines Sprachpaket) NICHT yielden -> PLAN->BACKUP->UPDATE->
                    // VERIFY laufen in EINEM Tick durch (im Budget, sonst greift die while-Deadline), sodass
                    // der ERSTE Anlauf sofort ECHT installiert statt 'update_one_pending' zu melden und auf
                    // mehrere Re-Nudge-Ticks angewiesen zu sein ("2 Anlaeufe").
                    if (isset($state['job']) && $state['job']['phase'] === 'UPDATE'
                        && empty($state['job']['force_key'])) {
                        break; // YIELD vor UPDATE (nur Batch)
                    }
                } elseif ($phase === 'UPDATE') {
                    self::execUpdate($cfg, $state, $stateFile);

                    // Nach der schweren UPDATE-Phase yielden - beim Einzel-Install (force_key) NICHT, damit
                    // VERIFY/FINALIZE denselben Tick abschliessen (bounded durch time() < $deadline oben).
                    if (empty($state['job']['force_key'])) {
                        break; // Batch: schwerste Phase -> danach yielden
                    }
                } elseif ($phase === 'VERIFY') {
                    self::execVerify($cfg, $state);
                } elseif ($phase === 'FINALIZE') {
                    self::execFinalize($cfg, $state);
                } elseif ($phase === 'ROLLBACK') {
                    self::execRollback($cfg, $state, false);

                    break; // Rollback stoppt den Job
                } else {
                    break;
                }
            }

            self::saveState($stateFile, $state);
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open
        }

        @unlink($lockFile);
    }

    /**
     * Phasenuebergang anwenden (nutzt die reine jobNextPhase) + Job ggf. abschliessen.
     *
     * @return void
     */
    private static function applyTransition($cfg, &$state, $result)
    {
        if (!isset($state['job']) || !is_array($state['job'])) {
            return;
        }

        $t = self::jobNextPhase($state['job'], $result);
        $state['job']['phase']  = $t['phase'];
        $state['job']['idx']    = $t['idx'];
        $state['job']['marker'] = null;

        if (!empty($t['done'])) {
            self::finishJob($cfg, $state, !empty($t['stopped']));
        } elseif ($t['phase'] === 'BACKUP') {
            $state['job']['cur'] = array(); // naechstes Ziel -> Arbeitsdaten leeren
        }
    }

    private static function finishJob($cfg, &$state, $stopped)
    {
        $now     = time();
        $sched   = isset($state['job']['sched']) ? !empty($state['job']['sched']) : false;
        $summary = isset($state['job']['summary']) && is_array($state['job']['summary']) ? $state['job']['summary'] : array();
        unset($state['job']);

        // last_run ist AUSSCHLIESSLICH die Referenz des 'instant'-Zeitplans
        // (scheduleDueCfg). Ein REINER Selbst-Update-Tick (Zeitplan nicht faellig, keine
        // Dritt-Ziele, kein Abbruch) darf sie NICHT vorruecken - sonst verhungert 'instant'
        // bei Dauer-Traffic: der vom Master entkoppelte Selbst-Update-Tick UND der Waechter-
        // Herzschlag setzen last_run sonst staendig neu (mehrere Ticker mit ~840/900 s), und
        // (now - last_run) erreicht INSTANT_MIN nie. Der Selbst-Update hat seine EIGENE Kadenz
        // (last_self_check, SELF_CHECK_MIN). Nur ein ECHTER Zeitplan-Lauf ($sched), ein Lauf mit
        // verarbeiteten Zielen ($summary) oder ein Abbruch ($stopped) rueckt vor. Betrifft NUR
        // 'instant' (daily/weekly nutzen sched_done); Sicherheitsupdates laufen ohnehin sofort.
        if ($sched || !empty($summary) || $stopped) {
            $state['last_run'] = $now;
        }

        if ($sched && !$stopped) {
            $slot = self::currentSlot($cfg, $now);

            if ($slot > 0) {
                $state['sched_done'] = $slot;
            }
        }

        if (!$stopped) {
            $state['crit_pending'] = false; // Burst fertig -> bei naechster Pruefung neu bewerten
        }

        // Anti-Spamflut: statt einer Mail PRO Erweiterung genau EINE Sammel-Mail am
        // Batch-Ende, die alle Ergebnisse auflistet (aktualisiert / zurueckgerollt).
        self::mailBatchSummary($cfg, $summary);
    }

    /**
     * Ergebnis eines Ziels in die Batch-Zusammenfassung legen (statt sofort zu mailen).
     * finishJob verschickt am Ende EINE Sammel-Mail. Ohne aktiven Job: no-op.
     *
     * @param   array  $state  State (Referenz)
     * @param   array  $entry  {name, from, to, result(ok|rollback|suspend), security}
     *
     * @return void
     */
    private static function pushSummary(&$state, $entry)
    {
        if (!isset($state['job']) || !is_array($state['job'])) {
            return;
        }

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

        $state['job']['summary'][] = $entry;
    }

    /**
     * EINE Sammel-Mail fuer den abgeschlossenen Auto-Update-Batch (Anti-Spamflut).
     * Verschickt nur, wenn es etwas Meldenswertes gibt (Rollback/Sicherheitsupdate)
     * ODER der Erfolgs-Mail-Schalter an ist - sonst still (Routine-Erfolge ohne Schalter).
     *
     * @param   array  $cfg      Konfiguration
     * @param   array  $summary  Liste der Batch-Ergebnisse
     *
     * @return void
     */
    private static function mailBatchSummary($cfg, $summary)
    {
        if (empty($summary) || !is_array($summary)) {
            return;
        }

        $noteworthy = false;

        foreach ($summary as $e) {
            $r = isset($e['result']) ? (string) $e['result'] : '';

            if ($r === 'rollback' || $r === 'suspend' || !empty($e['security']) || !empty($e['malwarn'])) {
                $noteworthy = true;
                break;
            }
        }

        if (!$noteworthy && empty($cfg['autoupdate_mail_success'])) {
            return; // nur Routine-Erfolge + Schalter aus -> still
        }

        $lines   = array(htp_t('COM_HTPROTECT_AU_MAIL_SUM_INTRO'));
        $site    = self::selfSiteUrl($cfg);

        if ($site !== '') {
            $lines[] = htp_t('COM_HTPROTECT_MAIL_SITE', $site);
        }

        $lines[] = '';
        $anyRb   = false;
        $anyMal  = false;
        $fbItems = array(); // erfolgreich installierte Updates -> Feedback-Links

        foreach ($summary as $e) {
            $name = isset($e['name']) ? (string) $e['name'] : '';
            $from = isset($e['from']) ? (string) $e['from'] : '';
            $to   = isset($e['to']) ? (string) $e['to'] : '';
            $r    = isset($e['result']) ? (string) $e['result'] : '';

            if ($r === 'ok') {
                if (isset($e['malwarn']) && is_array($e['malwarn']) && !empty($e['malwarn'])) {
                    // Sicherheitsupdate EINGESPIELT (Luecke zu), aber Verdacht -> Warnung.
                    $anyMal  = true;
                    $wfiles  = implode(', ', array_filter($e['malwarn']));
                    $lines[] = htp_t('COM_HTPROTECT_AU_MAIL_SUM_SECWARN', $name, $from, $to, $wfiles);
                } else {
                    $line = htp_t('COM_HTPROTECT_AU_MAIL_SUM_OK', $name, $from, $to);

                    if (!empty($e['security'])) {
                        $line .= ' ' . htp_t('COM_HTPROTECT_AU_MAIL_SUM_SEC');
                    }

                    $lines[] = $line;
                }

                // Erfolgreich INSTALLIERTES Update -> Kandidat fuer das Feedback (Zeuge).
                if (isset($e['key']) && (string) $e['key'] !== '' && $to !== '') {
                    $fbItems[] = array('name' => $name, 'to' => $to, 'key' => (string) $e['key']);
                }
            } elseif (isset($e['reason']) && $e['reason'] === 'malware') {
                // Update wegen Code-Verdacht VORSORGLICH zurueckgestellt (nicht eingespielt).
                // Bewusst NICHT $anyRb (= kein alarmierender Rollback-Betreff / "bitte
                // pruefen"-Footer) - das ist eine ruhige Vorsorge-Massnahme, kein Fehlschlag.
                $anyMal  = true;
                $files   = (isset($e['malfiles']) && is_array($e['malfiles'])) ? implode(', ', array_filter($e['malfiles'])) : '';
                $giveup  = isset($e['malstate']) && $e['malstate'] === 'giveup';
                $lines[] = htp_t($giveup ? 'COM_HTPROTECT_AU_MAIL_SUM_MALWARE_GIVEUP' : 'COM_HTPROTECT_AU_MAIL_SUM_MALWARE', $name, $from, $to, $files);
            } else {
                $anyRb   = true;
                $lines[] = htp_t('COM_HTPROTECT_AU_MAIL_SUM_RB', $name, $from, $to);
            }
        }

        if ($anyMal) {
            // Ehrlicher Fehlalarm-Hinweis + Melde-Weg.
            $lines[] = '';
            $lines[] = htp_t('COM_HTPROTECT_AU_MAIL_SUM_MALWARE_FP');
        }

        if ($anyRb) {
            $lines[] = '';
            $lines[] = htp_t('COM_HTPROTECT_AU_MAIL_SUM_CHECK');
        }

        // Betreff nach Dringlichkeit: echter Fehlschlag/Rollback -> "bitte pruefen";
        // nur Code-Verdacht (vorsorglich zurueckgestellt / Sicherheits-Warnung) -> ruhig;
        // sonst Routine-Zusammenfassung.
        $subjectKey = $anyRb
            ? 'COM_HTPROTECT_AU_MAIL_SUM_SUBJECT_RB'
            : ($anyMal ? 'COM_HTPROTECT_AU_MAIL_SUM_SUBJECT_HELD' : 'COM_HTPROTECT_AU_MAIL_SUM_SUBJECT');

        // Dezente JED-Bewertungs-Einladung GANZ ans Ende der Mail (hinter Signatur +
        // Abmelde-Link - siehe sendNotify) - NUR bei echtem Erfolg (kein Rollback, kein
        // Malware-Verdacht) und hoechstens alle 90 Tage (Anti-Spam, damit sie bei mehreren
        // Sicherheitsupdates kurz hintereinander nicht nervt).
        $showJed = false;

        if (!$anyRb && !$anyMal) {
            $jedLast = isset($cfg['jed_mail_last']) ? (int) $cfg['jed_mail_last'] : 0;

            if ((time() - $jedLast) >= self::JED_MAIL_MIN_INTERVAL) {
                $showJed = true;

                if (class_exists('HtpConfig') && method_exists('HtpConfig', 'saveKeys')) {
                    HtpConfig::saveKeys(array('jed_mail_last' => time()));
                }
            }
        }

        // Zeugen-Feedback (Saeule 3): zu jedem erfolgreich installierten Update zwei
        // signierte Einmal-Knoepfe ("Lief problemlos" / "Problem melden"). REIN MELDEND.
        // sendNotify rendert daraus slim HTML-Buttons (+ URLs nur in der Plain-Alternative).
        self::sendNotify($cfg, $subjectKey, $lines, array('feedback' => $fbItems, 'jed' => $showJed));
    }

    /**
     * PLAN: faellige Ziele ermitteln (Joomla-Updater + Bewertung), Liste bauen.
     */
    private static function execPlan($cfg, &$state)
    {
        $vulnOnly = !empty($state['job']['vulnOnly']);

        // Sicherheits-Boden: gemanagte (dashboard-verbundene) Site mit lokal ausgeschaltetem Master.
        // Dann laufen NUR angreifbare (verwundbare) Erweiterungen automatisch - kein Freischalten von
        // Nicht-Security ueber die Hintertuer (useVulnOnly wird unten erzwungen). Sicherheit ist fuer
        // verbundene Sites eine Garantie (vgl. criticalPending).
        $vulnFloor = self::isManaged($cfg) && empty($cfg['autoupdate_ext']);

        // Anbieter-schonend (2.4.4): fremde Update-Server nur bei faelligem Voll-Check
        // (>=6h) ODER Sicherheitsupdate FRISCH abrufen; sonst Joomlas 6h-Cache nutzen
        // (Server, die juenger geprueft wurden, werden GAR NICHT kontaktiert) -> keine
        // Multi-Requests im instant-Modus. HTProtect SELBST wird bei Bedarf gezielt
        // frisch geprueft (nur der eigene Update-Server, 1 Request) -> Selbst-Update
        // bleibt schnell, ohne Fremd-Server zu belasten.
        if (!empty($state['job']['fresh'])) {
            self::joomlaFindUpdates();
            $nowChk = time();
            $state['last_full_check'] = $nowChk;
            $state['last_self_check'] = $nowChk; // HTProtect wurde beim Voll-Check mitgeprueft
        } else {
            // HTProtect selbst gezielt frisch pruefen (nur eigener Server) - aber
            // hoechstens alle 3h (SELF_CHECK_MIN). Eigene, statische XML -> keine Fremd-
            // Last, aber kein 15-Min-Uncached-Takt mehr. AUSNAHME: gezielter Selbst-Update
            // aus dem Dashboard (forceKey = self-Key) -> Throttle bewusst umgehen, der Nutzer
            // klickt ausdruecklich und will einen frischen Kandidaten (sonst verpuffte der Klick).
            $selfKeyChk = self::extKey('component', 'com_htprotect', '');
            $forceSelf  = (isset($state['job']['force_key']) && (string) $state['job']['force_key'] === $selfKeyChk);

            if ($forceSelf || empty($state['job']['vulnOnly']) || self::selfImmediate($cfg)) {
                $lastSelf = isset($state['last_self_check']) ? (int) $state['last_self_check'] : 0;
                $nowSelf  = time();

                if ($forceSelf || ($nowSelf - $lastSelf) >= self::SELF_CHECK_MIN) {
                    self::selfUpdateSiteRefresh();
                    $state['last_self_check'] = $nowSelf;
                }
            }

            self::joomlaFindUpdatesGentle();
        }

        // (2.6.11) HTProtect-Sprachpakete mit dem Self-Update mitziehen - NUR wenn "HTProtect immer
        // aktuell halten" (autoupdate_self) an ist, und gedrosselt (eigene Frist, SELF_CHECK_MIN).
        // Katalog-basiert (signiert), UNABHAENGIG von Joomlas Update-Site-Erkennung: installierte
        // HTProtect-Sprachpakete aelter als der Katalog werden still aktualisiert. Fail-soft, der
        // Aufruf darf den Lauf nie stoeren (HtpApp::langPackAutoUpdate kapselt try/catch selbst).
        if (self::selfImmediate($cfg) && class_exists('HtpApp') && method_exists('HtpApp', 'langPackAutoUpdate')) {
            $lastLp = isset($state['last_langpack_check']) ? (int) $state['last_langpack_check'] : 0;
            $nowLp  = time();

            if (($nowLp - $lastLp) >= self::LANGPACK_CHECK_MIN) {
                HtpApp::langPackAutoUpdate($cfg);
                $state['last_langpack_check'] = $nowLp;
            }
        }

        $infos = self::collectInfos($cfg, $state);
        self::reportHostChanges($cfg, $infos, $state);
        $state['known_hosts'] = self::rememberHosts($state, $infos);

        // Karenz-Zeit (Soak): Erstsichtung je ausstehendem Update (key@version) pflegen
        // - robust gegen Finder-Aussetzer (mergeSeen behaelt transient Verschwundene).
        $graceDays = isset($cfg['autoupdate_grace']) ? (int) $cfg['autoupdate_grace'] : 0;
        $now       = time();
        $pending   = array();

        foreach ($infos as $info) {
            if (empty($info['has_update']) || !empty($info['self'])) {
                continue;
            }

            $pending[self::extKey($info['type'], $info['element'], $info['folder']) . '@' . (string) $info['available']] = true;
        }

        $state['seen'] = self::mergeSeen(isset($state['seen']) ? $state['seen'] : array(), $pending, $now, ($graceDays + 7) * 86400);
        $seen          = $state['seen'];

        $targets  = array();
        $forceKey = isset($state['job']['force_key']) ? (string) $state['job']['force_key'] : '';
        $selfKey  = self::extKey('component', 'com_htprotect', '');

        if ($forceKey === $selfKey) {
            // Gezielter SELBST-Update aus dem Dashboard (update.run {self-Key}): den entkoppelten
            // Self-Pfad DIREKT ausfuehren. Der normale forceKey-Zielloop laesst 'self' bewusst aus
            // (self ist nie ein phasiges Ziel), und die Selbst-Update-Notbremse unten verlangt
            // forceKey === '' -> ohne diesen Zweig verpuffte der Klick STILL (kein Install, keine
            // Rueckmeldung). runOne meldet Erfolg/Fehlschlag ehrlich ueber den Vorher/Nachher-
            // Versionsvergleich (die Eigen-Pruefung oben wurde fuer diesen Fall throttle-frei erzwungen).
            $self = self::selfUpdateCandidate($infos);

            if ($self !== null) {
                $job          = $state['job'];
                $state        = self::updateSelf($cfg, $self, $state);
                $state['job'] = $job;
            }

            $state['job']['targets'] = array();
            self::applyTransition($cfg, $state, ''); // PLAN -> done (kein phasiges Ziel)

            return;
        }

        if ($forceKey !== '') {
            // Gezielter Einzel-Install (update.run {key}): GENAU diese Erweiterung, unabhaengig von
            // included/Karenz/vulnOnly - aber HARTE Sperren (Rueckruf/Malware-Block/ausgesetzt/
            // Host-Wechsel/nicht unterstuetzt) werden NICHT ueberstimmt.
            foreach ($infos as $c) {
                if (empty($c['has_update']) || !empty($c['self'])) {
                    continue;
                }

                if (self::extKey($c['type'], $c['element'], $c['folder']) !== $forceKey) {
                    continue;
                }

                $stx = isset($c['status']) ? (string) $c['status'] : '';

                if (in_array($stx, array('recalled', 'mal_blocked', 'suspended', 'host_changed', 'unsupported'), true)) {
                    break; // harte Sperre -> nicht installieren (Einzel-Install respektiert sie)
                }

                $targets[] = array(
                    'eid'        => (int) $c['eid'],
                    'type'       => (string) $c['type'],
                    'element'    => (string) $c['element'],
                    'folder'     => (string) $c['folder'],
                    'client'     => isset($c['client_id']) ? (int) $c['client_id'] : 0,
                    'name'       => isset($c['name']) && $c['name'] !== '' ? (string) $c['name'] : (string) $c['element'],
                    'from'       => (string) $c['installed'],
                    'to'         => (string) $c['available'],
                    'update_id'  => isset($c['update_id']) ? (int) $c['update_id'] : 0,
                    'vulnerable' => !empty($c['vulnerable']),
                    'key'        => self::extKey($c['type'], $c['element'], $c['folder']),
                );
                break;
            }
        } elseif (!empty($cfg['autoupdate_ext']) || $vulnFloor) {
            // Fremd-Erweiterungen NUR bei aktivem Sicherheitsupdate-Master installieren. Ist der
            // Master AUS (aber der Selbst-Schalter an), bleibt $targets leer -> unten laeuft
            // ausschliesslich der HTProtect-Selbst-Update (vom Master entkoppelt). So schaltet
            // der Selbst-Schalter NIE ungewollt Dritt-Auto-Installs frei.
            // SICHERHEITS-BODEN: bei gemanagter Site + Master aus ($vulnFloor) NUR angreifbare Ziele
            // (useVulnOnly erzwungen) - Karenz-Guard unten greift ohnehin nur fuer nicht-verwundbare.
            $useVulnOnly = $vulnOnly || $vulnFloor;

            foreach (self::pickAllCandidates($infos, $useVulnOnly) as $c) {
                // Karenz: NICHT-angreifbare Auswahl-Updates muessen erst die Soak-Zeit ueberstehen.
                // Effektive Karenz je Erweiterung (per-ext-Override, sonst Fleet-Default $graceDays).
                // Sicherheitsupdates (vulnerable) laufen IMMER sofort (Guard) - unabhaengig von jeder Karenz.
                $ckey = self::extKey($c['type'], $c['element'], $c['folder']);
                $g    = (class_exists('HtpAuIntent') && isset($cfg['autoupdate_intent']))
                    ? (int) HtpAuIntent::graceFor($cfg['autoupdate_intent'], $ckey, $graceDays)
                    : $graceDays;

                if (empty($c['vulnerable']) && $g > 0) {
                    $sk = $ckey . '@' . (string) $c['available'];

                    if (!self::graceElapsed(isset($seen[$sk]) ? (int) $seen[$sk] : $now, $now, $g)) {
                        continue; // noch in der Karenzzeit -> diesmal nicht installieren
                    }
                }

                $targets[] = array(
                    'eid'        => (int) $c['eid'],
                    'type'       => (string) $c['type'],
                    'element'    => (string) $c['element'],
                    'folder'     => (string) $c['folder'],
                    'client'     => isset($c['client_id']) ? (int) $c['client_id'] : 0,
                    'name'       => isset($c['name']) && $c['name'] !== '' ? (string) $c['name'] : (string) $c['element'],
                    'from'       => (string) $c['installed'],
                    'to'         => (string) $c['available'],
                    'update_id'  => isset($c['update_id']) ? (int) $c['update_id'] : 0,
                    'vulnerable' => !empty($c['vulnerable']),
                    'key'        => self::extKey($c['type'], $c['element'], $c['folder']),
                );
            }
        }

        // HTProtect SELBST aktualisieren (eigener data/-sicherer Pfad, einmalig; durch das Plugin-
        // Self-Heal gegen das Crash-Fenster geschuetzt - nicht Teil der phasigen Maschine). Laeuft im
        // planmaessigen alle-Modus ODER wenn der Selbst-Sofort-Schalter an ist (selfImmediate). NIE beim
        // gezielten Einzel-Install (forceKey) - der self-Key wird oben separat direkt ausgefuehrt, und ein
        // Dritt-forceKey darf die Starvations-Uhr NICHT anfassen -> der ganze Block nur bei forceKey === ''.
        //
        // ROBUSTHEIT (Anti-Starvation): der Self-Update laeuft normal ZULETZT (leere Queue), darf aber
        // NICHT unbegrenzt an einer klemmenden Queue verhungern - Self-Updates sind sicherheitsrelevant.
        // (a) endgueltig aufgegebene Ziele (key_fails.done - z. B. fehlender Download-Schluessel)
        //     blockieren ihn NIE; (b) haengt er laenger als SELF_STARVE_WINDOW an sonstigen Dauer-
        //     Blockierern (z. B. einer Backup-Schleife, die nie aufgibt), bricht er trotzdem durch.
        // WICHTIG: die Starvations-Uhr wird an der KANDIDATEN-Existenz gefuehrt, NICHT an der Tick-
        // Berechtigung - sonst wischen haeufige vulnOnly-/Critical-Ticks (oder Dritt-Einzel-Installs)
        // sie staendig weg und das Fenster wird nie voll (der sicherheitskritische Fall: verwundbares
        // HTProtect bei Selbst-Sofort AUS). Ein gesunder Lauf raeumt die Queue vorher ab -> (b) greift nie.
        if ($forceKey === '') {
            $selfCand = self::selfUpdateCandidate($infos); // unabhaengig von der Tick-Berechtigung ermitteln

            // Wiederholt-Crash-Bremse: stuerzte GENAU diese Version schon >= MAX_FAILS mal am Ziel ab
            // (Notfall-Restore, s. processHealed), NICHT endlos automatisch neu versuchen -> wie „kein
            // Kandidat" behandeln. Eine neuere Version (anderes 'available') ist NICHT betroffen und
            // laeuft weiter; ein manueller Dashboard-Trigger (forceKey oben) umgeht diese Bremse.
            if ($selfCand !== null
                && isset($state['self_crash']['ver'], $state['self_crash']['n'])
                && (string) $state['self_crash']['ver'] === (string) $selfCand['available']
                && (int) $state['self_crash']['n'] >= self::MAX_FAILS) {
                $selfCand = null;
            }

            if ($selfCand === null) {
                unset($state['self_pending_since']); // HTProtect ist aktuell (oder gedeckelt) -> keine Starvation

                // HTProtect ist selbst aktuell -> diesen (ohnehin berechtigten) Tick nutzen, um die
                // EIGENEN Sprachpakete (file_htprotect_*) still mitzuziehen. Gekoppelt an den Self-
                // Update-Schalter (autoupdate_self); de/en stecken im Paket und laufen ueber das
                // normale Self-Update. Laeuft NUR hier (selfCand === null) -> nie im selben Tick wie
                // der Code-Tausch (der steckt im else-Zweig) -> keine Kollision.
                if (!empty($cfg['autoupdate_self'])) {
                    $state = self::updateOwnLangpacks($cfg, $state);
                }
            } else {
                // Blockierende Ziele = solche, die NICHT endgueltig aufgegeben haben (key_fails.done).
                $blocking = 0;

                foreach ($targets as $bt) {
                    $bkf = isset($state['key_fails'][$bt['key']]) ? $state['key_fails'][$bt['key']] : null;

                    if (is_array($bkf) && !empty($bkf['done'])) {
                        continue; // hoffnungslos aufgegeben -> blockiert den Self-Update nicht
                    }

                    $blocking++;
                }

                // Darf der Self-Update in DIESEM Tick laufen? planmaessiger alle-Modus ODER Selbst-Sofort.
                $selfEligible = (empty($state['job']['vulnOnly']) || self::selfImmediate($cfg));
                $runSelf      = false;

                if ($blocking === 0) {
                    // Queue frei -> Self laeuft (im gesunden Fall genau hier: zuletzt, einmalig).
                    // Die Starvations-Uhr NUR auf einem BERECHTIGTEN Tick loeschen: ein vulnOnly-/
                    // Critical-Tick filtert Nicht-Vuln-Ziele aus $targets heraus und sieht die Queue
                    // faelschlich leer - er darf eine bereits laufende Uhr NICHT wegwischen, sonst
                    // wird das 3h-Fenster bei einem NICHT-verwundbaren Dauer-Blocker nie voll (der
                    // sicherheitskritische Fall: verwundbares HTProtect bei Selbst-Sofort AUS).
                    if ($selfEligible) {
                        unset($state['self_pending_since']);
                        $runSelf = true;
                    }
                } else {
                    // Queue klemmt (im vollen, berechtigten Blick sichtbar) -> Starvations-Uhr laufen
                    // lassen. Durchbruch nur auf einem berechtigten Tick, sobald das Fenster voll ist.
                    $since = isset($state['self_pending_since']) ? (int) $state['self_pending_since'] : 0;

                    if ($since <= 0) {
                        $state['self_pending_since'] = $now;
                        $since                       = $now;
                    }

                    // Die Uhr hier NICHT loeschen -> erst der naechste Tick raeumt sie, sobald Self
                    // wirklich aktuell ist (selfCand===null oben). So retryt ein fehlgeschlagener
                    // Durchbruch am naechsten berechtigten Tick (updateSelf hat eigenen Backoff:
                    // key_cooldown/suspended -> selfCand faellt weg), statt erneut volle 3h zu warten.
                    if ($selfEligible && ($now - $since) >= self::SELF_STARVE_WINDOW) {
                        $runSelf = true;
                    }
                }

                if ($runSelf) {
                    $job          = $state['job'];               // Job sichern
                    $state        = self::updateSelf($cfg, $selfCand, $state);
                    $state['job'] = $job;                        // ... und wiederherstellen

                    // KEIN Dritt-Ziel im SELBEN Tick wie der Selbst-Code-Tausch backuppen/verifizieren
                    // (sonst wuerde ein phasiger Job den Versionswechsel ueberspannen: BACKUP mit alt,
                    // VERIFY mit neu). Queue leeren -> Job endet ueber die getestete leere-Queue-Bahn;
                    // der naechste Tick plant unter der NEUEN Version frisch (das Ziel bleibt erhalten).
                    if (!empty($targets)) {
                        $targets = array();
                    }
                }
            }
        }

        $state['job']['targets'] = $targets;
        self::applyTransition($cfg, $state, ''); // PLAN -> BACKUP (oder done, wenn leer)
    }

    /**
     * BACKUP: aktuelles Ziel sichern (Dateien + DB-Tabellen). No-backup-no-update.
     * Marker VOR dem Backup (durabel) -> Crash erkennbar.
     */
    private static function execBackup($cfg, &$state, $stateFile)
    {
        $idx = (int) $state['job']['idx'];

        if (!isset($state['job']['targets'][$idx])) {
            self::applyTransition($cfg, $state, 'skip');

            return;
        }

        $t    = $state['job']['targets'][$idx];
        $eid  = (int) $t['eid'];
        $keep = isset($cfg['autoupdate_keep']) ? (int) $cfg['autoupdate_keep'] : 1;

        if (!self::isUpgrade($t['from'], $t['to'])) {
            $state = self::logSkip($state, $t['key'], $eid, $t['name'], $t['from'], $t['to'], 'downgrade');
            self::applyTransition($cfg, $state, 'skip');

            return;
        }

        // Marker + Baseline-Health VOR dem Backup; State durabel speichern.
        $state['job']['marker']          = array('phase' => 'BACKUP', 'started' => time(), 'attempt' => 0);
        $state['job']['cur']['baseline'] = self::captureHealth($cfg);

        // Vor-Update-Malware-Baseline des Ziel-Scopes (frisch, klein, gebudgetet). Zwei Zwecke:
        //  (1) DELTA: beim spaeteren Verify NUR neu durch das Update eingeschleuste Funde blocken -
        //      eine bereits VORHANDENE Infektion (z. B. ein Fremd-Shell in einem media/-Ordner)
        //      darf ein - womoeglich sicherheitskritisches - Update NIE zurueckrollen.
        //  (2) SAUBERES PAKET: diese bekannten Schaddateien aus dem Rollback-Snapshot ausschliessen,
        //      damit das Backup-Paket selbst frei von Malware ist (kein Wiedereinschleusen beim
        //      Restore, keine AV-Quarantaene/Schwarm-Fehlalarme auf das Paket).
        // Fail-open: bei Scanner-/Zeitproblemen bleibt die Baseline leer (kein Fehl-Verhalten).
        $malScope   = self::scopeMalwareState($t);
        $state['job']['cur']['malbase'] = $malScope['base'];
        self::saveState($stateFile, $state);

        $snapId = HtpUpdateBackup::snapshot($eid, $t['type'], $t['element'], $t['folder'], (int) $t['client'], $t['from'], true, $malScope['abs']);

        if ($snapId === '') {
            $state['job']['marker'] = null;
            $state = self::logSkip($state, $t['key'], $eid, $t['name'], $t['from'], $t['to'], 'backup');
            self::applyTransition($cfg, $state, 'skip');

            return;
        }

        if (!HtpUpdateBackup::verify($eid, $snapId)) {
            HtpUpdateBackup::prune($eid, max(1, $keep));
            $state['job']['marker'] = null;
            $state = self::logSkip($state, $t['key'], $eid, $t['name'], $t['from'], $t['to'], 'backup_incomplete');
            self::applyTransition($cfg, $state, 'skip');

            return;
        }

        $state['job']['cur']['snap'] = $snapId;
        $state['job']['marker']      = null;
        self::applyTransition($cfg, $state, 'ok'); // BACKUP -> UPDATE
    }

    /**
     * UPDATE: headless Joomla-Install des aktuellen Ziels. Eigener Lauf, volles
     * Budget. Marker VOR dem Install (durabel) -> Crash = ROLLBACK beim naechsten Lauf.
     */
    private static function execUpdate($cfg, &$state, $stateFile)
    {
        $idx = (int) $state['job']['idx'];

        if (!isset($state['job']['targets'][$idx])) {
            self::applyTransition($cfg, $state, '');

            return;
        }

        $t = $state['job']['targets'][$idx];

        $state['job']['marker'] = array('phase' => 'UPDATE', 'started' => time(), 'attempt' => 0);
        self::saveState($stateFile, $state);

        // Die in PLAN gemerkte update_id kann bis zu DIESEM Tick veraltet sein:
        // #__updates-IDs sind fluechtig (jeder Joomla-Update-Check leert die Tabelle
        // und vergibt neue IDs). Eine veraltete ID liefe als No-op-Install ins Leere
        // (Update bliebe still liegen). Darum die GUELTIGE ID fuer dieses Ziel hier
        // per extension_id frisch aufloesen; ist sie ganz verschwunden, einmal frisch
        // pruefen; sonst Fallback auf die gemerkte (installOutcome faengt einen No-op
        // ueber den Versionscheck ab).
        $uid = self::currentUpdateId((int) $t['eid']);

        if ($uid <= 0) {
            self::joomlaFindUpdates();
            $uid = self::currentUpdateId((int) $t['eid']);
        }

        if ($uid <= 0) {
            $uid = (int) $t['update_id'];
        }

        // Sicherheits-Revalidierung VOR dem Install: die update_id kann seit PLAN neu
        // aufgeloest worden sein (fluechtige #__updates-IDs). Die JETZT aufgeloeste Version
        // erneut gegen die zentrale Sperrliste (Recall) UND die Upgrade-Regel pruefen - so
        // schluepft keine mittendrin eingewechselte, gesperrte oder Downgrade-Version am
        // PLAN-Gate vorbei. Version unbekannt (DB-Hiccup) -> fail-open (PLAN-Gate + Post-
        // Install-Malscan greifen weiter).
        $newVer = self::updateVersionOf($uid);

        if ($newVer !== '') {
            $recalled = class_exists('HtpDefs')
                && HtpDefs::recallMatch(HtpDefs::recallList(), (string) $t['type'], (string) $t['element'], $newVer);

            if ($recalled || !self::isUpgrade((string) $t['from'], $newVer)) {
                $state['job']['marker'] = null; // nichts installiert -> kein Crash-Rollback
                $state = self::logSkip($state, (string) $t['key'], (int) $t['eid'], $t['name'],
                    (string) $t['from'], $newVer, $recalled ? 'recalled' : 'downgrade');
                self::applyTransition($cfg, $state, 'skip'); // UPDATE -> naechstes Ziel

                return;
            }
        }

        $inst = self::installUpdate($uid);
        self::invalidateOpcache($t['type'], $t['element'], $t['folder'], (int) $t['client']);

        $state['job']['cur']['inst'] = $inst;
        $state['job']['marker']      = null;
        self::applyTransition($cfg, $state, ''); // UPDATE -> VERIFY
    }

    /**
     * Scan-Scope eines Update-Ziels: die absoluten ownedPaths-Verzeichnisse der
     * Erweiterung (dort sitzt ein boesartiger Update-Payload realistisch). admin ->
     * htp_admin_root(), site -> htp_root() (wie der Backup-Snapshot aufloest).
     *
     * @return array  Absolute, existierende Verzeichnisse
     */
    private static function scanScopeDirs($t)
    {
        $dirs = array();

        if (!class_exists('HtpUpdateBackup') || !is_array($t)) {
            return $dirs;
        }

        $owned = HtpUpdateBackup::ownedPaths((string) $t['type'], (string) $t['element'], (string) $t['folder'], (int) $t['client']);
        $adm   = rtrim(str_replace('\\', '/', htp_admin_root()), '/');
        $site  = rtrim(str_replace('\\', '/', htp_root()), '/');

        foreach ((array) $owned as $p) {
            if (!isset($p['root'], $p['rel'])) {
                continue;
            }

            $abs = (($p['root'] === 'admin') ? $adm : $site) . '/' . ltrim((string) $p['rel'], '/');

            if (@is_dir($abs)) {
                $dirs[] = $abs;
            }
        }

        return $dirs;
    }

    /**
     * Frischer Malware-Scan des Ziel-Scopes VOR dem Update. Liefert zwei Sichten:
     *   'base' => array(rel => sha1)     kompakt, wird im Job-State durabel gemerkt und
     *                                    beim Verify fuer das Delta genutzt.
     *   'abs'  => array(absoluter Pfad)  fuer den Snapshot-Ausschluss (sauberes Paket).
     * Identischer Cap/Budget wie der Verify-Scan (400/6.0) -> Vor- und Nach-Sicht decken
     * denselben Bereich ab. Fail-open (leer bei Scanner-/Zeitproblemen).
     *
     * @param   array  $t  Ziel
     *
     * @return array{base:array,abs:array}
     */
    private static function scopeMalwareState($t)
    {
        $out = array('base' => array(), 'abs' => array());

        if (empty($t) || !class_exists('HtpStorage')) {
            return $out;
        }

        $dirs = self::scanScopeDirs($t);

        if (empty($dirs)) {
            return $out;
        }

        $hits = HtpStorage::scanMalwareUnder($dirs, 400, 6.0);

        foreach ((array) $hits as $h) {
            $rel = isset($h['rel']) ? (string) $h['rel'] : '';
            $abs = isset($h['abs']) ? (string) $h['abs'] : '';

            if ($rel === '' || isset($out['base'][$rel])) {
                continue;
            }

            $hash = ($abs !== '') ? @sha1_file($abs) : false;
            // '' = Datei war vorhanden, aber (noch) nicht hashbar -> beim Delta konservativ
            // als Vor-Infektion behandeln (nicht als eingeschleust blocken).
            $out['base'][$rel] = ($hash !== false && $hash !== null) ? (string) $hash : '';

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

            if (count($out['base']) >= 500) {
                break; // State-Deckel
            }
        }

        return $out;
    }

    /**
     * Delta-Filter: aus den Nach-Update-Funden nur die behalten, die das Update
     * TATSAECHLICH eingeschleust hat - eine neue Datei ODER eine an gleicher Stelle
     * inhaltlich veraenderte Datei. Bereits vor dem Update unveraendert vorhandene
     * Schaddateien (Vor-Infektion) werden NICHT zurueckgegeben -> kein Fehl-Rollback.
     *
     * @param   array  $hits  Nach-Update-Funde (rel/abs)
     * @param   array  $base  Vor-Update-Baseline (rel => sha1)
     *
     * @return array  Nur die eingeschleusten Funde
     */
    private static function filterIntroducedMal($hits, $base)
    {
        if (!is_array($base)) {
            $base = array();
        }

        $out = array();

        foreach ((array) $hits as $h) {
            $rel = isset($h['rel']) ? (string) $h['rel'] : '';
            $abs = isset($h['abs']) ? (string) $h['abs'] : '';

            if ($rel === '') {
                $out[] = $h; // ohne Pfad nicht zuordenbar -> konservativ als Fund werten
                continue;
            }

            if (!array_key_exists($rel, $base)) {
                $out[] = $h; // vor dem Update NICHT vorhanden -> eingeschleust
                continue;
            }

            $bh = (string) $base[$rel];

            if ($bh === '') {
                continue; // war vorhanden, vorab nicht hashbar -> als Vor-Infektion behandeln
            }

            $ph = ($abs !== '') ? @sha1_file($abs) : false;

            if ($ph === false || $ph === null) {
                continue; // Nach-Datei nicht hashbar -> fail-open (nicht blocken)
            }

            if ((string) $ph !== $bh) {
                $out[] = $h; // Inhalt geaendert -> das Update hat die Datei ersetzt/eingeschleust
            }
            // sonst: byte-identisch zur Vor-Infektion -> ignorieren (kein Rollback)
        }

        return $out;
    }

    /**
     * VERIFY: Health-Delta + Versions-Bump -> success | key | fail.
     */
    private static function execVerify($cfg, &$state)
    {
        $idx = (int) $state['job']['idx'];

        if (!isset($state['job']['targets'][$idx])) {
            self::applyTransition($cfg, $state, 'skip');

            return;
        }

        $t   = $state['job']['targets'][$idx];
        $cur = isset($state['job']['cur']) ? $state['job']['cur'] : array();
        $key = (string) $t['key'];
        $eid = (int) $t['eid'];

        $inst      = isset($cur['inst']) && is_array($cur['inst']) ? $cur['inst'] : array('result' => false, 'gone' => false);
        $diskAfter = self::confirmOnDiskVersion($t['type'], $t['element'], $t['folder'], (int) $t['client'], $t['to']);
        $baseline  = isset($cur['baseline']) ? $cur['baseline'] : null;
        $after     = self::captureHealth($cfg);
        $reason    = (is_array($baseline) && is_array($after)) ? self::confirmedRegression($cfg, $baseline, $after) : '';
        $outcome   = self::installOutcome(!empty($inst['result']), !empty($inst['gone']), $t['from'], $t['to'], $diskAfter, $reason);

        // Inhalts-Pruefung: nach erfolgreichem Install die frisch geschriebenen
        // Update-Dateien (ownedPaths der Erweiterung) gegen die Malware-Signaturen
        // scannen. Ein Fund wird wie ein fehlgeschlagener Verify behandelt -> der
        // vorhandene Auto-Rollback stellt den Vor-Stand wieder her. FAIL-OPEN: ein
        // Scan-Abbruch/Timeout ist KEIN Fund (kein Fehl-Rollback bei sauberem Update).
        if ($outcome === 'success' && !empty($cfg['autoupdate_malscan']) && class_exists('HtpStorage')) {
            $malHits = HtpStorage::scanMalwareUnder(self::scanScopeDirs($t), 400, 6.0);

            // DELTA: nur wirklich durch dieses Update eingeschleuste Funde zaehlen. Eine bereits
            // vor dem Update vorhandene Infektion (Baseline aus execBackup) darf das Update nicht
            // blocken/zurueckrollen - sonst kehrt eine (evtl. verwundbare) Alt-Version zurueck und
            // die Malware bliebe ohnehin. So schluepft ein kompromittiertes Update (neue/veraenderte
            // Datei) weiter durch, eine Vor-Infektion aber nie.
            if (!empty($malHits)) {
                $malHits = self::filterIntroducedMal(
                    $malHits,
                    isset($cur['malbase']) && is_array($cur['malbase']) ? $cur['malbase'] : array()
                );
            }

            if (!empty($malHits)) {
                // Den Fund EINMAL je Version anonym an radar melden - IMMER aktiv (ohne UI/
                // Hinweis): zeigt zentral, welche Extension-Updates mit dem Auto-Updater
                // kollidieren + macht FP-Kandidaten per Feed-fp-Regel schnell fixbar. Die
                // Dateien existieren hier noch (vor dem evtl. Rollback). contrib_id persistiert
                // sich selbst; fail-soft + Timeout (eine radar-Panne darf den Update-Lauf NIE
                // stoeren); genau 1 kleiner POST je Version (firstForVer) = radar-schonend.
                $firstForVer = !isset($state['mal_block'][$key]) || (string) $state['mal_block'][$key]['ver'] !== (string) $t['to'];

                if ($firstForVer && class_exists('HtpSwarm')) {
                    $rels = array();

                    foreach ($malHits as $h) {
                        if (isset($h['rel'])) {
                            $rels[] = (string) $h['rel'];
                        }
                    }

                    $note = 'auto-update: ' . $t['name'] . ' ' . $t['from'] . '->' . $t['to']
                        . ' [' . (!empty($t['vulnerable']) ? 'warn' : 'block') . '] sig='
                        . (isset($malHits[0]['reason']) ? (string) $malHits[0]['reason'] : '');

                    // Strukturierte Meta: erlaubt radar, eine praezise Vorsorge-Sperre
                    // (element@version) abzuleiten. secupd=Sicherheitsupdate -> radar
                    // sperrt das NICHT (Lueckenschluss hat Vorrang).
                    $meta = array(
                        'kind'    => 'auto-update',
                        'key'     => (string) $t['key'],
                        'element' => strtolower((string) $t['element']),
                        'etype'   => strtolower((string) $t['type']),
                        'version' => (string) $t['to'],
                        'secupd'  => !empty($t['vulnerable']) ? 1 : 0,
                        'sig'     => isset($malHits[0]['reason']) ? (string) $malHits[0]['reason'] : '',
                    );

                    try {
                        HtpSwarm::report($cfg, 'update', $rels, $note, $meta);
                    } catch (Exception $e) {
                        // egal
                    } catch (Throwable $e) {
                        // egal
                    }
                }

                if (!empty($t['vulnerable'])) {
                    // SICHERHEITSUPDATE: der Lueckenschluss hat Vorrang -> NICHT blocken.
                    // Der Install bleibt (Loch zu), der Verdacht geht aber als WARNUNG in die
                    // Mail (kompromittiertes Security-Update? -> Nutzer kann sperren/zurueckrollen).
                    $state['job']['cur']['malwarn'] = $malHits;
                } else {
                    // Auswahl-Update (Dritt, kein Zeitdruck): blocken -> Rollback + Cooldown.
                    $state['job']['cur']['malhits'] = $malHits;
                    $outcome = 'fail';
                }
            }
        }

        $state['job']['cur']['diskAfter'] = $diskAfter;

        if ($outcome === 'success') {
            // Helix Ultimate: nach einem BESTAETIGT erfolgreichen Update den bekannten
            // Nebeneffekt automatisch reparieren - ein Template-Reinstall setzt die aktive
            // Template-Style zurueck (Header/Logo/Mega-Menue weg) bzw. das SCSS/CSS wird nicht
            // neu kompiliert ("custom style nicht geladen", JoomShaper-/joomla.de-Forum). Die
            // Reparatur ist streng No-Op-wenn-nichts-kaputt + selbst fail-safe; hier zusaetzlich
            // doppelt gekapselt, damit ein Reparatur-Fehler den erfolgreichen Update-Lauf NIE
            // stoert (HTProtect darf nie etwas brechen). Gilt fuer J4/5/6 wie fuer den J3-Patch.
            if (class_exists('HtpJoomShaperPatch')
                && strtolower((string) $t['element']) === 'helixultimate') {
                try {
                    HtpJoomShaperPatch::restoreHelixMenu();
                } catch (Exception $e) {
                    // Reparatur ist best-effort - Update-Erfolg bleibt unberuehrt.
                } catch (Throwable $e) {
                    // dito (PHP 7+ Fehler)
                }
            }

            self::applyTransition($cfg, $state, 'success'); // -> FINALIZE

            return;
        }

        if ($outcome === 'key') {
            // Nichts installiert (No-Op: Download-Key/Quelle/Netz - der genaue Grund ist
            // NICHT sicher bekannt, daher wird er nie behauptet). Datei-Rollback (no-op),
            // dann wachsender Backoff + endlicher Abbruch statt stuendlichem Dauer-Retry
            // und Log-Flut.
            HtpUpdateBackup::rollback($eid, isset($cur['snap']) ? $cur['snap'] : '', false);
            self::invalidateOpcache($t['type'], $t['element'], $t['folder'], (int) $t['client']);
            $state = self::registerKeyFail($state, $key, $eid, $t, !empty($t['vulnerable']));
            self::applyTransition($cfg, $state, 'key');

            return;
        }

        self::applyTransition($cfg, $state, 'fail'); // -> ROLLBACK-Phase
    }

    /**
     * FINALIZE: Erfolg verbuchen (Retention, Mail, Log), naechstes Ziel.
     */
    private static function execFinalize($cfg, &$state)
    {
        $idx = (int) $state['job']['idx'];

        if (!isset($state['job']['targets'][$idx])) {
            self::applyTransition($cfg, $state, '');

            return;
        }

        $t    = $state['job']['targets'][$idx];
        $cur  = isset($state['job']['cur']) ? $state['job']['cur'] : array();
        $key  = (string) $t['key'];
        $eid  = (int) $t['eid'];
        $keep = isset($cfg['autoupdate_keep']) ? (int) $cfg['autoupdate_keep'] : 1;

        HtpUpdateBackup::prune($eid, $keep);
        unset($state['suspended'][$key], $state['key_cooldown'][$key], $state['mal_block'][$key], $state['key_fails'][$key]);

        $okVer = (isset($cur['diskAfter']) && $cur['diskAfter'] !== '') ? (string) $cur['diskAfter'] : (string) $t['to'];

        $sumEntry = array('name' => $t['name'], 'from' => $t['from'], 'to' => $okVer, 'result' => 'ok', 'security' => !empty($t['vulnerable']) ? 1 : 0, 'key' => (string) $t['key']);
        $warn     = isset($cur['malwarn']) && is_array($cur['malwarn']) ? $cur['malwarn'] : array();

        if (!empty($warn)) {
            // Sicherheitsupdate eingespielt, aber verdaechtiger Code -> Warn-Markierung.
            $sumEntry['malwarn'] = array_slice(array_map(static function ($h) {
                return isset($h['rel']) ? (string) $h['rel'] : '';
            }, $warn), 0, 6);
        }

        self::pushSummary($state, $sumEntry);
        $state = self::logResult($state, $key, $eid, $t['name'], $t['from'], $okVer, 'ok', !empty($warn) ? 'malwarn' : '');
        self::applyTransition($cfg, $state, ''); // FINALIZE -> naechstes Ziel
    }

    /**
     * ROLLBACK: Ziel zurueckrollen (+ Flatter-Schutz + Mail), DANACH Job STOPPEN.
     * Auch fuer die Crash-Recovery ($isCrash).
     */
    private static function execRollback($cfg, &$state, $isCrash)
    {
        $idx = (int) $state['job']['idx'];
        $t   = isset($state['job']['targets'][$idx]) ? $state['job']['targets'][$idx] : null;

        if (is_array($t)) {
            $cur  = isset($state['job']['cur']) ? $state['job']['cur'] : array();
            $key  = (string) $t['key'];
            $eid  = (int) $t['eid'];
            $snap = isset($cur['snap']) ? (string) $cur['snap'] : '';

            $rb = $snap !== '' ? HtpUpdateBackup::rollback($eid, $snap, true) : array('ok' => false);
            self::invalidateOpcache($t['type'], $t['element'], $t['folder'], (int) $t['client']);

            $baseline  = isset($cur['baseline']) ? $cur['baseline'] : null;
            $reAfter   = self::captureHealth($cfg);
            $reProbeOk = !is_array($baseline) || !is_array($reAfter) || self::healthRegressed($baseline, $reAfter) === '';

            $malHits = isset($cur['malhits']) && is_array($cur['malhits']) ? $cur['malhits'] : array();

            if (!empty($malHits)) {
                // MALWARE-Block: versions-bewusster Cooldown-Retry statt Dauer-Aussetzen,
                // damit ein spaeter korrigiertes Signatur-/fp-Update beim naechsten Versuch
                // durchzieht; erst nach hartnaeckigen Blocks DERSELBEN Version endgueltig aus.
                $files  = array_slice(array_map(static function ($h) {
                    return isset($h['rel']) ? (string) $h['rel'] : '';
                }, $malHits), 0, 6);
                $reason = isset($malHits[0]['reason']) ? (string) $malHits[0]['reason'] : '';

                $mb = self::malBlockOnHit(
                    isset($state['mal_block'][$key]) ? $state['mal_block'][$key] : null,
                    $t['to'], time(), self::MALSCAN_COOLDOWN, self::MALSCAN_MAX_BLOCKS, $files, $reason
                );
                $state['mal_block'][$key] = $mb['entry'];

                $result = !empty($mb['final']) ? 'suspend' : 'rollback';
                $state  = self::logResult($state, $key, $eid, $t['name'], $t['from'], $t['to'], $result, 'malware');

                // Anti-Spam: nur Erst-Block + Aufgeben mailen, Zwischen-Retries still.
                if (!empty($mb['mail'])) {
                    self::pushSummary($state, array(
                        'name' => $t['name'], 'from' => $t['from'], 'to' => $t['to'],
                        'result' => 'rollback', 'security' => 0, 'reason' => 'malware',
                        'malstate' => !empty($mb['final']) ? 'giveup' : 'retry', 'malfiles' => $files,
                    ));
                }
            } else {
                // Normaler Fehlschlag (Health/Install/Crash): Flatter-Schutz wie gehabt.
                $fails = isset($state['suspended'][$key]['fails']) ? (int) $state['suspended'][$key]['fails'] : 0;
                $fails++;
                $suspend = self::shouldSuspend($fails);

                $state['suspended'][$key] = array('fails' => $fails, 'since' => time(), 'suspended' => $suspend ? 1 : 0);

                $result = $suspend ? 'suspend' : 'rollback';
                $note   = !empty($rb['ok']) ? ($isCrash ? 'crash' : '') : 'rollback-incomplete';
                $state  = self::logResult($state, $key, $eid, $t['name'], $t['from'], $t['to'], $result, $note);
                self::pushSummary($state, array('name' => $t['name'], 'from' => $t['from'], 'to' => $t['to'], 'result' => $result, 'security' => 0));
            }
        }

        $state['job']['marker'] = null;
        $state['job']['phase']  = 'ROLLBACK';
        self::applyTransition($cfg, $state, ''); // ROLLBACK -> done + stopped
    }

    /**
     * Crash waehrend BACKUP: Teil-Backup verwerfen, Ziel zaehlen/parken, Job beenden
     * (naechster Lauf plant frisch; das angreifbare/ausgewaehlte Ziel bleibt fuer den
     * naechsten regulaeren Lauf - bei Cap als needs-manual ausgesetzt).
     */
    private static function execDiscardBackup($cfg, &$state)
    {
        $idx = (int) $state['job']['idx'];
        $t   = isset($state['job']['targets'][$idx]) ? $state['job']['targets'][$idx] : null;

        if (is_array($t)) {
            $eid  = (int) $t['eid'];
            $key  = (string) $t['key'];
            $keep = isset($cfg['autoupdate_keep']) ? (int) $cfg['autoupdate_keep'] : 1;

            HtpUpdateBackup::prune($eid, max(1, $keep)); // Teil-Backup verwerfen

            $fails = isset($state['suspended'][$key]['fails']) ? (int) $state['suspended'][$key]['fails'] : 0;
            $fails++;
            $suspend = self::shouldSuspend($fails);
            $state['suspended'][$key] = array('fails' => $fails, 'since' => time(), 'suspended' => $suspend ? 1 : 0);
            $state = self::logSkip($state, $key, $eid, $t['name'], $t['from'], $t['to'], 'backup');
        }

        unset($state['job']);
        $state['last_run'] = time();
    }

    /** Routine-Id der Scheduler-Aufgabe (vom Task-Plugin gemeldet/ausgefuehrt). */
    const TASK_ROUTINE = 'htp.autoupdate';

    /**
     * Beim AKTIVIEREN des Auto-Updates (>= J5.4): Task-Plugin installieren+aktivieren
     * + idempotent eine Scheduler-Zeile anlegen. Beim DEAKTIVIEREN: Zeile pausieren.
     * Best effort - darf das Speichern nie hart stoeren.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return void
     */
    public static function syncTaskPlugin($cfg)
    {
        if (!self::menuEligible()) {
            return; // kein com_scheduler < J5.4
        }

        try {
            // Feature dormant (in Arbeit) -> NICHT provisionieren; eine evtl. frueher
            // angelegte Scheduler-Zeile pausieren (deaktiviert die Ausfuehrung).
            if (!self::featureVisible()) {
                self::pauseTask();

                return;
            }

            if (!empty($cfg['autoupdate_ext']) || !empty($cfg['autoupdate_notify'])) {
                self::ensureTaskPlugin();
                self::provisionTask($cfg);
            } else {
                self::pauseTask();
            }
        } catch (Exception $e) {
            // best effort
        } catch (Throwable $e) {
            // best effort
        }
    }

    /**
     * Mitgeliefertes Task-Plugin installieren (falls noetig) + aktivieren. Folgt dem
     * Muster des Waechter-Plugins (htp_install_plugin_clean), aktiviert danach den
     * Eintrag (der Installer legt Plugins deaktiviert an).
     *
     * @return void
     */
    private static function ensureTaskPlugin()
    {
        if (!function_exists('htp_install_plugin_clean') || !defined('HTP_CORE')) {
            return;
        }

        $info = function_exists('htp_extension_enabled') ? htp_extension_enabled('plugin', 'htpautoupdate', 'task') : array();

        if (empty($info['installed'])) {
            $dir = realpath(HTP_CORE . '/../assets/plg_task_htpautoupdate');

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

            htp_install_plugin_clean($dir, 'htpautoupdate', 'task');
        }

        // Aktivieren (#__extensions.enabled = 1).
        $db = function_exists('htp_db') ? htp_db() : null;

        if ($db) {
            try {
                $u = $db->getQuery(true)
                    ->update($db->quoteName('#__extensions'))
                    ->set($db->quoteName('enabled') . ' = 1')
                    ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
                    ->where($db->quoteName('element') . ' = ' . $db->quote('htpautoupdate'))
                    ->where($db->quoteName('folder') . ' = ' . $db->quote('task'));
                $db->setQuery($u);
                $db->execute();
            } catch (Exception $e) {
                // egal
            } catch (Throwable $e) {
                // egal
            }
        }
    }

    /**
     * Idempotente Scheduler-Zeile (#__scheduler_tasks) fuer die Routine htp.autoupdate
     * anlegen/aktivieren. Regel: alle 15 Min (interval-minutes / ISO PT15M). created_by
     * = realer Super-User aus der Konfiguration (nie Request).
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return boolean
     */
    public static function provisionTask($cfg)
    {
        $db = function_exists('htp_db') ? htp_db() : null;

        if (!$db) {
            return false;
        }

        try {
            $sel = $db->getQuery(true)
                ->select($db->quoteName('id'))
                ->from($db->quoteName('#__scheduler_tasks'))
                ->where($db->quoteName('type') . ' = ' . $db->quote(self::TASK_ROUTINE));
            $db->setQuery($sel, 0, 1);
            $id = (int) $db->loadResult();

            if ($id > 0) {
                // Vorhanden -> nur (re)aktivieren, Regel/Notiz des Nutzers nicht ueberschreiben.
                $upd = $db->getQuery(true)
                    ->update($db->quoteName('#__scheduler_tasks'))
                    ->set($db->quoteName('state') . ' = 1')
                    ->where($db->quoteName('id') . ' = ' . $id);
                $db->setQuery($upd);
                $db->execute();

                return true;
            }

            $title = function_exists('htp_t') ? htp_t('COM_HTPROTECT_AU_TASK_TITLE') : 'HTProtect Auto-Update';

            if ($title === '' || $title === 'COM_HTPROTECT_AU_TASK_TITLE') {
                $title = 'HTProtect Auto-Update';
            }

            $exec = json_encode(array('rule-type' => 'interval-minutes', 'interval-minutes' => 15, 'exec-time' => '00:00', 'exec-day' => '1'));
            $cron = json_encode(array('type' => 'interval', 'exp' => 'PT15M'));
            $nowU = gmdate('Y-m-d H:i:s'); // Scheduler speichert UTC
            $uid  = (int) self::findSuperUserId();

            $cols = array('title', 'type', 'execution_rules', 'cron_rules', 'state', 'last_exit_code',
                'next_execution', 'times_executed', 'times_failed', 'priority', 'ordering', 'cli_exclusive',
                'params', 'created', 'created_by');

            $vals = array(
                $db->quote($title), $db->quote(self::TASK_ROUTINE), $db->quote($exec), $db->quote($cron),
                '1', '0', $db->quote($nowU), '0', '0', '0', '0', '0', $db->quote('{}'), $db->quote($nowU), (string) $uid,
            );

            $ins = $db->getQuery(true)
                ->insert($db->quoteName('#__scheduler_tasks'))
                ->columns($db->quoteName($cols))
                ->values(implode(',', $vals));
            $db->setQuery($ins);
            $db->execute();

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

    /**
     * Scheduler-Zeile pausieren (Deaktivieren des Auto-Updates) - nicht loeschen, damit
     * eine etwaige Nutzer-Regel erhalten bleibt.
     *
     * @return void
     */
    public static function pauseTask()
    {
        $db = function_exists('htp_db') ? htp_db() : null;

        if (!$db) {
            return;
        }

        try {
            $upd = $db->getQuery(true)
                ->update($db->quoteName('#__scheduler_tasks'))
                ->set($db->quoteName('state') . ' = 0')
                ->where($db->quoteName('type') . ' = ' . $db->quote(self::TASK_ROUTINE));
            $db->setQuery($upd);
            $db->execute();
        } catch (Exception $e) {
            // egal
        } catch (Throwable $e) {
            // egal
        }
    }

    /**
     * Scheduler-Zeile entfernen (bei Komponenten-Uninstall). Best effort.
     *
     * @return void
     */
    public static function removeTask()
    {
        $db = function_exists('htp_db') ? htp_db() : null;

        if (!$db) {
            return;
        }

        try {
            $del = $db->getQuery(true)
                ->delete($db->quoteName('#__scheduler_tasks'))
                ->where($db->quoteName('type') . ' = ' . $db->quote(self::TASK_ROUTINE));
            $db->setQuery($del);
            $db->execute();
        } catch (Exception $e) {
            // egal
        } catch (Throwable $e) {
            // egal
        }
    }

    /**
     * Health-Momentaufnahme (Front + Back) - Reuse von HtpProbe::fetch.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array|null  Messwerte oder null (kein HTTP-Client)
     */
    public static function captureHealth($cfg)
    {
        if (!class_exists('HtpProbe') || !HtpProbe::available()) {
            return null;
        }

        // Echte Site-URL (CLI-sicher). Fehlt eine verlaessliche URL -> Health-Probe
        // ueberspringen (null); dann entscheidet allein der Versions-Bump (kein
        // Fehl-Rollback). S. selfSiteUrl() zur Begruendung (joomla.invalid-Platzhalter).
        $site = self::selfSiteUrl($cfg);

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

        $home  = HtpProbe::fetch($site, true);
        $admin = HtpProbe::fetch(rtrim($site, '/') . '/administrator/index.php', false);

        $hs = (int) $home['status'];

        if (HtpProbe::isRedirectLoop($hs)) {
            $hs = 599; // Schleife = nicht erreichbar
        }

        return array(
            'home_status'  => $hs,
            'home_len'     => isset($home['body']) ? strlen((string) $home['body']) : 0,
            'home_fatal'   => self::looksFatal(isset($home['body']) ? (string) $home['body'] : ''),
            'admin_status' => (int) $admin['status'],
        );
    }

    /**
     * Echte, CLI-sichere Basis-URL der eigenen Site (mit abschliessendem Slash) -
     * oder '' wenn unbekannt. WICHTIG fuer den Scheduler-/CLI-Kontext: dort liefert
     * JUri::root() (htp_site_url) Joomlas Platzhalter https://joomla.invalid/... -
     * eine Health-Probe darauf scheitert IMMER (Fehl-Rollback) und in Mails stuende
     * eine Bogus-Site-URL. Quelle daher htp_trusted_base (live_site bzw. die im
     * Backend erfasste echte URL/unsub_base); Platzhalter werden zu '' entschaerft.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return string  https?://host/  oder ''
     */
    private static function selfSiteUrl($cfg)
    {
        $site = function_exists('htp_trusted_base')
            ? htp_trusted_base(isset($cfg['unsub_base']) ? (string) $cfg['unsub_base'] : '', true)
            : '';

        if (!is_string($site)
            || !preg_match('~^https?://~i', $site)
            || strpos($site, 'joomla.invalid') !== false) {
            return '';
        }

        return $site;
    }

    /**
     * Sprache fuer Hintergrund-Benachrichtigungen laden. Im CLI-/Scheduler-Kontext ist
     * die aktive Sprache en-GB; die Mail soll aber der konfigurierten Backend-Sprache
     * folgen. Laedt en-GB als Basis (Fallback fuer fehlende Keys) und die Zielsprache
     * darueber. Best-effort, fail-open.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return void
     */
    private static function loadNotifyLang($cfg)
    {
        // Einheitliche Sprachwahl (identisch fuer ALLE HTProtect-Mails).
        if (class_exists('HtpMailLang')) {
            HtpMailLang::load($cfg);

            return;
        }

        try {
            if (!class_exists('Joomla\\CMS\\Factory')) {
                if (function_exists('htp_load_lang')) {
                    htp_load_lang();
                }

                return;
            }

            $app  = Joomla\CMS\Factory::getApplication();
            $base = defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR
                : (defined('JPATH_ROOT') ? JPATH_ROOT . '/administrator' : '');

            if (!$app || !method_exists($app, 'getLanguage') || $base === '') {
                if (function_exists('htp_load_lang')) {
                    htp_load_lang();
                }

                return;
            }

            $lang = $app->getLanguage();

            if (!$lang || !method_exists($lang, 'load')) {
                return;
            }

            $tag = self::notifyLangTag($cfg);

            // HTProtect liefert nur de-DE + en-GB. Jede deutsche Variante (de-AT, de-CH,
            // de-LI, de-LU ...) daher auf de-DE abbilden - sonst gaebe es mangels eigener
            // .ini eine englische Mail fuer z. B. de-AT-Admins.
            if (strncasecmp($tag, 'de', 2) === 0) {
                $tag = 'de-DE';
            }

            // en-GB als Basis, dann die Zielsprache darueber (ueberschreibt vorhandene
            // Keys, en-GB fuellt etwaige Luecken). $default = false: nur genau diese.
            $lang->load('com_htprotect', $base, 'en-GB', true, false);

            if ($tag !== '' && $tag !== 'en-GB') {
                $lang->load('com_htprotect', $base, $tag, true, false);
            }
        } catch (Exception $e) {
            // fail-open
        } catch (Throwable $e) {
            // fail-open
        }
    }

    /**
     * Sprach-Tag fuer Benachrichtigungen bestimmen: 1) Backend-Sprache des Empfaengers
     * (falls notify_email ein Joomla-User mit eigener Sprache ist), 2) globale
     * Standard-Admin-/Site-Sprache (com_languages-Params, in Erweiterungen -> Sprachen
     * gewaehlt), 3) Global-Config-/Default-Content-Sprache, 4) en-GB. Rein lesend, fail-open.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return string  z. B. 'de-DE'
     */
    private static function notifyLangTag($cfg)
    {
        // Einheitliche Sprachwahl (identisch fuer ALLE HTProtect-Mails).
        if (class_exists('HtpMailLang')) {
            return HtpMailLang::tag($cfg);
        }

        // 1) Backend-Sprache des Empfaengers.
        $email = isset($cfg['notify_email']) ? (string) $cfg['notify_email'] : '';
        $email = trim((string) preg_replace('~[,;\s].*$~s', '', $email));
        $db    = htp_db();

        if ($db && $email !== '' && strpos($email, '@') !== false) {
            try {
                $q = $db->getQuery(true)
                    ->select($db->quoteName('params'))
                    ->from($db->quoteName('#__users'))
                    ->where($db->quoteName('email') . ' = ' . $db->quote($email));
                $db->setQuery($q, 0, 1);
                $p = json_decode((string) $db->loadResult(), true);

                if (is_array($p)) {
                    foreach (array('admin_language', 'language') as $k) {
                        if (!empty($p[$k]) && preg_match('~^[a-z]{2,3}-[A-Z]{2}$~', (string) $p[$k])) {
                            return (string) $p[$k];
                        }
                    }
                }
            } catch (Exception $e) {
                // weiter mit Fallback
            } catch (Throwable $e) {
                // weiter
            }
        }

        // 2) Globale Backend-/Site-Default-Sprache: die unter "Erweiterungen -> Sprachen"
        //    gewaehlte Standard-Administrator-/Site-Sprache (in den com_languages-Params).
        //    Das ist die "globale Backend-Sprache" - greift, wenn der Empfaenger keine
        //    eigene Sprache gesetzt hat. (Bevorzugt administrator, dann site.)
        if ($db) {
            try {
                $q = $db->getQuery(true)
                    ->select($db->quoteName('params'))
                    ->from($db->quoteName('#__extensions'))
                    ->where($db->quoteName('element') . ' = ' . $db->quote('com_languages'));
                $db->setQuery($q, 0, 1);
                $lp = json_decode((string) $db->loadResult(), true);

                if (is_array($lp)) {
                    foreach (array('administrator', 'site') as $k) {
                        if (!empty($lp[$k]) && preg_match('~^[a-z]{2,3}-[A-Z]{2}$~', (string) $lp[$k])) {
                            return (string) $lp[$k];
                        }
                    }
                }
            } catch (Exception $e) {
                // weiter
            } catch (Throwable $e) {
                // weiter
            }
        }

        // 3) Konfigurierte Standard-Sprache (Global-Config) bzw. Default-Content-Sprache.
        try {
            if (class_exists('Joomla\\CMS\\Factory')) {
                $app = Joomla\CMS\Factory::getApplication();
                $tag = ($app && method_exists($app, 'get')) ? (string) $app->get('language', '') : '';

                if (preg_match('~^[a-z]{2,3}-[A-Z]{2}$~', $tag)) {
                    return $tag;
                }
            }

            if (class_exists('Joomla\\CMS\\Language\\LanguageHelper')) {
                $def = (string) Joomla\CMS\Language\LanguageHelper::getDefault();

                if (preg_match('~^[a-z]{2,3}-[A-Z]{2}$~', $def)) {
                    return $def;
                }
            }
        } catch (Exception $e) {
            // weiter
        } catch (Throwable $e) {
            // weiter
        }

        return 'en-GB';
    }

    /**
     * Log-Eintrag "skip" (uebersprungen) anhaengen.
     *
     * @return array  State
     */
    private static function logSkip($state, $key, $eid, $name, $from, $to, $note)
    {
        return self::logResult($state, $key, $eid, $name, $from, $to, 'skip', $note);
    }

    /**
     * Beliebigen Log-Eintrag anhaengen.
     *
     * @return array  State
     */
    private static function logResult($state, $key, $eid, $name, $from, $to, $result, $note)
    {
        $state['history'] = self::pushHistory(
            isset($state['history']) ? $state['history'] : array(),
            array('t' => time(), 'key' => $key, 'eid' => (int) $eid, 'name' => $name,
                  'from' => $from, 'to' => $to, 'result' => $result, 'note' => $note)
        );

        return $state;
    }

    // ═════════════════════════════════════════════════════════════════
    //  §7: HTProtect-Selbst-Update (eigener, extra-vorsichtiger Pfad)
    // ═════════════════════════════════════════════════════════════════

    /**
     * HTProtect aktualisiert sich SELBST (plan 7). Schluessel-Einsicht: der laufende
     * Prozess hat den ALTEN HTProtect-Code bereits im Speicher (bootstrap require_once
     * beim Request-Start) - das Ersetzen der Dateien aendert daran fuer DIESEN Request
     * nichts. Die alte, in-memory laufende Orchestrierung fuehrt also Backup -> Install
     * -> Probe -> ggf. Rollback selbst zu Ende, auch wenn die neue Version auf Platte
     * kaputt waere. data/-sicher (snapshotSelf/rollback mit Ouroboros-Schutz).
     *
     * @param   array  $cfg   Konfiguration
     * @param   array  $info  Selbst-Update-Kandidat (com_htprotect)
     * @param   array  $state Tick-State
     *
     * @return array  aktualisierter State
     */
    private static function updateSelf($cfg, $info, $state)
    {
        $eid     = (int) $info['eid'];
        $key     = self::extKey('component', 'com_htprotect', '');
        $fromVer = (string) $info['installed'];
        $toVer   = (string) $info['available'];
        $name    = 'HTProtect';
        $keep    = isset($cfg['autoupdate_keep']) ? (int) $cfg['autoupdate_keep'] : 1;

        if (!self::isUpgrade($fromVer, $toVer)) {
            return self::logSkip($state, $key, $eid, $name, $fromVer, $toVer, 'downgrade');
        }

        // Backup von HTProtect selbst (Dateien, OHNE data/). Schlaegt es fehl/ist es
        // unvollstaendig -> NICHT wagen.
        $snapId = HtpUpdateBackup::snapshotSelf($eid, $fromVer);

        if ($snapId === '' || !HtpUpdateBackup::verify($eid, $snapId)) {
            if ($snapId !== '') {
                HtpUpdateBackup::prune($eid, max(1, $keep));
            }

            return self::logSkip($state, $key, $eid, $name, $fromVer, $toVer, 'backup');
        }

        // Crash-Marker (mit self-Flag -> Recovery nutzt den data/-sicheren Rollback).
        self::writeMarker(array(
            'eid' => $eid, 'snap' => $snapId, 'type' => 'component', 'element' => 'com_htprotect',
            'folder' => '', 'client' => 1, 'name' => $name, 'from' => $fromVer, 'to' => $toVer,
            'self' => 1, 'started' => time(),
        ));

        $before = self::captureHealth($cfg);
        $inst   = self::installUpdate((int) $info['update_id']);

        self::invalidateOpcacheSelf();

        $diskAfter = self::confirmOnDiskVersion('component', 'com_htprotect', '', 1, $toVer);
        $after     = self::captureHealth($cfg);
        $reason    = (is_array($before) && is_array($after)) ? self::confirmedRegression($cfg, $before, $after) : '';
        $outcome   = self::installOutcome($inst['result'], $inst['gone'], $fromVer, $toVer, $diskAfter, $reason);

        if ($outcome === 'success') {
            self::clearMarker();
            HtpUpdateBackup::prune($eid, $keep);
            unset($state['suspended'][$key], $state['key_cooldown'][$key], $state['mal_block'][$key], $state['key_fails'][$key]);
            unset($state['self_crash']); // sauberer Self-Update -> Crash-Bremse zuruecksetzen

            // Frische Version = frische Feeds: nach erfolgreichem Selbst-Update die Schutz-Feeds
            // SOFORT neu ziehen (refresh(true) umgeht die 6h-Sperre) statt bis zum naechsten
            // 6h-Fenster zu warten. ERGAENZUNG zum Automatik-Zyklus (der bleibt unveraendert).
            // Laeuft noch im alten Code (der Dateitausch wirkt erst im naechsten Request) - egal:
            // der data/-Cache ist versions-stabil und wird vom neuen Code gelesen. Fail-soft +
            // ETag-konditional (meist nur 304) -> darf den Erfolgspfad nie stoeren.
            if (class_exists('HtpDefs')) {
                try {
                    HtpDefs::refresh(true);
                    if (method_exists('HtpDefs', 'refreshMalwareSigs')) {
                        HtpDefs::refreshMalwareSigs(true);
                    }
                } catch (Exception $e) {
                } catch (Throwable $e) {
                }
            }

            $okVer = $diskAfter !== '' ? $diskAfter : $toVer;
            // HTProtect-SELBST-Update laeuft IMMER STILL - KEIN Erfolgs-Mail (bewusst
            // ungegated, unabhaengig von autoupdate_mail_success). Bei 2+ Releases/Tag
            // wuerde sonst jede Selbst-Aktualisierung die Admins zumuellen. Fremd-Update-
            // Erfolge meldet weiterhin die Sammel-Mail (gemaess Schalter); Fehlschlag/
            // Rollback/Host-Wechsel bei HTProtect selbst melden ohnehin separat.

            return self::logResult($state, $key, $eid, $name, $fromVer, $okVer, 'ok', '');
        }

        // Nicht-Erfolg -> der noch-im-Speicher-alte Code rollt zurueck (data/-sicher).
        $rb = HtpUpdateBackup::rollback($eid, $snapId, false);
        self::invalidateOpcacheSelf();
        self::selfVersionReset($fromVer); // DB-Versionszeiger explizit auf die alte Version
        self::clearMarker();

        if ($outcome === 'key') {
            // HTProtect-Selbst-Update ist sicherheitsrelevant -> kurzer Cooldown, damit ein
            // transienter Quellen-Blip die eigene Aktualisierung nicht 3 Tage still aufhaelt.
            $state['key_cooldown'][$key] = time() + self::keyCooldownSecs(true);

            return self::logSkip($state, $key, $eid, $name, $fromVer, $toVer, 'key');
        }

        $fails = isset($state['suspended'][$key]['fails']) ? (int) $state['suspended'][$key]['fails'] : 0;
        $fails++;
        $suspend = self::shouldSuspend($fails);
        $state['suspended'][$key] = array('fails' => $fails, 'since' => time(), 'suspended' => $suspend ? 1 : 0);

        $state = self::logResult($state, $key, $eid, $name, $fromVer, $toVer,
            $suspend ? 'suspend' : 'rollback', !empty($rb['ok']) ? '' : 'rollback-incomplete');
        self::mailRollback($cfg, $name, $fromVer, $toVer, !empty($rb['ok']), true, $suspend);

        return $state;
    }

    /**
     * HTProtects EIGENE Sprachpakete (file_htprotect_*) still mitziehen - gekoppelt an den Self-
     * Update-Schalter (autoupdate_self, s. Aufrufer). de/en stecken im com_htprotect-Paket und
     * laufen ueber das normale Self-Update; die Zusatz-Sprachen sind separate File-Erweiterungen
     * (eigener Update-Server) und wuerden sonst NIE von HTProtect aktualisiert - denn supportedType
     * laesst 'file' bewusst aussen vor (Snapshot/Rollback greift bei verstreuten .ini nicht). Da es
     * NUR HTProtects eigene .ini-Pakete sind (risikoarm), wird hier OHNE Snapshot/Rollback direkt
     * installiert: schlaegt es fehl, bleibt schlicht die alte Uebersetzung stehen (kein Funktions-
     * verlust). STILL (kein Mail), pro Tick gedeckelt, mit kurzem Fehl-Cooldown gegen Dauer-Retry.
     *
     * @param   array  $cfg    Konfiguration
     * @param   array  $state  Tick-State
     *
     * @return  array  aktualisierter State
     */
    private static function updateOwnLangpacks($cfg, $state)
    {
        $db = function_exists('htp_db') ? htp_db() : null;

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

        // HTProtects eigene File-Sprachpakete MIT anstehendem Update (INNER JOIN auf #__updates).
        // Grobfilter %htprotect% in SQL, exaktes Praefix dann in PHP (LIKE-Underscore ist ein
        // Platzhalter, kein Literal - Escaping-Fallen vermeiden).
        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName(array('e.extension_id', 'e.element', 'e.name', 'e.manifest_cache')))
                ->select($db->quoteName('u.update_id', 'update_id'))
                ->select($db->quoteName('u.version', 'avail'))
                ->from($db->quoteName('#__extensions', 'e'))
                ->innerJoin($db->quoteName('#__updates', 'u')
                    . ' ON ' . $db->quoteName('u.extension_id') . ' = ' . $db->quoteName('e.extension_id'))
                ->where($db->quoteName('e.type') . ' = ' . $db->quote('file'))
                ->where($db->quoteName('e.element') . ' LIKE ' . $db->quote('%htprotect%'));
            $db->setQuery($q, 0, 12);
            $rows = $db->loadAssocList();
        } catch (Exception $e) {
            return $state;
        } catch (Throwable $e) {
            return $state;
        }

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

        $now  = time();
        $done = 0;

        foreach ($rows as $row) {
            if ($done >= 3) {
                break; // pro Tick deckeln - der naechste Tick holt den Rest
            }

            $element = isset($row['element']) ? (string) $row['element'] : '';

            // Exakt HTProtects eigenes File-Langpack-Muster (file_htprotect_<tag>).
            if (strpos(strtolower($element), 'file_htprotect_') !== 0) {
                continue;
            }

            // de/en stecken im com_htprotect-Paket (Self-Update) -> nie als separates File-Paket.
            $tag = strtolower(substr($element, strlen('file_htprotect_')));

            if ($tag === 'de_de' || $tag === 'en_gb') {
                continue;
            }

            // Kurzer Fehl-Cooldown: ein kaputtes Sprachpaket-Update nicht jeden Tick neu versuchen.
            $cdKey = strtolower($element);

            if (isset($state['lang_cd'][$cdKey]) && $now < (int) $state['lang_cd'][$cdKey]) {
                continue;
            }

            $eid       = (int) $row['extension_id'];
            $updateId  = (int) $row['update_id'];
            $avail     = (string) $row['avail'];
            $installed = self::manifestVersion(isset($row['manifest_cache']) ? $row['manifest_cache'] : '');

            if ($updateId <= 0 || !self::isUpgrade($installed, $avail)) {
                continue;
            }

            $inst = self::installUpdate($updateId);
            $ok   = !empty($inst['result']) || !empty($inst['gone']);
            $key  = self::extKey('file', $element, '');
            $name = isset($row['name']) ? (string) $row['name'] : '';
            $name = ($name === '' || preg_match('~^[A-Z][A-Z0-9_]+$~', $name)) ? $element : $name;
            $done++;

            if ($ok) {
                unset($state['lang_cd'][$cdKey]);
                $state = self::logResult($state, $key, $eid, $name, $installed, $avail, 'ok', '');
            } else {
                $state['lang_cd'][$cdKey] = $now + 43200; // 12 h Ruhe nach Fehlschlag
                $state = self::logSkip($state, $key, $eid, $name, $installed, $avail, 'lang');
            }
        }

        return $state;
    }

    /**
     * Nach einem HTProtect-SELBST-Rollback den DB-Versionszeiger (#__extensions.
     * manifest_cache der Komponente) EXPLIZIT auf die wiederhergestellte ALTE Version
     * setzen. rollback() ueberspringt die Registry-Reconciliation fuer den Selbst-Fall
     * (Ouroboros-Schutz: keine Fremd-Tabellen anfassen, waehrend HTProtect sich selbst
     * ersetzt); hatte der abgebrochene Install den Zeiger aber schon auf NEU gebumpt,
     * bliebe Datei=ALT / DB=NEU zurueck -> Joomla boete das (evtl. Sicherheits-)Update
     * nie wieder an, obwohl der ALTE Code laeuft. Dieser gezielte Metadaten-Fix (nur die
     * eigene Zeile, kein Installer/Script) stellt Konsistenz her. Idempotent (schreibt
     * nur bei Abweichung), fail-silent, self-contained (laeuft auch bei degradierter
     * Engine ohne HtpApp).
     *
     * @param   string  $ver  Wiederhergestellte (alte) Version
     *
     * @return void
     */
    private static function selfVersionReset($ver)
    {
        $ver = (string) $ver;
        $db  = ($ver !== '' && function_exists('htp_db')) ? htp_db() : null;

        if (!$db) {
            return;
        }

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

            if (!is_array($row) || empty($row['extension_id']) || empty($row['manifest_cache'])) {
                return;
            }

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

            if (!is_array($mc) || !isset($mc['version']) || (string) $mc['version'] === $ver) {
                return; // fehlt oder bereits ALT -> nichts zu tun
            }

            $mc['version'] = $ver;

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

    /**
     * opcache der com_htprotect-Dateien invalidieren (Selbst-Update), damit die
     * Health-Probe (separater Request) die NEUE Version laedt. data/ wird nicht
     * angefasst.
     *
     * @return void
     */
    private static function invalidateOpcacheSelf()
    {
        if (!function_exists('opcache_invalidate')) {
            return;
        }

        // Gezielt die Code-Unterordner - NICHT data/ (Backups; nur No-op-Treffer +
        // ggf. grosser Baum). htprotect.php als Einstieg zusaetzlich.
        $admin = htp_admin_root() . '/components/com_htprotect';

        foreach (array('core', 'services', 'src', 'assets') as $sub) {
            self::invalidatePhpUnder($admin . '/' . $sub);
        }

        if (is_file($admin . '/htprotect.php')) {
            @opcache_invalidate($admin . '/htprotect.php', true);
        }

        // Site-Anteil (meist klein/leer).
        self::invalidatePhpUnder(htp_root() . '/components/com_htprotect');
    }

    // ═════════════════════════════════════════════════════════════════
    //  §7b: Backup vor MANUELLEM Update (Installer-Hook, eigener Schalter)
    // ═════════════════════════════════════════════════════════════════

    /**
     * Vorab-Backup VOR einem MANUELLEN Erweiterungs-Update (Joomlas
     * "Aktualisieren"-Knopf). Wird vom Installer-Hook `onExtensionBeforeInstall`
     * des Plugins mit Route 'update' aufgerufen - BEVOR die neuen Dateien die alten
     * ueberschreiben (alter Stand noch da). NIE blockierend, strikt fail-open:
     * scheitert das Backup, laeuft das (vom Admin ausgeloeste) Update trotzdem,
     * nur Hinweis "kein Rollback-Punkt". KEIN automatischer Health-Rollback (der
     * Admin ist live dabei) - Netz = behaltenes Backup + Ein-Klick-Rollback.
     *
     * Synchron im Admin-Request -> grosse Backups werden in der Engine gecappt.
     *
     * @param   string  $type      Erweiterungstyp (Event-Arg, oft leer)
     * @param   mixed   $manifest  Manifest (SimpleXMLElement) aus dem Event
     * @param   mixed   $eid       Extension-ID aus dem Event
     *
     * @return void
     */
    public static function manualBackupFromEvent($type, $manifest, $eid)
    {
        try {
            if (!self::menuEligible() || !class_exists('HtpConfig')) {
                return;
            }

            $cfg = HtpConfig::load();

            if (empty($cfg['autoupdate_manual'])) {
                return;
            }

            $info = self::resolveExtension($eid, $type, $manifest);

            if ($info === null) {
                return; // Erweiterung nicht eindeutig bestimmbar -> kein Backup (kein Block)
            }

            self::manualBackup($cfg, $info);
        } catch (Exception $e) {
            // fail-open: das manuelle Update darf nie an uns scheitern
        } catch (Throwable $e) {
            // dito
        }
    }

    /**
     * Extension-Identitaet fuer das manuelle Backup ermitteln (bevorzugt ueber die
     * Event-extension_id aus #__extensions). Liefert null, wenn nicht eindeutig
     * bestimmbar/unterstuetzt.
     *
     * @param   mixed   $eid       Extension-ID
     * @param   string  $type      Typ (Event)
     * @param   mixed   $manifest  Manifest (ungenutzt; Reserve)
     *
     * @return array|null
     */
    private static function resolveExtension($eid, $type, $manifest)
    {
        $eid = (int) $eid;
        $db  = htp_db();

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

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName(array('extension_id', 'type', 'element', 'folder', 'client_id', 'manifest_cache')))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('extension_id') . ' = ' . $eid);
            $db->setQuery($query);
            $row = $db->loadAssoc();
        } catch (Exception $e) {
            return null;
        } catch (Throwable $e) {
            return null;
        }

        if (!is_array($row) || empty($row['element'])) {
            return null;
        }

        $etype   = (string) $row['type'];
        $element = (string) $row['element'];
        $folder  = isset($row['folder']) ? (string) $row['folder'] : '';
        $client  = isset($row['client_id']) ? (int) $row['client_id'] : 0;

        if ($element === 'com_htprotect'
            || !HtpUpdateBackup::supportedType($etype) || !HtpUpdateBackup::validName($element)) {
            return null;
        }

        // Aktuelle (Vor-Update-)Version: On-Disk-Manifest bevorzugt, sonst Cache.
        $ver = class_exists('HtpDefs') ? HtpDefs::onDiskVersion($etype, $element, $folder, $client) : '';

        if ($ver === '' && !empty($row['manifest_cache'])) {
            $mc = json_decode((string) $row['manifest_cache'], true);

            if (is_array($mc) && isset($mc['version'])) {
                $ver = (string) $mc['version'];
            }
        }

        return array(
            'eid' => $eid, 'type' => $etype, 'element' => $element,
            'folder' => $folder, 'client' => $client, 'version' => $ver, 'name' => $element,
        );
    }

    /**
     * Das eigentliche manuelle Vorab-Backup ausfuehren + protokollieren (fail-open).
     *
     * @param   array  $cfg   Konfiguration
     * @param   array  $info  resolveExtension()-Ergebnis
     *
     * @return void
     */
    private static function manualBackup($cfg, $info)
    {
        $keep   = isset($cfg['autoupdate_keep']) ? (int) $cfg['autoupdate_keep'] : 1;
        $snapId = HtpUpdateBackup::snapshot($info['eid'], $info['type'], $info['element'],
            $info['folder'], $info['client'], $info['version'], true);

        $dir  = function_exists('htp_state_dir') ? htp_state_dir() : '';
        $file = $dir !== '' ? rtrim(str_replace('\\', '/', $dir), '/') . '/' . self::STATE_FILE : '';

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

        $state = self::loadState($file);
        $key   = self::extKey($info['type'], $info['element'], $info['folder']);

        if ($snapId !== '') {
            HtpUpdateBackup::prune($info['eid'], max(1, $keep));
            $state = self::logResult($state, $key, $info['eid'], $info['name'], $info['version'], '', 'manual_backup', '');
        } else {
            // "kein Rollback-Punkt" - Hinweis an den Admin (blockiert NIE das Update).
            self::enqueue(htp_t('COM_HTPROTECT_AU_MANUAL_NOBACKUP', htp_esc($info['element'])));
            $state = self::logResult($state, $key, $info['eid'], $info['name'], $info['version'], '', 'manual_skip', 'backup');
        }

        self::saveState($file, $state);
    }

    /**
     * Joomla-Meldung einreihen (fail-open) - fuer den "kein Rollback-Punkt"-Hinweis.
     *
     * @param   string  $msg  Bereits lokalisierte Meldung
     *
     * @return void
     */
    private static function enqueue($msg)
    {
        try {
            if (class_exists('Joomla\\CMS\\Factory')) {
                $app = Joomla\CMS\Factory::getApplication();

                if ($app && method_exists($app, 'enqueueMessage')) {
                    $app->enqueueMessage($msg, 'warning');
                }
            }
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still
        }
    }

    // ═════════════════════════════════════════════════════════════════
    //  Side-Effects: Backend-Tasks (app.php delegiert hierher)
    // ═════════════════════════════════════════════════════════════════

    /**
     * Backend-Task ausfuehren (Schalter speichern / jetzt pruefen / Rollback).
     * Super-User + CSRF werden zentral in HtpApp::run() geprueft; hier zur
     * Sicherheit (Defense in Depth) erneut. Liefert den aktiven Bereich zurueck.
     *
     * @param   string  $task      Task-Name (autoupdate_*)
     * @param   array   $cfg       Konfiguration (Referenz)
     * @param   array   $messages  Meldungen (Referenz)
     * @param   array   $data      Anzeigedaten (Referenz)
     *
     * @return string  Aktiver Bereich ('backups')
     */
    public static function dispatchTask($task, &$cfg, &$messages, &$data)
    {
        // Defense in Depth: Super-User + CSRF erneut pruefen.
        $user = function_exists('htp_user') ? htp_user() : null;

        if (!$user || !method_exists($user, 'authorise') || !$user->authorise('core.admin', 'com_htprotect')
            || !htp_check_token()) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_ERR_TOKEN'));

            return 'backups';
        }

        // Feature-Gate: nur wo Auto-Update greift (5.4+ ODER Joomla 3) laufen die
        // Panel-Tasks (Speichern/Pruefen/Rollback). Auf J3 sind alle drei Handler
        // J3-tauglich: syncTaskPlugin ist intern per menuEligible abgesichert (No-Op),
        // die Update-Suche nutzt den ab J3.8 vorhandenen Updater (class_exists-Guard),
        // Rollback ist reine FS/DB-Operation. J4-<5.4 bleibt ausgespart.
        if (!self::execEligible()) {
            return 'backups';
        }

        try {
            switch ($task) {
                case 'autoupdate_save':
                    self::taskSave($cfg, $messages);
                    break;

                case 'autoupdate_check':
                    self::taskCheck($cfg, $messages);
                    break;

                case 'autoupdate_rollback':
                    self::taskRollback($cfg, $messages);
                    break;

                default:
                    $messages[] = self::msg('err', htp_t('COM_HTPROTECT_ERR_UNKNOWN_ACTION'));
                    break;
            }
        } catch (Exception $e) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_AU_ERR_GENERIC'));
        } catch (Throwable $e) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_AU_ERR_GENERIC'));
        }

        return 'backups';
    }

    /**
     * Task: Master-Schalter, Intervall und Per-Erweiterung-Ausnahmen speichern.
     *
     * @param   array  $cfg       Konfiguration (Referenz)
     * @param   array  $messages  Meldungen (Referenz)
     *
     * @return void
     */
    private static function taskSave(&$cfg, &$messages)
    {
        // Auto-Update Zwei-Wege-Sync: die per-Erweiterung-ABSICHT VOR den Formular-Aenderungen aus dem
        // noch ungeaenderten $cfg festhalten. Am Ende schreiben wir nur die WIRKLICH geaenderten
        // Erweiterungen als Backend-Edit (origin='backend', neue seq) in den intent - so uebernimmt die
        // App sie beim naechsten Heartbeat, ohne dass eine unveraenderte Auswahl faelschlich "neu" wirkt.
        $auOld = class_exists('HtpAuIntent') ? HtpAuIntent::ensure($cfg, time()) : null;

        $cfg['autoupdate_notify']       = htp_post_bool('htp_autoupdate_notify');
        $cfg['autoupdate_ext']          = htp_post_bool('htp_autoupdate_ext');
        $cfg['autoupdate_all']          = htp_post_bool('htp_autoupdate_all');
        $cfg['autoupdate_self']         = htp_post_bool('htp_autoupdate_self');
        $cfg['autoupdate_manual']       = htp_post_bool('htp_autoupdate_manual');
        $cfg['autoupdate_mail_success'] = htp_post_bool('htp_autoupdate_mail_success');
        $cfg['autoupdate_malscan']      = htp_post_bool('htp_autoupdate_malscan');
        $cfg['keep_update_sites']       = htp_post_bool('htp_keep_update_sites');

        // "Grosse Spruenge nicht automatisch": die Checkbox sitzt in der Auswahl-Liste und
        // wird bei LEERER Liste gar nicht gerendert. Nur uebernehmen, wenn sie tatsaechlich
        // im Formular stand (Marker) - sonst wuerde ein Speichern ohne sichtbare Liste den
        // gespeicherten Wert still auf 0 zuruecksetzen.
        if (htp_post('htp_au_skipmajor_shown', '') !== '') {
            $cfg['autoupdate_skip_major'] = htp_post_bool('htp_autoupdate_skip_major');
        }

        $keep = (int) htp_post('htp_autoupdate_keep', '1');
        $cfg['autoupdate_keep'] = ($keep >= 1 && $keep <= 10) ? $keep : 1;

        // Update-Haeufigkeit der normalen Updates (angreifbare + HTProtect-Selbst-Update
        // laufen immer sofort). Defaults MUESSEN dem config.php-Schema entsprechen
        // (daily / Fr / 21:00), sonst setzt ein Speichern ohne diese Felder sie still falsch.
        $sched = (string) htp_post('htp_autoupdate_schedule', 'daily');
        $cfg['autoupdate_schedule'] = in_array($sched, array('instant', 'daily', 'weekly'), true) ? $sched : 'daily';

        $hour = (int) htp_post('htp_autoupdate_hour', '21');
        $cfg['autoupdate_hour'] = ($hour >= 0 && $hour <= 23) ? $hour : 21;

        $wd = (int) htp_post('htp_autoupdate_weekday', '5');
        $cfg['autoupdate_weekday'] = ($wd >= 1 && $wd <= 7) ? $wd : 5;

        // Karenz-Zeit (Soak) der Auswahl-Updates: keine | 24h | 3 | 7 Tage (14 entfernt - praxisfern).
        $grace = (int) htp_post('htp_autoupdate_grace', '3');
        $cfg['autoupdate_grace'] = in_array($grace, array(0, 1, 3, 7), true) ? $grace : 3;

        // Per-Erweiterung-AUSWAHL aus dem Formular: 3-Stufen-Schalter je Erweiterung
        // (htp_au_mode[key] = 'never'|'security'|'all'). htp_au_rendered[] = die im Dialog gezeigten
        // Keys (Diff-Marker), damit nicht gezeigte Erweiterungen unangetastet bleiben.
        $rendered = self::cleanKeyList(htp_raw('htp_au_rendered', null, 'post'));
        $modesRaw = htp_raw('htp_au_mode', array(), 'post');
        $modes    = is_array($modesRaw) ? $modesRaw : array();

        $modeOf = function ($modes, $key) {
            $m = isset($modes[$key]) ? strtolower((string) $modes[$key]) : '';
            return in_array($m, array('never', 'security', 'all'), true) ? $m : 'security';
        };

        // Manuelle Update-Quellen-Ausnahmen (Ausnahmen-Dialog): angezeigte vs. angehakte
        // extKeys. mergeIncludes erhält Ausnahmen, die gerade nicht im Dialog standen.
        $exRendered = self::cleanKeyList(htp_raw('htp_keepexcept_rendered', null, 'post'));
        $exChecked  = self::cleanKeyList(array_keys((array) htp_raw('htp_keepexcept', array(), 'post')));
        $exPrev     = isset($cfg['keep_update_sites_except']) && is_array($cfg['keep_update_sites_except']) ? $cfg['keep_update_sites_except'] : array();

        $cfg['keep_update_sites_except'] = self::mergeIncludes($exPrev, $exRendered, $exChecked);

        // Nur die im Formular WIRKLICH veraenderten Erweiterungen versioniert in den intent uebernehmen
        // (origin='backend'). Eine gewaehlte Stufe, die exakt der VORGABE entspricht, wird als 'inherit'
        // geschrieben (Override entfernen = folgt kuenftig der Vorgabe) - so bleibt das Register schlank,
        // und ein blosses Oeffnen+Speichern des Dialogs erzeugt KEINE Flut expliziter Eintraege/Syncs.
        if ($auOld !== null) {
            $now = time();

            foreach ($rendered as $rk) {
                $rk = (string) $rk;
                if ($rk === '') {
                    continue;
                }

                $m  = $modeOf($modes, $rk);
                $ds = self::defaultStageForKey($cfg, $rk);

                if ($m === $ds) {
                    $want = 'inherit';       // gewaehlte Stufe == Vorgabe -> Override entfernen
                } elseif ($m === 'all') {
                    $want = 'on';            // Immer
                } elseif ($m === 'never') {
                    $want = 'never';         // Nie (unterhalb des Sicherheits-Bodens)
                } else {
                    $want = 'off';           // Nur Sicherheitsupdates (Standard/Boden)
                }

                $cur = isset($auOld['ext'][$rk]['val']) ? $auOld['ext'][$rk]['val'] : 'inherit';

                if ($cur !== $want) {
                    HtpAuIntent::applyExt($auOld, $rk, $want, HtpAuIntent::rev($auOld) + 1, $now, 'backend');
                }
            }

            $cfg['autoupdate_intent'] = $auOld;

            // Intent ist die Wahrheit -> Flach-Keys DARAUS ableiten (symmetrisch zum App-Pfad in
            // applyUpdatePolicy), inkl. der neuen never-Liste. Ohne das bliebe z. B. autoupdate_exclude
            // aus einem frueheren Flotten-Push stehen, obwohl der Betreiber die Erweiterung hier gerade
            // umgestellt hat - die Wahl kippte still zurueck.
            $flat = HtpAuIntent::deriveFlat($auOld);
            $cfg['autoupdate_include'] = $flat['autoupdate_include'];
            $cfg['autoupdate_exclude'] = $flat['autoupdate_exclude'];
            $cfg['autoupdate_never']   = $flat['autoupdate_never'];
            $cfg['autoupdate_default'] = $flat['autoupdate_default'];
        } else {
            // Fallback ohne HtpAuIntent (Datei fehlt -> degradiert aufs Alt-Verhalten, kein Sync):
            // Flach-Listen direkt aus den Modi ableiten, nicht gezeigte Erweiterungen bewahren. Eine
            // Stufe == Vorgabe landet in KEINER Liste (folgt der Vorgabe).
            $rm = array();

            foreach ($rendered as $rk) {
                $rm[strtolower((string) $rk)] = true;
            }

            $keepList = function ($cfg, $listKey, $rm) {
                $out  = array();
                $list = isset($cfg[$listKey]) && is_array($cfg[$listKey]) ? $cfg[$listKey] : array();
                foreach ($list as $k) {
                    if (!isset($rm[strtolower((string) $k)])) {
                        $out[] = (string) $k;
                    }
                }
                return $out;
            };

            $inc = $keepList($cfg, 'autoupdate_include', $rm);
            $exc = $keepList($cfg, 'autoupdate_exclude', $rm);
            $nev = $keepList($cfg, 'autoupdate_never', $rm);

            foreach ($rendered as $rk) {
                $rk = (string) $rk;
                if ($rk === '') {
                    continue;
                }

                $m  = $modeOf($modes, $rk);
                $ds = self::defaultStageForKey($cfg, $rk);

                if ($m === $ds) {
                    continue;                // folgt der Vorgabe -> in keiner Liste
                }
                if ($m === 'all') {
                    $inc[] = $rk;
                } elseif ($m === 'never') {
                    $nev[] = $rk;
                } else {
                    $exc[] = $rk;
                }
            }

            $cfg['autoupdate_include'] = array_values(array_unique($inc));
            $cfg['autoupdate_exclude'] = array_values(array_unique($exc));
            $cfg['autoupdate_never']   = array_values(array_unique($nev));
        }

        // Gate-Kopplung (identisch zu HtpLink::applyUpdatePolicy): sobald eine Erweiterung explizit auf
        // "Immer" steht (Include), muss der "auf alle ausweiten"-Modus aktiv sein - sonst bliebe die
        // Include-Wahl wirkungslos (autoUpdateEligible verlangt allOn && included). Der Default fuer NICHT
        // gelistete Erweiterungen bleibt getrennt ueber autoupdate_default gesteuert.
        if (!empty($cfg['autoupdate_include'])) {
            $cfg['autoupdate_all'] = 1;
        }

        // HTProtect selbst gehoert NICHT in die per-Erweiterung-Auswahlliste (es laeuft ueber den
        // eigenen Selbst-Update-Schalter). Einen ggf. durch aeltere Versionen eingesickerten
        // com_htprotect-Eintrag hier beim Speichern aus Auswahl UND Intent entfernen (self-heilend).
        self::stripSelfInclude($cfg);

        HtpConfig::save($cfg);

        // Ausfuehrung laeuft ueber den Joomla Task Scheduler: Plugin + Task-Zeile beim
        // Aktivieren anlegen, beim Deaktivieren pausieren (>= J5.4; best effort).
        self::syncTaskPlugin($cfg);

        $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_AU_SAVED'));
    }

    /**
     * com_htprotect (HTProtect selbst) aus der per-Erweiterung-Auswahl UND dem Intent entfernen.
     * HTProtect wird ausschliesslich ueber den Selbst-Update-Schalter (autoupdate_self) gesteuert und
     * gehoert nicht in autoupdate_include; ein dort (durch aeltere Versionen) eingesickerter Eintrag
     * ist zwar folgenlos - Self-Update laeuft ueber einen eigenen Pfad, und der Include-Pfad braucht
     * ohnehin den all-Modus -, aber unsauber und taucht auf gekoppelten Sites als Dashboard-Override
     * auf. Idempotent. Muss auch den Intent bereinigen, sonst re-derivt deriveFlat den Eintrag zurueck.
     * Wird von BEIDEN gleichberechtigten Schreibpfaden aufgerufen: taskSave (Backend) und
     * HtpLink::applyUpdatePolicy (Dashboard-/Fleet-Push) - sonst sickerte com_htprotect auf
     * gekoppelten Sites ueber den Dashboard-Weg weiter ein.
     *
     * @param   array  $cfg  Konfiguration bzw. Schluessel-Set (Referenz)
     *
     * @return void
     */
    public static function stripSelfInclude(&$cfg)
    {
        $selfKey = self::extKey('component', 'com_htprotect', ''); // bereits lowercased

        foreach (array('autoupdate_include', 'autoupdate_exclude', 'autoupdate_never') as $listKey) {
            if (isset($cfg[$listKey]) && is_array($cfg[$listKey])) {
                $filtered = array();

                foreach ($cfg[$listKey] as $k) {
                    if (strtolower((string) $k) !== $selfKey) {
                        $filtered[] = $k;
                    }
                }

                $cfg[$listKey] = $filtered;
            }
        }

        if (isset($cfg['autoupdate_intent']['ext']) && is_array($cfg['autoupdate_intent']['ext'])) {
            foreach (array_keys($cfg['autoupdate_intent']['ext']) as $ik) {
                if (strtolower((string) $ik) === $selfKey) {
                    unset($cfg['autoupdate_intent']['ext'][$ik]);
                }
            }
        }
    }

    /**
     * Task: jetzt nach Updates suchen (kein Install). Frischt die Anzeige-Liste.
     *
     * @param   array  $cfg       Konfiguration
     * @param   array  $messages  Meldungen (Referenz)
     *
     * @return void
     */
    private static function taskCheck($cfg, &$messages)
    {
        self::joomlaFindUpdates();

        // "Jetzt pruefen" ist eine ausdrueckliche Nutzer-Geste -> aufgegebene bzw.
        // gedrosselte Ziele bekommen eine frische Chance: Key-Fehlversuchszaehler +
        // Backoff-Cooldowns leeren. Ein Terminal ('manuell erforderlich') re-armt sich und
        // faellt beim naechsten Lauf auf einen normalen Versuch zurueck.
        $dir  = function_exists('htp_state_dir') ? htp_state_dir() : '';
        $file = $dir !== '' ? rtrim(str_replace('\\', '/', $dir), '/') . '/' . self::STATE_FILE : '';

        if ($file !== '') {
            $state = self::loadState($file);

            if (!empty($state['key_fails']) || !empty($state['key_cooldown'])) {
                unset($state['key_fails'], $state['key_cooldown']);
                self::saveState($file, $state);
            }
        }

        $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_AU_CHECKED'));
    }

    /**
     * Task: Ein-Klick-Rollback einer kuerzlich aktualisierten Erweiterung.
     *
     * @param   array  $cfg       Konfiguration
     * @param   array  $messages  Meldungen (Referenz)
     *
     * @return void
     */
    private static function taskRollback($cfg, &$messages)
    {
        $eid    = (int) htp_post('htp_eid', '0');
        $snapId = htp_post('htp_snap', '');

        if ($eid <= 0 || !preg_match(HtpUpdateBackup::SNAP_RE, $snapId)) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_AU_RB_BADREQ'));

            return;
        }

        $meta = HtpUpdateBackup::readMeta($eid, $snapId);

        if ($meta === null) {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_AU_RB_NOTFOUND'));

            return;
        }

        // DB-Restore beim MANUELLEN Rollback nur auf ausdrueckliche Bestaetigung
        // (Warnung: seit dem Update entstandene Daten gehen verloren). Standard:
        // nur Dateien + Erweiterungs-Eintrag.
        $restoreDb = htp_post_bool('htp_restore_db') ? true : false;

        $rb = HtpUpdateBackup::rollback($eid, $snapId, $restoreDb);

        if (isset($meta['type'], $meta['element'])) {
            self::invalidateOpcache($meta['type'], $meta['element'],
                isset($meta['folder']) ? $meta['folder'] : '',
                isset($meta['client_id']) ? (int) $meta['client_id'] : 0);
        }

        // Log-Eintrag im State festhalten (race-sicher ueber die State-Datei).
        self::recordManualRollback($meta, !empty($rb['ok']));

        if (!empty($rb['ok'])) {
            $messages[] = self::msg('ok', htp_t('COM_HTPROTECT_AU_RB_OK',
                htp_esc(isset($meta['element']) ? $meta['element'] : (string) $eid)));
        } else {
            $messages[] = self::msg('err', htp_t('COM_HTPROTECT_AU_RB_PARTIAL'));
        }
    }

    // ═════════════════════════════════════════════════════════════════
    //  Side-Effects: Anzeige-Daten (ui.php / secBackups delegiert hierher)
    // ═════════════════════════════════════════════════════════════════

    /**
     * Anzeige-Daten fuer die UI sammeln: Schalter-Zustand, Liste verfuegbarer
     * Updates samt Status, zurueckrollbare Erweiterungen, Log.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array
     */
    /**
     * Kompaktes Erweiterungs-Inventar fuer den Link-Heartbeat (PROTOCOL.md §4, Delta-Sync).
     * Baut auf collectInfos auf (dieselbe Quelle wie die lokale UI) und liefert positionale Zeilen
     * [k, n, cur, new, f, st] + einen stabilen 12-Hex-Fingerprint. Der Heartbeat traegt IMMER nur
     * den Hash; die Zeilen nur, wenn sich der Hash seit dem letzten Upload geaendert hat.
     *
     * f = Bitmaske: 1=security (angreifbar), 2=major (SemVer-Major-Sprung), 4=auto (lokal included),
     *     8=auto_effective (wird nach aktueller Config wirklich automatisch aktualisiert; floor-bewusst),
     *     16=never ("Nie": unterhalb des Sicherheits-Bodens - kein Auto-Update, auch keine Sicherheit;
     *        deckt explizites autoupdate_never UND den Template-/Fleet-'never'-Default ab).
     * st (Status) wird bei 'ready' weggelassen (Bytes sparen).
     *
     * @param   array  $cfg  Komponenten-Konfiguration
     *
     * @return array{hash:string, rows:array, pending:int}
     */
    public static function inventory($cfg)
    {
        $dir       = function_exists('htp_state_dir') ? htp_state_dir() : '';
        $stateFile = $dir !== '' ? rtrim(str_replace('\\', '/', $dir), '/') . '/' . self::STATE_FILE : '';
        $state     = $stateFile !== '' ? self::loadState($stateFile) : array();

        $infos = array();

        try {
            $infos = self::collectInfos($cfg, $state);
        } catch (Exception $e) {
            $infos = array();
        } catch (Throwable $e) {
            $infos = array();
        }

        $rows    = array();
        $pending = 0;

        // Config-Gates einmal auswerten (fuer Bit 8 = auto_effective je Zeile). Spiegelt EXAKT
        // autoUpdateEligible + den gemanagten Sicherheits-Boden: eine angreifbare Erweiterung ist auf
        // gemanagten Sites auch bei Master-aus auto-wirksam (Floor). Das erlaubt dem Dashboard, eine
        // Divergenz (Auswahl 'Auto', aber am Connector wirkungslos weil Gate zu) ehrlich zu erkennen.
        $masterOn = !empty($cfg['autoupdate_ext']);
        $allOn    = $masterOn && !empty($cfg['autoupdate_all']);
        $managed  = self::isManaged($cfg);

        foreach ($infos as $u) {
            $type    = isset($u['type']) ? (string) $u['type'] : '';
            $element = isset($u['element']) ? (string) $u['element'] : '';
            $folder  = isset($u['folder']) ? (string) $u['folder'] : '';
            $key     = self::extKey($type, $element, $folder);
            $name    = isset($u['name']) && $u['name'] !== '' ? (string) $u['name'] : $element;
            $cur     = isset($u['installed']) ? (string) $u['installed'] : '';
            $hasUp   = !empty($u['has_update']);
            $new     = $hasUp ? (string) $u['available'] : null;

            if ($hasUp && empty($u['self'])) {
                $pending++;
            }

            $major = $hasUp ? self::isMajorJump($cur, (string) $u['available']) : false;

            $vuln  = !empty($u['vulnerable']);
            $incl  = !empty($u['included']);
            $never = !empty($u['never']);
            // auto_effective (Bit 8): wird diese Erweiterung nach der AKTUELLEN Connector-Konfiguration
            // ueberhaupt automatisch aktualisiert? (Config-Gate, NICHT der Laufzeit-Status wie major_hold.)
            // HTProtect selbst (com_htprotect) laeuft ueber einen EIGENEN, vom Master ENTKOPPELTEN Pfad
            // (autoupdate_self / selfImmediate) - daher NICHT durch das Master/Auswahl-Gate bewerten,
            // sonst meldet die Self-Zeile faelschlich "wirkungslos". Sonst: kanonisches Praedikat (never-
            // bewusst) + gemanagter Sicherheits-Boden - ABER "Nie" unterschreitet auch den Boden, also
            // KEIN auto_effective fuer eine angreifbare "Nie"-Erweiterung (sonst meldete das Dashboard
            // faelschlich "wird automatisch geschlossen", obwohl bewusst nichts installiert wird).
            $autoEff = !empty($u['self'])
                ? !empty($cfg['autoupdate_self'])
                : (self::autoUpdateEligible($masterOn, $allOn, $vuln, $incl, $never) || ($managed && $vuln && !$never));

            $f = 0;
            if ($vuln)    { $f |= 1; }
            if ($major)   { $f |= 2; }
            if ($incl)    { $f |= 4; }
            if ($autoEff) { $f |= 8; }
            if ($never)   { $f |= 16; }

            $st  = isset($u['status']) ? (string) $u['status'] : 'ready';
            $row = array($key, $name, $cur, $new, $f);

            if ($st !== 'ready') {
                $row[] = $st;
            }

            $rows[] = $row;
        }

        // Stabiler Fingerprint - collectInfos sortiert bereits deterministisch. Der Hash dient NUR
        // der agenten-eigenen Aenderungs-Erkennung (der Relay speichert ihn nur), daher genuegt
        // plain json_encode (keine Cross-Impl-Kanonisierung noetig).
        $hash = substr(hash('sha256', (string) json_encode($rows)), 0, 12);

        return array('hash' => $hash, 'rows' => $rows, 'pending' => $pending);
    }

    public static function viewData($cfg)
    {
        $dir       = function_exists('htp_state_dir') ? htp_state_dir() : '';
        $stateFile = $dir !== '' ? rtrim(str_replace('\\', '/', $dir), '/') . '/' . self::STATE_FILE : '';
        $state     = $stateFile !== '' ? self::loadState($stateFile) : array();

        $infos = array();

        try {
            $infos = self::collectInfos($cfg, $state);
        } catch (Exception $e) {
            $infos = array();
        } catch (Throwable $e) {
            $infos = array();
        }

        // Namen der ANGREIFBAREN Erweiterungen (Transparenz: was wird unter Schalter 1
        // automatisch aktualisiert?) - aus den Infos, die ohnehin 'vulnerable' tragen.
        $vulnNames = array();

        foreach ($infos as $vinf) {
            if (!empty($vinf['vulnerable'])) {
                $vn = isset($vinf['name']) && $vinf['name'] !== '' ? (string) $vinf['name'] : (string) $vinf['element'];

                if (!in_array($vn, $vulnNames, true)) {
                    $vulnNames[] = $vn;
                }
            }
        }

        // Fortschritt/ETA: Anzahl ausstehender Auto-Update-Ziele (ohne HTProtect selbst)
        // + Momentaufnahme eines GERADE laufenden Jobs (Ziel-Index/-Anzahl/Phase), damit
        // die UI zeigen kann, wie weit die Auto-Updates sind statt "Blackbox".
        $pending = 0;

        foreach ($infos as $pinf) {
            if (empty($pinf['self']) && !empty($pinf['has_update'])) {
                $pending++;
            }
        }

        $job = null;

        if (isset($state['job']['phase']) && (string) $state['job']['phase'] !== '') {
            $targets = (isset($state['job']['targets']) && is_array($state['job']['targets'])) ? $state['job']['targets'] : array();
            $jidx    = isset($state['job']['idx']) ? (int) $state['job']['idx'] : 0;
            $job     = array(
                'phase' => (string) $state['job']['phase'],
                'idx'   => $jidx,
                'total' => count($targets),
                'name'  => isset($targets[$jidx]['name']) ? (string) $targets[$jidx]['name'] : '',
            );
        }

        $s = self::scheduleCfg($cfg);

        return array(
            'notify'       => !empty($cfg['autoupdate_notify']),
            'pending_count' => $pending,
            'job'           => $job,
            'enabled'      => !empty($cfg['autoupdate_ext']),
            'all'          => !empty($cfg['autoupdate_all']),
            'self'         => !empty($cfg['autoupdate_self']),
            'manual'       => !empty($cfg['autoupdate_manual']),
            'mail_success' => !empty($cfg['autoupdate_mail_success']),
            'malscan'      => !isset($cfg['autoupdate_malscan']) || !empty($cfg['autoupdate_malscan']),
            'keep_sites'   => !isset($cfg['keep_update_sites']) || !empty($cfg['keep_update_sites']),
            'keep_list'    => (!isset($cfg['keep_update_sites']) || !empty($cfg['keep_update_sites'])) ? self::siteKeepList($cfg) : array(),
            'keep'         => isset($cfg['autoupdate_keep']) ? (int) $cfg['autoupdate_keep'] : 1,
            'schedule'     => $s['schedule'],
            'hour'         => $s['hour'],
            'weekday'      => $s['weekday'],
            'grace'        => isset($cfg['autoupdate_grace']) ? (int) $cfg['autoupdate_grace'] : 0,
            'skip_major'   => !empty($cfg['autoupdate_skip_major']),
            'updates'   => $infos,
            'vuln_names' => $vulnNames,
            'fpm'        => function_exists('fastcgi_finish_request'),
            'tls_warn'  => self::triggerTlsWarning(),
            'reach_warn' => self::triggerReachWarning(),
            'next_run'  => self::nextSlot($cfg, time()),
            'sched_next' => self::schedulerNextRun(),
            'rollbacks' => self::rollbackTargets(),
            'history'   => isset($state['history']) ? $state['history'] : array(),
            'last_run'  => isset($state['last_run']) ? (int) $state['last_run'] : 0,
        );
    }

    /**
     * Zurueckrollbare Erweiterungen (je Erweiterung der jeweils juengste Snapshot)
     * mit aktueller On-Disk-Version + Snapshot-Zielversion + Datum.
     *
     * @return array
     */
    private static function rollbackTargets()
    {
        $out = array();

        foreach (HtpUpdateBackup::listExtIds() as $eid) {
            $snaps = HtpUpdateBackup::listSnapshots($eid);

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

            $latest  = $snaps[0];
            $type    = isset($latest['type']) ? (string) $latest['type'] : '';
            $element = isset($latest['element']) ? (string) $latest['element'] : '';
            $folder  = isset($latest['folder']) ? (string) $latest['folder'] : '';
            $client  = isset($latest['client_id']) ? (int) $latest['client_id'] : 0;

            $current = class_exists('HtpDefs') ? HtpDefs::onDiskVersion($type, $element, $folder, $client) : '';

            $snapId = isset($latest['id']) ? (string) $latest['id'] : '';

            $out[] = array(
                'eid'      => $eid,
                'snap'     => $snapId,
                'element'  => $element,
                'type'     => $type,
                'current'  => $current,
                'previous' => isset($latest['version']) ? (string) $latest['version'] : '',
                'ts'       => isset($latest['ts']) ? (int) $latest['ts'] : 0,
                'has_db'   => $snapId !== '' && HtpUpdateBackup::hasDbBackup($eid, $snapId),
            );
        }

        // Neueste zuerst (das zuletzt durchgefuehrte Backup steht oben - wie bei den
        // .htaccess-Backups). Ohne Sortierung stuende es in Extension-ID-Reihenfolge.
        // Ohne Spaceship-Operator (PHP 5.6 / Joomla 2.5-kompatibel).
        usort($out, function ($a, $b) {
            $ta = (int) $a['ts'];
            $tb = (int) $b['ts'];

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

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

        return $out;
    }

    // ═════════════════════════════════════════════════════════════════
    //  Side-Effects: DB-Lesen, Joomla-Updater/UpdateModel, opcache, Probe
    // ═════════════════════════════════════════════════════════════════

    /**
     * Bewertete Info-Datensaetze fuer alle verfuegbaren Updates aufbauen
     * (verbindet #__updates, #__update_sites und #__extensions). Reine Lesezugriffe.
     *
     * @param   array  $cfg    Konfiguration
     * @param   array  $state  Tick-State (Ausschluss/Aussetz/bekannte Hosts)
     *
     * @return array  Liste der Info-Datensaetze (jeweils inkl. 'status')
     */
    private static function collectInfos($cfg, $state)
    {
        $db = htp_db();

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

        $includes  = isset($cfg['autoupdate_include']) && is_array($cfg['autoupdate_include']) ? $cfg['autoupdate_include'] : array();
        $includeMp = array();

        foreach ($includes as $k) {
            $includeMp[strtolower((string) $k)] = true;
        }

        // Fleet-weite Policy (PROTOCOL.md §6a): explizite Ausnahmen + Default fuer NICHT gelistete
        // Erweiterungen. 'all' = auto fuer alles (Security separat ueber den ext-Master);
        // 'security'|'off' = nur explizit eingeschlossene. Vom Relay via update.policy gesetzt.
        $excludes  = isset($cfg['autoupdate_exclude']) && is_array($cfg['autoupdate_exclude']) ? $cfg['autoupdate_exclude'] : array();
        $excludeMp = array();

        foreach ($excludes as $k) {
            $excludeMp[strtolower((string) $k)] = true;
        }

        // "Nie" (ab 2.7.2): bewusster Opt-out UNTERHALB des Sicherheits-Bodens - kein Auto-Update, auch
        // kein Sicherheitsupdate. Schlaegt include UND exclude (staerkster Zustand). Wird weiter gemeldet.
        $nevers  = isset($cfg['autoupdate_never']) && is_array($cfg['autoupdate_never']) ? $cfg['autoupdate_never'] : array();
        $neverMp = array();

        foreach ($nevers as $k) {
            $neverMp[strtolower((string) $k)] = true;
        }

        $auDefault = isset($cfg['autoupdate_default']) ? (string) $cfg['autoupdate_default'] : 'security';

        $suspended = isset($state['suspended']) && is_array($state['suspended']) ? $state['suspended'] : array();
        $hosts     = isset($state['known_hosts']) && is_array($state['known_hosts']) ? $state['known_hosts'] : array();
        $kc        = isset($state['key_cooldown']) && is_array($state['key_cooldown']) ? $state['key_cooldown'] : array();
        $kf        = isset($state['key_fails']) && is_array($state['key_fails']) ? $state['key_fails'] : array();
        $mb        = isset($state['mal_block']) && is_array($state['mal_block']) ? $state['mal_block'] : array();
        $now       = time();

        // Set der ANGREIFBAREN Element-Namen (bekannte Luecke) - Sicherheits-Priuoritaet
        // fuer Schalter 1. Bei Paketen zaehlt eine verwundbare Kind-Erweiterung
        // (z. B. JCE: com_jce verwundbar -> pkg_jce ist der Update-Kandidat).
        // Wert = FIX-Version ('below' aus dem Feed, '' wenn unbekannt): erlaubt spaeter zu
        // pruefen, ob ein angebotenes Update die Luecke ueberhaupt SCHLIESST (available >= below).
        $vulnSet = array();

        if (class_exists('HtpDefs')) {
            try {
                foreach (HtpDefs::checkExtensions() as $v) {
                    if (isset($v['element']) && $v['element'] !== '') {
                        $vulnSet[strtolower((string) $v['element'])] = isset($v['below']) ? (string) $v['below'] : '';
                    }
                }
            } catch (Exception $e) {
                // ohne Liste -> keine als angreifbar markiert
            } catch (Throwable $e) {
                // dito
            }
        }

        // Zentrale Rueckruf-/Sperrliste (signierter Feed): hochproblematische Update-
        // Versionen, die NICHT installiert werden sollen. Einmal laden, je Erweiterung
        // gegen die verfuegbare UND die installierte Version pruefen (rein, fail-closed).
        $recallList = array();
        $holdList   = array();

        if (class_exists('HtpDefs') && method_exists('HtpDefs', 'recallList')) {
            try {
                $recallList = HtpDefs::recallList();
            } catch (Exception $e) {
                $recallList = array();
            } catch (Throwable $e) {
                $recallList = array();
            }
        }

        // Vorsorge-Sperre (von radar, TLS-autorisiert, rein subtraktiv): geclampte
        // Liste aktuell still zurueckgestellter Update-Versionen.
        if (class_exists('HtpDefs') && method_exists('HtpDefs', 'holdList')) {
            try {
                $holdList = HtpDefs::holdList();
            } catch (Exception $e) {
                $holdList = array();
            } catch (Throwable $e) {
                $holdList = array();
            }
        }

        // 1) Anstehende Updates (#__updates) als Karte eid -> {update_id, version}.
        $pending = array();

        try {
            $pq = $db->getQuery(true)
                ->select($db->quoteName(array('extension_id', 'update_id', 'version')))
                ->from($db->quoteName('#__updates'))
                ->where($db->quoteName('extension_id') . ' > 0');
            $db->setQuery($pq, 0, 1000);

            foreach ((array) $db->loadAssocList() as $r) {
                $pe = (int) $r['extension_id'];

                if ($pe > 0 && !isset($pending[$pe])) {
                    $pending[$pe] = array(
                        'update_id' => isset($r['update_id']) ? (int) $r['update_id'] : 0,
                        'version'   => isset($r['version']) ? (string) $r['version'] : '',
                    );
                }
            }
        } catch (Exception $e) {
            // ohne Pending-Liste werden Erweiterungen nur als "aktuell" angezeigt
        } catch (Throwable $e) {
            // dito
        }

        // 2) ALLE installierten Erweiterungen mit AKTIVER Update-Site (nicht nur die
        //    mit anstehendem Update) - damit man Auto-Update vorab pro Erweiterung
        //    waehlen kann. extension_id 0 (Joomla-Sprachpakete) bleibt draussen.
        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName(array(
                    'e.extension_id', 'e.type', 'e.element', 'e.folder', 'e.client_id', 'e.name', 'e.manifest_cache',
                )))
                ->select($db->quoteName('s.location', 'site_location'))
                ->from($db->quoteName('#__extensions', 'e'))
                ->innerJoin($db->quoteName('#__update_sites_extensions', 'se')
                    . ' ON ' . $db->quoteName('se.extension_id') . ' = ' . $db->quoteName('e.extension_id'))
                ->innerJoin($db->quoteName('#__update_sites', 's')
                    . ' ON ' . $db->quoteName('s.update_site_id') . ' = ' . $db->quoteName('se.update_site_id'))
                ->where($db->quoteName('e.extension_id') . ' > 0')
                ->where($db->quoteName('s.enabled') . ' = 1')
                ->order($db->quoteName('e.name'));
            $db->setQuery($query, 0, 500);
            $rows = $db->loadAssocList();
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }

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

        $infos = array();
        $seen  = array();

        foreach ($rows as $row) {
            $eid     = isset($row['extension_id']) ? (int) $row['extension_id'] : 0;
            $element = isset($row['element']) ? (string) $row['element'] : '';

            // Eine Erweiterung kann (selten) mehreren Update-Sites zugeordnet sein.
            if ($eid <= 0 || $element === '' || isset($seen[$eid])) {
                continue;
            }

            $seen[$eid] = true;

            $type    = isset($row['type']) ? (string) $row['type'] : '';
            $folder  = isset($row['folder']) ? (string) $row['folder'] : '';
            $client  = isset($row['client_id']) ? (int) $row['client_id'] : 0;

            // Nicht in die Auto-Update-Liste: der Joomla-Kern (files_joomla),
            // Joomlas eigene Update-Komponente sowie alle nicht auto-updatebaren
            // Typen (Sprachen + Kerndateien = "nur manuell") - die sind nur Rauschen
            // hier und werden ausgeblendet (Anzeige UND Auswahl).
            if (!HtpUpdateBackup::supportedType($type)
                || in_array(strtolower($element), array('joomla', 'files_joomla', 'pkg_joomla', 'com_joomlaupdate'), true)) {
                continue;
            }

            $key     = self::extKey($type, $element, $folder);
            $siteUrl = isset($row['site_location']) ? (string) $row['site_location'] : '';

            $pend      = isset($pending[$eid]) ? $pending[$eid] : null;
            $available = $pend !== null ? (string) $pend['version'] : '';

            // Installierte Version: bei anstehendem Update On-Disk (praezise, wenige),
            // sonst aus dem manifest_cache (schnell, nur Anzeige - keine XML-Lese-Flut).
            if ($pend !== null) {
                $installed = class_exists('HtpDefs') ? HtpDefs::onDiskVersion($type, $element, $folder, $client) : '';

                if ($installed === '') {
                    $installed = self::manifestVersion(isset($row['manifest_cache']) ? $row['manifest_cache'] : '');
                }
            } else {
                $installed = self::manifestVersion(isset($row['manifest_cache']) ? $row['manifest_cache'] : '');
            }

            $hasUpdate = $pend !== null && self::isUpgrade($installed, $available);

            // Anzeigename: #__extensions.name ist oft ein Sprachschluessel
            // (COM_XYZ) -> dann lieber das Element zeigen.
            $rawName = isset($row['name']) ? (string) $row['name'] : '';
            $name    = ($rawName === '' || preg_match('~^[A-Z][A-Z0-9_]+$~', $rawName)) ? $element : $rawName;

            // Angreifbar? (direkt ODER - bei Paketen - eine verwundbare Kind-Erweiterung)
            // $vulnBelow = Fix-Version der zugehoerigen Luecke ('' wenn unbekannt) fuer die
            // Feed-Lag-Pruefung (schliesst das angebotene Update die Luecke ueberhaupt?).
            $vulnerable = isset($vulnSet[strtolower($element)]);
            $vulnBelow  = $vulnerable ? (string) $vulnSet[strtolower($element)] : '';

            if (!$vulnerable && !empty($vulnSet) && strtolower($type) === 'package') {
                $kids = HtpUpdateBackup::packageChildren($eid);

                foreach ($kids as $ch) {
                    if (isset($ch['element']) && isset($vulnSet[strtolower((string) $ch['element'])])) {
                        $vulnerable = true;
                        $vulnBelow  = (string) $vulnSet[strtolower((string) $ch['element'])];
                        break;
                    }
                }

                // Fallback NUR wenn die #__extensions.package_id-Verknuepfung LEER ist (kaputt/fehlt -
                // haeufig nach Joomla-Migrationen oder getrennter Installation; real beobachtet: BaForms-
                // Kinder zeigten per package_id aufs FALSCHE Paket): das Paket ueber den gemeinsamen
                // Vendor-Stamm (vendorToken, z. B. pkg_baforms -> 'baforms', com_baforms -> 'baforms')
                // an eine verwundbare Erweiterung binden. Sonst wuerde ein ECHTES Sicherheitsupdate still
                // verschluckt, nur weil packageChildren() wegen kaputter package_id nichts liefert.
                // BEWUSST nur bei LEEREN Kindern: ein korrekt verknuepftes Paket ohne verwundbares Kind
                // darf NICHT ueber eine zufaellige Token-Kollision faelschlich markiert werden.
                if (!$vulnerable && empty($kids) && method_exists('HtpUpdateBackup', 'vendorToken')) {
                    $ptok = HtpUpdateBackup::vendorToken($element);

                    if ($ptok !== '') {
                        foreach ($vulnSet as $vel => $vbelow) {
                            if (HtpUpdateBackup::vendorToken((string) $vel) === $ptok) {
                                $vulnerable = true;
                                $vulnBelow  = (string) $vbelow;
                                break;
                            }
                        }
                    }
                }
            }

            // Schliesst das ANGEBOTENE Update die Luecke wirklich (available >= Fix-Version)? Nur dann
            // darf die Security-Ausnahme die Halte-Gates (Major/radar-held) ueberstimmen. Fail-open:
            // ohne Update oder bei unklaren Versionen gilt der Fix als erreicht (Sicherheit hat Vorrang).
            $vulnFixReach = $vulnerable && $hasUpdate && self::fixReachable($available, $vulnBelow);

            // Fleet-Auswahl aufloesen: Ausnahme schlaegt alles; sonst explizit eingeschlossen (oder
            // HTProtect-Self via autoupdate_self); sonst Default 'all' -> auto, 'security'/'off' ->
            // nur explizit. Die Security-Automatik laeuft unabhaengig hiervon ueber den ext-Master.
            $isSelfElement = (strtolower($element) === 'com_htprotect');
            $isSelf        = ($isSelfElement && !empty($cfg['autoupdate_self']));

            // Reihenfolge der Aufloesung (staerkster zuerst): HTProtect selbst -> explizit "Nie" ->
            // explizit "Nur Sicherheit" (exclude) -> explizit "Immer" (include) -> Typ-/Fleet-Default.
            // HTProtect selbst ist NIE "Nie"-faehig - UNABHAENGIG vom Self-Update-Schalter (die
            // Sicherheits-/Fix-Verteilung laeuft immer ueber den eigenen, entkoppelten Pfad). Der Test
            // MUSS auf der ELEMENT-Identitaet sitzen (nicht auf $isSelf, das an autoupdate_self haengt),
            // sonst faellt com_htprotect bei Self-Schalter AUS + Fleet-Default 'never' faelschlich auf
            // never=true und sein Selbst-Update (auch Sicherheits-Fixes) wuerde verschluckt.
            $never = false;

            if ($isSelfElement) {
                // included folgt dem Self-Schalter (nur Anzeige-relevant; execPlan nutzt den self-Pfad).
                $included = !empty($cfg['autoupdate_self']);
                $never    = false;
            } elseif (isset($neverMp[$key])) {
                $included = false;
                $never    = true;
            } elseif (isset($excludeMp[$key])) {
                $included = false;
            } elseif (isset($includeMp[$key])) {
                $included = true;
            } else {
                // Unlisted: Template-Typ-Default = "Nie" (Templates sind generell update-kritischer -
                // Forum d/100). Sonst folgt die Erweiterung dem globalen Fleet-Default.
                if (strtolower($type) === 'template') {
                    $included = false;
                    $never    = true;
                } elseif ($auDefault === 'never') {
                    $included = false;
                    $never    = true;
                } else {
                    $included = ($auDefault === 'all');
                }
            }

            $info = array(
                'eid'          => $eid,
                'update_id'    => $pend !== null ? (int) $pend['update_id'] : 0,
                'type'         => $type,
                'element'      => $element,
                'folder'       => $folder,
                'client_id'    => $client,
                'name'         => $name,
                'installed'    => $installed,
                'available'    => $available,
                'has_update'   => $hasUpdate,
                'site_url'     => $siteUrl,
                'site_enabled' => true, // nach s.enabled = 1 gefiltert
                // HTProtect selbst gilt als "ausgewaehlt", sobald autoupdate_self an ist
                // (eigener Sofort-Pfad) - unabhaengig von der Auswahlliste.
                'included'     => $included,
                // "Nie" (autoupdate_never bzw. Template-Typ-Default): unterschreitet den Sicherheits-Boden.
                // eligibility() prueft dies VOR dem Verwundbar-Bypass -> kein Auto-Update, auch bei Luecke.
                'never'        => $never,
                'suspended'    => !empty($suspended[$key]['suspended']),
                'known_host'   => isset($hosts[$key]) ? (string) $hosts[$key] : '',
                'key_cooldown' => isset($kc[$key]) && $now < (int) $kc[$key],
                // Endgueltig aufgegeben ('manuell erforderlich') - NUR fuer genau die
                // Version, die aufgegeben wurde; eine neu angebotene Version re-armt (done
                // trifft dann nicht mehr, faellt zurueck auf 'ready'/frischen Versuch).
                'key_manual'   => !empty($kf[$key]['done']) && (string) (isset($kf[$key]['to']) ? $kf[$key]['to'] : '') === (string) $available,
                'mal_block'    => isset($mb[$key]) ? $mb[$key] : null,
                'self'         => (strtolower($element) === 'com_htprotect'),
                // Echter Zustand des Selbst-Update-Schalters (NICHT 'included' - das wird bei
                // auDefault='all' auch ohne aktives Self-Update true, was die Zeilen-Badge sonst
                // faelschlich auf "an" setzt). Nur fuer die self-Zeile relevant.
                'self_on'      => !empty($cfg['autoupdate_self']),
                'vulnerable'   => $vulnerable,
                // Schliesst das angebotene Update die Luecke wirklich? Steuert die Security-Ausnahme
                // an den Halte-Gates (Major/radar-held); 'vulnerable' bleibt fuer Anzeige/Ampel/Bit 1.
                'vuln_fix_reachable' => $vulnFixReach,
                // Zentral gesperrt? (rein, fail-closed). 'recall_avail' = das ANGEBOTENE
                // Update darf nicht installiert werden; 'recall_inst' = die bereits
                // installierte Version ist zurueckgerufen (nur Hinweis, kein Eingriff).
                'recall_avail' => ($available !== '' && !empty($recallList))
                    ? HtpDefs::recallMatch($recallList, $type, $element, $available) : null,
                'recall_inst'  => ($installed !== '' && !empty($recallList))
                    ? HtpDefs::recallMatch($recallList, $type, $element, $installed) : null,
                // Vorsorglich (still) zurueckgestellt? Nur die VERFUEGBARE Version zaehlt.
                'held'         => ($available !== '' && !empty($holdList))
                    ? HtpDefs::heldVersion($holdList, $element, $available) : null,
                // Grosser Versionssprung (SemVer-Major, z. B. 3.x -> 4.x) bei aktiviertem
                // Schalter zurueckhalten - nur wenn ein Update vorliegt. Fail-open via
                // isMajorJump; die Security-Ausnahme steckt in eligibility().
                'major_hold'   => ($hasUpdate && !empty($cfg['autoupdate_skip_major']))
                    ? self::isMajorJump($installed, $available) : false,
            );

            $info['status'] = self::eligibility($info);
            $infos[]        = $info;
        }

        // Sortieren: Erweiterungen MIT Update zuerst, dann alphabetisch.
        usort($infos, array(__CLASS__, 'cmpInfo'));

        return $infos;
    }

    /**
     * Sortier-Vergleich der Info-Liste: mit verfuegbarem Update zuerst, dann Name.
     *
     * @param   array  $a  Info A
     * @param   array  $b  Info B
     *
     * @return int
     */
    public static function cmpInfo($a, $b)
    {
        $au = !empty($a['has_update']) ? 1 : 0;
        $bu = !empty($b['has_update']) ? 1 : 0;

        if ($au !== $bu) {
            return $bu - $au;
        }

        return strcasecmp(isset($a['name']) ? (string) $a['name'] : '', isset($b['name']) ? (string) $b['name'] : '');
    }

    /**
     * Version aus einem manifest_cache-JSON ziehen (nur Anzeige).
     *
     * @param   string  $json  manifest_cache-Inhalt
     *
     * @return string
     */
    private static function manifestVersion($json)
    {
        if (!is_string($json) || $json === '') {
            return '';
        }

        $d = json_decode($json, true);

        return (is_array($d) && isset($d['version'])) ? (string) $d['version'] : '';
    }

    /**
     * Joomlas Updater anstossen, damit #__updates die verfuegbaren Updates fuellt.
     * Nur HTTPS-Quellen werden spaeter installiert (Eligibility), aber die Pruefung
     * selbst macht Joomla gegen die admin-registrierten Update-Sites.
     *
     * @return boolean
     */
    private static function joomlaFindUpdates()
    {
        if (!class_exists('Joomla\\CMS\\Updater\\Updater')) {
            return false;
        }

        try {
            $updater = Joomla\CMS\Updater\Updater::getInstance();

            if (!$updater || !method_exists($updater, 'findUpdates')) {
                return false;
            }

            // Frische Pruefung erzwingen (plan 18): stale #__updates ueber die
            // Updater-API leeren + last_check_timestamp zuruecksetzen, damit
            // findUpdates wirklich neu gegen die Update-Sites prueft. enabled wird
            // BEWUSST nicht angefasst (eine vom Admin deaktivierte Update-Site
            // bleibt deaktiviert).
            if (method_exists($updater, 'purge')) {
                try {
                    $updater->purge();
                } catch (Exception $e) {
                    // egal - findUpdates aktualisiert ohnehin
                } catch (Throwable $e) {
                    // egal
                }
            }

            $db = htp_db();

            if ($db) {
                try {
                    $q = $db->getQuery(true)->update($db->quoteName('#__update_sites'))
                        ->set($db->quoteName('last_check_timestamp') . ' = 0');
                    $db->setQuery($q)->execute();
                } catch (Exception $e) {
                    // egal
                } catch (Throwable $e) {
                    // egal
                }
            }

            // Nur STABLE-Updates (plan 17.2). findUpdates(eid=0 -> alle,
            // cacheTimeout=0 -> jetzt pruefen, minimum_stability = STABLE).
            $stable = defined('Joomla\\CMS\\Updater\\Updater::STABILITY_STABLE')
                ? constant('Joomla\\CMS\\Updater\\Updater::STABILITY_STABLE') : 4;

            // NEBENWIRKUNGSSCHUTZ (Forenfall Svenoldman): Joomlas findUpdates() schaltet
            // nicht-erreichbare Update-Sites SELBSTTAETIG ab (dokumentiertes Kernverhalten,
            // ohne Auto-Wiedereinschalten). Damit HTProtects Pruefung NIEMALS zu einer
            // deaktivierten Update-Quelle beitraegt, merken wir die vorher aktiven Sites
            // und reaktivieren danach jede, die die Pruefung abgeschaltet hat. HTProtect
            // schaltet dabei AUSSCHLIESSLICH wieder ein, nie ab.
            $htpEnabledBefore = self::enabledUpdateSiteIds();

            try {
                $updater->findUpdates(0, 0, $stable);
            } finally {
                // IMMER reaktivieren - auch wenn findUpdates() nach dem Abschalten einer
                // nicht erreichbaren Quelle eine Ausnahme wirft. Nur so gilt die Invariante
                // "HTProtects Pruefung laesst NIE eine Update-Quelle deaktiviert zurueck"
                // ausnahmslos (sicherheitsrelevant: Update-Quelle muss verfuegbar bleiben).
                self::reenableUpdateSites($htpEnabledBefore);
            }

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

    /**
     * IDs aller aktuell AKTIVEN Update-Sites (enabled = 1). Schnappschuss VOR einer
     * Joomla-Update-Pruefung, damit reenableUpdateSites() danach exakt die Quellen
     * wieder einschalten kann, die die Pruefung deaktiviert hat - und NUR diese.
     * Reiner Lesezugriff, fail-soft (Fehler -> leere Liste -> kein Restore, aber auch
     * kein Schaden).
     *
     * @return array  Liste von update_site_id (int), kann leer sein
     */
    private static function enabledUpdateSiteIds()
    {
        $db = htp_db();

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

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName('update_site_id'))
                ->from($db->quoteName('#__update_sites'))
                ->where($db->quoteName('enabled') . ' = 1');
            $db->setQuery($q);
            $ids = $db->loadColumn();

            return is_array($ids) ? array_map('intval', $ids) : array();
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }
    }

    /**
     * Jede Update-Site aus $ids, die WÄHREND unserer Pruefung deaktiviert wurde,
     * wieder aktivieren (enabled 0 -> 1). Garantiert, dass eine HTProtect-ausgeloeste
     * Update-Pruefung KEINE Update-Quelle (mit-)deaktiviert zurücklässt - HTProtect
     * schaltet hier ausschliesslich WIEDER EIN, niemals ab (ein vom Admin bewusst
     * deaktivierter, hier nicht gelisteter Eintrag bleibt unberuehrt). Fail-soft.
     *
     * @param   array  $ids  update_site_id-Liste aus enabledUpdateSiteIds()
     *
     * @return void
     */
    private static function reenableUpdateSites($ids)
    {
        if (empty($ids) || !is_array($ids)) {
            return;
        }

        $clean = array();

        foreach ($ids as $id) {
            $id = (int) $id;

            if ($id > 0) {
                $clean[] = $id;
            }
        }

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

        $db = htp_db();

        if (!$db) {
            return;
        }

        try {
            $q = $db->getQuery(true)
                ->update($db->quoteName('#__update_sites'))
                ->set($db->quoteName('enabled') . ' = 1')
                ->where($db->quoteName('update_site_id') . ' IN (' . implode(',', $clean) . ')')
                ->where($db->quoteName('enabled') . ' = 0');
            $db->setQuery($q)->execute();
        } catch (Exception $e) {
            // fail-soft: gelingt das Reaktivieren nicht, ist der Zustand hoechstens
            // wie ohne diesen Schutz - HTProtect hat nichts VERSCHLECHTERT.
        } catch (Throwable $e) {
        }
    }

    /**
     * REINE Policy des Update-Quellen-Wächters (testbar, ohne DB/IO). Entscheidet je
     * Update-Quelle, ob sie reaktiviert wird, ob eine FREMDE Quelle nach zu vielen
     * vergeblichen Runden in Ruhe gelassen (Opt-out) wird, und schreibt den Zähler-State
     * fort.
     *
     * Prinzip:
     *  - Die EIGENE Quelle (com_htprotect) UND jede Erweiterung mit aktivem Auto-Update
     *    ('autoupdate'=true) werden IMMER reaktiviert - kein Zähler, kein Opt-out,
     *    unabhängig vom Schalter (Auto-Update braucht die Quelle zwingend erreichbar).
     *  - Eine manuelle AUSNAHME ('except'=true, vom Nutzer im Ausnahmen-Dialog gesetzt)
     *    wird NIE reaktiviert - steht ÜBER dem Schalter, aber UNTER self/autoupdate.
     *  - FREMDE Quellen nur, wenn der Schalter an ist ($switchOn) UND die Erweiterung
     *    noch vorhanden ist. Ein temporärer Hänger wird geheilt; bleibt eine Quelle
     *    trotz Reaktivierung hartnäckig aus (chronisch unerreichbar ODER vom Admin
     *    bewusst deaktiviert), gibt HTProtect nach KEEPALIVE_MAX_TRIES Runden Ruhe und
     *    meldet das GENAU EINMAL (Opt-out).
     *  - Ist eine zuvor reaktivierte Quelle wieder AN, war die Reaktivierung erfolgreich
     *    -> Zähler zurücksetzen (jeder Erfolg startet frisch, kein schleichender Opt-out).
     *
     * @param   array  $sites     je Eintrag ['id'=>int,'enabled'=>bool,'eid'=>int,'installed'=>bool,'autoupdate'=>bool,'except'=>bool,'name'=>string]
     * @param   array  $state     bisher: ['<id>' => ['tries'=>int,'optout'=>bool]]
     * @param   int    $selfEid   eigene extension_id (com_htprotect); 0 = unbekannt
     * @param   bool   $switchOn  Schalter "fremde Quellen aktiv halten"
     *
     * @return  array  ['reenable'=>int[], 'state'=>array, 'newOptOut'=>array<['id','name']>]
     */
    public static function keepAlivePlan($sites, $state, $selfEid, $switchOn)
    {
        $reenable  = array();
        $newState  = array();
        $newOptOut = array();

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

        if (!is_array($sites)) {
            return array('reenable' => $reenable, 'state' => $newState, 'newOptOut' => $newOptOut);
        }

        $selfEid = (int) $selfEid;

        foreach ($sites as $s) {
            $id = isset($s['id']) ? (int) $s['id'] : 0;

            if ($id <= 0) {
                continue;
            }

            $enabled   = !empty($s['enabled']);
            $eid       = isset($s['eid']) ? (int) $s['eid'] : 0;
            $installed = !empty($s['installed']);
            $key       = (string) $id;
            $prev      = isset($state[$key]) && is_array($state[$key]) ? $state[$key] : array();

            // 1) EIGENE Quelle ODER Erweiterung mit aktivem Auto-Update: IMMER aktiv
            //    halten (ohne Zähler/Opt-out/Schalter). Bei Auto-Update MUSS die Quelle
            //    erreichbar sein, sonst könnte HTProtect gar nicht updaten - eine
            //    deaktivierte Quelle wäre ein Selbstwiderspruch.
            if (($selfEid > 0 && $eid === $selfEid) || !empty($s['autoupdate'])) {
                if (!$enabled) {
                    $reenable[] = $id;
                }

                continue; // kein State-Eintrag (nie Opt-out)
            }

            // 2) Verwaiste Quelle (Erweiterung deinstalliert): nicht anfassen, State vergessen.
            if (!$installed) {
                continue;
            }

            // 2b) Manuelle Ausnahme (Nutzer hat sie im Ausnahmen-Dialog auf "in Ruhe lassen"
            //     gestellt): nie reaktivieren. Steht ÜBER dem Schalter (explizite Nutzer-
            //     Entscheidung), aber UNTER self/autoupdate (die brauchen die Quelle zwingend
            //     - solche Quellen sind im Dialog gar nicht ausnehmbar). State vergessen.
            if (!empty($s['except'])) {
                continue;
            }

            // 3) Schalter aus -> fremde Quellen in Ruhe lassen, aber gemerkten Zähler behalten
            //    (ein spaeteres Wieder-Einschalten faengt nicht bei 0 an).
            if (!$switchOn) {
                if ($prev) {
                    $newState[$key] = $prev;
                }

                continue;
            }

            // 4) Bereits aufgegeben -> in Ruhe lassen (keine erneute Meldung).
            if (!empty($prev['optout'])) {
                $newState[$key] = $prev;

                continue;
            }

            // 5) Quelle ist AN: nie ausgefallen ODER unsere Reaktivierung war erfolgreich
            //    -> Zähler zuruecksetzen (kein State-Eintrag mehr).
            if ($enabled) {
                continue;
            }

            // 6) Quelle ist AUS.
            $tries = isset($prev['tries']) ? (int) $prev['tries'] : 0;

            if ($tries >= self::KEEPALIVE_MAX_TRIES) {
                // Genug vergeblich versucht -> Opt-out + einmal melden.
                $newState[$key] = array('tries' => $tries, 'optout' => true);
                $newOptOut[]    = array('id' => $id, 'name' => isset($s['name']) ? (string) $s['name'] : '');

                continue;
            }

            // Reaktivieren + Zähler hoch.
            $reenable[]     = $id;
            $newState[$key] = array('tries' => $tries + 1, 'optout' => false);
        }

        return array('reenable' => $reenable, 'state' => $newState, 'newOptOut' => $newOptOut);
    }

    /**
     * Update-Quellen-Wächter (eigenständig, hoechstens 1x/Tag, UNABHAENGIG vom Auto-
     * Update-Schalter). Reaktiviert Update-Quellen, die Joomlas Kern bei temporaeren
     * Haengern selbsttaetig abgeschaltet hat - die eigene (com_htprotect) immer, fremde
     * nur mit Schalter + selbstlernendem Opt-out (siehe keepAlivePlan). Rein additiv:
     * schaltet NUR ein, nie ab. Vollstaendig fail-soft.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return void
     */
    public static function keepUpdateSitesAlive($cfg)
    {
        try {
            $file  = function_exists('htp_state_file') ? htp_state_file('updatesites.keepalive') : '';
            $state = self::keepAliveState($file);

            // Taegliches Gate (+ installations-spezifischer Versatz gegen synchrone Last).
            $now  = time();
            $last = isset($state['last_run']) ? (int) $state['last_run'] : 0;

            if (($now - $last) < (self::KEEPALIVE_CHECK + self::siteJitter())) {
                return;
            }

            // Schalter fuer FREMDE Quellen (Default an). Die EIGENE bleibt immer aktiv.
            $switchOn = !is_array($cfg) || !isset($cfg['keep_update_sites']) || !empty($cfg['keep_update_sites']);

            $sites    = self::updateSiteRows($cfg);
            $prevMap  = isset($state['sites']) && is_array($state['sites']) ? $state['sites'] : array();
            $plan     = self::keepAlivePlan($sites, $prevMap, self::selfExtensionId(), $switchOn);

            if (!empty($plan['reenable'])) {
                self::reenableUpdateSites($plan['reenable']);
            }

            $state['sites']    = $plan['state'];
            $state['last_run'] = $now;
            self::saveState($file, $state);

            // Neue Opt-outs GENAU EINMAL ruhig melden (fail-soft).
            if (!empty($plan['newOptOut']) && is_array($cfg)) {
                self::notifyKeepAliveOptOut($cfg, $plan['newOptOut']);
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    /**
     * Zähler-/Lauf-State des Update-Quellen-Wächters laden. Fail-soft.
     *
     * @param   string  $file  Pfad
     *
     * @return array  ['last_run'=>int, 'sites'=>array]
     */
    private static function keepAliveState($file)
    {
        if ($file === '' || !is_file($file)) {
            return array('last_run' => 0, 'sites' => array());
        }

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

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

        if (!isset($json['last_run'])) {
            $json['last_run'] = 0;
        }

        if (!isset($json['sites']) || !is_array($json['sites'])) {
            $json['sites'] = array();
        }

        return $json;
    }

    /**
     * REINE Eligibilität (testbar): Wird eine Erweiterung automatisch aktualisiert - und
     * braucht daher zwingend eine AKTIVE Update-Quelle? Spiegelt EXAKT die execPlan-Regel
     * (autoupdate.php ~Z.1826/1830): Master-Schalter an UND (angreifbar = Sicherheitsupdate
     * laeuft immer ODER im "alle"-Modus ausgewaehlt). NICHT "alle sind auto, sobald der
     * alle-Modus an ist" - die Auswahl (included) bleibt ausschlaggebend.
     *
     * @param   bool  $masterOn    autoupdate_ext (Master "Sicherheitsupdates automatisch")
     * @param   bool  $allOn       autoupdate_all ("auf alle ausweiten"-Modus)
     * @param   bool  $vulnerable  Erweiterung ist angreifbar (Sicherheitsupdate)
     * @param   bool  $included    in autoupdate_include (per-Erweiterung ausgewaehlt)
     * @param   bool  $never       Erweiterung steht auf "Nie" (autoupdate_never) - unterschreitet den
     *                             Sicherheits-Boden: KEIN Auto-Update, auch kein Sicherheitsupdate.
     *
     * @return boolean
     */
    public static function autoUpdateEligible($masterOn, $allOn, $vulnerable, $included, $never = false)
    {
        if ((bool) $never) {
            return false; // "Nie" schlaegt ALLES (auch den Verwundbar-Boden) - bewusster Opt-out
        }
        if (!$masterOn) {
            return false;
        }

        return (bool) $vulnerable || ((bool) $allOn && (bool) $included);
    }

    /**
     * Alle Update-Quellen mit Zustand + verknüpfter Erweiterung. Je Eintrag:
     * id, enabled(bool), eid, installed(bool = zugehörige Erweiterung existiert noch),
     * name. Quellen ohne Junction-Zeile (keine Erweiterung) erscheinen nicht -> nichts
     * zu reaktivieren. Fail-soft ([]).
     *
     * @return array
     */
    private static function updateSiteRows($cfg)
    {
        $db = htp_db();

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

        // Ist Auto-Update fuer eine Erweiterung vorgesehen? Dann deren Quelle IMMER aktiv
        // halten (Deaktivieren waere ein Selbstwiderspruch): Master-Schalter an UND
        // ("alle"-Modus ODER Erweiterung in der Auswahl-Liste). Pro Zeile ausgewertet.
        $masterOn = is_array($cfg) && !empty($cfg['autoupdate_ext']);
        $allOn    = $masterOn && !empty($cfg['autoupdate_all']);
        $incMap   = array();
        $vulnSet  = array();

        if ($masterOn) {
            // Auswahl-Liste (Opt-in; wirkt NUR im "alle"-Modus) - immer laden, wenn Master an.
            if (isset($cfg['autoupdate_include']) && is_array($cfg['autoupdate_include'])) {
                foreach ($cfg['autoupdate_include'] as $k) {
                    $incMap[strtolower((string) $k)] = true;
                }
            }

            // Angreifbare Erweiterungen: ihr Sicherheitsupdate laeuft bei Master an IMMER
            // automatisch (unabhaengig von Auswahl/alle-Modus) -> Quelle wird gebraucht.
            if (class_exists('HtpDefs') && method_exists('HtpDefs', 'checkExtensions')) {
                foreach ((array) HtpDefs::checkExtensions() as $v) {
                    if (isset($v['element']) && $v['element'] !== '') {
                        $vulnSet[strtolower((string) $v['element'])] = true;
                    }
                }
            }
        }

        // Manuelle Ausnahmen (extKey-basiert, aus dem Ausnahmen-Dialog): diese Quellen
        // werden bewusst NICHT aktiv gehalten.
        $excMap = array();

        if (is_array($cfg) && isset($cfg['keep_update_sites_except']) && is_array($cfg['keep_update_sites_except'])) {
            foreach ($cfg['keep_update_sites_except'] as $k) {
                $excMap[strtolower((string) $k)] = true;
            }
        }

        try {
            $q = $db->getQuery(true)
                ->select(array(
                    $db->quoteName('s.update_site_id', 'id'),
                    $db->quoteName('s.enabled', 'en'),
                    $db->quoteName('s.name', 'nm'),
                    $db->quoteName('e.extension_id', 'eid'),
                    $db->quoteName('x.extension_id', 'xid'),
                    $db->quoteName('x.element', 'el'),
                    $db->quoteName('x.type', 'ty'),
                    $db->quoteName('x.folder', 'fo'),
                    $db->quoteName('x.name', 'xn'),
                ))
                ->from($db->quoteName('#__update_sites', 's'))
                ->join(
                    'INNER',
                    $db->quoteName('#__update_sites_extensions', 'e')
                        . ' ON ' . $db->quoteName('e.update_site_id') . ' = ' . $db->quoteName('s.update_site_id')
                )
                ->join(
                    'LEFT',
                    $db->quoteName('#__extensions', 'x')
                        . ' ON ' . $db->quoteName('x.extension_id') . ' = ' . $db->quoteName('e.extension_id')
                );
            $db->setQuery($q);
            $rows = $db->loadObjectList();

            $out = array();

            if (is_array($rows)) {
                foreach ($rows as $r) {
                    $installed = ($r->xid !== null); // LEFT JOIN traf -> Erweiterung existiert
                    $exKey     = $installed ? strtolower(self::extKey((string) $r->ty, (string) $r->el, (string) $r->fo)) : '';
                    $auto      = false;

                    // "Laeuft automatisch"? -> Quelle zwingend aktiv halten. autoUpdateEligible
                    // spiegelt exakt die execPlan-Regel (nicht "alle sind auto im alle-Modus").
                    if ($installed) {
                        $vuln = isset($vulnSet[strtolower((string) $r->el)]);

                        // Wie collectInfos: bei einem PAKET zaehlt auch eine verwundbare
                        // KIND-Erweiterung (z. B. pkg_jce -> com_jce) - dann laeuft das
                        // Sicherheitsupdate ueber das Paket und die Quelle wird gebraucht.
                        if (!$vuln && $masterOn && !empty($vulnSet) && strtolower((string) $r->ty) === 'package'
                            && class_exists('HtpUpdateBackup') && method_exists('HtpUpdateBackup', 'packageChildren')) {
                            $kids = (array) HtpUpdateBackup::packageChildren((int) $r->eid);

                            foreach ($kids as $ch) {
                                if (isset($ch['element']) && isset($vulnSet[strtolower((string) $ch['element'])])) {
                                    $vuln = true;
                                    break;
                                }
                            }

                            // Fallback bei LEEREN Kindern (kaputte/fehlende package_id) - Vendor-Stamm,
                            // exakt wie in collectInfos(), damit auch die Update-Quelle des Pakets aktiv
                            // gehalten wird, wenn nur ein Kind (fehlverlinkt) verwundbar ist.
                            if (!$vuln && empty($kids) && method_exists('HtpUpdateBackup', 'vendorToken')) {
                                $ptok = HtpUpdateBackup::vendorToken((string) $r->el);

                                if ($ptok !== '') {
                                    foreach ($vulnSet as $vel => $vbelow) {
                                        if (HtpUpdateBackup::vendorToken((string) $vel) === $ptok) {
                                            $vuln = true;
                                            break;
                                        }
                                    }
                                }
                            }
                        }

                        // BEWUSST OHNE never-Parameter: eine auf "Nie" gestellte, aber angreifbare
                        // Erweiterung soll ihre Update-Quelle AKTIV behalten, damit das Update weiter
                        // erkannt und als Warnung gezeigt wird ("Nie" verhindert nur den Auto-INSTALL,
                        // nicht die Erkennung). Der Nicht-Install passiert in eligibility()/pickAllCandidates.
                        $auto = self::autoUpdateEligible($masterOn, $allOn, $vuln, isset($incMap[$exKey]));
                    }

                    $out[] = array(
                        'id'         => (int) $r->id,
                        'enabled'    => ((int) $r->en === 1),
                        'eid'        => (int) $r->eid,
                        'installed'  => $installed,
                        'autoupdate' => $auto,
                        'except'     => ($installed && isset($excMap[$exKey])),
                        'name'       => (string) $r->nm,
                        'extkey'     => $exKey,
                        'extname'    => (string) $r->xn,
                    );
                }
            }

            return $out;
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }
    }

    /**
     * Aufbereitete Liste ALLER Update-Quellen installierter Erweiterungen für den
     * Ausnahmen-Dialog (public, von der UI genutzt). Je Erweiterung EIN Eintrag (nach
     * stabilem extKey dedupliziert), alphabetisch. 'locked' = eigene ODER Auto-Update
     * -> immer aktiv, im Dialog nicht ausnehmbar. Fail-soft ([]).
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array  je Eintrag ['extkey','name','enabled','locked','except']
     */
    public static function siteKeepList($cfg)
    {
        $selfEid = self::selfExtensionId();
        $rows    = self::updateSiteRows($cfg);
        $byKey   = array();

        foreach ($rows as $r) {
            if (empty($r['installed']) || $r['extkey'] === '') {
                continue; // verwaiste/kaputte Quellen nicht anzeigen
            }

            $k      = $r['extkey'];
            $locked = ($selfEid > 0 && $r['eid'] === $selfEid) || !empty($r['autoupdate']);

            if (isset($byKey[$k])) {
                // Mehrere Quellen derselben Erweiterung zusammenfassen.
                $byKey[$k]['enabled'] = $byKey[$k]['enabled'] || !empty($r['enabled']);
                $byKey[$k]['locked']  = $byKey[$k]['locked'] || $locked;

                continue;
            }

            // Anzeigename: Erweiterungs-Name, sofern kein blosser Sprachschluessel (COM_XYZ);
            // sonst der Update-Site-Name, sonst der Schluessel selbst.
            $nm = (string) $r['extname'];

            if ($nm === '' || preg_match('~^[A-Z][A-Z0-9_]+$~', $nm)) {
                $nm = ((string) $r['name'] !== '') ? (string) $r['name'] : $k;
            }

            $byKey[$k] = array(
                'extkey'  => $k,
                'name'    => $nm,
                'enabled' => !empty($r['enabled']),
                'locked'  => $locked,
                'except'  => !empty($r['except']),
            );
        }

        $out = array_values($byKey);
        usort($out, function ($a, $b) {
            return strcasecmp((string) $a['name'], (string) $b['name']);
        });

        return $out;
    }

    /**
     * Eine ruhige Sammel-Mail, wenn HTProtect eine (oder mehrere) FREMDE Update-Quelle
     * nach mehreren vergeblichen Reaktivierungen in Ruhe lässt. Rein informativ,
     * kommt je Quelle nur einmal (danach optout=true). Fail-soft.
     *
     * @param   array  $cfg   Konfiguration
     * @param   array  $list  [['id'=>int,'name'=>string], ...]
     *
     * @return void
     */
    private static function notifyKeepAliveOptOut($cfg, $list)
    {
        $names = array();

        foreach ($list as $it) {
            $nm      = isset($it['name']) ? trim((string) $it['name']) : '';
            $names[] = ($nm !== '') ? $nm : ('#' . (isset($it['id']) ? (int) $it['id'] : 0));
        }

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

        $lines = array(htp_t('COM_HTPROTECT_AU_KEEPALIVE_OPTOUT_INTRO'), '');

        foreach ($names as $nm) {
            $lines[] = '  - ' . $nm;
        }

        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_AU_KEEPALIVE_OPTOUT_HINT');

        self::sendNotify($cfg, 'COM_HTPROTECT_AU_KEEPALIVE_OPTOUT_SUBJECT', $lines);
    }

    /**
     * Deterministischer Zeit-Versatz (0-1800 s) je INSTALLATION. Damit die 6h-Drossel
     * fuer Fremd-Update-Server NICHT auf allen HTProtect-Seiten zur selben Sekunde
     * faellig wird (kein synchroner "Thundering Herd" bei vielen Seiten desselben
     * Hosters). Stabil pro Install (aus dem Site-Pfad) -> ueber Laeufe hinweg gleich.
     *
     * @return int  0..1800 (Sekunden)
     */
    private static function siteJitter()
    {
        $seed = function_exists('htp_root') ? (string) htp_root() : '';

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

        // 24-Bit-Hash (immer positiv, passt in int) -> 0..1800.
        return (int) (hexdec(substr(md5($seed), 0, 6)) % 1801);
    }

    /**
     * Mindestabstand zwischen zwei FRISCHEN Fremd-Abrufen: 6 h + installations-
     * spezifischer Versatz. Zwischen zwei frischen Abrufen nutzt HTProtect Joomlas
     * Cache (kein Kontakt zu Servern, die juenger als der Floor geprueft wurden).
     *
     * @return int  Sekunden
     */
    private static function providerCheckFloor()
    {
        return self::PROVIDER_CHECK_MIN + self::siteJitter();
    }

    /**
     * Die eigene extension_id (com_htprotect-Komponente). Pro Request gemerkt.
     * Fail-soft (0 = unbekannt).
     *
     * @return int
     */
    private static function selfExtensionId()
    {
        static $eid = null;

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

        $eid = 0;
        $db  = htp_db();

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

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName('extension_id'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_htprotect'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'));
            $db->setQuery($q);
            $eid = (int) $db->loadResult();
        } catch (Exception $e) {
            $eid = 0;
        } catch (Throwable $e) {
            $eid = 0;
        }

        return $eid;
    }

    /**
     * NUR HTProtects EIGENE Update-Site frisch pruefen (ein Request an den eigenen
     * Update-Server) - unabhaengig von der 6h-Drossel fuer Fremd-Anbieter. So bleibt
     * das HTProtect-Selbst-Update schnell (z. B. 15-Min-Sofort-Modus), OHNE fremde
     * Update-Server (Kubik-Rubik u. a.) zu belasten. findUpdates(eid) trifft nur die
     * Update-Site(s) DIESER Erweiterung. Fail-soft; Nebenwirkungsschutz inklusive.
     *
     * @return void
     */
    private static function selfUpdateSiteRefresh()
    {
        if (!class_exists('Joomla\\CMS\\Updater\\Updater')) {
            return;
        }

        $eid = self::selfExtensionId();

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

        // Konditionaler Vor-Check (server-schonend): die eigene, statische htprotect.xml
        // per If-None-Match/If-Modified-Since anfragen. 304 = unveraendert -> es KANN keine
        // neue Selbst-Version geben -> Joomlas findUpdates ganz ueberspringen (kein Download,
        // kein Parse, keine DB-Schreibzugriffe). Nur 200 (geaenderte XML) oder ein unklarer
        // Status loesen den vollen frischen Lauf aus. Fail-open fuer den SKIP (im Zweifel
        // NICHT ueberspringen). Ein sehr seltener, manuell geleerter #__updates-Eintrag wird
        // spaetestens vom 6h-Voll-Check wieder aufgebaut. Analog zum Signatur-Feed (HtpDefs).
        if (self::selfXmlUnchanged((int) $eid)) {
            return;
        }

        try {
            $updater = Joomla\CMS\Updater\Updater::getInstance();

            if (!$updater || !method_exists($updater, 'findUpdates')) {
                return;
            }

            $stable = defined('Joomla\\CMS\\Updater\\Updater::STABILITY_STABLE')
                ? constant('Joomla\\CMS\\Updater\\Updater::STABILITY_STABLE') : 4;

            $before = self::enabledUpdateSiteIds();

            try {
                $updater->findUpdates((int) $eid, 0, $stable); // nur com_htprotect, frisch
            } finally {
                // auch bei Ausnahme: die eigene Update-Quelle nie deaktiviert lassen
                self::reenableUpdateSites($before);
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    /**
     * URL der (aktiven) Update-Site der eigenen Komponente (com_htprotect) - die statische
     * htprotect.xml auf dem eigenen Update-Server. Aus #__update_sites via Junction-Tabelle.
     * Nur http(s) (kein file:// o. Ae.). '' = unbekannt. Genau die URL, die Joomlas
     * findUpdates ohnehin abruft (kein neuer Abruf-/SSRF-Kontext).
     *
     * @param   int  $eid  com_htprotect extension_id
     *
     * @return string
     */
    private static function selfUpdateXmlUrl($eid)
    {
        $eid = (int) $eid;
        $db  = htp_db();

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

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName('s.location'))
                ->from($db->quoteName('#__update_sites', 's'))
                ->join(
                    'INNER',
                    $db->quoteName('#__update_sites_extensions', 'e')
                        . ' ON ' . $db->quoteName('e.update_site_id') . ' = ' . $db->quoteName('s.update_site_id')
                )
                ->where($db->quoteName('e.extension_id') . ' = ' . $eid)
                ->where($db->quoteName('s.enabled') . ' = 1');
            $db->setQuery($q, 0, 1);
            $loc = (string) $db->loadResult();
        } catch (Exception $e) {
            return '';
        } catch (Throwable $e) {
            return '';
        }

        return preg_match('~^https?://~i', $loc) ? $loc : '';
    }

    /**
     * REINE Freshness-Pruefung der eigenen Update-XML: liefert true NUR, wenn der Server
     * mit 304 (konditionaler Abruf per If-None-Match/If-Modified-Since) antwortet - dann ist
     * die htprotect.xml unveraendert und es KANN keine neue Selbst-Version geben, der volle
     * findUpdates-Lauf entfaellt (spart den Voll-Download + Parse + DB-Schreib). ETag/Last-
     * Modified werden je URL im geschuetzten data/-Ordner gehalten. FAIL-CLOSED fuer den SKIP:
     * kein HtpProbe/kein state_file/keine URL/keine gespeicherten Validatoren/Fehler/jeder
     * Status != 304 -> false (voll pruefen). SSL wird von HtpProbe verifiziert.
     *
     * @param   int  $eid  com_htprotect extension_id
     *
     * @return bool  true = unveraendert (findUpdates ueberspringbar)
     */
    private static function selfXmlUnchanged($eid)
    {
        if (!class_exists('HtpProbe') || !function_exists('htp_state_file')) {
            return false;
        }

        $url = self::selfUpdateXmlUrl((int) $eid);

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

        $cacheFile = htp_state_file('selfupdate.xml.cache');
        $cache     = @is_file($cacheFile) ? json_decode((string) @file_get_contents($cacheFile), true) : null;
        $cache     = is_array($cache) ? $cache : array();

        // Gespeicherte Validatoren nur nutzen, wenn sie zur AKTUELLEN URL gehoeren.
        $sameUrl = isset($cache['url']) && $cache['url'] === $url;
        $etag    = ($sameUrl && isset($cache['etag']))     ? (string) $cache['etag']     : '';
        $mod     = ($sameUrl && isset($cache['modified'])) ? (string) $cache['modified'] : '';

        $req = array();

        if ($etag !== '') {
            $req[] = 'If-None-Match: ' . $etag;
        }

        if ($mod !== '') {
            $req[] = 'If-Modified-Since: ' . $mod;
        }

        try {
            $resp = HtpProbe::fetch($url, false, null, 6, $req); // HEAD (kein Body), konditional
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }

        $status  = isset($resp['status'])   ? (int) $resp['status']      : 0;
        $newEtag = isset($resp['etag'])     ? (string) $resp['etag']     : '';
        $newMod  = isset($resp['modified']) ? (string) $resp['modified'] : '';

        // Validatoren fuer den naechsten Lauf mitnehmen (bei 304 liefert der Server oft nur
        // den ETag erneut -> vorhandene behalten). Best-effort, fail-silent.
        $keepEtag = $newEtag !== '' ? $newEtag : $etag;
        $keepMod  = $newMod !== ''  ? $newMod  : $mod;

        if ($keepEtag !== '' || $keepMod !== '') {
            @file_put_contents(
                $cacheFile,
                json_encode(array('url' => $url, 'etag' => $keepEtag, 'modified' => $keepMod)),
                LOCK_EX
            );
        }

        // Ueberspringen NUR bei echtem 304 UND vorhandenem Validator (ohne Validator konnte
        // der Server gar nicht konditional antworten -> nichts gespart, nichts ueberspringen).
        return $status === 304 && ($etag !== '' || $mod !== '');
    }

    /**
     * PROAKTIVE Vuln-Freischaltung, ENTKOPPELT vom Auto-Update-Feature: sobald der
     * Waechter eine INSTALLIERT-VERWUNDBARE Erweiterung erkennt (HtpDefs::checkExtensions),
     * loest HTProtect EINMALIG einen gezielten frischen Joomla-Update-Check NUR fuer
     * diese Erweiterung aus -> die Fix-Version steht sofort in #__updates (Joomlas
     * Update-Liste + HTProtects Ampel + installierbar), auch fuer Nutzer mit
     * autoupdate_ext=AUS / Nur-Melden, die selbst updaten wollen. Gezielt
     * (findUpdates(eid) = ein Anbieter-Request, konsistent zur 6h-Anbieter-Schonung),
     * EINMALIG je Erweiterung@Version (State-Merker) -> kein Haemmern. NUR im Backend
     * (dort gehoert die Update-Pruefung hin + der Admin ist da). Fail-soft;
     * Nebenwirkungsschutz (reenableUpdateSites) inklusive.
     *
     * @param   array  $vuln  Rueckgabe von HtpDefs::checkExtensions()
     *
     * @return void
     */
    public static function surfaceVulnerableUpdates($vuln)
    {
        if (empty($vuln) || !is_array($vuln) || !class_exists('Joomla\\CMS\\Updater\\Updater')) {
            return;
        }

        // Nur im Admin-Kontext: eine Update-Pruefung ist eine Backend-Operation.
        try {
            $app = class_exists('Joomla\\CMS\\Factory') ? Joomla\CMS\Factory::getApplication() : null;

            if (!$app || !method_exists($app, 'isClient') || !$app->isClient('administrator')) {
                return;
            }
        } catch (Exception $e) {
            return;
        } catch (Throwable $e) {
            return;
        }

        $seen   = self::loadVulnSurfaced();
        $now    = time();
        $dirty  = false;
        $stable = defined('Joomla\\CMS\\Updater\\Updater::STABILITY_STABLE')
            ? constant('Joomla\\CMS\\Updater\\Updater::STABILITY_STABLE') : 4;

        foreach ($vuln as $v) {
            $element = isset($v['element']) ? (string) $v['element'] : '';

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

            $ver = isset($v['installed']) ? (string) $v['installed'] : '';
            $key = strtolower($element) . '|' . strtolower(isset($v['folder']) ? (string) $v['folder'] : '') . '@' . $ver;

            if (isset($seen[$key])) {
                continue; // diese verwundbare Version wurde schon einmal frisch geprueft
            }

            $eid = self::extensionIdOf(
                $element,
                isset($v['type']) ? (string) $v['type'] : '',
                isset($v['folder']) ? (string) $v['folder'] : '',
                isset($v['client_id']) ? (int) $v['client_id'] : -1
            );

            if ($eid > 0) {
                try {
                    $updater = Joomla\CMS\Updater\Updater::getInstance();

                    if ($updater && method_exists($updater, 'findUpdates')) {
                        $before = self::enabledUpdateSiteIds();

                        try {
                            $updater->findUpdates((int) $eid, 0, $stable); // gezielt frisch
                        } finally {
                            self::reenableUpdateSites($before); // auch bei Ausnahme
                        }
                    }
                } catch (Exception $e) {
                } catch (Throwable $e) {
                }
            }

            // Auch bei eid=0 merken -> kein Dauer-Retry bei jedem Tick.
            $seen[$key] = $now;
            $dirty      = true;
        }

        if ($dirty) {
            self::saveVulnSurfaced($seen);
        }
    }

    /**
     * extension_id einer Erweiterung (element [+ type] [+ folder] [+ client_id]).
     * Fail-soft (0 = unbekannt).
     *
     * @param   string   $element
     * @param   string   $type
     * @param   string   $folder
     * @param   integer  $client  -1 = egal
     *
     * @return integer
     */
    private static function extensionIdOf($element, $type = '', $folder = '', $client = -1)
    {
        $element = (string) $element;

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

        $db = htp_db();

        if (!$db) {
            return 0;
        }

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName('extension_id'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('element') . ' = ' . $db->quote($element));

            if ((string) $type !== '') {
                $q->where($db->quoteName('type') . ' = ' . $db->quote((string) $type));
            }

            if ((string) $folder !== '') {
                $q->where($db->quoteName('folder') . ' = ' . $db->quote((string) $folder));
            }

            if ((int) $client >= 0) {
                $q->where($db->quoteName('client_id') . ' = ' . (int) $client);
            }

            $db->setQuery($q, 0, 1);

            return (int) $db->loadResult();
        } catch (Exception $e) {
            return 0;
        } catch (Throwable $e) {
            return 0;
        }
    }

    /** Pfad des Vuln-Freischaltungs-Merkers (Erweiterung@Version -> ts). */
    private static function vulnSurfacedFile()
    {
        $dir = function_exists('htp_state_dir') ? htp_state_dir() : '';

        return $dir !== '' ? rtrim(str_replace('\\', '/', $dir), '/') . '/htprotect_vuln_surfaced.json' : '';
    }

    /**
     * Merker laden: welche Erweiterung@Version wurde schon einmal proaktiv frisch geprueft.
     *
     * @return array
     */
    private static function loadVulnSurfaced()
    {
        $file = self::vulnSurfacedFile();

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

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

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

    /**
     * Merker speichern (gedeckelt: nur die juengsten 300 Eintraege). Fail-soft.
     *
     * @param   array  $map
     *
     * @return void
     */
    private static function saveVulnSurfaced($map)
    {
        $file = self::vulnSurfacedFile();

        if ($file === '' || !is_array($map)) {
            return;
        }

        if (count($map) > 300) {
            arsort($map);
            $map = array_slice($map, 0, 300, true);
        }

        $json = json_encode($map);

        if ($json !== false) {
            @file_put_contents($file, $json, LOCK_EX);
            @chmod($file, 0600);
        }
    }

    /**
     * SANFTE Update-Suche fuer den Notify-Modus: KEIN purge, KEIN last_check-Reset -
     * mit Cache-Timeout (6 h). Nutzt die vorhandenen #__updates, frischt nur stale
     * Sites nach; schlaegt ein Fetch fehl, bleiben die bisherigen Eintraege erhalten
     * (anders als das destruktive joomlaFindUpdates, das beim Fehlschlag alles leert).
     * Wir melden ja nur - da darf ein flatterhafter Update-Server die Liste nicht
     * faelschlich auf "nichts ausstehend" setzen.
     *
     * @return boolean
     */
    private static function joomlaFindUpdatesGentle()
    {
        if (!class_exists('Joomla\\CMS\\Updater\\Updater')) {
            return false;
        }

        try {
            $updater = Joomla\CMS\Updater\Updater::getInstance();

            if (!$updater || !method_exists($updater, 'findUpdates')) {
                return false;
            }

            $stable = defined('Joomla\\CMS\\Updater\\Updater::STABILITY_STABLE')
                ? constant('Joomla\\CMS\\Updater\\Updater::STABILITY_STABLE') : 4;

            // Nebenwirkungsschutz wie in joomlaFindUpdates: vorher aktive Update-Sites
            // merken und danach wieder einschalten, falls die Pruefung sie abgeschaltet
            // hat. HTProtect darf nie zu einer deaktivierten Update-Quelle beitragen.
            $htpEnabledBefore = self::enabledUpdateSiteIds();

            try {
                $updater->findUpdates(0, 21600, $stable);
            } finally {
                // auch bei Ausnahme: keine Update-Quelle deaktiviert zuruecklassen
                self::reenableUpdateSites($htpEnabledBefore);
            }

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

    /**
     * Aktuelle #__updates-Datensatz-ID fuer eine Erweiterung (per extension_id)
     * frisch auflesen. Die in PLAN gemerkte ID kann bis zum UPDATE-Tick veraltet
     * sein, weil Joomlas Update-Checks #__updates leeren + neu vergeben - dann liefe
     * installUpdate(stale) ins Leere. So bekommt der Install immer die gueltige ID.
     *
     * @param   int  $eid  Extension-ID
     *
     * @return int  update_id oder 0 (kein anstehendes Update fuer diese Erweiterung)
     */
    private static function currentUpdateId($eid)
    {
        $eid = (int) $eid;
        $db  = htp_db();

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

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName('update_id'))
                ->from($db->quoteName('#__updates'))
                ->where($db->quoteName('extension_id') . ' = ' . $eid);
            $db->setQuery($query, 0, 1);

            return (int) $db->loadResult();
        } catch (Exception $e) {
            return 0;
        } catch (Throwable $e) {
            return 0;
        }
    }

    /**
     * Version des #__updates-Datensatzes (update_id) - fuer die Sicherheits-Revalidierung
     * unmittelbar VOR dem Install (Recall-/Upgrade-Recheck). '' wenn unbekannt.
     *
     * @param   int  $uid  update_id
     *
     * @return string
     */
    private static function updateVersionOf($uid)
    {
        $uid = (int) $uid;
        $db  = htp_db();

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

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName('version'))
                ->from($db->quoteName('#__updates'))
                ->where($db->quoteName('update_id') . ' = ' . $uid);
            $db->setQuery($query, 0, 1);

            return (string) $db->loadResult();
        } catch (Exception $e) {
            return '';
        } catch (Throwable $e) {
            return '';
        }
    }

    /**
     * Ein Update ueber Joomlas Standard-UpdateModel installieren (dieselbe Routine
     * wie der "Aktualisieren"-Knopf) - HEADLESS-tauglich (plan 18). Download-Key
     * haengt das UpdateModel via extra_query selbst an; Joomla prueft die im
     * Update-XML hinterlegte SHA (256/384/512) beim Download und laesst die SSL-
     * Zertifikatspruefung an (kein eigener Download, kein eval, keine Request-URL).
     *
     * Im Hintergrund-Tick gibt es keinen eingeloggten Nutzer, UpdateModel prueft
     * aber core.admin -> fuer die Dauer des Calls eine Super-Admin-Identitaet setzen
     * (request-scoped, danach zuruecknehmen). Der Tick laeuft DETACHED nach der
     * Ausgabe (fastcgi_finish_request/Session geschlossen) -> es wird kein Besucher
     * mehr bedient. Zusaetzlich die system-/installer-Plugins laden + onAfterInitialise
     * dispatchen (Extensions mit eigenem Installer-Script/Library erwarten das).
     *
     * @param   int  $updateId  Datensatz-ID in #__updates
     *
     * @return array  ['result' => bool (getState('result')), 'gone' => bool (Update
     *                weg aus #__updates = angewandt/Race)]
     */
    private static function installUpdate($updateId)
    {
        $updateId = (int) $updateId;
        $miss     = array('result' => false, 'gone' => false);

        if ($updateId <= 0) {
            return $miss;
        }

        // NUR JOOMLA 3: der Install laeuft ueber das MVC-Legacy-Modell (ausgelagert in
        // HtpAutoUpdateLegacy) - die J4+-Fabrik unten (bootComponent/createModel) gibt es dort
        // nicht. BEWUSST VOR dem Joomla\CMS\Factory-Guard (existiert erst ab J3.8) - der
        // Adapter nutzt nur die J-praefigierten Klassen, die es auf ganz J3.x gibt. 'gone' +
        // On-Disk-Verifikation macht dieser Aufrufer einheitlich weiter (unten in
        // installOutcome), deshalb liefert der Adapter nur das konservative 'result'-Signal.
        if (class_exists('HtpAutoUpdateLegacy') && HtpAutoUpdateLegacy::isJ3()) {
            $okJ3 = HtpAutoUpdateLegacy::installUpdateById($updateId);

            return array('result' => (bool) $okJ3, 'gone' => self::updateGone($updateId));
        }

        if (!class_exists('Joomla\\CMS\\Factory')) {
            return $miss;
        }

        $app = null;

        try {
            $app = Joomla\CMS\Factory::getApplication();
        } catch (Exception $e) {
            return $miss;
        } catch (Throwable $e) {
            return $miss;
        }

        if (!$app || !method_exists($app, 'bootComponent')) {
            return $miss;
        }

        // Headless-ACL: aktuelle Identitaet + root_user sichern, Super-Admin setzen.
        $restore = self::elevateIdentity($app);
        $ok      = false;

        try {
            // Installer-Kontext (option=com_installer) + Sperr-Datei-Cleanup VOR dem Plugin-Import,
            // damit Erweiterungs-Plugins (z. B. Regular Labs) keinen Selbst-Deadlock ausloesen.
            self::prepareInstallerContext($app);

            // Plugins laden, die Installer-Skripte/Libraries erwarten, + Init-Event.
            if (class_exists('Joomla\\CMS\\Plugin\\PluginHelper')) {
                Joomla\CMS\Plugin\PluginHelper::importPlugin('system');
                Joomla\CMS\Plugin\PluginHelper::importPlugin('installer');
            }

            self::dispatchEvent($app, 'onAfterInitialise');

            $component = $app->bootComponent('com_installer');

            if ($component && method_exists($component, 'getMVCFactory')) {
                $model = $component->getMVCFactory()->createModel('Update', 'Administrator', array('ignore_request' => true));

                if ($model && method_exists($model, 'update')) {
                    if (method_exists($model, 'setState')) {
                        $model->setState('list.start', 0);
                    }

                    // Nur GENAU dieses eine Update anwenden. Dabei Joomlas Aktionslog fuer
                    // Extension-Events kurz abhaengen: manche Ziel-Postflights rufen com_installers
                    // Updatesites::rebuild() (z. B. SP Page Builder), das im hier gesetzten God-Mode-
                    // Fenster fuer JEDE Erweiterung ein onExtensionAfterUpdate feuert -> dutzende
                    // namenlose "aktualisiert"-Phantomzeilen. Der Install selbst laeuft unveraendert.
                    $alRestore = self::suppressExtensionActionlog($app);

                    try {
                        $model->update(array($updateId));
                    } finally {
                        self::restoreExtensionActionlog($app, $alRestore);
                    }

                    // WICHTIG: update() wirft bei Fehlschlag nicht, sondern setzt nur
                    // 'result'. Erfolg ausschliesslich darueber lesen.
                    if (method_exists($model, 'getState')) {
                        $ok = (bool) $model->getState('result');
                    }

                    // Joomla-Cache (mod_menu, _system, com_modules ...) leeren.
                    self::cleanJoomlaCache($app);
                }
            }
        } catch (Exception $e) {
            $ok = false;
        } catch (Throwable $e) {
            $ok = false;
        }

        // Identitaet/root_user IMMER zuruecknehmen (auch im Fehlerfall).
        self::restoreIdentity($app, $restore);

        // 'gone' = das Ziel-Update ist nicht mehr in #__updates -> von Joomla
        // angewandt (oder Race). Zusammen mit 'result' + Versionsanstieg in
        // installOutcome ausgewertet.
        return array('result' => $ok, 'gone' => self::updateGone($updateId));
    }

    /**
     * Ist das Ziel-Update nach dem Lauf NICHT mehr in #__updates? (Joomla entfernt
     * den Datensatz beim erfolgreichen Anwenden -> sauberes Erfolgssignal; deckt
     * auch "schon angewandt / Re-Run / Race" ab.)
     *
     * @param   int  $updateId  Datensatz-ID
     *
     * @return boolean
     */
    private static function updateGone($updateId)
    {
        $db = htp_db();

        if (!$db) {
            return false;
        }

        try {
            $query = $db->getQuery(true)
                ->select('COUNT(*)')
                ->from($db->quoteName('#__updates'))
                ->where($db->quoteName('update_id') . ' = ' . (int) $updateId);
            $db->setQuery($query);

            return ((int) $db->loadResult()) === 0;
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }
    }

    /**
     * Request-scoped Super-Admin-Identitaet setzen (plan 18). Liefert die Daten zum
     * spaeteren Zuruecknehmen. Findet keine Super-Admin-Identitaet -> keine Aenderung
     * (das Update scheitert dann ehrlich an der ACL-Pruefung, ohne Schaden).
     *
     * @param   object  $app  Anwendung
     *
     * @return array  Restore-Daten
     */
    private static function elevateIdentity($app)
    {
        $restore = array('user' => null, 'root' => null, 'changed' => false);

        try {
            // loadIdentity gibt es erst ab J4; dieser Pfad laeuft ohnehin nur ab J5.4 (J3 nutzt den
            // Legacy-Install). Ohne loadIdentity kein sauberer Weg -> nichts anfassen (fail-open).
            if (!method_exists($app, 'loadIdentity')) {
                return $restore;
            }

            if (method_exists($app, 'getIdentity')) {
                $restore['user'] = $app->getIdentity();
            }

            if (method_exists($app, 'get')) {
                $restore['root'] = $app->get('root_user');
            }

            // SYNTHETISCHE System-Identitaet statt eines ECHTEN Super-Users: Der headless-Install
            // braucht core.admin, soll aber KEINEM realen Konto zugeschrieben werden - das ist im
            // Joomla-Aktionsprotokoll verwirrend und wechselte je nach zuletzt aktivem Admin. Wir
            // nehmen das Gast-Objekt als Basis, setzen eine FESTE, nicht existente hohe ID und schalten
            // sie per root_user in den God-Mode: User::authorise() ist damit fuer die Dauer des Calls
            // allmaechtig (Installer + Erweiterungs-Install-Skripte sehen einen berechtigten Admin),
            // aber es haengt KEIN Mensch dran - ein etwaiger Actionlog-Eintrag traegt eine ID, die
            // keinem Konto entspricht (-> "System"/leer). restoreIdentity() nimmt es danach zurueck.
            $sys = self::loadUser(0);

            if (!is_object($sys)) {
                return $restore;
            }

            $sys->id    = self::SYSTEM_UID;
            $sys->guest = 0; // als angemeldet behandeln (id > 0), nicht als Gast

            $app->loadIdentity($sys);
            $restore['changed'] = true;

            // God-Mode-Kurzschluss in User::authorise() (Guertel + Hosentraeger).
            if (method_exists($app, 'set')) {
                $app->set('root_user', self::SYSTEM_UID);
            }
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still
        }

        return $restore;
    }

    /**
     * Die in elevateIdentity() gesetzte Identitaet/root_user zuruecknehmen.
     *
     * @param   object  $app      Anwendung
     * @param   array   $restore  Restore-Daten
     *
     * @return void
     */
    private static function restoreIdentity($app, $restore)
    {
        try {
            if (!empty($restore['changed']) && method_exists($app, 'loadIdentity')) {
                if ($restore['user'] !== null) {
                    $app->loadIdentity($restore['user']);
                } else {
                    $guest = self::loadUser(0);

                    if ($guest) {
                        $app->loadIdentity($guest);
                    }
                }
            }

            if (method_exists($app, 'set')) {
                $app->set('root_user', isset($restore['root']) ? $restore['root'] : null);
            }
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still
        }
    }

    /**
     * 2.6.12: Aktionslog-Phantomzeilen beim Headless-Install verhindern. Manche Ziel-Erweiterungen
     * rufen in ihrem eigenen Postflight com_installers Updatesites::rebuild() (z. B. SP Page Builder).
     * Das braucht core.admin (nur im hier gesetzten God-Mode-Fenster vorhanden) und feuert dabei fuer
     * JEDE Erweiterung mit Update-Server ein onExtensionAfterUpdate - OHNE echten Install. Joomlas
     * plg_actionlog_joomla macht daraus dutzende namenlose "aktualisiert"-Zeilen (uid SYSTEM_UID).
     * Darum den Actionlog-Subscriber fuer die Dauer des einen update()-Aufrufs abhaengen und danach
     * exakt wieder anmelden (addSubscriber liest getSubscribedEvents inkl. Prioritaeten neu). Der
     * Install laeuft voellig unveraendert; HTProtects echte Updates stehen im Dashboard/Journal.
     * Nur J4+ (Subscriber-Dispatcher). Strikt fail-open: schlaegt es fehl, wird eben normal geloggt.
     *
     * @param   object  $app  Anwendung
     * @return  array   Liste der abgehaengten Subscriber (fuer restoreExtensionActionlog)
     */
    private static function suppressExtensionActionlog($app)
    {
        $removed = array();

        try {
            if (!is_object($app) || !method_exists($app, 'getDispatcher')
                || !interface_exists('Joomla\\Event\\SubscriberInterface')) {
                return $removed;
            }

            $disp = $app->getDispatcher();

            if (!is_object($disp) || !method_exists($disp, 'getListeners') || !method_exists($disp, 'removeSubscriber')) {
                return $removed;
            }

            $seen = array();

            foreach (array('onExtensionAfterUpdate', 'onExtensionAfterInstall', 'onExtensionAfterUninstall') as $ev) {
                try {
                    $listeners = $disp->getListeners($ev);
                } catch (Exception $e) {
                    continue;
                } catch (Throwable $e) {
                    continue;
                }

                foreach ((array) $listeners as $listener) {
                    $obj = (is_array($listener) && isset($listener[0]) && is_object($listener[0])) ? $listener[0] : null;

                    // NUR das Joomla-Actionlog-Plugin abhaengen - jeder Fremd-Listener bleibt unberuehrt.
                    if ($obj !== null
                        && $obj instanceof Joomla\Event\SubscriberInterface
                        && stripos(get_class($obj), 'Actionlog') !== false) {
                        $h = spl_object_hash($obj);

                        if (!isset($seen[$h])) {
                            $seen[$h] = true;

                            try {
                                $disp->removeSubscriber($obj);
                                $removed[] = $obj;
                            } catch (Exception $e) {
                            } catch (Throwable $e) {
                            }
                        }
                    }
                }
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return $removed;
    }

    /**
     * Die in suppressExtensionActionlog() abgehaengten Actionlog-Subscriber wieder anmelden.
     *
     * @param   object  $app      Anwendung
     * @param   array   $removed  Abgehaengte Subscriber
     * @return  void
     */
    private static function restoreExtensionActionlog($app, $removed)
    {
        if (empty($removed) || !is_array($removed)) {
            return;
        }

        try {
            if (!is_object($app) || !method_exists($app, 'getDispatcher')) {
                return;
            }

            $disp = $app->getDispatcher();

            if (!is_object($disp) || !method_exists($disp, 'addSubscriber')) {
                return;
            }

            foreach ($removed as $sub) {
                if (!is_object($sub)) {
                    continue;
                }

                try {
                    $disp->addSubscriber($sub);
                } catch (Exception $e) {
                } catch (Throwable $e) {
                }
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    /**
     * Den programmatischen Install als ECHTEN com_installer-Request praesentieren, BEVOR die
     * System-Plugins booten - versionsneutral (J4+ getInput(), J3 ->input). Grund: manche
     * Erweiterungs-Plugins (z. B. Regular Labs' System-Plugin) halten sonst - weil sie keinen
     * Installer-Kontext sehen - eine shared-Sperre (flock) auf einer eigenen Lock-Datei ueber den
     * GANZEN Request, und das nachfolgende Install-Skript blockiert im SELBEN PHP-Prozess auf der
     * exklusiven Sperre (Selbst-Deadlock; jeder Install nach dem ersten haengt). Der Installer-Kontext
     * laesst deren Sperr-Wache aussetzen. Zusaetzlich eine liegengebliebene Sperr-Datei defensiv
     * wegraeumen, damit ein frueher abgestuerzter/haengender Versuch diesen Lauf nicht ewig blockiert.
     * Strikt fail-open - fasst NUR den Request-Kontext an, nie den echten Zustand.
     *
     * @param   object  $app  Joomla-Anwendung
     *
     * @return void
     */
    public static function prepareInstallerContext($app)
    {
        try {
            if (!is_object($app)) {
                return;
            }

            // Input versionsneutral: J4+ getInput(), Joomla 3 die ->input-Eigenschaft (magisch).
            $inp = null;

            if (method_exists($app, 'getInput')) {
                $inp = $app->getInput();
            } else {
                try {
                    $inp = $app->input;
                } catch (Exception $e) {
                    $inp = null;
                } catch (Throwable $e) {
                    $inp = null;
                }
            }

            if (is_object($inp) && method_exists($inp, 'set')) {
                $inp->set('option', 'com_installer');
                $inp->set('task', 'install.install');
            }

            // Liegengebliebene Extension-Install-Sperren im tmp-Ordner wegraeumen (eng, nur bekannte
            // Namen). Ein haengender Alt-Worker haelt die Sperre evtl. noch auf dem alten Inode; das
            // Loeschen des NAMENS laesst diesen Lauf eine frische Datei anlegen statt zu blockieren.
            $tmp = method_exists($app, 'get') ? (string) $app->get('tmp_path') : '';

            if ($tmp !== '') {
                $tmp   = rtrim(str_replace('\\', '/', $tmp), '/');
                $locks = array('regularlabs-library-install.lock');

                foreach ($locks as $name) {
                    $f = $tmp . '/' . $name;

                    if (@is_file($f)) {
                        @unlink($f);
                    }
                }
            }
        } catch (Exception $e) {
            // still - fail-open
        } catch (Throwable $e) {
            // still
        }
    }

    /**
     * Eine aktivierte, nicht gesperrte Super-Admin-Konto-ID finden (niedrigste ID, deterministisch,
     * i. d. R. das urspruengliche Administratorkonto). Wird NUR noch fuer den created_by des
     * Auto-Update-Scheduler-Tasks genutzt (Ersteller-Attribution des Tasks selbst) - NICHT mehr fuer
     * die Install-Identitaet: die ist bewusst SYNTHETISCH (siehe elevateIdentity), damit ein Update
     * keinem realen Konto zugeschrieben wird.
     *
     * @return int  Benutzer-ID oder 0
     */
    private static function findSuperUserId()
    {
        $db = htp_db();

        if (!$db) {
            return 0;
        }

        $groups = (class_exists('HtpAccounts') && method_exists('HtpAccounts', 'privilegedGroupIds'))
            ? array_map('intval', HtpAccounts::privilegedGroupIds()) : array();

        $groups = array_values(array_filter($groups, function ($g) {
            return $g > 0;
        }));

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

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName('u.id'))
                ->from($db->quoteName('#__users', 'u'))
                ->join('INNER', $db->quoteName('#__user_usergroup_map', 'm')
                    . ' ON ' . $db->quoteName('m.user_id') . ' = ' . $db->quoteName('u.id'))
                ->where($db->quoteName('u.block') . ' = 0')
                ->where($db->quoteName('m.group_id') . ' IN (' . implode(',', $groups) . ')')
                ->order($db->quoteName('u.id') . ' ASC');
            $db->setQuery($query, 0, 1);

            return (int) $db->loadResult();
        } catch (Exception $e) {
            return 0;
        } catch (Throwable $e) {
            return 0;
        }
    }

    /**
     * Benutzerobjekt laden (versionsneutral: UserFactory bevorzugt, Fallback
     * Factory::getUser).
     *
     * @param   int  $id  Benutzer-ID (0 = Gast)
     *
     * @return object|null
     */
    private static function loadUser($id)
    {
        $id = (int) $id;

        try {
            if (method_exists('Joomla\\CMS\\Factory', 'getContainer')) {
                $c = Joomla\CMS\Factory::getContainer();

                if ($c && $c->has('Joomla\\CMS\\User\\UserFactoryInterface')) {
                    $uf = $c->get('Joomla\\CMS\\User\\UserFactoryInterface');

                    if ($uf && method_exists($uf, 'loadUserById')) {
                        return $uf->loadUserById($id);
                    }
                }
            }
        } catch (Exception $e) {
            // Fallback unten
        } catch (Throwable $e) {
            // Fallback unten
        }

        try {
            if (method_exists('Joomla\\CMS\\Factory', 'getUser')) {
                return Joomla\CMS\Factory::getUser($id);
            }
        } catch (Exception $e) {
            return null;
        } catch (Throwable $e) {
            return null;
        }

        return null;
    }

    /**
     * Ein Anwendungs-Event dispatchen (versionsneutral, fail-open).
     *
     * @param   object  $app    Anwendung
     * @param   string  $event  Event-Name
     *
     * @return void
     */
    private static function dispatchEvent($app, $event)
    {
        try {
            if ($app && method_exists($app, 'triggerEvent')) {
                $app->triggerEvent($event);
            }
        } catch (Exception $e) {
            // still
        } catch (Throwable $e) {
            // still
        }
    }

    /**
     * Relevante Joomla-Cache-Gruppen nach dem Update leeren (mod_menu, _system,
     * Module/Plugins) - sonst zeigt die Seite alte gecachte Strukturen.
     *
     * @param   object  $app  Anwendung
     *
     * @return void
     */
    private static function cleanJoomlaCache($app)
    {
        if (!class_exists('Joomla\\CMS\\Cache\\Cache') || !method_exists('Joomla\\CMS\\Cache\\Cache', 'getInstance')) {
            return;
        }

        foreach (array('_system', 'mod_menu', 'com_modules', 'com_plugins') as $grp) {
            try {
                $cache = Joomla\CMS\Cache\Cache::getInstance('callback', array('defaultgroup' => $grp));

                if ($cache && method_exists($cache, 'clean')) {
                    $cache->clean($grp);
                }
            } catch (Exception $e) {
                // still
            } catch (Throwable $e) {
                // still
            }
        }
    }

    /**
     * opcache fuer die PHP-Dateien einer Erweiterung invalidieren, damit die
     * Health-Probe (separater HTTP-Request) den NEUEN Code laedt.
     *
     * @param   string  $type      Erweiterungstyp
     * @param   string  $element   Element
     * @param   string  $folder    Plugin-Gruppe
     * @param   int     $clientId  0|1
     *
     * @return void
     */
    private static function invalidateOpcache($type, $element, $folder, $clientId)
    {
        if (!function_exists('opcache_invalidate') && !function_exists('opcache_reset')) {
            return;
        }

        $paths = HtpUpdateBackup::ownedPaths($type, $element, $folder, $clientId);

        if (empty($paths)) {
            if (function_exists('opcache_reset')) {
                @opcache_reset();
            }

            return;
        }

        foreach ($paths as $entry) {
            $base = ($entry['root'] === 'admin') ? htp_admin_root() : htp_root();
            self::invalidatePhpUnder($base . '/' . $entry['rel']);
        }
    }

    /**
     * Rekursiv alle .php-Dateien unter einem Pfad opcache-invalidieren (hart
     * gekappt gegen Ausreisser).
     *
     * @param   string  $path   Datei oder Ordner
     * @param   int     $depth  aktuelle Tiefe
     *
     * @return void
     */
    private static function invalidatePhpUnder($path, $depth = 0)
    {
        if ($depth > 12) {
            return;
        }

        if (is_file($path)) {
            if (substr($path, -4) === '.php' && function_exists('opcache_invalidate')) {
                @opcache_invalidate($path, true);
            }

            return;
        }

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

        $items = @scandir($path);

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

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

            self::invalidatePhpUnder($path . '/' . $name, $depth + 1);
        }
    }

    // ── Crash-sicherer In-Progress-Marker (plan 17.1) ──

    /**
     * Pfad der In-Progress-Marker-Datei.
     *
     * @return string
     */
    private static function markerFile()
    {
        $dir = function_exists('htp_state_dir') ? htp_state_dir() : '';

        return $dir !== '' ? rtrim(str_replace('\\', '/', $dir), '/') . '/htprotect_autoupdate.inprogress' : '';
    }

    /**
     * In-Progress-Marker schreiben (vor dem Install).
     *
     * @param   array  $info  Marker-Daten
     *
     * @return void
     */
    private static function writeMarker($info)
    {
        $file = self::markerFile();

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

        $json = json_encode($info);

        if ($json !== false && @file_put_contents($file, $json, LOCK_EX) !== false) {
            @chmod($file, 0600);
        }
    }

    /**
     * In-Progress-Marker lesen (oder null).
     *
     * @return array|null
     */
    private static function readMarker()
    {
        $file = self::markerFile();

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

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

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

    /**
     * In-Progress-Marker entfernen (nach Abschluss/Rollback).
     *
     * @return void
     */
    private static function clearMarker()
    {
        $file = self::markerFile();

        if ($file !== '' && is_file($file)) {
            @unlink($file);
        }
    }

    /**
     * Laeuft GERADE ein HTProtect-SELBST-Update (In-Progress-Marker mit self-Flag)? Fuer die
     * Versions-Selbstheilung (HtpApp::selfHealComponentVersion): waehrend eines laufenden Self-
     * Updates darf die DB-Version NICHT an die (im laufenden Request noch ALTE) HTP_VERSION
     * angeglichen werden - sonst wuerde der gerade gesetzte Bump zurueckgedreht. Nach Abschluss
     * (clearMarker) ODER Notfall-Restore (der den Marker entfernt) ist der Marker weg -> heilen
     * erlaubt. Nur SELBST-Marker zaehlen; ein Dritt-Update ruehrt HTProtects eigene Version nicht an.
     *
     * @return boolean
     */
    public static function selfMarkerActive()
    {
        $m = self::readMarker();

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

        // NUR ein frischer Marker heisst „laeuft gerade". Ein verwaister (Crash + Watch-Plugin aus
        // oder Notfall-Restore schlaegt wiederholt fehl; die Engine-Recovery ist noch nicht dran)
        // darf die Selbstheilung NICHT dauerhaft blockieren - sonst bliebe der Versions-Mix ewig
        // stehen (inkl. des Aufwaerts-Heilens). Fehlt der Zeitstempel -> als verwaist behandeln.
        $started = isset($m['started']) ? (int) $m['started'] : 0;

        return $started > 0 && (time() - $started) < self::MARKER_STALE;
    }

    /**
     * Crash-Recovery: einen verwaisten In-Progress-Marker zurueckrollen + aufraeumen
     * (plan 17.1). Der Marker existiert beim Tick-Start nur, wenn ein frueherer
     * Update-Lauf zwischen writeMarker() und clearMarker() abgebrochen ist (der
     * Re-Entrancy-Lock verhindert echte Parallel-Laeufe). Liefert true, wenn ein
     * Marker gefunden (und behandelt) wurde.
     *
     * @param   array  $cfg    Konfiguration
     * @param   array  $state  State (Referenz - Log wird ergaenzt)
     *
     * @return boolean
     */
    private static function recoverCrashed($cfg, &$state)
    {
        $m = self::readMarker();

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

        $eid  = isset($m['eid']) ? (int) $m['eid'] : 0;
        $snap = isset($m['snap']) ? (string) $m['snap'] : '';
        $name = isset($m['name']) ? (string) $m['name'] : (isset($m['element']) ? (string) $m['element'] : '');
        $key  = self::extKey(isset($m['type']) ? $m['type'] : '',
            isset($m['element']) ? $m['element'] : '', isset($m['folder']) ? $m['folder'] : '');

        $rbOk = false;

        if ($eid > 0 && preg_match(HtpUpdateBackup::SNAP_RE, $snap)) {
            // Crash-Recovery = Auto-Rollback: Dateien + DB wiederherstellen.
            $rb   = HtpUpdateBackup::rollback($eid, $snap, true);
            $rbOk = !empty($rb['ok']);

            if (!empty($m['self'])) {
                self::invalidateOpcacheSelf();
                self::selfVersionReset(isset($m['from']) ? (string) $m['from'] : ''); // DB-Zeiger zurueck
            } elseif (isset($m['type'], $m['element'])) {
                self::invalidateOpcache($m['type'], $m['element'],
                    isset($m['folder']) ? $m['folder'] : '', isset($m['client']) ? (int) $m['client'] : 0);
            }
        }

        self::clearMarker();

        // Als Fehlschlag fuer den Flatter-Schutz werten (ein abgebrochener Lauf ist
        // ein schlechtes Zeichen) - aber nie aussetzen, ohne es zu melden.
        $fails = isset($state['suspended'][$key]['fails']) ? (int) $state['suspended'][$key]['fails'] : 0;
        $fails++;
        $suspend = self::shouldSuspend($fails);
        $state['suspended'][$key] = array('fails' => $fails, 'since' => time(), 'suspended' => $suspend ? 1 : 0);

        $state = self::logResult($state, $key, $eid, $name,
            isset($m['from']) ? (string) $m['from'] : '', isset($m['to']) ? (string) $m['to'] : '',
            'crash_rollback', $rbOk ? '' : 'rollback-incomplete');

        self::mailRollback($cfg, $name, isset($m['from']) ? $m['from'] : '',
            isset($m['to']) ? $m['to'] : '', $rbOk, true, $suspend);

        return true;
    }

    /**
     * Sieht die Antwort nach einem PHP-Fatal/Parse-Error aus (White-Screen-/
     * Fehlerseite)?
     *
     * @param   string  $body  Antwort-Body (gekappt)
     *
     * @return boolean
     */
    public static function looksFatal($body)
    {
        $needle = array(
            'Fatal error', 'Parse error', 'Uncaught Error', 'Uncaught TypeError',
            'Uncaught Exception', 'There was an error rendering the page',
            'Allowed memory size of', 'Class \'', 'Call to undefined',
        );

        foreach ($needle as $n) {
            if (stripos($body, $n) !== false) {
                return true;
            }
        }

        return false;
    }

    // ═════════════════════════════════════════════════════════════════
    //  State-Verwaltung + Mail + kleine Helfer
    // ═════════════════════════════════════════════════════════════════

    /**
     * Tick-/Log-State laden.
     *
     * @param   string  $file  Pfad
     *
     * @return array
     */
    private static function loadState($file)
    {
        if ($file === '' || !is_file($file)) {
            return array('last_run' => 0, 'suspended' => array(), 'history' => array(), 'known_hosts' => array(), 'key_cooldown' => array(), 'mal_block' => array(), 'recall_notified' => array());
        }

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

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

        foreach (array('suspended', 'history', 'known_hosts', 'key_cooldown', 'mal_block', 'recall_notified') as $k) {
            if (!isset($json[$k]) || !is_array($json[$k])) {
                $json[$k] = array();
            }
        }

        if (!isset($json['last_run'])) {
            $json['last_run'] = 0;
        }

        return $json;
    }

    /**
     * Tick-/Log-State speichern (0600, im geschuetzten State-Ordner).
     *
     * @param   string  $file   Pfad
     * @param   array   $state  State
     *
     * @return void
     */
    private static function saveState($file, $state)
    {
        if ($file === '') {
            return;
        }

        $json = json_encode($state);

        if ($json !== false && @file_put_contents($file, $json, LOCK_EX) !== false) {
            @chmod($file, 0600);
        }
    }

    /**
     * Bekannte Update-Hosts je Erweiterung aus den aktuellen Infos fortschreiben
     * (Erstkontakt merken; Wechsel wird separat gemeldet und NICHT stillschweigend
     * uebernommen).
     *
     * @param   array  $state  State
     * @param   array  $infos  Aktuelle Infos
     *
     * @return array  aktualisierte known_hosts
     */
    private static function rememberHosts($state, $infos)
    {
        $hosts = isset($state['known_hosts']) && is_array($state['known_hosts']) ? $state['known_hosts'] : array();

        foreach ($infos as $info) {
            $key  = self::extKey($info['type'], $info['element'], isset($info['folder']) ? $info['folder'] : '');
            $host = self::hostOf(isset($info['site_url']) ? $info['site_url'] : '');

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

            // Nur den ERSTEN Host merken (Erstkontakt). Ein Wechsel bleibt sichtbar,
            // bis ihn der Nutzer (durch ein manuelles Update/Reset) bestaetigt.
            if (!isset($hosts[$key]) || $hosts[$key] === '') {
                $hosts[$key] = $host;
            }
        }

        return $hosts;
    }

    /**
     * Quellen-Wechsel (host_changed) per Mail melden - eine Mail je neuem Host.
     *
     * @param   array  $cfg    Konfiguration
     * @param   array  $infos  Infos
     * @param   array  $state  State (Referenz-frei; reportet anhand state['reported_hosts'])
     *
     * @return void
     */
    private static function reportHostChanges($cfg, $infos, &$state)
    {
        $reported = isset($state['reported_hosts']) && is_array($state['reported_hosts']) ? $state['reported_hosts'] : array();

        foreach ($infos as $info) {
            if (!isset($info['status']) || $info['status'] !== 'host_changed') {
                continue;
            }

            $key  = self::extKey($info['type'], $info['element'], isset($info['folder']) ? $info['folder'] : '');
            $host = self::hostOf(isset($info['site_url']) ? $info['site_url'] : '');
            $sig  = $key . '=>' . $host;

            if (isset($reported[$sig])) {
                continue; // schon gemeldet
            }

            self::mailHostChanged($cfg, isset($info['name']) ? $info['name'] : $info['element'],
                isset($info['known_host']) ? $info['known_host'] : '', $host);
            $reported[$sig] = time();
        }

        // Liste klein halten.
        if (count($reported) > 100) {
            $reported = array_slice($reported, -100, 100, true);
        }

        $state['reported_hosts'] = $reported;
    }

    /**
     * Manuellen Rollback im State protokollieren (race-sicher: frisch laden,
     * Eintrag voranstellen, speichern).
     *
     * @param   array    $meta  Snapshot-Meta
     * @param   boolean  $ok    Rollback erfolgreich?
     *
     * @return void
     */
    private static function recordManualRollback($meta, $ok)
    {
        $dir  = function_exists('htp_state_dir') ? htp_state_dir() : '';
        $file = $dir !== '' ? rtrim(str_replace('\\', '/', $dir), '/') . '/' . self::STATE_FILE : '';

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

        $state = self::loadState($file);
        $key   = self::extKey(isset($meta['type']) ? $meta['type'] : '',
            isset($meta['element']) ? $meta['element'] : '',
            isset($meta['folder']) ? $meta['folder'] : '');

        $state['history'] = self::pushHistory(
            isset($state['history']) ? $state['history'] : array(),
            array('t' => time(), 'key' => $key, 'eid' => isset($meta['eid']) ? (int) $meta['eid'] : 0,
                  'name' => isset($meta['element']) ? (string) $meta['element'] : '',
                  'from' => '', 'to' => isset($meta['version']) ? (string) $meta['version'] : '',
                  'result' => $ok ? 'manual_rollback' : 'manual_rollback_partial', 'note' => '')
        );

        self::saveState($file, $state);
    }

    /**
     * Heal-Breadcrumb der Plugin-Notfall-Wiederherstellung verbuchen (§7): das
     * Plugin hat HTProtect nach einem abgebrochenen Selbst-Update aus dem Backup
     * wiederhergestellt (in plain PHP, ohne die evtl. kaputte Engine). Jetzt - mit
     * wieder gesundem Code - protokollieren + EINMAL melden + Breadcrumb entfernen.
     *
     * @param   string  $base       State-Ordner
     * @param   string  $stateFile  Pfad der State-Datei
     * @param   array   $cfg        Konfiguration
     *
     * @return void
     */
    private static function processHealed($base, $stateFile, $cfg)
    {
        $file = $base . '/' . self::HEALED_FILE;

        if (!is_file($file)) {
            return; // Normalfall: ein stat, dann raus.
        }

        $h = json_decode((string) @file_get_contents($file), true);
        @unlink($file); // zuerst entfernen -> idempotent gegen Parallel-Requests

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

        $name = isset($h['name']) ? (string) $h['name'] : 'HTProtect';
        $from = isset($h['from']) ? (string) $h['from'] : '';
        $to   = isset($h['to']) ? (string) $h['to'] : '';
        $key  = self::extKey('component', 'com_htprotect', '');

        $state = self::loadState($stateFile);
        $state = self::logResult($state, $key, 0, $name, $from, $to, 'self_heal', '');

        // Wiederholt-Crash-Bremse (versions-bewusst): Ein Notfall-Restore heisst, das Self-Update auf
        // $to ist am Ziel-Punkt abgestuerzt (statt eines sauberen Health-Rollbacks, der ueber
        // shouldSuspend gebremst haette). Ohne Gegenzaehler wuerde die (nun runter-geheilte) DB die
        // gleiche Version alle paar Stunden endlos neu anbieten. Zaehler JE Zielversion; execPlan
        // ueberspringt sie ab MAX_FAILS. Eine NEUERE Version hat ein anderes $to -> faengt frisch an.
        if ($to !== '') {
            $pn = (isset($state['self_crash']['ver']) && (string) $state['self_crash']['ver'] === $to)
                ? (int) $state['self_crash']['n'] : 0;
            $state['self_crash'] = array('ver' => $to, 'n' => $pn + 1, 'ts' => time());
        }

        self::saveState($stateFile, $state);

        // Melden (Reuse der Rollback-Mail: HTProtect wurde aus dem Backup
        // wiederhergestellt, Seite erreichbar).
        self::mailRollback($cfg, $name, $from, $to, true, true, false);
    }

    /**
     * Mail bei Auto-Rollback / Aussetzen.
     *
     * @return void
     */
    private static function mailRollback($cfg, $name, $fromVer, $toVer, $rolledBack, $reProbeOk, $suspended)
    {
        $site  = self::selfSiteUrl($cfg);
        $lines = array();
        $lines[] = htp_t('COM_HTPROTECT_AU_MAIL_RB_INTRO');
        $lines[] = $site !== '' ? htp_t('COM_HTPROTECT_MAIL_SITE', $site) : '';
        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_AU_MAIL_RB_EXT', $name, $fromVer, $toVer);
        $lines[] = $rolledBack
            ? htp_t('COM_HTPROTECT_AU_MAIL_RB_RESTORED')
            : htp_t('COM_HTPROTECT_AU_MAIL_RB_PARTIAL');
        $lines[] = $reProbeOk
            ? htp_t('COM_HTPROTECT_AU_MAIL_RB_PROBE_OK')
            : htp_t('COM_HTPROTECT_AU_MAIL_RB_PROBE_BAD');

        if ($suspended) {
            $lines[] = '';
            $lines[] = htp_t('COM_HTPROTECT_AU_MAIL_RB_SUSPENDED', $name);
        }

        self::sendNotify($cfg, 'COM_HTPROTECT_AU_MAIL_RB_SUBJECT', $lines);
    }

    /**
     * Mail bei Quellen-Wechsel-Sperre.
     *
     * @return void
     */
    private static function mailHostChanged($cfg, $name, $oldHost, $newHost)
    {
        $site  = self::selfSiteUrl($cfg);
        $lines = array();
        $lines[] = htp_t('COM_HTPROTECT_AU_MAIL_HOST_INTRO');
        $lines[] = $site !== '' ? htp_t('COM_HTPROTECT_MAIL_SITE', $site) : '';
        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_AU_MAIL_HOST_BODY', $name, $oldHost !== '' ? $oldHost : '?', $newHost);

        self::sendNotify($cfg, 'COM_HTPROTECT_AU_MAIL_HOST_SUBJECT', $lines);
    }

    /**
     * Mail bei ERFOLGREICHEM Auto-Update - NUR wenn der optionale Schalter
     * autoupdate_mail_success an ist (Default AUS; Fehlschlag/Rollback/Host-Wechsel
     * melden ohnehin). Bewusst kurz, eine Mail je erfolgreichem Update.
     *
     * @return void
     */
    private static function mailSuccess($cfg, $name, $fromVer, $toVer, $security = false, $key = '')
    {
        // SICHERHEITSUPDATES (angreifbare Erweiterung) IMMER melden - ein automatisch
        // geschlossenes Loch ist meldenswert. Routine-Erfolge nur bei aktivem Schalter.
        if (!$security && empty($cfg['autoupdate_mail_success'])) {
            return;
        }

        $site  = self::selfSiteUrl($cfg);
        $lines = array();
        $lines[] = $security ? htp_t('COM_HTPROTECT_AU_MAIL_SEC_INTRO') : htp_t('COM_HTPROTECT_AU_MAIL_OK_INTRO');
        $lines[] = $site !== '' ? htp_t('COM_HTPROTECT_MAIL_SITE', $site) : '';
        $lines[] = '';
        $lines[] = htp_t('COM_HTPROTECT_AU_MAIL_OK_EXT', $name, $fromVer, $toVer);

        // "Problem melden"-Link (wie in der Drittanbieter-Sammel-Mail): auch die
        // erfolgreiche Auto-Update-Mail (inkl. HTProtect-Selbst-Update) bekommt den
        // Rueckmelde-Knopf - falls das Update doch etwas gebrochen hat.
        $extras = array();

        if ((string) $key !== '' && (string) $toVer !== ''
            && class_exists('HtpFeedback') && HtpFeedback::validKey($key)) {
            $extras['feedback'] = array(array('name' => $name, 'to' => (string) $toVer, 'key' => (string) $key));
        }

        self::sendNotify($cfg, $security ? 'COM_HTPROTECT_AU_MAIL_SEC_SUBJECT' : 'COM_HTPROTECT_AU_MAIL_OK_SUBJECT', $lines, $extras);
    }

    /**
     * Mail versenden - pro Empfaenger mit unfaelschbarem Abmeldelink (HMAC) +
     * List-Unsubscribe-Header, genau wie die Waechter-Mails (gemeinsames
     * notify_email-Opt-out, keine eigene Liste). Anti-Spam: Auto-Update-Mails sind
     * Einzel-Ereignisse (Rollback/Host-Wechsel) und pro Vorfall gedrosselt (Flatter-
     * Schutz setzt nach wenigen Fehlschlaegen aus) - daher kein Reminder-Loop.
     *
     * @param   array   $cfg         Konfiguration
     * @param   string  $subjectKey  Sprachschluessel des Betreffs
     * @param   array   $lines       Mailtext-Zeilen (ohne Abmeldelink/Signatur)
     *
     * @return void
     */
    private static function sendNotify($cfg, $subjectKey, $lines, $extras = array())
    {
        $recipients = self::recipients($cfg);

        if (empty($recipients) || !function_exists('htp_send_mail')) {
            return;
        }

        // Betreff + HTML in DERSELBEN Sprache wie der (vom Aufrufer bereits gebaute)
        // Text-Body: die Notify-Sprache frisch laden, NICHT die aktive CLI-/Request-
        // Sprache (sonst waere z. B. der Betreff englisch, der Body deutsch).
        self::loadNotifyLang($cfg);

        // Site-eigenes HMAC-Secret fuer Abmelde-/Feedback-Links lazy sicherstellen.
        $secret = isset($cfg['unsub_secret']) ? (string) $cfg['unsub_secret'] : '';

        if ($secret === '' && class_exists('HtpUnsub') && class_exists('HtpConfig')) {
            $secret = HtpUnsub::genSecret();
            HtpConfig::saveKeys(array('unsub_secret' => $secret));
        }

        $unsubBase = function_exists('htp_trusted_base')
            ? htp_trusted_base(isset($cfg['unsub_base']) ? $cfg['unsub_base'] : '') : '';

        $subject = htp_t($subjectKey);
        $useHtml = class_exists('HtpMail');

        // Aktions-Buttons (z. B. Backend-Deeplink) - {label, url, variant}.
        $actions = (isset($extras['actions']) && is_array($extras['actions'])) ? $extras['actions'] : array();

        // Feedback-Items (strukturiert) -> Links EINMAL bauen (gleich fuer alle Empfaenger).
        $fbItems = (isset($extras['feedback']) && is_array($extras['feedback'])) ? $extras['feedback'] : array();
        $fbUrls  = array();

        // JED-Bewertungs-Einladung GANZ ans Ende (hinter Signatur + Abmelde-Link), NICHT in
        // den Body - der Aufrufer hat per Drossel entschieden, ob sie diesmal mitkommt.
        $jedText = !empty($extras['jed']) ? htp_t('COM_HTPROTECT_JED_MAIL') : '';
        $jedUrl  = ($jedText !== '' && class_exists('HtpApp')) ? HtpApp::JED_REVIEW_URL : '';

        if (!empty($fbItems) && class_exists('HtpFeedback') && $secret !== '') {
            $fbBase = function_exists('htp_trusted_base')
                ? htp_trusted_base(isset($cfg['unsub_base']) ? $cfg['unsub_base'] : '', true) : '';

            // Report-Seite in DERSELBEN Sprache wie die Mail zeigen: die geladene
            // Notify-Sprache (htp_lang() = 'de'|'en' nach loadNotifyLang) als Hinweis
            // in den Link (htp_fbl). Der Waechter laedt sie dort fuer die Report-Seite.
            $fbLang = function_exists('htp_lang') ? htp_lang() : '';

            if ($fbBase !== '' && $fbBase !== '/') {
                foreach ($fbItems as $it) {
                    if (!isset($it['key'], $it['to']) || (string) $it['key'] === '' || (string) $it['to'] === '') {
                        continue;
                    }

                    // Nur der "Problem melden"-Link (badUrl) - der positive Knopf entfiel
                    // (wurde praktisch nie genutzt); spart hier den OK-Token/-Link-Build.
                    $fbUrls[] = array(
                        'name'   => isset($it['name']) ? (string) $it['name'] : '',
                        'to'     => (string) $it['to'],
                        'badUrl' => HtpFeedback::link($fbBase, '0', $it['key'], $it['to'], HtpFeedback::token('0', $it['key'], $it['to'], $secret), $fbLang),
                    );
                }
            }
        }

        // HTML-Inhalt einmal bauen (empfaenger-unabhaengig; Abmelden kommt je Empfaenger).
        $contentHtml = '';

        if ($useHtml) {
            foreach ($lines as $line) {
                if ((string) $line !== '') {
                    $contentHtml .= HtpMail::lineP($line);
                }
            }

            if (!empty($actions)) {
                $btns = array();

                foreach ($actions as $a) {
                    if (isset($a['label'], $a['url']) && (string) $a['url'] !== '') {
                        $btns[] = HtpMail::button($a['label'], $a['url'], isset($a['variant']) ? $a['variant'] : 'ghost');
                    }
                }

                $contentHtml .= HtpMail::buttonRow($btns);
            }

            if (!empty($fbUrls)) {
                $contentHtml .= HtpMail::divider();
                $contentHtml .= HtpMail::muted(htp_t('COM_HTPROTECT_AU_FB_INTRO'));

                foreach ($fbUrls as $f) {
                    $contentHtml .= '<p style="margin:10px 0 4px;font-size:13px;color:#2b3038"><strong>' . HtpMail::esc(trim($f['name'] . ' ' . $f['to'])) . '</strong></p>';
                    // Nur "Problem melden" (dezentes Outline). Den positiven Knopf gibt es
                    // bewusst nicht mehr - er wurde praktisch nie geklickt; Feedback ist rein
                    // problem-meldend (passt zur Problem-Mail an den Radar).
                    $contentHtml .= HtpMail::buttonRow(array(
                        HtpMail::button(htp_t('COM_HTPROTECT_AU_FB_BAD_BTN'), $f['badUrl'], 'ghost'),
                    ));
                }
            }
        }

        foreach ($recipients as $addr) {
            $headers  = array();
            $unsubUrl = '';

            if ($secret !== '' && $unsubBase !== '' && $unsubBase !== '/' && class_exists('HtpUnsub')) {
                $tok = HtpUnsub::token($addr, $secret);

                if ($tok !== '') {
                    $unsubUrl = HtpUnsub::link($unsubBase, $tok);
                    $headers['List-Unsubscribe'] = '<' . $unsubUrl . '>';
                }
            }

            // ── Plain-Text-Alternative (mit URLs, da keine Buttons) ──
            $plain = $lines;

            foreach ($actions as $a) {
                if (isset($a['label'], $a['url']) && (string) $a['url'] !== '') {
                    $plain[] = '';
                    $plain[] = $a['label'] . ': ' . $a['url'];
                }
            }

            if (!empty($fbUrls)) {
                $plain[] = '';
                $plain[] = htp_t('COM_HTPROTECT_AU_FB_INTRO');

                foreach ($fbUrls as $f) {
                    $plain[] = '';
                    $plain[] = trim($f['name'] . ' ' . $f['to']) . ':';
                    $plain[] = htp_t('COM_HTPROTECT_AU_FB_BAD_LINK') . ' ' . $f['badUrl'];
                }
            }

            $plain[] = '';
            $plain[] = htp_t('COM_HTPROTECT_MAIL_SIGNATURE');

            if ($unsubUrl !== '') {
                $plain[] = '';
                $plain[] = htp_t('COM_HTPROTECT_MAIL_UNSUB', $unsubUrl);
            }

            // JED-Einladung als Allerletztes (hinter Signatur + Abmelden).
            if ($jedText !== '') {
                $plain[] = '';
                $plain[] = $jedText;

                if ($jedUrl !== '') {
                    $plain[] = $jedUrl;
                }
            }

            // ── HTML ── (ohne Ueberschrift: der Betreff titelt im Posteingang; die
            //    Intro-Zeile fuehrt -> spart oben Platz, keine Doppelung).
            $html = '';

            if ($useHtml) {
                $footer = ($unsubUrl !== '') ? HtpMail::smallLink(htp_t('COM_HTPROTECT_MAIL_UNSUB_SHORT'), $unsubUrl) : '';

                // JED-Einladung als Allerletztes (hinter Signatur + Abmelden), Link klickbar.
                if ($jedText !== '') {
                    $footer .= HtpMail::jedInvite($jedText, $jedUrl);
                }

                $html = HtpMail::wrap('', $contentHtml, $footer);
            }

            $err = '';
            htp_send_mail($addr, $subject, implode("\n", $plain), '', $err, $headers, $html !== '' ? $html : null);
        }
    }

    /**
     * Empfaengerliste (gleiche Quelle wie der Waechter: notify_email).
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array
     */
    private static function recipients($cfg)
    {
        if (class_exists('HtpUnsub')) {
            return HtpUnsub::normalizeList(isset($cfg['notify_email']) ? $cfg['notify_email'] : '');
        }

        $out = array();

        foreach (preg_split('/[,;]+/', (string) (isset($cfg['notify_email']) ? $cfg['notify_email'] : '')) as $a) {
            $a = trim($a);

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

        return $out;
    }

    /**
     * Schluessel-Liste aus dem Formular saeubern (Whitelist type|element|folder).
     *
     * @param   mixed  $raw  Rohwert (Array oder String)
     *
     * @return array
     */
    private static function cleanKeyList($raw)
    {
        if (is_string($raw)) {
            $raw = preg_split('/[\r\n,]+/', $raw);
        }

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

        $out = array();

        foreach ($raw as $k) {
            $k = strtolower(trim((string) $k));

            if ($k !== '' && preg_match('~^[a-z0-9_.\-]*\|[a-z0-9_.\-]+\|[a-z0-9_.\-]*$~', $k)) {
                $out[$k] = true;
            }
        }

        return array_keys($out);
    }

    /**
     * Meldungs-Helfer (gleiche Form wie HtpApp::msg).
     *
     * @param   string  $type  ok|err|warn|info
     * @param   string  $text  Text (bereits lokalisiert)
     *
     * @return array
     */
    private static function msg($type, $text)
    {
        return array('type' => $type, 'html' => $text);
    }
}
