<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Kern-Update fuer JOOMLA 3 (EOL) - eigenes, klar abgetrenntes Modul.
 *
 * Warum getrennt von HtpCoreUpdateEngine (die J5.4+ treibt): Joomla 3 hat eine ANDERE
 * Update-Modell-API. Diese Klasse kapselt alle J3-Besonderheiten und spiegelt die
 * Engine-Schnittstelle 1:1 (available/checkUpdate/prepare/unpackInProcess/finalise/cleanup),
 * sodass die Orchestrierung in coreupdate.php NUR die Engine-KLASSE tauschen muss. Die
 * reinen, versions-agnostischen Kopier-/Manifest-Helfer (buildCopyManifest/copyManifestSlice/
 * siteRoot/manifestPath/installedVersion) bleiben in HtpCoreUpdateEngine und werden von der
 * Orchestrierung weiter direkt aufgerufen.
 *
 * J3-Unterschiede (gegen den J5-Pfad):
 *   - Modell laden: Legacy `JModelLegacy::getInstance('Default','JoomlaupdateModel')` statt
 *     `bootComponent()->getMVCFactory()->createModel()` (existiert auf J3 nicht).
 *   - `download()` liefert auf J3 den Basisnamen als STRING (auf J4+ ein Array) - beides robust behandelt.
 *   - Entpacken ueber `JInstallerHelper::unpack()` (die namespaced `Joomla\CMS\Installer\InstallerHelper`
 *     fehlt auf J3).
 *   - `finaliseUpgrade()`/`cleanUp()` heissen gleich.
 *
 * Update-QUELLE: bewusst die vom Site-Betreiber in com_joomlaupdate konfigurierte Quelle
 * (z. B. die JoomlaWorks Security Distribution `list.xml`, die 3.10-3.15 weiterpflegt).
 * Das Modul erzwingt KEINE Quelle - es nutzt, was eingetragen ist. Zum bequemen Setzen der
 * JoomlaWorks-Quelle per Flotten-Aktion gibt es setUpdateSource().
 *
 * Bootstrap-OPTIONAL: fehlt/beschaedigt diese Datei, bleibt die Engine vollstaendig - dann
 * ist lediglich das J3-Kern-Update nicht verfuegbar (Aufrufer degradieren sauber).
 */

defined('_JEXEC') or die;

class HtpCoreUpdateJ3
{
    /** JoomlaWorks Security Distribution - Collection-list.xml (Vorgabe der Flotten-Aktion). */
    const JOOMLAWORKS_LIST = 'https://joomlaworks.github.io/joomla-3.x/list.xml';

    /**
     * Nur auf einer echten Joomla-3-Installation mit ladbarem Update-Modell verfuegbar.
     *
     * @return boolean
     */
    public static function available()
    {
        if (!class_exists('HtpAutoUpdateLegacy') || !HtpAutoUpdateLegacy::isJ3()) {
            return false;
        }

        return self::model() !== null;
    }

    /**
     * Das J3-Kern-Update-Modell (JoomlaupdateModelDefault) kopflos laden - exakt nach dem
     * Muster von HtpAutoUpdateLegacy (Legacy-MVC via JModelLegacy). KEIN ACL/Super-User.
     *
     * @return object|null
     */
    private static function model()
    {
        if (!class_exists('JModelLegacy')) {
            return null;
        }

        try {
            $admin = defined('JPATH_ADMINISTRATOR')
                ? JPATH_ADMINISTRATOR
                : (defined('JPATH_ROOT') ? JPATH_ROOT . '/administrator' : '');

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

            $base = $admin . '/components/com_joomlaupdate';

            if (!@is_dir($base)) {
                return null; // Update-Komponente fehlt (sehr alte/kaputte Installation)
            }

            // KOPFLOS: die Komponenten-Pfad-Konstanten setzen, BEVOR eine Stock-Methode laeuft.
            // Das Stock-Modell dereferenziert JPATH_COMPONENT_ADMINISTRATOR (downloadPackage() und
            // cleanUp()); ohne Komponenten-Dispatch ist die Konstante undefiniert -> auf PHP 8 ein
            // FATALER Error (genau der beobachtete core_prepare_failed). model() ist der gemeinsame
            // Engpass ALLER Phasen -> ein Aufruf hier deckt prepare/finalise/cleanup zugleich ab.
            self::ensureComponentContext();

            JModelLegacy::addIncludePath($base . '/models');

            $model = JModelLegacy::getInstance('Default', 'JoomlaupdateModel', array('ignore_request' => true));

            return (is_object($model) && method_exists($model, 'getUpdateInformation')) ? $model : null;
        } catch (Exception $e) {
            return null;
        } catch (Throwable $e) {
            return null;
        }
    }

