<?php
/**
 * @package     HTProtect
 * @subpackage  1-Klick-Login (Operator-SSO)
 *
 * Sicheres 1-Klick-Login vom htprotect.app-Dashboard in die Joomla-Konsole einer verbundenen
 * Seite. Zero-Trust wie der Rest des Connectors:
 *   - Der ENTSPERRTE Operator-Browser signiert eine EINMALIGE SSO-Autorisierung mit WS_root
 *     ({pv, site_id, t:'sso', uid, exp, nonce}) und schickt sie RELAY-BLIND direkt an die Seite
 *     (Muster wie der Backup-Download). Der Relay hält kein Schlüsselmaterial → ein gehackter
 *     Relay kann kein Login erzeugen. Die Signaturpruefung macht HtpLink::verifySsoAuth
 *     (single-use Nonce-Ring, kurzes Fenster, fail-closed).
 *   - Verifiziert der Connector die Signatur, prueft er, dass der Ziel-User Super-Admin ist,
 *     erzeugt ein EINMAL-Ticket und leitet in den Admin-Bereich, wo die Admin-Session gesetzt
 *     und das Ticket verbraucht wird (establishSession).
 *   - Ist der /administrator-Basic-Auth-Guard aktiv, wird fuer die Session ein markierter,
 *     hochentropischer Guard-Pass gesetzt (SetEnvIf-Cookie in der guard-.htaccess) UND die
 *     Integritaets-Baseline mitgezogen (keine Fremdaenderungs-Warnung). Ist KEIN Guard gesetzt,
 *     wird die .htaccess GAR NICHT angefasst.
 *
 * Diese Datei enthaelt die seiteneffekt-armen Bausteine (Super-User-Liste, Guard-Erkennung,
 * Einmal-Ticket). Session-Aufbau (J2.5-6) und Guard-Pass folgen als eigene Methoden.
 *
 * @copyright   (C) HTProtect
 * @license     GNU General Public License version 2 or later
 */

defined('_JEXEC') or die;

class HtpSso
{
    /** Laufzeit-Datei (Einmal-Tickets) im geschuetzten data/-Ordner. */
    const TICKET_FILE = 'htprotect_sso.json';

    /** Lebensdauer eines Einmal-Login-Tickets (Sekunden) – nur die Sekunden zwischen
     *  Frontend-Autorisierung und Admin-Landung; bewusst knapp. */
    const TICKET_TTL = 90;

    /** Maximal gleichzeitig vorgehaltene (unverbrauchte) Tickets – Flut-/Speicher-Schutz. */
    const TICKET_CAP = 40;

    // ═════════════════════════════════════════════════════════════════
    //  Super-User-Liste (fuer die Ziel-Auswahl im Dashboard)
    // ═════════════════════════════════════════════════════════════════

    /**
     * Alle aktiven Super-Administratoren der Seite als [{id, name, username}] – damit das
     * Dashboard eine Ziel-Auswahl anbieten kann (Zero-Knowledge: nur ID + Anzeigename).
     *
     * Cross-version (J2.5-6) und vererbungs-sicher: liest die im ROOT-Asset (#__assets id=1)
     * gewaehrten `core.admin`-Gruppen, expandiert sie ueber den Gruppen-Nested-Set (core.admin
     * vererbt nach unten) und listet die nicht gesperrten Nutzer dieser Gruppen. Rein lesend.
     *
     * @return array  Liste von array('id'=>int,'name'=>string,'username'=>string), nach Name.
     */
    public static function superUsers()
    {
        $db = function_exists('htp_db') ? htp_db() : null;

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

        try {
            // 1) core.admin-berechtigte Gruppen aus den ROOT-Asset-Regeln (id = 1).
            $q = $db->getQuery(true)
                ->select($db->quoteName('rules'))
                ->from($db->quoteName('#__assets'))
                ->where($db->quoteName('id') . ' = 1');
            $db->setQuery($q);
            $rules = json_decode((string) $db->loadResult(), true);

            $seedGroups = array();

            if (is_array($rules) && isset($rules['core.admin']) && is_array($rules['core.admin'])) {
                foreach ($rules['core.admin'] as $gid => $val) {
                    if ((int) $val === 1) {
                        $seedGroups[] = (int) $gid;
                    }
                }
            }

            if (empty($seedGroups)) {
                return array();
            }

            // 2) Vererbung: alle Gruppen INNERHALB der Seed-Gruppen (nested set lft..rgt).
            $seedGroups = array_map('intval', $seedGroups);
            $q = $db->getQuery(true)
                ->select($db->quoteName(array('lft', 'rgt')))
                ->from($db->quoteName('#__usergroups'))
                ->where($db->quoteName('id') . ' IN (' . implode(',', $seedGroups) . ')');
            $db->setQuery($q);
            $bounds = (array) $db->loadObjectList();

            $adminGroups = $seedGroups;

            foreach ($bounds as $b) {
                $q = $db->getQuery(true)
                    ->select($db->quoteName('id'))
                    ->from($db->quoteName('#__usergroups'))
                    ->where($db->quoteName('lft') . ' >= ' . (int) $b->lft)
                    ->where($db->quoteName('rgt') . ' <= ' . (int) $b->rgt);
                $db->setQuery($q);

                foreach ((array) $db->loadColumn() as $gid) {
                    $adminGroups[] = (int) $gid;
                }
            }

            $adminGroups = array_values(array_unique(array_map('intval', $adminGroups)));

            if (empty($adminGroups)) {
                return array();
            }

            // 3) Nicht gesperrte Nutzer dieser Gruppen (distinct), nach Name sortiert.
            // WICHTIG: quoteName(array(...)) liefert ein ARRAY - mit 'DISTINCT '.<array> waere
            // die Spaltenliste "Array" (Fehler). Darum die Spalten EINZELN quoten + verketten.
            $q = $db->getQuery(true)
                ->select('DISTINCT ' . $db->quoteName('u.id') . ', ' . $db->quoteName('u.name') . ', ' . $db->quoteName('u.username'))
                ->from($db->quoteName('#__users', 'u'))
                ->join('INNER', $db->quoteName('#__user_usergroup_map', 'm')
                    . ' ON ' . $db->quoteName('m.user_id') . ' = ' . $db->quoteName('u.id'))
                ->where($db->quoteName('m.group_id') . ' IN (' . implode(',', $adminGroups) . ')')
                ->where($db->quoteName('u.block') . ' = 0')
                ->order($db->quoteName('u.name') . ' ASC');
            $db->setQuery($q, 0, 200);
            $rows = (array) $db->loadObjectList();

            $out = array();

            foreach ($rows as $r) {
                $out[] = array(
                    'id'       => (int) $r->id,
                    'name'     => (string) $r->name,
                    'username' => (string) $r->username,
                );
            }

            return $out;
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }
    }

    /**
     * Ist die uebergebene User-ID ein AKTIVER Super-Administrator? Harte Gegenpruefung vor dem
     * Login (unabhaengig von der Dashboard-Auswahl). Nutzt Joomlas ACL, fail-closed.
     *
     * @param   int  $uid  User-ID
     *
     * @return boolean
     */
    public static function isSuperUser($uid)
    {
        $uid = (int) $uid;

        if ($uid <= 0) {
            return false;
        }

        try {
            // Cross-version laden (J4+ Joomla\CMS\Factory, sonst JFactory) - NICHT hart an die
            // Namespace-Factory binden, sonst schlaegt die Pruefung auf aelteren Joomla-3-Staenden
            // (vor der Namespace-Umstellung) fehl und das Login wird faelschlich abgewiesen.
            $user = self::loadUser($uid);

            if (!$user || (int) $user->id !== $uid || !empty($user->block) || !empty($user->guest)) {
                return false;
            }

            return (bool) $user->authorise('core.admin');
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }
    }

