<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Versionsneutrale Helfer: ein Satz Funktionen, der von Joomla 2.5
 * (PHP 5.3) bis Joomla 6 (PHP 8.x) identisch funktioniert.
 */

defined('_JEXEC') or die;

// ── Legacy-Aliase nachziehen, falls Joomla 5/6 sie nicht mehr stellt ──
if (!class_exists('JFactory') && class_exists('Joomla\\CMS\\Factory')) {
    class_alias('Joomla\\CMS\\Factory', 'JFactory');
}
if (!class_exists('JUri') && class_exists('Joomla\\CMS\\Uri\\Uri')) {
    class_alias('Joomla\\CMS\\Uri\\Uri', 'JUri');
}

/**
 * Sprachdatei der Komponente laden (versionsneutral, nur einmal). Vom Backend-
 * Einstieg und vom Hintergrund-Plugin aufzurufen, damit htp_t() in jedem
 * Kontext uebersetzte Strings liefert. Faellt lautlos aus, wenn kein
 * Sprachsystem verfuegbar ist (dann liefert htp_t() den Schluessel/Default).
 *
 * @return void
 */
function htp_load_lang()
{
    static $done = false;

    if ($done) {
        return;
    }

    $done = true;

    try {
        $lang = null;

        if (class_exists('Joomla\\CMS\\Factory')) {
            $factory = 'Joomla\\CMS\\Factory';

            if (method_exists($factory, 'getApplication')) {
                $app = $factory::getApplication();

                if ($app && method_exists($app, 'getLanguage')) {
                    $lang = $app->getLanguage();
                }
            }

            if (!$lang && method_exists($factory, 'getLanguage')) {
                $lang = $factory::getLanguage();
            }
        } elseif (class_exists('JFactory')) {
            $lang = JFactory::getLanguage();
        }

        if ($lang && method_exists($lang, 'load')) {
            $base = defined('JPATH_ADMINISTRATOR')
                ? JPATH_ADMINISTRATOR
                : (defined('JPATH_ROOT') ? JPATH_ROOT . '/administrator' : '');

            if ($base !== '') {
                // Jede deutsche Sprachvariante (de-AT, de-CH, de-LI ...) bekommt die
                // deutschen Texte - nicht nur das Original de-DE. Ohne das faellt z. B.
                // de-AT mangels eigener de-AT-.ini auf en-GB zurueck. Also bei Deutsch
                // explizit de-DE laden, sonst die aktuelle Sprache. 4. Arg true = frisch
                // laden; 5. Arg true = en-GB als Rueckfall (fuellt evtl. fehlende Keys).
                // Deutsch: jede de-*-Variante bekommt die deutschen Texte (de-DE). Sonst: gibt es fuer die
                // aktive Sprache ein HTProtect-Sprachpaket (kanonischer Tag, ggf. Varianten-Rueckfall wie
                // es-MX->es-ES), dieses laden; andernfalls die aktive Sprache mit en-GB-Fallback (5. Arg true).
                $active = method_exists($lang, 'getTag') ? (string) $lang->getTag() : '';

                if (strtolower(substr($active, 0, 2)) === 'de') {
                    $load = 'de-DE';
                } else {
                    $canon = htp_langpack_for($active);
                    $load  = ($canon !== null) ? $canon : null;
                }

                $lang->load('com_htprotect', $base, $load, true, true);
            }
        }
    } catch (Exception $e) {
        // ohne Sprachsystem faellt htp_t() auf den Schluessel zurueck
    } catch (Throwable $e) {
        // still (PHP 7+)
    }
}

/**
 * Aktuelles UI-Sprachkuerzel: 'de' oder 'en' (Default 'en'). HTProtect kennt
 * nur diese beiden Sprachen; alles andere faellt auf 'en' (Master). Dient der
 * zweisprachigen Auswahl der Defs-Texte (HtpDefs::localized).
 *
 * @return string
 */
