<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * HtpCoreUpdateEngine - der gekapselte CMS-Adapter fuer das Joomla-Kern-Update.
 *
 * Fasst AUSSCHLIESSLICH Joomlas eigene, oeffentliche Update-Komponente an
 * (com_joomlaupdate): Pruefen, Download+Vorbereiten, gestepptes Entpacken,
 * Abschluss (Schema-Migration) und Aufraeumen. Der Orchestrator HtpCoreUpdate
 * ruft diese Schritte in einer resumierbaren Phasen-Maschine auf; die gesamte
 * versions-/CMS-spezifische Mechanik steckt hier an EINER Stelle - leicht pruef-
 * und pflegbar (analog HtpAutoUpdateLegacy fuer den J3-Erweiterungs-Pfad).
 *
 * Nutzt Joomlas eigene Modell-API (UpdateModel::refreshUpdates / getUpdateInformation
 * / download / finaliseUpgrade / cleanUp). Kopfloser Betrieb: KEIN eingeloggter Nutzer
 * und KEIN Super-User noetig (der Kern-Update-Pfad ruft nie authorise(); nur der hier
 * ungenutzte upload()-Pfad tut das).
 *
 * ENTPACKEN LAEUFT IN-PROCESS (kein HTTP-Loopback):
 *  Frueher trieb ein Loopback-POST zu com_joomlaupdate/extract.php den Entpacker. Das
 *  scheiterte auf Seiten mit FREMDEM /administrator-Basic-Auth (Hoster/Plesk/manuell)
 *  hart an HTTP 401 - der Loopback rennt in den Passwortschutz, fuer den es keine
 *  extract.php-Ausnahme gibt. Deshalb wird das bereits heruntergeladene Paket jetzt
 *  IN-PROCESS entpackt und der Kern kopiert - exakt der Weg von BackupMonkey/
 *  RemoteMonkey (David Jardin): InstallerHelper::unpack() (native ZipArchive, streamt
 *  Datei fuer Datei auf die Platte - RAM-schonend) in einen Temp-Ordner, danach
 *  gestept + resumierbar ueber die Site-Wurzel kopiert. Kein Loopback -> kein
 *  Admin-Auth, keine WAF, keine Selbst-Erreichbarkeit noetig; laeuft bei fremdem,
 *  eigenem oder gar keinem Backend-Schutz gleich zuverlaessig.
 */

defined('_JEXEC') or die;

class HtpCoreUpdateEngine
{
    // Dateien je Kopier-Schnitt (der Orchestrator schleift zusaetzlich bis zum Wall-
    // Clock-Budget). Klein genug, dass ein harter Host-Timeout nur den laufenden
    // Schnitt trifft; der naechste Tick macht am persistierten Cursor idempotent weiter.
    const COPY_BATCH = 300;

    // ── Verfuegbarkeit ──────────────────────────────────────────────────────

    /** Ist Joomlas Kern-Update-Komponente kopflos ansprechbar (J4+)? */
    public static function available()
    {
        return class_exists('Joomla\\CMS\\Factory') && self::model() !== null;
    }

