<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Defacement-/SEO-Spam-Waechter (F3) - PHASE 1: plumpe Defacements/SEO-Hacks aus
 * EINEM normalen Eigen-Abruf der Startseite. Mechanismus-/anomalie-basiert,
 * FP-arm: ALARM nur auf DEFINITIVE Signale (Inhalt nach </html> mit Fremdlinks,
 * Weiterleitung auf Fremdhost, Defacement-Marker im Titel/H1). Keyword-Cluster
 * sind NUR Label, nie Ausloeser. Baseline wird beim ersten sauberen Lauf STILL
 * gebildet; bei Alarm NICHT aktualisiert (meldet bis zur Behebung weiter).
 *
 * Reine Erkennung (profile/classify/baselineDelta/score) ohne IO -> voll
 * unit-testbar. IO nur im Orchestrator scan(). Phase 2 (Cloaking-Multi-Persona,
 * versteckter Text, Sprach-Mismatch) folgt separat hinter dem FP-Gate.
 */

defined('_JEXEC') or die;

class HtpSeo
{
    // Signal-Gewichte (Defaults; via defs['seo']['weights'] uebersteuerbar)
    const W_AFTER_HTML    = 90;
    const W_REDIRECT      = 95;
    const W_DEFACE_MARKER = 90;
    const W_SCRIPT_INJECT = 92;
    const W_HREFLANG_CLOAK = 90;  // hreflang/amphtml >=2x auf EINE Fremd-Domain, canonical eigen (SEO-Cloaking)

    const ALERT_DEFINITE  = 85;   // ab diesem Gewicht gilt ein Signal als definitiv
    const DELTA_MAJOR     = 0.30; // Text-Aenderungsanteil fuer "major"
    const SHINGLE_CAP     = 1000; // Obergrenze gespeicherter Shingle-Hashes. BEWUSST niedrig: die
                                  // Baseline liegt in #__extensions.params (64-KB-Limit); bei 4000
                                  // belegte allein sie bis ~60 KB und konnte JEDES Speichern der
                                  // Config blockieren. 1000 reicht fuer die Jaccard-Aehnlichkeit
                                  // voellig; Alt-Baselines schrumpfen beim naechsten SEO-Lauf.
    const SEO_FETCH_MAX   = 524288;

    // ─────────────────────────────────────────────────────────────────
    //  Reine Funktionen (testbar)
    // ─────────────────────────────────────────────────────────────────

    /**
     * Seiten-Profil aus gerendertem HTML (rein). $ctx:
     *   own_domains => array(host=>1), final_url => string, site_lang => string
     *
     * @param   string  $html  Gerendertes HTML
     * @param   array   $ctx   Kontext
     *
     * @return array
     */
    public static function profile($html, $ctx)
    {
        $html = (string) $html;
        $own  = (isset($ctx['own_domains']) && is_array($ctx['own_domains'])) ? $ctx['own_domains'] : array();

        $after = self::afterHtml($html);
        $text  = self::normalizeText(self::stripToText($html));

        return array(
            'title'         => self::extractTitle($html),
            'text'          => $text,
            'shingles'      => self::shingles($text),
            'out_domains'   => self::linkDomains($html, $own),
            'after_links'   => self::foreignRefCount($after, '~<a\b[^>]*\bhref\s*=\s*["\']?\s*(https?:)?//([^/"\'\s>]+)~i', $own),
            'after_scripts' => self::foreignRefCount($after, '~<' . self::scTag() . '\b[^>]*\bsrc\s*=\s*["\']?\s*(https?:)?//([^/"\'\s>]+)~i', $own),
            'redirect_to'   => self::redirectHost($ctx, $own),
            'deface_marker' => self::defacementMarker($html),
            'script_inject' => self::scriptInjection($html),
            'hreflang_cloak' => self::hreflangCloak($html, $own),
            // Roh-Regionen (gedeckelt) NUR fuer kuratierte Feed-Signaturen - sie
            // beeinflussen die eingebauten Mechanismen oben in keiner Weise.
            'tail'          => substr($after, 0, 8192),
            'scripts'       => self::scriptSrcs($html),
        );
    }

    /**
     * "script" aus Fragmenten (byte-identisch) - haelt den Quelltext dieser
     * Defacement-/SEO-Erkennung frei von zusammenhaengenden code-aehnlichen Tokens,
     * damit Hoster-Virenscanner ihn nicht selbst fehlmelden. Vgl. HtpHelix::sigParts.
     */
    private static function scTag()
    {
        return 'scr' . 'ipt';
    }

    /** Alle Script-src-URLs als ein String (gedeckelt) - fuer Feed-Signaturen. */
    private static function scriptSrcs($html)
    {
        $out = '';
        $sc  = self::scTag();

        if (preg_match_all('~<' . $sc . '\b[^>]*\bsrc\s*=\s*["\']?([^"\'\s>]+)~i', (string) $html, $m)) {
            $out = implode(' ', $m[1]);
        }

        return substr($out, 0, 4096);
    }

