<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * HtpCoreUpdate - Orchestrator fuer das ferngesteuerte, kopflose Joomla-Kern-Update.
 *
 * Resumierbare Phasen-Maschine (check -> prepare -> extract -> finalise -> verify),
 * gebaut wie HtpBackup/HtpCoreScan: sie klinkt sich in den vorhandenen Job-Motor von
 * HtpLink (advanceJob/finishJob/Heartbeat-Fortschritt/Haenger-Erkennung/Wake-Fort-
 * setzung) ein und erbt so Fortschrittsbalken, Timeout- und Wiederaufnahme-Verhalten
 * ohne Zusatzcode. Die eigentliche CMS-Mechanik steckt gekapselt in HtpCoreUpdateEngine.
 *
 * BEWUSSTE ABGRENZUNG (Umfang):
 *  - Nur GLEICHE Major (z. B. 5.4.2 -> 5.4.7 oder 6.0 -> 6.1); KEIN Sprung 5 -> 6
 *    (B/C-Risiko) und KEIN Joomla 4/3 (End-of-Life). Untergrenze 5.4.0.
 *  - Nur remote per signiertem Dashboard-Befehl (core.update) ausloesbar - nie lokal.
 *  - Ein optionales Backup DAVOR ist reine Dashboard-Orchestrierung (backup.run dann
 *    core.update); diese Engine kennt kein Backup - sie aktualisiert nur.
 *  - EHRLICHE Rueckfall-Semantik: fuer den Kern gibt es KEINEN fingierten Auto-Rollback
 *    (Schema-Migrationen sind nicht sauber umkehrbar). Scheitert das Entpacken, bleibt
 *    die alte Version unberuehrt; nach dem Update wird die Erreichbarkeit geprueft und
 *    eine Verschlechterung LAUT gemeldet (der Betreiber sichert bei Bedarf zurueck).
 *
 * Die reinen Entscheidungs-Statics (eligible/sameMajor/progAt/nextPhase) sind Joomla-frei
 * und ohne CMS testbar (test/corecoreupdatecheck.php).
 */

defined('_JEXEC') or die;

class HtpCoreUpdate
{
    // Untergrenze: ab dieser Joomla-Version ist das Kern-Update verfuegbar (deckt sich
    // mit HtpAutoUpdate::MIN_JVERSION; J4/J3 sind EOL und ausgeschlossen).
    const MIN_JVERSION = '5.4.0';

    // Wiederhol-Grenzen je Phase (der Job-Haenger-Watchdog greift zusaetzlich).
    const PREPARE_MAX_FAILS = 3;   // Download/Vorbereiten
    const EXTRACT_MAX_FAILS = 5;   // aufeinanderfolgende Transport-Fehler beim Entpacken