    /**
     * KOPFLOS fehlende Komponenten-Pfad-Konstanten auf com_joomlaupdate setzen. Das Stock-Modell
     * (downloadPackage() JLoader::import, cleanUp() restoration.php) und ggf. das JoomlaWorks-fork-
     * script.php dereferenzieren JPATH_COMPONENT_ADMINISTRATOR - ohne Komponenten-Dispatch ist es
     * undefiniert -> auf PHP 8 ein FATALER Error. Alle define() sind !defined()-gepruft, sodass ein
     * spaeterer ECHTER Komponenten-Dispatch im selben Request unberuehrt bleibt (kein Joomla-Kern
     * wird gepatcht). Konstanten ueberleben Ticks nicht -> in model() (jede Phase) neu zu setzen ist
     * korrekt und billig.
     *
     * @return void
     */
    private static function ensureComponentContext()
    {
        $admin = defined('JPATH_ADMINISTRATOR')
            ? JPATH_ADMINISTRATOR
            : (defined('JPATH_ROOT') ? JPATH_ROOT . '/administrator' : '');

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

        $site = defined('JPATH_ROOT')
            ? JPATH_ROOT
            : (defined('JPATH_SITE') ? JPATH_SITE : dirname($admin));

        $comp = $admin . '/components/com_joomlaupdate';

        if (!defined('JPATH_COMPONENT_ADMINISTRATOR')) {
            define('JPATH_COMPONENT_ADMINISTRATOR', $comp);
        }

        if (!defined('JPATH_COMPONENT')) {
            define('JPATH_COMPONENT', $comp);
        }

        if (!defined('JPATH_COMPONENT_SITE')) {
            define('JPATH_COMPONENT_SITE', $site . '/components/com_joomlaupdate');
        }
    }

    // ── Phase: Pruefen ──────────────────────────────────────────────────────

    /**
     * Verfuegbares Kern-Update ermitteln. Spiegelt HtpCoreUpdateEngine::checkUpdate().
     *
     * @return array {ok, hasUpdate, installed, latest, error}
     */
    public static function checkUpdate()
    {
        $m = self::model();

        if ($m === null) {
            return array('ok' => false, 'error' => 'no_model');
        }

        try {
            if (method_exists($m, 'refreshUpdates')) {
                $m->refreshUpdates(true);
            }

            $info = $m->getUpdateInformation();

            if (!is_array($info)) {
                return array('ok' => false, 'error' => 'no_info');
            }

            return array(
                'ok'        => true,
                'hasUpdate' => !empty($info['hasUpdate']),
                'installed' => isset($info['installed']) ? (string) $info['installed'] : '',
                'latest'    => isset($info['latest']) ? (string) $info['latest'] : '',
                'error'     => '',
            );
        } catch (Exception $e) {
            return array('ok' => false, 'error' => self::errMsg($e));
        } catch (Throwable $e) {
            return array('ok' => false, 'error' => self::errMsg($e));
        }
    }

    // ── Phase: Download ─────────────────────────────────────────────────────

    /**
     * Update-Paket nach tmp_path herunterladen. J3-`download()` liefert den Basisnamen als
     * STRING (oder false); ein J4+-Array wird ebenfalls sauber behandelt. Wie im J5-Pfad
     * BEWUSST nur download() (kein restore.php/Loopback) - wir entpacken danach in-process.
     *
     * @param   string  $targetVersion  nur informativ; das Ziel steckt im #__updates-Eintrag
     *
     * @return array {ok, filename, filesize, error}
     */
    public static function prepare($targetVersion)
    {
        $m = self::model();

        if ($m === null || !method_exists($m, 'download')) {
            return array('ok' => false, 'error' => 'no_model');
        }

        try {
            if (class_exists('JPluginHelper')) {
                JPluginHelper::importPlugin('installer');
            }

            // Vor dem Download sicherstellen, dass wirklich ein anstehendes Update MIT Download-URL
            // vorliegt. Das Stock-download() liest $info['object']->downloadurl->_data OHNE Null-
            // Pruefung -> war die #__updates-Zeile zwischen checkUpdate() und jetzt leer (der
            // Watcher-Kontext liefert getUpdateInformation() zeitweise nichts), waere das auf PHP 8
            // ein Fatal. Einmal frisch pruefen, sonst sauber als 'no_pending_update' melden.
            if (!self::hasDownloadUrl($m) && method_exists($m, 'refreshUpdates')) {
                try {
                    $m->refreshUpdates(true);
                } catch (Exception $e) {
                } catch (Throwable $e) {
                }
            }

            if (!self::hasDownloadUrl($m)) {
                return array('ok' => false, 'error' => 'no_pending_update');
            }

            $res = $m->download();

            // J3: string basename | false. J4+: array {basename, check}.
            $file        = '';
            $checkFailed = false;

            if (is_string($res)) {
                $file = $res;
            } elseif (is_array($res)) {
                $file = isset($res['basename']) ? (string) $res['basename'] : '';

                if (array_key_exists('check', $res) && $res['check'] === false) {
                    $checkFailed = true;
                }
            }

            if ($res === false || $file === '') {
                return array('ok' => false, 'error' => 'download_failed');
            }

            if ($checkFailed) {
                return array('ok' => false, 'error' => 'checksum');
            }

            // Stock downloadPackage() prueft den JFile::write()-Rueckgabewert NICHT und meldet
            // trotzdem den Basisnamen -> ein nicht-beschreibbares/volles tmp_path ergaebe ein
            // falsches ok. Darum hier hart gegen die REALE Datei pruefen (existiert + >0 Byte),
            // sonst faellt der Fehler erst spaeter beim Entpacken als 'no_package' auf.
            $tmp  = self::tmpPath();
            $pkg  = ($tmp !== '') ? $tmp . '/' . basename($file) : '';
            $size = ($pkg !== '' && @is_file($pkg)) ? (int) @filesize($pkg) : 0;

            if ($pkg === '' || $size <= 0) {
                return array('ok' => false, 'error' => 'download_write');
            }

            // GitHub-Release-Redirects (JoomlaWorks) zeigen auf eine SIGNIERTE Asset-URL, deren
            // Pfad-Segment eine GUID OHNE Datei-Endung ist -> das Stock-download() speichert das
            // Paket endungslos. Joomlas JArchive/JInstallerHelper waehlt den Entpacker aber NACH der
            // Datei-Endung -> ohne ".zip" scheitert das Entpacken ("unpack_failed"). Darum den
            // Basisnamen normieren (nur wenn es wirklich ein ZIP ist - Magie-Bytes gepruft).
            $norm = self::ensureArchiveExtension($tmp, $file);

            if ($norm !== '') {
                $file = $norm;
                $pkg  = $tmp . '/' . basename($file);
                $size = @is_file($pkg) ? (int) @filesize($pkg) : $size;
            }

            return array('ok' => true, 'filename' => $file, 'filesize' => $size, 'error' => '');
        } catch (Exception $e) {
            return array('ok' => false, 'error' => self::errMsg($e));
        } catch (Throwable $e) {
            return array('ok' => false, 'error' => self::errMsg($e));
        }
    }