    /**
     * Statische Signale aus EINEM Profil (rein). Keyword-Cluster nur als Label.
     *
     * @param   array  $profile  Profil
     * @param   array  $ctx      Kontext
     * @param   array  $defs     SEO-Defs (Gewichte/Keywords)
     *
     * @return array ['signals'=>[{id,weight,detail}], 'topics'=>[]]
     */
    public static function classify($profile, $ctx, $defs)
    {
        $signals = array();

        // Bewusst NUR Fremd-LINKS nach </html> (klassische Backlink-Farm-
        // Injektion = near-zero-FP). Ein einzelnes nachgestelltes Fremd-SKRIPT
        // (Analytics/Cache/Livechat-Murks) ist zu haeufig legitim -> kein Treiber.
        if ((int) $profile['after_links'] >= 1) {
            $signals[] = array(
                'id'     => 'content_after_html',
                'weight' => self::weight('content_after_html', self::W_AFTER_HTML, $defs),
                'detail' => self::reason('COM_HTPROTECT_SEO_R_AFTERLINKS',
                    (int) $profile['after_links'] . ' Fremdlink(s) nach </html>',
                    (int) $profile['after_links']),
            );
        }

        if ((string) $profile['redirect_to'] !== '') {
            $signals[] = array(
                'id'     => 'redirect_foreign',
                'weight' => self::weight('redirect_foreign', self::W_REDIRECT, $defs),
                'detail' => self::reason('COM_HTPROTECT_SEO_R_REDIRECT',
                    'Weiterleitung auf ' . $profile['redirect_to'], (string) $profile['redirect_to']),
            );
        }

        if ((string) $profile['deface_marker'] !== '') {
            $signals[] = array(
                'id'     => 'defacement_marker',
                'weight' => self::weight('defacement_marker', self::W_DEFACE_MARKER, $defs),
                'detail' => self::reason('COM_HTPROTECT_SEO_R_MARKER',
                    'Defacement-Hinweis: ' . $profile['deface_marker'], (string) $profile['deface_marker']),
            );
        }

        // Eingeschleuster client-seitiger Schad-Script-Loader (Spam-Redirect/
        // Cloaking) - KEINE HTTP-Weiterleitung (Status 200, eigener Host), darum
        // von redirect_foreign nicht sichtbar. Definitiv: in legitimem Code nie.
        if ((string) $profile['script_inject'] !== '') {
            $signals[] = array(
                'id'     => 'script_injection',
                'weight' => self::weight('script_injection', self::W_SCRIPT_INJECT, $defs),
                'detail' => self::reason('COM_HTPROTECT_SEO_R_SCRIPT',
                    'Eingeschleuster Script-Loader: ' . $profile['script_inject'], (string) $profile['script_inject']),
            );
        }

        // hreflang/amphtml zeigen MEHRFACH auf EINE fremde Domain, waehrend canonical
        // die eigene ist -> definitives SEO-Cloaking (gekaperte Sprachroute wie eine
        // untergeschobene /en/index.html -> Fremd-/Casino-Seite). FP-frei: echte
        // mehrsprachige Seiten verlinken hreflang auf die EIGENE Domain.
        if ((string) (isset($profile['hreflang_cloak']) ? $profile['hreflang_cloak'] : '') !== '') {
            $signals[] = array(
                'id'     => 'hreflang_cloak',
                'weight' => self::weight('hreflang_cloak', self::W_HREFLANG_CLOAK, $defs),
                'detail' => self::reason('COM_HTPROTECT_SEO_R_CLOAK',
                    'hreflang/amphtml verweisen auf Fremd-Domain: ' . $profile['hreflang_cloak'],
                    (string) $profile['hreflang_cloak']),
            );
        }

        // Kuratierte Feed-Signaturen als zusaetzliche DEFINITIVE Signale, an die
        // jeweilige FP-sichere Region gekoppelt (tail/scripts/redirect/text). Leerer
        // Feed -> nichts; die eingebauten Mechanismen oben bleiben unveraendert.
        $sigs = (isset($defs['sigs']) && is_array($defs['sigs'])) ? $defs['sigs'] : array();

        foreach (self::feedSignals($profile, $sigs) as $fs) {
            $signals[] = $fs;
        }

        return array('signals' => $signals, 'topics' => self::keywordTopics($profile['text'], $defs));
    }

    /**
     * Kuratierte Feed-Signaturen gegen die Profil-Regionen pruefen (rein/testbar).
     * Jede Signatur deklariert eine REGION ('scope') und deklarative Substrings
     * (all/any/not) - so bleibt der Treffer an eine FP-sichere Stelle gebunden.
     * Ein Treffer ist ein DEFINITIVES Signal (curated). Kein Feed -> leer.
     *
     * @param   array  $profile  Profil (mit Regionen tail/scripts/redirect_to/text)
     * @param   array  $sigs     Normalisierte Feed-Signaturen (HtpDefs::seoSigs())
     *
     * @return array Liste von Signalen
     */
    public static function feedSignals($profile, $sigs)
    {
        $out = array();

        if (!is_array($profile) || !is_array($sigs) || empty($sigs)) {
            return $out;
        }

        // Jede REGION hoechstens EINMAL kleinschreiben (nicht pro Signatur) und nur,
        // wenn ueberhaupt eine Signatur sie nutzt -> der grosse Seitentext wird gar
        // nicht angefasst, solange keine 'text'-Signatur existiert.
        $regions = array();

        foreach ($sigs as $sig) {
            if (!is_array($sig)) {
                continue;
            }

            $scope = isset($sig['scope']) ? (string) $sig['scope'] : 'tail';

            if (!isset($regions[$scope])) {
                if ($scope === 'text') {
                    $raw = isset($profile['text']) ? (string) $profile['text'] : '';
                } elseif ($scope === 'scripts') {
                    $raw = isset($profile['scripts']) ? (string) $profile['scripts'] : '';
                } elseif ($scope === 'redirect') {
                    $raw = isset($profile['redirect_to']) ? (string) $profile['redirect_to'] : '';
                } else {
                    $raw = isset($profile['tail']) ? (string) $profile['tail'] : ''; // tail (Default)
                }

                $regions[$scope] = strtolower($raw);
            }

            $hay = $regions[$scope];

            if ($hay === '' || !self::matchSig($hay, $sig)) {
                continue;
            }

            $kind  = (isset($sig['kind']) && $sig['kind'] === 'defacement') ? 'defacement' : 'seo_spam';
            $label = (isset($sig['label']) && $sig['label'] !== '')
                ? (string) $sig['label']
                : (isset($sig['id']) ? (string) $sig['id'] : 'sig');

            $out[] = array(
                'id'     => 'feed:' . (isset($sig['id']) ? (string) $sig['id'] : 'sig'),
                'kind'   => $kind,
                'weight' => self::ALERT_DEFINITE,
                'detail' => self::reason('COM_HTPROTECT_SEO_R_FEED', 'Bekanntes Schad-Muster: ' . $label, $label),
            );
        }

        return $out;
    }

    /**
     * EINE deklarative Signatur gegen einen (bereits kleingeschriebenen) Heuhaufen
     * pruefen (rein/testbar, lineare strpos -> ReDoS unmoeglich). Needles kommen
     * normalisiert + kleingeschrieben. Kein positives Kriterium (all/any) -> false.
     *
     * @param   string  $hay  Region, kleingeschrieben
     * @param   array   $sig  Signatur
     *
     * @return boolean
     */
    public static function matchSig($hay, $sig)
    {
        if (!is_array($sig)) {
            return false;
        }

        $all = (isset($sig['all']) && is_array($sig['all'])) ? $sig['all'] : array();
        $any = (isset($sig['any']) && is_array($sig['any'])) ? $sig['any'] : array();
        $not = (isset($sig['not']) && is_array($sig['not'])) ? $sig['not'] : array();

        if (empty($all) && empty($any)) {
            return false; // kein Catch-all
        }

        foreach ($all as $n) {
            if (!is_string($n) || $n === '' || strpos($hay, $n) === false) {
                return false;
            }
        }

        if (!empty($any)) {
            $hit = false;

            foreach ($any as $n) {
                if (is_string($n) && $n !== '' && strpos($hay, $n) !== false) {
                    $hit = true;
                    break;
                }
            }

            if (!$hit) {
                return false;
            }
        }

        foreach ($not as $n) {
            if (is_string($n) && $n !== '' && strpos($hay, $n) !== false) {
                return false;
            }
        }

        return true;
    }