function htp_lang()
{
    // Waehrend des Mail-Baus zwingt HtpMailLang die Sprache passend zur gewaehlten
    // Mailsprache. So folgen auch htp_lang()-gesteuerte Texte (z. B. der Advisory-Link
    // via HtpDefs::localized) DERSELBEN Sprache wie der restliche Mailtext - sonst gaebe
    // es im Cron-/CLI-Kontext (aktiv oft en) einen Mix. Uebersteuert den Aktiv-Cache.
    if (class_exists('HtpMailLang', false)) {
        $forced = HtpMailLang::langCode();

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

    static $code = null;

    if ($code !== null) {
        return $code;
    }

    $tag = '';

    try {
        $lang = null;

        if (class_exists('Joomla\\CMS\\Factory')) {
            $factory = 'Joomla\\CMS\\Factory';

            if (method_exists($factory, 'getApplication')) {
                $app = $factory::getApplication();

                if ($app && method_exists($app, 'getLanguage')) {
                    $lang = $app->getLanguage();
                }
            }

            if (!$lang && method_exists($factory, 'getLanguage')) {
                $lang = $factory::getLanguage();
            }
        } elseif (class_exists('JFactory')) {
            $lang = JFactory::getLanguage();
        }

        if ($lang && method_exists($lang, 'getTag')) {
            $tag = (string) $lang->getTag();
        }
    } catch (Exception $e) {
        $tag = '';
    } catch (Throwable $e) {
        $tag = '';
    }

    $code = (strtolower(substr($tag, 0, 2)) === 'de') ? 'de' : 'en';

    return $code;
}

/**
 * Zu einem Backend-Sprach-Tag den passenden HTProtect-Sprachpaket-Tag bestimmen (2.6.4).
 * Deutsch + Englisch (alle Varianten) sind im Kern enthalten -> null. Regionale Varianten fallen auf die
 * kanonische Sprache zurueck (fr-CA->fr-FR, es-MX->es-ES ...), AUSSER pt (pt-PT/pt-BR sind getrennte
 * Pakete) und zh (kein Paket). EINE Quelle der Wahrheit fuer htp_load_lang (welche .ini laden) UND den
 * Overview-Hinweis/1-Klick-Install (welches Paket).
 *
 * @param   string  $tag  Joomla-Sprach-Tag (z. B. "es-ES")
 * @return  string|null   kanonischer Paket-Tag oder null (abgedeckt bzw. kein Paket)
 */
function htp_langpack_for($tag)
{
    $tag = trim((string) $tag);

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

    $pre = strtolower(substr($tag, 0, 2));

    if ($pre === 'de' || $pre === 'en') {
        return null; // im Kern-Paket enthalten
    }

    // Kanonische Sprachpakete (Tier 1 + Tier 2). Werte irrelevant - Praesenz zaehlt.
    static $packs = array(
        'es-ES' => 1, 'ca-ES' => 1, 'fr-FR' => 1, 'it-IT' => 1, 'pt-PT' => 1, 'pt-BR' => 1,
        'nl-NL' => 1, 'pl-PL' => 1, 'cs-CZ' => 1, 'sk-SK' => 1, 'hu-HU' => 1, 'ro-RO' => 1,
        'el-GR' => 1, 'ru-RU' => 1, 'uk-UA' => 1, 'tr-TR' => 1, 'sv-SE' => 1, 'da-DK' => 1,
        'nb-NO' => 1, 'fi-FI' => 1, 'hr-HR' => 1, 'sl-SI' => 1, 'bg-BG' => 1, 'sr-RS' => 1,
        'et-EE' => 1, 'lv-LV' => 1, 'lt-LT' => 1,
    );
    // Explizite Varianten-Aliase (gleiche Sprache, anderer Tag).
    static $alias = array('fr-CA' => 'fr-FR', 'nl-BE' => 'nl-NL', 'sr-YU' => 'sr-RS');

    if (isset($packs[$tag])) {
        return $tag;
    }

    if (isset($alias[$tag])) {
        return $alias[$tag];
    }

    // Praefix-Rueckfall fuer Ein-Varianten-Sprachen (z. B. es-419 -> es-ES). pt/zh BEWUSST ausgenommen.
    if ($pre !== 'pt' && $pre !== 'zh') {
        foreach ($packs as $ptag => $one) {
            if (strtolower(substr($ptag, 0, 2)) === $pre) {
                return $ptag;
            }
        }
    }

    return null;
}

/**
 * Uebersetzten String liefern (versionsneutral). Joomla 4-6:
 * Joomla\CMS\Language\Text; Joomla 2.5-3: JText; ohne CMS (Tests): der
 * Schluessel unveraendert. Weitere Argumente werden wie bei Text::sprintf()
 * in Platzhalter (%s, %d ...) eingesetzt.
 *
 * @param   string  $key  Sprachschluessel (COM_HTPROTECT_...)
 *
 * @return string
 */
function htp_t($key)
{
    $args = func_get_args();

    if (class_exists('Joomla\\CMS\\Language\\Text')) {
        $text = 'Joomla\\CMS\\Language\\Text';

        if (count($args) > 1) {
            return htp_qq(call_user_func_array(array($text, 'sprintf'), $args));
        }

        return htp_qq($text::_($key));
    }

    if (class_exists('JText')) {
        if (count($args) > 1) {
            return htp_qq(call_user_func_array(array('JText', 'sprintf'), $args));
        }

        return htp_qq(JText::_($key));
    }

    // Fallback ohne CMS (z. B. Testharness): Schluessel zurueckgeben.
    return $key;
}

/**
 * Joomlas INI-Platzhalter "_QQ_" (= ein doppeltes Anfuehrungszeichen)
 * dekodieren. Je nach Joomla- bzw. INI-Parser-Variante liefert Text::_()
 * den Platzhalter unveraendert zurueck - dann stuende er woertlich in den
 * erzeugten .htaccess-/nginx-Kommentaren (z. B. "_QQ_"HTProtect"_QQ_").
 * Defensiv hier aufloesen; bei bereits korrekt dekodierten Strings ein No-Op.
 *
 * @param   string  $text  Uebersetzter Text
 *
 * @return string
 */
function htp_qq($text)
{
    if (!is_string($text) || strpos($text, '_QQ_') === false) {
        return (string) $text;
    }

    return str_replace(array('"_QQ_"', '_QQ_'), '"', $text);
}

/**
 * Uebersetzung-oder-Fallback fuer generierte Datei-Kommentare (.htaccess/nginx)
 * und datengetriebene Texte. Liefert die Uebersetzung zum Schluessel, sonst den
 * mitgegebenen Rohtext (deutscher Originaltext). So bleibt der Output auch ohne
 * geladenes Sprachsystem (Tests) lesbar und in der jeweiligen UI-Sprache, ohne
 * dass rohe Schluessel in einer Datei landen koennen.
 *
 * @param   string  $key       Sprachschluessel (COM_HTPROTECT_...)
 * @param   string  $fallback  Rohtext, falls keine Uebersetzung vorliegt
 *
 * @return string
 */
function htp_tc($key, $fallback)
{
    $t = htp_t($key);

    return $t === $key ? (string) $fallback : $t;
}

/**
 * Uebersetzung fuer ATTRIBUT-/Klartext-Kontexte (placeholder, title, aria-label,
 * value): liefert echten UTF-8-Text mit zu echten Zeichen aufgeloesten HTML-
 * Entities. Hintergrund: die UI-Sprachstrings nutzen HTML-Entities (&szlig;,
 * &uuml;, &hellip; ...) fuer den HTML-Fliesstext. In einem Attribut (z. B.
 * placeholder="...") werden Entities aber NICHT vom Browser dekodiert -> dort
 * stuende sonst woertlich "Gru&szlig;" statt "Gruß". Diese Funktion loest die
 * Entities auf; das Ergebnis ist anschliessend noch mit htp_esc() in das Attribut
 * einzusetzen (htmlspecialchars kodiert nur & < > " ' - die Umlaute bleiben echt).
 *
 * @param   string  $key  Sprachschluessel (COM_HTPROTECT_...)
 *
 * @return string  Klartext (echtes UTF-8), entity-frei
 */
function htp_t_attr($key)
{
    $flags = ENT_QUOTES | (defined('ENT_HTML5') ? ENT_HTML5 : 0);

    return html_entity_decode(htp_t($key), $flags, 'UTF-8');
}

/**
 * Aktuelle Joomla-Anwendung (Backend).
 *
 * @return object|null
 */
function htp_app()
{
    try {
        if (class_exists('JFactory')) {
            return JFactory::getApplication();
        }
    } catch (Exception $e) {
        // faellt unten auf null zurueck
    }

    return null;
}

/**
 * Angemeldeter Benutzer - bevorzugt ueber die Anwendung (J4+),
 * sonst klassisch (J2.5/3).
 *
 * @return object|null
 */
function htp_user()
{
    $app = htp_app();

    if ($app && method_exists($app, 'getIdentity')) {
        $identity = $app->getIdentity();

        if ($identity) {
            return $identity;
        }
    }

    if (class_exists('JFactory') && method_exists('JFactory', 'getUser')) {
        try {
            return JFactory::getUser();
        } catch (Exception $e) {
            // ignorieren
        }
    }

    return null;
}

/**
 * Datenbank-Objekt - in J4+ aus dem Container, sonst klassisch.
 *
 * @return object|null
 */
function htp_db()
{
    if (class_exists('JFactory') && method_exists('JFactory', 'getContainer')) {
        try {
            $container = JFactory::getContainer();

            if ($container && $container->has('Joomla\\Database\\DatabaseInterface')) {
                return $container->get('Joomla\\Database\\DatabaseInterface');
            }
        } catch (Exception $e) {
            // weiter unten probieren
        }
    }

    if (class_exists('JFactory') && method_exists('JFactory', 'getDbo')) {
        try {
            return JFactory::getDbo();
        } catch (Exception $e) {
            // ignorieren
        }
    }

    return null;
}

/**
 * Joomla-Stammverzeichnis (Site-Root) mit Forward-Slashes.
 *
 * @return string
 */
function htp_root()
{
    if (defined('JPATH_ROOT')) {
        $root = JPATH_ROOT;
    } elseif (defined('JPATH_ADMINISTRATOR')) {
        $root = dirname(JPATH_ADMINISTRATOR);
    } else {
        $root = dirname(dirname(dirname(dirname(__DIR__))));
    }

    return rtrim(str_replace('\\', '/', $root), '/');
}

/**
 * Oeffentlicher Webroot (Document-Root) mit Forward-Slashes.
 *
 * Ab Joomla 5.1 kann die Auslieferung aus einem Unterordner ("public folder")
 * erfolgen: dann ist JPATH_PUBLIC der ausgelieferte Docroot (z. B. .../public),
 * waehrend JPATH_ROOT auf das App-Stammverzeichnis darueber zeigt. Fuer alles,
 * was im AUSGELIEFERTEN Verzeichnis liegt (Haupt-.htaccess, Upload-Ordner-
 * Haertung, Datei-Scans), ist DIESER Pfad massgeblich. In klassischen Installs
 * ist JPATH_PUBLIC nicht definiert bzw. identisch mit JPATH_ROOT -> dann
 * verhaelt sich diese Funktion exakt wie htp_root() (keine Aenderung).
 *
 * @return string
 */
function htp_docroot()
{
    if (defined('JPATH_PUBLIC') && JPATH_PUBLIC) {
        return rtrim(str_replace('\\', '/', JPATH_PUBLIC), '/');
    }

    return htp_root();
}

/**
 * /administrator-Verzeichnis mit Forward-Slashes.
 *
 * @return string
 */
function htp_admin_root()
{
    $admin = defined('JPATH_ADMINISTRATOR') ? JPATH_ADMINISTRATOR : htp_root() . '/administrator';

    return rtrim(str_replace('\\', '/', $admin), '/');
}

/**
 * Laeuft diese Installation im "Public Folder"-Modus (Joomla 5.1+)? Genau dann, wenn
 * JPATH_PUBLIC definiert ist UND vom App-Stamm (JPATH_ROOT) abweicht. In klassischen
 * Installs ist JPATH_PUBLIC nicht definiert bzw. identisch mit JPATH_ROOT -> false, und
 * saemtliche darauf gegateten Sonderpfade verhalten sich EXAKT wie bisher (keine Side-Effects).
 *
 * @return boolean
 */
function htp_is_public_folder()
{
    if (!defined('JPATH_PUBLIC') || !JPATH_PUBLIC) {
        return false;
    }

    $pub  = rtrim(str_replace('\\', '/', JPATH_PUBLIC), '/');
    $root = htp_root();

    return $pub !== '' && $pub !== $root;
}

/**
 * Das WEB-AUSGELIEFERTE /administrator-Verzeichnis (die URL, die der Browser unter
 * <site>/administrator/ erreicht). Im Public-Folder-Modus ist das public/administrator
 * (unter htp_docroot()), NICHT das Code-administrator (htp_admin_root() = JPATH_ADMINISTRATOR,
 * das dort OBERHALB des Webroots liegt und fuer die URL nie ausgeliefert wird). Genau hierhin
 * gehoert die .htaccess, die den Backend-Passwortschutz fuer die /administrator-URL durchsetzt.
 * In klassischen Installs ist htp_docroot()/administrator == htp_admin_root() -> identisch.
 *
 * @return string
 */
function htp_web_admin_root()
{
    return htp_docroot() . '/administrator';
}

/**
 * Oeffentliche Site-URL inklusive Pfad, mit abschliessendem Slash.
 * Beispiel: https://www.example.org/ oder https://example.org/joomla/
 *
 * @return string
 */
function htp_site_url()
{
    if (class_exists('JUri')) {
        try {
            $root = JUri::root();

            if (is_string($root) && $root !== '') {
                return rtrim($root, '/') . '/';
            }
        } catch (Exception $e) {
            // Fallback unten
        }
    }

    return '/';
}

/**
 * Vertrauenswuerdige Basis-URL fuer SERVERSEITIG erzeugte Links (z. B.
 * Abmeldelinks in Mails) - gegen Host-Header-Poisoning gehaertet (sonst koennte
 * ein Angreifer einen Link auf seinen Server in eine Mail schmuggeln und ein
 * gueltiges Token abgreifen). Reihenfolge:
 *   1) admin-gesetzte Joomla-Globaleinstellung live_site (nie Host-abgeleitet),
 *   2) $storedBase: im Backend (authentifizierter Super-User-Request) erfasste
 *      echte Site-URL - greift, wenn live_site wie meist leer ist,
 *   3) Fallback htp_site_url() (request-abgeleitet) nur, wenn 1 + 2 fehlen.
 *
 * @param   string   $storedBase  im Backend erfasste, vertrauenswuerdige Basis
 * @param   boolean  $strict      true (z. B. Startseiten-Waechter): KEIN request-
 *                                abgeleiteter Host-Fallback und Platzhalter
 *                                joomla.invalid verworfen -> '' = keine
 *                                vertrauenswuerdige Basis. false (Mail-Links):
 *                                wie bisher request-abgeleiteter Fallback.
 *
 * @return string  Basis-URL mit abschliessendem Slash, oder '' (nur bei $strict)
 */
function htp_trusted_base($storedBase = '', $strict = false)
{
    $app = htp_app();

    if ($app && method_exists($app, 'get')) {
        $live = (string) $app->get('live_site', '');

        if ($live !== '' && preg_match('~^https?://~i', $live) && (!$strict || strpos($live, 'joomla.invalid') === false)) {
            return rtrim($live, '/') . '/';
        }
    }

    $storedBase = (string) $storedBase;

    if ($storedBase !== '' && preg_match('~^https?://~i', $storedBase) && (!$strict || strpos($storedBase, 'joomla.invalid') === false)) {
        return rtrim($storedBase, '/') . '/';
    }

    // $strict: kein request-abgeleiteter Host (host-poisoning-fest) -> '' bedeutet
    // "keine vertrauenswuerdige Basis, nicht verwenden". Sonst der bisherige Fallback.
    return $strict ? '' : htp_site_url();
}

/**
 * Erkannte Webserver-Software (leer, wenn unbekannt).
 *
 * @return string
 */
function htp_server_software()
{
    return isset($_SERVER['SERVER_SOFTWARE']) ? (string) $_SERVER['SERVER_SOFTWARE'] : '';
}

/**
 * Meldet die Server-Software nginx? ROHE Kennung aus SERVER_SOFTWARE - nur
 * fuer Anzeige/Vor-Entscheidung. Fuer die eigentliche Entscheidung, ob die
 * .htaccess greift, htp_nginx_mode() verwenden (aktiver Funktionstest), denn
 * SERVER_SOFTWARE ist in Proxy-Setups unzuverlaessig: Plesk reicht haeufig
 * "nginx" durch, obwohl Apache dahinter die .htaccess sehr wohl auswertet.
 *
 * @return boolean
 */
function htp_is_nginx()
{
    return stripos(htp_server_software(), 'nginx') !== false;
}

/**
 * LiteSpeed-Server? (PHP-SAPI ODER SERVER_SOFTWARE). LiteSpeed komprimiert Antworten
 * NATIV - eine zweite Kompressions-Schicht (Joomlas PHP-gzip oder ein eigenes
 * AddOutputFilterByType in der .htaccess) fuehrt dort zum "content encoding error".
 *
 * @return boolean
 */
function htp_is_litespeed()
{
    if (function_exists('php_sapi_name') && stripos((string) php_sapi_name(), 'litespeed') !== false) {
        return true;
    }

    return stripos(htp_server_software(), 'litespeed') !== false;
}

/**
 * Komprimiert bereits eine Schicht UNTERHALB von Joomla (Server oder PHP)? Dann legt
 * Joomlas eigenes PHP-gzip (ob_gzhandler) eine ZWEITE Schicht drauf -> Browser-
 * Dekodierfehler (ERR_CONTENT_DECODING_FAILED). Zuverlaessige Doppel-Ausloeser:
 * LiteSpeed (nativ) und PHP zlib.output_compression. Apache/nginx respektieren ein
 * bereits gesetztes Content-Encoding und doppeln NICHT -> dort bleibt Joomlas gzip
 * unangetastet (es ist der einzige, funktionierende Kompressor).
 *
 * @return boolean
 */
function htp_server_precompresses()
{
    if (htp_is_litespeed()) {
        return true;
    }

    // zlib.output_compression: Off/0/"" = aus; On/1/<Byte-Groesse> = an.
    $z = strtolower(trim((string) @ini_get('zlib.output_compression')));

    return !($z === '' || $z === '0' || $z === 'off' || $z === 'false');
}

/**
 * Soll HTProtect den nginx-Weg gehen (kein .htaccess-Schild, sondern der
 * nginx-Regelblock)? Maszgeblich ist NICHT allein SERVER_SOFTWARE, sondern
 * das Ergebnis des aktiven .htaccess-Funktionstests (HtpProbe). Damit werden
 * Apache-hinter-nginx-Setups (Plesk-Proxy) korrekt als Apache behandelt -
 * der "Jetzt absichern"-Button erscheint und das Schild wird geschrieben.
 *
 * Liest nur den gecachten Befund (keine HTTP-/Schreib-Seiteneffekte) - der
 * aktive Test wird einmalig beim Komponentenaufruf angestoszen. Solange noch
 * kein Befund vorliegt ('unknown'), bleibt es beim konservativen nginx-Weg.
 *
 * @param   array  $cfg  Konfiguration (Cache-Traeger)
 *
 * @return boolean
 */
function htp_nginx_mode($cfg)
{
    if (!htp_is_nginx()) {
        return false;
    }

    if (class_exists('HtpProbe')) {
        return HtpProbe::htaccessCapability($cfg, false) !== 'yes';
    }

    return true;
}

/**
 * Meldet die Server-Software Microsoft IIS? ROHE Kennung aus SERVER_SOFTWARE - fuer die
 * Anzeige/Vor-Entscheidung, ob der IIS-Weg (web.config) angeboten wird.
 *
 * @return boolean
 */
function htp_is_iis()
{
    return stripos(htp_server_software(), 'microsoft-iis') !== false;
}

/**
 * Soll HTProtect den IIS-Weg gehen (kein .htaccess-Schild, sondern die web.config)? IIS liest
 * .htaccess grundsaetzlich NICHT (Ausnahme: seltenes Helicon ISAPI_Rewrite). Wie beim nginx-
 * Modus entscheidet der aktive .htaccess-Funktionstest: greift die .htaccess nachweislich NICHT
 * (und wir sind auf IIS), ist der web.config-Weg der richtige. Nur gecachter Befund, keine
 * Seiteneffekte; solange 'unknown' bleibt es beim konservativen IIS-Weg.
 *
 * @param   array  $cfg  Konfiguration (Cache-Traeger)
 *
 * @return boolean
 */
function htp_iis_mode($cfg)
{
    if (!htp_is_iis()) {
        return false;
    }

    if (class_exists('HtpProbe')) {
        return HtpProbe::htaccessCapability($cfg, false) !== 'yes';
    }

    return true;
}

/**
 * Ist ein Hostname (heuristisch) eine SUBDOMAIN, d. h. NICHT die registrierbare
 * Hauptdomain? Bewusst VORSICHTIG: im Zweifel false (keine Subdomain), damit
 * nie faelschlich eine Warnung/Sperre greift. Auf einer echten Subdomain ist
 * die www-Erzwingung sinnlos und gefaehrlich (www.<subdomain> existiert idR
 * nicht) -> die UI warnt dann.
 *
 *  - 'meineseite.de'            -> false (Hauptdomain)
 *  - 'www.meineseite.de'        -> false (www = kanonische Hauptdomain)
 *  - 'joomsix.meineseite.de'    -> true  (Subdomain)
 *  - 'meineseite.co.uk'         -> false (mehrteilige TLD, KEINE Subdomain)
 *  - 'shop.meineseite.co.uk'    -> true  (Subdomain auf mehrteiliger TLD)
 *  - IP-Adresse / 'localhost'   -> false
 *
 * @param   string  $host  Hostname (ohne Schema/Port)
 *
 * @return boolean
 */
function htp_is_subdomain($host)
{
    $host = strtolower(trim((string) $host));
    $host = rtrim($host, '.');

    if ($host === '' || strpos($host, '.') === false) {
        return false; // localhost o. ae.
    }

    // IP-Adresse (v4/v6) ist nie eine Subdomain.
    if (filter_var($host, FILTER_VALIDATE_IP) !== false || strpos($host, ':') !== false) {
        return false;
    }

    $labels = explode('.', $host);

    // Fuehrendes 'www' gehoert zur kanonischen Hauptdomain, nicht zu einer
    // (warnwuerdigen) Subdomain -> fuer die Bewertung abschneiden.
    if (count($labels) > 2 && $labels[0] === 'www') {
        array_shift($labels);
    }

    $count = count($labels);

    if ($count < 3) {
        return false; // example.de / www -> example.de
    }

    // Bekannte mehrteilige TLDs: dann ist die registrierbare Domain 3 Labels
    // lang (name.co.uk) und erst ein 4. Label waere eine Subdomain.
    static $multi = array(
        'co.uk' => 1, 'org.uk' => 1, 'me.uk' => 1, 'ltd.uk' => 1, 'plc.uk' => 1,
        'ac.uk' => 1, 'gov.uk' => 1, 'net.uk' => 1, 'sch.uk' => 1,
        'com.au' => 1, 'net.au' => 1, 'org.au' => 1, 'edu.au' => 1, 'gov.au' => 1, 'id.au' => 1,
        'co.nz' => 1, 'net.nz' => 1, 'org.nz' => 1, 'govt.nz' => 1,
        'co.za' => 1, 'org.za' => 1, 'co.jp' => 1, 'or.jp' => 1, 'ne.jp' => 1,
        'com.br' => 1, 'com.cn' => 1, 'com.tr' => 1, 'com.mx' => 1, 'com.ar' => 1,
        'co.in' => 1, 'co.il' => 1, 'co.kr' => 1, 'com.sg' => 1, 'com.hk' => 1,
    );

    $last2 = $labels[$count - 2] . '.' . $labels[$count - 1];

    if (isset($multi[$last2])) {
        return $count > 3;
    }

    return $count > 2;
}

/**
 * Laeuft die aktuelle Installation (heuristisch) unter einer Subdomain?
 * Liest den Host aus der aktuellen Site-URL.
 *
 * @return boolean
 */
function htp_current_is_subdomain()
{
    $host = parse_url(htp_site_url(), PHP_URL_HOST);

    return is_string($host) ? htp_is_subdomain($host) : false;
}

/**
 * Liegt ein Reverse-Proxy/Cache vor dem Webserver? Erkennbar an den
 * typischen Weiterleitungs-Headern (nginx-vor-Apache, Plesk-Hybrid,
 * Load-Balancer, CDN). Dient nur der UI-Vereinfachung: ohne Proxy ist
 * der Nginx-Zusatzregelblock unter Apache irrelevant und wird ausgeblendet.
 *
 * @return boolean
 */
function htp_proxy_detected()
{
    $headers = array(
        'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED_PROTO', 'HTTP_X_FORWARDED_HOST',
        'HTTP_X_FORWARDED_SERVER', 'HTTP_X_REAL_IP', 'HTTP_FORWARDED', 'HTTP_VIA',
    );

    foreach ($headers as $key) {
        if (!empty($_SERVER[$key])) {
            return true;
        }
    }

    return false;
}

/**
 * Joomla-Version als String, z. B. "5.2.3".
 *
 * @return string
 */
function htp_jversion()
{
    if (class_exists('JVersion')) {
        $v = new JVersion();

        if (method_exists($v, 'getShortVersion')) {
            return $v->getShortVersion();
        }
    }

    if (defined('JVERSION')) {
        return JVERSION;
    }

    return 'unbekannt';
}

/**
 * Joomla-Toolbar-/Subhead-Titel setzen (versionsneutral). Fuellt den sonst
 * leeren Subhead-Balken der Komponente - sonst entsteht in J3 (haengt ueber dem
 * Logo) wie in J4-6 eine leere Leiste. J4-6: ToolbarHelper; J2.5/3: JToolbarHelper
 * (Klassennamen sind in PHP case-insensitiv, deckt auch J2.5 JToolBarHelper ab).
 *
 * @param   string  $title  Titeltext (darf HTML-Entities enthalten)
 * @param   string  $icon   Icon-Kuerzel (Joomla baut daraus icon-<name>)
 *
 * @return void
 */
function htp_toolbar_title($title, $icon = 'shield')
{
    try {
        if (class_exists('Joomla\\CMS\\Toolbar\\ToolbarHelper')) {
            Joomla\CMS\Toolbar\ToolbarHelper::title($title, $icon);

            return;
        }

        if (class_exists('JToolbarHelper')) {
            JToolbarHelper::title($title, $icon);
        }
    } catch (Exception $e) {
        // ohne Toolbar-System bleibt der Balken wie bisher
    } catch (Throwable $e) {
        // still (PHP 7+)
    }
}

/**
 * Temp-Verzeichnis der Joomla-Konfiguration.
 *
 * @return string
 */
function htp_tmp_path()
{
    $app = htp_app();

    if ($app && method_exists($app, 'get')) {
        $tmp = $app->get('tmp_path');

        if (is_string($tmp) && $tmp !== '') {
            return rtrim(str_replace('\\', '/', $tmp), '/');
        }
    }

    if (class_exists('JFactory') && method_exists('JFactory', 'getConfig')) {
        try {
            $config = JFactory::getConfig();

            if ($config && method_exists($config, 'get')) {
                $tmp = $config->get('tmp_path');

                if (is_string($tmp) && $tmp !== '') {
                    return rtrim(str_replace('\\', '/', $tmp), '/');
                }
            }
        } catch (Exception $e) {
            // Fallback unten
        }
    }

    return htp_root() . '/tmp';
}

/**
 * Dauerhafter, web-geschuetzter Datenordner fuer HTProtects Laufzeitdateien
 * (Waechter-State/Lock, WAF-Log/-Trigger, Status-Schnappschuss). Bevorzugt
 * `administrator/components/com_htprotect/data/` - der ueberlebt tmp-/Cache-
 * Leerungen UND Extension-Updates (runtime-erzeugt, nicht im Paket) und ist
 * bereits vom Malware-Scan ausgenommen (/com_htprotect). Fallback: Joomla-tmp,
 * falls /administrator nicht beschreibbar ist. Ergebnis pro Request gecacht.
 *
 * WICHTIG: Das mitgelieferte Plugin nutzt dieselbe Aufloesung selbst-enthalten
 * (es laeuft ohne Engine) - beide muessen denselben Ordner liefern, sonst
 * lesen/schreiben sie an verschiedenen Orten. Da Frontend und Backend als
 * derselbe Systembenutzer laufen, ist die Schreibbarkeits-Entscheidung gleich.
 *
 * @return string Pfad mit Forward-Slashes, ohne abschliessenden Slash
 */
function htp_state_dir()
{
    static $cached = null;

    if ($cached !== null) {
        return $cached;
    }

    $data = htp_admin_root() . '/components/com_htprotect/data';

    if (htp_prepare_state_dir($data)) {
        htp_migrate_state_from_tmp($data);
        $cached = $data;
    } else {
        $cached = htp_tmp_path();
    }

    return $cached;
}

/**
 * Vollständiger Pfad einer Laufzeitdatei im Datenordner.
 *
 * @param   string  $name  Dateiname (ohne Pfad)
 *
 * @return string
 */
function htp_state_file($name)
{
    return htp_state_dir() . '/' . $name;
}

/**
 * Praefix der PHP-gewrappten Zustandsdateien. Die Datei traegt zusaetzlich die
 * Endung .php: ruft sie jemand direkt ueber das Web auf, fuehrt der Server sie
 * als PHP aus, `die` beendet SOFORT - der eigentliche Inhalt hinter `?>` wird
 * nie ausgegeben. Das wirkt auf JEDEM Server, auch wo die .htaccess des
 * Datenordners nicht ausgewertet wird (nginx).
 *
 * @return string
 */
function htp_state_wrap_prefix()
{
    return "<?php die; ?>\n";
}

/**
 * Wrapper-Praefix entfernen (falls vorhanden). Alte, ungewrappte Dateien gehen
 * unveraendert durch - dieselbe Funktion liest also BEIDE Formate.
 *
 * @param   string  $raw  Roher Dateiinhalt
 *
 * @return string
 */
function htp_state_unwrap($raw)
{
    $raw = (string) $raw;

    if (strncmp($raw, '<?php', 5) !== 0) {
        return $raw;
    }

    $pos = strpos($raw, '?>');

    if ($pos === false) {
        return $raw;
    }

    return ltrim(substr($raw, $pos + 2), "\r\n");
}

/**
 * Zustandsdatei lesen. Bevorzugt die neue, PHP-gewrappte Fassung (<name>.php);
 * fehlt sie, wird die alte Fassung (<name>) gelesen. Liefert null, wenn keine
 * der beiden Dateien lesbar ist.
 *
 * @param   string  $name  Dateiname ohne Pfad, ALTE Schreibweise (z. B. foo.json)
 *
 * @return string|null
 */
function htp_state_read($name)
{
    $dir = htp_state_dir();

    foreach (array($dir . '/' . $name . '.php', $dir . '/' . $name) as $path) {
        if (!is_file($path)) {
            continue;
        }

        $raw = @file_get_contents($path);

        if ($raw === false) {
            continue;
        }

        $out = htp_state_unwrap($raw);

        // Leerer Inhalt = abgebrochener Schreibvorgang. Dann NICHT hier stehenbleiben, sondern
        // auf die alte Fassung durchfallen - sonst wuerde eine kaputte neue Datei die intakte
        // alte dauerhaft verdecken (die Site verloere z. B. ihre Kopplung).
        if ($out !== '') {
            return $out;
        }
    }

    return null;
}

/**
 * Zustandsdatei schreiben - stille Migration auf das neue Format. Reihenfolge
 * bewusst vorsichtig: erst die neue Datei schreiben, danach zurueck-lesen und
 * vergleichen, und NUR wenn das nachweislich geklappt hat die alte entfernen.
 * Schlaegt das Schreiben der neuen Fassung fehl, wird wie bisher die alte Datei
 * geschrieben - eine gekoppelte Site verliert so nie ihre Zugangsdaten.
 *
 * @param   string  $name  Dateiname ohne Pfad, ALTE Schreibweise (z. B. foo.json)
 * @param   string  $data  Zu speichernder Inhalt
 *
 * @return boolean
 */
function htp_state_write($name, $data)
{
    $dir  = htp_state_dir();
    $new  = $dir . '/' . $name . '.php';
    $old  = $dir . '/' . $name;
    $data = (string) $data;

    if (@file_put_contents($new, htp_state_wrap_prefix() . $data, LOCK_EX) === false) {
        // Neues Format nicht schreibbar -> exakt das bisherige Verhalten.
        return @file_put_contents($old, $data, LOCK_EX) !== false;
    }

    @chmod($new, 0600);

    // Gegenprobe: nur ein bytegleicher Rueckleseweg zaehlt als Erfolg. Ein abgebrochener
    // Schreibvorgang (volle Platte) liefert eine VERKUERZTE Datei, nicht false - die muss weg,
    // sonst verdeckt sie die intakte alte Fassung.
    $back = @file_get_contents($new);

    if ($back === false || htp_state_unwrap($back) !== $data) {
        @unlink($new);

        return @file_put_contents($old, $data, LOCK_EX) !== false;
    }

    if (is_file($old)) {
        @unlink($old);
    }

    return true;
}

/**
 * Datenordner anlegen (falls nötig) + gegen Web-Zugriff schützen. Liefert true,
 * wenn er existiert und beschreibbar ist.
 *
 * @param   string  $dir  Zielordner
 *
 * @return boolean
 */
function htp_prepare_state_dir($dir)
{
    if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
        return false;
    }

    if (!is_writable($dir)) {
        return false;
    }

    if (!is_file($dir . '/.htaccess')) {
        @file_put_contents(
            $dir . '/.htaccess',
            "# HTProtect Datenordner - kein direkter Web-Zugriff\n"
            . "<IfModule mod_authz_core.c>\n    Require all denied\n</IfModule>\n"
            . "<IfModule !mod_authz_core.c>\n    Order allow,deny\n    Deny from all\n</IfModule>\n"
        );
    }

    if (!is_file($dir . '/index.html')) {
        @file_put_contents($dir . '/index.html', '<!DOCTYPE html><title></title>');
    }

    return true;
}

