<?php
/**
 * HtpSearch - flottenweite Inhalts-Suche (Connector-Seite, NUR LESEND).
 *
 * Wird ueber den signierten Befehl 'search.run' (link.php dispatch) SYNCHRON
 * ausgefuehrt und liefert das Ergebnis als kompaktes JSON im log-Feld zurueck
 * (Praezedenzfall: reg.cleanup preview). Das Dashboard liest es via
 * GET /api/v1/commands/{id}/result.
 *
 * DESIGN-ENTSCHEIDUNGEN (bewusst):
 *  - Nur-Lesen: ausschliesslich SELECT, keinerlei Schreibzugriff.
 *  - Content-Tabellen-ALLOW-LISTE (areaSpec): Artikel (#__content introtext/
 *    fulltext), Custom-HTML-Module (#__modules content) und eigene Felder
 *    (#__fields_values value). Serialisierte params-/Registry-Spalten sind
 *    BEWUSST NICHT dabei (Roh-LIKE traefe dort Schluesselnamen/Escapes ->
 *    Falschtreffer + kaputte Snippets). Eigene Felder mit gueltigem JSON-Wert
 *    (Listen/Subforms) werden zur Laufzeit uebersprungen.
 *  - Zero-Knowledge-Ausnahme (wie backup-excludes / reg.cleanup): das Ergebnis
 *    enthaelt gedeckelte, entschaerfte Content-SNIPPETS. Bewusst, weil der
 *    Operator die Site ohnehin per 1-Klick-Login voll einsehen kann; der Relay
 *    bleibt blind fuer Secrets, CommandResult.log ist transient + 2000-gedeckelt.
 *  - Robust/host-schonend: LIKE-Prefilter (DB filtert), seitenweise + hart
 *    gedeckelt (MAX_SCAN), Wall-Clock-Deadline, pcre.backtrack_limit gesenkt,
 *    ueberall fail-open (nie Exception nach oben, fehlende Tabelle = still leer).
 */

defined('_JEXEC') or die;

class HtpSearch
{
    const MAX_SCAN     = 6000;   // max. gelesene Zeilen JE Bereich (Deckel gegen Massentreffer)
    const BATCH        = 500;    // Seitengroesse fuer setQuery-Paginierung
    const MAX_SAMPLES  = 40;     // max. gesammelte Beispiel-Snippets (Zaehlung laeuft weiter)
    const SNIP_CTX     = 60;     // Kontext links/rechts der Trefferstelle (Zeichen)
    const HIT_MAX      = 80;     // laengste dargestellte Trefferzeichenkette
    const TITLE_MAX    = 48;     // Titel deckeln
    const LOG_BUDGET   = 1800;   // json_encode(out) MUSS < 2000 bleiben (Headroom)
    const TERM_MIN     = 2;      // kuerzere Begriffe = zu viele Treffer
    const DEADLINE     = 6.0;    // Wall-Clock-Budget der ganzen Suche (Sekunden)