    // Fortschritts-Bereiche je Phase (nicht ueberlappend, monoton). 1.0 nur bei 'done'.
    private static function ranges()
    {
        return array(
            'check'    => array(0.00, 0.05),
            'prepare'  => array(0.05, 0.15),
            'extract'  => array(0.15, 0.85),
            'finalise' => array(0.85, 0.95),
            'verify'   => array(0.95, 0.99),
        );
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Reine, testbare Logik (kein Joomla)
    // ─────────────────────────────────────────────────────────────────────────

    /** Version auf "X.Y.Z" normalisieren (fehlende Stellen mit 0 auffuellen). '' = ungueltig. */
    public static function canonVer($v)
    {
        if (!preg_match('~([0-9]+)(?:\.([0-9]+))?(?:\.([0-9]+))?~', (string) $v, $m)) {
            return '';
        }

        return (int) $m[1] . '.' . (int) (isset($m[2]) ? $m[2] : 0) . '.' . (int) (isset($m[3]) ? $m[3] : 0);
    }

    /** Major-Nummer einer Version. -1 = ungueltig. */
    public static function major($v)
    {
        $c = self::canonVer($v);

        return $c === '' ? -1 : (int) explode('.', $c)[0];
    }

    public static function sameMajor($a, $b)
    {
        $ma = self::major($a);

        return $ma >= 0 && $ma === self::major($b);
    }

    /**
     * Versions-abhaengige Engine-Klasse fuer die MODELL-getriebenen Aufrufe
     * (available/checkUpdate/prepare/unpackInProcess/finalise/cleanup): Joomla 3 ->
     * HtpCoreUpdateJ3 (eigene Legacy-API), sonst HtpCoreUpdateEngine (J5.4+). Die reinen,
     * versions-agnostischen Kopier-/Manifest-Helfer laufen IMMER auf HtpCoreUpdateEngine.
     *
     * @return string  Klassenname
     */
    private static function eng()
    {
        if (class_exists('HtpAutoUpdateLegacy') && HtpAutoUpdateLegacy::isJ3()
            && class_exists('HtpCoreUpdateJ3')) {
            return 'HtpCoreUpdateJ3';
        }

        return 'HtpCoreUpdateEngine';
    }

    /**
     * Ist ein Kern-Update von $installed auf $target zulaessig?
     *   ok=true  -> darf laufen (target = normalisierte Zielversion)
     *   ok=false -> reason: 'badversion' | 'min_version' | 'cross_major' | 'uptodate'
     *
     * Joomla 3 (Major 3) ist bewusst FREIGEGEBEN (eigener Pfad HtpCoreUpdateJ3, treibt die
     * vom Betreiber konfigurierte Quelle - z. B. die JoomlaWorks-Distribution). Nur GLEICHE
     * Major (3.x->3.y). Die 5.4-Untergrenze bleibt fuer alles andere (J4/frueh-J5 = kein Pfad).
     *
     * @return array {ok, target, reason}
     */
    public static function eligible($installed, $target)
    {
        $ci = self::canonVer($installed);
        $ct = self::canonVer($target);

        if ($ci === '' || $ct === '') {
            return array('ok' => false, 'target' => $ct, 'reason' => 'badversion');
        }

        // Major 3 = Joomla 3 -> eigener J3-Pfad, die 5.4-Untergrenze gilt hier NICHT.
        if (self::major($ci) !== 3 && version_compare($ci, self::MIN_JVERSION, '<')) {
            return array('ok' => false, 'target' => $ct, 'reason' => 'min_version');
        }

        if (!self::sameMajor($ci, $ct)) {
            // Cross-Major (z. B. 5 -> 6) wird bewusst NICHT automatisch gefahren.
            return array('ok' => false, 'target' => $ct, 'reason' => 'cross_major');
        }

        if (version_compare($ct, $ci, '<=')) {
            return array('ok' => false, 'target' => $ct, 'reason' => 'uptodate');
        }

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

    /**
     * Phasen-lokalen Anteil (0..1) in einen GLOBALEN, monotonen Fortschritt 0..0.99
     * abbilden. 1.0 ist allein dem Abschluss ('done') vorbehalten - so springt der
     * Balken nie auf 100 %, solange noch Arbeit laeuft, und faellt zwischen Phasen nie
     * zurueck (max mit dem bisherigen Wert).
     *
     * @return float
     */
    public static function progAt($job, $phase, $frac)
    {
        $ranges = self::ranges();
        $r      = isset($ranges[$phase]) ? $ranges[$phase] : array(0.0, 0.99);

        $frac = max(0.0, min(1.0, (float) $frac));
        $g    = $r[0] + ($r[1] - $r[0]) * $frac;

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

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

    // ─────────────────────────────────────────────────────────────────────────
    //  Start (seedet den Job-Slot; getrieben wird ueber HtpLink::advanceJob)
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Seedet den Kern-Update-Job. Wie beim Backup: NUR eintragen + sofort async
     * zurueck; der Job-Motor (tick/jobInline) rechnet ihn dann Schritt fuer Schritt.
     *
     * @param   array   $st      Link-State (Referenz)
     * @param   string  $cid     Operator-Command-ID (fuer die Ergebnis-Rueckmeldung)
     * @param   array   $params  Befehls-Parameter (derzeit ungenutzt; Ziel wird selbst ermittelt)
     *
     * @return array {ok, code[, async]}
     */
    public static function start(&$st, $cid, $params)
    {
        if (!empty($st['job'])) {
            return array('ok' => false, 'code' => 'busy');
        }

        $eng = self::eng();

        if (!class_exists($eng) || !$eng::available()) {
            return array('ok' => false, 'code' => 'no_engine');
        }

        // Der Entpacker laeuft per Loopback-HTTP -> Selbst-Erreichbarkeit ist Pflicht.
        if (!class_exists('HtpProbe') || !HtpProbe::available()) {
            return array('ok' => false, 'code' => 'core_no_http');
        }

        $st['job'] = array(
            'type'         => 'coreupdate',
            'cmd_id'       => (string) $cid,
            'phase'        => 'check',
            'started'      => time(),
            // Fortschritts-Zeitstempel: Basis der Haenger-Erkennung (HtpLink::jobStale).
            'advanced'     => time(),
            'progress'     => 0.0,
            'fromVersion'  => '',
            'targetVersion' => '',
            'filename'     => '',
            'filesize'     => 0,
            'xdir'         => '',   // In-Process-Entpackordner (unter tmp_path)
            'manifest'     => '',   // Ort der Kopier-Liste
            'ncopy'        => 0,    // Anzahl Kopier-Eintraege
            'cur'          => 0,    // Kopier-Cursor (resumierbar)
            'ex_pct'       => 0,    // Kopier-Fortschritt in % (Anzeige)
            'baseline'     => null, // Health-Baseline VOR dem Update
            'prep_fails'   => 0,
            'ex_fails'     => 0,
            'health_warn'  => '',
        );

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

    // ─────────────────────────────────────────────────────────────────────────
    //  Ein resumierbarer Schritt (vom Job-Motor je Tick aufgerufen)
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Bringt den Job im uebergebenen Wall-Clock-Budget voran. Vertrag wie HtpBackup::advance:
     * gibt IMMER {job, done, ok, code} zurueck; der Fortschritt steht in job['progress'].
     * Bei done ruft der Aufrufer (HtpLink) finishJob und meldet code/log dem Operator.
     *
     * @param   array  $job       aktueller Job-Datensatz
     * @param   float  $deadline  microtime()-Grenze fuer diesen Tick
     * @param   array  $cfg       Konfiguration
     *
     * @return array {job, done, ok, code}
     */
    public static function advance($job, $deadline, $cfg)
    {
        try {
            $phase = isset($job['phase']) ? (string) $job['phase'] : 'check';

            if ($phase === 'check') {
                return self::phaseCheck($job, $cfg);
            }

            if ($phase === 'prepare') {
                return self::phasePrepare($job);
            }

            if ($phase === 'extract') {
                return self::phaseExtract($job, $deadline);
            }

            if ($phase === 'finalise') {
                return self::phaseFinalise($job);
            }

            if ($phase === 'verify') {
                return self::phaseVerify($job, $cfg);
            }

            // Unbekannte Phase -> sauber beenden.
            return self::fail($job, 'core_bad_phase');
        } catch (Exception $e) {
            return self::fail($job, 'core_error');
        } catch (Throwable $e) {
            return self::fail($job, 'core_error');
        }
    }

    // ── Phase: Pruefen + Eignung + Health-Baseline ──────────────────────────

    private static function phaseCheck($job, $cfg)
    {
        $eng = self::eng();
        $chk = $eng::checkUpdate();

        if (empty($chk['ok'])) {
            return self::fail($job, 'core_check_failed:' . (isset($chk['error']) ? $chk['error'] : '?'));
        }

        $installed = isset($chk['installed']) ? $chk['installed'] : '';

        // Fallback: falls getUpdateInformation die installierte Version nicht liefert,
        // authoritativ vom Datentraeger lesen.
        if (self::canonVer($installed) === '') {
            $installed = HtpCoreUpdateEngine::installedVersion();
        }

        if (empty($chk['hasUpdate'])) {
            // Nichts zu tun ist KEIN Fehler.
            return self::done($job, true, 'core_uptodate');
        }

        $target = isset($chk['latest']) ? $chk['latest'] : '';
        $elig   = self::eligible($installed, $target);

        if (empty($elig['ok'])) {
            // Bewusste, klare Ablehnung (Dashboard zeigt "manuell"/"nicht moeglich").
            $reason = isset($elig['reason']) ? $elig['reason'] : '?';

            // uptodate hier = die einzige verfuegbare Aktualisierung ist eine andere
            // Major -> fuer den GLEICHE-Major-Pfad "aktuell".
            $ok = ($reason === 'uptodate');

            return self::done($job, $ok, 'core_not_eligible:' . $reason);
        }

        $job['fromVersion']   = self::canonVer($installed);
        $job['targetVersion'] = $elig['target'];

        // Health-Baseline VOR dem Update (fuer die Verschlechterungs-Erkennung nachher).
        // Fehlt eine verlaessliche URL, bleibt sie null -> dann entscheidet der Versions-
        // anstieg allein (kein Fehlalarm). Reine Wiederverwendung des Auto-Update-Probes.
        if (class_exists('HtpAutoUpdate') && method_exists('HtpAutoUpdate', 'captureHealth')) {
            $job['baseline'] = HtpAutoUpdate::captureHealth($cfg);
        }

        $job['phase']    = 'prepare';
        $job['progress'] = self::progAt($job, 'check', 1.0);

        return self::run($job);
    }

    // ── Phase: Download + Vorbereiten ───────────────────────────────────────

    private static function phasePrepare($job)
    {
        $eng  = self::eng();
        $prep = $eng::prepare($job['targetVersion']);

        if (empty($prep['ok'])) {
            $job['prep_fails'] = (int) $job['prep_fails'] + 1;

            if ($job['prep_fails'] >= self::PREPARE_MAX_FAILS) {
                return self::fail($job, 'core_prepare_failed:' . (isset($prep['error']) ? $prep['error'] : '?'));
            }

            // Erneut versuchen (naechster Tick); der Download ist idempotent.
            return self::run($job);
        }

        $job['filename'] = (string) $prep['filename'];
        $job['filesize'] = (int) $prep['filesize'];
        $job['xdir']     = '';
        $job['manifest'] = '';
        $job['ncopy']    = 0;
        $job['cur']      = 0;
        $job['ex_pct']   = 0;
        $job['ex_fails'] = 0;

        $job['phase']    = 'extract';
        $job['progress'] = self::progAt($job, 'prepare', 1.0);

        return self::run($job);
    }

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

    private static function phaseExtract($job, $deadline)
    {
        // Einmalig: das bereits heruntergeladene Paket IN-PROCESS in einen Temp-Ordner
        // entpacken (kein Loopback -> kein /administrator-Basic-Auth, kein 401) und die
        // Kopier-Liste erzeugen. Beruehrt NUR den Temp-Ordner; der Live-Kern bleibt bis
        // zum Kopieren unberuehrt.
        if ((string) $job['xdir'] === '') {
            $eng = self::eng();
            $u   = $eng::unpackInProcess($job['filename']);

            if (empty($u['ok'])) {
                $job['ex_fails'] = (int) $job['ex_fails'] + 1;

                if ($job['ex_fails'] >= self::EXTRACT_MAX_FAILS) {
                    return self::fail($job, 'core_extract_failed:' . (isset($u['error']) ? $u['error'] : '?'));
                }

                return self::run($job); // naechster Tick erneut entpacken
            }

            $job['xdir']     = (string) $u['extractdir'];
            $job['manifest'] = HtpCoreUpdateEngine::manifestPath();

            $n = HtpCoreUpdateEngine::buildCopyManifest($job['xdir'], $job['manifest']);

            if ($n < 0) {
                return self::fail($job, 'core_extract_failed:manifest');
            }

            $job['ncopy']    = (int) $n;
            $job['cur']      = 0;
            $job['ex_fails'] = 0;
        }

        $root = HtpCoreUpdateEngine::siteRoot();

        // Gestept ueber die Site-Wurzel kopieren, im Wall-Clock-Budget dieses Ticks;
        // der naechste Tick macht am persistierten Cursor idempotent weiter - so
        // ueberlebt das Kopieren auch einen hart abgewuergten Request.
        do {
            $r = HtpCoreUpdateEngine::copyManifestSlice(
                $job['xdir'],
                $root,
                $job['manifest'],
                (int) $job['cur'],
                HtpCoreUpdateEngine::COPY_BATCH
            );

            if (empty($r['ok'])) {
                // Transienter Fehler (Manifest/Kopie) -> begrenzt am selben Cursor erneut.
                $job['ex_fails'] = (int) $job['ex_fails'] + 1;

                if ($job['ex_fails'] >= self::EXTRACT_MAX_FAILS) {
                    return self::fail($job, 'core_extract_failed:' . (isset($r['error']) ? $r['error'] : '?'));
                }

                return self::run($job);
            }

            $job['ex_fails'] = 0;
            $job['cur']      = (int) $r['cursor'];
            $tot             = max(1, (int) (isset($r['total']) ? $r['total'] : $job['ncopy']));
            $job['ex_pct']   = (int) floor($job['cur'] * 100 / $tot);
            $job['progress'] = self::progAt($job, 'extract', $job['cur'] / $tot);

            if (!empty($r['done'])) {
                $job['phase'] = 'finalise';

                // Yield: der DB-Abschluss laeuft als EIGENER Schritt (naechster Tick bzw.
                // naechste jobInline-Runde). phaseFinalise leert zuvor den OPcache, damit die
                // frisch kopierten Kern-Dateien/Klassen sauber greifen.
                return self::run($job);
            }
        } while (microtime(true) < $deadline);

        // Budget aufgebraucht, aber noch nicht fertig -> naechster Tick macht weiter.
        return self::run($job);
    }

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

    private static function phaseFinalise($job)
    {
        $eng = self::eng();
        $fin = $eng::finalise($job['fromVersion'], $job['filename']);

        if (empty($fin['ok'])) {
            // Kein blindes Wiederholen einer Schema-Migration -> ehrlich als Fehler melden.
            return self::fail($job, 'core_finalise_failed:' . (isset($fin['error']) ? $fin['error'] : '?'));
        }

        // Paket/update.php/Wurzel-joomla.xml entfernen + onJoomlaAfterUpdate (best effort).
        $eng = self::eng();
        $eng::cleanup($job['fromVersion'], $job['filename']);

        // In-Process-Temp (Entpackordner + Kopier-Liste + Paket) aufraeumen (best effort).
        HtpCoreUpdateEngine::cleanupInProcess($job['xdir'], $job['manifest'], $job['filename']);

        $job['phase']    = 'verify';
        $job['progress'] = self::progAt($job, 'finalise', 1.0);

        return self::run($job);
    }

    // ── Phase: Verifikation (Versionsanstieg + Health-Delta) ────────────────

    private static function phaseVerify($job, $cfg)
    {
        $newVer = HtpCoreUpdateEngine::installedVersion();
        $target = (string) $job['targetVersion'];

        if (self::canonVer($newVer) === '' || version_compare(self::canonVer($newVer), $target, '<')) {
            // Version stieg nicht auf das Ziel -> Update NICHT sauber angewandt. KEIN
            // fingierter Rollback; laut melden (Betreiber prueft / sichert zurueck).
            return self::fail($job, 'core_verify_failed:' . self::canonVer($newVer));
        }

        // Health-Delta (nur wenn eine Baseline existierte). Der Kern IST aktualisiert;
        // eine Verschlechterung fuehrt daher NICHT zum (unmoeglichen) Auto-Rollback,
        // sondern zu einer deutlichen Warnung im Ergebnis.
        $warn = '';

        if (is_array($job['baseline']) && class_exists('HtpAutoUpdate')
            && method_exists('HtpAutoUpdate', 'captureHealth')
            && method_exists('HtpAutoUpdate', 'healthRegressed')) {
            $after = HtpAutoUpdate::captureHealth($cfg);

            if (is_array($after)) {
                $reason = (string) HtpAutoUpdate::healthRegressed($job['baseline'], $after);

                if ($reason !== '') {
                    $warn = $reason;
                }
            }
        }

        $job['progress'] = self::progAt($job, 'verify', 1.0);

        if ($warn !== '') {
            $job['health_warn'] = $warn;

            return self::done($job, true, 'core_updated_warn:' . self::canonVer($newVer) . ':' . $warn);
        }

        return self::done($job, true, 'core_updated:' . self::canonVer($newVer));
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Rueckgabe-Helfer (einheitlicher advance-Vertrag)
    // ─────────────────────────────────────────────────────────────────────────

    /** Job laeuft weiter. */
    private static function run($job)
    {
        return array('job' => $job, 'done' => false, 'ok' => true, 'code' => 'core_running');
    }

    /** Job erfolgreich beendet. */
    private static function done($job, $ok, $code)
    {
        if ($ok) {
            $job['progress'] = 1.0;
        }

        return array('job' => $job, 'done' => true, 'ok' => (bool) $ok, 'code' => (string) $code);
    }

    /** Job mit Fehler beendet (Fortschritt bleibt auf dem letzten echten Wert - nie 100 %). */
    private static function fail($job, $code)
    {
        return array('job' => $job, 'done' => true, 'ok' => false, 'code' => (string) $code);
    }
}