    /**
     * Vergleich gegen die Basislinie (rein). Liefert Aenderungs-Schwere +
     * neue Fremddomains. KEIN Alarm-Treiber in Phase 1 (nur Baseline-Politik
     * + Kontext; mittlere Signale erst in Phase 2 ueber dieses Gate).
     *
     * @param   array  $current   Aktuelles Profil
     * @param   array  $baseline  Gespeicherte Basislinie
     *
     * @return array
     */
    public static function baselineDelta($current, $baseline)
    {
        if (!is_array($baseline) || empty($baseline)) {
            return array('first' => true, 'changed' => false, 'severity' => 'none',
                'new_domains' => array(), 'title_changed' => false, 'text_delta' => 0.0);
        }

        $curT = (string) (isset($current['title']) ? $current['title'] : '');
        $oldT = (string) (isset($baseline['title']) ? $baseline['title'] : '');
        $titleChanged = ($curT !== $oldT);

        $curDom = (isset($current['out_domains']) && is_array($current['out_domains'])) ? $current['out_domains'] : array();
        $oldDom = (isset($baseline['out_domains']) && is_array($baseline['out_domains'])) ? $baseline['out_domains'] : array();
        $newDomains = array_values(array_diff(array_keys($curDom), array_keys($oldDom)));

        $textDelta = 1.0 - self::jaccard(
            (isset($current['shingles']) && is_array($current['shingles'])) ? $current['shingles'] : array(),
            (isset($baseline['shingles']) && is_array($baseline['shingles'])) ? $baseline['shingles'] : array()
        );

        $changed  = $titleChanged || $textDelta >= self::DELTA_MAJOR || !empty($newDomains);
        $severity = ($textDelta >= self::DELTA_MAJOR || count($newDomains) >= 5) ? 'major' : ($changed ? 'minor' : 'none');

        return array('first' => false, 'changed' => $changed, 'severity' => $severity,
            'new_domains' => $newDomains, 'title_changed' => $titleChanged, 'text_delta' => round($textDelta, 3));
    }

    /**
     * Gesamturteil (rein). PHASE 1: Alarm NUR bei einem definitiven Signal
     * (>= ALERT_DEFINITE). Keywords/mittlere Signale loesen nie allein aus.
     *
     * @param   array  $classify  Ergebnis von classify()
     * @param   array  $delta     Ergebnis von baselineDelta()
     * @param   array  $cfg       Konfiguration
     * @param   array  $defs      SEO-Defs
     *
     * @return array ['verdict'=>clean|alert,'kind'=>seo_spam|defacement,'reasons'=>[]]
     */
    public static function score($classify, $delta, $cfg, $defs)
    {
        $reasons  = array();
        $definite = false;
        $kind     = 'seo_spam';

        foreach ($classify['signals'] as $s) {
            if ((int) $s['weight'] >= self::ALERT_DEFINITE) {
                $definite  = true;
                $reasons[] = $s['detail'];

                // Defacement, wenn das Signal es selbst sagt (Feed) oder ein
                // eingebauter Defacement-/Hijack-Mechanismus es ist.
                if ((isset($s['kind']) && $s['kind'] === 'defacement')
                    || $s['id'] === 'defacement_marker' || $s['id'] === 'redirect_foreign') {
                    $kind = 'defacement';
                }
            }
        }

        return array('verdict' => $definite ? 'alert' : 'clean', 'kind' => $kind, 'reasons' => $reasons);
    }

    /**
     * Aus den Vorfaellen eines scan()-Laufs die kompakte SEO-/Defacement-
     * Ableitung fuer die Uebersicht/Schnappschuss bilden (rein). Liefert den
     * ersten echten SEO-/Defacement-Befund als array('kind'=>…,'reasons'=>[…])
     * oder ein leeres Array. Betriebliche Vorfaelle (kind 'site_error' =
     * Startseite down) zaehlen hier bewusst NICHT - die haben ihren eigenen
     * Mail-Weg und gehoeren nicht in die SEO-Ampel/Kachel.
     *
     * @param   array  $incidents  Rueckgabe von scan()
     *
     * @return array array('kind'=>string,'reasons'=>array) oder array()
     */
    public static function findingFromIncidents($incidents)
    {
        if (!is_array($incidents)) {
            return array();
        }

        foreach ($incidents as $inc) {
            if (!is_array($inc)) {
                continue;
            }

            $kind = isset($inc['kind']) ? (string) $inc['kind'] : '';

            if ($kind !== 'seo_spam' && $kind !== 'defacement') {
                continue;
            }

            $reasons = (isset($inc['reasons']) && is_array($inc['reasons'])) ? array_values($inc['reasons']) : array();

            return array('kind' => $kind, 'reasons' => $reasons);
        }

        return array();
    }

    // ─────────────────────────────────────────────────────────────────
    //  Orchestrator (IO) - Phase 1: nur die Startseite, 1 Abruf
    // ─────────────────────────────────────────────────────────────────