    /**
     * Einstieg. $params: q (Begriff), mode ('literal'|'regex'), areas (array),
     * limit (Beispielanzahl), published (bool - nur veroeffentlichte).
     *
     * @return array ok/code (+ q/n/hp/sc/tr/m/more bei Erfolg)
     */
    public static function run($params)
    {
        $params = is_array($params) ? $params : array();
        $q      = isset($params['q']) ? trim((string) $params['q']) : '';
        $qlen   = function_exists('mb_strlen') ? mb_strlen($q) : strlen($q);

        if ($qlen < self::TERM_MIN) {
            return array('ok' => false, 'code' => 'term_short');
        }
        if ($qlen > 200) {
            return array('ok' => false, 'code' => 'term_long');
        }

        $mode    = (isset($params['mode']) && $params['mode'] === 'regex') ? 'regex' : 'literal';
        $limit   = isset($params['limit']) ? max(1, min(20, (int) $params['limit'])) : 12;
        $onlyPub = !empty($params['published']);
        $areas   = self::wantedAreas($params);

        // Bei Regex das Muster VORAB validieren (nie ungeprueft in preg_match).
        $rx = '';

        if ($mode === 'regex') {
            $rx = self::compileRegex($q);

            if ($rx === '') {
                return array('ok' => false, 'code' => 'bad_regex');
            }
        }

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

        if (!$db) {
            return array('ok' => false, 'code' => 'no_db');
        }

        // PCRE gegen ReDoS entschaerfen (wie helix.php).
        $oldLimit = @ini_get('pcre.backtrack_limit');
        @ini_set('pcre.backtrack_limit', '200000');

        $deadline  = microtime(true) + self::DEADLINE;
        $count     = 0;   // Gesamt-Treffer (Vorkommen, nicht Zeilen)
        $loc       = 0;   // Fundstellen (Zeile-Spalte mit >=1 Treffer)
        $php       = 0;   // Vorkommen mit PHP-Oeffner in der Naehe
        $scanned   = 0;   // gelesene Zeilen gesamt
        $truncated = false;
        $samples   = array();

        foreach ($areas as $area) {
            $spec = self::areaSpec($area);

            if (!$spec) {
                continue;
            }

            $part = self::scanArea($db, $spec, $q, $mode, $rx, $onlyPub, $deadline, count($samples));

            $count   += $part['count'];
            $loc     += $part['loc'];
            $php     += $part['php'];
            $scanned += $part['scanned'];

            if ($part['truncated']) {
                $truncated = true;
            }

            foreach ($part['samples'] as $s) {
                if (count($samples) < self::MAX_SAMPLES) {
                    $samples[] = $s;
                }
            }

            if (microtime(true) >= $deadline) {
                $truncated = true;
                break;
            }
        }

        if ($oldLimit !== false) {
            @ini_set('pcre.backtrack_limit', $oldLimit);
        }

        // Beispiele auf das Anzeige-Limit kuerzen ...
        $shown = array_slice($samples, 0, $limit);

        $out = array(
            'ok'   => true,
            'code' => 'search_done',
            'q'    => $q,
            'n'    => $count,
            'loc'  => $loc,
            'hp'   => $php,
            'sc'   => $scanned,
            'tr'   => $truncated ? 1 : 0,
            'm'    => array_values($shown),
            'more' => max(0, $loc - count($shown)),
        );

        // ... und HART auf das 2000-Zeichen-Log-Budget garantieren: solange das
        // JSON zu gross ist, das letzte Beispiel abbauen (n/loc bleiben korrekt).
        while (!empty($out['m']) && strlen((string) json_encode($out)) > self::LOG_BUDGET) {
            array_pop($out['m']);
            $out['more'] = max(0, $loc - count($out['m']));
        }

        return $out;
    }

    /** Gewuenschte Bereiche (Whitelist), Default = alle. */
    private static function wantedAreas($params)
    {
        $all = array('art', 'mod', 'fld');

        if (empty($params['areas']) || !is_array($params['areas'])) {
            return $all;
        }

        $out = array();

        foreach ($params['areas'] as $a) {
            $a = (string) $a;

            if (in_array($a, $all, true) && !in_array($a, $out, true)) {
                $out[] = $a;
            }
        }

        return $out ? $out : $all;
    }

    /**
     * Content-Tabellen-Allow-Liste. Jede Spalte hier ist REINER Text/HTML -
     * NIEMALS serialisierte params/Registry (siehe Klassenkommentar).
     */
    private static function areaSpec($area)
    {
        switch ($area) {
            case 'art':
                return array('t' => '#__content', 'id' => 'id', 'ti' => 'title',
                    'cols' => array('introtext', 'fulltext'), 'pub' => 'state', 'json' => false);

            case 'mod':
                return array('t' => '#__modules', 'id' => 'id', 'ti' => 'title',
                    'cols' => array('content'), 'pub' => 'published', 'json' => false);

            case 'fld':
                // #__fields_values.value: reiner Text bei Text-/Editor-/Custom-HTML-Feldern.
                // Listen/Subform-Felder legen GUELTIGES JSON ab -> zur Laufzeit uebersprungen
                // (skipJson). Kein Titel/pub in dieser Tabelle.
                return array('t' => '#__fields_values', 'id' => 'item_id', 'ti' => '',
                    'cols' => array('value'), 'pub' => '', 'json' => true);
        }

        return null;
    }

