<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * HtpAuIntent - versionierte Auto-Update-ABSICHT je Erweiterung fuer den Zwei-Wege-Sync mit
 * htprotect.app (Dashboard und Joomla-Backend gleichberechtigt bedienbar, immer konsistent).
 *
 * Struktur (im Config-Key htp_shield.autoupdate_intent):
 *   { ext: { "<extKey>": {val:'on'|'off'|'inherit'|'never', seq:int, ts:int, origin:'app'|'backend'} },
 *     default: {val:'security'|'all'|'off'|'never', seq, ts, origin} }
 *
 * Die drei UI-Stufen bilden hierauf ab: 'on' = Immer, 'off' = Nur Sicherheitsupdates (der Boden laeuft
 * weiter), 'never' = Nie (unterhalb des Sicherheits-Bodens: KEIN Auto-Update, auch keine Sicherheit -
 * die Erweiterung wird aber weiter als verwundbar gemeldet und vom WAF geschuetzt). 'inherit' folgt der
 * Vorgabe. 'off' ist NICHT 'never' - Alt-Ausschluesse bleiben "nur Sicherheit". ('off'-Default = Legacy.)
 *
 * WAHRHEIT fuer den Sync; die Flach-Keys autoupdate_include/exclude/never/default werden DARAUS abgeleitet
 * (deriveFlat), damit die Ausfuehrungs-Maschinerie in autoupdate.php unveraendert bleibt.
 *
 * Konvergenz = LWW-Register pro Schluessel: Vergleichsschluessel (seq, ts, originRank); wer aendert,
 * setzt seq = (bekannte seq)+1. Ein veralteter Stand kann einen frischeren NIE ueberschreiben.
 *
 * Rein/deterministisch (Zeit wird als Argument uebergeben) -> standalone testbar. Genutzt von
 * link.php (Poll-Apply/Heartbeat) und autoupdate.php (Backend-Save), jeweils per class_exists-Guard,
 * damit ein Fehlen der Datei sauber auf das Alt-Verhalten degradiert (nie Fatal).
 */

defined('_JEXEC') or die;

class HtpAuIntent
{
    const VALS     = array('on', 'off', 'inherit', 'never');
    const DEFAULTS = array('security', 'all', 'off', 'never');
    const GRACE_VALS = array(0, 1, 3, 7); // erlaubte per-Erweiterung-Karenz (Soak-Tage)

    /** origin-Rang fuer den Tie-Break bei exakt gleichem (seq, ts): 'backend' schlaegt 'app'. */
    private static function originRank($o)
    {
        return $o === 'backend' ? 1 : 0;
    }

    /**
     * Ist $cand STRIKT neuer als $cur? LWW-Register, Vergleichsschluessel (ts, originRank, seq):
     * ts = eigentliche Ordnung (cross-writer, beide stempeln "jetzt"); origin bricht gleiche Sekunde
     * ('backend' > 'app'); seq = letzter Tie-Break. MUSS byte-gleich zur App (AutoUpdateReconciler::newer) sein.
     */
    public static function newer($cand, $cur)
    {
        $ct = isset($cand['ts']) ? (int) $cand['ts'] : 0;
        $rt = isset($cur['ts']) ? (int) $cur['ts'] : 0;
        if ($ct !== $rt) {
            return $ct > $rt;
        }

        $co = self::originRank(isset($cand['origin']) ? (string) $cand['origin'] : 'app');
        $ro = self::originRank(isset($cur['origin']) ? (string) $cur['origin'] : 'app');
        if ($co !== $ro) {
            return $co > $ro;
        }

        $cs = isset($cand['seq']) ? (int) $cand['seq'] : 0;
        $rs = isset($cur['seq']) ? (int) $cur['seq'] : 0;

        return $cs > $rs;
    }

    /** Struktur robust sicherstellen: {ext:array, default:array|null}. */
    public static function normalize($intent)
    {
        if (!is_array($intent)) {
            $intent = array();
        }
        if (!isset($intent['ext']) || !is_array($intent['ext'])) {
            $intent['ext'] = array();
        }
        if (!isset($intent['default']) || !is_array($intent['default'])) {
            $intent['default'] = null;
        }

        return $intent;
    }

    /** Hoechste seq (rev) ueber alle ext-Eintraege + default. 0, wenn leer. */
    public static function rev($intent)
    {
        $intent = self::normalize($intent);
        $rev = 0;

        foreach ($intent['ext'] as $e) {
            if (is_array($e) && isset($e['seq']) && (int) $e['seq'] > $rev) {
                $rev = (int) $e['seq'];
            }
        }
        if (is_array($intent['default']) && isset($intent['default']['seq']) && (int) $intent['default']['seq'] > $rev) {
            $rev = (int) $intent['default']['seq'];
        }

        return $rev;
    }