    /** true, wenn das Update-Modell eine anstehende Aktualisierung MIT nutzbarer Download-URL kennt. */
    private static function hasDownloadUrl($m)
    {
        try {
            if ($m === null || !method_exists($m, 'getUpdateInformation')) {
                return false;
            }

            $info = $m->getUpdateInformation();

            return is_array($info) && !empty($info['hasUpdate'])
                && isset($info['object']) && is_object($info['object'])
                && isset($info['object']->downloadurl->_data)
                && trim((string) $info['object']->downloadurl->_data) !== '';
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }
    }

    /**
     * Sorgt dafuer, dass das heruntergeladene Paket eine von Joomlas JArchive erkannte Endung traegt.
     * GitHub-Release-Redirects liefern eine signierte Asset-URL mit GUID-Pfad OHNE Endung -> das
     * Stock-download() speichert die Datei endungslos; JArchive waehlt den Entpacker aber ueber die
     * Datei-Endung -> ohne ".zip" => unpack_failed. Traegt der Name schon eine bekannte Archiv-Endung,
     * bleibt er (Rueckgabe ''). Sonst wird per Magie-Bytes ein ZIP erkannt und die Datei auf denselben
     * Namen + ".zip" umbenannt; der neue Basisname wird zurueckgegeben ('' = keine Aenderung).
     *
     * @param   string  $tmp   tmp-Verzeichnis (ohne abschliessenden Slash)
     * @param   string  $file  aktueller Paket-Basisname
     *
     * @return  string  neuer Basisname oder '' (unveraendert)
     */
    private static function ensureArchiveExtension($tmp, $file)
    {
        try {
            $base = basename((string) $file);
            $ext  = strtolower((string) pathinfo($base, PATHINFO_EXTENSION));

            // Bereits eine von JArchive erkannte Endung? -> nichts tun.
            if (in_array($ext, array('zip', 'tar', 'gz', 'tgz', 'bz2', 'tbz2', 'gzip', 'bzip2'), true)) {
                return '';
            }

            $src = rtrim((string) $tmp, '/') . '/' . $base;

            if (!@is_file($src)) {
                return '';
            }

            // ZIP-Magie pruefen (PK\x03\x04 normal, PK\x05\x06 leer, PK\x07\x08 gespannt).
            $magic = '';
            $fh    = @fopen($src, 'rb');

            if ($fh) {
                $magic = (string) @fread($fh, 4);
                @fclose($fh);
            }

            if (strncmp($magic, "PK\x03\x04", 4) !== 0
                && strncmp($magic, "PK\x05\x06", 4) !== 0
                && strncmp($magic, "PK\x07\x08", 4) !== 0) {
                return ''; // kein ZIP -> nicht anfassen (faellt spaeter mit klarer Meldung)
            }

            $dst = $src . '.zip';

            if (@rename($src, $dst) || (@copy($src, $dst) && @unlink($src))) {
                return $base . '.zip';
            }

            return '';
        } catch (Exception $e) {
            return '';
        } catch (Throwable $e) {
            return '';
        }
    }

    // ── Phase: Entpacken (IN-PROCESS) ───────────────────────────────────────