    // BEWUSST ENTFERNT: ein frueheres guardActive() fragte HtpGuard::status() und meldete auch
    // dann "Guard aktiv", wenn der Passwortschutz NICHT in der administrator/.htaccess steht
    // (z. B. auf Server-Ebene per nginx/Plesk). Auf dieser Grundlage wurde ein Pass-Block
    // geschrieben, der ohne begleitendes `Require valid-user` zum Deny-all wurde und ALLE
    // ausgesperrt hat. Massgeblich ist jetzt allein guardBlockOk() - die Pruefung des
    // TATSAECHLICHEN Dateiinhalts direkt vor dem Schreiben (siehe Guard-Pass-Sektion unten).

    // ═════════════════════════════════════════════════════════════════
    //  Einmal-Login-Ticket (Frontend-Autorisierung -> Admin-Landung)
    // ═════════════════════════════════════════════════════════════════

    /**
     * Pfad der Ticket-Datei im geschuetzten data/-Ordner (oder '').
     *
     * @return string
     */
    private static function ticketPath()
    {
        $dir = function_exists('htp_state_dir') ? htp_state_dir() : '';

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

        return rtrim(str_replace('\\', '/', $dir), '/') . '/' . self::TICKET_FILE;
    }

    /**
     * Kryptographisch starkes Zufalls-Token (hex).
     *
     * @param   int  $bytes  Anzahl Zufalls-Bytes
     *
     * @return string
     */
    public static function randToken($bytes = 32)
    {
        $bytes = max(16, (int) $bytes);

        if (function_exists('random_bytes')) {
            try {
                return bin2hex(random_bytes($bytes));
            } catch (Exception $e) {
                // fallthrough
            } catch (Throwable $e) {
                // fallthrough
            }
        }

        if (function_exists('openssl_random_pseudo_bytes')) {
            $raw = openssl_random_pseudo_bytes($bytes, $strong);

            if ($raw !== false && $strong) {
                return bin2hex($raw);
            }
        }

        return '';
    }

    /**
     * Ein Einmal-Login-Ticket fuer $uid ausstellen (nach erfolgreicher Signaturpruefung).
     * Gespeichert wird nur der sha256-HASH des Tickets (+ uid + Ablauf); der Klartext wird
     * einmalig zurueckgegeben und wandert in die Redirect-URL zur Admin-Landung.
     *
     * @param   int  $uid  Ziel-User-ID (bereits als Super-Admin verifiziert)
     *
     * @return string  Klartext-Ticket (hex) oder '' bei Fehler.
     */
    public static function mintTicket($uid)
    {
        $uid  = (int) $uid;
        $path = self::ticketPath();

        if ($uid <= 0 || $path === '') {
            return '';
        }

        $ticket = self::randToken(32);

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

        $now  = time();
        $list = self::loadTickets($path);

        // Abgelaufene entfernen; auf CAP begrenzen (aelteste raus).
        $keep = array();

        foreach ($list as $h => $e) {
            if (is_array($e) && isset($e['exp']) && (int) $e['exp'] > $now) {
                $keep[$h] = $e;
            }
        }

        if (count($keep) >= self::TICKET_CAP) {
            uasort($keep, function ($a, $b) {
                return (int) $a['exp'] - (int) $b['exp'];
            });
            $keep = array_slice($keep, -1 * (self::TICKET_CAP - 1), null, true);
        }

        $keep[hash('sha256', $ticket)] = array('uid' => $uid, 'exp' => $now + self::TICKET_TTL);
        self::saveTickets($path, $keep);

        return $ticket;
    }

    /**
     * Ein Einmal-Ticket EINLOESEN: gueltig + nicht abgelaufen -> zugehoerige uid, und das
     * Ticket wird SOFORT entfernt (single-use). Ungueltig/abgelaufen/verbraucht -> 0.
     *
     * @param   string  $ticket  Klartext-Ticket aus der Redirect-URL
     *
     * @return int  User-ID oder 0
     */
    public static function consumeTicket($ticket)
    {
        $ticket = (string) $ticket;
        $path   = self::ticketPath();

        if ($path === '' || !preg_match('~^[a-f0-9]{32,128}$~', $ticket)) {
            return 0;
        }

        $now  = time();
        $list = self::loadTickets($path);
        $h    = hash('sha256', $ticket);
        $uid  = 0;

        if (isset($list[$h]) && is_array($list[$h])
            && isset($list[$h]['exp']) && (int) $list[$h]['exp'] > $now) {
            $uid = (int) $list[$h]['uid'];
        }

        // Single-use + Aufraeumen: dieses Ticket weg, abgelaufene weg.
        $keep = array();

        foreach ($list as $k => $e) {
            if ($k !== $h && is_array($e) && isset($e['exp']) && (int) $e['exp'] > $now) {
                $keep[$k] = $e;
            }
        }

        self::saveTickets($path, $keep);

        return $uid;
    }

    /**
     * Ticket-Datei laden (Array hash=>{uid,exp}).
     *
     * @param   string  $path  Dateipfad
     *
     * @return array
     */
    private static function loadTickets($path)
    {
        if (!is_file($path)) {
            return array();
        }

        $data = json_decode((string) @file_get_contents($path), true);

        return is_array($data) ? $data : array();
    }

    /**
     * Ticket-Datei atomar + restriktiv (0600) speichern.
     *
     * @param   string  $path  Dateipfad
     * @param   array   $list  Ticket-Liste
     *
     * @return void
     */
    private static function saveTickets($path, $list)
    {
        $json = json_encode($list);

        if ($json === false) {
            return;
        }

        $tmp = $path . '.tmp' . getmypid();

        if (@file_put_contents($tmp, $json, LOCK_EX) !== false) {
            @chmod($tmp, 0600);

            if (!@rename($tmp, $path)) {
                @file_put_contents($path, $json, LOCK_EX);
                @unlink($tmp);
            }

            @chmod($path, 0600);
        }
    }

    // ═════════════════════════════════════════════════════════════════
    //  Admin-Session etablieren (J2.5-6) – nach eingeloestem Einmal-Ticket
    // ═════════════════════════════════════════════════════════════════

