<?php
/**
 * db.php - HtpDb: sicherer, ferngesteuerter DB-Verwaltungs-Kern (Weg A, 2.7.x).
 *
 * ZWECK
 *   Datenbank-Verwaltung aus dem Dashboard - als EIGENE, schlanke Operationen (KEIN ausgeliefertes
 *   Fremd-Tool). Die UI lebt AUSSCHLIESSLICH im Dashboard (Vollbild-Ansicht); der Connector stellt
 *   nur eine klar umrissene Menge signierter, E2E-versiegelter DB-Operationen bereit - exakt das
 *   Modell des Dateimanagers (HtpFiles), nur fuer die Datenbank.
 *
 * SICHERHEITSMODELL (wie HtpFiles)
 *   - OFF-BY-DEFAULT: je Site EINMAL signiert freigeschaltet (db.enable), mit Verfall (Default 1h) als
 *     Sicherheits-Auto-Aus. Schalter dateibasiert (0600-data), DB-unabhaengig.
 *   - Jede Operation ist im Operator-Browser Ed25519-signiert (relay-blind); diese Klasse fuehrt selbst
 *     KEINE Signatur-/Transport-Logik aus - der Aufrufer (onAjax-Endpunkt) hat den Befehl bereits
 *     verifiziert und E2E-entsiegelt und reicht nur Parameter herein. Ergebnisse gehen versiegelt zurueck.
 *   - Es wird die BEREITS VERBUNDENE Joomla-DB genutzt (htp_db()) - keine separaten Zugangsdaten, keine
 *     Fremd-Server (kein SSRF). Der Operator hat als Betreiber ohnehin Vollzugriff auf diese DB.
 *
 * SERVER-VIRENSCANNER
 *   Keine Kommando-Ausfuehrung, keine Laufzeit-Code-Auswertung, keine Aufruf-Indirektion; die
 *   entsprechenden Begriffe stehen bewusst NICHT als Funktions-Literale im Code. Nur benannte,
 *   gewoehnliche DB-/Dateisystem-Aufrufe in klar kommentiertem OOP.
 *
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 */

defined('_JEXEC') or die;

final class HtpDb
{
    /** Zustand des Freischalt-Fensters (JSON: on, enabled_at, expires_at) im 0600-data-Ordner. */
    const STATE_NAME = 'htprotect_db';

    /** Standard-Laufzeit des Freischalt-Fensters (1h, Sicherheits-Auto-Aus) + Obergrenze (12h).
     *  60 Min. genuegen - die Reaktivierung ist ein einziger Klick. Das Fenster ist ab Freischaltung
     *  fix und wird durch Nutzung NICHT verlaengert. */
    const DEFAULT_TTL = 3600;
    const MAX_TTL     = 43200;

    /** Ergebnis-Grenzen (Schutz vor Speicher-/Zeit-Explosion bei grossen Tabellen). */
    const BROWSE_CAP   = 200;   // max. Zeilen je Browse-Seite
    const QUERY_ROWCAP = 2000;  // max. zurueckgegebene Zeilen je SQL-Runner-Lauf
    const CELL_CAP     = 65536; // max. Bytes je Zellwert in der Rueckgabe (grosse Blobs gekuerzt)

    // ─────────────────────────────────────────────────────────────────────────
    //  Freischalt-Fenster (dateibasiert, DB-unabhaengig, mit Verfall)
    // ─────────────────────────────────────────────────────────────────────────