    /**
     * Das heruntergeladene Paket IN-PROCESS in einen Temp-Ordner entpacken - ueber J3s
     * `JInstallerHelper::unpack()` (native ZipArchive). Beruehrt NUR den Temp-Ordner.
     * Spiegelt HtpCoreUpdateEngine::unpackInProcess().
     *
     * @param   string  $filename  Paket-Basisname in tmp_path (aus prepare())
     *
     * @return array {ok, extractdir, error}
     */
    public static function unpackInProcess($filename)
    {
        // J3: JInstallerHelper; sehr defensiv auch die namespaced Variante zulassen.
        $helper = '';

        if (class_exists('JInstallerHelper')) {
            $helper = 'JInstallerHelper';
        } elseif (class_exists('Joomla\\CMS\\Installer\\InstallerHelper')) {
            $helper = 'Joomla\\CMS\\Installer\\InstallerHelper';
        } else {
            return array('ok' => false, 'extractdir' => '', 'error' => 'no_installerhelper');
        }

        @set_time_limit(300);

        try {
            $tmp = self::tmpPath();

            if ($tmp === '') {
                return array('ok' => false, 'extractdir' => '', 'error' => 'no_tmp');
            }

            $pkg = $tmp . '/' . basename((string) $filename);

            if (!@is_file($pkg)) {
                return array('ok' => false, 'extractdir' => '', 'error' => 'no_package');
            }

            // unpack($archive, $alwaysReturnArray=true) -> {packagename, extractdir, type}.
            $package = $helper::unpack($pkg, true);

            if (!is_array($package) || empty($package['extractdir']) || !@is_dir($package['extractdir'])) {
                return array('ok' => false, 'extractdir' => '', 'error' => 'unpack_failed');
            }

            return array('ok' => true, 'extractdir' => (string) $package['extractdir'], 'error' => '');
        } catch (Exception $e) {
            return array('ok' => false, 'extractdir' => '', 'error' => self::errMsg($e));
        } catch (Throwable $e) {
            return array('ok' => false, 'extractdir' => '', 'error' => self::errMsg($e));
        }
    }

    // ── Phase: Abschluss (Schema-Migration) + Aufraeumen ────────────────────