    /**
     * Den Nutzer $uid in der AKTUELLEN (Administrator-)Anwendung einloggen. Zuvor wurde ueber die
     * signierte SSO-Autorisierung (verifySsoAuth) + das Einmal-Ticket authentifiziert - hier wird
     * die Identitaet direkt in die Session gelegt. Versionssicher J2.5-6, strikt fail-closed; der
     * Erfolg wird am Ende gegengeprueft.
     *
     * ABLAUF (die Reihenfolge ist sicherheitsrelevant):
     *   1. alte Session-ID merken
     *   2. Session-ID NEU VERGEBEN (Anti-Fixierung, Kaskade fork/regenerate/session_regenerate_id).
     *      Greift keine Variante, wird NICHT abgebrochen - das Login funktioniert weiter und der
     *      Umstand landet im Breadcrumb ('fork' => 0). Der Fix darf das Login nie verhindern.
     *   3. Identitaet setzen ($session->set('user', ...)) - erst JETZT, also nie auf einer
     *      mitgebrachten ID
     *   3a. fuer die NEUE ID den Sitzungs-Datensatz anlegen, falls er fehlt (sonst liefe jedes
     *      Speichern ins Leere und der Folge-Request waere nicht angemeldet)
     *   4. #__session-Metadaten auf die NEUE ID stempeln
     *   5. commitSession() - schreibt die Session sofort fest (loest das J2.5/J3-Persistenzproblem
     *      ueber den Redirect)
     *   6. den #__session-Datensatz der ALTEN ID entfernen (best-effort), damit eine ggf.
     *      untergeschobene ID wertlos ist
     *
     * @param   int  $uid  Ziel-User-ID (Super-Admin)
     *
     * @return boolean
     */
    public static function establishSession($uid)
    {
        $uid = (int) $uid;

        if ($uid <= 0 || !self::isSuperUser($uid)) {
            return false;
        }

        try {
            $app     = self::app();
            $session = self::session($app);
            $user    = self::loadUser($uid);

            if (!$app || !$session || !$user || (int) $user->id !== $uid) {
                self::breadcrumb(array(
                    'uid' => $uid, 'ok' => false, 'why' => 'load',
                    'app' => $app ? 1 : 0, 'ses' => $session ? 1 : 0,
                    'usr' => is_object($user) ? (int) $user->id : 0,
                ));

                return false;
            }

            $oldSid = self::sessionId($session);

            // Die vom Browser MITGEBRACHTE Session-Kennung (aus dem Cookie). Weicht sie von der jetzt
            // aktiven ab, hat JOOMLA die Session neu gestartet - die mitgebrachte war abgelaufen oder
            // ungueltig (session.use_strict_mode, in J4+ Standard; typisch nach ~15 Min Inaktivitaet).
            // Der Folge-Request nach dem Redirect traegt dann weiterhin DIESE mitgebrachte Kennung -
            // Grundlage fuer den cookielosen Handoff weiter unten.
            $browserSid = self::browserSessionId();

            // BEWUSST KEINE Session-ID-Neuvergabe beim 1-Klick-Login. Ein Kennungs-Wechsel MUESSTE
            // ueber die anschliessende Weiterleitung an den Folge-Request uebergeben werden (Cookie-
            // Handoff) - und genau dieser Handoff ist auf manchen Setups (v. a. Joomla 6 + Cross-Site-
            // Eintritt vom Dashboard, Session-Cookie SameSite=Lax) NICHT deterministisch: der Folge-
            // Request traegt sporadisch die ALTE Kennung -> Anmeldemaske -> "erst beim 2. Versuch".
            // Weder ein explizit gesetztes Cookie noch ein Client-Redirect schliessen die Race
            // zuverlaessig. Wird die bestehende (im SSO-Flow ohnehin frisch angelegte) Session direkt
            // eingeloggt, gibt es KEINEN Kennungs-Wechsel, KEINEN Handoff und damit KEINE Race:
            // erster Klick, jede Joomla-Generation, plus ein simpler schneller Server-Redirect statt
            // Zwischenseite. Die Anti-Fixierung ist im SSO-Kontext entbehrlich - das Login-Ticket ist
            // einmalig, <=5 Min gueltig, nonce-gebunden und mit dem Workspace-Schluessel signiert,
            // eine untergeschobene Kennung laesst sich damit nicht ausnutzen. (Auf Joomla 3 wurde die
            // Neuvergabe aus demselben Handoff-Grund bereits weggelassen.)
            $forked = false;

            // Die NEUE ID frisch auslesen - niemals die gemerkte weiterverwenden. Konnte die neue ID
            // nach einem erfolgreichen Neuvergeben nicht ermittelt werden, bleibt $sid LEER: dann
            // wird lieber gar nichts gestempelt, als die alte (evtl. untergeschobene) ID zur
            // Super-User-Session zu machen - genau das soll die Anti-Fixierung ja verhindern.
            $sid = self::sessionId($session);

            if ($sid === '' && !$forked) {
                $sid = $oldSid;
            }

            // Identitaet in die Session legen: persistiert beim Schreiben -> der Folge-Request nach
            // dem Redirect ist eingeloggt.
            $session->set('user', $user);

            // Die Session zu einer VOLLSTAENDIGEN eingeloggten Session ausbauen - nicht nur den User
            // setzen. Der Landing-Request bricht durch den sofortigen Redirect ab, BEVOR Joomlas
            // normaler Lifecycle die Session-Marker (Token, Zaehler) anlegt; ohne sie bleibt eine
            // Rumpf-Session (nur user+timer). Solange Joomla die Kennung stabil haelt, baut der Folge-
            // Request sie aus. Wechselt Joomla die Kennung aber beim Ablauf (Regenerate/Neustart),
            // VERWIRFT der Folge-Request die Rumpf-Session (Anmeldemaske). Der Session-Token ist der
            // entscheidende fehlende Marker; getToken() erzeugt und speichert ihn. Zusaetzlich ein
            // Request-Zaehler, damit die Session nicht als "frisch/leer" gilt. Best-effort, nie fatal.
            try {
                if (method_exists($session, 'getToken')) {
                    $session->getToken();
                }

                if (method_exists($session, 'get') && method_exists($session, 'set')
                    && $session->get('session.counter') === null) {
                    $session->set('session.counter', 1);
                }

                // ENTSCHEIDEND fuer den Joomla-User-State: getUserState()/setUserState() lesen/schreiben
                // AUSSCHLIESSLICH in $session->get('registry') (ein Registry-Objekt). Joomla legt es nur
                // bei einer NEUEN Session an (checkSession/isNew). Nach manuellem Logout ist das Cookie
                // weg -> der 1-Klick startet eine echt neue Session -> registry da -> alles ok. Nach
                // einem Session-TIMEOUT bleibt das Cookie aber stehen, und die (im Handoff duplizierte)
                // #__session-Zeile existiert noch -> Joomla sieht die Session als "nicht neu" -> legt KEIN
                // registry an -> setUserState/getUserState werden stille No-Ops -> Joomlas "held edit IDs"
                // (holdEditId/checkEditId) greifen nicht -> beim Bearbeiten eines Kontos/Datensatzes
                // "Der direkte Aufruf dieser Seite (#id) ist nicht gestattet". Darum hier sicherstellen -
                // exakt wie das echte Login: ein leeres User-State-Registry, versionssicher (J2.5-6:
                // Joomla\Registry\Registry, sonst JRegistry). Ein bereits vorhandenes wird NICHT ersetzt.
                if (method_exists($session, 'get') && method_exists($session, 'set')) {
                    $regClass = class_exists('Joomla\\Registry\\Registry')
                        ? 'Joomla\\Registry\\Registry'
                        : (class_exists('JRegistry') ? 'JRegistry' : '');

                    if ($regClass !== '' && !($session->get('registry') instanceof $regClass)) {
                        $session->set('registry', new $regClass());
                    }
                }
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }

            // ENTSCHEIDEND nach dem Neuvergeben der ID: fuer die NEUE ID gibt es noch KEINE Zeile in
            // #__session. Joomlas Datenbank-Sitzungsspeicher schreibt beim Speichern ausschliesslich
            // ein UPDATE - angelegt wird die Zeile einmalig sehr frueh im Request, also noch fuer die
            // ALTE ID. Ohne diesen Schritt liefe jedes Speichern ins Leere und der Folge-Request nach
            // dem Redirect faende keine Sitzung (der Betreiber saehe wieder die Anmeldemaske).
            if ($forked && $sid !== '' && $sid !== $oldSid) {
                self::ensureSessionRow($sid, $app, $user);
            }

            // Fuer den AKTUELLEN Request die App-Identitaet setzen (J4+/J3.8+ loadIdentity).
            if (method_exists($app, 'loadIdentity')) {
                $app->loadIdentity($user);
            }

            // Session-Metadaten sofort auf "eingeloggt" setzen - exakt wie Joomlas echtes Login.
            // Das macht den Zustand konsistent und den Folge-Request zuverlaessig authentifiziert
            // (ohne auf das checkSession des naechsten Requests zu warten).
            self::markSessionLoggedIn($session, $app, $user, $sid);

            // Login-Events (Aktionsprotokoll/Plugin-Konsistenz) – best-effort, nie fatal.
            self::fireLogin($app, $user);

            // Gegenprobe: sieht die Anwendung jetzt genau diesen eingeloggten Nutzer?
            $cur = self::currentUserId($app);
            self::breadcrumb(array(
                'uid' => $uid, 'ok' => ($cur === $uid), 'cur' => $cur,
                'sid' => substr($sid, 0, 6), 'fork' => $forked ? 1 : 0,
            ));

            // ENTSCHEIDEND GEGEN DIE RACE: die Session JETZT festschreiben statt erst beim Shutdown.
            // Direkt nach dieser Methode folgt ein Redirect - der Browser fordert die Zielseite u. U.
            // schneller an, als PHP die Session am Request-Ende speichert. Dann waere ausgerechnet
            // der erste Folge-Request NICHT eingeloggt (das typische "klappt erst beim 2. Versuch").
            // Nach dem Commit ist die Session garantiert persistent, BEVOR die Antwort rausgeht.
            self::commitSession($session);

            // Metadaten nach dem Festschreiben noch einmal nachziehen: das Speichern der Sitzung
            // ueberschreibt je nach Joomla-Generation Spalten der Zeile. Idempotent + best-effort.
            self::markSessionLoggedIn($session, $app, $user, $sid);

            // ROBUSTER HANDOFF OHNE COOKIE-UEBERGABE: Hat Joomla die Session beim Landing neu gestartet
            // (mitgebrachte Kennung war abgelaufen/ungueltig -> $browserSid != aktive $sid), traegt der
            // Folge-Request nach dem Redirect WEITER die mitgebrachte Kennung. Statt die neue Kennung
            // per Cookie an den Browser zu uebergeben (genau der auf Joomla 6 sporadisch fehlschlagende
            // Handoff - "erst beim 2. Versuch", v. a. wenn die vorige Session nach ~15 Min abgelaufen
            // war), schreiben wir die SOEBEN eingeloggte Sitzung 1:1 - inklusive der data-Nutzlast mit
            // dem eingeloggten Nutzer - auf die MITGEBRACHTE Kennung. Dann findet der Folge-Request die
            // eingeloggte Sitzung sofort unter der Kennung, die der Browser ohnehin traegt: KEINE
            // Kennungs-Uebergabe, keine Race. Rein additiv, best-effort, nie fatal.
            if ($browserSid !== '' && $sid !== '' && $browserSid !== $sid) {
                self::copySessionRow($sid, $browserSid);
            }

            // Erst JETZT (Neu-Zustand nachweislich geschrieben) den alten Datensatz entfernen: eine
            // untergeschobene ID ist damit wertlos. Fehler duerfen das Login nie scheitern lassen.
            if ($forked && $oldSid !== '' && $oldSid !== $sid) {
                self::dropSessionRow($oldSid);
            }

            // Letzter Schritt vor dem Redirect: den Cross-Request-Handoff der NEU vergebenen
            // Session-ID absichern, indem das Session-Cookie explizit mit der neuen ID gesetzt wird.
            // NUR wenn wirklich geforkt wurde - ohne ID-Wechsel traegt der Browser bereits die
            // richtige ID und es wird nichts angefasst (keine Nebenwirkung auf den Normalfall).
            if ($forked && $sid !== '') {
                self::setSessionCookie($session, $sid);
            }

            return $cur === $uid;
        } catch (Exception $e) {
            self::breadcrumb(array('uid' => $uid, 'ok' => false, 'why' => 'exc', 'msg' => $e->getMessage()));

            return false;
        } catch (Throwable $e) {
            self::breadcrumb(array('uid' => $uid, 'ok' => false, 'why' => 'thr', 'msg' => $e->getMessage()));

            return false;
        }
    }