/**
 * Basisordner fuer die GROSSEN Sicherungen (.app-DB/Voll-Backups + Auto-Update-Rollback-Snapshots
 * samt CAS-Store). Bewusst `<docroot>/administrator/_htprotect_backups`:
 *   (a) unter /administrator -> erbt HTProtects Backend-Passwortschutz als zusaetzliche Schicht,
 *   (b) EIN klar benannter Ordner auf flacher Ebene -> in Dritt-Backup-Tools (Akeeba/XCloner)
 *       trivial als Ganzes auszuschliessen (statt tief in .../com_htprotect/data vergraben).
 *
 * FALLBACK auf den bisherigen geschuetzten State-Ordner (htp_state_dir), wenn der neue Ort nicht
 * anlegbar/schuetzbar ist (Rechte, open_basedir) - ein Backup darf am Speicherort NIE scheitern.
 * Beim ERSTEN Aufloesen auf den neuen Ort werden bestehende Backups (idempotent, resumierbar)
 * vom Alt-Ort hierher geholt.
 *
 * @return string  ohne abschliessenden Slash, oder '' (dann degradiert der Aufrufer sauber)
 */
function htp_backup_root()
{
    static $cached = null;

    if ($cached !== null) {
        return $cached;
    }

    $admin = htp_admin_root();
    $dir   = ($admin !== '') ? ($admin . '/_htprotect_backups') : '';
    $state = function_exists('htp_state_dir') ? htp_state_dir() : '';

    if ($dir !== '' && $dir !== $state && htp_prepare_backup_dir($dir)) {
        $cached = $dir;
        htp_migrate_backups($state, $dir); // Alt-Backups (State-Ordner) hierher holen - einmalig
    } else {
        $cached = $state; // Fallback: Aufrufer haengen ihre Unterordner (backup/, autoupdate/) an
    }

    return $cached;
}