    /**
     * Einmalige Migration aus den Flach-Keys -> intent (origin='app', seq=1). origin='app' ist bewusst:
     * so adoptiert die App diese Alt-Werte NICHT als Overrides (kein First-Sync-Flood) - nur echte
     * kuenftige Backend-Edits (origin='backend') fliessen hoch.
     */
    public static function migrateFromFlat($cfg, $now)
    {
        $ext = array();
        $inc = (isset($cfg['autoupdate_include']) && is_array($cfg['autoupdate_include'])) ? $cfg['autoupdate_include'] : array();
        $exc = (isset($cfg['autoupdate_exclude']) && is_array($cfg['autoupdate_exclude'])) ? $cfg['autoupdate_exclude'] : array();

        foreach ($inc as $k) {
            $k = (string) $k;
            if ($k !== '') {
                $ext[$k] = array('val' => 'on', 'seq' => 1, 'ts' => (int) $now, 'origin' => 'app');
            }
        }
        foreach ($exc as $k) {
            $k = (string) $k;
            if ($k !== '') {
                $ext[$k] = array('val' => 'off', 'seq' => 1, 'ts' => (int) $now, 'origin' => 'app'); // exclude schlaegt include
            }
        }
        $nev = (isset($cfg['autoupdate_never']) && is_array($cfg['autoupdate_never'])) ? $cfg['autoupdate_never'] : array();
        foreach ($nev as $k) {
            $k = (string) $k;
            if ($k !== '') {
                $ext[$k] = array('val' => 'never', 'seq' => 1, 'ts' => (int) $now, 'origin' => 'app'); // never schlaegt include/exclude (staerkster Opt-out)
            }
        }

        $def = (isset($cfg['autoupdate_default']) && in_array($cfg['autoupdate_default'], self::DEFAULTS, true))
            ? $cfg['autoupdate_default'] : 'security';

        return array(
            'ext'     => $ext,
            'default' => array('val' => $def, 'seq' => 1, 'ts' => (int) $now, 'origin' => 'app'),
        );
    }

    /**
     * Liefert die einsatzbereite intent-Map: den gespeicherten Stand, oder - falls noch nicht
     * initialisiert - eine In-Memory-Migration aus den Flach-Keys (wird beim naechsten Save persistiert).
     * So ist der intent ueberall (applyUpdatePolicy/Heartbeat/Backend-Save) konsistent verfuegbar, ohne
     * beim blossen Lesen zu schreiben.
     */
    public static function ensure($cfg, $now)
    {
        $intent = self::normalize(isset($cfg['autoupdate_intent']) ? $cfg['autoupdate_intent'] : array());

        // Noch komplett leer (kein ext, kein default) -> aus den Flach-Keys ableiten.
        if (empty($intent['ext']) && $intent['default'] === null) {
            $intent = self::migrateFromFlat($cfg, $now);
        }

        return $intent;
    }

    /**
     * LWW-Merge einer Erweiterungs-Aenderung. Uebernimmt nur, wenn strikt neuer.
     * @return bool true = uebernommen.
     */
    public static function applyExt(&$intent, $key, $val, $seq, $ts, $origin)
    {
        $intent = self::normalize($intent);
        $key = (string) $key;
        if ($key === '' || !in_array($val, self::VALS, true)) {
            return false;
        }

        $cand = array('seq' => (int) $seq, 'ts' => (int) $ts, 'origin' => (string) $origin);
        $cur  = (isset($intent['ext'][$key]) && is_array($intent['ext'][$key])) ? $intent['ext'][$key] : array();

        if (!self::newer($cand, $cur)) {
            return false;
        }

        $entry = array('val' => (string) $val, 'seq' => (int) $seq, 'ts' => (int) $ts, 'origin' => (string) $origin);

        // Zusatzfelder BEWAHREN (per-Erweiterung-Karenz mit eigenem grace_ts-LWW): eine val-Aenderung
        // ersetzt den Eintrag NICHT mehr komplett, sonst ginge eine gesetzte Karenz bei jedem Toggle
        // verloren (nicht ersetzen -> mergen).
        if (isset($cur['grace'])) {
            $entry['grace'] = (int) $cur['grace'];
        }
        if (isset($cur['grace_ts'])) {
            $entry['grace_ts'] = (int) $cur['grace_ts'];
        }

        $intent['ext'][$key] = $entry;

        return true;
    }

