<?php
declare(strict_types=1);

/**
 * Per-bot SQL namespace and scope firewall.
 *
 * Every generated bot uses the mother application's existing MySQL account,
 * but all of its tables live under a private deterministic prefix. SQL is
 * rewritten before execution and then checked so a child cannot accidentally
 * address an unprefixed mother table or another child's namespace.
 */

if (!function_exists('tenant_known_tables')) {
    /** @return array<string,string> lowercase => canonical */
    function tenant_known_tables(): array
    {
        static $tables = null;
        if ($tables !== null) return $tables;

        $canonical = [
            'admin', 'affiliates', 'app', 'botsaz', 'cancel_service',
            'card_number', 'category', 'channels', 'cron_runtime_state',
            'departman', 'Discount', 'DiscountSell', 'Giftcodeconsumed',
            'help', 'Inbound', 'invoice', 'logs_api', 'manualsell',
            'marzban_panel', 'panel', 'Payment_report', 'PaySetting',
            'processed_updates', 'product', 'protocol', 'reagent_report',
            'Requestagent', 'service_other', 'setting', 'shopSetting',
            'support_message', 'textbot', 'topicid', 'user', 'wheel_list',
            'x_ui',
        ];

        $tables = [];
        foreach ($canonical as $name) $tables[strtolower($name)] = $name;
        return $tables;
    }
}

if (!function_exists('tenant_validate_prefix')) {
    function tenant_validate_prefix(string $prefix): string
    {
        if (!preg_match('/^[a-z][a-z0-9_]{8,30}_$/', $prefix)) {
            throw new RuntimeException('پیشوند فضای داده اختصاصی ربات نامعتبر است.');
        }
        return $prefix;
    }
}

if (!function_exists('tenant_table')) {
    function tenant_table(string $table, ?string $prefix = null): string
    {
        $table = trim($table, " `\t\n\r\0\x0B");
        $prefix = tenant_validate_prefix($prefix ?? (string)($GLOBALS['tenant_table_prefix'] ?? ''));

        if (str_starts_with($table, $prefix)) {
            if (!preg_match('/^[A-Za-z0-9_]{1,64}$/', $table)) {
                throw new RuntimeException('نام جدول اختصاصی نامعتبر است.');
            }
            return $table;
        }

        $known = tenant_known_tables();
        $key = strtolower($table);
        if (isset($known[$key])) {
            $canonical = $known[$key];
        } else {
            // Unknown dynamic names are never allowed to fall back to a shared
            // table. They are validated and forced into this tenant's prefix.
            $maxLogicalLength = 64 - strlen($prefix);
            if ($maxLogicalLength < 1 || !preg_match('/^[A-Za-z_][A-Za-z0-9_]{0,' . ($maxLogicalLength - 1) . '}$/', $table)) {
                throw new RuntimeException('نام جدول پویا نامعتبر یا بیش از حد طولانی است.');
            }
            $canonical = $table;
        }

        $result = $prefix . $canonical;
        if (strlen($result) > 64) throw new RuntimeException('نام جدول اختصاصی بیش از حد طولانی است.');
        return $result;
    }
}

if (!function_exists('tenant_like_escape')) {
    function tenant_like_escape(string $value): string
    {
        return strtr($value, ['\\' => '\\\\', '%' => '\\%', '_' => '\\_']);
    }
}