/**
 * Den Backup-Basisordner anlegen + HAERTEN. Er enthaelt die vollen DB-Dumps -> sensibler als der
 * State-Ordner: Deny-all (.htaccess + web.config), kein Directory-Listing (index.html + index.php
 * mit `die` + Options -Indexes), Perms 0700. Der eigentliche Schutz auf JEDEM Server (auch nginx,
 * das .htaccess ignoriert) ist das unerratbare Zufalls-Unterverzeichnis pro Backup - das setzen
 * backup.php / autoupdate_backup.php. Alle Wachen fail-open (@) - nie ein Backup daran scheitern.
 *
 * @return bool  true, wenn der Ordner existiert + beschreibbar ist
 */
function htp_prepare_backup_dir($dir)
{
    if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) {
        return false;
    }

    if (!is_writable($dir)) {
        return false;
    }

    @chmod($dir, 0700);

    if (!is_file($dir . '/.htaccess')) {
        @file_put_contents(
            $dir . '/.htaccess',
            "# HTProtect-Sicherungen - KEIN direkter Web-Zugriff\n"
            . "Options -Indexes\n"
            . "<IfModule mod_authz_core.c>\n    Require all denied\n</IfModule>\n"
            . "<IfModule !mod_authz_core.c>\n    Order allow,deny\n    Deny from all\n</IfModule>\n"
        );
    }

    if (!is_file($dir . '/web.config')) {
        @file_put_contents(
            $dir . '/web.config',
            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
            . "<configuration><system.webServer><authorization>"
            . "<deny users=\"*\" /></authorization></system.webServer></configuration>\n"
        );
    }

    if (!is_file($dir . '/index.html')) {
        @file_put_contents($dir . '/index.html', '<!DOCTYPE html><title></title>');
    }

    if (!is_file($dir . '/index.php')) {
        @file_put_contents($dir . '/index.php', "<?php die; ?>\n");
    }

    return true;
}

/**
 * Einmalige, RESUMIERBARE Migration bestehender Sicherungen vom Alt-Ort ($oldBase = State-Ordner)
 * an den neuen Backup-Ort ($newBase). Verschiebt die zwei Unterbaeume `backup` (.app-Backups) und
 * `autoupdate` (Rollback-Snapshots + `cas/`).
 *
 * SICHER: bevorzugt atomares rename() (gleiches Dateisystem = sofort, kein Teilzustand). Nur wenn
 * das scheitert (anderes Dateisystem), wird an einen TEMP-Namen kopiert + per Groesse/Anzahl
 * VERIFIZIERT, atomar an den Zielnamen umbenannt und erst DANN die Alt-Kopie geloescht - ein
 * Abbruch verliert also NIE ein Backup, jedes Element ist unabhaengig (naechster Lauf setzt fort).
 * No-Op, sobald am Alt-Ort nichts mehr liegt.
 */