    /**
     * Schema-Migration/Abschluss ueber das J3-Modell. Spiegelt HtpCoreUpdateEngine::finalise().
     *
     * @return array {ok, error}
     */
    public static function finalise($fromVersion, $filename)
    {
        if (function_exists('opcache_reset')) {
            @opcache_reset();
        }

        $m = self::model();

        if ($m === null || !method_exists($m, 'finaliseUpgrade')) {
            return array('ok' => false, 'error' => 'no_model');
        }

        try {
            self::setUpdateState($fromVersion, $filename);

            $ok   = (bool) $m->finaliseUpgrade();
            $errs = method_exists($m, 'getErrors') ? (array) $m->getErrors() : array();

            if (!$ok || !empty($errs)) {
                // Bevor wir einen Fehler melden: hat sich der Kern auf der Platte TROTZDEM bewegt?
                // finaliseUpgrade() endet mit kosmetischen App-Aufrufen (z. B. flushAssets); wirft
                // einer davon NACH der eigentlichen Migration, waere die Fehlermeldung ein FALSCH-
                // NEGATIV, das den Orchestrator einen bereits migrierten Kern "zurueckrollen" liesse.
                if (self::onDiskAtLeast($fromVersion)) {
                    self::removeLeftoverInstaller();

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

                return array('ok' => false, 'error' => !empty($errs) ? self::clip(implode('; ', $errs)) : 'finalise_failed');
            }

            // Sicherheits-Nachsorge: einen etwaig VORHANDENEN Installer-Ordner entfernen. Unsere
            // eigene Kopie legt /installation nie an (buildCopyManifest ueberspringt ihn); dies
            // raeumt nur Altbestand weg, falls die Seite frueher mal per Voll-Paket (mit Installer)
            // aktualisiert wurde und /installation liegen blieb. Best effort.
            self::removeLeftoverInstaller();

            return array('ok' => true, 'error' => '');
        } catch (Exception $e) {
            if (self::onDiskAtLeast($fromVersion)) {
                return array('ok' => true, 'error' => '');
            }

            return array('ok' => false, 'error' => self::errMsg($e));
        } catch (Throwable $e) {
            if (self::onDiskAtLeast($fromVersion)) {
                return array('ok' => true, 'error' => '');
            }

            return array('ok' => false, 'error' => self::errMsg($e));
        }
    }

    /**
     * Aufraeumen (Paket loeschen, Event feuern). Best effort. Spiegelt HtpCoreUpdateEngine::cleanup().
     *
     * @return void
     */
    public static function cleanup($fromVersion, $filename)
    {
        $m = self::model();

        if ($m === null || !method_exists($m, 'cleanUp')) {
            return;
        }

        try {
            self::setUpdateState($fromVersion, $filename);
            $m->cleanUp();

            // Gurt-und-Hosentraeger: Voll-Pakete tragen KEINE Wurzel-joomla.xml; falls doch eine
            // liegen blieb (oder cleanUp() frueher einmal vorzeitig abbrach), best effort weg damit.
            if (defined('JPATH_ROOT') && @is_file(JPATH_ROOT . '/joomla.xml')) {
                @unlink(JPATH_ROOT . '/joomla.xml');
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    // ── Flotten-Aktion: Update-Quelle setzen (J3) ───────────────────────────

    /**
     * Die Kern-Update-Quelle von com_joomlaupdate auf eine benutzerdefinierte Collection-URL
     * setzen (Vorgabe: JoomlaWorks Security Distribution). Setzt BEIDES robust:
     *   (1) die Komponenten-Parameter `updatesource=custom` + `customurl` (das, was der
     *       Options-Dialog schreibt und was com_joomlaupdate zur Quellwahl liest), und
     *   (2) die Location der bestehenden Kern-Update-Site in #__update_sites (damit die
     *       naechste Pruefung sofort die neue Quelle nutzt, ohne auf einen Options-Save
     *       angewiesen zu sein).
     * Rein DB-basiert (kein ACL). Idempotent.
     *
     * @param   string  $url  Collection-list.xml-URL ('' -> Default JoomlaWorks)
     *
     * @return array {ok, changed, error}
     */
    public static function setUpdateSource($url = '')
    {
        if (!class_exists('HtpAutoUpdateLegacy') || !HtpAutoUpdateLegacy::isJ3()) {
            return array('ok' => false, 'changed' => 0, 'error' => 'not_j3');
        }

        $url = trim((string) $url);

        if ($url === '') {
            $url = self::JOOMLAWORKS_LIST;
        }

        if (!preg_match('~^https://[^\s"\'<>]+$~i', $url)) {
            return array('ok' => false, 'changed' => 0, 'error' => 'bad_url'); // nur HTTPS
        }

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

        if (!$db) {
            return array('ok' => false, 'changed' => 0, 'error' => 'no_db');
        }

        $changed = 0;

        try {
            // (1) Komponenten-Params updatesource=custom + customurl.
            $q = $db->getQuery(true)
                ->select($db->quoteName('params'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_joomlaupdate'));
            $db->setQuery($q);
            $raw = (string) $db->loadResult();

            $params = json_decode($raw !== '' ? $raw : '{}', true);

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

            $params['updatesource'] = 'custom';
            $params['customurl']    = $url;

            $upd = $db->getQuery(true)
                ->update($db->quoteName('#__extensions'))
                ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params)))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_joomlaupdate'));
            $db->setQuery($upd);
            $db->execute();
            $changed += (int) $db->getAffectedRows();

            // (2) Location der bestehenden Kern-Collection-Update-Site umbiegen. Die Kern-Site
            //     ist die Collection, die auf update.joomla.org/core/list.xml zeigt (Default)
            //     bzw. bereits eine benutzerdefinierte Core-Collection. Wir treffen genau die
            //     Core-Liste (list.xml), NICHT die vielen Erweiterungs-Update-Sites.
            $usel = $db->getQuery(true)
                ->select($db->quoteName(array('update_site_id', 'location')))
                ->from($db->quoteName('#__update_sites'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('collection'))
                ->where('(' . $db->quoteName('location') . ' LIKE ' . $db->quote('%update.joomla.org/core/%')
                    . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%joomla-3.x/list.xml') . ')');
            $db->setQuery($usel, 0, 20);
            $sites = $db->loadObjectList();

            if (is_array($sites)) {
                foreach ($sites as $s) {
                    if ((string) $s->location === $url) {
                        continue; // schon gesetzt
                    }

                    $uu = $db->getQuery(true)
                        ->update($db->quoteName('#__update_sites'))
                        ->set($db->quoteName('location') . ' = ' . $db->quote($url))
                        ->where($db->quoteName('update_site_id') . ' = ' . (int) $s->update_site_id);
                    $db->setQuery($uu);
                    $db->execute();
                    $changed += (int) $db->getAffectedRows();
                }
            }

            // Frisch pruefen lassen (fuellt #__updates aus der NEUEN Quelle), damit sie sofort
            // greift und das Dashboard die anstehende Version gleich beim naechsten Facts-Report
            // (want_facts nach core.updatesource) erfaehrt - nicht erst beim naechsten Joomla-Check.
            try {
                $m = self::model();

                if ($m !== null && method_exists($m, 'refreshUpdates')) {
                    $m->refreshUpdates(true);
                }
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }

            return array('ok' => true, 'changed' => $changed, 'error' => '');
        } catch (Exception $e) {
            return array('ok' => false, 'changed' => $changed, 'error' => self::errMsg($e));
        } catch (Throwable $e) {
            return array('ok' => false, 'changed' => $changed, 'error' => self::errMsg($e));
        }
    }

    // ── Referenz-Paket fuer den Kern-Abgleich (corescan) ────────────────────

    /**
     * Loest - WENN eine eigene Collection-Quelle (customurl, z. B. JoomlaWorks) konfiguriert
     * ist - das AKTUELLE Referenz-Voll-Paket auf: customurl (list.xml, Collection) -> passender
     * <extension> (targetplatformversion ~ laufende JVERSION) -> detailsurl (extension.xml) ->
     * {version, downloadurl}. corescan nutzt das als Kern-Abgleich-Referenz, ABER nur wenn die
     * Paket-Version == installierte Version (Voll-Paket-Referenz; installation/ wird ausgeschlossen).
     *
     * Ist KEINE eigene Quelle gesetzt (offizielle Quelle), kommt custom=false zurueck -> corescan
     * nutzt weiter das offizielle Update-Paket ueber HtpCoreScan::packageUrl($version).
     *
     * @return array {ok, custom, version, url, error}
     */
    public static function referencePackage()
    {
        $custom = self::customCollectionUrl();

        if ($custom === '') {
            return array('ok' => true, 'custom' => false, 'version' => '', 'url' => '', 'error' => '');
        }

        $jv = function_exists('htp_jversion') ? (string) htp_jversion() : (defined('JVERSION') ? (string) JVERSION : '');

        if (!preg_match('~^\d+\.\d+~', $jv)) {
            return array('ok' => false, 'custom' => true, 'version' => '', 'url' => '', 'error' => 'no_version');
        }

        // 1) Collection holen -> detailsurl fuer die passende Plattform.
        $list = self::httpGet($custom);

        if ($list === '') {
            return array('ok' => false, 'custom' => true, 'version' => '', 'url' => '', 'error' => 'list_fetch');
        }

        $details = self::pickDetailsUrl($list, $jv);

        if ($details === '') {
            return array('ok' => false, 'custom' => true, 'version' => '', 'url' => '', 'error' => 'no_match');
        }

        // 2) extension.xml -> version + (bevorzugt full-)downloadurl.
        $ext = self::httpGet($details);

        if ($ext === '') {
            return array('ok' => false, 'custom' => true, 'version' => '', 'url' => '', 'error' => 'ext_fetch');
        }

        $parsed = self::parseExtension($ext);

        if ($parsed[0] === '' || $parsed[1] === '') {
            return array('ok' => false, 'custom' => true, 'version' => '', 'url' => '', 'error' => 'ext_parse');
        }

        return array('ok' => true, 'custom' => true, 'version' => $parsed[0], 'url' => $parsed[1], 'error' => '');
    }

    /**
     * Anstehende J3-Kern-Update-Zielversion aus der KONFIGURIERTEN Quelle (fuer die Dashboard-
     * Anzeige - die offizielle latest-Map kennt J3-Fork-Versionen nicht). Liest den GECACHTEN
     * Update-Stand (getUpdateInformation, KEIN Netz-Refresh) -> billig genug fuer den Heartbeat.
     * Liefert die Zielversion nur bei echtem, gleich-Major-3-Update, sonst ''.
     *
     * @return string  z. B. "3.15.0" oder ''
     */
    public static function pendingTarget()
    {
        if (!class_exists('HtpAutoUpdateLegacy') || !HtpAutoUpdateLegacy::isJ3()) {
            return '';
        }

        try {
            // 1) DIREKT aus #__updates lesen - GENAU die Tabelle, die auch Joomlas Backend
            //    („Joomla! Update") anzeigt. Robust gegen Modell-/Kontext-Eigenheiten des
            //    Headless-Aufrufs (getUpdateInformation liefert im Watcher-Kontext teils nichts,
            //    obwohl der Eintrag da ist) und OHNE Netz. Hat Joomla die 3.15.0 erkannt, steht
            //    sie hier -> sofort korrekt gemeldet.
            $t = self::coreUpdateRow();

            if ($t !== '') {
                return $t;
            }

            // 2) Ersatzweise ueber Joomlas eigenes Update-Modell (falls die Tabelle anders belegt ist).
            $m = self::model();

            if ($m !== null) {
                $t = self::extractTarget($m->getUpdateInformation());

                if ($t !== '') {
                    return $t;
                }
            }

            // 3) #__updates leer (Joomla hat noch NICHT geprueft) -> die Version DIREKT aus der
            //    konfigurierten JoomlaWorks-/Custom-Collection aufloesen, damit sie auch OHNE
            //    Joomla-Check erscheint (keine „Verzoegerung durch Sparsamkeit"). SPARSAM:
            //    nur J3 + eigene Quelle, GEDROSSELT (max 1x/12 h, Merker in data/), winziger
            //    list.xml/extension.xml-Abruf; j3core reist per Delta-Sync ohnehin nur bei Aenderung.
            if (self::sourceCheckDue()) {
                $ref = self::referencePackage();

                if (!empty($ref['custom']) && !empty($ref['ok']) && (string) $ref['version'] !== '') {
                    $installed = function_exists('htp_jversion') ? (string) htp_jversion() : (defined('JVERSION') ? (string) JVERSION : '');

                    if (preg_match('~^3\.~', (string) $ref['version']) && version_compare((string) $ref['version'], $installed, '>')) {
                        return (string) $ref['version'];
                    }
                }
            }

            return '';
        } catch (Exception $e) {
            return '';
        } catch (Throwable $e) {
            return '';
        }
    }

    /** Darf jetzt ein DIREKTER Quell-Abruf (list.xml/extension.xml) laufen? Max 1x/12 h (data/-Merker). */
    private static function sourceCheckDue()
    {
        $dir = function_exists('htp_state_dir') ? (string) htp_state_dir() : '';

        if ($dir === '') {
            return false; // ohne State-Dir kein Heartbeat-Abruf (kein unkontrolliertes Netz)
        }

        $mk  = rtrim(str_replace('\\', '/', $dir), '/') . '/htp_j3srcchk.tick';
        $now = time();

        if (@is_file($mk) && ($now - (int) @filemtime($mk)) < 43200) {
            return false;
        }

        @touch($mk); // naechster Direkt-Abruf fruehestens in 12 h

        return true;
    }

    /**
     * Anstehende Kern-Zielversion DIREKT aus #__updates lesen - genau die Tabelle, die auch
     * Joomlas „Joomla! Update"-Backend anzeigt. Kein Modell, kein Netz. '' wenn keine gleich-
     * Major-3-Aktualisierung ansteht.
     *
     * @return string  z. B. "3.15.0" oder ''
     */
    private static function coreUpdateRow()
    {
        $db = function_exists('htp_db') ? htp_db() : null;

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

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName('version'))
                ->from($db->quoteName('#__updates'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('joomla'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('file'));
            $db->setQuery($q, 0, 1);
            $v = trim((string) $db->loadResult());

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

            $installed = function_exists('htp_jversion') ? (string) htp_jversion() : (defined('JVERSION') ? (string) JVERSION : '');

            if (preg_match('~^3\.~', $v) && preg_match('~^3\.~', $installed) && version_compare($v, $installed, '>')) {
                return $v;
            }

            return '';
        } catch (Exception $e) {
            return '';
        } catch (Throwable $e) {
            return '';
        }
    }

    /** Aus getUpdateInformation die anstehende gleich-Major-3-Zielversion ziehen (sonst ''). */
    private static function extractTarget($info)
    {
        if (!is_array($info) || empty($info['hasUpdate'])) {
            return '';
        }

        $latest    = isset($info['latest']) ? (string) $info['latest'] : '';
        $installed = isset($info['installed']) ? (string) $info['installed'] : (defined('JVERSION') ? (string) JVERSION : '');

        if (!preg_match('~^3\.~', $latest) || !preg_match('~^3\.~', $installed) || version_compare($latest, $installed, '<=')) {
            return '';
        }

        return $latest;
    }

    /** com_joomlaupdate customurl, NUR wenn updatesource='custom' - sonst ''. */
    private static function customCollectionUrl()
    {
        $db = function_exists('htp_db') ? htp_db() : null;

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

        try {
            $q = $db->getQuery(true)
                ->select($db->quoteName('params'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_joomlaupdate'));
            $db->setQuery($q);
            $raw = (string) $db->loadResult();

            $p = json_decode($raw !== '' ? $raw : '{}', true);

            if (!is_array($p) || (isset($p['updatesource']) ? $p['updatesource'] : '') !== 'custom') {
                return '';
            }

            $u = isset($p['customurl']) ? trim((string) $p['customurl']) : '';

            return preg_match('~^https://[^\s"\'<>]+$~i', $u) ? $u : '';
        } catch (Exception $e) {
            return '';
        } catch (Throwable $e) {
            return '';
        }
    }

    /** Aus einer Collection-list.xml die detailsurl des Eintrags waehlen, dessen
     *  targetplatformversion (als Regex) auf die laufende JVERSION passt. */
    private static function pickDetailsUrl($xml, $jv)
    {
        if (!preg_match_all('~<extension\b[^>]*>~i', (string) $xml, $m)) {
            return '';
        }

        foreach ($m[0] as $tag) {
            if (!preg_match('~targetplatformversion="([^"]*)"~i', $tag, $tp)
                || !preg_match('~detailsurl="([^"]*)"~i', $tag, $du)) {
                continue;
            }

            $tpv = trim($tp[1]);

            if ($tpv === '' || !@preg_match('~^(?:' . $tpv . ')~', $jv)) {
                continue;
            }

            $url = html_entity_decode(trim($du[1]), ENT_QUOTES);

            if (preg_match('~^https://[^\s"\'<>]+$~i', $url)) {
                return $url;
            }
        }

        return '';
    }

    /** Aus einer extension.xml [version, downloadurl] ziehen (bevorzugt type="full"). */
    private static function parseExtension($xml)
    {
        $xml = (string) $xml;
        $ver = preg_match('~<version>\s*([0-9][0-9.]*)\s*</version>~i', $xml, $mv) ? trim($mv[1]) : '';
        $url = '';

        if (preg_match('~<downloadurl\b[^>]*\btype="full"[^>]*>\s*([^<]+?)\s*</downloadurl>~i', $xml, $mu)) {
            $url = trim($mu[1]);
        } elseif (preg_match('~<downloadurl\b[^>]*>\s*([^<]+?)\s*</downloadurl>~i', $xml, $mu2)) {
            $url = trim($mu2[1]);
        }

        $url = html_entity_decode($url, ENT_QUOTES);

        if (!preg_match('~^https://[^\s"\'<>]+$~i', $url)) {
            $url = '';
        }

        return array($ver, $url);
    }

    /** Kleiner HTTPS-GET (verifiziert, mit ausgeliefertem CA-Bundle). '' bei Fehler/Nicht-2xx. */
    private static function httpGet($url)
    {
        if (!function_exists('curl_init')) {
            return '';
        }

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

        $ca = function_exists('htp_ca_bundle') ? htp_ca_bundle() : '';

        if ($ca !== '') {
            curl_setopt($ch, CURLOPT_CAINFO, $ca);
        }

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

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

        return (is_string($body) && $status >= 200 && $status < 300) ? substr($body, 0, 262144) : '';
    }

    // ── Sicherheits-Nachsorge ───────────────────────────────────────────────

    /**
     * Einen etwaig VORHANDENEN Joomla-Installer-Ordner (/installation) im Wurzelverzeichnis
     * entfernen - streng verankert: nur wenn es ein ECHTER Installer ist (installation/index.php
     * existiert) und der Pfad exakt <root>/installation lautet. Best effort, fail-open.
     *
     * @return void
     */
    private static function removeLeftoverInstaller()
    {
        try {
            $root = function_exists('htp_root')
                ? rtrim(str_replace('\\', '/', (string) htp_root()), '/')
                : (defined('JPATH_ROOT') ? rtrim(str_replace('\\', '/', JPATH_ROOT), '/') : '');

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

            $inst = $root . '/installation';

            // Sicherheits-Anker: nur einen ECHTEN Installer entfernen, nie irgendeinen Ordner.
            if (!@is_dir($inst) || !@is_file($inst . '/index.php')) {
                return;
            }

            $it = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($inst, FilesystemIterator::SKIP_DOTS),
                RecursiveIteratorIterator::CHILD_FIRST
            );

            foreach ($it as $f) {
                if ($f->isDir()) {
                    @rmdir($f->getPathname());
                } else {
                    @unlink($f->getPathname());
                }
            }

            @rmdir($inst);
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    // ── Helfer ──────────────────────────────────────────────────────────────

    /** tmp_path der Site (normalisiert, ohne Slash am Ende). '' bei Fehler. */
    private static function tmpPath()
    {
        try {
            $app = class_exists('JFactory') ? JFactory::getApplication() : null;
            $tmp = ($app && method_exists($app, 'get')) ? (string) $app->get('tmp_path') : '';

            if ($tmp === '' && class_exists('JFactory')) {
                $cfg = JFactory::getConfig();
                $tmp = (string) $cfg->get('tmp_path');
            }

            return $tmp !== '' ? rtrim(str_replace('\\', '/', $tmp), '/') : '';
        } catch (Exception $e) {
            return '';
        } catch (Throwable $e) {
            return '';
        }
    }

    /** com_joomlaupdate.oldversion/.file im User-State setzen (finaliseUpgrade/cleanUp lesen es). */
    private static function setUpdateState($fromVersion, $filename)
    {
        try {
            $app = class_exists('JFactory') ? JFactory::getApplication() : null;

            if ($app && method_exists($app, 'setUserState')) {
                $app->setUserState('com_joomlaupdate.oldversion', (string) $fromVersion);
                $app->setUserState('com_joomlaupdate.file', (string) $filename);
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    /**
     * true, wenn die On-Disk-Kernversion echt ueber $fromVersion liegt (die Migration hat also
     * gegriffen). Genutzt, um eine SPAETE/kosmetische Exception in finaliseUpgrade() NICHT als
     * Fehlschlag zu melden (sonst falsch-negativ -> unnoetiger „Rollback" eines migrierten Kerns).
     * HtpCoreUpdateEngine::installedVersion() liest die Version FRISCH von der Platte (nicht die
     * im Request bereits geladene JVERSION-Konstante).
     *
     * @param   string  $fromVersion  Version VOR dem Update
     *
     * @return boolean
     */
    private static function onDiskAtLeast($fromVersion)
    {
        try {
            if (function_exists('opcache_reset')) {
                @opcache_reset();
            }

            $now = (class_exists('HtpCoreUpdateEngine') && method_exists('HtpCoreUpdateEngine', 'installedVersion'))
                ? (string) HtpCoreUpdateEngine::installedVersion()
                : (defined('JVERSION') ? (string) JVERSION : '');

            return ($now !== '' && (string) $fromVersion !== '' && version_compare($now, (string) $fromVersion, '>'));
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }
    }

    private static function errMsg($e)
    {
        $msg = (is_object($e) && method_exists($e, 'getMessage')) ? (string) $e->getMessage() : '';

        return self::clip($msg);
    }

    private static function clip($s)
    {
        $s = trim((string) $s);

        return strlen($s) > 200 ? substr($s, 0, 200) . '…' : $s;
    }
}
