<?php

declare(strict_types=1);

namespace ShieldX\Security;

defined('ABSPATH') || exit;

// phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The WAF intentionally inspects raw request/server surfaces before WordPress normalizes them.

use ShieldX\Support\RequestPath;

final class MinimalWaf
{
    public const ENGINE_VERSION = 'shieldx-regex-waf-v3';
    public const MAX_BODY_BYTES = WafSettings::DEFAULT_MAX_BODY_INSPECTION_BYTES;
    private const MAX_HEADER_VALUE_BYTES = 2048;
    private const MAX_PATTERN_EVALUATIONS = 512;
    private const MAX_EVALUATION_TIME_MS = 35;
    private const PCRE_LIMIT_MATCH = 50000;
    private const PCRE_LIMIT_RECURSION = 10000;

    /**
     * @var array<int,array<string,mixed>>
     */
    private $rules;

    /**
     * @var array<string,string>
     */
    private static $clientIpCache = [];

    /** @var array<int,string>|null */
    private static $serverHostnameIps = null;

    /** @var string */
    private $mode;

    /** @var int */
    private $maxBodyBytes;

    /** @var bool */
    private $anomalyThresholdEnabled;

    /** @var int */
    private $anomalyChallengeThreshold;

    /** @var int */
    private $anomalyBlockThreshold;

    public function __construct(?array $rules = null, ?WafSettings $settings = null)
    {
        $settings = $settings ?: new WafSettings();
        $this->mode = $settings->mode();
        $this->maxBodyBytes = $settings->maxBodyInspectionBytes();
        $this->anomalyThresholdEnabled = $settings->anomalyThresholdEnabled();
        $this->anomalyChallengeThreshold = $settings->anomalyChallengeThreshold();
        $this->anomalyBlockThreshold = $settings->anomalyBlockThreshold();
        $rules = $settings->applyToRules($rules ?: self::defaultRules());
        $this->rules = self::prepareRules($rules);
    }

    public function mode(): string
    {
        return $this->mode;
    }