function htp_migrate_backups($oldBase, $newBase)
{
    $oldBase = rtrim(str_replace('\\', '/', (string) $oldBase), '/');
    $newBase = rtrim(str_replace('\\', '/', (string) $newBase), '/');

    if ($oldBase === '' || $newBase === '' || $oldBase === $newBase) {
        return;
    }

    // 'backup' = HtpBackup::DIR, 'autoupdate' = HtpUpdateBackup::DIRNAME (bewusst hart benannt -
    // compat.php laedt vor den Klassen). CAS liegt in autoupdate/cas -> wandert mit.
    foreach (array('backup', 'autoupdate') as $sub) {
        $oldSub = $oldBase . '/' . $sub;
        $newSub = $newBase . '/' . $sub;

        if (!is_dir($oldSub)) {
            continue;
        }

        // Ziel-Teilbaum existiert noch nicht -> alles in EINEM atomaren rename verschieben.
        if (!is_dir($newSub) && @rename($oldSub, $newSub)) {
            continue;
        }

        if (!is_dir($newSub) && !@mkdir($newSub, 0700, true) && !is_dir($newSub)) {
            continue; // Ziel nicht anlegbar -> spaeter erneut
        }

        // Ziel existiert (schon neue Backups da) -> pro direktem Kind zusammenfuehren.
        foreach ((array) @scandir($oldSub) as $entry) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }

            htp_migrate_backup_item($oldSub . '/' . $entry, $newSub . '/' . $entry);
        }

        @rmdir($oldSub); // nur wenn leer -> sonst bleibt Rest fuer den naechsten Lauf
    }
}

/** Ein Element (Datei/Ordner) vom Alt- an den Neu-Ort bringen: rename, sonst copy+verify+delete. */
function htp_migrate_backup_item($old, $new)
{
    if (file_exists($new)) {
        // Schon migriert (das Neu-Element wird erst nach vollstaendigem Kopieren sichtbar) -> Alt weg.
        htp_bk_rmtree($old);

        return;
    }

    if (@rename($old, $new)) {
        return; // atomar (gleiches Dateisystem)
    }

    // Anderes Dateisystem: an TEMP kopieren, verifizieren, atomar umbenennen, dann Alt loeschen.
    $tmp = $new . '.htmig';
    htp_bk_rmtree($tmp);

    if (htp_bk_copytree($old, $tmp) && htp_bk_tree_equal($old, $tmp) && @rename($tmp, $new)) {
        htp_bk_rmtree($old);

        return;
    }

    htp_bk_rmtree($tmp); // fehlgeschlagen -> Alt bleibt unangetastet, naechster Lauf versucht erneut
}

/** Rekursiv kopieren (Datei ODER Ordner). Symlinks werden NICHT gefolgt/kopiert. */
function htp_bk_copytree($src, $dst)
{
    if (is_link($src)) {
        return true; // Symlinks im Backup-Baum ignorieren (dort gibt es keine)
    }

    if (is_file($src)) {
        return @copy($src, $dst);
    }

    if (!is_dir($src)) {
        return false;
    }

    if (!is_dir($dst) && !@mkdir($dst, 0700, true) && !is_dir($dst)) {
        return false;
    }

    $ok = true;

    foreach ((array) @scandir($src) as $e) {
        if ($e === '.' || $e === '..') {
            continue;
        }

        $ok = htp_bk_copytree($src . '/' . $e, $dst . '/' . $e) && $ok;
    }

    return $ok;
}

/** Zwei Baeume gleich? (Dateizahl + Gesamtgroesse) - guenstiger Integritaets-Check fuer den Copy-Fallback. */
function htp_bk_tree_equal($a, $b)
{
    $sa = htp_bk_tree_stats($a);
    $sb = htp_bk_tree_stats($b);

    return $sa[0] === $sb[0] && $sa[1] === $sb[1] && $sa[0] > 0;
}

/** [Dateizahl, Gesamtbytes] eines Baums. */
function htp_bk_tree_stats($dir)
{
    if (is_link($dir)) {
        return array(0, 0);
    }

    if (is_file($dir)) {
        return array(1, (int) @filesize($dir));
    }

    if (!is_dir($dir)) {
        return array(0, 0);
    }

    $n = 0;
    $b = 0;

    foreach ((array) @scandir($dir) as $e) {
        if ($e === '.' || $e === '..') {
            continue;
        }

        $s  = htp_bk_tree_stats($dir . '/' . $e);
        $n += $s[0];
        $b += $s[1];
    }

    return array($n, $b);
}

/** Rekursiv loeschen (Datei/Ordner), ohne Symlinks zu folgen. */
function htp_bk_rmtree($path)
{
    if (is_link($path) || is_file($path)) {
        @unlink($path);

        return;
    }

    if (!is_dir($path)) {
        return;
    }

    foreach ((array) @scandir($path) as $e) {
        if ($e === '.' || $e === '..') {
            continue;
        }

        htp_bk_rmtree($path . '/' . $e);
    }

    @rmdir($path);
}

/**
 * Einmalige Migration: die bisher in tmp/ liegenden Laufzeitdateien in den
 * neuen Datenordner übernehmen und im tmp/ entfernen. Greift nur, solange dort
 * noch Altbestände liegen (is_file-Gate) - danach No-Op. Eine bereits am neuen
 * Ort vorhandene (frische) Datei wird NICHT überschrieben.
 *
 * @param   string  $data  Ziel-Datenordner
 *
 * @return void
 */
function htp_migrate_state_from_tmp($data)
{
    $tmp = htp_tmp_path();

    if ($tmp === '' || $tmp === $data) {
        return;
    }

    foreach (array('htprotect_watch.state', 'htprotect_waf.log', 'htprotect_waf.triggers', 'htprotect_status.json') as $name) {
        $old = $tmp . '/' . $name;

        if (!is_file($old)) {
            continue;
        }

        $new = $data . '/' . $name;

        if (!is_file($new)) {
            $c = @file_get_contents($old);

            if ($c !== false) {
                @file_put_contents($new, $c);
            }
        }

        @unlink($old);
    }

    // Transiente Sperre nie kopieren - nur Altbestand entfernen.
    @unlink($tmp . '/htprotect_watch.lock');
}

/**
 * CSRF-Formular-Token.
 *
 * @return string
 */
function htp_form_token()
{
    if (class_exists('Joomla\\CMS\\Session\\Session')
        && method_exists('Joomla\\CMS\\Session\\Session', 'getFormToken')) {
        return Joomla\CMS\Session\Session::getFormToken();
    }

    if (class_exists('JSession') && method_exists('JSession', 'getFormToken')) {
        return JSession::getFormToken();
    }

    if (class_exists('JUtility') && method_exists('JUtility', 'getToken')) {
        return JUtility::getToken();
    }

    return '';
}

/**
 * EINZIGER, bewusst gekapselter Zugriffspunkt auf die PHP-Request-Superglobale
 * der Engine-Seite ($_GET/$_POST/$_REQUEST/$_FILES). Liefert das ROHE Bag als
 * Referenz zurueck - HTProtect ist ein Sicherheits-Werkzeug und MUSS die echte,
 * UNGEFILTERTE Eingabe sehen (die WAF sucht exakt nach den Mustern, die Joomlas
 * Input-Filter wegputzen wuerde: ../, php://, serialisierte Objekte ...). Genau
 * deshalb laeuft alles ueber diese eine, dokumentierte Stelle statt verstreut
 * im Code - jeder andere Engine-Helfer (htp_post, htp_cmd, ...) greift hierauf
 * zu, nie direkt auf die Superglobale. Versionsneutral (PHP 5.3-8.5), kein
 * Verhalten geaendert: es ist exakt der bisherige Direktzugriff, nur gebuendelt.
 *
 * Hinweis fuer Code-Pruefer (JED/PHPCS): der hier gekapselte Superglobal-Zugriff
 * ist fuer eine WAF/Scanner-Komponente unvermeidbar und absichtlich auf diese
 * Funktion begrenzt.
 *
 * @param   string  $which  'get' | 'post' | 'request' | 'files'
 *
 * @return array  Referenz auf die jeweilige Superglobale ($_POST als Default)
 */
function &htp_input_bag($which)
{
    // phpcs:disable
    switch ($which) {
        case 'get':
            return $_GET;
        case 'request':
            return $_REQUEST;
        case 'files':
            return $_FILES;
        case 'post':
        default:
            return $_POST;
    }
    // phpcs:enable
}

/**
 * Ist ein Schluessel in der genannten Superglobale gesetzt? Ersetzt verstreute
 * isset($_POST[...])-/isset($_FILES[...])-Pruefungen durch den gekapselten
 * Zugriff. Verhalten identisch zu isset().
 *
 * @param   string  $key     Parametername
 * @param   string  $source  'get' | 'post' | 'request' | 'files'
 *
 * @return boolean
 */
function htp_has($key, $source = 'post')
{
    $bag = &htp_input_bag($source);

    return isset($bag[$key]);
}

/**
 * Roher (ungefilterter) Wert aus einer Superglobale - NUR fuer die wenigen
 * Stellen, die bewusst den Rohwert brauchen (Schwarm-Pfade, Upload-Struktur).
 * Liefert den Wert unveraendert oder den Default, wenn nicht gesetzt.
 *
 * @param   string  $key      Parametername
 * @param   mixed   $default  Rueckfall
 * @param   string  $source   'get' | 'post' | 'request' | 'files'
 *
 * @return mixed
 */
function htp_raw($key, $default = null, $source = 'post')
{
    $bag = &htp_input_bag($source);

    return isset($bag[$key]) ? $bag[$key] : $default;
}

/**
 * CSRF-Pruefung fuer POST-Anfragen.
 *
 * @return boolean
 */
function htp_check_token()
{
    if (class_exists('Joomla\\CMS\\Session\\Session')
        && method_exists('Joomla\\CMS\\Session\\Session', 'checkToken')) {
        return (bool) Joomla\CMS\Session\Session::checkToken('post');
    }

    if (class_exists('JSession') && method_exists('JSession', 'checkToken')) {
        return (bool) JSession::checkToken('post');
    }

    if (class_exists('JRequest') && method_exists('JRequest', 'checkToken')) {
        return (bool) JRequest::checkToken('post');
    }

    // Minimaler Eigen-Check, falls gar keine Joomla-API verfuegbar ist
    $token = htp_form_token();

    return $token !== '' && htp_has($token, 'post');
}

/**
 * Magic-Quotes-sicheres Auslesen (nur fuer Steinzeit-PHP < 5.4 relevant).
 *
 * @param   mixed  $value  Rohwert
 *
 * @return mixed
 */
function htp_unquote($value)
{
    static $magic = null;

    if ($magic === null) {
        $magic = (PHP_VERSION_ID < 50400
            && function_exists('get_magic_quotes_gpc')
            && @get_magic_quotes_gpc());
    }

    if (!$magic) {
        return $value;
    }

    if (is_array($value)) {
        return array_map('htp_unquote', $value);
    }

    return is_string($value) ? stripslashes($value) : $value;
}

/**
 * GET/REQUEST-Wert als einfacher Befehls-String (a-z0-9_-).
 *
 * @param   string  $key      Parametername
 * @param   string  $default  Standardwert
 *
 * @return string
 */
function htp_cmd($key, $default = '')
{
    $value = htp_has($key, 'request') ? htp_unquote(htp_raw($key, $default, 'request')) : $default;

    if (!is_string($value)) {
        return $default;
    }

    $value = preg_replace('/[^a-zA-Z0-9_\-]/', '', $value);

    return $value === '' ? $default : $value;
}

/**
 * POST-Wert als String (roh, UTF-8-bereinigt, ohne Nullbytes).
 *
 * @param   string  $key      Parametername
 * @param   string  $default  Standardwert
 *
 * @return string
 */