    /**
     * Per-Erweiterung-Karenz (Soak-Tage) setzen ODER entfernen. APP-AUTORITATIV (nur das Dashboard setzt
     * sie; es gibt keinen Backend-Setter) - daher ein EIGENER kleiner LWW ueber grace_ts, unabhaengig vom
     * val-LWW. Wird als Zusatzfeld am ext-Eintrag gefuehrt; ein spaeteres val-Toggle bewahrt es
     * (applyExt-Merge). Erlaubte Setz-Werte: 0/1/3/7; SONDERWERT -1 = Karenz ENTFERNEN (folgt wieder dem
     * Fleet-Default). graceFor() liest sie, execPlan nutzt sie statt der globalen Karenz.
     *
     * @return bool  true = uebernommen.
     */
    public static function applyExtGrace(&$intent, $key, $grace, $ts)
    {
        $intent = self::normalize($intent);
        $key    = (string) $key;
        $grace  = (int) $grace;
        $ts     = (int) $ts;
        $clear  = ($grace === -1); // Sentinel: per-ext-Karenz loeschen

        if ($key === '' || (!$clear && !in_array($grace, self::GRACE_VALS, true))) {
            return false;
        }

        $cur    = (isset($intent['ext'][$key]) && is_array($intent['ext'][$key])) ? $intent['ext'][$key] : array();
        $curGts = isset($cur['grace_ts']) ? (int) $cur['grace_ts'] : 0;

        if ($ts < $curGts) {
            return false; // aeltere Karenz-Absicht verliert (LWW nur fuer die Karenz)
        }

        if ($clear) {
            // Nur wenn es einen Eintrag gibt: Karenz-Felder abstreifen (val-Regel bleibt unberuehrt).
            // grace_ts als Grabstein behalten -> ein aelteres, spaet eintreffendes Setzen kann sie nicht
            // wiederbeleben (LWW). Ohne bestehenden Eintrag ist nichts zu tun (bereits Fleet-Default).
            if (!isset($intent['ext'][$key]) || !is_array($intent['ext'][$key])) {
                return false;
            }

            unset($intent['ext'][$key]['grace']);
            $intent['ext'][$key]['grace_ts'] = $ts;

            return true;
        }

        // Karenz auch OHNE bestehende val-Regel setzbar -> minimaler Eintrag, val faellt auf 'inherit'.
        if (!isset($intent['ext'][$key]) || !is_array($intent['ext'][$key])) {
            $intent['ext'][$key] = array('val' => 'inherit', 'seq' => 0, 'ts' => 0, 'origin' => 'app');
        }

        $intent['ext'][$key]['grace']    = $grace;
        $intent['ext'][$key]['grace_ts'] = $ts;

        return true;
    }

    /**
     * Effektive Karenz (Soak-Tage) fuer eine Erweiterung: per-ext-Wert wenn gesetzt+gueltig, sonst der
     * Fleet-Default. Rein (kein State-Schreiben).
     *
     * @return int
     */
    public static function graceFor($intent, $key, $fleetDefault)
    {
        $intent = self::normalize($intent);
        $key    = (string) $key;
        $e      = (isset($intent['ext'][$key]) && is_array($intent['ext'][$key])) ? $intent['ext'][$key] : null;

        if ($e !== null && isset($e['grace']) && in_array((int) $e['grace'], self::GRACE_VALS, true)) {
            return (int) $e['grace'];
        }

        return (int) $fleetDefault;
    }

    /**
     * LWW-Merge des Workspace-Defaults.
     * @return bool true = uebernommen.
     */
    public static function applyDefault(&$intent, $val, $seq, $ts, $origin)
    {
        $intent = self::normalize($intent);
        if (!in_array($val, self::DEFAULTS, true)) {
            return false;
        }

        $cand = array('seq' => (int) $seq, 'ts' => (int) $ts, 'origin' => (string) $origin);
        $cur  = is_array($intent['default']) ? $intent['default'] : array();

        if (!self::newer($cand, $cur)) {
            return false;
        }

        $intent['default'] = array('val' => (string) $val, 'seq' => (int) $seq, 'ts' => (int) $ts, 'origin' => (string) $origin);

        return true;
    }

    /**
     * Leitet die Flach-Keys aus dem intent ab (Projektion), damit autoupdate.php unveraendert laeuft.
     * 'inherit' landet in keiner Liste (folgt dem Default). 'never' bekommt eine EIGENE Liste
     * (autoupdate_never) - sie unterschreitet den Sicherheits-Boden und darf daher NICHT mit 'off'
     * (exclude = nur Sicherheit) verwechselt werden.
     * @return array {autoupdate_include, autoupdate_exclude, autoupdate_never, autoupdate_default}
     */
    public static function deriveFlat($intent)
    {
        $intent = self::normalize($intent);
        $inc = array();
        $exc = array();
        $nev = array();

        foreach ($intent['ext'] as $key => $e) {
            if (!is_array($e)) {
                continue;
            }
            $v = isset($e['val']) ? $e['val'] : 'inherit';
            if ($v === 'on') {
                $inc[] = (string) $key;
            } elseif ($v === 'off') {
                $exc[] = (string) $key;
            } elseif ($v === 'never') {
                $nev[] = (string) $key;
            }
        }

        $def = (is_array($intent['default']) && isset($intent['default']['val']) && in_array($intent['default']['val'], self::DEFAULTS, true))
            ? $intent['default']['val'] : 'security';

        return array(
            'autoupdate_include' => array_values(array_unique($inc)),
            'autoupdate_exclude' => array_values(array_unique($exc)),
            'autoupdate_never'   => array_values(array_unique($nev)),
            'autoupdate_default' => $def,
        );
    }