if (!function_exists('tenant_strip_sql_literals')) {
    /**
     * Removes SQL string literals/comments while preserving identifiers and
     * string length. This is used only by the scope firewall, never for query
     * execution.
     */
    function tenant_strip_sql_literals(string $sql): string
    {
        $length = strlen($sql);
        $out = '';
        $state = 'normal';

        for ($i = 0; $i < $length; $i++) {
            $char = $sql[$i];
            $next = $i + 1 < $length ? $sql[$i + 1] : '';

            if ($state === 'normal') {
                if ($char === "'") {
                    $state = 'single';
                    $out .= ' ';
                } elseif ($char === '"') {
                    $state = 'double';
                    $out .= ' ';
                } elseif ($char === '#' ) {
                    $state = 'line';
                    $out .= ' ';
                } elseif ($char === '-' && $next === '-' && ($i + 2 >= $length || ord($sql[$i + 2]) <= 32)) {
                    // MySQL recognizes -- as a comment only when followed by
                    // whitespace/control (or end of input).
                    $state = 'line';
                    $out .= '  ';
                    $i++;
                } elseif ($char === '/' && $next === '*') {
                    $state = 'block';
                    $out .= '  ';
                    $i++;
                } else {
                    $out .= $char;
                }
                continue;
            }

            if ($state === 'line') {
                if ($char === "\n" || $char === "\r") {
                    $state = 'normal';
                    $out .= $char;
                } else {
                    $out .= ' ';
                }
                continue;
            }

            if ($state === 'block') {
                if ($char === '*' && $next === '/') {
                    $state = 'normal';
                    $out .= '  ';
                    $i++;
                } else {
                    $out .= ($char === "\n" || $char === "\r") ? $char : ' ';
                }
                continue;
            }

            // single/double quoted literal
            $quote = $state === 'single' ? "'" : '"';
            if ($char === '\\') {
                $out .= ' ';
                if ($next !== '') {
                    $out .= ' ';
                    $i++;
                }
                continue;
            }
            if ($char === $quote) {
                if ($next === $quote) {
                    $out .= '  ';
                    $i++;
                } else {
                    $state = 'normal';
                    $out .= ' ';
                }
            } else {
                $out .= ($char === "\n" || $char === "\r") ? $char : ' ';
            }
        }

        return $out;
    }
}

if (!function_exists('tenant_assert_identifier_in_scope')) {
    function tenant_assert_identifier_in_scope(string $identifier, string $prefix): void
    {
        $identifier = preg_replace('/\s+/', '', trim($identifier)) ?? trim($identifier);
        $identifier = str_replace('`', '', $identifier);
        if ($identifier === '') return;

        $parts = explode('.', $identifier, 2);
        if (count($parts) === 2) {
            $schema = strtolower($parts[0]);
            $table = $parts[1];
            if (in_array($schema, ['information_schema', 'performance_schema'], true)) return;
            throw new RuntimeException('دسترسی ربات فرزند به دیتابیس یا schema دیگر مسدود شد.');
        }

        $table = $parts[0];
        if (strcasecmp($table, 'dual') === 0) return;
        if (!preg_match('/^[A-Za-z0-9_]{1,64}$/', $table)) {
            throw new RuntimeException('نام جدول در کوئری ربات نامعتبر است.');
        }
        if (!str_starts_with($table, $prefix)) {
            throw new RuntimeException('کوئری خارج از فضای داده اختصاصی این ربات مسدود شد: ' . $table);
        }
    }
}