function htp_post($key, $default = '')
{
    if (!htp_has($key, 'post')) {
        return $default;
    }

    $value = htp_unquote(htp_raw($key, $default, 'post'));

    if (!is_string($value)) {
        return $default;
    }

    return str_replace(chr(0), '', $value);
}

/**
 * GET-Wert als String (gespiegelt zu htp_post). Fuer harmlose UI-Signale wie
 * ?htp_uninstall=1 (Assistent aufklappen).
 *
 * @param   string  $key      Parametername
 * @param   string  $default  Rueckfall
 *
 * @return string
 */
function htp_get($key, $default = '')
{
    if (!htp_has($key, 'get')) {
        return $default;
    }

    $value = htp_unquote(htp_raw($key, $default, 'get'));

    if (!is_string($value)) {
        return $default;
    }

    return str_replace(chr(0), '', $value);
}

/**
 * POST-Wert als Array von Strings.
 *
 * @param   string  $key  Parametername
 *
 * @return array
 */
function htp_post_array($key)
{
    $raw = htp_raw($key, null, 'post');

    if (!is_array($raw)) {
        return array();
    }

    $out = array();

    foreach ($raw as $value) {
        $value = htp_unquote($value);

        if (is_string($value)) {
            $out[] = str_replace(chr(0), '', $value);
        }
    }

    return $out;
}

/**
 * POST-Checkbox als 0/1.
 *
 * @param   string  $key  Parametername
 *
 * @return integer
 */
function htp_post_bool($key)
{
    return htp_raw($key, '', 'post') ? 1 : 0;
}

/**
 * HTML-Escaping-Kurzform.
 *
 * @param   string  $value  Rohtext
 *
 * @return string
 */
function htp_esc($value)
{
    return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}

/**
 * Konfigurierte Joomla-Zeitzone (globale "Server-Zeitzone" / offset). Fallback UTC.
 *
 * @return string  z. B. "Europe/Berlin" oder "UTC"
 */
function htp_joomla_tz()
{
    $tz = 'UTC';

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

            if ($app && method_exists($app, 'get')) {
                $tz = (string) $app->get('offset', 'UTC');
            }
        } elseif (class_exists('JFactory')) {
            $cfg = JFactory::getConfig();
            $tz  = $cfg ? (string) $cfg->get('offset', 'UTC') : 'UTC';
        }
    } catch (Exception $e) {
        // UTC
    } catch (Throwable $e) {
        // UTC
    }

    return $tz === '' ? 'UTC' : $tz;
}

/**
 * Unix-Zeitstempel in der konfigurierten Joomla-Zeitzone formatieren. In Joomla
 * steht die PHP-Default-Zeitzone auf UTC - rohes date() zeigt daher UTC statt der
 * eingestellten Server-Zeitzone. Diese Funktion verschiebt korrekt darauf.
 *
 * @param   int     $ts   Unix-Zeitstempel
 * @param   string  $fmt  date()-Format
 *
 * @return string  Formatierte Zeit (leer bei ts <= 0)
 */
function htp_local_time($ts, $fmt = 'd.m.Y H:i')
{
    $ts = (int) $ts;

    if ($ts <= 0) {
        return '';
    }

    try {
        $d = new DateTime('@' . $ts); // '@' ignoriert die Default-TZ -> immer UTC
        $d->setTimezone(new DateTimeZone(htp_joomla_tz()));

        return $d->format($fmt);
    } catch (Exception $e) {
        return date($fmt, $ts); // Fallback: Server-Default
    }
}

/**
 * Kryptografisch sichere Zufallsbytes - versionsneutral. Auf PHP 7+ exakt
 * random_bytes() (unveraendert); nur auf PHP < 7.0 (J3/5.6) greift der
 * OpenSSL-/mcrypt-Fallback, damit der Aufruf dort nicht hart abbricht.
 *
 * @param   int  $len  Anzahl Bytes
 *
 * @return string
 */
function htp_random_bytes($len)
{
    $len = (int) $len;

    if ($len < 1) {
        $len = 1;
    }

    if (function_exists('random_bytes')) {
        try {
            return random_bytes($len); // PHP 7+
        } catch (Exception $e) {
            // weiter zum Fallback
        }
    }

    if (function_exists('openssl_random_pseudo_bytes')) {
        $strong = false;
        $b      = openssl_random_pseudo_bytes($len, $strong);

        if (is_string($b) && strlen($b) === $len) {
            return $b;
        }
    }

    if (function_exists('mcrypt_create_iv') && defined('MCRYPT_DEV_URANDOM')) {
        $b = @mcrypt_create_iv($len, MCRYPT_DEV_URANDOM);

        if (is_string($b) && strlen($b) === $len) {
            return $b;
        }
    }

    // Letzter Ausweg (schwaecher, aber nie Abbruch).
    $s = '';

    for ($i = 0; $i < $len; $i++) {
        $s .= chr(mt_rand(0, 255));
    }

    return $s;
}

/**
 * Pfad zu einem verwendbaren CA-Bundle fuer die Zertifikatspruefung ausgehender
 * HTTPS-Aufrufe. Bevorzugt das mit HTProtect ausgelieferte, aktuelle Bundle
 * (cacert.pem neben dieser Datei); faellt sonst auf gaengige System-Ablagen
 * zurueck. Wird als RETTUNG genutzt, wenn der System-Standard-Trust einer Site
 * fehlt oder veraltet ist (haeufigste Ursache fuer ein stilles "status 0" beim
 * ausgehenden POST) - die Verifikation (VERIFYPEER) bleibt dabei immer AN.
 *
 * @return string  Absoluter Pfad zu einem lesbaren CA-Bundle oder ''
 */
function htp_ca_bundle()
{
    static $cached = null;

    if ($cached !== null) {
        return $cached;
    }

    $candidates = array(
        __DIR__ . '/cacert.pem',                      // ausgeliefert (aktuell zum Build-Zeitpunkt)
        '/etc/ssl/certs/ca-certificates.crt',         // Debian/Ubuntu
        '/etc/pki/tls/certs/ca-bundle.crt',           // RHEL/CentOS/Fedora
        '/etc/ssl/cert.pem',                          // Alpine/BSD/macOS
        '/usr/local/share/certs/ca-root-nss.crt',     // FreeBSD
    );

    foreach ($candidates as $p) {
        if (is_string($p) && $p !== '' && @is_readable($p)) {
            $cached = $p;

            return $cached;
        }
    }

    $cached = '';

    return $cached;
}

/**
 * Einen ausgehenden HTTP-/Transport-Fehler in eine kurze, stabile Kategorie
 * uebersetzen - fuer eine KLARE Nutzer-Meldung statt eines Sammeltextes. Rein
 * und testbar (kein Netz).
 *
 * @param   integer  $errno   curl-Fehlernummer (0 = kein Transportfehler)
 * @param   integer  $status  HTTP-Statuscode (0 = keine Antwort erhalten)
 *
 * @return string  '' (2xx ok) | 'ssl' | 'timeout' | 'connect' | 'http'
 */
function htp_http_reason($errno, $status)
{
    $errno  = (int) $errno;
    $status = (int) $status;

    // SSL/Zertifikat: 35 SSL_CONNECT, 51 PEER_FAILED_VERIFICATION, 53/54 Cipher/Key,
    // 58 SSL_CERTPROBLEM, 59 SSL_CIPHER, 60 SSL_CACERT, 66 SSL_ENGINE,
    // 77 SSL_CACERT_BADFILE, 83 SSL_ISSUER_ERROR.
    if (in_array($errno, array(35, 51, 53, 54, 58, 59, 60, 66, 77, 83), true)) {
        return 'ssl';
    }

    if ($errno === 28) { // OPERATION_TIMEDOUT
        return 'timeout';
    }

    if (in_array($errno, array(5, 6, 7), true)) { // Proxy / DNS / Connect
        return 'connect';
    }

    if ($status >= 200 && $status < 300) {
        return '';
    }

    if ($status === 0) {
        return 'connect';
    }

    return 'http'; // echte HTTP-Antwort, aber kein 2xx -> Portal hat abgelehnt
}

/**
 * Einen multipart-POST per cURL ausfuehren (ein Versuch). Optional mit
 * explizitem CA-Bundle (CURLOPT_CAINFO) fuer den Rettungs-Retry. VERIFYPEER
 * bleibt IMMER an. Interner Helfer fuer htp_http_post_multipart().
 *
 * @return array  ['status'=>int, 'body'=>string, 'error'=>string, 'errno'=>int]
 */
function htp_curl_multipart_exec($url, $body, $ctype, $timeout, $cainfo = '')
{
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
    curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: ' . $ctype, 'Expect:'));
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(6, $timeout));
    curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
    curl_setopt($ch, CURLOPT_USERAGENT, 'HTProtect-Swarm');

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

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

    $resp   = curl_exec($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $errno  = (int) curl_errno($ch);
    $err    = ($resp === false) ? (string) curl_error($ch) : '';
    curl_close($ch);

    return array(
        'status' => $status,
        'body'   => is_string($resp) ? substr($resp, 0, 65536) : '',
        'error'  => $err,
        'errno'  => $errno,
    );
}

/**
 * multipart/form-data per POST senden - versionsneutral (cURL bevorzugt, sonst
 * Stream-Wrapper). Der Body wird selbst zusammengebaut (kein CURLFile-Zwang) ->
 * laeuft ueberall. Faellt nie hart aus. Nur HTTPS-Ziele. Fuer den Schwarm-Upload.
 *
 * Robustheit: schlaegt der Verbindungsaufbau an einem Zertifikats-/CA-Fehler
 * fehl (Site ohne/mit veraltetem System-CA-Bundle -> "status 0"), wird EINMAL
 * mit dem ausgelieferten Bundle (htp_ca_bundle) erneut versucht. Der Rueckgabe-
 * wert traegt zusaetzlich 'errno' und eine 'reason'-Kategorie (htp_http_reason)
 * fuer klare Fehlermeldungen.
 *
 * @param   string  $url      Ziel-URL (https)
 * @param   array   $fields   name => wert (Text-Felder)
 * @param   array   $file     ['name'=>..,'filename'=>..,'type'=>..,'data'=>bytes] | leer
 * @param   int     $timeout  Sekunden
 *
 * @return array  ['status'=>int, 'body'=>string, 'error'=>string, 'errno'=>int, 'reason'=>string]
 */