    /**
     * Einen SEO-Lauf ausfuehren (Startseite). Aktualisiert bei sauberem Lauf die
     * Basislinie in $cfg (Aufrufer speichert). Liefert Vorfaelle.
     *
     * @param   array    $cfg    Konfiguration (Referenz)
     * @param   boolean  $force  manueller "Jetzt pruefen"-Lauf: prueft auch bei
     *                           ausgeschaltetem Schalter (der steuert nur die Automatik)
     *
     * @return array Vorfaelle (kind: defacement|seo_spam, path 'seo:<url>')
     */
    public static function scan(&$cfg, $force = false, $deep = true)
    {
        $incidents = array();

        // Hintergrund-Lauf respektiert den Schalter; ein manueller Lauf ($force)
        // prueft IMMER - der Schalter steuert nur die 6h-Automatik im Hintergrund.
        if ((!$force && empty($cfg['seo_watch'])) || !class_exists('HtpProbe') || !HtpProbe::available()) {
            return $incidents;
        }

        // Host-poisoning-fest: die EIGENE Startseite NUR an einer vertrauenswuerdigen
        // Basis pruefen (admin-gesetztes live_site bzw. die im Backend erfasste echte
        // unsub_base) - NIE am request-abgeleiteten Host (JUri::root). Sonst liesse eine
        // einzige Anfrage mit fremdem/altem Host-Header (Bot, Umzugs-Rest, Wildcard-DNS)
        // den Waechter einen FREMDEN Host abrufen -> Fehlalarm "Startseite kaputt" UND ein
        // request-steuerbarer Outbound-Abruf. Keine vertrauenswuerdige Basis -> nicht pruefen.
        $site = function_exists('htp_trusted_base')
            ? htp_trusted_base(isset($cfg['unsub_base']) ? (string) $cfg['unsub_base'] : '', true)
            : '';

        if ($site === '') {
            return $incidents;
        }

        $wl = (isset($cfg['seo_whitelist']) && is_array($cfg['seo_whitelist'])) ? $cfg['seo_whitelist'] : array();

        if (self::whitelisted($site, $wl)) {
            return $incidents; // Startseite ausdruecklich ausgenommen
        }

        $res    = HtpProbe::fetch($site, true, null, 6);
        $status = (int) $res['status'];
        $body   = (string) $res['body'];

        // Probe selbst abgewiesen/gedrosselt -> KEIN Urteil, KEINE Baseline-Aenderung:
        //  0   = Selbst-Abruf nicht moeglich (Loopback evtl. geblockt)
        //  401 = bewusste Voll-Site-Authentifizierung
        //  403 = Bot-/WAF-/Hotlink-Block gegen den Server-Probe-UA (echte Browser: 200)
        //  429 = Rate-Limit
        // 403/429 bewusst NICHT als "Seite down" werten (sonst Fehlalarm auf gesunden
        // Sites) und NICHT analysieren (sonst Baseline aus der Sperr-/Challenge-Seite).
        if ($status === 0 || $status === 401 || $status === 403 || $status === 429) {
            return $incidents;
        }

        // ── Startseiten-Gesundheit: Fehlerstatus (5xx / 4xx ausser 401 /
        //    Redirect-Schleife) oder weisse/abgebrochene Seite. Bei Problem
        //    EINMAL kurz nachfassen (Transienten-Entprellung) - nur ein
        //    BESTAETIGTES Problem meldet, Basislinie bleibt der gesunde Stand. ──
        if (self::homepageBad($status, $body)) {
            if (function_exists('sleep')) {
                @sleep(3);
            }

            $res2    = HtpProbe::fetch($site, true, null, 6);
            $status2 = (int) $res2['status'];
            $body2   = (string) $res2['body'];

            if ($status2 === 0 || $status2 === 401 || $status2 === 403 || $status2 === 429) {
                return $incidents;
            }

            if (self::homepageBad($status2, $body2)) {
                $incidents[] = array(
                    'path'    => 'seo:' . $site,
                    'kind'    => 'site_error',
                    'url'     => $site,
                    'label'   => $site,
                    'reasons' => array(($status2 >= 200 && $status2 < 300)
                        ? self::reason('COM_HTPROTECT_SEO_R_BLANK', 'leere/abgebrochene Startseite')
                        : 'HTTP ' . $status2),
                );

                return $incidents;
            }

            // War transient -> mit dem gesunden 2. Ergebnis normal weiter.
            $res = $res2; $status = $status2; $body = $body2;
        }

        // ── Gesunde Seite -> Defacement-/SEO-Spam-Analyse ──
        $ctx = array(
            'own_domains' => self::ownDomains($cfg, $site),
            'final_url'   => isset($res['final_url']) ? (string) $res['final_url'] : $site,
            'site_lang'   => '',
        );

        $defs     = self::seoDefs();
        $profile  = self::profile($body, $ctx);
        $classify = self::classify($profile, $ctx, $defs);
        $baseline = (isset($cfg['seo_baseline']) && is_array($cfg['seo_baseline'])) ? $cfg['seo_baseline'] : array();
        $delta    = self::baselineDelta($profile, $baseline);

        // Weiterleitung auf Fremdhost NUR als Hijack werten, wenn sie NEU ist
        // (Baseline-Delta). Eine Seite, die schon immer auf ihre kanonische
        // Fremddomain leitet (Domain-Konsolidierung/Rebrand wie laff-arts.de ->
        // laffarts.de), ist KEIN Defacement -> Signal verwerfen. Bekannte Grenze:
        // eine bei Erstinstallation bereits umgeleitete Site wird still baselined;
        // after-html-Fremdlinks + Defacement-Marker alarmieren beim Erstlauf sehr wohl.
        if (!self::redirectIsNew($profile['redirect_to'], $baseline)) {
            $keep = array();

            foreach ($classify['signals'] as $s) {
                if ($s['id'] !== 'redirect_foreign') {
                    $keep[] = $s;
                }
            }

            $classify['signals'] = $keep;
        }

        $verdict = self::score($classify, $delta, $cfg, $defs);

        if ($verdict['verdict'] === 'alert') {
            $incidents[] = array(
                'path'    => 'seo:' . $site,
                'kind'    => $verdict['kind'],
                'url'     => $site,
                'label'   => $site,
                'reasons' => $verdict['reasons'],
            );
        } else {
            // Sauber -> Basislinie (still) fortschreiben. Bei Alarm bewusst NICHT.
            $cfg['seo_baseline'] = self::baselineFrom($profile);
        }

        // ── Zusaetzliche Sprach-Startseiten (Multi-URL, 2.6.14) ──────────────────────
        // Nur mehrsprachige Sites (Language Filter). Eine untergeschobene /en/index.html
        // (Casino/Cloaking) beschattet die Sprachroute; die Default-Startseite oben sieht
        // sie nicht. Fuer die Zusatz-URLs zaehlen NUR definitiv-auf-Sicht-Signale (Cloaking/
        // Fremdlinks-nach-</html>/Defacement-Marker/Script-Loader) - KEIN redirect_foreign
        // (baseline-abhaengig) und KEIN site_error (Route evtl. nicht eingerichtet -> kein FP).
        // Nur im TIEFEN Lauf (6h-Waechter + manuell), NICHT im schnellen Uebersichts-Check.
        if ($deep) {
            $sefs = self::publishedLanguageSefs();

            // (a) Zusatz-Sprach-Startseiten INHALTLICH pruefen (Multi-URL).
            foreach (self::languageUrlsFromSefs($site, $sefs) as $lurl) {
                if (self::whitelisted($lurl, $wl)) {
                    continue;
                }

                $lr  = HtpProbe::fetch($lurl, true, null, 6);
                $inc = self::analyzeExtraUrl(
                    $lurl,
                    (int) $lr['status'],
                    (string) $lr['body'],
                    isset($lr['final_url']) ? (string) $lr['final_url'] : $lurl,
                    $cfg,
                    $defs
                );

                if ($inc !== null) {
                    $incidents[] = $inc;
                }
            }

            // (b) PHYSISCHE Sprach-Verzeichnisse im Docroot (untergeschobene <sef>/index.html,
            //     die die virtuelle Joomla-Sprachroute beschattet) - inhaltsunabhaengiger Fang.
            if (function_exists('htp_docroot')) {
                foreach (self::languageDirIntrusion((string) htp_docroot(), $sefs) as $rel) {
                    $incidents[] = array(
                        'path'    => 'seo:' . $rel,
                        'kind'    => 'defacement',
                        'url'     => rtrim((string) $site, '/') . '/' . $rel,
                        'label'   => $rel,
                        'reasons' => array(self::reason('COM_HTPROTECT_SEO_R_LANGDIR',
                            'Untergeschobene Datei in einer Sprachroute: ' . $rel, $rel)),
                    );
                }
            }
        }

        return $incidents;
    }