    public function engineVersion(): string
    {
        return self::ENGINE_VERSION;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function rules(): array
    {
        return $this->rules;
    }

    /**
     * @param array<string,mixed>|null $server
     */
    public function needsRequestBody(string $method = '', ?array $server = null): bool
    {
        $usesBody = self::ruleSetUsesSurface($this->rules, 'body')
            || self::ruleSetUsesSurface($this->rules, 'all');
        if (!$usesBody) {
            return false;
        }

        $method = strtoupper(trim($method));
        if (in_array($method, ['GET', 'HEAD', 'OPTIONS'], true)) {
            return $server !== null && self::requestHasEntityBody($server);
        }

        return true;
    }

    /**
     * @return array<string,mixed>
     */
    public function ruleSummary(): array
    {
        return self::summarizeRules($this->rules);
    }

    /**
     * @param array<string,mixed> $request
     * @return array<string,mixed>
     */
    public function evaluate(array $request): array
    {
        $startedAt = microtime(true);
        $matched = [];
        $details = [];
        $score = 0;
        $shouldBlock = false;
        $shouldChallenge = false;
        $wouldBlock = false;
        $blockingRules = [];
        $challengeRules = [];
        $patternEvaluations = 0;
        $budgetExhausted = false;
        $budgetExhaustedReason = '';
        $pcreErrors = [];
        $scoreThresholdAction = 'none';
        $deadline = $startedAt + (self::MAX_EVALUATION_TIME_MS / 1000);
        $surfaces = self::inspectionSurfaces($request, $this->maxBodyBytes, self::requiredInspectionSurfaces($this->rules));

        foreach ($this->rules as $rule) {
            $budgetExhaustedReason = self::evaluationBudgetReason($deadline, $patternEvaluations);
            if ($budgetExhaustedReason !== '') {
                $budgetExhausted = true;
                break;
            }

            if (isset($rule['enabled']) && $rule['enabled'] === false) {
                continue;
            }

            if (!self::ruleMatchesRequestEnvelope($rule, $request)) {
                continue;
            }

            $ruleId = (string) ($rule['id'] ?? '');
            $matchedField = '';

            foreach (self::ruleFields($rule) as $field) {
                $value = (string) ($surfaces[$field] ?? '');

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

                foreach ((array) ($rule['patterns'] ?? []) as $pattern) {
                    $budgetExhaustedReason = self::evaluationBudgetReason($deadline, $patternEvaluations);
                    if ($budgetExhaustedReason !== '') {
                        $budgetExhausted = true;
                        break 3;
                    }

                    $pattern = (string) $pattern;

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

                    $patternEvaluations++;
                    $result = self::safePregMatch($pattern, $value);
                    if ($result['error'] !== '' && count($pcreErrors) < 5) {
                        $pcreErrors[] = [
                            'rule' => $ruleId,
                            'field' => $field,
                            'message' => $result['error'],
                        ];
                    }

                    if ($result['matched']) {
                        $matchedField = $field;
                        break 2;
                    }
                }
            }

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

            $matched[] = $ruleId;
            $score += (int) ($rule['score'] ?? 0);
            $ruleAction = self::effectiveRuleAction((string) ($rule['action'] ?? 'default'));
            $blocksInEnforce = $ruleAction !== 'log';
            $challengesInEnforce = $ruleAction === 'challenge';

            if ($blocksInEnforce) {
                $wouldBlock = true;
                $blockingRules[] = $ruleId;

                if ($this->mode === 'enforce' || $this->mode === 'temporary') {
                    if ($challengesInEnforce) {
                        $shouldChallenge = true;
                        $challengeRules[] = $ruleId;
                    } else {
                        $shouldBlock = true;
                    }
                }
            }

            $details[] = [
                'id' => $ruleId,
                'name' => (string) ($rule['name'] ?? $ruleId),
                'category' => (string) ($rule['category'] ?? 'generic'),
                'severity' => (string) ($rule['severity'] ?? 'medium'),
                'field' => $matchedField,
                'action' => $ruleAction,
                'would_block' => $blocksInEnforce,
                'cwe' => (string) ($rule['cwe'] ?? ''),
                'owasp' => (string) ($rule['owasp'] ?? ''),
            ];
        }

        if (self::shouldChallengeTruncatedBody($request, $surfaces, $this->maxBodyBytes)) {
            $truncatedBodyRuleId = 'SHIELDX-WAF-BODY-TRUNCATED';
            $matched[] = $truncatedBodyRuleId;
            $score += 25;
            $wouldBlock = true;
            $challengeRules[] = $truncatedBodyRuleId;
            if (in_array($this->mode, ['enforce', 'temporary'], true)) {
                $shouldChallenge = true;
            }
            $details[] = [
                'id' => $truncatedBodyRuleId,
                'name' => 'Request body inspection limit reached',
                'category' => 'engine',
                'severity' => 'high',
                'field' => 'body',
                'action' => 'challenge',
                'would_block' => true,
                'cwe' => '',
                'owasp' => 'A05:2021 Security Misconfiguration',
            ];
        }

        if (!$shouldBlock && !$shouldChallenge && count($matched) > 0 && $this->anomalyThresholdEnabled && in_array($this->mode, ['enforce', 'temporary'], true)) {
            if ($score >= $this->anomalyBlockThreshold) {
                $shouldBlock = true;
                $wouldBlock = true;
                $scoreThresholdAction = 'block';
                $blockingRules[] = 'SHIELDX-WAF-SCORE-THRESHOLD';
            } elseif ($score >= $this->anomalyChallengeThreshold) {
                $shouldChallenge = true;
                $wouldBlock = true;
                $scoreThresholdAction = 'challenge';
                $challengeRules[] = 'SHIELDX-WAF-SCORE-THRESHOLD';
            }
        }

        if ($budgetExhausted) {
            $budgetRuleId = 'SHIELDX-WAF-EVALUATION-BUDGET';
            $matched[] = $budgetRuleId;
            $details[] = [
                'id' => $budgetRuleId,
                'name' => 'WAF evaluation budget exhausted',
                'category' => 'engine',
                'severity' => 'medium',
                'field' => 'evaluation',
                'action' => 'log',
                'would_block' => false,
                'reason' => $budgetExhaustedReason,
                'cwe' => '',
                'owasp' => 'A05:2021 Security Misconfiguration',
            ];
        }

        $action = $shouldBlock ? 'block' : ($shouldChallenge ? 'challenge' : 'log');

        return [
            'matched' => count($matched) > 0,
            'mode' => $this->mode(),
            'action' => $action,
            'would_block' => $wouldBlock,
            'blocking_rules' => array_values(array_unique($blockingRules)),
            'challenge_rules' => array_values(array_unique($challengeRules)),
            'engine_version' => self::ENGINE_VERSION,
            'rules' => array_values(array_unique($matched)),
            'rule_details' => $details,
            'score' => $score,
            'score_threshold_action' => $scoreThresholdAction,
            'pattern_evaluations' => $patternEvaluations,
            'budget_exhausted' => $budgetExhausted,
            'budget_exhausted_reason' => $budgetExhaustedReason,
            'reason' => '',
            'pcre_errors' => $pcreErrors,
            'body_truncated' => isset($surfaces['body']) && self::requestBodyTruncated($request, $this->maxBodyBytes),
            'inspected_surfaces' => array_keys(array_filter($surfaces, static function (string $value): bool {
                return $value !== '';
            })),
            'analysis_time_ms' => round((microtime(true) - $startedAt) * 1000, 3),
        ];
    }

    private static function evaluationBudgetReason(float $deadline, int $patternEvaluations, ?float $currentTime = null): string
    {
        if ($patternEvaluations >= self::MAX_PATTERN_EVALUATIONS) {
            return 'pattern_limit';
        }

        $currentTime = $currentTime ?? microtime(true);

        return $currentTime >= $deadline ? 'time_limit' : '';
    }

    /**
     * Virtual patches are deliberately constrained before their patterns are
     * evaluated. Static rules omit these keys and retain their current behavior.
     *
     * @param array<string,mixed> $rule
     * @param array<string,mixed> $request
     */
    private static function ruleMatchesRequestEnvelope(array $rule, array $request): bool
    {
        $methods = isset($rule['methods']) && is_array($rule['methods']) ? array_map('strtoupper', array_map('strval', $rule['methods'])) : [];
        if ($methods !== [] && !in_array(strtoupper((string) ($request['method'] ?? '')), $methods, true)) {
            return false;
        }

        $prefixes = isset($rule['path_prefixes']) && is_array($rule['path_prefixes']) ? $rule['path_prefixes'] : [];
        if ($prefixes === []) {
            return true;
        }

        $path = (string) ($request['path'] ?? '');
        foreach ($prefixes as $prefix) {
            $prefix = (string) $prefix;
            if ($prefix !== '' && strncmp($path, $prefix, strlen($prefix)) === 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return array{matched:bool,error:string}
     */
    private static function safePregMatch(string $pattern, string $value): array
    {
        $result = @preg_match($pattern, $value);
        if ($result === false) {
            $message = 'unknown PCRE error';
            if (function_exists('preg_last_error_msg')) {
                $message = preg_last_error_msg();
            } else {
                $message = 'PCRE error code ' . (string) preg_last_error();
            }

            if (function_exists('error_log')) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional security telemetry for WAF PCRE failures; request content is not logged.
                \ShieldX\Support\ErrorLog::write('[SHIELD-X WAF] preg_match failed: ' . $message);
            }

            return [
                'matched' => false,
                'error' => $message,
            ];
        }

        return [
            'matched' => $result === 1,
            'error' => '',
        ];
    }

    /**
     * @param array<string,mixed> $request
     * @param array<string,string> $surfaces
     */
    private static function shouldChallengeTruncatedBody(array $request, array $surfaces, int $limit): bool
    {
        if (!isset($surfaces['body']) || !self::requestBodyTruncated($request, $limit)) {
            return false;
        }

        $contentType = strtolower((string) ($request['content_type'] ?? ''));
        if (
            strpos($contentType, 'application/json') !== false
            || strpos($contentType, 'application/x-www-form-urlencoded') !== false
            || strpos($contentType, 'multipart/form-data') !== false
            || strpos($contentType, 'application/xml') !== false
            || strpos($contentType, 'text/xml') !== false
        ) {
            return true;
        }

        $sources = array_map('strval', (array) ($request['body_sources'] ?? []));
        foreach (['parsed_form', 'multipart_raw_body'] as $source) {
            if (in_array($source, $sources, true)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param array<int,array<string,mixed>> $rules
     * @return array<int,array<string,mixed>>
     */
    private static function prepareRules(array $rules): array
    {
        foreach ($rules as &$rule) {
            if (!isset($rule['patterns']) || !is_array($rule['patterns'])) {
                continue;
            }

            $prepared = [];
            foreach ($rule['patterns'] as $pattern) {
                $pattern = (string) $pattern;
                if ($pattern !== '') {
                    $prepared[] = self::patternWithPcreLimits($pattern);
                }
            }
            $rule['patterns'] = $prepared;
        }
        unset($rule);

        return $rules;
    }

    private static function patternWithPcreLimits(string $pattern): string
    {
        if ($pattern === '' || strpos($pattern, '(*LIMIT_MATCH=') !== false) {
            return $pattern;
        }

        $delimiter = $pattern[0];
        if (ctype_alnum($delimiter) || $delimiter === '\\') {
            return $pattern;
        }

        return $delimiter
            . '(*LIMIT_MATCH=' . self::PCRE_LIMIT_MATCH . ')(*LIMIT_RECURSION=' . self::PCRE_LIMIT_RECURSION . ')'
            . substr($pattern, 1);
    }

    private static function effectiveRuleAction(string $action): string
    {
        return in_array($action, ['log', 'block', 'challenge'], true) ? $action : 'block';
    }

    /**
     * @param array<int,array<string,mixed>> $rules
     * @return array<int,string>
     */
    private static function requiredInspectionSurfaces(array $rules): array
    {
        $surfaces = [];

        foreach ($rules as $rule) {
            if (isset($rule['enabled']) && $rule['enabled'] === false) {
                continue;
            }

            foreach (self::ruleFields($rule) as $field) {
                $surfaces[$field] = true;
            }
        }

        return array_keys($surfaces);
    }

    /**
     * @param array<int,array<string,mixed>> $rules
     */
    private static function ruleSetUsesSurface(array $rules, string $surface): bool
    {
        foreach ($rules as $rule) {
            if (isset($rule['enabled']) && $rule['enabled'] === false) {
                continue;
            }

            if (in_array($surface, self::ruleFields($rule), true)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param array<string,mixed> $server
     */
    private static function requestHasEntityBody(array $server): bool
    {
        foreach (['CONTENT_LENGTH', 'HTTP_CONTENT_LENGTH'] as $key) {
            $value = isset($server[$key]) && is_scalar($server[$key]) ? trim((string) $server[$key]) : '';
            if ($value !== '' && ctype_digit($value) && (int) $value > 0) {
                return true;
            }
        }

        foreach (['HTTP_TRANSFER_ENCODING', 'TRANSFER_ENCODING'] as $key) {
            $value = isset($server[$key]) && is_scalar($server[$key]) ? strtolower(trim((string) $server[$key])) : '';
            if ($value !== '') {
                return true;
            }
        }

        return false;
    }

    /**
     * @return array<string,mixed>
     */
    public static function requestFromGlobals(?WafSettings $settings = null, bool $includeBody = true): array
    {
        $uri = self::serverText($_SERVER, 'REQUEST_URI');
        $pathValue = function_exists('wp_parse_url') ? wp_parse_url($uri, PHP_URL_PATH) : '';
        $path = is_string($pathValue) && $pathValue !== '' ? $pathValue : '/';
        $userAgent = self::serverText($_SERVER, 'HTTP_USER_AGENT');
        $contentType = self::serverText($_SERVER, 'CONTENT_TYPE', self::serverText($_SERVER, 'HTTP_CONTENT_TYPE'));
        $settings = $settings ?: new WafSettings();
        $headers = self::headersFromServer($_SERVER);
        $hasProxyHeaders = $settings->hasProxyHeaders(['headers' => $headers]);
        $body = $includeBody ? self::bodyFromInput($_SERVER, $settings) : self::bodyInfo('', false, []);

        return [
            'uri' => $uri,
            // Keep the raw parsed path on the shared request envelope. Callers
            // apply their own decoding policy, so WAF inspection stays faithful
            // to the original request while helpers avoid reparsing the URI.
            'path' => $path,
            'query_string' => self::serverText($_SERVER, 'QUERY_STRING'),
            'user_agent' => $userAgent,
            'browser' => self::browserFromUserAgent($userAgent),
            'ip' => self::clientIpFromServer($_SERVER, $settings),
            // Proxy headers disable the automatic server-IP allowlist, so there
            // is no need to perform hostname DNS resolution for that request.
            'server_ips' => self::serverIpsFromServer($_SERVER, !$hasProxyHeaders),
            'trusted_proxy' => ClientIpResolver::isTrustedProxy($_SERVER, $settings),
            'remote_host' => self::serverText($_SERVER, 'REMOTE_HOST'),
            'method' => self::serverText($_SERVER, 'REQUEST_METHOD'),
            'headers' => $headers,
            'body' => (string) ($body['body'] ?? ''),
            'body_truncated' => !empty($body['truncated']),
            'body_sources' => isset($body['sources']) && is_array($body['sources']) ? $body['sources'] : [],
            'body_is_inspection_text' => !empty($body['sources']),
            'content_type' => $contentType,
        ];
    }

    /**
     * @param array<string,mixed> $server
     */
    private static function serverText(array $server, string $key, string $default = ''): string
    {
        $value = isset($server[$key]) && is_scalar($server[$key]) ? (string) $server[$key] : $default;

        return self::inspectionText($value);
    }

    /**
     * @param array<string,mixed> $request
     * @param array<int,string>|null $requiredSurfaces
     * @return array<string,string>
     */
    public static function inspectionSurfaces(array $request, int $maxBodyBytes = self::MAX_BODY_BYTES, ?array $requiredSurfaces = null): array
    {
        $uri = (string) ($request['uri'] ?? '');
        $query = (string) ($request['query_string'] ?? '');
        $userAgent = (string) ($request['user_agent'] ?? '');
        $method = strtoupper((string) ($request['method'] ?? ''));
        $needsAll = $requiredSurfaces === null || in_array('all', $requiredSurfaces, true);
        $needs = static function (string $surface) use ($requiredSurfaces, $needsAll): bool {
            return $needsAll || $requiredSurfaces === null || in_array($surface, $requiredSurfaces, true);
        };
        $surfaces = [];
        $allParts = [];
        $headerStrings = [
            'headers' => '',
            'header_values' => '',
        ];

        if ($needs('uri')) {
            $surfaces['uri'] = self::expandedContent($uri, $maxBodyBytes);
            $allParts[] = $surfaces['uri'];
        }

        if ($needs('query_string')) {
            $surfaces['query_string'] = self::expandedContent($query, $maxBodyBytes);
            $allParts[] = $surfaces['query_string'];
        }

        if ($needs('user_agent')) {
            $surfaces['user_agent'] = self::expandedContent($userAgent, $maxBodyBytes);
            $allParts[] = $surfaces['user_agent'];
        }

        if ($needs('user_agent_state')) {
            $surfaces['user_agent_state'] = $userAgent === '' ? 'missing-user-agent' : 'present-user-agent';
        }

        if ($needs('method')) {
            $surfaces['method'] = $method;
            $allParts[] = $method;
        }

        $needsHeaders = $needs('headers');
        $needsHeaderValues = $needs('header_values');
        if ($needsHeaders || $needsHeaderValues) {
            $headerStrings = self::headerInspectionStrings($request['headers'] ?? [], $maxBodyBytes);
        }

        if ($needsHeaders) {
            $headers = $headerStrings['headers'];
            $surfaces['headers'] = self::expandedContent($headers, $maxBodyBytes);
            $allParts[] = $surfaces['headers'];
        }

        if ($needsHeaderValues) {
            $headerValues = $headerStrings['header_values'];
            $surfaces['header_values'] = self::expandedContent($headerValues, $maxBodyBytes);
        }

        if ($needs('body')) {
            $body = self::bodyFromRequest($request, $maxBodyBytes);
            $surfaces['body'] = self::expandedContent($body, $maxBodyBytes);
            $allParts[] = $surfaces['body'];
        }

        if ($needsAll) {
            $surfaces['all'] = self::truncate(trim(implode("\n", array_filter($allParts, static function (string $value): bool {
                return $value !== '';
            }))), $maxBodyBytes);
        }

        return $surfaces;
    }

    /**
     * @param array<string,mixed> $server
     */
    public static function clientIpFromServer(array $server, ?WafSettings $settings = null): string
    {
        $settingsKey = $settings
            ? $settings->trustedProxyHeader() . '|' . implode(',', $settings->trustedProxyCidrs())
            : 'default';
        $serverKey = [
            (string) ($server['REMOTE_ADDR'] ?? ''),
            (string) ($server['HTTP_CF_CONNECTING_IP'] ?? ''),
            (string) ($server['HTTP_X_FORWARDED_FOR'] ?? ''),
            (string) ($server['HTTP_X_REAL_IP'] ?? ''),
            (string) ($server['HTTP_FORWARDED'] ?? ''),
        ];
        $cacheKey = hash('sha256', $settingsKey . '|' . implode('|', $serverKey));

        if (isset(self::$clientIpCache[$cacheKey])) {
            return self::$clientIpCache[$cacheKey];
        }

        $ip = ClientIpResolver::fromServer($server, $settings);
        if (count(self::$clientIpCache) >= 16) {
            self::$clientIpCache = [];
        }
        self::$clientIpCache[$cacheKey] = $ip;

        return $ip;
    }

    /**
     * @param array<string,mixed> $server
     * @return array<int,string>
     */
    public static function serverIpsFromServer(array $server, bool $includeHostname = true): array
    {
        $ips = [];
        foreach (['SERVER_ADDR', 'LOCAL_ADDR'] as $key) {
            $value = isset($server[$key]) && is_scalar($server[$key]) ? trim((string) $server[$key]) : '';
            if ($value !== '' && filter_var($value, FILTER_VALIDATE_IP) && !in_array($value, $ips, true)) {
                $ips[] = $value;
            }
        }

        if (!$includeHostname) {
            return $ips;
        }

        foreach (self::serverHostnameIps() as $candidate) {
            if (!in_array($candidate, $ips, true)) {
                $ips[] = $candidate;
            }
        }

        return $ips;
    }

    /**
     * @return array<int,string>
     */
    private static function serverHostnameIps(): array
    {
        if (self::$serverHostnameIps !== null) {
            return self::$serverHostnameIps;
        }

        $ips = [];
        $hostname = function_exists('gethostname') ? (string) gethostname() : '';
        if ($hostname === '') {
            self::$serverHostnameIps = [];

            return self::$serverHostnameIps;
        }

        $resolved = function_exists('gethostbynamel') ? gethostbynamel($hostname) : false;
        if (is_array($resolved)) {
            foreach ($resolved as $candidate) {
                $candidate = trim((string) $candidate);
                if ($candidate !== '' && filter_var($candidate, FILTER_VALIDATE_IP) && !in_array($candidate, $ips, true)) {
                    $ips[] = $candidate;
                }
            }
        }

        $single = function_exists('gethostbyname') ? gethostbyname($hostname) : '';
        if (is_string($single) && $single !== $hostname && filter_var($single, FILTER_VALIDATE_IP) && !in_array($single, $ips, true)) {
            $ips[] = $single;
        }

        self::$serverHostnameIps = $ips;

        return self::$serverHostnameIps;
    }

    public static function browserFromUserAgent(string $userAgent): string
    {
        $ua = strtolower(self::sanitizeRequestText($userAgent));

        if ($ua === '') {
            return 'Unknown';
        }

        if (strpos($ua, 'edg/') !== false || strpos($ua, 'edge/') !== false) {
            return 'Microsoft Edge';
        }

        if (strpos($ua, 'opr/') !== false || strpos($ua, 'opera') !== false) {
            return 'Opera';
        }

        if (strpos($ua, 'chrome/') !== false || strpos($ua, 'chromium/') !== false) {
            return 'Chrome';
        }

        if (strpos($ua, 'firefox/') !== false) {
            return 'Firefox';
        }

        if (strpos($ua, 'safari/') !== false) {
            return 'Safari';
        }

        if (strpos($ua, 'curl/') !== false) {
            return 'curl';
        }

        if (preg_match('~(?:^|[^a-z0-9_-])wpscan(?:$|[^a-z0-9_-])~i', $ua) === 1) {
            return 'WPScan';
        }

        if (preg_match('~(?:^|[^a-z0-9_-])sqlmap(?:$|[^a-z0-9_-])~i', $ua) === 1) {
            return 'sqlmap';
        }

        return 'Other';
    }

    private static function sanitizeRequestText(string $value): string
    {
        if (function_exists('sanitize_text_field')) {
            $value = sanitize_text_field($value);
        } else {
            $value = preg_replace('/<[^>]*>/', '', $value) ?: '';
        }

        $value = preg_replace('/[\x00-\x1F\x7F\x{2028}\x{2029}]+/u', ' ', $value) ?: '';

        return trim($value);
    }

    /**
     * @param array<int,array<string,mixed>> $rules
     * @return array<string,mixed>
     */
    public static function summarizeRules(array $rules): array
    {
        $summary = [
            'total' => count($rules),
            'enabled' => 0,
            'categories' => [],
            'severities' => [],
        ];

        foreach ($rules as $rule) {
            if (($rule['enabled'] ?? true) !== false) {
                $summary['enabled']++;
            }

            $category = (string) ($rule['category'] ?? 'generic');
            $severity = (string) ($rule['severity'] ?? 'medium');
            $summary['categories'][$category] = isset($summary['categories'][$category]) ? $summary['categories'][$category] + 1 : 1;
            $summary['severities'][$severity] = isset($summary['severities'][$severity]) ? $summary['severities'][$severity] + 1 : 1;
        }

        ksort($summary['categories']);
        ksort($summary['severities']);

        return $summary;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public static function defaultRules(): array
    {
        return [
            self::rule(
                'SX-WAF-CRS-001',
                'SQL Injection Probe',
                'Detects boolean, UNION, stacked, metadata, and time-based SQL injection payloads.',
                'SQLi',
                'critical',
                ['query_string', 'body', 'headers'],
                [
                    '~\bunion(?:[\s(/]+all)?[\s(/]+select\b~i',
                    '~\b(?:select\b.{0,160}\bfrom|insert\b.{0,160}\binto|update\b.{0,160}\bset|delete\b.{0,160}\bfrom|drop\b.{0,160}\btable)\b~is',
                    '~\b(?:or|and)\b\s+[0-9a-z_\'"]+\s*=\s*[0-9a-z_\'"]+~i',
                    '~(?=.{0,240}(?:--|/\*|\*/|\#))\b(?:union|select|insert|update|delete|drop|or|and|sleep|benchmark|information_schema|sysobjects|systables)\b~is',
                    '~\b(?:sleep|benchmark)\s*\(|\bwaitfor\s+delay\b~i',
                    '~\b(?:information_schema|sysobjects|systables)\b~i',
                ],
                60,
                'CWE-89',
                'A03:2021 Injection'
            ),
            self::rule(
                'SX-WAF-CRS-002',
                'Path Traversal / LFI Probe',
                'Detects traversal markers, null-byte probes, and sensitive local file access.',
                'LFI',
                'high',
                ['uri', 'query_string', 'body'],
                [
                    '~\.\.[/\\\\]~i',
                    '~%2e%2e(?:%2f|/|\\\\)~i',
                    '~/(?:etc/passwd|etc/shadow|proc/self/environ)\b~i',
                    '~(?:c:\\\\windows|boot\.ini|win\.ini)~i',
                    '~(?:%00|\\\\x00)~i',
                ],
                45,
                'CWE-22',
                'A01:2021 Broken Access Control'
            ),
            self::rule(
                'SX-WAF-CRS-003',
                'Cross-Site Scripting Probe',
                'Detects script tags, dangerous protocols, event handlers, and browser execution payloads.',
                'XSS',
                'high',
                ['query_string', 'body', 'headers'],
                [
                    '~<\s*script\b~i',
                    '~\bjavascript\s*:~i',
                    '~<\s*(?:iframe|object|embed|svg|img)\b[^>]*(?:onload|onerror|srcdoc|javascript:)~is',
                    '~(?:<[^>]+|\b(?:href|src|data|style)\s*=|["\'`;])\s*(?:alert|confirm|prompt|eval|settimeout|setinterval)\s*\(~i',
                    '~data\s*:\s*text/html~i',
                ],
                45,
                'CWE-79',
                'A03:2021 Injection'
            ),
            self::rule(
                'SX-WAF-CRS-003A',
                'HTML Event Handler Probe',
                'Detects inline HTML event-handler attributes while using a challenge action to reduce false positives.',
                'XSS',
                'medium',
                ['query_string', 'body', 'headers'],
                [
                    '~\bon[a-z]{3,24}\s*=~i',
                ],
                25,
                'CWE-79',
                'A03:2021 Injection',
                'SHIELD-X managed, OWASP CRS inspired',
                'challenge'
            ),
            self::rule(
                'SX-WAF-CRS-004',
                'Command Injection Probe',
                'Detects shell chaining, command execution syntax, and execution-parameter probes.',
                'RCE',
                'critical',
                ['query_string', 'body'],
                [
                    '~(?:;|\||&&|\|\|)\s*(?:cat|wget|curl|bash|sh|zsh|php|python[23]?|perl|ruby|nc|netcat|nmap|chmod|chown|whoami|uname|id)\b~i',
                    '~`[^`]+`~',
                    '~\$\([^)]+\)~',
                    '~\b(?:cmd|exec|system|passthru|shell_exec)\s*=~i',
                ],
                55,
                'CWE-77',
                'A03:2021 Injection'
            ),
            self::rule(
                'SX-WAF-CRS-005',
                'SSRF Metadata Probe',
                'Detects cloud metadata endpoints, loopback URLs, and private network URL probes.',
                'SSRF',
                'high',
                ['query_string', 'body'],
                [
                    '~(?:169\.254\.169\.254|169\.254\.170\.2|metadata\.google\.internal|metadata\.azure\.com)~i',
                    '~(?:^|[?&\s,{\[\"\'])(?:url|uri|target(?:[-_]?url)?|dest(?:ination)?|redirect|next|callback(?:[-_]?url)?|webhook(?:[-_]?url)?|feed|proxy|endpoint|src|source|file|path)\s*[\"\']?\s*(?:=|:)\s*[\"\']?https?://(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})~i',
                ],
                45,
                'CWE-918',
                'A10:2021 Server-Side Request Forgery'
            ),
            self::rule(
                'SX-WAF-CRS-006',
                'Header Injection Probe',
                'Detects CRLF header injection and response-splitting markers.',
                'Header',
                'medium',
                ['header_values', 'query_string', 'body'],
                [
                    '~(?:\r|\n|%0d|%0a)\s*[a-z0-9-]+\s*:~i',
                ],
                30,
                'CWE-113',
                'A03:2021 Injection'
            ),
            self::rule(
                'SX-WAF-CRS-007',
                'Dangerous HTTP Method',
                'Detects legacy debug and trace methods that should not reach WordPress.',
                'Method',
                'medium',
                ['method'],
                [
                    '~^(?:TRACE|TRACK|DEBUG)$~i',
                ],
                25,
                'CWE-749',
                'A05:2021 Security Misconfiguration'
            ),
            self::rule(
                'SX-WAF-CRS-008',
                'Template Injection Probe',
                'Detects common template-expression delimiters seen in SSTI probing.',
                'SSTI',
                'medium',
                ['query_string', 'body'],
                [
                    '~(?:\{\{.*?\}\}|\{%.*?%\}|\$\{.*?\})~s',
                ],
                30,
                'CWE-94',
                'A03:2021 Injection'
            ),
            self::rule(
                'SX-WAF-CRS-009',
                'Prototype Pollution Probe',
                'Detects prototype pollution keys commonly sent to JSON endpoints.',
                'Prototype Pollution',
                'medium',
                ['query_string', 'body'],
                [
                    '~(?:__proto__|constructor\.prototype|object\.prototype)~i',
                ],
                30,
                'CWE-1321',
                'A08:2021 Software and Data Integrity Failures'
            ),
            self::rule(
                'SX-WAF-WP-001',
                'WordPress Scanner User Agent',
                'Detects common automated WordPress scanner user agents.',
                'Scanner',
                'medium',
                ['user_agent', 'headers'],
                [
                    '~(?:^|[^a-z0-9_-])(?:wpscan|sqlmap|nikto|acunetix|nessus|masscan|zgrab|dirbuster|gobuster|nuclei)(?:$|[^a-z0-9_-])~i',
                ],
                30,
                'CWE-200',
                'A05:2021 Security Misconfiguration',
                'SHIELD-X managed'
            ),
            self::rule(
                'SX-WAF-WP-002',
                'Sensitive WordPress File Probe',
                'Detects probes for WordPress config, backup, environment, and database export files.',
                'WordPress',
                'high',
                ['uri', 'query_string'],
                [
                    '~(?:wp-config\.php(?:\~|\.bak|\.save|\.old)?|\.env|\.sql(?:\.gz)?|debug\.log|backup\.(?:zip|sql|tar|gz))~i',
                ],
                45,
                'CWE-538',
                'A05:2021 Security Misconfiguration',
                'SHIELD-X managed'
            ),
            self::rule(
                'SX-WAF-WP-003',
                'WordPress Enumeration Probe',
                'Detects common user enumeration and WordPress install/XML-RPC probes.',
                'WordPress',
                'low',
                ['uri', 'query_string'],
                [
                    '~(?:/wp-json/wp/v2/users|[?&]author=\d+|/wp-admin/install\.php|/xmlrpc\.php)~i',
                ],
                20,
                'CWE-203',
                'A01:2021 Broken Access Control',
                'SHIELD-X managed'
            ),
            self::rule(
                'SX-WAF-CRS-010',
                'Remote File Inclusion Probe',
                'Detects PHP stream wrappers and remote include parameters that attempt to load server-side code.',
                'RFI',
                'high',
                ['query_string', 'body', 'uri'],
                [
                    '~\b(?:php|data|expect|zip|phar|glob|file)://~i',
                    '~(?:^|[?&\s])(?:url|uri|path|file|template|page|include|load|src|document)=https?://[^&\s]+\.(?:php[0-9]?|phtml|phar|inc|txt)(?:[?&\s]|$)~i',
                    '~(?:^|[?&\s])(?:file|path|include|template|load)=(?:https?|ftp)://~i',
                ],
                45,
                'CWE-98',
                'A03:2021 Injection'
            ),
            self::rule(
                'SX-WAF-CRS-011',
                'Upload Extension Evasion Probe',
                'Detects PHP-like upload filenames, double extensions, and encoded executable-extension probes.',
                'Upload',
                'high',
                ['uri', 'query_string', 'body', 'headers'],
                [
                    '~(?:filename|name)\s*=\s*"?[^"\r\n]*(?:\.php[0-9]?|\.phtml|\.phar|\.shtml|\.cgi)\b~i',
                    '~\.(?:php[0-9]?|phtml|phar)\.(?:jpe?g|png|gif|webp|txt|pdf|zip)\b~i',
                    '~(?:%2ephp[0-9]?|%2ephtml|%2ephar)(?:%2e|\.)(?:jpg|jpeg|png|gif|webp|txt|pdf)~i',
                ],
                45,
                'CWE-434',
                'A05:2021 Security Misconfiguration'
            ),
            self::rule(
                'SX-WAF-CRS-012',
                'Webshell Control Probe',
                'Detects common webshell names and request parameters used to control command shells.',
                'Webshell',
                'critical',
                ['uri', 'query_string', 'body'],
                [
                    '~(?:^|[?&\s])(?:cmd|command|exec|shell|0|pass|password|pwd)\s*=\s*(?:id|whoami|uname|cat|wget|curl|bash|sh|powershell|dir|ls|pwd|system|passthru|shell_exec)\b~i',
                    '~\b(?:wso|c99|r57|b374k|indoxploit|filesman|alfacgiapi|minishell)\b~i',
                    '~(?:^|[?&\s])(?:a|act|action)\s*=\s*(?:shell|cmd|upload|filemanager|terminal)\b~i',
                ],
                60,
                'CWE-77',
                'A03:2021 Injection'
            ),
            self::rule(
                'SX-WAF-WP-004',
                'XML-RPC Amplification Probe',
                'Detects XML-RPC multicall and pingback methods commonly used for amplification and credential attacks.',
                'WordPress',
                'high',
                ['uri', 'body'],
                [
                    '~\bsystem\.multicall\b~i',
                    '~\bpingback\.ping\b~i',
                    '~<methodName>\s*(?:system\.multicall|pingback\.ping)\s*</methodName>~i',
                ],
                45,
                'CWE-307',
                'A07:2021 Identification and Authentication Failures',
                'SHIELD-X managed'
            ),
            self::rule(
                'SX-WAF-WP-005',
                'WordPress Auth Abuse Signal',
                'Detects brute-force tooling and obvious default-credential probes against WordPress login surfaces.',
                'WordPress',
                'medium',
                ['uri', 'query_string', 'body', 'user_agent'],
                [
                    '~(?:^|[^a-z0-9_-])(?:hydra|medusa|patator|brutus|wpscan|wp-brute|xmlrpc-brute)(?:$|[^a-z0-9_-])~i',
                    '~(?:^|[&\s])log=(?:admin|administrator|test|root)&pwd=(?:admin|password|123456|123456789|qwerty)\b~i',
                    '~/wp-login\.php\?[^ \r\n]*(?:reauth=1&){2,}~i',
                ],
                25,
                'CWE-307',
                'A07:2021 Identification and Authentication Failures',
                'SHIELD-X managed',
                'log'
            ),
            self::rule(
                'SX-WAF-WP-006',
                'Plugin And Theme Endpoint Probe',
                'Detects direct probes for exposed plugin/theme files and risky unauthenticated AJAX-style actions.',
                'WordPress',
                'high',
                ['uri', 'query_string'],
                [
                    '~/wp-content/(?:plugins|themes)/[^/?]+/(?:readme\.txt|changelog\.txt|debug\.log|(?:backup|dump|export)\.(?:zip|sql|tar|gz))~i',
                    '~(?:^|[?&])action=(?:revslider_show_image|revslider_ajax_action|duplicator_download|download_backup|wp_file_manager|wp_ajax_nopriv_[^&]*(?:upload|import|export|download))\b~i',
                    '~/wp-admin/admin-ajax\.php\?[^ \r\n]*(?:action=[^&]*(?:backup|download|export|import|upload))~i',
                ],
                40,
                'CWE-862',
                'A01:2021 Broken Access Control',
                'SHIELD-X managed',
                'log'
            ),
            self::appSecRule(
                'SX-WAF-WP-CS-001',
                'Uploads PHP Execution Probe',
                'Detects direct requests for PHP-like executable files inside wp-content/uploads.',
                'WordPress',
                'critical',
                ['uri'],
                [
                    '~/wp-content/uploads/(?:[^?#\s]+/)*[^/?#\s]+\.(?:php[0-9]?|phtml|phar|shtml)(?:[/?#\s]|$)~i',
                ],
                55,
                'CWE-434',
                'A05:2021 Security Misconfiguration'
            ),
            self::appSecRule(
                'SX-WAF-WP-CS-002',
                'Uploads Directory Listing Probe',
                'Detects Apache-style autoindex sorting probes against wp-content/uploads directories.',
                'WordPress',
                'medium',
                ['uri', 'query_string'],
                [
                    '~/wp-content/uploads/(?:[^?#\s]+/)*\?(?:C=[NDMS];O=[AD]|O=[AD];C=[NDMS])(?:[&#\s]|$)~i',
                    '~/wp-content/uploads/(?:[^?#\s]+/)*[?&](?:C=[NDMS]&O=[AD]|O=[AD]&C=[NDMS])(?:[&#\s]|$)~i',
                ],
                25,
                'CWE-548',
                'A05:2021 Security Misconfiguration',
                'log'
            ),
            self::appSecRule(
                'SX-WAF-WP-CS-003',
                'WordPress Virtual Patch Action Probe',
                'Detects high-signal exploit action names seen in historical WordPress plugin attack traffic.',
                'WordPress',
                'high',
                ['uri', 'query_string', 'body'],
                [
                    '~(?:^|[?&\s])action=(?:revslider_ajax_action|revslider_show_image|duplicator_download|wp_file_manager|wfu_ajax_action|wpgdprc_process_action|elementor_upload_and_install_pro|wp_ajax_nopriv_(?:revslider|duplicator|wp_file_manager)[^&\s]*)\b~i',
                    '~/wp-admin/admin-ajax\.php\?[^ \r\n]*(?:action=(?:revslider_ajax_action|revslider_show_image|duplicator_download|wp_file_manager|wfu_ajax_action))\b~i',
                ],
                40,
                'CWE-862',
                'A01:2021 Broken Access Control',
                'log'
            ),
            self::appSecRule(
                'SX-WAF-WP-CS-004',
                'Missing User Agent Bot Probe',
                'Detects requests with no user-agent header, a weak signal often used by scripted probes.',
                'Bot',
                'low',
                ['user_agent_state'],
                [
                    '~^missing-user-agent$~',
                ],
                10,
                'CWE-200',
                'A05:2021 Security Misconfiguration',
                'log'
            ),
            self::rule(
                'SX-WAF-CRS-013',
                'Open Redirect And Host Header Probe',
                'Detects suspicious redirect targets and proxy host headers used for poisoning tests.',
                'Redirect',
                'medium',
                ['query_string', 'headers'],
                [
                    '~(?:^|[?&])(?:redirect_to|redirect|return|return_to|next|url|target|continue)=https?://(?:[^/&?]+\.)?(?:evil|attacker|interactsh|burpcollaborator)\b~i',
                    '~(?:^|[?&])(?:redirect_to|redirect|return|return_to|next|url|target|continue)=https?%3a%2f%2f(?:[^&%]+\.)?(?:evil|attacker|interactsh|burpcollaborator)\b~i',
                    '~(?:^|[?&])(?:redirect_to|redirect|return|return_to|next|target|continue)=//(?:[^/&?]+\.)?(?:evil|attacker|interactsh|burpcollaborator)\b~i',
                    '~(?:^|\s)(?:host|x-forwarded-host|x-original-host|x-rewrite-url)\s*:\s*[^ \r\n]*(?:evil|attacker|interactsh|burpcollaborator)\b~i',
                ],
                30,
                'CWE-601',
                'A01:2021 Broken Access Control',
                'SHIELD-X managed, OWASP CRS inspired',
                'log'
            ),
            self::rule(
                'SX-WAF-CRS-014',
                'PHP Object Injection Probe',
                'Detects serialized PHP object payloads and magic-method probes used in deserialization attacks.',
                'Deserialization',
                'high',
                ['query_string', 'body'],
                [
                    '~(?:^|[?&=\s])(?:O|C):\d+:"[A-Za-z0-9_\\\\]+":\d+:\{~',
                    '~(?:^|[?&=\s])a:\d+:\{[^ \r\n]{0,160}(?:O|C):\d+:"~s',
                    '~\b(?:__wakeup|__destruct|unserialize)\b~i',
                ],
                45,
                'CWE-502',
                'A08:2021 Software and Data Integrity Failures'
            ),
            self::rule(
                'SX-WAF-CRS-015',
                'NoSQL LDAP XPath Injection Probe',
                'Detects non-SQL injection operators and filter syntax frequently used against generic endpoints.',
                'Injection',
                'medium',
                ['query_string', 'body'],
                [
                    '~(?:\$ne|\$gt|\$where|\$regex|\$nin)\b~i',
                    '~(?:^|[?&\s])(?:username|user|login|filter)=(?:\*\)\(|\(\|\(|\)\(|%2a%29%28)~i',
                    '~\b(?:or|and)\s+\d+=\d+\s*(?:\]|\))~i',
                    '~\b(?:extractvalue|updatexml)\s*\(~i',
                ],
                30,
                'CWE-943',
                'A03:2021 Injection'
            ),
            self::rule(
                'SX-WAF-CRS-016',
                'HTTP Request Smuggling Header Probe',
                'Detects conflicting framing headers and method-override probes for blocked methods.',
                'Header',
                'medium',
                ['headers'],
                [
                    '~transfer-encoding\s*:\s*chunked.{0,160}content-length\s*:~is',
                    '~content-length\s*:.*transfer-encoding\s*:\s*chunked~is',
                    '~(?:x-http-method-override|x-method-override)\s*:\s*(?:TRACE|TRACK|DEBUG|CONNECT)\b~i',
                ],
                30,
                'CWE-444',
                'A05:2021 Security Misconfiguration'
            ),
        ];
    }

    /**
     * @param array<string,mixed> $server
     * @return array<string,string>
     */
    private static function headersFromServer(array $server): array
    {
        $headers = [];

        foreach ($server as $key => $value) {
            if (!is_scalar($value)) {
                continue;
            }

            $name = '';

            if (strpos((string) $key, 'HTTP_') === 0) {
                $name = substr((string) $key, 5);
            } elseif (in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH'], true)) {
                $name = (string) $key;
            }

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

            $name = strtolower((string) preg_replace('/[^A-Z0-9_]+/', '', $name));
            $name = str_replace('_', '-', $name);
            if ($name === '') {
                continue;
            }

            $headers[$name] = self::truncate(self::inspectionText((string) $value), self::MAX_HEADER_VALUE_BYTES);
        }

        return $headers;
    }

    /**
     * @param array<string,mixed> $server
     * @return array{body:string,truncated:bool,sources:array<int,string>}
     */
    private static function bodyFromInput(array $server, ?WafSettings $settings = null): array
    {
        $method = strtoupper((string) ($server['REQUEST_METHOD'] ?? ''));

        if (in_array($method, ['GET', 'HEAD', 'OPTIONS'], true) && !self::requestHasEntityBody($server)) {
            return self::bodyInfo('', false, []);
        }

        $settings = $settings ?: new WafSettings();
        $limit = $settings->maxBodyInspectionBytes();
        $contentType = (string) ($server['CONTENT_TYPE'] ?? ($server['HTTP_CONTENT_TYPE'] ?? ''));
        $knownFormEndpoint = self::isKnownFormEndpoint($server, $contentType);

        if (stripos($contentType, 'multipart/form-data') !== false) {
            return self::multipartBodyFromGlobals($limit);
        }

        if ($knownFormEndpoint) {
            $parsed = self::postedFieldsFromGlobals();
            if (!empty($parsed)) {
                return self::bodyInfoFromParsedFields($parsed, $limit, ['parsed_form']);
            }
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- The WAF intentionally inspects the raw request body; the read is php://input only, bounded to $limit + 1 bytes, and the content is pattern-inspected, never executed or echoed.
        $body = file_get_contents('php://input', false, null, 0, $limit + 1);

        if (!is_string($body)) {
            return self::bodyInfo('', false, []);
        }

        if ($knownFormEndpoint) {
            $parsed = self::parseUrlEncodedBody($body);
            if (!empty($parsed)) {
                if (strlen($body) <= $limit) {
                    self::rehydratePostedFields($parsed);
                }

                return self::bodyInfoFromParsedFields($parsed, $limit, ['parsed_form']);
            }
        } elseif (stripos($contentType, 'application/x-www-form-urlencoded') !== false && strlen($body) <= $limit) {
            // Some non-core form handlers read $_POST after init. Preserve their
            // parsed fields when a runtime exposed the raw stream before PHP did.
            self::rehydratePostedFields(self::parseUrlEncodedBody($body));
        }

        return self::bodyInfo(self::truncate($body, $limit), strlen($body) > $limit, ['raw_body']);
    }

    /**
     * @param array<string,mixed> $server
     */
    private static function isKnownFormEndpoint(array $server, string $contentType): bool
    {
        if (stripos($contentType, 'application/x-www-form-urlencoded') === false) {
            return false;
        }

        $uri = (string) ($server['REQUEST_URI'] ?? '');
        $pathValue = function_exists('wp_parse_url') ? wp_parse_url($uri, PHP_URL_PATH) : '';
        $path = strtolower(is_string($pathValue) && $pathValue !== '' ? $pathValue : explode('?', $uri, 2)[0]);
        $queryValue = function_exists('wp_parse_url') ? wp_parse_url($uri, PHP_URL_QUERY) : '';
        $query = strtolower(is_string($queryValue) ? $queryValue : (string) ($server['QUERY_STRING'] ?? ''));

        $knownPaths = [
            '/wp-login.php',
            '/wp-comments-post.php',
            rtrim(RequestPath::adminEndpointPath('admin-post.php'), '/'),
            rtrim(RequestPath::adminEndpointPath('admin-ajax.php'), '/'),
        ];

        if (in_array(rtrim($path, '/'), $knownPaths, true)) {
            return true;
        }

        if (strpos($query, 'wc-ajax=') !== false) {
            return true;
        }

        foreach (['/checkout', '/cart', '/my-account', '/contact'] as $segment) {
            if ($path === $segment || strpos($path, $segment . '/') === 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param mixed $headers
     * @return array{headers:string,header_values:string}
     */
    private static function headerInspectionStrings($headers, int $limit = self::MAX_BODY_BYTES): array
    {
        if (is_string($headers)) {
            $value = self::truncate($headers, $limit);

            return [
                'headers' => $value,
                'header_values' => $value,
            ];
        }

        if (!is_array($headers)) {
            return [
                'headers' => '',
                'header_values' => '',
            ];
        }

        $lines = [];
        $values = [];

        foreach ($headers as $name => $value) {
            if (!is_scalar($value)) {
                continue;
            }

            $headerValue = self::truncate((string) $value, self::MAX_HEADER_VALUE_BYTES);
            $lines[] = strtolower((string) $name) . ': ' . $headerValue;
            $values[] = $headerValue;
        }

        return [
            'headers' => self::truncate(implode(' ', $lines), $limit),
            'header_values' => self::truncate(implode(' ', $values), $limit),
        ];
    }

    /**
     * @param array<string,mixed> $request
     */
    private static function bodyFromRequest(array $request, int $limit = self::MAX_BODY_BYTES): string
    {
        $contentType = (string) ($request['content_type'] ?? '');
        $body = (string) ($request['body'] ?? '');

        if (stripos($contentType, 'multipart/form-data') !== false) {
            if (!empty($request['body_is_inspection_text'])) {
                return self::truncate($body, $limit);
            }

            $parts = [];
            if (isset($request['files']) && is_array($request['files'])) {
                $parts[] = self::filesMetadataToString($request['files'], $limit);
            }
            $parts[] = self::multipartHeadersFromBody($body, $limit);

            return self::truncate(trim(implode("\n", array_filter($parts, static function (string $value): bool {
                return $value !== '';
            }))), $limit);
        }

        return self::truncate($body, $limit);
    }

    private static function expandedContent(string $content, int $limit = self::MAX_BODY_BYTES): string
    {
        $values = [];
        $seen = [];
        $current = self::truncate($content, $limit);
        self::appendUnique($values, $seen, $current);

        // Decode only when a relevant marker is present. A plus sign is
        // intentionally included because urldecode() treats it as whitespace,
        // which is material to SQL-injection detection.
        if (strpos($current, '%') !== false || strpos($current, '+') !== false) {
            // Evaluate each URL-decoded representation up to a fixed depth so
            // common double/triple-encoded probes are matched without unbounded
            // recursive decoding on attacker-controlled input.
            for ($i = 0; $i < 3; $i++) {
                $decoded = urldecode($current);

                if ($decoded === $current) {
                    break;
                }

                $current = self::truncate($decoded, $limit);
                self::appendUnique($values, $seen, $current);
            }
        }

        if (strpos($current, '&') !== false) {
            $htmlDecoded = html_entity_decode($current, ENT_QUOTES | ENT_HTML5, 'UTF-8');
            self::appendUnique($values, $seen, self::truncate($htmlDecoded, $limit));
        }

        if (strpos($current, '\\') !== false) {
            $unicodeDecoded = self::decodeJsonUnicodeEscapes($current);
            self::appendUnique($values, $seen, self::truncate($unicodeDecoded, $limit));
        }

        foreach ($values as $value) {
            $normalizedSql = self::sqlCommentNormalizedContent($value);
            if ($normalizedSql !== $value) {
                self::appendUnique($values, $seen, self::truncate($normalizedSql, $limit));
            }
        }

        // Keep Base64 expansion token-scoped: encoded payloads need not contain
        // a URL, HTML-entity, or escape-sequence marker, but the helper only
        // decodes bounded base64-like tokens.
        self::appendBase64Expansions($values, $seen, $current, $limit);

        return self::truncate(implode(' ', $values), $limit);
    }

    private static function sqlCommentNormalizedContent(string $content): string
    {
        $normalized = preg_replace('~/\*.*?\*/|--[^\r\n]*~s', ' ', $content);
        if (!is_string($normalized)) {
            return $content;
        }

        $normalized = preg_replace('/\s+/', ' ', $normalized);

        return is_string($normalized) ? trim($normalized) : $content;
    }

    /**
     * @return array{body:string,truncated:bool,sources:array<int,string>}
     */
    private static function bodyInfo(string $body, bool $truncated, array $sources, int $limit = self::MAX_BODY_BYTES): array
    {
        $sources = array_values(array_unique(array_filter(array_map('strval', $sources))));

        return [
            'body' => self::truncate($body, $limit),
            'truncated' => $truncated || strlen($body) > $limit,
            'sources' => $sources,
        ];
    }

    /**
     * @param array<string,mixed> $fields
     * @param array<int,string> $sources
     * @return array{body:string,truncated:bool,sources:array<int,string>}
     */
    private static function bodyInfoFromParsedFields(array $fields, int $limit, array $sources): array
    {
        $body = implode("\n", self::fieldLines($fields));

        return self::bodyInfo($body, strlen($body) > $limit, $sources, $limit);
    }

    /**
     * @return array<string,mixed>
     */
    private static function postedFieldsFromGlobals(): array
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The WAF intentionally inspects parsed form fields before application nonce checks; values are converted to bounded inspection text before rule matching.
        return is_array($_POST) ? $_POST : [];
    }

    /**
     * @return array<string,mixed>
     */
    private static function parseUrlEncodedBody(string $body): array
    {
        $parsed = [];
        parse_str($body, $parsed);

        return is_array($parsed) ? $parsed : [];
    }

    /**
     * @param array<string,mixed> $parsed
     */
    private static function rehydratePostedFields(array $parsed): void
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Restores only an empty parsed-form surface; the downstream endpoint remains responsible for nonce verification.
        if ($parsed === [] || !isset($_POST) || !is_array($_POST) || $_POST !== []) {
            return;
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Writes only the PHP-parsed form surface after a compatible raw-body read.
        $_POST = $parsed;
    }

    /**
     * @param array<string|int,mixed> $fields
     * @return array<int,string>
     */
    private static function fieldLines(array $fields, string $prefix = ''): array
    {
        $lines = [];

        foreach ($fields as $key => $value) {
            $name = self::inspectionText((string) $key);
            if ($name === '') {
                continue;
            }

            $fieldName = $prefix !== '' ? $prefix . '[' . $name . ']' : $name;
            if (self::isSensitiveFieldName($fieldName)) {
                $lines[] = $fieldName . '=[redacted]';
                continue;
            }

            if (is_array($value)) {
                $lines = array_merge($lines, self::fieldLines($value, $fieldName));
                continue;
            }

            if (!is_scalar($value)) {
                continue;
            }

            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- WAF inspection text must preserve attacker-controlled probes; sensitive fields are redacted and output is never echoed.
            $raw = function_exists('wp_unslash') ? wp_unslash($value) : $value;
            $lines[] = $fieldName . '=' . self::inspectionText(is_scalar($raw) ? (string) $raw : '');
        }

        return $lines;
    }

    private static function isSensitiveFieldName(string $field): bool
    {
        if (preg_match('/(^|[\[_-])action($|[\]_-])/i', $field) === 1) {
            return false;
        }

        return preg_match('/(?:^|[\[_-])(?:pass|pwd|password|nonce|wpnonce|token|secret|auth|security|session|cookie|card|cc|cvv|cvc|iban|stripe|paypal|license[_-]?key|user[_-]?login|user[_-]?email|email|first[_-]?name|last[_-]?name|display[_-]?name|nickname)(?:$|[\]_-])/i', $field) === 1;
    }

    /**
     * @return array{body:string,truncated:bool,sources:array<int,string>}
     */
    private static function multipartBodyFromGlobals(int $limit): array
    {
        $parts = [];
        $sources = [];

        $posted = self::postedFieldsFromGlobals();
        if (!empty($posted)) {
            $parts[] = implode("\n", self::fieldLines($posted));
            $sources[] = 'parsed_form';
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The WAF inspects upload metadata before application nonce checks and never reads binary file contents here.
        if (isset($_FILES) && is_array($_FILES) && !empty($_FILES)) {
            // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Upload metadata is converted to bounded inspection text only and binary file contents are never read here.
            $parts[] = self::filesMetadataToString($_FILES, $limit);
            $sources[] = 'upload_metadata';
        }

        if (!empty($parts)) {
            $body = trim(implode("\n", array_filter($parts, static function (string $value): bool {
                return $value !== '';
            })));

            return self::bodyInfo($body, strlen($body) > $limit, $sources, $limit);
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- The WAF intentionally inspects the raw request body only to extract multipart headers; the read is php://input only, bounded to $limit + 1 bytes, and the content is never executed or echoed.
        $body = file_get_contents('php://input', false, null, 0, $limit + 1);
        if (!is_string($body)) {
            return self::bodyInfo('', false, []);
        }

        return self::bodyInfo(self::truncate($body, $limit), strlen($body) > $limit, ['multipart_raw_body'], $limit);
    }

    /**
     * @param array<string,mixed> $files
     */
    private static function filesMetadataToString(array $files, int $limit): string
    {
        $lines = [];
        self::appendFileMetadataLines($lines, $files);

        return self::truncate(implode("\n", $lines), $limit);
    }

    /**
     * @param array<int,string> $lines
     * @param array<string|int,mixed> $files
     */
    private static function appendFileMetadataLines(array &$lines, array $files, string $prefix = ''): void
    {
        foreach ($files as $key => $value) {
            $name = self::inspectionText((string) $key);
            $fieldName = $prefix !== '' ? $prefix . '[' . $name . ']' : $name;

            if (is_array($value) && array_key_exists('name', $value)) {
                $filename = $value['name'];
                if (is_array($filename)) {
                    self::appendFileMetadataLines($lines, $filename, $fieldName . '[name]');
                    continue;
                }

                $type = isset($value['type']) && is_scalar($value['type']) ? (string) $value['type'] : '';
                $size = isset($value['size']) && is_scalar($value['size']) ? (string) $value['size'] : '';
                $lines[] = trim('field=' . $fieldName . ' filename="' . self::inspectionText(is_scalar($filename) ? (string) $filename : '') . '" type="' . self::inspectionText($type) . '" size=' . self::inspectionText($size));
                continue;
            }

            if (is_array($value)) {
                self::appendFileMetadataLines($lines, $value, $fieldName);
            }
        }
    }

    private static function multipartHeadersFromBody(string $body, int $limit): string
    {
        $headers = [];
        foreach (preg_split('/\r\n|\n|\r/', $body) ?: [] as $line) {
            $line = self::inspectionText((string) $line);
            if (preg_match('/^(?:content-disposition|content-type)\s*:/i', $line) === 1) {
                $headers[] = $line;
            }
        }

        return self::truncate(implode("\n", $headers), $limit);
    }

    private static function inspectionText(string $value): string
    {
        return function_exists('wp_unslash') ? (string) wp_unslash($value) : $value;
    }

    private static function decodeJsonUnicodeEscapes(string $content): string
    {
        $decoded = preg_replace_callback('/\\\\u([0-9a-fA-F]{4})/', static function (array $matches): string {
            $codepoint = hexdec((string) $matches[1]);
            if ($codepoint < 32 && !in_array($codepoint, [9, 10, 13], true)) {
                return '';
            }

            return html_entity_decode('&#x' . (string) $matches[1] . ';', ENT_NOQUOTES, 'UTF-8');
        }, $content);

        return is_string($decoded) ? $decoded : $content;
    }

    /**
     * @param array<int,string> $values
     * @param array<string,bool> $seen
     */
    private static function appendBase64Expansions(array &$values, array &$seen, string $content, int $limit): void
    {
        // The candidate expression below requires at least 16 Base64 characters,
        // so shorter surfaces cannot contain an encoded payload.
        if (strlen($content) < 16) {
            return;
        }

        if (preg_match_all('/(?:^|[^A-Za-z0-9+\/_-])([A-Za-z0-9+\/_-]{16,512}={0,2})(?=$|[^A-Za-z0-9+\/_-])/', $content, $matches) < 1) {
            return;
        }

        $checked = 0;
        foreach ((array) ($matches[1] ?? []) as $token) {
            if ($checked >= 5) {
                break;
            }
            $checked++;

            $candidate = strtr((string) $token, '-_', '+/');
            $remainder = strlen($candidate) % 4;
            if ($remainder === 1) {
                continue;
            }
            if ($remainder > 0) {
                $candidate .= str_repeat('=', 4 - $remainder);
            }

            $decoded = base64_decode($candidate, true);
            if (!is_string($decoded) || !self::isMostlyText($decoded)) {
                continue;
            }

            self::appendUnique($values, $seen, self::truncate($decoded, $limit));
        }
    }

    private static function isMostlyText(string $value): bool
    {
        if ($value === '') {
            return false;
        }

        $printable = preg_replace('/[^\P{C}\t\r\n]/u', '', $value);
        if (!is_string($printable)) {
            return false;
        }

        return strlen($printable) >= (int) floor(strlen($value) * 0.85);
    }

    /**
     * @param array<string,mixed> $request
     */
    private static function requestBodyTruncated(array $request, int $limit): bool
    {
        return !empty($request['body_truncated'])
            || (isset($request['body']) && is_scalar($request['body']) && strlen((string) $request['body']) > $limit);
    }

    /**
     * @param array<int,string> $values
     * @param array<string,bool> $seen
     */
    private static function appendUnique(array &$values, array &$seen, string $value): void
    {
        if ($value === '') {
            return;
        }

        $key = hash('sha256', $value);
        if (isset($seen[$key])) {
            return;
        }

        $seen[$key] = true;
        $values[] = $value;
    }

    /**
     * @param array<string,mixed> $rule
     * @return array<int,string>
     */
    private static function ruleFields(array $rule): array
    {
        if (isset($rule['fields']) && is_array($rule['fields'])) {
            return array_values(array_filter(array_map('strval', $rule['fields'])));
        }

        if (isset($rule['field']) && (string) $rule['field'] !== '') {
            return [(string) $rule['field']];
        }

        return ['all'];
    }

    private static function truncate(string $value, int $limit): string
    {
        return strlen($value) > $limit ? substr($value, 0, $limit) : $value;
    }

    /**
     * @param array<int,string> $fields
     * @param array<int,string> $patterns
     * @return array<string,mixed>
     */
    private static function rule(
        string $id,
        string $name,
        string $description,
        string $category,
        string $severity,
        array $fields,
        array $patterns,
        int $score,
        string $cwe,
        string $owasp,
        string $source = 'SHIELD-X managed',
        string $action = 'default'
    ): array {
        return [
            'id' => $id,
            'name' => $name,
            'description' => $description,
            'category' => $category,
            'severity' => $severity,
            'source' => $source,
            'rule_family' => 'WordPress request hardening and application security pattern families',
            'license' => 'SHIELD-X bundled rule catalog',
            'cwe' => $cwe,
            'owasp' => $owasp,
            'enabled' => true,
            'field' => implode(', ', $fields),
            'fields' => $fields,
            'patterns' => $patterns,
            'pattern_count' => count($patterns),
            'score' => $score,
            'action' => $action,
        ];
    }

    /**
     * @param array<int,string> $fields
     * @param array<int,string> $patterns
     * @return array<string,mixed>
     */
    private static function appSecRule(
        string $id,
        string $name,
        string $description,
        string $category,
        string $severity,
        array $fields,
        array $patterns,
        int $score,
        string $cwe,
        string $owasp,
        string $action = 'default'
    ): array {
        $rule = self::rule(
            $id,
            $name,
            $description,
            $category,
            $severity,
            $fields,
            $patterns,
            $score,
            $cwe,
            $owasp,
            'SHIELD-X managed, AppSec ruleset',
            $action
        );

        $rule['rule_family'] = 'SHIELD-X managed, AppSec ruleset';
        $rule['license'] = 'SHIELD-X bundled rule catalog';

        return $rule;
    }
}