function htp_http_post_multipart($url, $fields = array(), $file = array(), $timeout = 8)
{
    $url     = (string) $url;
    $timeout = ((int) $timeout > 0) ? (int) $timeout : 8;

    if ($url === '' || stripos($url, 'https://') !== 0) {
        return array('status' => 0, 'body' => '', 'error' => 'badurl', 'errno' => 0, 'reason' => 'connect');
    }

    $boundary = '----htpswarm' . substr(sha1(uniqid('', true) . mt_rand()), 0, 24);
    $eol      = "\r\n";
    $body     = '';

    foreach ((array) $fields as $name => $value) {
        $body .= '--' . $boundary . $eol;
        $body .= 'Content-Disposition: form-data; name="' . str_replace('"', '', (string) $name) . '"' . $eol . $eol;
        $body .= (string) $value . $eol;
    }

    if (is_array($file) && isset($file['name'], $file['data'])) {
        $fname = isset($file['filename']) ? (string) $file['filename'] : 'file';
        $ftype = isset($file['type']) ? (string) $file['type'] : 'application/octet-stream';
        $body .= '--' . $boundary . $eol;
        $body .= 'Content-Disposition: form-data; name="' . str_replace('"', '', (string) $file['name'])
            . '"; filename="' . str_replace('"', '', $fname) . '"' . $eol;
        $body .= 'Content-Type: ' . str_replace(array("\r", "\n"), '', $ftype) . $eol . $eol;
        $body .= (string) $file['data'] . $eol;
    }

    $body .= '--' . $boundary . '--' . $eol;
    $ctype = 'multipart/form-data; boundary=' . $boundary;

    if (function_exists('curl_init')) {
        $res = htp_curl_multipart_exec($url, $body, $ctype, $timeout, '');

        // Rettungs-Retry NUR bei einem Zertifikats-/CA-Fehler: haeufig hat die Site
        // kein oder ein veraltetes System-CA-Bundle -> curl liefert status 0. Einmal
        // mit dem ausgelieferten Bundle erneut versuchen; VERIFYPEER bleibt AN. Der
        // erste Versuch nutzt bewusst den (frischeren) System-Trust.
        if (htp_http_reason((int) $res['errno'], (int) $res['status']) === 'ssl') {
            $ca = htp_ca_bundle();

            if ($ca !== '') {
                $retry = htp_curl_multipart_exec($url, $body, $ctype, $timeout, $ca);

                // Nur uebernehmen, wenn der Retry eine echte Antwort/kein Fehler ist
                // (sonst den urspruenglichen Fehler behalten -> ehrliche Meldung).
                if ((int) $retry['status'] > 0 || (int) $retry['errno'] === 0) {
                    $res = $retry;
                }
            }
        }

        $res['reason'] = htp_http_reason((int) $res['errno'], (int) $res['status']);

        return $res;
    }

    if (ini_get('allow_url_fopen')) {
        $ca  = htp_ca_bundle();
        $ssl = array('verify_peer' => true, 'verify_peer_name' => true);

        if ($ca !== '') {
            $ssl['cafile'] = $ca;
        }

        $context = stream_context_create(array(
            'http' => array(
                'method'        => 'POST',
                'header'        => 'Content-Type: ' . $ctype . $eol . 'Content-Length: ' . strlen($body) . $eol,
                'content'       => $body,
                'timeout'       => $timeout,
                'ignore_errors' => true,
                'user_agent'    => 'HTProtect-Swarm',
            ),
            'ssl'  => $ssl,
        ));

        $resp   = @file_get_contents($url, false, $context);
        $status = 0;

        if (isset($http_response_header) && is_array($http_response_header)) {
            foreach ($http_response_header as $h) {
                if (preg_match('~^HTTP/\S+\s+(\d{3})~', $h, $m)) {
                    $status = (int) $m[1];
                }
            }
        }

        // Kein curl-errno im Stream-Pfad: 0 -> reason ergibt sich aus dem Status
        // (0 = keine Antwort = connect, sonst 2xx=ok / 4xx/5xx=http).
        return array(
            'status' => $status,
            'body'   => is_string($resp) ? substr($resp, 0, 65536) : '',
            'error'  => ($status === 0 ? 'connect' : ''),
            'errno'  => 0,
            'reason' => htp_http_reason(0, $status),
        );
    }

    return array('status' => 0, 'body' => '', 'error' => 'nohttp', 'errno' => 0, 'reason' => 'connect');
}

/**
 * E-Mail versenden - versionsneutral (Joomla 4+ Container-Mailer,
 * sonst klassischer JFactory::getMailer). Schlaegt nie hart fehl.
 *
 * @param   string  $to       Empfaengeradresse
 * @param   string  $subject  Betreff
 * @param   string  $body     Textinhalt (reiner Text)
 * @param   string  $replyTo  Optionale Antwort-an-Adresse
 * @param   string  $error    (Ausgabe) Fehlertext, falls der Versand scheitert
 *
 * @return boolean true = an den Mailversand uebergeben (keine Zustellgarantie!)
 */
function htp_send_mail($to, $subject, $body, $replyTo = '', &$error = null, $headers = array(), $html = null)
{
    $error = '';

    // $body ist der PLAIN-Text-Teil; die Sprachstrings nutzen HTML-Entities (fuer die
    // UI), die hier einmalig zu echten UTF-8-Zeichen aufgeloest werden (sonst steht
    // z. B. "M&ouml;glicher" statt "Möglicher" im Plain-Teil). ENT_HTML5 erfasst auch
    // typografische Anfuehrungszeichen. Header bleiben roh. Ist $html gesetzt, wird die
    // Mail als HTML versendet ($body = Plain-Alternative; das $html bleibt unangetastet).
    $htpEntFlags = ENT_QUOTES | (defined('ENT_HTML5') ? ENT_HTML5 : 0);
    $subject     = html_entity_decode((string) $subject, $htpEntFlags, 'UTF-8');
    $body        = html_entity_decode((string) $body, $htpEntFlags, 'UTF-8');
    $htpHtml     = (is_string($html) && $html !== '') ? $html : '';

    // Kommaseparierte Empfaengerliste zulassen, jede Adresse einzeln pruefen.
    $recipients = array();

    foreach (preg_split('/[,;]+/', (string) $to) as $addr) {
        $addr = trim($addr);

        if ($addr !== '' && preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $addr)) {
            $recipients[] = $addr;
        }
    }

    if (empty($recipients)) {
        $error = 'Ungueltige Empfaengeradresse.';

        return false;
    }

    // Absenderdaten aus der globalen Konfiguration
    $fromAddr = '';
    $fromName = 'HTProtect';

    $app = htp_app();

    if ($app && method_exists($app, 'get')) {
        // Joomla-Globaleinstellung "E-Mail versenden" (mailonline)
        if ((int) $app->get('mailonline', 1) === 0) {
            $error = htp_t('COM_HTPROTECT_MAIL_DISABLED');

            return false;
        }

        $cfgAddr = $app->get('mailfrom');
        $cfgName = $app->get('fromname');

        if (is_string($cfgAddr) && $cfgAddr !== '') {
            $fromAddr = $cfgAddr;
        }

        if (is_string($cfgName) && $cfgName !== '') {
            $fromName = $cfgName;
        }
    }

    try {
        $mailer = null;

        // Joomla 4+: Mailer ueber den DI-Container
        if (class_exists('JFactory') && method_exists('JFactory', 'getContainer')) {
            try {
                $container = JFactory::getContainer();

                if ($container && $container->has('Joomla\\CMS\\Mail\\MailerFactoryInterface')) {
                    $factory = $container->get('Joomla\\CMS\\Mail\\MailerFactoryInterface');

                    if ($factory && method_exists($factory, 'createMailer')) {
                        $mailer = $factory->createMailer();
                    }
                }
            } catch (Exception $e) {
                $mailer = null;
            }
        }

        // Fallback: klassischer Mailer (J2.5-J5)
        if ($mailer === null && class_exists('JFactory') && method_exists('JFactory', 'getMailer')) {
            $mailer = JFactory::getMailer();
        }

        if ($mailer === null) {
            $error = 'Es ist kein Joomla-Mailer verfuegbar.';

            return false;
        }

        if ($fromAddr !== '' && method_exists($mailer, 'setSender')) {
            $mailer->setSender(array($fromAddr, $fromName));
        }

        if (method_exists($mailer, 'addRecipient')) {
            $mailer->addRecipient($recipients);
        }

        if (method_exists($mailer, 'setSubject')) {
            $mailer->setSubject($subject);
        }

        if ($htpHtml !== '') {
            // HTML-Mail: HTML-Body + Plain-Text-Alternative (Clients ohne HTML / Spam-Score).
            if (method_exists($mailer, 'isHtml')) {
                $mailer->isHtml(true);
            } elseif (method_exists($mailer, 'isHTML')) {
                $mailer->isHTML(true);
            }

            if (method_exists($mailer, 'setBody')) {
                $mailer->setBody($htpHtml);
            }

            try {
                $mailer->AltBody = $body; // PHPMailer-Eigenschaft
            } catch (Exception $e) {
                // Alt-Teil ist optional
            } catch (Throwable $e) {
                // dito
            }
        } elseif (method_exists($mailer, 'setBody')) {
            $mailer->setBody($body);
        }

        // Optionale Zusatz-Header (z. B. List-Unsubscribe). CRLF-gehaertet gegen
        // Header-Injection - die Werte sind intern, werden aber dennoch bereinigt.
        if (is_array($headers) && !empty($headers) && method_exists($mailer, 'addCustomHeader')) {
            foreach ($headers as $hName => $hVal) {
                $hName = str_replace(array("\r", "\n"), ' ', (string) $hName);
                $hVal  = str_replace(array("\r", "\n"), ' ', (string) $hVal);

                if ($hName !== '' && $hVal !== '') {
                    try {
                        $mailer->addCustomHeader($hName, $hVal);
                    } catch (Exception $e) {
                        // Header optional - der Versand darf nie daran scheitern.
                    }
                }
            }
        }

        if ($replyTo !== '' && preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $replyTo) && method_exists($mailer, 'addReplyTo')) {
            $mailer->addReplyTo($replyTo);
        }

        $result = $mailer->Send();

        if ($result === true || $result === 1) {
            return true;
        }

        // Genauen Fehler aus dem Mailer ziehen, falls vorhanden (PHPMailer)
        if (isset($mailer->ErrorInfo) && is_string($mailer->ErrorInfo) && $mailer->ErrorInfo !== '') {
            $error = $mailer->ErrorInfo;
        } else {
            $error = htp_t('COM_HTPROTECT_MAIL_NOT_ACCEPTED');
        }

        htp_mail_note_fail($error);

        return false;
    } catch (Exception $e) {
        $error = $e->getMessage();

        htp_mail_note_fail($error);

        return false;
    } catch (Throwable $e) {
        $error = $e->getMessage();

        htp_mail_note_fail($error);

        return false;
    }
}

/**
 * Passiver Mail-Health-Fingerzeig (2.6.5): ein ECHTER Versand ueber htp_send_mail ist
 * gescheitert -> Zeitpunkt + Kurzgrund race-sicher merken. Der naechste Heartbeat meldet
 * daraus den Mail-Zustand "defekt"; ein erfolgreicher 24h-Connect-Test loescht die Marke
 * wieder (Erholung). Fail-soft: ohne HtpConfig passiert nichts.
 *
 * @param   string  $error  Kurzgrund des Fehlschlags
 *
 * @return void
 */