    /**
     * Veroeffentlichte Content-Sprach-SEFs lesen (#__languages, published=1). Basis fuer
     * den Multi-URL-Check UND den physischen Sprach-Verzeichnis-Check. Fail-closed (leer).
     *
     * @return array
     */
    private static function publishedLanguageSefs()
    {
        if (!function_exists('htp_db')) {
            return array();
        }

        try {
            $db = htp_db();

            if (!$db || !method_exists($db, 'getQuery')) {
                return array();
            }

            $q = $db->getQuery(true)
                ->select($db->quoteName('sef'))
                ->from($db->quoteName('#__languages'))
                ->where($db->quoteName('published') . ' = 1');
            $db->setQuery($q, 0, 12);
            $sefs = $db->loadColumn();
        } catch (\Exception $e) {
            return array();
        } catch (\Throwable $e) {
            return array();
        }

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

    /**
     * Physische Sprach-Verzeichnisse im Docroot (testbar). Bei aktivem Language Filter
     * (>=2 Sprachen) sind /sef/ VIRTUELLE Joomla-Routen - ein PHYSISCHER Ordner <sef>/ mit
     * einer static index.html darin beschattet die Route und wird von Apache VOR Joomla
     * ausgeliefert (klassisches SEO-Parasite-Defacement). Joomla erzeugt so etwas nie ->
     * near-zero-FP. Monolingual (<2) -> leer. Liefert die relativen Treffer (z. B. en/index.html).
     *
     * @param   string  $docroot
     * @param   array   $sefs
     *
     * @return array
     */
    public static function languageDirIntrusion($docroot, $sefs)
    {
        $hits = array();

        if (!is_array($sefs) || count($sefs) < 2 || (string) $docroot === '') {
            return $hits;
        }

        $root = rtrim(str_replace('\\', '/', (string) $docroot), '/');
        $seen = array();

        foreach ($sefs as $sef) {
            $sef = strtolower(trim((string) $sef, '/'));

            // Nur echte Sprach-Tags (ll bzw. ll-CC); dedupliziert.
            if ($sef === '' || isset($seen[$sef]) || !preg_match('~^[a-z]{2,3}(-[a-z]{2,4})?$~', $sef)) {
                continue;
            }

            $seen[$sef] = 1;
            $dir        = $root . '/' . $sef;

            if (is_dir($dir) && is_file($dir . '/index.html')) {
                $hits[] = $sef . '/index.html';
            }
        }

        return $hits;
    }

    /**
     * Sprach-Startseiten-URLs aus den SEF-Strings bauen (rein/testbar). Monolingual
     * (<2 Sprachen) -> leer; nur echte Sprach-Tags (ll bzw. ll-CC); dedupliziert.
     *
     * @param   string  $site
     * @param   array   $sefs
     *
     * @return array
     */
    public static function languageUrlsFromSefs($site, $sefs)
    {
        $urls = array();

        // Nur mehrsprachig -> ueberhaupt Sprach-Routen (sonst kein /sef/-Prefix).
        if (!is_array($sefs) || count($sefs) < 2) {
            return $urls;
        }

        $base = rtrim((string) $site, '/');
        $seen = array();

        foreach ($sefs as $sef) {
            $sef = trim((string) $sef, '/');

            // Nur echte Sprach-Tags (ll bzw. ll-CC) - keine krummen SEF-Strings.
            if ($sef === '' || !preg_match('~^[a-z]{2,3}(-[a-z]{2,4})?$~i', $sef)) {
                continue;
            }

            $u = $base . '/' . strtolower($sef) . '/';

            if (!isset($seen[$u])) {
                $seen[$u] = 1;
                $urls[]   = $u;
            }
        }

        return $urls;
    }

    /**
     * Eine ZUSATZ-Sprach-URL analysieren (testbar). Nur bei gesunder 200-Antwort; nur
     * DEFINITIV-auf-Sicht-Signale (redirect_foreign RAUS = baseline-abhaengig; kein
     * site_error = Zusatz-Route evtl. nicht eingerichtet -> kein FP). Vorfall oder null.
     *
     * @return array|null
     */
    public static function analyzeExtraUrl($url, $status, $body, $finalUrl, $cfg, $defs)
    {
        if ($status < 200 || $status >= 300 || self::homepageBad($status, $body)) {
            return null;
        }

        $ctx = array(
            'own_domains' => self::ownDomains($cfg, $url),
            'final_url'   => (string) $finalUrl,
            'site_lang'   => '',
        );

        $classify = self::classify(self::profile($body, $ctx), $ctx, $defs);
        $keep     = array();

        foreach ($classify['signals'] as $s) {
            if ($s['id'] !== 'redirect_foreign') {
                $keep[] = $s;
            }
        }

        $classify['signals'] = $keep;
        $verdict = self::score($classify, array('first' => true), $cfg, $defs);

        if ($verdict['verdict'] !== 'alert') {
            return null;
        }

        return array(
            'path'    => 'seo:' . $url,
            'kind'    => $verdict['kind'],
            'url'     => $url,
            'label'   => $url,
            'reasons' => $verdict['reasons'],
        );
    }

    /**
     * Ist die Startseite NICHT in Ordnung? (rein, testbar) Fehlerstatus
     * (5xx, 4xx ausser 401, 3xx-nach-Folgen = Redirect-Schleife) ODER eine
     * weisse/abgebrochene Seite (2xx, aber winzig UND ohne schliessendes
     * html-Tag - eine gesunde Joomla-Startseite endet immer mit </html>; klein
     * + ohne, damit eine bei MAX_BODY abgeschnittene RIESEN-Seite nicht
     * faelschlich als leer gilt). Status 0/401 faengt der Aufrufer vorab ab.
     *
     * @param   int     $status  HTTP-Status (nach Folgen der Weiterleitungen)
     * @param   string  $body    Antwort-Body
     *
     * @return boolean
     */
    public static function homepageBad($status, $body)
    {
        $status = (int) $status;

        if ($status >= 400 && $status !== 401) {
            return true; // 4xx (ausser bewusster Auth) + 5xx
        }

        if ($status >= 300 && $status < 400) {
            return true; // Weiterleitungs-Schleife (Limit beim Folgen erreicht)
        }

        if ($status >= 200 && $status < 300) {
            return strlen((string) $body) < 2048 && stripos((string) $body, '</html' . '>') === false;
        }

        return false;
    }

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

    private static function baselineFrom($profile)
    {
        return array(
            'title'       => (string) $profile['title'],
            'out_domains' => (isset($profile['out_domains']) && is_array($profile['out_domains'])) ? $profile['out_domains'] : array(),
            'shingles'    => (isset($profile['shingles']) && is_array($profile['shingles'])) ? $profile['shingles'] : array(),
            'redirect_to' => (string) $profile['redirect_to'],
            'ts'          => time(),
        );
    }

    /**
     * Ist die aktuelle Fremd-Weiterleitung NEU gegenueber der Basislinie?
     * (rein, testbar) Nur dann ist sie ein Hijack-Verdacht. Ohne bekannte
     * Baseline-Weiterleitung (Erstlauf / Altformat) wird sie NICHT als neu
     * gewertet (kein Fehlalarm bei legitimer Domain-Konsolidierung).
     *
     * @param   string  $currentRedirect  Aktueller Fremd-Host ('' = keine)
     * @param   array   $baseline         Gespeicherte Basislinie
     *
     * @return boolean
     */
    public static function redirectIsNew($currentRedirect, $baseline)
    {
        $cur = (string) $currentRedirect;

        if ($cur === '') {
            return false; // keine Weiterleitung
        }

        if (!is_array($baseline) || !isset($baseline['redirect_to'])) {
            return false; // keine bekannte Baseline -> nicht als "neu" werten
        }

        return $cur !== (string) $baseline['redirect_to'];
    }

    /** Inhalt nach dem ERSTEN schliessenden html-Tag (oder '') - genau dort sitzt
     *  injizierter Backlink-/Spam-Murks (eine gesunde Seite endet mit </html>). */
    private static function afterHtml($html)
    {
        // Das ECHTE Dokumentende ist das LETZTE </html> - ein frueheres Vorkommen ist
        // eingebetteter Inhalt: ein in einen Beitrag/Seitenbaustein kopiertes komplettes
        // HTML-Fragment (WYSIWYG-Editor, Page Builder) traegt sein eigenes </body></html>
        // mit. stripos() (ERSTES Vorkommen) wertete den restlichen ECHTEN Seiteninhalt
        // dahinter faelschlich als "nach dem Seitenende" - legitime Links auf eigene/Partner-
        // Seiten wurden so als Backlink-Farm-Injektion gemeldet (Fehlalarm auf sauberen
        // Seiten). Eine echte Injektion nach dem Dokument steht weiterhin hinter dem letzten
        // </html> und bleibt sichtbar.
        $pos = strripos($html, '</html' . '>');

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

        return trim((string) substr($html, $pos + 7));
    }

    private static function extractTitle($html)
    {
        if (preg_match('~<title\b[^>]*>(.*?)</title>~is', $html, $m)) {
            return trim(self::decode(strip_tags($m[1])));
        }

        return '';
    }

    /** H1-Text (erstes) - fuer den Defacement-Marker. */
    private static function firstH1($html)
    {
        if (preg_match('~<h1\b[^>]*>(.*?)</h1>~is', $html, $m)) {
            return trim(self::decode(strip_tags($m[1])));
        }

        return '';
    }

    /** Sichtbaren Text gewinnen: script/style raus, Tags raus, dekodieren. */
    private static function stripToText($html)
    {
        $html = (string) $html;

        // preg_replace kann bei PCRE-Limits (riesige Seite) null liefern -> dann
        // den letzten guten Stand behalten (kein null-Durchreichen = Deprecation).
        $html = self::reReplace('~<' . self::scTag() . '\b[^>]*>.*?</' . self::scTag() . '>~is', ' ', $html);
        $html = self::reReplace('~<style\b[^>]*>.*?</style>~is', ' ', $html);
        $html = self::reReplace('~<!--.*?-->~s', ' ', $html);

        return self::decode(strip_tags($html));
    }

    /** preg_replace mit null-Schutz (behaelt die Eingabe bei PCRE-Fehler). */
    private static function reReplace($pattern, $replace, $subject)
    {
        $r = preg_replace($pattern, $replace, (string) $subject);

        return ($r === null) ? (string) $subject : $r;
    }

    /** Normalisieren: lowercase, Nonces/lange Hex raus, Whitespace eindampfen. */
    private static function normalizeText($text)
    {
        $text = (string) $text;
        $text = function_exists('mb_strtolower') ? mb_strtolower($text, 'UTF-8') : strtolower($text);
        $text = self::reReplace('~[0-9a-f]{16,}~', ' ', $text); // CSRF/Nonce/Hash-Reste
        $text = self::reReplace('~\s+~u', ' ', $text);          // ~u liefert null bei kaputtem UTF-8

        return trim($text);
    }

    /** 3-Wort-Shingles, deterministisch 1/8 gesampelt, gedeckelt. */
    private static function shingles($text)
    {
        $words = preg_split('~[^\p{L}\p{N}]+~u', $text, -1, PREG_SPLIT_NO_EMPTY);

        if (!is_array($words) || count($words) < 3) {
            return array();
        }

        $set = array();
        $n   = count($words);

        for ($i = 0; $i + 2 < $n; $i++) {
            $h = crc32($words[$i] . ' ' . $words[$i + 1] . ' ' . $words[$i + 2]);

            if (($h & 7) === 0) {
                $set[$h] = 1;

                if (count($set) >= self::SHINGLE_CAP) {
                    break;
                }
            }
        }

        return $set;
    }

    private static function jaccard($a, $b)
    {
        if (empty($a) && empty($b)) {
            return 1.0; // beide leer = gleich
        }

        if (empty($a) || empty($b)) {
            return 0.0;
        }

        $inter = count(array_intersect_key($a, $b));
        $union = count($a + $b);

        return $union > 0 ? $inter / $union : 1.0;
    }

    /** Fremde Link-Zieldomains aus <a href> (Set host=>1), ohne eigene. */
    private static function linkDomains($html, $own)
    {
        $out = array();

        if (preg_match_all('~<a\b[^>]*\bhref\s*=\s*["\']?\s*(?:https?:)?//([^/"\'\s>]+)~i', $html, $m)) {
            foreach ($m[1] as $host) {
                $host = strtolower($host);

                if ($host !== '' && !isset($own[$host])) {
                    $out[$host] = 1;
                }
            }
        }

        return $out;
    }

    /** Fremd-Referenzen (Link/Skript) in einem HTML-Schnipsel zaehlen. */
    private static function foreignRefCount($snippet, $rx, $own)
    {
        if ((string) $snippet === '' || !preg_match_all($rx, $snippet, $m)) {
            return 0;
        }

        $count = 0;

        foreach ($m[2] as $host) {
            $host = strtolower($host);

            if ($host !== '' && !isset($own[$host])) {
                $count++;
            }
        }

        return $count;
    }

    /** Ziel-Host der (gefolgten) Weiterleitung, falls fremd; sonst ''. */
    private static function redirectHost($ctx, $own)
    {
        $final = isset($ctx['final_url']) ? (string) $ctx['final_url'] : '';
        $host  = strtolower((string) parse_url($final, PHP_URL_HOST));

        if ($host === '' || isset($own[$host])) {
            return '';
        }

        return $host;
    }

    /** Defacement-Marker im Titel oder H1 (near-zero-FP). '' wenn keiner. */
    private static function defacementMarker($html)
    {
        // Parole-Woerter AUS FRAGMENTEN (byte-identisch), damit ein Hoster-Virenscanner
        // diese Schutz-Datei nicht selbst als verdaechtig einstuft.
        $hk = 'hac' . 'ked'; $df = 'defac' . 'ed'; $pw = 'pwn' . 'ed'; $ow = 'own' . 'ed'; $hz = 'hac' . 'kerz?';
        $rx = '~\b(' . $hk . '|' . $df . '|' . $pw . '|' . $ow . '|h[a4]ck[e3]d|' . $hz . ')\s+by\b~i';

        foreach (array(self::extractTitle($html), self::firstH1($html)) as $cand) {
            if ($cand !== '' && preg_match($rx, $cand, $m)) {
                return trim($cand);
            }
        }

        return '';
    }

    /**
     * Eingeschleuster client-seitiger Schad-Script-Loader im ROHEN HTML
     * (near-zero-FP). Faengt Spam-Redirect-/Cloaking-Loader, die KEINE HTTP-
     * Weiterleitung sind (Status 200, eigener Host -> von redirect_foreign nicht
     * sichtbar) und die stripToText sonst entfernt. Liefert eine kurze
     * Marker-Bezeichnung oder ''.
     *
     * @param   string  $html  Rohes (NICHT text-bereinigtes) HTML
     *
     * @return string
     */
    private static function scriptInjection($html)
    {
        $html = (string) $html;

        // (1) Bekannte Kampagnen-Marker - in legitimem Code nie (zero-FP).
        if (preg_match('~<' . self::scTag() . '\b[^>]*\bid\s*=\s*["\']?jmtouch~i', $html)) {
            return 'jmtouch';
        }

        // (2) obfuscator.io-Stil: Hex-Variablen-Deklaration UND Hex-indizierter
        //     String-Array-Zugriff (z. B. "var _0x5a49d0=" + "_0x12a0c0(0x197)").
        //     Diese Doppel-Signatur hat in legitimem/minifiziertem JS praktisch
        //     keine Entsprechung (Minifier nutzen a/b/_t, nie _0x<hex>-Arrays) ->
        //     near-zero-FP. BEIDE Muster noetig, damit ein einzelnes _0x nicht reicht.
        if (preg_match('~\bvar\s+_0x[0-9a-f]{4,10}\s*=~i', $html)
            && preg_match('~_0x[0-9a-f]{4,10}\(0x[0-9a-f]+~i', $html)) {
            return 'obfuskierter Script-Loader';
        }

        return '';
    }

    /**
     * SEO-Cloaking ueber hreflang/amphtml (near-zero-FP). Faengt gekaperte Sprach-/
     * Alternate-Routen (z. B. eine untergeschobene /en/index.html), deren canonical die
     * EIGENE Domain nennt, waehrend MEHRERE hreflang-/amphtml-Alternates auf EINE fremde
     * Domain zeigen (dorthin wird die SEO-Autoritaet umgeleitet). Echte mehrsprachige
     * Seiten verlinken hreflang auf die EIGENE Domain (inkl. Subdomains) -> kein Treffer;
     * eine Domain-Migration nennt die neue Domain auch im canonical -> canonical nicht mehr
     * "eigen" -> kein Treffer. Liefert die Fremd-Domain oder ''.
     *
     * @param   string  $html
     * @param   array   $own   Set eigener Hosts (host=>1)
     *
     * @return string
     */
    private static function hreflangCloak($html, $own)
    {
        $html = (string) $html;

        // Billiges Vor-Gate: ohne <link + (hreflang|amphtml) gibt es nichts zu pruefen.
        if (stripos($html, '<link') === false
            || (stripos($html, 'hreflang') === false && stripos($html, 'amphtml') === false)) {
            return '';
        }

        $canon   = '';
        $foreign = array(); // fremd-host => Anzahl alternate/amphtml-Verweise

        if (preg_match_all('~<link\b[^>]*>~i', $html, $tags)) {
            foreach ($tags[0] as $tag) {
                if (!preg_match('~\brel\s*=\s*["\']?\s*([a-z-]+)~i', $tag, $rm)) {
                    continue;
                }

                $rel = strtolower($rm[1]);

                // Absolute href-Zieldomain (nur mit Host; relative Links ignorieren).
                if (!preg_match('~\bhref\s*=\s*["\']?\s*(?:https?:)?//([^/"\'\s>]+)~i', $tag, $hm)) {
                    continue;
                }

                $host = strtolower($hm[1]);

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

                if ($rel === 'canonical') {
                    if ($canon === '') {
                        $canon = $host;
                    }
                } elseif ($rel === 'amphtml'
                    || ($rel === 'alternate' && stripos($tag, 'hreflang') !== false)) {
                    if (self::isForeignHost($host, $own)) {
                        $foreign[$host] = (isset($foreign[$host]) ? $foreign[$host] : 0) + 1;
                    }
                }
            }
        }

        // Nur wenn die Seite sich selbst als EIGENE Domain ausgibt (canonical eigen)
        // UND >=2 hreflang/amphtml auf EINE fremde Domain zeigen -> Cloaking.
        if ($canon === '' || self::isForeignHost($canon, $own)) {
            return '';
        }

        foreach ($foreign as $host => $n) {
            if ($n >= 2 && $host !== $canon) {
                return $host;
            }
        }

        return '';
    }

    /**
     * Ist $host fremd? Eigene Hosts UND deren Subdomains (x.eigene-domain) gelten als
     * eigen - so loesen legitime hreflang-Alternates auf eigene Subdomains keinen FP aus.
     */
    private static function isForeignHost($host, $own)
    {
        $host = strtolower((string) $host);

        if ($host === '' || isset($own[$host])) {
            return false;
        }

        foreach ($own as $o => $_v) {
            $o = (string) $o;

            if ($o !== '' && substr($host, -(strlen($o) + 1)) === '.' . $o) {
                return false; // Subdomain einer eigenen Domain
            }
        }

        return true;
    }

    private static function whitelisted($url, $wl)
    {
        foreach ($wl as $entry) {
            $entry = trim((string) $entry);

            if ($entry !== '' && stripos($url, $entry) !== false) {
                return true;
            }
        }

        return false;
    }

    /** Eigene Domains (Site-Host + www-Variante), lowercase Set. */
    private static function ownDomains($cfg, $site)
    {
        $host = strtolower((string) parse_url($site, PHP_URL_HOST));
        $set  = array();

        if ($host !== '') {
            $set[$host] = 1;
            $set[preg_replace('~^www\.~', '', $host)] = 1;
            $set['www.' . preg_replace('~^www\.~', '', $host)] = 1;
        }

        return $set;
    }

    private static function decode($s)
    {
        return html_entity_decode((string) $s, ENT_QUOTES, 'UTF-8');
    }

    /**
     * Reason-Text fuer Anzeige/Mail: in Produktion uebersetzt (htp_t, de+en), im
     * reinen Funktionstest (kein CMS -> htp_t undefiniert) der deutsche Fallback.
     * Reiner Praesentationstext - fliesst NIE in Logik/Scoring ein (das nutzt
     * id/weight). Optionale weitere Argumente werden als sprintf-Args durchgereicht.
     *
     * @param   string  $key       Sprachschluessel
     * @param   string  $fallback  Deutscher Fallback (Testharness)
     *
     * @return string
     */
    private static function reason($key, $fallback)
    {
        if (!function_exists('htp_t')) {
            return $fallback;
        }

        $args = func_get_args();
        array_splice($args, 1, 1); // $fallback entfernen -> [$key, optionale sprintf-Args]

        return (string) call_user_func_array('htp_t', $args);
    }

    /** Gewicht aus defs uebersteuern, sonst Default. */
    private static function weight($id, $default, $defs)
    {
        if (isset($defs['weights'][$id])) {
            return (int) $defs['weights'][$id];
        }

        return (int) $default;
    }

    /** SEO-Defs: eingebaute Keywords als Basis; der signierte Feed ergaenzt
     *  Gewichte/Keywords und - vor allem - kuratierte Defacement-/Spam-Signaturen. */
    private static function seoDefs()
    {
        $defs = self::builtinSeoDefs();
        $defs['sigs'] = array();

        if (class_exists('HtpDefs')) {
            if (method_exists('HtpDefs', 'seoDefs')) {
                $d = HtpDefs::seoDefs();

                if (is_array($d)) {
                    if (isset($d['weights']) && is_array($d['weights']) && !empty($d['weights'])) {
                        $defs['weights'] = $d['weights'];
                    }

                    if (isset($d['keywords']) && is_array($d['keywords']) && !empty($d['keywords'])) {
                        $defs['keywords'] = $d['keywords'];
                    }
                }
            }

            if (method_exists('HtpDefs', 'seoSigs')) {
                $s = HtpDefs::seoSigs();

                if (is_array($s)) {
                    $defs['sigs'] = $s;
                }
            }
        }

        return $defs;
    }

    /** Eingebauter Mini-Fallback (Keyword-Cluster nur als Label). */
    private static function builtinSeoDefs()
    {
        return array(
            'weights'  => array(),
            'keywords' => array(
                'casino'  => array('casino', 'slot', 'roulette', 'betting', 'gambling', 'poker', 'jackpot'),
                'pharma'  => array('viagra', 'cialis', 'tadalafil', 'sildenafil', 'pharmacy', 'prescription'),
                'replica' => array('replica watch', 'rolex replica', 'louis vuitton', 'cheap jersey'),
                'loans'   => array('payday loan', 'quick loan', 'cash advance'),
            ),
        );
    }

    /** Getroffene Keyword-Cluster (NUR Label). */
    private static function keywordTopics($text, $defs)
    {
        $topics  = array();
        $clusters = (isset($defs['keywords']) && is_array($defs['keywords'])) ? $defs['keywords'] : array();

        foreach ($clusters as $name => $words) {
            foreach ((array) $words as $w) {
                if ($w !== '' && strpos($text, strtolower($w)) !== false) {
                    $topics[$name] = 1;
                    break;
                }
            }
        }

        return array_keys($topics);
    }
}