    /**
     * Das Update-Modell der Kern-Update-Komponente kopflos erzeugen - exakt wie
     * Joomlas eigener Headless-API-Treiber (com_joomlaupdate JSON-View). KEIN ACL,
     * kein Super-User: der Kern-Pfad ruft nie authorise(). Gibt null zurueck, wenn
     * die Fabrik/Komponente fehlt (aeltere/kaputte Installation).
     *
     * @return object|null
     */
    private static function model()
    {
        if (!class_exists('Joomla\\CMS\\Factory')) {
            return null;
        }

        try {
            $app = Joomla\CMS\Factory::getApplication();

            if (!$app || !method_exists($app, 'bootComponent')) {
                return null;
            }

            $component = $app->bootComponent('com_joomlaupdate');

            if (!$component || !method_exists($component, 'getMVCFactory')) {
                return null;
            }

            $model = $component->getMVCFactory()->createModel('Update', 'Administrator', array('ignore_request' => true));

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

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

    /**
     * Verfuegbares Kern-Update ermitteln. refreshUpdates(true) fuellt #__updates
     * frisch, getUpdateInformation() liefert installiert/neueste/hasUpdate.
     *
     * @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 ─────────────────────────────────────────────────────

    /**
     * Das Update-Paket nach tmp_path herunterladen - Joomlas eigenes download() mit
     * Pruefsummen-Kontrolle. BEWUSST NUR download() (nicht prepareAutoUpdate): wir
     * entpacken in-process und brauchen deshalb KEIN update.php - das createUpdateFile()
     * von prepareAutoUpdate schreibt nach administrator/components/com_joomlaupdate/,
     * was auf gesperrten/fremdgeschuetzten Admin-Verzeichnissen scheitern kann. download()
     * schreibt ausschliesslich nach tmp_path -> robuster. Das Ziel steckt bereits in
     * #__updates (phaseCheck rief refreshUpdates + getUpdateInformation).
     *
     * @param   string  $targetVersion  X.Y.Z (nur informativ; Kanal liefert das Ziel)
     *
     * @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 {
            // Installer-Plugins laden (Installer-Skripte/Libraries erwarten sie).
            if (class_exists('Joomla\\CMS\\Plugin\\PluginHelper')) {
                Joomla\CMS\Plugin\PluginHelper::importPlugin('installer');
            }

            $res = $m->download();

            if (!is_array($res) || empty($res['basename'])) {
                return array('ok' => false, 'error' => 'download_failed');
            }

            // check === false = Pruefsumme stimmt nicht (Paket verworfen).
            if (array_key_exists('check', $res) && $res['check'] === false) {
                return array('ok' => false, 'error' => 'checksum');
            }

            $file = (string) $res['basename'];
            $size = 0;
            $app  = class_exists('Joomla\\CMS\\Factory') ? Joomla\CMS\Factory::getApplication() : null;

            if ($app) {
                $pkg = rtrim(str_replace('\\', '/', (string) $app->get('tmp_path')), '/') . '/' . $file;

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

            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));
        }
    }

    // ── Phase: Entpacken + Kopieren (IN-PROCESS, kein Loopback) ──────────────

    /**
     * Das bereits heruntergeladene Paket IN-PROCESS in einen Temp-Ordner entpacken -
     * ueber Joomlas InstallerHelper::unpack() (native ZipArchive, streamt Datei fuer
     * Datei auf die Platte, RAM-schonend). Beruehrt NUR den Temp-Ordner; scheitert das
     * Entpacken, bleibt der Live-Kern unberuehrt. Zeit-Limit BEWUSST gedeckelt (nicht 0): ein
     * haengendes Entpacken darf den Job-Lock nicht ewig halten (der Aufrufer advanceJob deckelt
     * ohnehin je Schritt) - 300 s reichen fuer ein Joomla-Core-Paket bequem.
     *
     * @param   string  $filename  Paket-Basisname in tmp_path (aus prepare())
     *
     * @return array {ok, extractdir, error}
     */
    public static function unpackInProcess($filename)
    {
        if (!class_exists('Joomla\\CMS\\Installer\\InstallerHelper')) {
            return array('ok' => false, 'extractdir' => '', 'error' => 'no_installerhelper');
        }

        @set_time_limit(300);

        try {
            $app = class_exists('Joomla\\CMS\\Factory') ? Joomla\CMS\Factory::getApplication() : null;
            $tmp = $app ? (string) $app->get('tmp_path') : '';

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

            $pkg = rtrim(str_replace('\\', '/', $tmp), '/') . '/' . basename((string) $filename);

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

            // unpack() entpackt in einen frischen Unterordner von tmp_path und liefert
            // {packagename, extractdir, type}. Rein lokal, native ZipArchive.
            $package = Joomla\CMS\Installer\InstallerHelper::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));
        }
    }

