<?php

declare(strict_types=1);

namespace ShieldX\Security;

defined('ABSPATH') || exit;

use ShieldX\Core\RequestCache;
use ShieldX\Support\RequestPath;
use ShieldX\Support\WordPressPaths;

final class WafSettings
{
    public const OPTION_NAME = 'shieldx_waf_settings';
    public const MAX_REPUTATION_FEED_URL_LENGTH = 2048;
    public const DEFAULT_TEMPORARY_RULE_BLOCK_MINUTES = 5;
    public const DEFAULT_MAX_BODY_INSPECTION_BYTES = 32768;
    public const MIN_MAX_BODY_INSPECTION_BYTES = 8192;
    public const MAX_MAX_BODY_INSPECTION_BYTES = 131072;
    public const DEFAULT_ANOMALY_CHALLENGE_THRESHOLD = 10;
    public const DEFAULT_ANOMALY_BLOCK_THRESHOLD = 20;

    /** @var array<int,string> */
    private const PROXY_HEADER_NAMES = [
        'cf-connecting-ip',
        'front-end-https',
        'forwarded',
        'true-client-ip',
        'via',
        'x-client-ip',
        'x-forwarded-for',
        'x-forwarded-host',
        'x-forwarded-port',
        'x-forwarded-proto',
        'x-forwarded-scheme',
        'x-forwarded-ssl',
        'x-real-ip',
    ];

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

    /**
     * @var array<string,array{exact:array<string,string>,cidr:array<int,string>}>
     */
    private $ipListCache = [];

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

    /**
     * @param array<string,mixed> $settings
     */
    public function __construct(array $settings = [])
    {
        $this->settings = self::normalize($settings);
    }

    public static function fromWordPress(): self
    {
        $cached = RequestCache::get(self::class);
        if ($cached instanceof self) {
            return $cached;
        }

        $settings = function_exists('get_option') ? get_option(self::OPTION_NAME, []) : [];

        $instance = new self(is_array($settings) ? $settings : []);
        RequestCache::rememberOption(self::class, self::OPTION_NAME, $instance);

        return $instance;
    }

    /**
     * @return array<string,mixed>
     */
    public function all(): array
    {
        $settings = $this->settings;
        $settings['temporary_blocks'] = $this->activeTemporaryBlocks();

        return $settings;
    }

    public function mode(): string
    {
        return (string) $this->settings['mode'];
    }

    public function temporaryRuleBlockMinutes(): int
    {
        return (int) $this->settings['temporary_rule_block_minutes'];
    }

    public function maxBodyInspectionBytes(): int
    {
        return (int) $this->settings['max_body_inspection_bytes'];
    }

    public function anomalyThresholdEnabled(): bool
    {
        return !empty($this->settings['anomaly_threshold_enabled']);
    }

    public function anomalyChallengeThreshold(): int
    {
        return (int) $this->settings['anomaly_challenge_threshold'];
    }

    public function anomalyBlockThreshold(): int
    {
        return (int) $this->settings['anomaly_block_threshold'];
    }

    /**
     * @return array<int,string>
     */
    public function disabledRules(): array
    {
        return (array) $this->settings['disabled_rules'];
    }

    /**
     * @return array<string,string>
     */
    public function ruleActions(): array
    {
        return (array) $this->settings['rule_actions'];
    }

    public function ruleAction(string $ruleId): string
    {
        $actions = $this->ruleActions();

        if (isset($actions[$ruleId])) {
            return (string) $actions[$ruleId];
        }

        return 'default';
    }

    /**
     * @return array<int,string>
     */
    public function pluginWhitelist(): array
    {
        return (array) $this->settings['plugin_whitelist'];
    }

    public function trustedProxyHeader(): string
    {
        return (string) $this->settings['trusted_proxy_header'];
    }

    /**
     * @return array<int,string>
     */
    public function trustedProxyCidrs(): array
    {
        return (array) $this->settings['trusted_proxy_cidrs'];
    }