if (!function_exists('tenant_assert_sql_scope')) {
    /**
     * Fails closed after rewriting. The generated source has no direct raw
     * mysqli/PDO connection, so every executed child query reaches this check.
     */
    function tenant_assert_sql_scope(string $sql, ?string $prefix = null): void
    {
        $prefix = tenant_validate_prefix($prefix ?? (string)($GLOBALS['tenant_table_prefix'] ?? ''));
        if (preg_match('~/\*(?:!|\+)~', $sql)) {
            throw new RuntimeException('کامنت اجرایی SQL در ربات فرزند مجاز نیست.');
        }
        $clean = tenant_strip_sql_literals($sql);
        $withoutTrailingTerminators = preg_replace('/(?:;\s*)+$/', '', trim($clean)) ?? trim($clean);
        if (str_contains($withoutTrailingTerminators, ';')) {
            throw new RuntimeException('اجرای چند دستور SQL در یک درخواست مسدود شد.');
        }

        // Administrative/dynamic-SQL statements are not required by the child
        // source and could bypass table-level isolation. Fail closed.
        if (preg_match('/\b(?:CREATE|ALTER|DROP)\s+(?:DATABASE|SCHEMA|USER|VIEW|TRIGGER|PROCEDURE|FUNCTION|EVENT|SERVER|TABLESPACE)\b|\b(?:GRANT|REVOKE|USE|CALL|HANDLER|PREPARE|EXECUTE|DEALLOCATE\s+PREPARE|LOAD\s+DATA|INTO\s+OUTFILE|INTO\s+DUMPFILE|SHOW\s+DATABASES|INSTALL\s+PLUGIN|UNINSTALL\s+PLUGIN)\b/iu', $clean)) {
            throw new RuntimeException('دستور SQL خارج از نیاز ربات فرزند مسدود شد.');
        }

        // Even if an attacker learns another child's prefix, it cannot be used
        // through this bot's connection wrapper.
        if (preg_match_all('/\bmb[0-9a-f]{8,30}_[A-Za-z_][A-Za-z0-9_]*\b/i', $clean, $matches)) {
            foreach ($matches[0] as $physical) {
                if (!str_starts_with($physical, $prefix)) {
                    throw new RuntimeException('ارجاع به فضای داده ربات دیگر مسدود شد.');
                }
            }
        }

        $name = '(?:(?:`[A-Za-z0-9_$-]+`|[A-Za-z_][A-Za-z0-9_$-]*)\s*\.\s*)?(?:`[A-Za-z0-9_$-]+`|[A-Za-z_][A-Za-z0-9_$-]*)';
        $patterns = [
            '/\b(?:FROM|JOIN|REFERENCES)\s+(' . $name . ')/iu',
            '/\b(?:INSERT|REPLACE)\s+(?:(?:LOW_PRIORITY|DELAYED|HIGH_PRIORITY|IGNORE)\s+)*INTO\s+(' . $name . ')/iu',
            '/\b(?:ALTER|DROP|TRUNCATE|CREATE)\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(' . $name . ')/iu',
            '/\bSHOW\s+CREATE\s+TABLE\s+(' . $name . ')/iu',
            '/^\s*(?:DESC|DESCRIBE)\s+(' . $name . ')/iu',
            '/\b(?:ANALYZE|OPTIMIZE|CHECK|REPAIR)\s+TABLE\s+(' . $name . ')/iu',
            '/\bLOCK\s+TABLES\s+(' . $name . ')/iu',
            '/\bRENAME\s+TABLE\s+(' . $name . ')/iu',
        ];

        foreach ($patterns as $pattern) {
            if (!preg_match_all($pattern, $clean, $references)) continue;
            foreach ($references[1] as $identifier) {
                tenant_assert_identifier_in_scope((string)$identifier, $prefix);
            }
        }

        // UPDATE is checked globally so a stacked statement cannot bypass the
        // firewall. The assignment part of ON DUPLICATE KEY UPDATE is skipped.
        $updatePattern = '/\bUPDATE\s+(?:(?:LOW_PRIORITY|IGNORE)\s+)*(' . $name . ')/iu';
        if (preg_match_all($updatePattern, $clean, $updateReferences, PREG_OFFSET_CAPTURE)) {
            foreach ($updateReferences[0] as $index => $fullMatch) {
                $before = substr($clean, max(0, (int)$fullMatch[1] - 40), min(40, (int)$fullMatch[1]));
                if (preg_match('/(?:DUPLICATE\s+KEY|ON)\s*$/iu', $before)) continue;
                tenant_assert_identifier_in_scope((string)$updateReferences[1][$index][0], $prefix);
            }
        }

        // Old-style comma joins: FROM a, b WHERE ... . Parse only the FROM
        // clause at parenthesis depth zero so SELECT columns, VALUES lists and
        // function arguments can never be mistaken for table identifiers.
        if (preg_match_all('/\bFROM\b/iu', $clean, $fromMatches, PREG_OFFSET_CAPTURE)) {
            $sqlLength = strlen($clean);
            foreach ($fromMatches[0] as $fromMatch) {
                $start = (int)$fromMatch[1] + strlen((string)$fromMatch[0]);
                $depth = 0;
                $end = $sqlLength;
                for ($i = $start; $i < $sqlLength; $i++) {
                    $char = $clean[$i];
                    if ($char === '(') { $depth++; continue; }
                    if ($char === ')') {
                        if ($depth === 0) { $end = $i; break; }
                        $depth--;
                        continue;
                    }
                    if ($depth !== 0) continue;
                    if ($char === ';') { $end = $i; break; }
                    if (($i === 0 || !preg_match('/[A-Za-z0-9_]/', $clean[$i - 1]))
                        && preg_match('/\G(?:WHERE|GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|UNION|FOR\s+UPDATE|LOCK\s+IN\s+SHARE|PROCEDURE|WINDOW)\b/iu', $clean, $clause, 0, $i)) {
                        $end = $i;
                        break;
                    }
                }

                $segment = substr($clean, $start, $end - $start);
                $parts = [];
                $partStart = 0;
                $partDepth = 0;
                $segmentLength = strlen($segment);
                for ($i = 0; $i < $segmentLength; $i++) {
                    if ($segment[$i] === '(') { $partDepth++; continue; }
                    if ($segment[$i] === ')') { if ($partDepth > 0) $partDepth--; continue; }
                    if ($segment[$i] === ',' && $partDepth === 0) {
                        $parts[] = substr($segment, $partStart, $i - $partStart);
                        $partStart = $i + 1;
                    }
                }
                $parts[] = substr($segment, $partStart);

                foreach (array_slice($parts, 1) as $part) {
                    $part = ltrim($part);
                    if ($part === '' || $part[0] === '(') continue;
                    if (preg_match('/^(' . $name . ')/iu', $part, $commaTable)) {
                        tenant_assert_identifier_in_scope((string)$commaTable[1], $prefix);
                    }
                }
            }
        }
    }
}