    /** Fester Ort der Kopier-Liste (in tmp_path, AUSSERHALB des extractdir -> wird nie mitkopiert). */
    public static function manifestPath()
    {
        $app = class_exists('Joomla\\CMS\\Factory') ? Joomla\CMS\Factory::getApplication() : null;
        $tmp = $app ? (string) $app->get('tmp_path') : (defined('JPATH_ROOT') ? JPATH_ROOT . '/tmp' : '.');

        return rtrim(str_replace('\\', '/', $tmp), '/') . '/htp-coreupd.manifest';
    }

    /** Ziel des Kopierens: die Site-Wurzel (dort liegen administrator/, libraries/, ...). */
    public static function siteRoot()
    {
        if (defined('JPATH_ROOT')) {
            return str_replace('\\', '/', JPATH_ROOT);
        }

        return function_exists('htp_root') ? rtrim(str_replace('\\', '/', htp_root()), '/') : '.';
    }

    /**
     * Kopier-Liste EINMALIG erzeugen: alle Verzeichnisse + Dateien unter $extractdir,
     * relativ, mit 'D:'/'F:'-Praefix (SELF_FIRST -> Verzeichnisse vor ihren Dateien).
     * Grundlage des resumierbaren, gestepten Kopierens. Reines PHP (Joomla-versions-
     * unabhaengig, kein Folder-API-Namespace-Risiko).
     *
     * @return int  Anzahl Eintraege, -1 bei Fehler
     */
    public static function buildCopyManifest($extractdir, $manifestPath)
    {
        try {
            $base = rtrim(str_replace('\\', '/', (string) $extractdir), '/');

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

            $it = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
                RecursiveIteratorIterator::SELF_FIRST
            );

            $lines = array();

            foreach ($it as $item) {
                $full = str_replace('\\', '/', $item->getPathname());
                $rel  = ltrim(substr($full, strlen($base)), '/');

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

                // Installer-Ordner NIE auf die Live-Site kopieren: nur VOLL-Pakete (z. B. das
                // JoomlaWorks-J3-UTD-Paket) enthalten ihn; ein Installer auf einer Live-Site
                // waere ein Sicherheitsrisiko. Auf J5-Update-Paketen nicht vorhanden -> wirkungslos.
                if ($rel === 'installation' || strpos($rel, 'installation/') === 0) {
                    continue;
                }

                // Aktive .htaccess NIE ueberschreiben: die verwaltet HTProtect (Upload-Ordner-Schild,
                // Haupt-/administrator-.htaccess) bzw. der Betreiber. Joomla liefert im Update ohnehin
                // nur die INAKTIVE htaccess.txt und fasst die aktive .htaccess nie an. Wuerde der
                // Kopiervorgang HTProtects gehaertetes z. B. images/.htaccess mit Joomlas Default
                // ueberschreiben, ginge (a) die Haertung verloren und (b) der .htaccess-Waechter
                // meldete einen FEHLALARM ("Upload-Ordner-Schild ausserhalb von HTProtect geaendert").
                // Nur der GENAUE Basename ".htaccess" (nicht etwa "foo.htaccess") wird ausgelassen.
                if (!$item->isDir() && ($rel === '.htaccess' || substr($rel, -10) === '/.htaccess')) {
                    continue;
                }

                $lines[] = ($item->isDir() ? 'D:' : 'F:') . $rel;
            }

            if (@file_put_contents((string) $manifestPath, implode("\n", $lines), LOCK_EX) === false) {
                return -1;
            }

            return count($lines);
        } catch (Exception $e) {
            return -1;
        } catch (Throwable $e) {
            return -1;
        }
    }

    /**
     * EINEN Kopier-Schnitt fahren: Eintraege [$cursor .. $cursor+$batch) der Kopier-Liste
     * von $extractdir ueber $siteRoot kopieren (Verzeichnisse anlegen, Dateien ueber-
     * schreiben). IDEMPOTENT - ein erneuter Lauf am selben Cursor schadet nicht, ein
     * abgebrochener Request wird am persistierten Cursor fortgesetzt.
     *
     * @return array {ok, cursor, done, total, error}
     */
    public static function copyManifestSlice($extractdir, $siteRoot, $manifestPath, $cursor, $batch)
    {
        try {
            @set_time_limit(300); // gedeckelt statt 0: ein Kopier-Schnitt darf den Lock nicht ewig halten

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

            if ($raw === false) {
                return array('ok' => false, 'cursor' => (int) $cursor, 'done' => false, 'total' => 0, 'error' => 'no_manifest');
            }

            $lines = ($raw === '') ? array() : explode("\n", $raw);
            $total = count($lines);
            $base  = rtrim(str_replace('\\', '/', (string) $extractdir), '/');
            $dest  = rtrim(str_replace('\\', '/', (string) $siteRoot), '/');
            $start = max(0, (int) $cursor);
            $end   = min($total, $start + max(1, (int) $batch));

            for ($i = $start; $i < $end; $i++) {
                $ln = $lines[$i];

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

                $isDir = (strncmp($ln, 'D:', 2) === 0);
                $rel   = substr($ln, 2);

                if ($rel === '' || strpos($rel, '..') !== false) {
                    continue;
                }

                $src = $base . '/' . $rel;
                $dst = $dest . '/' . $rel;

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

                    continue;
                }

                $dd = dirname($dst);

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

                // Read-only-Ziel entsperren, BEVOR ueberschrieben wird - typischer Fall: ein
                // HTProtect-gehaertetes .htaccess (0444) in einem Upload-Ordner (images/, media/ ...),
                // aber auch jede andere schreibgeschuetzte Kern-Datei. Sonst scheitert @copy hart mit
                // copy_failed und das GESAMTE Update bricht ab. Die betroffenen .htaccess haertet
                // HTProtects DirShield nach dem Update ohnehin selbst wieder nach.
                if (@is_file($dst) && !@is_writable($dst)) {
                    @chmod($dst, 0644);
                }

                if (@copy($src, $dst) === false) {
                    // Zweiter Versuch: Ziel evtl. erst durch den fehlgeschlagenen copy als read-only
                    // erkennbar -> hart entsperren und noch einmal.
                    @chmod($dst, 0644);

                    if (@copy($src, $dst) === false) {
                        return array('ok' => false, 'cursor' => $i, 'done' => false, 'total' => $total, 'error' => 'copy_failed:' . self::clip($rel));
                    }
                }
            }

            return array('ok' => true, 'cursor' => $end, 'done' => ($end >= $total), 'total' => $total, 'error' => '');
        } catch (Exception $e) {
            return array('ok' => false, 'cursor' => (int) $cursor, 'done' => false, 'total' => 0, 'error' => self::errMsg($e));
        } catch (Throwable $e) {
            return array('ok' => false, 'cursor' => (int) $cursor, 'done' => false, 'total' => 0, 'error' => self::errMsg($e));
        }
    }

    /**
     * In-Process-Temp aufraeumen (best effort): entpacktes Verzeichnis + Kopier-Liste +
     * Paket. Wird nach dem Abschluss gefahren; nicht kritisch (cleanTmp raeumt spaeter).
     *
     * @return void
     */
    public static function cleanupInProcess($extractdir, $manifestPath, $filename)
    {
        try {
            $app = class_exists('Joomla\\CMS\\Factory')
                ? Joomla\CMS\Factory::getApplication()
                : (class_exists('JFactory') ? JFactory::getApplication() : null);
            $tmp = ($app && method_exists($app, 'get')) ? rtrim(str_replace('\\', '/', (string) $app->get('tmp_path')), '/') : '';
            $pkg = ($tmp !== '' && (string) $filename !== '') ? $tmp . '/' . basename((string) $filename) : '';

            if (class_exists('Joomla\\CMS\\Installer\\InstallerHelper') && (string) $extractdir !== '') {
                Joomla\CMS\Installer\InstallerHelper::cleanupInstall($pkg, (string) $extractdir);
            } elseif (class_exists('JInstallerHelper') && (string) $extractdir !== '') {
                // Joomla 3: die namespaced InstallerHelper fehlt -> ohne diesen Fallback bliebe der
                // komplette entpackte Kern-Baum (7000+ Dateien) bis cleanTmp()>24h im tmp liegen.
                JInstallerHelper::cleanupInstall($pkg, (string) $extractdir);
            }

            if ((string) $manifestPath !== '' && @is_file((string) $manifestPath)) {
                @unlink((string) $manifestPath);
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

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

    /**
     * Den DB-seitigen Abschluss fahren: finaliseUpgrade() spielt die SQL-Schema-
     * Updates + das Installer-Skript ein und entfernt den #__updates-Datensatz.
     * MUSS in einem FRISCHEN Request nach dem Entpacken laufen (neue Dateien/Klassen)
     * - der Orchestrator trennt die Phasen entsprechend. Nutzer-Zustand (oldversion/
     * file) wird vorher neu gesetzt, weil eine Gast-Session Requests nicht ueberlebt.
     *
     * @param   string  $fromVersion  Version VOR dem Update
     * @param   string  $filename     Paket-Basisname
     *
     * @return array {ok, error}
     */
    public static function finalise($fromVersion, $filename)
    {
        // OPcache leeren, damit die neu ausgepackten Kern-Klassen (u. a. das
        // Installer-Skript com_admin/script.php) frisch geladen werden.
        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 {
            $app = class_exists('Joomla\\CMS\\Factory') ? Joomla\CMS\Factory::getApplication() : null;

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

            $ok = (bool) $m->finaliseUpgrade();

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

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

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

    /**
     * Aufraeumen: cleanUp() loescht Paket + update.php + die im Wurzelverzeichnis
     * abgelegte joomla.xml und feuert onJoomlaAfterUpdate. Best effort (fail-open).
     *
     * @param   string  $fromVersion
     * @param   string  $filename
     *
     * @return void
     */
    public static function cleanup($fromVersion, $filename)
    {
        $m = self::model();

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

        try {
            $app = class_exists('Joomla\\CMS\\Factory') ? Joomla\CMS\Factory::getApplication() : null;

            // cleanUp liest com_joomlaupdate.file (Paket loeschen) + .oldversion (Event).
            if ($app && method_exists($app, 'setUserState')) {
                $app->setUserState('com_joomlaupdate.oldversion', (string) $fromVersion);
                $app->setUserState('com_joomlaupdate.file', (string) $filename);
            }

            $m->cleanUp();
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    // ── Verifikation ────────────────────────────────────────────────────────

    /**
     * Installierte Version NACH dem Update - authoritativ vom Datentraeger
     * (administrator/manifests/files/joomla.xml), NICHT aus der JVERSION-Konstante
     * (die ist beim Prozessstart eingefroren und zeigt noch die alte Version).
     * Wiederverwendet HtpCoreScan::detectVersion() (liest genau dieses Manifest).
     *
     * @return string  "X.Y.Z" oder ''
     */
    public static function installedVersion()
    {
        if (class_exists('HtpCoreScan') && method_exists('HtpCoreScan', 'detectVersion')) {
            return (string) HtpCoreScan::detectVersion();
        }

        return '';
    }

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

    private static function errMsg($e)
    {
        $msg = '';

        if (is_object($e) && method_exists($e, 'getMessage')) {
            $msg = (string) $e->getMessage();
        }

        $msg = self::clip($msg);

        return $msg !== '' ? $msg : 'exception';
    }

    private static function clip($s)
    {
        $s = trim(preg_replace('~\s+~', ' ', (string) $s));

        return function_exists('mb_substr') ? mb_substr($s, 0, 300) : substr($s, 0, 300);
    }
}