    private static function readState()
    {
        if (!function_exists('htp_state_read')) {
            return array();
        }

        $raw = htp_state_read(self::STATE_NAME);

        if (!is_string($raw) || $raw === '') {
            return array();
        }

        $j = json_decode($raw, true);

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

    private static function writeState($arr)
    {
        if (!function_exists('htp_state_write')) {
            return false;
        }

        return (bool) htp_state_write(self::STATE_NAME, (string) json_encode($arr));
    }

    /** Ist die DB-Verwaltung JETZT freigeschaltet? Standard NEIN; abgelaufenes Fenster gilt als AUS. */
    public static function isEnabled()
    {
        $st = self::readState();

        if (empty($st['on'])) {
            return false;
        }

        return (isset($st['expires_at']) ? (int) $st['expires_at'] : 0) > time();
    }

    /**
     * Freischalten (idempotent-erneuernd, NUR aus signiertem db.enable). Setzt ein Fenster mit Verfall.
     *
     * @param   int  $ttl  Sekunden (0 = Default), geklemmt auf MAX_TTL.
     *
     * @return array{ok:boolean,code:string,expires_at?:int}
     */
    public static function enable($ttl = 0)
    {
        $ttl = (int) $ttl;

        if ($ttl <= 0) {
            $ttl = self::DEFAULT_TTL;
        }
        if ($ttl > self::MAX_TTL) {
            $ttl = self::MAX_TTL;
        }

        $now = time();
        $ok  = self::writeState(array('on' => 1, 'enabled_at' => $now, 'expires_at' => $now + $ttl));

        return $ok
            ? array('ok' => true, 'code' => 'enabled', 'expires_at' => $now + $ttl)
            : array('ok' => false, 'code' => 'write_failed');
    }

    /** Abschalten (idempotent): Fenster schliessen + Zustandsdateien entfernen. */
    public static function disable()
    {
        $wasOn = self::isEnabled();
        self::writeState(array('on' => 0, 'expires_at' => 0));

        if (function_exists('htp_state_file')) {
            foreach (array(self::STATE_NAME . '.php', self::STATE_NAME) as $n) {
                $p = (string) htp_state_file($n);

                if ($p !== '' && @is_file($p)) {
                    @unlink($p);
                }
            }
        }

        return array('ok' => true, 'code' => $wasOn ? 'disabled' : 'already_off');
    }

    /** Anzeige-Status: freigeschaltet + Restlaufzeit. */
    public static function status()
    {
        $st  = self::readState();
        $on  = self::isEnabled();
        $rem = $on ? max(0, (int) $st['expires_at'] - time()) : 0;

        return array('on' => $on, 'remaining' => $rem);
    }

    /**
     * FESTES Fenster (nicht mehr gleitend): Das Freischalt-Fenster laeuft ab enable() eine feste Laufzeit
     * (DEFAULT_TTL, 1h) und wird durch Nutzung NICHT verlaengert. Laeuft es ab, schliesst es hart zu; der
     * Operator schaltet dann per Knopfdruck neu frei. Bewusst No-op, damit die Aufrufer (link.php, nach
     * erfolgreicher Operation) unveraendert bleiben. Parity zu HtpFiles::touch().
     */
    public static function touch()
    {
        // No-op: kein gleitendes Verlaengern mehr - das 1h-Fenster ist ab Freischaltung fix.
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  DB-Zugriff (die bereits verbundene Joomla-DB) + Bezeichner-Quoting
    // ─────────────────────────────────────────────────────────────────────────

    /** Joomla-DB-Treiber (verbunden) oder null. */
    private static function db()
    {
        if (!function_exists('htp_db')) {
            return null;
        }

        $db = htp_db();

        return is_object($db) ? $db : null;
    }

    /** Bezeichner (Tabelle/Spalte) sicher in Backticks setzen: interne Backticks verdoppeln. */
    private static function qi($ident)
    {
        return '`' . str_replace('`', '``', (string) $ident) . '`';
    }

    /** String-Literal fuer eine SQL-Klausel sicher quoten (nutzt den Treiber-Escaper). */
    private static function ql($val)
    {
        $db = self::db();

        if ($db && is_callable([$db, 'quote'])) {
            return (string) $db->quote((string) $val);
        }

        return "'" . str_replace(array('\\', "'"), array('\\\\', "\\'"), (string) $val) . "'";
    }

    /**
     * Fallback-SQL-Splitter, falls der Treiber kein OEFFENTLICH aufrufbares splitSql() bietet (in einer
     * Joomla-Version protected). Trennt an ';' auf Anweisungs-Grenzen und respektiert '..'/".."/`..`-
     * Literale (inkl. Backslash-Escape) sowie -- und #-Zeilenkommentare. Bewusst schlicht (kein DELIMITER
     * / gespeicherte Prozeduren) - deckt normale Dump-Importe ab; der Normalfall nutzt ohnehin splitSql().
     *
     * @param   string  $sql
     *
     * @return array
     */
    private static function splitSqlFallback($sql)
    {
        $sql = (string) $sql;
        $len = strlen($sql);
        $out = array();
        $buf = '';
        $q   = ''; // aktives Quote-Zeichen ('' | ' | " | `)

        for ($i = 0; $i < $len; $i++) {
            $c = $sql[$i];

            if ($q !== '') {
                $buf .= $c;

                if ($c === '\\' && $i + 1 < $len) {
                    $buf .= $sql[++$i]; // Escape im String-Literal
                } elseif ($c === $q) {
                    $q = '';
                }

                continue;
            }

            if (($c === '-' && $i + 1 < $len && $sql[$i + 1] === '-') || $c === '#') {
                while ($i < $len && $sql[$i] !== "\n") {
                    $buf .= $sql[$i++];
                }

                if ($i < $len) {
                    $buf .= "\n";
                }

                continue;
            }

            if ($c === "'" || $c === '"' || $c === '`') {
                $q    = $c;
                $buf .= $c;
                continue;
            }

            if ($c === ';') {
                $out[] = $buf;
                $buf   = '';
                continue;
            }

            $buf .= $c;
        }

        if (trim($buf) !== '') {
            $out[] = $buf;
        }

        return $out;
    }

    /** Zellwert fuer die Rueckgabe saeubern: null bleibt null, grosse Blobs werden gekuerzt markiert. */
    private static function cell($v)
    {
        if ($v === null) {
            return null;
        }

        $v = (string) $v;

        if (strlen($v) > self::CELL_CAP) {
            return substr($v, 0, self::CELL_CAP) . '…';
        }

        // Nicht-UTF8 (Binaerdaten) fuer die JSON-Rueckgabe kenntlich machen, nicht roh durchreichen.
        if (!preg_match('~~u', $v)) {
            return '0x' . bin2hex(substr($v, 0, 64)) . (strlen($v) > 64 ? '…' : '');
        }

        return $v;
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Lese-Operationen: Tabellen, Struktur, Browsen, SQL-Runner
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Alle Tabellen der aktuellen DB + geschaetzte Zeilenzahl + Groesse. Rein lesend.
     *
     * @return array{ok:boolean,code?:string,db?:string,tables?:array}
     */
    public static function tables()
    {
        $db = self::db();

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

        try {
            // DB-Name NICHT via $db->getDatabase() holen - die Methode ist im Joomla-Treiber PROTECTED
            // (method_exists() meldet sie zwar, der Aufruf wirft aber einen Error -> Throwable). Universell
            // sicher ueber SQL:
            $db->setQuery('SELECT DATABASE()');
            $name = (string) $db->loadResult();
            $db->setQuery(
                // Alias bewusst NICHT "rows" - das ist in MySQL 8 ein reserviertes Wort (Syntaxfehler).
                'SELECT TABLE_NAME AS n, TABLE_ROWS AS trows, DATA_LENGTH AS dlen, INDEX_LENGTH AS ilen,'
                . ' TABLE_TYPE AS ttype, ENGINE AS engine'
                . ' FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME'
            );
            $rows = $db->loadAssocList();
        } catch (Exception $e) {
            return array('ok' => false, 'code' => 'query_failed', 'error' => self::errText($e));
        } catch (Throwable $e) {
            return array('ok' => false, 'code' => 'query_failed', 'error' => self::errText($e));
        }

        $out = array();

        foreach ((array) $rows as $r) {
            $out[] = array(
                'name'   => (string) (isset($r['n']) ? $r['n'] : ''),
                'rows'   => (int) (isset($r['trows']) ? $r['trows'] : 0),
                'size'   => (int) (isset($r['dlen']) ? $r['dlen'] : 0) + (int) (isset($r['ilen']) ? $r['ilen'] : 0),
                'view'   => (isset($r['ttype']) && $r['ttype'] === 'VIEW'),
                'engine' => (string) (isset($r['engine']) ? $r['engine'] : ''),
            );
        }

        return array('ok' => true, 'db' => $name, 'tables' => $out);
    }

    /**
     * Struktur einer Tabelle: Spalten (Name/Typ/Null/Default/Key/Extra), Indizes, Fremdschluessel.
     *
     * @param   string  $table
     *
     * @return array{ok:boolean,code?:string,columns?:array,indexes?:array,foreign?:array}
     */
    public static function structure($table)
    {
        $db = self::db();

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

        $t = (string) $table;

        if (!self::tableExists($t)) {
            return array('ok' => false, 'code' => 'no_table');
        }

        try {
            $db->setQuery('SHOW FULL COLUMNS FROM ' . self::qi($t));
            $cols = $db->loadAssocList();

            $db->setQuery('SHOW INDEX FROM ' . self::qi($t));
            $idx = $db->loadAssocList();

            $db->setQuery(
                'SELECT k.COLUMN_NAME AS col, k.REFERENCED_TABLE_NAME AS rtable, k.REFERENCED_COLUMN_NAME AS rcol,'
                . ' k.CONSTRAINT_NAME AS cname'
                . ' FROM information_schema.KEY_COLUMN_USAGE k'
                . ' WHERE k.TABLE_SCHEMA = DATABASE() AND k.TABLE_NAME = ' . self::ql($t)
                . ' AND k.REFERENCED_TABLE_NAME IS NOT NULL'
            );
            $fks = $db->loadAssocList();
        } catch (Exception $e) {
            return array('ok' => false, 'code' => 'query_failed', 'error' => self::errText($e));
        } catch (Throwable $e) {
            return array('ok' => false, 'code' => 'query_failed', 'error' => self::errText($e));
        }

        $columns = array();

        foreach ((array) $cols as $c) {
            $columns[] = array(
                'name'    => (string) (isset($c['Field']) ? $c['Field'] : ''),
                'type'    => (string) (isset($c['Type']) ? $c['Type'] : ''),
                'null'    => (isset($c['Null']) && $c['Null'] === 'YES'),
                'key'     => (string) (isset($c['Key']) ? $c['Key'] : ''),
                'default' => isset($c['Default']) ? $c['Default'] : null,
                'extra'   => (string) (isset($c['Extra']) ? $c['Extra'] : ''),
                'comment' => (string) (isset($c['Comment']) ? $c['Comment'] : ''),
            );
        }

        return array('ok' => true, 'columns' => $columns, 'indexes' => (array) $idx, 'foreign' => (array) $fks);
    }

    /**
     * Tabelle browsen: paginiert, optional sortiert + gefiltert. Struktur-basiert (keine Roh-SQL vom
     * Client): Spalten-/Tabellennamen werden gegen die echte Struktur geprueft, Werte werden gequotet.
     *
     * @param   string  $table
     * @param   array   $opts   {page:int, sort:string, dir:'asc'|'desc', where:[{col,op,val}]}
     *
     * @return array{ok:boolean,code?:string,columns?:array,rows?:array,total?:int,page?:int}
     */
    public static function browse($table, $opts = array())
    {
        $db = self::db();

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

        $t = (string) $table;

        if (!self::tableExists($t)) {
            return array('ok' => false, 'code' => 'no_table');
        }

        $cols = self::columnNames($t);

        if (empty($cols)) {
            return array('ok' => false, 'code' => 'no_columns');
        }

        $colSet = array_fill_keys($cols, true);
        $opts   = is_array($opts) ? $opts : array();

        // WHERE (nur erlaubte Spalten + Operatoren; Werte gequotet).
        $where = self::buildWhere($opts, $colSet);

        // ORDER (nur eine erlaubte Spalte).
        $order = '';

        if (!empty($opts['sort']) && isset($colSet[$opts['sort']])) {
            $dir   = (isset($opts['dir']) && strtolower((string) $opts['dir']) === 'desc') ? 'DESC' : 'ASC';
            $order = ' ORDER BY ' . self::qi($opts['sort']) . ' ' . $dir;
        }

        $page = isset($opts['page']) ? max(0, (int) $opts['page']) : 0;
        $off  = $page * self::BROWSE_CAP;

        try {
            $db->setQuery('SELECT COUNT(*) FROM ' . self::qi($t) . $where);
            $total = (int) $db->loadResult();

            $db->setQuery('SELECT * FROM ' . self::qi($t) . $where . $order
                . ' LIMIT ' . (int) self::BROWSE_CAP . ' OFFSET ' . (int) $off);
            $rows = $db->loadAssocList();
        } catch (Exception $e) {
            return array('ok' => false, 'code' => 'query_failed', 'error' => self::errText($e));
        } catch (Throwable $e) {
            return array('ok' => false, 'code' => 'query_failed', 'error' => self::errText($e));
        }

        return array(
            'ok'      => true,
            'columns' => $cols,
            'rows'    => self::cleanRows($rows),
            'total'   => $total,
            'page'    => $page,
            'perPage' => self::BROWSE_CAP,
        );
    }

    /**
     * SQL-Runner: fuehrt EINE Anweisung aus. Bei SELECT/SHOW/EXPLAIN -> Ergebnis-Grid (gedeckelt),
     * sonst -> betroffene Zeilen. Der Operator ist voll berechtigt (Betreiber); die Sicherheit liegt in
     * der signierten, off-by-default Freischaltung + dem versiegelten Transport, NICHT in SQL-Filterung.
     *
     * @param   string  $sql
     *
     * @return array{ok:boolean,code?:string,kind?:string,columns?:array,rows?:array,affected?:int,truncated?:boolean}
     */
    public static function runSql($sql)
    {
        $db = self::db();

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

        $sql = trim((string) $sql);

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

        $isRead = (bool) preg_match('~^\s*\(?\s*(SELECT|SHOW|EXPLAIN|DESC|DESCRIBE|WITH)\b~i', $sql);

        try {
            if ($isRead) {
                $db->setQuery($sql, 0, self::QUERY_ROWCAP + 1);
                $rows = $db->loadAssocList();
                $rows = is_array($rows) ? $rows : array();
                $trunc = count($rows) > self::QUERY_ROWCAP;

                if ($trunc) {
                    $rows = array_slice($rows, 0, self::QUERY_ROWCAP);
                }

                $cols = !empty($rows) ? array_keys($rows[0]) : array();

                return array(
                    'ok'        => true,
                    'kind'      => 'result',
                    'columns'   => $cols,
                    'rows'      => self::cleanRows($rows),
                    'truncated' => $trunc,
                );
            }

            $db->setQuery($sql);
            $db->execute();
            $aff = is_callable([$db, 'getAffectedRows']) ? (int) $db->getAffectedRows() : 0;

            return array('ok' => true, 'kind' => 'exec', 'affected' => $aff);
        } catch (Exception $e) {
            return array('ok' => false, 'code' => 'sql_error', 'error' => self::errText($e));
        } catch (Throwable $e) {
            return array('ok' => false, 'code' => 'sql_error', 'error' => self::errText($e));
        }
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Schreib-Operationen: Zeile speichern/loeschen/duplizieren, Tabelle leeren/loeschen
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Zeile speichern: INSERT (isNew) oder UPDATE (per Identitaets-WHERE). Spalten werden gegen die
     * echte Struktur geprueft, Werte gequotet; NULL bleibt NULL. UPDATE OHNE WHERE wird abgelehnt
     * (kein versehentliches "alle Zeilen").
     *
     * @param   string   $table
     * @param   array    $set    col=>val (zu schreibende Werte; val===null -> NULL)
     * @param   array    $where  col=>val (Identitaet der Zeile fuer UPDATE)
     * @param   boolean  $isNew  true = INSERT
     *
     * @return array{ok:boolean,code?:string,affected?:int,insert_id?:int}
     */
    public static function saveRow($table, $set, $where, $isNew)
    {
        $db = self::db();

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

        $t = (string) $table;

        if (!self::tableExists($t)) {
            return array('ok' => false, 'code' => 'no_table');
        }

        $colSet = array_fill_keys(self::columnNames($t), true);
        $set    = is_array($set) ? $set : array();
        $assign = array();
        $cols   = array();
        $vals   = array();

        foreach ($set as $c => $v) {
            if (!isset($colSet[$c])) {
                continue; // unbekannte Spalte ignorieren
            }

            $lit          = ($v === null) ? 'NULL' : self::ql((string) $v);
            $assign[]     = self::qi($c) . ' = ' . $lit;
            $cols[]       = self::qi($c);
            $vals[]       = $lit;
        }

        if (empty($assign)) {
            return array('ok' => false, 'code' => 'no_fields');
        }

        try {
            if ($isNew) {
                $db->setQuery('INSERT INTO ' . self::qi($t) . ' (' . implode(', ', $cols) . ') VALUES ('
                    . implode(', ', $vals) . ')');
                $db->execute();

                $id = is_callable([$db, 'insertid']) ? (int) $db->insertid() : 0;

                return array('ok' => true, 'affected' => 1, 'insert_id' => $id);
            }

            $wc = self::whereFromIdentity($where, $colSet);

            if ($wc === '') {
                return array('ok' => false, 'code' => 'no_identity'); // UPDATE ohne WHERE abgelehnt
            }

            $db->setQuery('UPDATE ' . self::qi($t) . ' SET ' . implode(', ', $assign) . $wc . ' LIMIT 1');
            $db->execute();

            return array('ok' => true, 'affected' => is_callable([$db, 'getAffectedRows']) ? (int) $db->getAffectedRows() : 0);
        } catch (Exception $e) {
            return array('ok' => false, 'code' => 'write_failed', 'error' => self::errText($e));
        } catch (Throwable $e) {
            return array('ok' => false, 'code' => 'write_failed', 'error' => self::errText($e));
        }
    }

    /**
     * Zeilen loeschen: jede Identitaet ist ein col=>val-Set (WHERE ... AND ...). Leere Identitaet wird
     * uebersprungen (kein "alle Zeilen"). Je Zeile LIMIT 1.
     *
     * @param   string  $table
     * @param   array   $rows   Liste von col=>val-Maps
     *
     * @return array{ok:boolean,code?:string,affected?:int}
     */
    public static function deleteRows($table, $rows)
    {
        $db = self::db();

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

        $t = (string) $table;

        if (!self::tableExists($t)) {
            return array('ok' => false, 'code' => 'no_table');
        }

        $colSet = array_fill_keys(self::columnNames($t), true);
        $rows   = is_array($rows) ? $rows : array();
        $aff    = 0;

        try {
            foreach ($rows as $ident) {
                $wc = self::whereFromIdentity($ident, $colSet);

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

                $db->setQuery('DELETE FROM ' . self::qi($t) . $wc . ' LIMIT 1');
                $db->execute();
                $aff += is_callable([$db, 'getAffectedRows']) ? (int) $db->getAffectedRows() : 0;
            }
        } catch (Exception $e) {
            return array('ok' => false, 'code' => 'delete_failed', 'error' => self::errText($e));
        } catch (Throwable $e) {
            return array('ok' => false, 'code' => 'delete_failed', 'error' => self::errText($e));
        }

        return array('ok' => true, 'affected' => $aff);
    }

    /** Tabelle leeren (TRUNCATE). */
    public static function truncateTable($table)
    {
        return self::tableAdmin($table, 'TRUNCATE TABLE ');
    }

    /** Tabelle loeschen (DROP). */
    public static function dropTable($table)
    {
        return self::tableAdmin($table, 'DROP TABLE ');
    }

    /** Gemeinsamer, strukturgeprueft Tabellen-Admin (Truncate/Drop). */
    private static function tableAdmin($table, $verb)
    {
        $db = self::db();

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

        $t = (string) $table;

        if (!self::tableExists($t)) {
            return array('ok' => false, 'code' => 'no_table');
        }

        try {
            $db->setQuery($verb . self::qi($t));
            $db->execute();
        } catch (Exception $e) {
            return array('ok' => false, 'code' => 'admin_failed', 'error' => self::errText($e));
        } catch (Throwable $e) {
            return array('ok' => false, 'code' => 'admin_failed', 'error' => self::errText($e));
        }

        return array('ok' => true);
    }

    /** WHERE aus einer Identitaets-Map (nur erlaubte Spalten, Gleichheit; NULL -> IS NULL). '' wenn leer. */
    private static function whereFromIdentity($map, $colSet)
    {
        if (!is_array($map) || empty($map)) {
            return '';
        }

        $parts = array();

        foreach ($map as $c => $v) {
            if (!isset($colSet[$c])) {
                continue;
            }

            $parts[] = self::qi($c) . ($v === null ? ' IS NULL' : ' = ' . self::ql((string) $v));
        }

        return empty($parts) ? '' : ' WHERE ' . implode(' AND ', $parts);
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Export / Import / Suche
    // ─────────────────────────────────────────────────────────────────────────

    /** Export-Obergrenze (Byte) des ROH-SQL - Schutz vor Speicher-/Antwort-Explosion; darueber sauber gekuerzt. */
    const EXPORT_MAX  = 8388608;  // 8 MB (unkomprimiert: seal/transport arbeiten auf dem Roh-SQL)
    /** Hoehere Roh-Obergrenze fuer den gz-Export: seal/transport arbeiten auf den KOMPRIMIERTEN Bytes
     *  (~1/8), daher ist ein groesseres Roh-Budget vertretbar - erlaubt deutlich mehr Daten je Export. */
    const EXPORT_MAX_GZ = 33554432; // 32 MB Roh-SQL -> als .sql.gz ~4 MB Datei
    const SEARCH_CAP  = 500;     // max. Treffer gesamt
    const SEARCH_TBLS = 200;     // max. durchsuchte Tabellen

    /**
     * SQL-Export ausgewaehlter Tabellen (Struktur + Daten). Voll-fidele Werte (kein Zell-Kappen), aber
     * bei der Groessen-Obergrenze sauber abgebrochen. Rueckgabe: {sql, truncated} bzw. bei $gz
     * {gzip:base64(gzencode(sql)), truncated} - dann laedt der Client eine .sql.gz (viel kleiner).
     *
     * @param   array    $tables  Tabellennamen (leer = alle)
     * @param   boolean  $gz      true -> gzip-komprimiert zurueckgeben (kleinere Datei, hoeheres Roh-Budget)
     *
     * @return array{ok:boolean,code?:string,sql?:string,gzip?:string,truncated?:boolean}
     */
    public static function exportSql($tables, $gz = false)
    {
        $db = self::db();

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

        // gz nur wenn zlib da ist; sonst faellt der Export sauber auf unkomprimiert (mit dem kleineren
        // Roh-Budget) zurueck - nie fatal.
        $gz    = $gz && function_exists('gzencode');
        $max   = $gz ? self::EXPORT_MAX_GZ : self::EXPORT_MAX;
        $list  = self::resolveTableList($tables);
        $out   = "-- HTProtect DB-Export\nSET FOREIGN_KEY_CHECKS=0;\n\n";
        $trunc = false;

        try {
            foreach ($list as $t) {
                $db->setQuery('SHOW CREATE TABLE ' . self::qi($t));
                $cr = $db->loadRow();
                $create = is_array($cr) && isset($cr[1]) ? (string) $cr[1] : '';

                $out .= 'DROP TABLE IF EXISTS ' . self::qi($t) . ";\n" . $create . ";\n\n";

                $cols = self::columnNames($t);
                $qcols = array();

                foreach ($cols as $c) {
                    $qcols[] = self::qi($c);
                }

                // Daten in Bloecken lesen (Speicher schonen), als INSERTs anhaengen.
                $offset = 0;

                while (true) {
                    $db->setQuery('SELECT * FROM ' . self::qi($t) . ' LIMIT 500 OFFSET ' . (int) $offset);
                    $rows = $db->loadRowList();

                    if (empty($rows)) {
                        break;
                    }

                    foreach ($rows as $row) {
                        $vals = array();

                        foreach ((array) $row as $v) {
                            $vals[] = ($v === null) ? 'NULL' : self::ql((string) $v);
                        }

                        $out .= 'INSERT INTO ' . self::qi($t) . ' (' . implode(', ', $qcols) . ') VALUES ('
                            . implode(', ', $vals) . ");\n";

                        if (strlen($out) >= $max) {
                            $trunc = true;
                            break 3;
                        }
                    }

                    $offset += 500;
                }

                $out .= "\n";
            }
        } catch (Exception $e) {
            return array('ok' => false, 'code' => 'export_failed', 'error' => self::errText($e));
        } catch (Throwable $e) {
            return array('ok' => false, 'code' => 'export_failed', 'error' => self::errText($e));
        }

        $out .= "SET FOREIGN_KEY_CHECKS=1;\n";

        if ($gz) {
            // gzencode -> Roh-Bytes; base64 fuer den JSON-Transport (die Antwort wird ohnehin versiegelt).
            $packed = @gzencode($out, 9);

            if ($packed !== false) {
                return array('ok' => true, 'gzip' => base64_encode($packed), 'truncated' => $trunc);
            }
            // gzencode unerwartet fehlgeschlagen -> unkomprimiert ausliefern (Client erkennt 'sql').
        }

        return array('ok' => true, 'sql' => $out, 'truncated' => $trunc);
    }

    /**
     * CSV-Export einer Tabelle (optional gefiltert wie browse). Rueckgabe: {csv, truncated}.
     *
     * @param   string  $table
     * @param   array   $opts   {where, sort, dir} wie browse
     *
     * @return array{ok:boolean,code?:string,csv?:string,truncated?:boolean}
     */
    public static function exportCsv($table, $opts = array())
    {
        $db = self::db();

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

        $t = (string) $table;

        if (!self::tableExists($t)) {
            return array('ok' => false, 'code' => 'no_table');
        }

        $cols   = self::columnNames($t);
        $colSet = array_fill_keys($cols, true);
        $opts   = is_array($opts) ? $opts : array();
        $where  = self::buildWhere($opts, $colSet);
        $order  = '';

        if (!empty($opts['sort']) && isset($colSet[$opts['sort']])) {
            $dir   = (isset($opts['dir']) && strtolower((string) $opts['dir']) === 'desc') ? 'DESC' : 'ASC';
            $order = ' ORDER BY ' . self::qi($opts['sort']) . ' ' . $dir;
        }

        $csv   = self::csvLine($cols);
        $trunc = false;
        $off   = 0;

        try {
            while (true) {
                $db->setQuery('SELECT * FROM ' . self::qi($t) . $where . $order . ' LIMIT 1000 OFFSET ' . (int) $off);
                $rows = $db->loadRowList();

                if (empty($rows)) {
                    break;
                }

                foreach ($rows as $row) {
                    $csv .= self::csvLine((array) $row);

                    if (strlen($csv) >= self::EXPORT_MAX) {
                        $trunc = true;
                        break 2;
                    }
                }

                $off += 1000;
            }
        } catch (Exception $e) {
            return array('ok' => false, 'code' => 'export_failed', 'error' => self::errText($e));
        } catch (Throwable $e) {
            return array('ok' => false, 'code' => 'export_failed', 'error' => self::errText($e));
        }

        return array('ok' => true, 'csv' => $csv, 'truncated' => $trunc);
    }

    /**
     * SQL-Import: fuehrt ein Skript aus (mehrere Anweisungen). Nutzt den Treiber-Splitter (quoting-sicher),
     * fuehrt Anweisung fuer Anweisung aus und sammelt Fehler (gedeckelt), statt beim ersten abzubrechen.
     * Bei $gz ist $sql die base64-kodierte .sql.gz-Datei (der Client sendet komprimiert -> kleiner Transport).
     *
     * @param   string   $sql
     * @param   boolean  $gz   true -> $sql ist base64(gzip); hier entpacken
     *
     * @return array{ok:boolean,code?:string,executed?:int,errors?:array}
     */
    public static function importSql($sql, $gz = false)
    {
        $db = self::db();

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

        $sql = (string) $sql;

        // gz-Import: der Client schickt base64(gzip-Datei) -> hier entpacken. Nie fatal, saubere Codes.
        if ($gz) {
            if (!function_exists('gzdecode')) {
                return array('ok' => false, 'code' => 'no_zlib');
            }

            $raw = base64_decode($sql, true);
            $dec = ($raw === false) ? false : @gzdecode($raw);

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

            $sql = (string) $dec;
        }

        if (trim($sql) === '') {
            return array('ok' => false, 'code' => 'empty');
        }

        $stmts = is_callable([$db, 'splitSql']) ? (array) $db->splitSql($sql) : self::splitSqlFallback($sql);
        $done  = 0;
        $errs  = array();

        foreach ($stmts as $s) {
            $s = trim((string) $s);

            if ($s === '' || strncmp($s, '--', 2) === 0 || strncmp($s, '/*', 2) === 0) {
                continue;
            }

            try {
                $db->setQuery($s);
                $db->execute();
                $done++;
            } catch (Exception $e) {
                $errs[] = array('stmt' => self::clip($s, 120), 'error' => self::errText($e));
            } catch (Throwable $e) {
                $errs[] = array('stmt' => self::clip($s, 120), 'error' => self::errText($e));
            }

            if (count($errs) > 100) {
                break;
            }
        }

        return array('ok' => empty($errs), 'executed' => $done, 'errors' => $errs);
    }

    /**
     * Volltext-Suche eines Teilstrings ueber Text-Spalten (gedeckelt: Tabellen, Treffer). Rueckgabe je
     * Treffer: {table, count}. Rein lesend, budgetiert.
     *
     * @param   string  $needle
     * @param   array   $tables  optional eingeschraenkt
     *
     * @return array{ok:boolean,code?:string,matches?:array,capped?:boolean}
     */
    public static function search($needle, $tables = array())
    {
        $db = self::db();

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

        $needle = (string) $needle;

        if (strlen($needle) < 2) {
            return array('ok' => false, 'code' => 'too_short');
        }

        $like    = self::ql('%' . $needle . '%');
        $list    = array_slice(self::resolveTableList($tables), 0, self::SEARCH_TBLS);
        $matches = array();
        $capped  = false;

        try {
            foreach ($list as $t) {
                // nur text-artige Spalten in die OR-Bedingung nehmen.
                $db->setQuery('SELECT COLUMN_NAME FROM information_schema.COLUMNS'
                    . ' WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ' . self::ql($t)
                    . " AND DATA_TYPE IN ('char','varchar','text','tinytext','mediumtext','longtext','enum','set')");
                $tcols = array_map('strval', (array) $db->loadColumn());

                if (empty($tcols)) {
                    continue;
                }

                $ors = array();

                foreach ($tcols as $c) {
                    $ors[] = self::qi($c) . ' LIKE ' . $like;
                }

                $db->setQuery('SELECT COUNT(*) FROM ' . self::qi($t) . ' WHERE ' . implode(' OR ', $ors));
                $cnt = (int) $db->loadResult();

                if ($cnt > 0) {
                    $matches[] = array('table' => $t, 'count' => $cnt);

                    if (count($matches) >= self::SEARCH_CAP) {
                        $capped = true;
                        break;
                    }
                }
            }
        } catch (Exception $e) {
            return array('ok' => false, 'code' => 'search_failed', 'error' => self::errText($e));
        } catch (Throwable $e) {
            return array('ok' => false, 'code' => 'search_failed', 'error' => self::errText($e));
        }

        return array('ok' => true, 'matches' => $matches, 'capped' => $capped);
    }

    /** Tabellen-Liste aufloesen: gegebene (nur existierende) oder alle. */
    private static function resolveTableList($tables)
    {
        $db = self::db();

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

        if (is_array($tables) && !empty($tables)) {
            $out = array();

            foreach ($tables as $t) {
                if (self::tableExists((string) $t)) {
                    $out[] = (string) $t;
                }
            }

            return $out;
        }

        try {
            $db->setQuery('SELECT TABLE_NAME FROM information_schema.TABLES'
                . " WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME");

            return array_map('strval', (array) $db->loadColumn());
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }
    }

    /** Eine CSV-Zeile (RFC-4180-artig: Felder in Anfuehrungszeichen, interne verdoppelt). */
    private static function csvLine($fields)
    {
        $cells = array();

        foreach ((array) $fields as $f) {
            $s = ($f === null) ? '' : (string) $f;

            if (!preg_match('~~u', $s)) {
                $s = '0x' . bin2hex(substr($s, 0, 64)) . (strlen($s) > 64 ? '…' : '');
            }

            $cells[] = '"' . str_replace('"', '""', $s) . '"';
        }

        return implode(',', $cells) . "\r\n";
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Interne Helfer
    // ─────────────────────────────────────────────────────────────────────────

    /** Existiert die Tabelle in der aktuellen DB? (gegen information_schema geprueft) */
    private static function tableExists($t)
    {
        $db = self::db();

        if (!$db || (string) $t === '') {
            return false;
        }

        try {
            $db->setQuery('SELECT 1 FROM information_schema.TABLES'
                . ' WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ' . self::ql($t) . ' LIMIT 1');

            return (int) $db->loadResult() === 1;
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }
    }

    /** Spaltennamen einer Tabelle (aus der echten Struktur), oder leeres Array. */
    private static function columnNames($t)
    {
        $db = self::db();

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

        try {
            $db->setQuery('SELECT COLUMN_NAME FROM information_schema.COLUMNS'
                . ' WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ' . self::ql($t)
                . ' ORDER BY ORDINAL_POSITION');

            return array_map('strval', (array) $db->loadColumn());
        } catch (Exception $e) {
            return array();
        } catch (Throwable $e) {
            return array();
        }
    }

    /** WHERE-Klausel aus strukturierten Filtern bauen (nur erlaubte Spalten + Operatoren). '' wenn keine. */
    private static function buildWhere($opts, $colSet)
    {
        if (empty($opts['where']) || !is_array($opts['where'])) {
            return '';
        }

        $allowedOps = array('=', '!=', '<', '<=', '>', '>=', 'LIKE', 'NOT LIKE');
        $parts      = array();

        foreach ($opts['where'] as $w) {
            if (!is_array($w) || empty($w['col']) || !isset($colSet[$w['col']])) {
                continue;
            }

            $op = isset($w['op']) ? strtoupper((string) $w['op']) : '=';

            if (!in_array($op, $allowedOps, true)) {
                $op = '=';
            }

            $val = isset($w['val']) ? (string) $w['val'] : '';

            if (($op === 'LIKE' || $op === 'NOT LIKE') && strpos($val, '%') === false && strpos($val, '_') === false) {
                $val = '%' . $val . '%';
            }

            $parts[] = self::qi($w['col']) . ' ' . $op . ' ' . self::ql($val);
        }

        return empty($parts) ? '' : ' WHERE ' . implode(' AND ', $parts);
    }

    /** Ergebniszeilen fuer die Rueckgabe saeubern (Zellwerte kappen/markieren). */
    private static function cleanRows($rows)
    {
        $out = array();

        foreach ((array) $rows as $r) {
            $row = array();

            foreach ((array) $r as $k => $v) {
                $row[(string) $k] = self::cell($v);
            }

            $out[] = $row;
        }

        return $out;
    }

    /** Fehlermeldung fuer die (versiegelte) Rueckgabe an den Operator - kompakt, ohne Stacktrace. */
    private static function errText($e)
    {
        $m = method_exists($e, 'getMessage') ? (string) $e->getMessage() : 'error';

        return self::clip($m, 500);
    }

    /**
     * String auf hoechstens $max Zeichen kuerzen - multibyte-sicher WENN mbstring da ist, sonst
     * byte-basiert (fuer reine Debug-/Vorschau-Strings unbedenklich). Bewusst OHNE harte mbstring-
     * Abhaengigkeit: der Connector darf auch auf einem (falsch konfigurierten) Host ohne mbstring nie
     * fatal werden - Joomla verlangt mbstring zwar, aber diese Klasse soll es nicht voraussetzen.
     */
    private static function clip($s, $max)
    {
        $s = (string) $s;

        if (function_exists('mb_substr')) {
            return mb_substr($s, 0, $max);
        }

        return substr($s, 0, $max);
    }
}