if (!function_exists('tenant_rewrite_sql')) {
    function tenant_rewrite_sql(string $sql, ?string $prefix = null): string
    {
        $prefix = tenant_validate_prefix($prefix ?? (string)($GLOBALS['tenant_table_prefix'] ?? ''));
        if ($sql === '') return $sql;

        $known = tenant_known_tables();

        // SHOW TABLES is converted into a prefix-filtered listing. Exact LIKE
        // checks are mapped to the physical table in this bot's namespace.
        $sql = preg_replace_callback(
            '/\bSHOW\s+TABLES\s+LIKE\s+([\'\"])([^\'\"]+)\1/iu',
            static function (array $m) use ($prefix, $known): string {
                $name = $m[2];
                if (str_starts_with($name, $prefix)) {
                    $mapped = $name;
                } else {
                    $key = strtolower($name);
                    $mapped = isset($known[$key]) ? $prefix . $known[$key] : $prefix . $name;
                }
                return "SHOW TABLES LIKE '" . str_replace("'", "''", tenant_like_escape($mapped)) . "'";
            },
            $sql
        ) ?? $sql;

        if (preg_match('/^\s*SHOW\s+TABLES\s*;?\s*$/iu', $sql)) {
            return "SHOW TABLES LIKE '" . tenant_like_escape($prefix) . "%'";
        }

        $tableAlternation = implode('|', array_map(
            static fn(string $table): string => preg_quote($table, '/'),
            array_values($known)
        ));

        $patterns = [
            '/(\b(?:FROM|JOIN|REFERENCES)\s+)(`?)(' . $tableAlternation . ')(`?)(?=\s|\.|,|\(|\)|;|$)/iu',
            '/(^\s*(?:EXPLAIN\s+)?UPDATE\s+(?:(?:LOW_PRIORITY|IGNORE)\s+)*)(`?)(' . $tableAlternation . ')(`?)(?=\s|\.|,|\(|\)|;|$)/iu',
            '/(\b(?:INSERT|REPLACE)\s+(?:(?:LOW_PRIORITY|DELAYED|HIGH_PRIORITY|IGNORE)\s+)*INTO\s+)(`?)(' . $tableAlternation . ')(`?)(?=\s|\.|,|\(|\)|;|$)/iu',
            '/(\b(?:ALTER|DROP|TRUNCATE|CREATE)\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?)(`?)(' . $tableAlternation . ')(`?)(?=\s|,|\(|;|$)/iu',
            '/(\bSHOW\s+CREATE\s+TABLE\s+)(`?)(' . $tableAlternation . ')(`?)(?=\s|;|$)/iu',
            '/(^\s*(?:DESC|DESCRIBE)\s+)(`?)(' . $tableAlternation . ')(`?)(?=\s|;|$)/iu',
            '/(\b(?:ANALYZE|OPTIMIZE|CHECK|REPAIR)\s+TABLE\s+)(`?)(' . $tableAlternation . ')(`?)(?=\s|,|;|$)/iu',
        ];

        foreach ($patterns as $pattern) {
            $sql = preg_replace_callback(
                $pattern,
                static function (array $m) use ($prefix, $known): string {
                    $key = strtolower($m[3]);
                    if (!isset($known[$key])) return $m[0];
                    return $m[1] . ($m[2] ?? '') . $prefix . $known[$key] . ($m[4] ?? '');
                },
                $sql
            ) ?? $sql;
        }

        tenant_assert_sql_scope($sql, $prefix);
        return $sql;
    }
}