function htp_mail_note_fail($error)
{
    if (!class_exists('HtpConfig')) {
        return;
    }

    try {
        HtpConfig::saveKeys(array(
            'mail_last_fail_at' => time(),
            'mail_last_fail'    => substr((string) $error, 0, 160),
        ));
    } catch (Exception $e) {
    } catch (Throwable $e) {
    }
}

/**
 * Leichter Mail-Health-Check OHNE Versand (2.6.5): baut denselben versionsneutralen
 * Joomla-Mailer wie htp_send_mail und prueft bei SMTP nur den Verbindungsaufbau
 * (Handshake/TLS) via PHPMailer::smtpConnect() - es wird NIE eine Mail verschickt.
 * mail/sendmail sind ohne Versand nicht sinnvoll pruefbar -> ok = -1 (untested).
 *
 * @return array{ok:int,err:string}  ok: 1 = ok, 0 = fehlgeschlagen, -1 = nicht pruefbar
 */
function htp_mail_probe()
{
    $app = function_exists('htp_app') ? htp_app() : null;

    if (!$app || !method_exists($app, 'get')) {
        return array('ok' => -1, 'err' => 'no_app');
    }

    if ((int) $app->get('mailonline', 1) === 0) {
        return array('ok' => 0, 'err' => 'disabled');
    }

    if (strtolower((string) $app->get('mailer', 'mail')) !== 'smtp') {
        return array('ok' => -1, 'err' => 'not_smtp');
    }

    $mailer = null;

    try {
        // Joomla 4+: Mailer ueber den DI-Container (wie htp_send_mail).
        if (class_exists('JFactory') && method_exists('JFactory', 'getContainer')) {
            try {
                $container = JFactory::getContainer();

                if ($container && $container->has('Joomla\\CMS\\Mail\\MailerFactoryInterface')) {
                    $factory = $container->get('Joomla\\CMS\\Mail\\MailerFactoryInterface');

                    if ($factory && method_exists($factory, 'createMailer')) {
                        $mailer = $factory->createMailer();
                    }
                }
            } catch (Exception $e) {
                $mailer = null;
            }
        }

        // Fallback: klassischer Mailer (J2.5-J5).
        if ($mailer === null && class_exists('JFactory') && method_exists('JFactory', 'getMailer')) {
            $mailer = JFactory::getMailer();
        }

        if ($mailer === null) {
            return array('ok' => -1, 'err' => 'no_mailer');
        }

        // Kurzer Timeout - der PHPMailer-Default ist 300 s und wuerde den Waechter-Tick blockieren.
        if (property_exists($mailer, 'Timeout')) {
            $mailer->Timeout = 7;
        }
        if (property_exists($mailer, 'SMTPDebug')) {
            $mailer->SMTPDebug = 0;
        }

        if (!method_exists($mailer, 'smtpConnect')) {
            return array('ok' => -1, 'err' => 'no_probe');
        }

        // smtpConnect() baut Verbindung + STARTTLS auf (kein Auth, kein Versand). Fehler ->
        // ErrorInfo. Erreichbarkeit/TLS werden so verifiziert; Auth-/Absender-Fehler zeigen
        // sich erst beim echten Versand (passiv erfasst) bzw. beim On-Abruf-Echttest.
        $ok = @$mailer->smtpConnect();

        if ($ok) {
            if (method_exists($mailer, 'smtpClose')) {
                @$mailer->smtpClose();
            }

            return array('ok' => 1, 'err' => '');
        }

        $err = (isset($mailer->ErrorInfo) && is_string($mailer->ErrorInfo) && $mailer->ErrorInfo !== '')
            ? $mailer->ErrorInfo
            : 'connect_failed';

        return array('ok' => 0, 'err' => substr($err, 0, 200));
    } catch (Exception $e) {
        return array('ok' => 0, 'err' => substr($e->getMessage(), 0, 200));
    } catch (Throwable $e) {
        return array('ok' => 0, 'err' => substr($e->getMessage(), 0, 200));
    }
}

/**
 * Erweiterung aus einem Verzeichnis installieren (Plugin-Mitlieferung).
 * Versionsneutral; fail-silent.
 *
 * @param   string  $dir  Absoluter Pfad zum entpackten Erweiterungsordner
 *
 * @return boolean
 */
function htp_install_from_dir($dir)
{
    $dir = (string) $dir;

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

    try {
        // WICHTIG: getInstance() verwenden, NICHT "new Installer()". Ab Joomla 4
        // ist der Installer DatabaseAwareInterface; getInstance() setzt die
        // Datenbank (setDatabase(... DatabaseInterface ...)), ein blankes
        // "new Installer()" NICHT -> install() wirft mangels DB und schlaegt fehl.
        // getInstance ist hier sicher: ein normaler Komponenten-Request hat keinen
        // laufenden Installer (keine Re-Entrancy), und Joomla installiert selbst
        // mehrere Erweiterungen nacheinander ueber dieselbe Instanz.
        if (class_exists('Joomla\\CMS\\Installer\\Installer')) {
            $installer = method_exists('Joomla\\CMS\\Installer\\Installer', 'getInstance')
                ? Joomla\CMS\Installer\Installer::getInstance()
                : new Joomla\CMS\Installer\Installer();

            return (bool) $installer->install($dir);
        }

        // Joomla 2.5/3
        if (class_exists('JInstaller')) {
            $installer = method_exists('JInstaller', 'getInstance')
                ? JInstaller::getInstance()
                : new JInstaller();

            return (bool) $installer->install($dir);
        }
    } catch (Exception $e) {
        return false;
    }

    return false;
}

/**
 * Verzeichnis rekursiv entfernen (Joomla-Folder bevorzugt, sonst manuell).
 * Versionsneutral, fail-silent.
 *
 * @param   string  $path  Pfad
 *
 * @return boolean
 */
function htp_delete_dir($path)
{
    if (!is_string($path) || $path === '' || !is_dir($path)) {
        return false;
    }

    try {
        if (class_exists('Joomla\\CMS\\Filesystem\\Folder') && Joomla\CMS\Filesystem\Folder::delete($path)) {
            return true;
        }

        if (class_exists('JFolder') && JFolder::delete($path)) {
            return true;
        }
    } catch (Exception $e) {
        // manueller Fallback unten
    } catch (Throwable $e) {
        // manueller Fallback unten
    }

    $items = @scandir($path);

    if (is_array($items)) {
        foreach ($items as $item) {
            if ($item === '.' || $item === '..') {
                continue;
            }

            $sub = $path . '/' . $item;

            if (is_dir($sub)) {
                htp_delete_dir($sub);
            } else {
                @unlink($sub);
            }
        }
    }

    return (bool) @rmdir($path);
}

/**
 * Weicht die installierte Plugin-Hauptdatei vom mitgelieferten Stand ab? So
 * werden auch Fehlerbehebungen an einem mitgelieferten Plugin OHNE Versions-
 * sprung beim naechsten Komponenten-Aufruf zuverlaessig nachgezogen - sonst
 * frieren sie ein, weil der Auto-Sync sonst nur bei HOEHERER Version auffrischt
 * (genau dann erreicht ein Bugfix bei gleichbleibender Version die Installation
 * nie). Vergleicht den SHA1 der Hauptdatei.
 *
 * @param   string  $assetMain  Mitgelieferte Quelle (in den Assets)
 * @param   string  $instMain   Installierte Plugin-Hauptdatei
 *
 * @return boolean
 */
function htp_plugin_file_stale($assetMain, $instMain)
{
    if (!is_file($assetMain) || !is_file($instMain)) {
        return false;
    }

    $a = @sha1_file($assetMain);
    $b = @sha1_file($instMain);

    return is_string($a) && is_string($b) && $a !== '' && $a !== $b;
}

/**
 * Mitgeliefertes Plugin ROBUST (nach)installieren - auch wenn verwaiste Dateien
 * OHNE #__extensions-Eintrag herumliegen (Joomla listet die dann nur unter
 * "Installieren -> Ueberpruefen"/Discover). Genau dieser Rest entsteht, wenn ein
 * Installer-Lauf UEBER bereits vorhandene Dateien fehlschlaegt: Dateien da,
 * Eintrag fehlt - und jeder weitere "ueber vorhandene Dateien"-Versuch scheitert
 * gleich wieder. Loesung: liegen Dateien ohne DB-Eintrag, werden sie ZUERST
 * entfernt; ein sauberer Lauf legt dann Dateien + Eintrag zuverlaessig neu an.
 *
 * @param   string  $dir      Quell-Ordner (mitgeliefertes Plugin in den Assets)
 * @param   string  $element  Plugin-Element (z. B. htprotect, htprotectwatch)
 * @param   string  $folder   Plugin-Gruppe (z. B. quickicon, system)
 *
 * @return boolean  true, wenn danach ein #__extensions-Eintrag existiert
 */
function htp_install_plugin_clean($dir, $element, $folder)
{
    // (1) Erst regulaer versuchen. Existiert ein Eintrag, war es ein normaler
    //     (Upgrade-)Install - nichts wird geloescht. Klappt der Lauf auch ueber
    //     vorhandene Reste, sparen wir uns das Loeschen.
    htp_install_from_dir($dir);

    if (!empty(htp_extension_enabled('plugin', $element, $folder)['installed'])) {
        return true;
    }

    // (2) Immer noch kein Eintrag -> der Lauf ist an verwaisten Dateien ohne
    //     DB-Eintrag gescheitert (nur via "Ueberpruefen" auffindbar). Diese
    //     Reste entfernen und SAUBER neu installieren -> zuverlaessiger Eintrag.
    //     (Eine discoverbare, aber inaktive Datei geht dabei nicht verloren:
    //     sie war ohne Eintrag ohnehin nicht funktionsfaehig.)
    $target = htp_root() . '/plugins/' . $folder . '/' . $element;

    if (is_dir($target)) {
        htp_delete_dir($target);
        htp_install_from_dir($dir);
    }

    return !empty(htp_extension_enabled('plugin', $element, $folder)['installed']);
}

/**
 * Aktivierungszustand einer Erweiterung abfragen.
 *
 * @param   string  $type     Typ (z. B. 'plugin')
 * @param   string  $element  Element (z. B. 'htprotectwatch')
 * @param   string  $folder   Plugin-Gruppe (z. B. 'system') oder ''
 *
 * @return array ['installed' => bool, 'enabled' => bool]
 */
function htp_extension_enabled($type, $element, $folder = '')
{
    $db = htp_db();
    $out = array('installed' => false, 'enabled' => false);

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

    try {
        $query = $db->getQuery(true)
            ->select($db->quoteName('enabled'))
            ->from($db->quoteName('#__extensions'))
            ->where($db->quoteName('type') . ' = ' . $db->quote($type))
            ->where($db->quoteName('element') . ' = ' . $db->quote($element));

        if ($folder !== '') {
            $query->where($db->quoteName('folder') . ' = ' . $db->quote($folder));
        }

        $db->setQuery($query);
        $value = $db->loadResult();

        if ($value !== null) {
            $out['installed'] = true;
            $out['enabled']   = (int) $value === 1;
        }
    } catch (Exception $e) {
        // Defaults
    }

    return $out;
}