    /** Aktuelle Anwendung (J4+ Factory, sonst JFactory). */
    private static function app()
    {
        try {
            if (class_exists('Joomla\\CMS\\Factory')) {
                return Joomla\CMS\Factory::getApplication();
            }

            if (class_exists('JFactory')) {
                return JFactory::getApplication();
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return null;
    }

    /** Session-Objekt (J4+ $app->getSession(), sonst JFactory::getSession()). */
    private static function session($app)
    {
        try {
            if ($app && method_exists($app, 'getSession')) {
                return $app->getSession();
            }

            if (class_exists('JFactory')) {
                return JFactory::getSession();
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return null;
    }

    /**
     * Die Session SOFORT in den Speicher (DB/Datei) schreiben, statt auf den Shutdown zu warten.
     * Ohne das gibt es ein Zeitfenster, in dem der Browser dem Redirect bereits folgt, waehrend die
     * Session noch nicht persistiert ist -> der Folge-Request ist nicht eingeloggt. Versionssicher
     * (J4+ Session::close(), sonst PHPs session_write_close) und strikt best-effort.
     *
     * @return void
     */
    private static function commitSession($session)
    {
        try {
            if ($session && method_exists($session, 'close')) {
                $session->close();

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

        try {
            if (function_exists('session_write_close')) {
                @session_write_close();
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    /**
     * Das Joomla-SESSION-Cookie explizit mit der (neu vergebenen) ID setzen - Absicherung des
     * Cross-Request-Handoffs. Nach dem Neuvergeben der Session-ID (fork/regenerate) haengt es sonst
     * am Timing von Joomlas eigener Cookie-Auslieferung, ob der Redirect-Folge-Request schon die
     * NEUE ID traegt. Auf Joomla 6 (neuere Session-Schicht) ist das nicht zuverlaessig - Symptom
     * "erst beim 2. Versuch drin": der Landing-Request loggt korrekt ein, aber der Folge-Request
     * traegt noch die alte ID und sieht die Anmeldemaske. Wir setzen das Cookie darum selbst, mit
     * EXAKT denselben Parametern wie die aktive Session (session_get_cookie_params) -> es entsteht
     * kein zweites, konkurrierendes Cookie und kein Pfad-/Domain-Mismatch.
     *
     * Betrifft AUSSCHLIESSLICH das Session-Cookie. Der Backend-Guard-Pass (eigenes htp_sso-Cookie +
     * .htaccess-Mechanik) bleibt voellig unberuehrt. Rein additiv, best-effort, wirft nie. Wird nur
     * aufgerufen, wenn die ID wirklich neu vergeben wurde - ohne Neuvergabe kein Eingriff.
     *
     * @return void
     */
    private static function setSessionCookie($session, $sid)
    {
        try {
            $sid = (string) $sid;

            if ($sid === '' || headers_sent()) {
                return;
            }

            // Cookie-Name: Joomla-Session-API bevorzugen, sonst PHP-nativ.
            $name = '';

            if ($session && method_exists($session, 'getName')) {
                try {
                    $name = (string) $session->getName();
                } catch (Exception $e) {
                } catch (Throwable $e) {
                }
            }

            if ($name === '' && function_exists('session_name')) {
                $name = (string) @session_name();
            }

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

            // Parameter EXAKT wie die aktive Session -> kein konkurrierendes Zweit-Cookie.
            $p        = function_exists('session_get_cookie_params') ? (array) @session_get_cookie_params() : array();
            $path     = (isset($p['path']) && $p['path'] !== '') ? $p['path'] : '/';
            $domain   = isset($p['domain']) ? (string) $p['domain'] : '';
            $secure   = isset($p['secure']) ? (bool) $p['secure']
                        : (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== '' && strtolower((string) $_SERVER['HTTPS']) !== 'off');
            $httponly = isset($p['httponly']) ? (bool) $p['httponly'] : true;
            $samesite = (isset($p['samesite']) && $p['samesite'] !== '') ? (string) $p['samesite'] : 'Lax';

            // Array-Signatur ab PHP 7.3 (Connector-Minimum 7.4) -> SameSite sauber setzbar.
            @setcookie($name, $sid, array(
                'expires'  => 0,
                'path'     => $path,
                'domain'   => $domain,
                'secure'   => $secure,
                'httponly' => $httponly,
                'samesite' => $samesite,
            ));

            // Falls im selben Request noch etwas die Session neu oeffnet (Redirect/Plugin), soll es
            // die NEUE ID aus dem Request-Cookie lesen - nicht die alte.
            $_COOKIE[$name] = $sid;
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    /** Aktuelle Session-ID oder '' - nie werfend. */
    private static function sessionId($session)
    {
        try {
            if ($session && method_exists($session, 'getId')) {
                return (string) $session->getId();
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return '';
    }

    /**
     * Die Session-ID NEU VERGEBEN (Anti-Fixierung), versionssicher J2.5-6. `fork()` gibt es nicht
     * ueberall und verhaelt sich je nach Joomla-Generation anders - darum eine Kaskade, jede Stufe
     * einzeln gekapselt und jeweils vorher auf Verfuegbarkeit geprueft:
     *   1. $session->fork()                  (Joomlas eigener Weg, NUR ab Joomla 4)
     *   2. $session->regenerate(true)        (neuere Session-Bibliothek)
     *   3. session_regenerate_id(true)       (PHP direkt, nur bei aktiver Session)
     * Als Erfolg zaehlt NUR eine nachweislich GEAENDERTE ID. Greift keine Stufe, wird bewusst NICHT
     * abgebrochen - der Aufrufer laeuft wie bisher weiter und vermerkt es im Breadcrumb.
     *
     * WARUM fork() erst ab Joomla 4: auf 2.5/3 raeumt fork() die Sitzung intern komplett ab
     * (zerstoeren + neu starten). Bringt das auf einem bestimmten Stand keine neue ID, bliebe eine
     * halb abgeraeumte Sitzung zurueck und der Aufrufer glaubte faelschlich, es sei nichts passiert.
     * Auf diesen Generationen wird darum direkt der PHP-eigene, nicht zerstoerende Weg genutzt.
     *
     * @param   object  $session  Session-Objekt
     * @param   string  $oldSid   ID vor dem Neuvergeben
     *
     * @return boolean  true, wenn die ID nachweislich neu ist
     */
    private static function regenerateSessionId($session, $oldSid)
    {
        $oldSid = (string) $oldSid;

        if (!$session || $oldSid === '') {
            return false;
        }

        $j4plus = defined('JVERSION') && version_compare(JVERSION, '4.0', '>=');

        if ($j4plus && method_exists($session, 'fork')) {
            try {
                $session->fork();
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }

            $now = self::sessionId($session);

            if ($now !== '' && $now !== $oldSid) {
                return true;
            }
        }

        if (method_exists($session, 'regenerate')) {
            try {
                $session->regenerate(true);
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }

            $now = self::sessionId($session);

            if ($now !== '' && $now !== $oldSid) {
                return true;
            }
        }

        try {
            if (function_exists('session_regenerate_id')
                && (!function_exists('session_status') || session_status() === PHP_SESSION_ACTIVE)) {
                @session_regenerate_id(true);
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        $now = self::sessionId($session);

        if ($now === '' && function_exists('session_id')) {
            $now = (string) @session_id();
        }

        return ($now !== '' && $now !== $oldSid);
    }

    /**
     * Fuer eine NEU vergebene Session-ID den #__session-Datensatz anlegen, falls er fehlt.
     *
     * WARUM das noetig ist: Joomlas Datenbank-Sitzungsspeicher (Standard in J2.5-6) legt die Zeile
     * genau EINMAL sehr frueh im Request an; alles Weitere ist ein reines UPDATE. Nach dem
     * Neuvergeben der ID gibt es fuer die neue ID also keine Zeile - jedes Speichern traefe null
     * Zeilen und meldete trotzdem Erfolg. Der Folge-Request nach dem Redirect faende dann keine
     * Sitzung und der Betreiber saehe wieder die Anmeldemaske.
     *
     * Bewusst spaltenweise gegen das TATSAECHLICHE Schema gebaut (J2.5/J3 haben u. a. `usertype`,
     * `time` ist mal Zahl, mal Text) und strikt best-effort: schlaegt es fehl, aendert sich
     * gegenueber vorher nichts.
     *
     * @param   string  $sid   Neue Session-ID
     * @param   object  $app   Anwendung
     * @param   object  $user  Ziel-Nutzer
     *
     * @return void
     */
    private static function ensureSessionRow($sid, $app, $user)
    {
        try {
            $sid = (string) $sid;
            $db  = function_exists('htp_db') ? htp_db() : null;

            if (!$db || $sid === '' || !is_object($user)) {
                return;
            }

            // Existiert die Zeile schon (z. B. weil die Generation sie selbst anlegt), nichts tun.
            $q = $db->getQuery(true)
                ->select($db->quoteName('session_id'))
                ->from($db->quoteName('#__session'))
                ->where($db->quoteName('session_id') . ' = ' . $db->quote($sid));
            $db->setQuery($q, 0, 1);

            if ((string) $db->loadResult() !== '') {
                return;
            }

            $cols = array();

            if (method_exists($db, 'getTableColumns')) {
                $cols = $db->getTableColumns('#__session', true);
            }

            if (!is_array($cols) || empty($cols)) {
                return; // Schema unbekannt -> lieber gar nichts schreiben
            }

            $clientId = 1; // establishSession laeuft ausschliesslich im Administrator

            if (method_exists($app, 'getClientId')) {
                $cid = (int) $app->getClientId();

                if ($cid >= 0) {
                    $clientId = $cid;
                }
            }

            // Kandidaten-Werte; uebernommen wird nur, was die Tabelle wirklich hat.
            $vals = array(
                'session_id' => $db->quote($sid),
                'client_id'  => (string) $clientId,
                'guest'      => '0',
                'userid'     => (string) (int) $user->id,
                'username'   => $db->quote((string) $user->username),
                'usertype'   => $db->quote(''),
                'data'       => $db->quote(''),
            );

            // `time` ist je nach Generation Zahl (J4+) oder Text (J2.5/J3) - Typ aus dem Schema.
            if (isset($cols['time'])) {
                $isNum = (stripos((string) $cols['time'], 'int') !== false)
                    || (stripos((string) $cols['time'], 'numeric') !== false);
                $vals['time'] = $isNum ? (string) time() : $db->quote((string) time());
            }

            $names  = array();
            $values = array();

            foreach ($vals as $col => $val) {
                if (isset($cols[$col])) {
                    $names[]  = $db->quoteName($col);
                    $values[] = $val;
                }
            }

            if (empty($names) || !in_array($db->quoteName('session_id'), $names, true)) {
                return;
            }

            $q = $db->getQuery(true)
                ->insert($db->quoteName('#__session'))
                ->columns($names)
                ->values(implode(',', $values));
            $db->setQuery($q);
            $db->execute();
        } catch (Exception $e) {
            // best-effort: nie fatal
        } catch (Throwable $e) {
            // best-effort: nie fatal
        }
    }

    /**
     * Die vom Browser mitgebrachte Session-Kennung aus dem Session-Cookie - oder '', wenn keine
     * plausible Kennung vorliegt. Nur eine konservative Session-ID-Form wird akzeptiert (verhindert
     * Unsinn; der DB-Layer quotet zusaetzlich).
     *
     * @return string
     */
    private static function browserSessionId()
    {
        try {
            $cn = function_exists('session_name') ? (string) @session_name() : '';

            if ($cn !== '' && isset($_COOKIE[$cn])) {
                $bs = (string) $_COOKIE[$cn];

                if (preg_match('/^[A-Za-z0-9,\-]{20,160}$/', $bs)) {
                    return $bs;
                }
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return '';
    }

    /**
     * Die #__session-Zeile von $fromSid VOLLSTAENDIG (inkl. data-Nutzlast mit dem eingeloggten Nutzer)
     * auf $toSid schreiben, sodass ein Request unter $toSid dieselbe - eingeloggte - Sitzung sieht.
     * Kern des cookielosen Handoffs: die soeben eingeloggte (von Joomla neu gestartete) Sitzung wird
     * auf die vom Browser mitgebrachte Kennung dupliziert, damit der Folge-Request ohne Kennungs-
     * Uebergabe eingeloggt ist. Spaltenweise gegen das TATSAECHLICHE Schema gebaut (versionssicher).
     * Strikt best-effort, nie fatal.
     *
     * @param   string  $fromSid  Quell-Kennung (aktive, eingeloggte Sitzung)
     * @param   string  $toSid    Ziel-Kennung (die der Browser traegt)
     *
     * @return boolean
     */
    private static function copySessionRow($fromSid, $toSid)
    {
        try {
            $fromSid = (string) $fromSid;
            $toSid   = (string) $toSid;
            $db      = function_exists('htp_db') ? htp_db() : null;

            if (!$db || $fromSid === '' || $toSid === '' || $fromSid === $toSid) {
                return false;
            }

            $q = $db->getQuery(true)
                ->select('*')
                ->from($db->quoteName('#__session'))
                ->where($db->quoteName('session_id') . ' = ' . $db->quote($fromSid));
            $db->setQuery($q, 0, 1);
            $row = $db->loadAssoc();

            if (!is_array($row) || empty($row) || !array_key_exists('session_id', $row)) {
                return false;
            }

            $row['session_id'] = $toSid;

            // establishSession laeuft ausschliesslich im Administrator: die Kopie MUSS client_id=1
            // tragen. Eine im Ablauf-Fall neu gestartete Quell-Session kann anfangs eine leere
            // client_id haben - ungefixt akzeptierte die Admin-Konsole die kopierte, eingeloggte
            // Sitzung dann nicht (Anmeldemaske). Doppelt abgesichert zu markSessionLoggedIn.
            if (array_key_exists('client_id', $row)) {
                $row['client_id'] = '1';
            }

            // Etwaige Alt-Zeile der Zielkennung entfernen (war abgelaufen/ungueltig).
            $del = $db->getQuery(true)
                ->delete($db->quoteName('#__session'))
                ->where($db->quoteName('session_id') . ' = ' . $db->quote($toSid));
            $db->setQuery($del);
            $db->execute();

            $names  = array();
            $values = array();

            foreach ($row as $col => $val) {
                $names[]  = $db->quoteName((string) $col);
                $values[] = ($val === null) ? 'NULL' : $db->quote((string) $val);
            }

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

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

        return false;
    }

    /**
     * Den #__session-Datensatz einer ALTEN Session-ID entfernen - nach dem Neuvergeben der ID ist er
     * nur noch ein Rest, und solange er existiert, bliebe eine untergeschobene ID verwertbar. Rein
     * aufraeumend und strikt best-effort: schlaegt es fehl, bleibt das frische Login unberuehrt und
     * der Rest laeuft regulaer ab.
     *
     * @param   string  $sid  Alte Session-ID
     *
     * @return void
     */
    private static function dropSessionRow($sid)
    {
        try {
            $sid = (string) $sid;
            $db  = function_exists('htp_db') ? htp_db() : null;

            if (!$db || $sid === '') {
                return;
            }

            $q = $db->getQuery(true)
                ->delete($db->quoteName('#__session'))
                ->where($db->quoteName('session_id') . ' = ' . $db->quote($sid));
            $db->setQuery($q);
            $db->execute();
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    /** Nutzer laden (J4+ Factory, sonst JFactory). */
    private static function loadUser($uid)
    {
        try {
            if (class_exists('Joomla\\CMS\\Factory')) {
                return Joomla\CMS\Factory::getUser((int) $uid);
            }

            if (class_exists('JFactory')) {
                return JFactory::getUser((int) $uid);
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return null;
    }

    /**
     * Die #__session-Metadaten der AKTUELLEN Session sofort auf "eingeloggt" setzen - genau wie es
     * Joomlas echtes Login nach erfolgreicher Anmeldung tut. So ist der Sitzungszustand konsistent
     * (guest=0, userid, username), ohne auf das checkSession des naechsten Requests zu warten.
     * Rein additiv + best-effort: schlaegt es fehl, traegt die Session-Nutzlast (data-Blob) die
     * Identitaet trotzdem, das checkSession des Folge-Requests zieht die Spalten dann nach.
     *
     * @return void
     */
    private static function markSessionLoggedIn($session, $app, $user, $sid)
    {
        try {
            $sid = (string) $sid;

            if ($sid === '' && $session && method_exists($session, 'getId')) {
                $sid = (string) $session->getId();
            }

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

            if (!$db || $sid === '' || !is_object($user)) {
                return;
            }

            // client_id MUSS auf "Administrator" (1) gesetzt werden: establishSession laeuft
            // ausschliesslich im Backend. Startet Joomla die Session im Ablauf-Fall NEU, legt es die
            // Zeile blitzfrueh mit LEERER client_id an - und der SSO-Redirect beendet den Request, bevor
            // Joomla sie auf 1 nachzieht. Ohne dieses explizite Setzen bliebe client_id NULL, und die
            // Admin-Konsole akzeptierte die Sitzung trotz guest=0 NICHT als eingeloggt (Anmeldemaske).
            $q = $db->getQuery(true)
                ->update($db->quoteName('#__session'))
                ->set($db->quoteName('guest') . ' = 0')
                ->set($db->quoteName('client_id') . ' = 1')
                ->set($db->quoteName('userid') . ' = ' . (int) $user->id)
                ->set($db->quoteName('username') . ' = ' . $db->quote((string) $user->username))
                ->where($db->quoteName('session_id') . ' = ' . $db->quote($sid));
            $db->setQuery($q);
            $db->execute();
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    /**
     * Kleiner, geschuetzter Breadcrumb des letzten 1-Klick-Login-Ergebnisses (data/, 0600). Dient
     * dem Support ("warum ging mein 1-Klick-Login nicht?") und der Ferndiagnose. Bewusst winzig,
     * ueberschreibt sich selbst, nie fatal.
     *
     * @return void
     */
    private static function breadcrumb($data)
    {
        try {
            $dir = function_exists('htp_state_dir') ? htp_state_dir() : '';

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

            $data['ts'] = time();
            $file = rtrim(str_replace('\\', '/', $dir), '/') . '/htprotect_sso_last.json';
            @file_put_contents($file, json_encode($data), LOCK_EX);
            @chmod($file, 0600);
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    /** Aktuell eingeloggte User-ID (J4+ getIdentity, sonst Factory/JFactory getUser). */
    private static function currentUserId($app)
    {
        try {
            if ($app && method_exists($app, 'getIdentity')) {
                $u = $app->getIdentity();

                if ($u) {
                    return (int) $u->id;
                }
            }

            if (class_exists('Joomla\\CMS\\Factory')) {
                $u = Joomla\CMS\Factory::getUser();

                if ($u) {
                    return (int) $u->id;
                }
            }

            if (class_exists('JFactory')) {
                $u = JFactory::getUser();

                if ($u) {
                    return (int) $u->id;
                }
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        return 0;
    }

    /** onUserAfterLogin feuern (Plugin-Konsistenz/Protokoll), best-effort. */
    private static function fireLogin($app, $user)
    {
        try {
            if (class_exists('Joomla\\CMS\\Plugin\\PluginHelper')) {
                Joomla\CMS\Plugin\PluginHelper::importPlugin('user');
            } elseif (class_exists('JPluginHelper')) {
                JPluginHelper::importPlugin('user');
            }

            if (!method_exists($app, 'triggerEvent')) {
                return;
            }

            // Das Event FLACH und mit dem User-OBJEKT feuern - exakt wie Joomlas eigenes Login.
            // Das Action-Log-Plugin (und andere User-Plugins) lesen $options['user']->id/->username
            // und $options['action'] DIREKT; die frueher verschachtelte Form (['user'=>array,
            // 'options'=>...]) liess sie auf ein Array zugreifen -> "Attempt to read property id on
            // array" + "Undefined array key action" bei JEDEM SSO-Login (durch den alten Server-
            // Redirect nur unsichtbar). Objekt + flach = warnfrei ueber alle Generationen
            // (J3.9-Action-Log bis J6).
            $opt = array(
                'action'       => 'core.login.admin',
                'responseType' => 'htprotect-sso',
                'user'         => $user,
                'remember'     => false,
            );
            $app->triggerEvent('onUserAfterLogin', array($opt));
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }

    // ═════════════════════════════════════════════════════════════════
    //  Guard-Pass: den Basic-Auth-Schutz von /administrator fuer EINE Session passieren lassen
    //
    //  SICHERHEITS-GRUNDSATZ (nach einem Aussperr-Vorfall verschaerft): dieser Code darf NIEMALS
    //  den Zugang zum Backend verlieren lassen. Ein `Require env htp_sso_ok` ist naemlich nur dann
    //  eine ODER-Alternative, wenn im GLEICHEN Kontext ein `Require valid-user` steht - sonst ist es
    //  die EINZIGE Regel und sperrt ALLE aus (Deny-all). Darum dreifach abgesichert:
    //    1) HARTES STRUKTUR-GATE: geschrieben wird NUR, wenn die administrator/.htaccess exakt
    //       HTProtects eigenen Guard-Block enthaelt (beide Marker + darin ein `Require valid-user`).
    //       Liegt der Passwortschutz woanders (nginx/Plesk, fremder Block) oder gar nicht vor,
    //       passiert NICHTS - der Operator gibt dann einmal das Passwort ein. Kein Risiko.
    //    2) NACH-PRUEFUNG + ROLLBACK: nach dem Schreiben wird frisch vom Datentraeger gelesen und
    //       geprueft (Guard unversehrt, Container ausbalanciert). Faellt etwas durch, wird der
    //       Originalinhalt sofort zurueckgeschrieben.
    //    3) AUFRAEUMEN GEHT IMMER: Entfernen unterliegt KEINEM Gate - es kann den Zugang nur
    //       wiederherstellen, nie nehmen. Der Waechter fegt bei jedem Tick.
    // ═════════════════════════════════════════════════════════════════

    /** Cookie-Name des Guard-Passes (Session-Cookie, HttpOnly/Secure/SameSite=Lax, /administrator). */
    const SSO_COOKIE = 'htp_sso';

    /** Rueckfall-Lebensdauer eines Passes. Regulaer entfernt ihn der Logout bzw. der naechste
     *  Waechter-Tick (Session weg); diese TTL ist nur das Sicherheitsnetz, falls beides ausbleibt. */
    const GUARD_TTL = 3600; // 1 Stunde

    /** Obergrenze gleichzeitiger Paesse (mehrere Team-Mitglieder). Verhindert jedes Anwachsen. */
    const GUARD_MAX = 5;

    /** Pfad der guard-.htaccess (administrator/.htaccess) oder ''. */
    private static function guardHtaccessPath()
    {
        $admin = function_exists('htp_admin_root') ? htp_admin_root() : '';

        return $admin === '' ? '' : rtrim(str_replace('\\', '/', $admin), '/') . '/.htaccess';
    }

    /**
     * DAS ENTSCHEIDENDE GATE: Enthaelt der Inhalt HTProtects eigenen Guard-Block MIT einem
     * `Require valid-user` darin? Nur dann darf ein Pass gesetzt werden (nur dann wirkt Apaches
     * implizites <RequireAny>). Alles andere -> false -> es wird nichts geschrieben.
     *
     * @return boolean
     */
    private static function guardBlockOk($content)
    {
        if (!class_exists('HtpGuard') || !is_string($content) || $content === '') {
            return false;
        }

        $from = strpos($content, HtpGuard::MARK_BEGIN);

        if ($from === false) {
            return false;
        }

        $to = strpos($content, HtpGuard::MARK_END, $from);

        if ($to === false) {
            return false;
        }

        // `Require valid-user` MUSS im Guard-Block auf oberster Ebene stehen (nicht in <Files...>,
        // sonst gilt es nur dort und unser Pass waere wieder die einzige globale Regel).
        $block = substr($content, $from, $to - $from);
        $block = preg_replace('~<Files[^>]*>.*?</Files[^>]*>~is', '', $block);

        return (bool) preg_match('~^[ \t]*Require[ \t]+valid-user[ \t]*$~mi', (string) $block);
    }

    /** Ist der Inhalt nach einer Aenderung unversehrt? (Guard vorhanden + Container ausbalanciert) */
    private static function guardIntact($content)
    {
        if (!self::guardBlockOk($content)) {
            return false;
        }

        return substr_count($content, '<IfModule') === substr_count($content, '</IfModule>');
    }

    /** Ein markierter Pass-Block. Der Marker traegt Token, Zeitstempel und Session-ID. */
    private static function guardPassBlock($gtoken, $ts, $sid)
    {
        return '# HTP-SSO ' . $gtoken . ' ' . (int) $ts . ' ' . ($sid !== '' ? $sid : '-') . "\n"
            . "<IfModule mod_setenvif.c>\n"
            . 'SetEnvIf Cookie "' . self::SSO_COOKIE . '=' . $gtoken . '" htp_sso_ok=1' . "\n"
            . "</IfModule>\n"
            . "<IfModule mod_authz_core.c>\n"
            . "Require env htp_sso_ok\n"
            . "</IfModule>\n"
            . '# /HTP-SSO ' . $gtoken . "\n";
    }

    /**
     * Alle Pass-Bloecke entfernen, ausser denen, fuer die $keepCb(token, ts, sid) true liefert.
     * Erfasst BEWUSST auch Alt-Bloecke ohne Session-ID (aus der fehlerhaften Vorfassung).
     */
    public static function stripSsoBlocks($content, $keepCb)
    {
        return (string) preg_replace_callback(
            '~\n*# HTP-SSO ([a-f0-9]{16,96}) (\d+)(?:[ \t]+(\S{1,128}))?\n.*?# /HTP-SSO \1\n~s',
            function ($m) use ($keepCb) {
                $sid = isset($m[3]) ? (string) $m[3] : '';
                $sid = ($sid === '-') ? '' : $sid;

                return ($keepCb && $keepCb($m[1], (int) $m[2], $sid)) ? $m[0] : "\n";
            },
            (string) $content
        );
    }

    /** Existiert diese Joomla-Session noch? Ohne DB-Antwort TRUE (dann raeumt die TTL auf). */
    public static function sessionAlive($sid)
    {
        if ($sid === '') {
            return false; // Alt-Block ohne Bindung -> nicht behalten
        }

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

            if (!$db) {
                return true;
            }

            $q = $db->getQuery(true)
                ->select('COUNT(*)')
                ->from($db->quoteName('#__session'))
                ->where($db->quoteName('session_id') . ' = ' . $db->quote($sid));
            $db->setQuery($q);

            return ((int) $db->loadResult()) > 0;
        } catch (Exception $e) {
            return true;
        } catch (Throwable $e) {
            return true;
        }
    }

    /** ID der aktuellen Joomla-Session (Bindung des Passes) oder ''. */
    private static function currentSessionId()
    {
        try {
            $s = self::session(self::app());

            return ($s && method_exists($s, 'getId')) ? (string) $s->getId() : '';
        } catch (Exception $e) {
            return '';
        } catch (Throwable $e) {
            return '';
        }
    }

    /**
     * Gelockter read-modify-write der guard-.htaccess.
     *
     * @param   callable  $cb      erhaelt den Inhalt, liefert den neuen
     * @param   boolean   $adding  true = Pass HINZUFUEGEN (strenges Gate + Nach-Pruefung + Rollback);
     *                             false = ENTFERNEN (kein Gate - Aufraeumen ist immer erlaubt)
     *
     * @return boolean  true, wenn geschrieben wurde
     */
    private static function withGuardHtaccess($cb, $adding)
    {
        $path = self::guardHtaccessPath();

        if ($path === '' || !is_file($path)) {
            return false;
        }

        $fp = @fopen($path, 'c+');

        if (!$fp) {
            return false;
        }

        $wrote = false;

        try {
            @flock($fp, LOCK_EX);
            $orig = (string) stream_get_contents($fp);

            // Hinzufuegen NUR bei exakt passender HTProtect-Struktur.
            if ($adding && !self::guardBlockOk($orig)) {
                @flock($fp, LOCK_UN);
                @fclose($fp);

                return false;
            }

            $new = (string) $cb($orig);

            // Beim Hinzufuegen MUSS das Ergebnis unversehrt sein; beim Entfernen genuegt, dass
            // ueberhaupt etwas uebrig bleibt (wir kuerzen ja nur eigene Bloecke heraus).
            $sane = $adding ? self::guardIntact($new) : ($new !== '');

            if ($new !== $orig && $sane) {
                @ftruncate($fp, 0);
                @rewind($fp);
                @fwrite($fp, $new);
                @fflush($fp);

                // NACH-PRUEFUNG frisch vom Datentraeger - bei jedem Zweifel ROLLBACK.
                @rewind($fp);
                $check = (string) stream_get_contents($fp);

                if ($check !== $new || ($adding && !self::guardIntact($check))) {
                    @ftruncate($fp, 0);
                    @rewind($fp);
                    @fwrite($fp, $orig);
                    @fflush($fp);
                } else {
                    $wrote = true;
                }
            }

            @flock($fp, LOCK_UN);
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        @fclose($fp);

        if ($wrote) {
            @chmod($path, 0644);
        }

        return $wrote;
    }

    /**
     * Session-Guard-Pass OEFFNEN. Liefert das Cookie-Token - oder '', wenn KEIN Pass gesetzt wurde
     * (kein HTProtect-Guard in der Datei, Schutz auf Server-Ebene, Fehler ...). '' ist der SICHERE
     * Normalfall: der Operator gibt dann einfach einmal das Passwort ein.
     *
     * @return string
     */
    public static function openGuardPass()
    {
        $gtoken = self::randToken(24);

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

        $now = time();
        $ttl = self::GUARD_TTL;
        $max = self::GUARD_MAX;
        $sid = self::currentSessionId();

        $wrote = self::withGuardHtaccess(function ($content) use ($gtoken, $now, $ttl, $max, $sid) {
            // Abgelaufene/verwaiste Paesse entsorgen und die Zahl deckeln (nie anwachsen lassen).
            $kept = 0;

            $pruned = HtpSso::stripSsoBlocks($content, function ($tok, $ts, $bsid) use ($now, $ttl, $max, &$kept) {
                if (($now - $ts) >= $ttl || $kept >= ($max - 1) || !HtpSso::sessionAlive($bsid)) {
                    return false;
                }

                $kept++;

                return true;
            });

            return rtrim($pruned, "\n") . "\n\n" . HtpSso::guardPassBlockPublic($gtoken, $now, $sid);
        }, true);

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

        self::rebaselineGuard();

        // GEGEN DIE RACE: der unmittelbar folgende Request wird von einem ANDEREN Prozess/Worker
        // bedient - Apache muss den Block dann bereits lesen koennen. Statt blind zu warten wird
        // die Sichtbarkeit aktiv geprueft (mit frischem stat-Cache); im Normalfall ist das nach
        // 0 ms erfuellt, es kostet also nichts. Bleibt der Block unsichtbar, wird er wieder
        // entfernt und KEIN Pass gemeldet -> der Operator sieht die normale Passwortabfrage
        // statt einer halb angewandten Regel.
        if (!self::guardPassVisible($gtoken)) {
            self::closeGuardPassByToken($gtoken);

            return '';
        }

        return $gtoken;
    }

    /**
     * Ist der geschriebene Pass-Block wirklich vom Datentraeger lesbar? Kurz gepollt (50-ms-Schritte)
     * statt pauschal gewartet - deckt Dateisystem-/Worker-Verzoegerungen ab, ohne den Normalfall
     * auszubremsen. Danach eine kleine Setzzeit als Puffer.
     *
     * @param   string  $gtoken  Token, das im Block stehen muss
     * @param   int     $maxMs   Obergrenze der Wartezeit in Millisekunden
     *
     * @return boolean
     */
    private static function guardPassVisible($gtoken, $maxMs = 1000)
    {
        $path = self::guardHtaccessPath();

        if ($path === '' || $gtoken === '') {
            return false;
        }

        for ($waited = 0; $waited <= $maxMs; $waited += 50) {
            if (function_exists('clearstatcache')) {
                @clearstatcache(true, $path);
            }

            if (strpos((string) @file_get_contents($path), $gtoken) !== false) {
                @usleep(250000); // 250 ms Setzzeit - unmerklich, faengt Rest-Jitter ab

                return true;
            }

            @usleep(50000); // 50 ms
        }

        return false;
    }

    /** Public-Bruecke fuer die Closure in openGuardPass. */
    public static function guardPassBlockPublic($gtoken, $ts, $sid)
    {
        return self::guardPassBlock($gtoken, $ts, $sid);
    }

    /** Einen bestimmten Pass entfernen (Logout). Entfernen unterliegt keinem Gate. */
    public static function closeGuardPassByToken($gtoken)
    {
        $gtoken = (string) $gtoken;

        if (!preg_match('~^[a-f0-9]{16,96}$~', $gtoken)) {
            return;
        }

        $wrote = self::withGuardHtaccess(function ($content) use ($gtoken) {
            return HtpSso::stripSsoBlocks($content, function ($tok, $ts, $sid) use ($gtoken) {
                return $tok !== $gtoken;
            });
        }, false);

        if ($wrote) {
            self::rebaselineGuard();
        }
    }

    /**
     * Waechter-Aufraeumen: Paesse entfernen, deren Joomla-Session nicht mehr existiert (Logout /
     * Ablauf) - spaetestens nach GUARD_TTL. Billiges Gate: nur wenn ueberhaupt ein Block drinsteht.
     *
     * @return void
     */
    public static function sweepGuardPasses()
    {
        $path = self::guardHtaccessPath();

        if ($path === '' || !is_file($path)) {
            return;
        }

        $head = (string) @file_get_contents($path);

        if (strpos($head, '# HTP-SSO ') === false) {
            return; // nichts zu tun -> 0 Kosten
        }

        $now = time();
        $ttl = self::GUARD_TTL;

        $wrote = self::withGuardHtaccess(function ($content) use ($now, $ttl) {
            return HtpSso::stripSsoBlocks($content, function ($tok, $ts, $sid) use ($now, $ttl) {
                if (($now - $ts) >= $ttl) {
                    return false; // Sicherheitsnetz
                }

                return HtpSso::sessionAlive($sid); // Session weg -> Pass weg
            });
        }, false);

        if ($wrote) {
            self::rebaselineGuard();
        }
    }

    /**
     * Integritaets-Baseline der administrator/.htaccess nach eigenem Schreiben nachziehen, damit der
     * Waechter die selbst verursachte Aenderung nicht als Fremdaenderung meldet (keine Warn-Mail).
     */
    private static function rebaselineGuard()
    {
        try {
            if (class_exists('HtpWatch') && class_exists('HtpConfig')) {
                $cfg = HtpConfig::load();
                HtpWatch::remember($cfg);

                // ZWINGEND saveKeys (NICHT save): save() ERSETZT htp_shield komplett durch das
                // uebergebene Array und wuerde die gesamte uebrige Konfiguration ausloeschen.
                HtpConfig::saveKeys(array('watch_manifest' => isset($cfg['watch_manifest']) ? $cfg['watch_manifest'] : array()));
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
    }
}