    /**
     * @param array<string,mixed> $request
     */
    public function hasProxyHeaders(array $request): bool
    {
        if (!empty($request['proxy_headers_present'])) {
            return true;
        }

        $headers = isset($request['headers']) && is_array($request['headers']) ? $request['headers'] : [];
        foreach ($headers as $name => $value) {
            if (!is_scalar($value) || trim((string) $value) === '') {
                continue;
            }

            $name = strtolower(str_replace('_', '-', trim((string) $name)));
            if (in_array($name, self::PROXY_HEADER_NAMES, true) || strpos($name, 'x-forwarded-') === 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param array<int,array<string,mixed>> $requests
     */
    public function shouldWarnForLoopbackProxyTraffic(array $requests): bool
    {
        if ($this->trustedProxyHeader() !== '' && $this->trustedProxyCidrs() !== []) {
            return false;
        }

        $hasObservedRequest = false;
        $hasProxyHeaders = false;

        foreach ($requests as $request) {
            $ip = trim((string) ($request['ip'] ?? ''));
            if (!filter_var($ip, FILTER_VALIDATE_IP)) {
                continue;
            }

            $hasObservedRequest = true;
            if (!self::isLoopbackIp($ip)) {
                return false;
            }

            if ($this->hasProxyHeaders($request)) {
                $hasProxyHeaders = true;
            }
        }

        return $hasObservedRequest && $hasProxyHeaders;
    }

    public function isRuleDisabled(string $ruleId): bool
    {
        return in_array($ruleId, $this->disabledRules(), true);
    }

    /**
     * @param array<int,array<string,mixed>> $rules
     * @return array<int,array<string,mixed>>
     */
    public function applyToRules(array $rules): array
    {
        foreach ($rules as $index => $rule) {
            $id = (string) ($rule['id'] ?? '');

            if ($id !== '' && $this->isRuleDisabled($id)) {
                $rules[$index]['enabled'] = false;
            } elseif (!array_key_exists('enabled', $rule)) {
                $rules[$index]['enabled'] = true;
            }

            $configuredAction = $id !== '' ? $this->ruleAction($id) : 'default';
            $rules[$index]['action'] = $configuredAction !== 'default'
                ? $configuredAction
                : (string) ($rule['action'] ?? 'default');
        }

        return array_values($rules);
    }

    /**
     * @param array<string,mixed> $request
     * @return array<string,mixed>
     */
    public function assessRequest(array $request): array
    {
        $ip = (string) ($request['ip'] ?? '');
        $userAgent = (string) ($request['user_agent'] ?? '');
        $referrer = $this->referrerFromRequest($request);
        $country = $this->countryFromRequest($request);
        $hostname = $this->hostnameFromRequest($request, $ip);

        if ($ip !== '' && $ip !== 'unknown' && $this->matchesIpList($ip, (array) $this->settings['ip_allowlist'])) {
            return [
                'action' => 'allow',
                'reason' => 'ip_allowlist',
                'matched_value' => $ip,
                'ip' => $ip,
            ];
        }

        $serverIpMatched = $this->matchedAutomaticServerIp($ip, $request);
        if ($serverIpMatched !== '') {
            return [
                'action' => 'allow',
                'reason' => 'server_ip_allowlist',
                'matched_value' => $serverIpMatched,
                'ip' => $ip,
            ];
        }

        if ($ip !== '' && $ip !== 'unknown') {
            $matched = $this->matchedIpEntry($ip, (array) $this->settings['ip_blocklist']);
            if ($matched !== '') {
                return $this->blockDecision('ip_blocklist', $matched, $ip, $country);
            }
        }

        $countryMatched = $this->matchedCountry($country, (array) $this->settings['country_blocklist']);
        if ($countryMatched !== '') {
            return $this->blockDecision('country_blocklist', $countryMatched, $ip, $country);
        }

        $uaMatched = $this->matchedTextToken($userAgent, (array) $this->settings['user_agent_blocklist']);
        if ($uaMatched !== '') {
            return $this->blockDecision('user_agent_blocklist', $uaMatched, $ip, $country);
        }

        $referrerMatched = $this->matchedTextToken($referrer, (array) $this->settings['referrer_blocklist']);
        if ($referrerMatched !== '') {
            return $this->blockDecision('referrer_blocklist', $referrerMatched, $ip, $country);
        }

        $hostnameMatched = $this->matchedHostnameToken($hostname, (array) $this->settings['hostname_blocklist']);
        if ($hostnameMatched !== '') {
            return $this->blockDecision('hostname_blocklist', $hostnameMatched, $ip, $country);
        }

        if (!empty($this->settings['reputation_enabled']) && !$this->isReputationAllowlisted($ip, $hostname, $userAgent, $referrer)) {
            $reputationIpMatched = $this->matchedIpEntry($ip, (array) $this->settings['reputation_ip_blocklist']);
            if ($reputationIpMatched !== '') {
                return $this->blockDecision('reputation_ip_blocklist', $reputationIpMatched, $ip, $country);
            }

            $reputationHostnameMatched = $this->matchedHostnameToken($hostname, (array) $this->settings['reputation_hostname_blocklist']);
            if ($reputationHostnameMatched !== '') {
                return $this->blockDecision('reputation_hostname_blocklist', $reputationHostnameMatched, $ip, $country);
            }

            $reputationUaMatched = $this->matchedTextToken($userAgent, (array) $this->settings['reputation_user_agent_blocklist']);
            if ($reputationUaMatched !== '') {
                return $this->blockDecision('reputation_user_agent_blocklist', $reputationUaMatched, $ip, $country);
            }
        }

        foreach ($this->activeTemporaryBlocks() as $block) {
            $type = (string) ($block['type'] ?? '');
            $value = (string) ($block['value'] ?? '');

            if ($this->temporaryBlockMatches($type, $value, $ip, $country, $userAgent, $referrer)) {
                $decision = $this->blockDecision('temporary_' . $type . '_block', $value, $ip, $country);
                $decision['expires_at'] = (int) ($block['expires_at'] ?? 0);
                $decision['temporary_id'] = (string) ($block['id'] ?? '');

                return $decision;
            }
        }

        return [
            'action' => 'inspect',
            'reason' => '',
            'ip' => $ip,
            'country' => $country,
        ];
    }

    /**
     * @param array<string,mixed> $request
     */
    public function isPluginWhitelistedRequest(array $request): bool
    {
        return $this->whitelistedPluginForRequest($request) !== '';
    }

    /**
     * @param array<string,mixed> $request
     */
    public function whitelistedPluginForRequest(array $request): string
    {
        $uri = (string) ($request['uri'] ?? '');
        $path = explode('?', explode('#', $uri, 2)[0], 2)[0];
        if (preg_match('~^https?://[^/]+(?P<path>/.*)?$~i', $path, $matches)) {
            $path = isset($matches['path']) && $matches['path'] !== '' ? $matches['path'] : '/';
        }

        $path = strtolower(str_replace('\\', '/', RequestPath::decodeTwice($path)));

        if (self::isUnsafeWhitelistPath($path)) {
            return '';
        }

        $base = self::wordPressBasePath();
        foreach ($this->pluginWhitelist() as $slug) {
            $pluginDirectory = $base . '/wp-content/plugins/' . $slug . '/';
            $singleFilePlugin = $base . '/wp-content/plugins/' . $slug . '.php';

            if (strpos($path, $pluginDirectory) === 0 || $path === $singleFilePlugin) {
                return $slug;
            }
        }

        return '';
    }

    /**
     * @param array<int,string>         $disabledRules
     * @param array<int,string>         $pluginWhitelist
     * @param array<string,mixed>       $extra
     */
    public function save(array $disabledRules, array $pluginWhitelist, array $extra = []): void
    {
        $extra['disabled_rules'] = $disabledRules;
        $extra['plugin_whitelist'] = $pluginWhitelist;
        $this->settings = self::normalize($extra);
        $this->activeTemporaryBlocks = null;

        if (function_exists('update_option')) {
            // The WAF consults this option on normal front-end requests, so keep it in WordPress'
            // autoloaded options set instead of forcing a separate options lookup per request.
            // phpcs:ignore -- Intentional autoload=yes for hot-path WAF configuration.
            update_option(self::OPTION_NAME, $this->settings, true);
        }

        RequestCache::rememberOption(self::class, self::OPTION_NAME, $this);
    }

    /**
     * @return array{ip:string,ip_allowlisted:bool,ip_block_entries_removed:int,temporary_blocks_removed:int,rule_block_overrides_removed:int,mode:string}
     */
    public function emergencySafeModeForIp(
        string $ip,
        bool $setLogOnly = false,
        bool $clearIpBlocks = true,
        bool $allowlistIp = true,
        bool $clearRuleBlockOverrides = true
    ): array
    {
        $ip = filter_var($ip, FILTER_VALIDATE_IP) ? $ip : '';
        $ipBlockEntriesRemoved = 0;
        $temporaryBlocksRemoved = 0;
        $ruleBlockOverridesRemoved = 0;
        $ipAllowlisted = false;
        $settings = $this->settings;

        if ($setLogOnly) {
            $settings['mode'] = 'log_only';
        }

        $ruleActions = [];
        foreach ((array) $settings['rule_actions'] as $ruleId => $action) {
            if ($clearRuleBlockOverrides && in_array((string) $action, ['block', 'challenge'], true)) {
                $ruleBlockOverridesRemoved++;
                continue;
            }

            $ruleActions[(string) $ruleId] = (string) $action;
        }
        $settings['rule_actions'] = $ruleActions;

        if ($ip !== '' && $clearIpBlocks) {
            $blocklist = [];
            foreach ((array) $settings['ip_blocklist'] as $entry) {
                $entry = (string) $entry;
                if ($entry === $ip || (strpos($entry, '/') !== false && $this->ipInCidr($ip, $entry))) {
                    $ipBlockEntriesRemoved++;
                    continue;
                }

                $blocklist[] = $entry;
            }
            $settings['ip_blocklist'] = $blocklist;

            $temporaryBlocks = [];
            foreach ((array) $settings['temporary_blocks'] as $block) {
                if (!is_array($block)) {
                    continue;
                }

                $type = (string) ($block['type'] ?? '');
                $value = (string) ($block['value'] ?? '');
                if ($type === 'ip' && ($value === $ip || (strpos($value, '/') !== false && $this->ipInCidr($ip, $value)))) {
                    $temporaryBlocksRemoved++;
                    continue;
                }

                $temporaryBlocks[] = $block;
            }
            $settings['temporary_blocks'] = $temporaryBlocks;
        }

        if ($ip !== '' && $allowlistIp) {
            $allowlist = (array) $settings['ip_allowlist'];
            if (!$this->matchesIpList($ip, $allowlist)) {
                $allowlist[] = $ip;
                $ipAllowlisted = true;
            }
            $settings['ip_allowlist'] = $allowlist;
        }

        $this->settings = self::normalize($settings);
        $this->activeTemporaryBlocks = null;

        if (function_exists('update_option')) {
            // The WAF consults this option on normal front-end requests, so keep it in WordPress'
            // autoloaded options set instead of forcing a separate options lookup per request.
            // phpcs:ignore -- Intentional autoload=yes for hot-path WAF configuration.
            update_option(self::OPTION_NAME, $this->settings, true);
        }

        RequestCache::rememberOption(self::class, self::OPTION_NAME, $this);

        return [
            'ip' => $ip,
            'ip_allowlisted' => $ipAllowlisted,
            'ip_block_entries_removed' => $ipBlockEntriesRemoved,
            'temporary_blocks_removed' => $temporaryBlocksRemoved,
            'rule_block_overrides_removed' => $ruleBlockOverridesRemoved,
            'mode' => (string) $this->settings['mode'],
        ];
    }

    /**
     * @return array<string,array{slug:string,name:string,file:string,active:bool}>
     */
    public function availablePlugins(): array
    {
        if (!function_exists('get_plugins')) {
            $pluginApi = WordPressPaths::adminIncludesPath('plugin.php');

            if ($pluginApi !== '' && is_readable($pluginApi)) {
                require_once $pluginApi;
            }
        }

        if (!function_exists('get_plugins')) {
            return [];
        }

        $plugins = [];

        foreach (get_plugins() as $file => $data) {
            $slug = self::pluginSlugFromFile((string) $file);

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

            $plugins[$slug] = [
                'slug' => $slug,
                'name' => isset($data['Name']) && (string) $data['Name'] !== '' ? (string) $data['Name'] : $slug,
                'file' => (string) $file,
                'active' => function_exists('is_plugin_active') ? (bool) is_plugin_active((string) $file) : false,
            ];
        }

        uasort($plugins, static function (array $left, array $right): int {
            return strcasecmp($left['name'], $right['name']);
        });

        return $plugins;
    }

    public static function pluginSlugFromFile(string $pluginFile): string
    {
        $pluginFile = trim(str_replace('\\', '/', $pluginFile), '/');

        if ($pluginFile === '') {
            return '';
        }

        $parts = explode('/', $pluginFile);
        $slug = count($parts) > 1 ? $parts[0] : preg_replace('/\.php$/i', '', $parts[0]);

        return self::sanitizePluginSlug((string) $slug);
    }

    /**
     * @param array<string,mixed> $settings
     * @return array<string,mixed>
     */
    private static function normalize(array $settings): array
    {
        $anomalyChallengeThreshold = self::boundedInt($settings['anomaly_challenge_threshold'] ?? self::DEFAULT_ANOMALY_CHALLENGE_THRESHOLD, 1, 1000);
        $anomalyBlockThreshold = self::boundedInt($settings['anomaly_block_threshold'] ?? self::DEFAULT_ANOMALY_BLOCK_THRESHOLD, $anomalyChallengeThreshold, 1000);

        return [
            'mode' => self::sanitizeMode((string) ($settings['mode'] ?? 'log_only')),
            'temporary_rule_block_minutes' => self::boundedInt($settings['temporary_rule_block_minutes'] ?? self::DEFAULT_TEMPORARY_RULE_BLOCK_MINUTES, 1, 43200),
            'max_body_inspection_bytes' => self::boundedInt($settings['max_body_inspection_bytes'] ?? self::DEFAULT_MAX_BODY_INSPECTION_BYTES, self::MIN_MAX_BODY_INSPECTION_BYTES, self::MAX_MAX_BODY_INSPECTION_BYTES),
            'anomaly_threshold_enabled' => self::toBool($settings['anomaly_threshold_enabled'] ?? false),
            'anomaly_challenge_threshold' => $anomalyChallengeThreshold,
            'anomaly_block_threshold' => $anomalyBlockThreshold,
            'disabled_rules' => self::uniqueCleanList((array) ($settings['disabled_rules'] ?? []), [self::class, 'sanitizeRuleId']),
            'rule_actions' => self::cleanRuleActions((array) ($settings['rule_actions'] ?? [])),
            'plugin_whitelist' => self::uniqueCleanList((array) ($settings['plugin_whitelist'] ?? []), [self::class, 'sanitizePluginSlug']),
            'ip_allowlist' => self::uniqueCleanList(self::coerceList($settings['ip_allowlist'] ?? []), [self::class, 'sanitizeIpRange']),
            'ip_blocklist' => self::uniqueCleanList(self::coerceList($settings['ip_blocklist'] ?? []), [self::class, 'sanitizeIpRange']),
            'country_blocklist' => self::uniqueCleanList(self::coerceList($settings['country_blocklist'] ?? []), [self::class, 'sanitizeCountryCode']),
            'user_agent_blocklist' => self::uniqueCleanList(self::coerceList($settings['user_agent_blocklist'] ?? []), [self::class, 'sanitizeTextToken']),
            'referrer_blocklist' => self::uniqueCleanList(self::coerceList($settings['referrer_blocklist'] ?? []), [self::class, 'sanitizeTextToken']),
            'hostname_blocklist' => self::uniqueCleanList(self::coerceList($settings['hostname_blocklist'] ?? []), [self::class, 'sanitizeHostnameToken']),
            'hostname_resolution_enabled' => self::toBool($settings['hostname_resolution_enabled'] ?? false),
            'reputation_enabled' => self::toBool($settings['reputation_enabled'] ?? false),
            'reputation_feed_url' => self::sanitizeFeedUrl((string) ($settings['reputation_feed_url'] ?? '')),
            'reputation_allowlist' => self::uniqueCleanList(self::coerceList($settings['reputation_allowlist'] ?? []), [self::class, 'sanitizeReputationToken']),
            'reputation_ip_blocklist' => self::uniqueCleanList(self::coerceList($settings['reputation_ip_blocklist'] ?? []), [self::class, 'sanitizeIpRange']),
            'reputation_hostname_blocklist' => self::uniqueCleanList(self::coerceList($settings['reputation_hostname_blocklist'] ?? []), [self::class, 'sanitizeHostnameToken']),
            'reputation_user_agent_blocklist' => self::uniqueCleanList(self::coerceList($settings['reputation_user_agent_blocklist'] ?? []), [self::class, 'sanitizeTextToken']),
            'reputation_last_refresh' => max(0, (int) ($settings['reputation_last_refresh'] ?? 0)),
            'reputation_last_attempt' => max(0, (int) ($settings['reputation_last_attempt'] ?? 0)),
            'reputation_last_status' => self::sanitizeTextToken((string) ($settings['reputation_last_status'] ?? '')),
            'reputation_last_error' => self::sanitizeTextToken((string) ($settings['reputation_last_error'] ?? '')),
            'trusted_proxy_header' => ClientIpResolver::sanitizeProxyHeader((string) ($settings['trusted_proxy_header'] ?? '')),
            'trusted_proxy_cidrs' => self::uniqueCleanList(self::coerceList($settings['trusted_proxy_cidrs'] ?? []), [self::class, 'sanitizeTrustedProxyRange']),
            'temporary_blocks' => self::cleanTemporaryBlocks((array) ($settings['temporary_blocks'] ?? [])),
        ];
    }

    /**
     * @param mixed $value
     */
    private static function toBool($value): bool
    {
        if (is_bool($value)) {
            return $value;
        }

        return in_array(strtolower((string) $value), ['1', 'true', 'yes', 'on'], true);
    }

    /**
     * @param mixed $value
     */
    private static function boundedInt($value, int $min, int $max): int
    {
        return max($min, min($max, (int) $value));
    }

    /**
     * @param mixed $value
     * @return array<int,mixed>
     */
    private static function coerceList($value): array
    {
        if (is_string($value)) {
            return preg_split('~[\r\n,]+~', $value) ?: [];
        }

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

    /**
     * @param array<int,mixed> $values
     * @param callable         $sanitize
     * @return array<int,string>
     */
    private static function uniqueCleanList(array $values, callable $sanitize): array
    {
        $clean = [];

        foreach ($values as $value) {
            $sanitized = (string) call_user_func($sanitize, (string) $value);

            if ($sanitized !== '') {
                $clean[$sanitized] = $sanitized;
            }
        }

        ksort($clean);

        return array_values($clean);
    }

    /**
     * @param array<string,mixed> $actions
     * @return array<string,string>
     */
    private static function cleanRuleActions(array $actions): array
    {
        $clean = [];

        foreach ($actions as $ruleId => $action) {
            $ruleId = self::sanitizeRuleId((string) $ruleId);
            $action = self::sanitizeRuleAction((string) $action);

            if ($ruleId !== '' && $action !== 'default') {
                $clean[$ruleId] = $action;
            }
        }

        ksort($clean);

        return $clean;
    }

    /**
     * @param array<int,mixed> $blocks
     * @return array<int,array<string,mixed>>
     */
    private static function cleanTemporaryBlocks(array $blocks): array
    {
        $clean = [];
        $now = time();

        foreach ($blocks as $block) {
            if (!is_array($block)) {
                continue;
            }

            $type = self::sanitizeTemporaryType((string) ($block['type'] ?? ''));
            $value = self::sanitizeTemporaryValue($type, (string) ($block['value'] ?? ''));
            $expiresAt = (int) ($block['expires_at'] ?? 0);
            $rawReason = (string) ($block['reason'] ?? '');

            if ($type === '' || $value === '' || $expiresAt <= $now) {
                continue;
            }

            if (strpos($rawReason, 'SHIELDX-WAF-EVALUATION-BUDGET') !== false) {
                continue;
            }

            $id = self::sanitizeBlockId((string) ($block['id'] ?? ''));
            if ($id === '') {
                $id = substr(sha1($type . '|' . $value . '|' . (string) $expiresAt), 0, 16);
            }

            $reason = self::sanitizeTextToken($rawReason);
            $clean[$id] = [
                'id' => $id,
                'type' => $type,
                'value' => $value,
                'expires_at' => $expiresAt,
            ];

            if ($reason !== '') {
                $clean[$id]['reason'] = $reason;
            }
        }

        ksort($clean);

        return array_values($clean);
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function activeTemporaryBlocks(): array
    {
        if ($this->activeTemporaryBlocks === null) {
            $this->activeTemporaryBlocks = self::cleanTemporaryBlocks((array) $this->settings['temporary_blocks']);
        }

        return $this->activeTemporaryBlocks;
    }

    private static function sanitizeMode(string $mode): string
    {
        return in_array($mode, ['log_only', 'temporary', 'enforce'], true) ? $mode : 'log_only';
    }

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

    private static function sanitizeRuleId(string $ruleId): string
    {
        $ruleId = trim($ruleId);

        return preg_match('/^[A-Za-z0-9._:-]{1,96}$/', $ruleId) === 1 ? $ruleId : '';
    }

    private static function sanitizePluginSlug(string $slug): string
    {
        $slug = trim($slug);

        if ($slug === '') {
            return '';
        }

        if (function_exists('sanitize_key')) {
            $slug = sanitize_key($slug);
        } else {
            $slug = strtolower($slug);
            $slug = (string) preg_replace('/[^a-z0-9_-]/', '', $slug);
        }

        return $slug;
    }

    private static function sanitizeIpRange(string $value): string
    {
        return ClientIpResolver::sanitizeIpRange($value);
    }

    private static function sanitizeTrustedProxyRange(string $value): string
    {
        return ClientIpResolver::sanitizeIpRange($value, true);
    }

    private static function sanitizeCountryCode(string $value): string
    {
        $value = strtoupper(trim($value));

        return preg_match('/^[A-Z]{2}$/', $value) === 1 ? $value : '';
    }

    private static function sanitizeTextToken(string $value): string
    {
        $value = function_exists('wp_strip_all_tags')
            ? wp_strip_all_tags($value)
            : (preg_replace('/<[^>]*>/', '', $value) ?: '');
        $value = trim($value);
        $value = preg_replace('/[\x00-\x1F\x7F]+/', ' ', $value) ?: '';
        $value = trim($value);

        return $value !== '' ? substr($value, 0, 160) : '';
    }

    private static function sanitizeHostnameToken(string $value): string
    {
        $value = strtolower(self::sanitizeTextToken($value));
        $value = str_replace(['*', 'http://', 'https://'], '', $value);
        $value = preg_replace('/[^a-z0-9._:-]+/', '', $value) ?: '';
        $value = trim($value, '.:-_');

        return $value !== '' ? substr($value, 0, 253) : '';
    }

    private static function sanitizeReputationToken(string $value): string
    {
        $ip = self::sanitizeIpRange($value);
        if ($ip !== '') {
            return $ip;
        }

        $hostname = self::sanitizeHostnameToken($value);
        if ($hostname !== '') {
            return $hostname;
        }

        return self::sanitizeTextToken($value);
    }

    private static function sanitizeFeedUrl(string $url): string
    {
        return ReputationFeed::sanitizeUrl($url);
    }

    private static function sanitizeTemporaryType(string $type): string
    {
        return in_array($type, ['ip', 'country', 'user_agent', 'referrer'], true) ? $type : '';
    }

    private static function sanitizeTemporaryValue(string $type, string $value): string
    {
        if ($type === 'ip') {
            return self::sanitizeIpRange($value);
        }

        if ($type === 'country') {
            return self::sanitizeCountryCode($value);
        }

        if ($type === 'user_agent' || $type === 'referrer') {
            return self::sanitizeTextToken($value);
        }

        return '';
    }

    private static function sanitizeBlockId(string $id): string
    {
        $id = trim($id);

        return preg_match('/^[a-f0-9]{8,40}$/', $id) === 1 ? $id : '';
    }

    /**
     * @param array<string,mixed> $request
     */
    private function referrerFromRequest(array $request): string
    {
        $headers = isset($request['headers']) && is_array($request['headers']) ? $request['headers'] : [];

        return (string) ($headers['referer'] ?? $headers['referrer'] ?? '');
    }

    /**
     * @param array<string,mixed> $request
     */
    private function countryFromRequest(array $request): string
    {
        if (isset($request['country']) && is_scalar($request['country'])) {
            $country = self::sanitizeCountryCode((string) $request['country']);
            if ($country !== '') {
                return $country;
            }
        }

        if (empty($request['trusted_proxy'])) {
            return '';
        }

        $headers = isset($request['headers']) && is_array($request['headers']) ? $request['headers'] : [];
        $candidates = [
            $headers['cf-ipcountry'] ?? '',
            $headers['x-country-code'] ?? '',
            $headers['x-geoip-country-code'] ?? '',
            $headers['x-appengine-country'] ?? '',
        ];

        foreach ($candidates as $candidate) {
            $country = self::sanitizeCountryCode((string) $candidate);
            if ($country !== '') {
                return $country;
            }
        }

        return '';
    }

    /**
     * @param array<int,string> $list
     */
    private function matchesIpList(string $ip, array $list): bool
    {
        return $this->matchedIpEntry($ip, $list) !== '';
    }

    /**
     * @param array<string,mixed> $request
     */
    private function matchedAutomaticServerIp(string $ip, array $request): string
    {
        if ($ip === '' || $ip === 'unknown' || !filter_var($ip, FILTER_VALIDATE_IP)) {
            return '';
        }

        // A reverse proxy commonly appears as loopback/LOCAL_ADDR. Keep its
        // traffic out of the automatic same-server allowlist so it is assessed
        // using the configured proxy and WAF controls instead.
        if ($this->hasProxyHeaders($request)) {
            return '';
        }

        $serverIps = [
            '127.0.0.1',
            '::1',
        ];

        $candidates = [];
        if (isset($request['server_ips']) && is_array($request['server_ips'])) {
            $candidates = $request['server_ips'];
        } elseif (isset($request['server_ip'])) {
            $candidates = [$request['server_ip']];
        }

        foreach ($candidates as $candidate) {
            $candidate = is_scalar($candidate) ? trim((string) $candidate) : '';
            if ($candidate !== '' && filter_var($candidate, FILTER_VALIDATE_IP) && !in_array($candidate, $serverIps, true)) {
                $serverIps[] = $candidate;
            }
        }

        return $this->matchedIpEntry($ip, $serverIps);
    }

    private static function isLoopbackIp(string $ip): bool
    {
        return $ip === '127.0.0.1' || $ip === '::1';
    }

    /**
     * @param array<int,string> $list
     */
    private function matchedIpEntry(string $ip, array $list): string
    {
        $lookup = $this->ipLookupFor($list);
        if (isset($lookup['exact'][$ip])) {
            return $lookup['exact'][$ip];
        }

        foreach ($lookup['cidr'] as $entry) {
            if ($this->ipInCidr($ip, $entry)) {
                return $entry;
            }
        }

        return '';
    }

    /**
     * @param array<int,string> $list
     * @return array{exact:array<string,string>,cidr:array<int,string>}
     */
    private function ipLookupFor(array $list): array
    {
        $cacheKey = md5(implode("\n", array_map('strval', $list)));
        if (isset($this->ipListCache[$cacheKey])) {
            return $this->ipListCache[$cacheKey];
        }

        $lookup = [
            'exact' => [],
            'cidr' => [],
        ];

        foreach ($list as $entry) {
            $entry = (string) $entry;
            if ($entry === '') {
                continue;
            }

            if (strpos($entry, '/') === false && filter_var($entry, FILTER_VALIDATE_IP)) {
                $lookup['exact'][$entry] = $entry;
                continue;
            }

            if (strpos($entry, '/') !== false) {
                $lookup['cidr'][] = $entry;
            }
        }

        $this->ipListCache[$cacheKey] = $lookup;

        return $lookup;
    }

    private function ipInCidr(string $ip, string $cidr): bool
    {
        [$range, $prefix] = explode('/', $cidr, 2);
        $prefix = (int) $prefix;
        if (!filter_var($ip, FILTER_VALIDATE_IP) || !filter_var($range, FILTER_VALIDATE_IP)) {
            return false;
        }

        $ipBin = inet_pton($ip);
        $rangeBin = inet_pton($range);

        if (!is_string($ipBin) || !is_string($rangeBin) || strlen($ipBin) !== strlen($rangeBin)) {
            return false;
        }

        $bytes = intdiv($prefix, 8);
        $bits = $prefix % 8;

        if ($bytes > 0 && substr($ipBin, 0, $bytes) !== substr($rangeBin, 0, $bytes)) {
            return false;
        }

        if ($bits === 0) {
            return true;
        }

        $mask = (0xff << (8 - $bits)) & 0xff;

        return (ord($ipBin[$bytes]) & $mask) === (ord($rangeBin[$bytes]) & $mask);
    }

    /**
     * @param array<int,string> $countries
     */
    private function matchedCountry(string $country, array $countries): string
    {
        return $country !== '' && in_array($country, $countries, true) ? $country : '';
    }

    /**
     * @param array<int,string> $tokens
     */
    private function matchedTextToken(string $value, array $tokens): string
    {
        $value = strtolower($value);

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

        foreach ($tokens as $token) {
            $token = strtolower((string) $token);

            if ($token !== '' && strpos($value, $token) !== false) {
                return (string) $token;
            }
        }

        return '';
    }

    /**
     * @param array<int,string> $tokens
     */
    private function matchedHostnameToken(string $hostname, array $tokens): string
    {
        $hostname = self::sanitizeHostnameToken($hostname);
        if ($hostname === '') {
            return '';
        }

        foreach ($tokens as $token) {
            $token = self::sanitizeHostnameToken((string) $token);
            if ($token === '') {
                continue;
            }

            if ($hostname === $token || substr($hostname, -strlen('.' . $token)) === '.' . $token) {
                return $token;
            }
        }

        return '';
    }

    private function isReputationAllowlisted(string $ip, string $hostname, string $userAgent, string $referrer): bool
    {
        $allowlist = (array) $this->settings['reputation_allowlist'];
        if ($ip !== '' && $ip !== 'unknown' && $this->matchesIpList($ip, $allowlist)) {
            return true;
        }

        if ($this->matchedHostnameToken($hostname, $allowlist) !== '') {
            return true;
        }

        return $this->matchedTextToken($userAgent, $allowlist) !== ''
            || $this->matchedTextToken($referrer, $allowlist) !== '';
    }

    /**
     * @param array<string,mixed> $request
     */
    private function hostnameFromRequest(array $request, string $ip): string
    {
        foreach (['hostname', 'remote_host'] as $field) {
            if (isset($request[$field]) && is_scalar($request[$field])) {
                $hostname = self::sanitizeHostnameToken((string) $request[$field]);
                if ($hostname !== '') {
                    return $hostname;
                }
            }
        }

        if (empty($this->settings['hostname_resolution_enabled']) || !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
            return '';
        }

        return self::reverseHostnameForIp($ip);
    }

    private static function reverseHostnameForIp(string $ip): string
    {
        $cacheKey = 'shieldx_host_' . hash('sha256', $ip);
        if (function_exists('get_transient')) {
            $cached = get_transient($cacheKey);
            if (is_string($cached)) {
                return $cached;
            }
        }

        $hostname = function_exists('gethostbyaddr') ? (string) gethostbyaddr($ip) : '';
        $hostname = $hostname !== $ip ? self::sanitizeHostnameToken($hostname) : '';

        if (function_exists('set_transient')) {
            $ttl = defined('HOUR_IN_SECONDS') ? 12 * HOUR_IN_SECONDS : 43200;
            set_transient($cacheKey, $hostname, $ttl);
        }

        return $hostname;
    }

    private function temporaryBlockMatches(string $type, string $value, string $ip, string $country, string $userAgent, string $referrer): bool
    {
        if ($type === 'ip') {
            return $ip !== '' && $ip !== 'unknown' && ($ip === $value || (strpos($value, '/') !== false && $this->ipInCidr($ip, $value)));
        }

        if ($type === 'country') {
            return $country !== '' && $country === $value;
        }

        if ($type === 'user_agent') {
            return $this->matchedTextToken($userAgent, [$value]) !== '';
        }

        if ($type === 'referrer') {
            return $this->matchedTextToken($referrer, [$value]) !== '';
        }

        return false;
    }

    /**
     * @return array<string,mixed>
     */
    private function blockDecision(string $reason, string $matchedValue, string $ip, string $country): array
    {
        return [
            'action' => 'block',
            'reason' => $reason,
            'matched_value' => $matchedValue,
            'ip' => $ip,
            'country' => $country,
        ];
    }

    private static function isUnsafeWhitelistPath(string $path): bool
    {
        if (strpos($path, '../') !== false || strpos($path, '..%2f') !== false || strpos($path, '%2e%2e') !== false) {
            return true;
        }

        $sensitiveMarkers = [
            'wp-config.php',
            '/.env',
            '.sql',
            '.sql.gz',
            '.bak',
            '.save',
            '.old',
            'debug.log',
        ];

        foreach ($sensitiveMarkers as $marker) {
            if (strpos($path, $marker) !== false) {
                return true;
            }
        }

        return substr($path, -1) === '~';
    }

    private static function wordPressBasePath(): string
    {
        if (!function_exists('home_url')) {
            return '';
        }

        $parts = function_exists('wp_parse_url') ? wp_parse_url((string) home_url('/')) : [];
        $path = is_array($parts) && isset($parts['path']) ? $parts['path'] : '';
        if (!is_string($path) || $path === '' || $path === '/') {
            return '';
        }

        return '/' . trim(strtolower(str_replace('\\', '/', $path)), '/');
    }
}