    /**
     * KANONISCHER 12-Hex-Hash des abgeleiteten Flach-Zustands (include/exclude/default). MUSS auf der
     * App-Seite (AutoUpdatePolicy::flatHash) BYTE-GLEICH berechnet werden - sonst meldet der Sync
     * staendig falsche Abweichung. Regeln (identisch beidseitig):
     *   - jeder Schluessel: strtolower + trim
     *   - HTProtect selbst (com_htprotect) RAUS (Connector strippt es ohnehin; App filtert es) - sonst
     *     wichen die Hashes bei einem eingesickerten Self-Eintrag dauerhaft ab
     *   - eindeutig, dann als String sortiert (SORT_STRING)
     *   - Kanon: "v1\n" . include(je Zeile) . US(0x1e) . exclude(je Zeile) . US . default
     *   - never (neu, 2.7.2): NUR wenn nicht leer wird " . US . never(je Zeile)" ANGEHAENGT. So bleibt der
     *     Hash fuer alle Sites OHNE "Nie"-Eintrag byte-gleich zu v1 -> kein Sync-Rauschen beim Rollout,
     *     nur echte Nutzer der neuen Stufe (erst nach beidseitigem 2.7.2 setzbar) bekommen das Zusatzsegment.
     * Rein/testbar.
     *
     * @param array  $include  ext_keys mit val=on
     * @param array  $exclude  ext_keys mit val=off
     * @param string $default  'security'|'all'|'off'|'never'
     * @param array  $never    ext_keys mit val=never (optional, additiv)
     * @return string 12 Hex
     */
    public static function flatHash($include, $exclude, $default, $never = array())
    {
        $norm = function ($arr) {
            $out = array();
            foreach ((array) $arr as $k) {
                $k = strtolower(trim((string) $k));
                if ($k === '' || strpos($k, 'com_htprotect') !== false) {
                    continue;
                }
                $out[$k] = true;
            }
            $out = array_keys($out);
            sort($out, SORT_STRING);
            return $out;
        };

        $inc = $norm($include);
        $exc = $norm($exclude);
        $nev = $norm($never);
        $def = in_array((string) $default, self::DEFAULTS, true) ? (string) $default : 'security';

        $canon = "v1\n" . implode("\n", $inc) . "\x1e" . implode("\n", $exc) . "\x1e" . $def;
        if (!empty($nev)) {
            $canon .= "\x1e" . implode("\n", $nev);
        }

        return substr(hash('sha256', $canon), 0, 12);
    }

    /**
     * Sparse-Delta fuer den Heartbeat: immer die rev, plus NUR Eintraege mit seq > ackedRev.
     * @return array {rev, ext?:{...}, default?:{...}}
     */
    public static function delta($intent, $ackedRev)
    {
        $intent   = self::normalize($intent);
        $ackedRev = (int) $ackedRev;
        $out      = array('rev' => self::rev($intent));
        $ext      = array();

        foreach ($intent['ext'] as $key => $e) {
            if (is_array($e) && isset($e['seq']) && (int) $e['seq'] > $ackedRev) {
                $ext[(string) $key] = array(
                    'val'    => isset($e['val']) ? (string) $e['val'] : 'inherit',
                    'seq'    => (int) $e['seq'],
                    'ts'     => isset($e['ts']) ? (int) $e['ts'] : 0,
                    'origin' => isset($e['origin']) ? (string) $e['origin'] : 'app',
                );
            }
        }
        if (!empty($ext)) {
            $out['ext'] = $ext;
        }

        $d = $intent['default'];
        if (is_array($d) && isset($d['seq']) && (int) $d['seq'] > $ackedRev) {
            $out['default'] = array(
                'val'    => isset($d['val']) ? (string) $d['val'] : 'security',
                'seq'    => (int) $d['seq'],
                'ts'     => isset($d['ts']) ? (int) $d['ts'] : 0,
                'origin' => isset($d['origin']) ? (string) $d['origin'] : 'app',
            );
        }

        return $out;
    }
}