    /**
     * Einen Bereich (Tabelle) durchsuchen. Literal-Modus nutzt einen LIKE-
     * Prefilter (die DB filtert), Regex-Modus liest gedeckelt + prueft in PHP.
     */
    private static function scanArea($db, $spec, $q, $mode, $rx, $onlyPub, $deadline, $have)
    {
        $count = 0; $loc = 0; $php = 0; $scanned = 0; $truncated = false; $samples = array();

        try {
            $sel = array($db->quoteName($spec['id']));

            if ($spec['ti'] !== '') {
                $sel[] = $db->quoteName($spec['ti']);
            }

            foreach ($spec['cols'] as $c) {
                $sel[] = $db->quoteName($c);
            }

            $offset = 0;

            while ($scanned < self::MAX_SCAN) {
                $query = $db->getQuery(true)
                    ->select($sel)
                    ->from($db->quoteName($spec['t']))
                    ->order($db->quoteName($spec['id']));

                if ($onlyPub && $spec['pub'] !== '') {
                    $query->where($db->quoteName($spec['pub']) . ' = 1');
                }

                // LIKE-Prefilter nur im Literal-Modus: Wildcards des Nutzers werden
                // escaped (escape(...,true)), damit % und _ nicht zu SQL-Wildcards werden.
                if ($mode === 'literal') {
                    $like = $db->quote('%' . $db->escape($q, true) . '%', false);
                    $ors  = array();

                    foreach ($spec['cols'] as $c) {
                        $ors[] = $db->quoteName($c) . ' LIKE ' . $like;
                    }

                    $query->where('(' . implode(' OR ', $ors) . ')');
                }

                $db->setQuery($query, $offset, self::BATCH);
                $rows = $db->loadAssocList();

                if (!is_array($rows) || empty($rows)) {
                    break;
                }

                foreach ($rows as $row) {
                    $scanned++;

                    $id = isset($row[$spec['id']]) ? (int) $row[$spec['id']] : 0;
                    $ti = ($spec['ti'] !== '' && isset($row[$spec['ti']])) ? (string) $row[$spec['ti']] : '';

                    foreach ($spec['cols'] as $c) {
                        $val = isset($row[$c]) ? (string) $row[$c] : '';

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

                        // Eigene Felder: gueltiges JSON (Listen/Subforms) NICHT als Text durchsuchen.
                        if (!empty($spec['json']) && self::looksLikeJson($val)) {
                            continue;
                        }

                        $hits = self::countHits($val, $q, $mode, $rx);

                        if ($hits <= 0) {
                            continue;
                        }

                        $count += $hits;
                        $loc++;
                        $isPhp  = self::hasPhpOpener($val);

                        if ($isPhp) {
                            $php += $hits;
                        }

                        // Beispiel-Snippet nur solange sammeln, bis das globale Limit erreicht ist.
                        if (($have + count($samples)) < self::MAX_SAMPLES) {
                            $snip = self::snippet($val, $q, $mode, $rx);

                            if ($snip) {
                                $samples[] = array(
                                    'ar' => $spec['t'] === '#__content' ? 'art' : ($spec['t'] === '#__modules' ? 'mod' : 'fld'),
                                    'id' => $id,
                                    'ti' => self::cut($ti, self::TITLE_MAX),
                                    'b'  => $snip[0],
                                    'h'  => $snip[1],
                                    'af' => $snip[2],
                                    'p'  => $isPhp ? 1 : 0,
                                );
                            }
                        }
                    }

                    if (microtime(true) >= $deadline) {
                        $truncated = true;
                        break;
                    }
                }

                if ($truncated || count($rows) < self::BATCH) {
                    break;
                }

                $offset += self::BATCH;
            }

            if ($scanned >= self::MAX_SCAN) {
                $truncated = true;
            }
        } catch (Exception $e) {
            // z. B. Tabelle nicht vorhanden -> still als leer behandeln.
        } catch (Throwable $e) {
            // PHP 7+: auch Error/ParseError abfangen -> nie hochblubbern.
        }

        return array('count' => $count, 'loc' => $loc, 'php' => $php, 'scanned' => $scanned,
            'truncated' => $truncated, 'samples' => $samples);
    }

    /** Anzahl der Vorkommen in einem Wert (case-insensitiv im Literal-Modus). */
    private static function countHits($val, $q, $mode, $rx)
    {
        if ($mode === 'regex') {
            $n = @preg_match_all($rx, $val, $m);

            return $n ? (int) $n : 0;
        }

        if (function_exists('mb_strtolower')) {
            return substr_count(mb_strtolower($val, 'UTF-8'), mb_strtolower($q, 'UTF-8'));
        }

        return substr_count(strtolower($val), strtolower($q));
    }