if (!function_exists('tenant_mysqli_query')) {
    /** @return mysqli_result|bool */
    function tenant_mysqli_query(mysqli $mysql, string $query, int $resultMode = MYSQLI_STORE_RESULT): mysqli_result|bool
    {
        if ($mysql instanceof TenantMysqli) return $mysql->query($query, $resultMode);
        return mysqli_query($mysql, tenant_rewrite_sql($query), $resultMode);
    }
}

if (class_exists('mysqli') && !class_exists('TenantMysqli', false)) {
    final class TenantMysqli extends mysqli
    {
        private string $tenantPrefix;

        public function __construct(
            string $hostname = '',
            string $username = '',
            string $password = '',
            string $database = '',
            int $port = 0,
            ?string $socket = null,
            string $tenantPrefix = ''
        ) {
            $this->tenantPrefix = tenant_validate_prefix($tenantPrefix);
            parent::__construct($hostname, $username, $password, $database, $port, $socket);
        }

        public function query(string $query, int $result_mode = MYSQLI_STORE_RESULT): mysqli_result|bool
        {
            return parent::query(tenant_rewrite_sql($query, $this->tenantPrefix), $result_mode);
        }

        public function prepare(string $query): mysqli_stmt|false
        {
            return parent::prepare(tenant_rewrite_sql($query, $this->tenantPrefix));
        }
    }
}

if (class_exists('PDO') && !class_exists('TenantPDO', false)) {
    final class TenantPDO extends PDO
    {
        private string $tenantPrefix;

        public function __construct(
            string $dsn,
            ?string $username = null,
            ?string $password = null,
            ?array $options = null,
            string $tenantPrefix = ''
        ) {
            $this->tenantPrefix = tenant_validate_prefix($tenantPrefix);
            parent::__construct($dsn, $username, $password, $options ?? []);
        }

        public function prepare(string $query, array $options = []): PDOStatement|false
        {
            return parent::prepare(tenant_rewrite_sql($query, $this->tenantPrefix), $options);
        }

        public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|false
        {
            $query = tenant_rewrite_sql($query, $this->tenantPrefix);
            if ($fetchMode === null) return parent::query($query);
            return parent::query($query, $fetchMode, ...$fetchModeArgs);
        }

        public function exec(string $statement): int|false
        {
            return parent::exec(tenant_rewrite_sql($statement, $this->tenantPrefix));
        }
    }
}