    /** Snippet [before, hit, after] um die ERSTE Fundstelle, multibyte-sicher. */
    private static function snippet($val, $q, $mode, $rx)
    {
        $pos = -1;
        $hit = '';

        if ($mode === 'regex') {
            if (@preg_match($rx, $val, $m, PREG_OFFSET_CAPTURE) === 1) {
                $byte = (int) $m[0][1];
                $hit  = (string) $m[0][0];
                $pos  = function_exists('mb_strlen') ? mb_strlen(substr($val, 0, $byte), 'UTF-8') : $byte;
            }
        } else {
            $pos = function_exists('mb_stripos') ? mb_stripos($val, $q, 0, 'UTF-8') : stripos($val, $q);

            if ($pos !== false && $pos >= 0) {
                // Original-Schreibweise an der Fundstelle uebernehmen.
                $qlen = function_exists('mb_strlen') ? mb_strlen($q, 'UTF-8') : strlen($q);
                $hit  = function_exists('mb_substr') ? mb_substr($val, $pos, $qlen, 'UTF-8') : substr($val, $pos, $qlen);
            } else {
                $pos = -1;
            }
        }

        if ($pos < 0) {
            return null;
        }

        $hlen = function_exists('mb_strlen') ? mb_strlen($hit, 'UTF-8') : strlen($hit);
        $hlen = max(1, $hlen);

        if (function_exists('mb_substr')) {
            $bStart = max(0, $pos - self::SNIP_CTX);
            $before = mb_substr($val, $bStart, $pos - $bStart, 'UTF-8');
            $hitStr = mb_substr($val, $pos, $hlen, 'UTF-8');
            $after  = mb_substr($val, $pos + $hlen, self::SNIP_CTX, 'UTF-8');
        } else {
            $bStart = max(0, $pos - self::SNIP_CTX);
            $before = substr($val, $bStart, $pos - $bStart);
            $hitStr = substr($val, $pos, $hlen);
            $after  = substr($val, $pos + $hlen, self::SNIP_CTX);
        }

        return array(
            self::clean($before),
            self::cut(self::clean($hitStr), self::HIT_MAX),
            self::clean($after),
        );
    }

    /** Whitespace zusammenfassen + Steuerzeichen entfernen (kompakter Snippet). */
    private static function clean($s)
    {
        $s = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]+/', '', (string) $s);
        $s = preg_replace('/\s+/u', ' ', $s);

        if ($s === null) {
            $s = '';
        }

        return trim($s);
    }

    /** Multibyte-sicher auf $max Zeichen kuerzen (mit Ellipse). */
    private static function cut($s, $max)
    {
        $s   = (string) $s;
        $len = function_exists('mb_strlen') ? mb_strlen($s, 'UTF-8') : strlen($s);

        if ($len <= $max) {
            return $s;
        }

        $s = function_exists('mb_substr') ? mb_substr($s, 0, $max - 1, 'UTF-8') : substr($s, 0, $max - 1);

        return $s . "\xE2\x80\xA6"; // Ellipse
    }

    /** Enthaelt der Wert einen PHP-Oeffner (RCE-Relevanz, z. B. {source}<?php ...)? */
    private static function hasPhpOpener($val)
    {
        // Nadel fragmentiert zusammensetzen (Server-AV-Selbstschutz).
        $open = '<' . '?';

        return stripos($val, $open . 'php') !== false || strpos($val, $open . '=') !== false;
    }

    /** Sieht der Wert wie ein serialisierter JSON-Container aus (Liste/Subform)? */
    private static function looksLikeJson($val)
    {
        $t = ltrim((string) $val);

        if ($t === '' || ($t[0] !== '{' && $t[0] !== '[')) {
            return false;
        }

        json_decode($t);

        return json_last_error() === JSON_ERROR_NONE;
    }

    /** Nutzer-Regex sicher kompilieren; '' bei ungueltig. */
    private static function compileRegex($q)
    {
        $pat = '#' . str_replace('#', '\\#', $q) . '#u';

        if (@preg_match($pat, '') !== false) {
            return $pat;
        }

        // /u (UTF-8) manchmal unpassend -> ohne Modifier erneut.
        $pat = '#' . str_replace('#', '\\#', $q) . '#';

        return (@preg_match($pat, '') !== false) ? $pat : '';
    }
}
