<?php

declare(strict_types=1);

namespace SiteFort\Modules\Firewall;

final class RuleEngine {
	public const PROFILE_SEARCH_FRIENDLY = 'search_friendly';
	public const PROFILE_BALANCED = 'balanced';
	public const PROFILE_STRICT = 'strict';

	public const CLASS_NONE = 'none';
	public const CLASS_TRUSTED_OVERRIDE = 'trusted_override';
	public const CLASS_MALICIOUS_SCANNER = 'malicious_scanner';
	public const CLASS_SEO_CRAWLER = 'seo_crawler';
	public const CLASS_POPULAR_AI_CRAWLER = 'popular_ai_crawler';
	public const CLASS_AI_TRAINING_CRAWLER = 'ai_training_crawler';
	public const CLASS_AI_CRAWLER = 'ai_crawler';
	public const CLASS_SEARCH_ENGINE = 'search_engine';
	public const CLASS_USER_TRIGGERED_FETCHER = 'user_triggered_fetcher';
	public const CLASS_SERVICE_CRAWLER = 'service_crawler';
	public const CLASS_SCRIPTED_HTTP_CLIENT = 'scripted_http_client';
	public const CLASS_UNKNOWN_AUTOMATION = 'unknown_automation';
	public const CLASS_MISSING_UA = 'missing_ua';

	private const NON_HTTP_UA_TOKENS = [
		'google-extended',
		'applebot-extended',
	];

	// Shape-based blocks waived on API endpoints, where scripted/UA-less callers
	// are normal. Malicious and scraper classes stay enforced everywhere.
	private const ENDPOINT_SCOPED_BLOCK_SOURCES = [
		'scripted_http_client_block',
		'missing_user_agent_block',
		'unknown_automation_block',
	];

	// search_friendly: malicious only. balanced: + secondary AI + scripted clients. strict: + unknown automation.
	
	// Allowed by default search engine.
	private const SEARCH_ENGINE_PATTERNS = [
		'googlebot', 'googlebot-image', 'googlebot-news', 'googlebot-video',
		'bingbot', 'bingpreview', 'adidxbot',
		'baiduspider', 'yandexbot', 'yandex.com/bots',
		'duckduckbot', 'sogou', 'sogoupy',
		'exabot', 'petalbot', 'coccocbot', 'naverbot',
		'seznam', 'qwantify', 'swiftbot', 'mojeekbot',
		'adsbot-google', 'mediapartners-google',
	];

	// Allowed by default - SEO service related.
	private const SEO_PATTERNS = [
		'ahrefsbot', 'ahrefssiteaudit',
		'semrushbot', 'semrushbot-ba', 'semrushbot-si', 'semrushbot-swa',
		'semrushbot-ocob', 'semrushbot-ft', 'siteauditbot', 'splitsignalbot',
		'mj12bot', 'dotbot', 'rogerbot', 'majestic-12',
		'seokicks', 'sitebulb', 'screaming frog', 'seospider',
		'woorank', 'woorankreview', 'serpstatbot', 'cognitiveseo',
		'megaindex',
		'brightedgebot', 'deepcrawl', 'oncrawl', 'botify', 'lumar',
		'seranking', 'siteimprove',
	];

	// Allowed by default - AI search/index crawlers that drive referral traffic.
	private const POPULAR_AI_PATTERNS = [
		'oai-searchbot',
		'claude-searchbot',
		'perplexitybot',
	];

	// AI training crawlers - allowed by default, blocked when block_ai_training_crawlers is on.
	private const AI_TRAINING_CRAWLER_PATTERNS = [
		'gptbot',
		'claudebot', 'anthropic-ai',
		'meta-externalagent',
		'facebookbot',
		'grokbot', 'xai-grok',
		'amazonbot',
		'cohere-ai', 'cohere-training-data-crawler',
		'pangubot',
	];

	// Blocked in balanced and strict.
	private const AI_PATTERNS = [
		'ccbot',
		'bytespider',
		'diffbot',
		'youbot',
		'imagesiftbot',
		'omgili', 'webz.io', 'webzio',
		'perceptiv-ai', 'meltwater', 'brandwatch',
		'deepseekbot', 'timpibot', 'kangaroo.io', 'friendlycrawler',
		'iaskspider', 'ai2bot', 'turnitin',
	];



	// Allowed by default.
	private const USER_TRIGGERED_FETCHER_PATTERNS = [
		'chatgpt-user',
		'claude-user',
		'perplexity-user',
		'mistralai-user',
		'manus-user',
		'duckassistbot',
	];

	// Allowed by default.
	private const SERVICE_PATTERNS = [
		'archive.org', 'ia_archiver',
		'bitlybot', 'linkcheck', 'linkscan', 'linkdexbot',
		'slackbot', 'discordbot',
		'facebookexternalhit', 'facebot', 'meta-externalfetcher',
		'linkedinbot',
		'twitterbot', 'x-bot',
		'pinterestbot',
		'whatsapp',
		'telegrambot',
		'threadsbot',
		'snapchat',
		'vkshare',
		'redditbot',
		'tumblrbot',
		'line-poker',
		'applebot',
		'googleother', 'googleother-image', 'googleother-video',
		'google-agent', 'google-read-aloud', 'google-notebooklm',
		'google-cloudvertexbot', 'google-safety', 'google-inspectiontool',
		'uptimerobot', 'statuscake', 'pingdom',
		'better uptime', 'newrelicpinger', 'gtmetrix',
		'hyperping', 'updown.io', 'freshpingbot', 'site24x7',
		'hetrixtools', 'monitis', 'panopta',
		'lighthouse', 'chrome-lighthouse',
		'mastodon', 'bluesky', 'misskey', 'lemmy', 'pleroma',
		'skype-http-reader',
		'iframely', 'embedly', 'cloudflare-autorag',
		'dataminr',
		'feedfetcher-google', 'feedly', 'inoreader', 'newsblur',
		'feedbin', 'the old reader',
		'censys',
	];

	// Blocked in balanced and strict, page requests only (see
	// ENDPOINT_SCOPED_BLOCK_SOURCES). No okhttp/CFNetwork: those are mobile apps.
	private const SCRIPTED_HTTP_CLIENT_PATTERNS = [
		'curl', 'wget', 'httpie',
		'python-requests', 'python-urllib', 'python-httpx', 'python/',
		'aiohttp', 'httpx', 'urllib3',
		'go-http-client',
		'java/', 'apache-httpclient', 'jersey', 'javaclient',
		'libwww-perl', 'lwp-trivial',
		'axios', 'node-fetch', 'node-http', 'undici', 'got/',
		'superagent', 'isomorphic-fetch',
		'guzzlehttp', 'symfony-http-client',
		'faraday', 'rest-client', 'typhoeus', 'httparty',
		'reqwest',
		'httpclient', 'system.net.webclient', 'microsoft url control',
		'scrapy',
		'libcurl', 'winhttp',
	];

	// Blocked only in strict.
	private const AUTOMATION_KEYWORDS = [
		'bot', 'crawler', 'spider', 'slurp', 'scraper',
		'headless', 'headlesschrome',
		'phantomjs', 'slimerjs', 'casperjs',
		'selenium', 'webdriver',
		'playwright', 'puppeteer',
		'cypress', 'nightwatch', 'testcafe', 'zombie', 'mechanize',
		'htmlunit', 'triflejs', 'jsdom',
	];

	// Blocked in every profile. '#...#' entries are word-bounded regexes.
	private const MALICIOUS_SIGNATURES = [
		'acunetix', 'nikto', 'sqlmap', 'wpscan',
		'nessus', 'openvas', 'qualys',
		'webinspect', 'appscan', 'netsparker',
		'#\bvega/\d#', 'wapiti', 'arachni', 'skipfish', 'w3af',
		'detectify', 'probely', 'rapidapi-scanner',
		'nmap scripting engine', 'masscan', 'zgrab', 'rustscan', 'zmap',
		'gobuster', 'feroxbuster', 'dirbuster', '#\bdirb\b#', 'ffuf', 'wfuzz',
		'dirsearch', '#\bkatana\b#', 'httpx-scanner',
		'whatweb', 'httprint', 'wappalyzer-scanner',
		'metasploit', 'burpsuite', 'burp suite', 'owasp zap', 'zaproxy',
		'havij', 'commix', 'fimap', 'xsser',
		'zmeu', 'censysinspect', 'shodan',
		'project-discovery', 'projectdiscovery',
		'bitsight', 'securitytrails-scanner',
		'#\bvuln#', '#\bexploit\b#', '#\bpwn\b#', '#\bhack\b#',
		'jaascois', '#\bgrabber\b#', 'indy library', 'python-nmap', 'masscan-ng',
		'#\bnuclei\b#', 'interactsh', 'pdtm', 'gospider', 'hakrawler',
		'#\bgau\b#', 'waybackurls', 'subfinder', '#\bamass\b#', 'altdns',
		'linkfinder', 'getallurls',
	];

	public static function sanitize_profile( string $profile ): string {
		$profile = strtolower( trim( $profile ) );
		return in_array( $profile, self::get_valid_profiles(), true ) ? $profile : self::PROFILE_BALANCED;
	}

	/**
	 * @return array<int, string>
	 */
	public static function get_valid_profiles(): array {
		return [
			self::PROFILE_SEARCH_FRIENDLY,
			self::PROFILE_BALANCED,
			self::PROFILE_STRICT,
		];
	}

	/**
	 * @param string|array<int, string> $input
	 * @return array<int, string>
	 */
	public static function normalize_ip_entries( $input, bool $allow_wildcards = true ): array {
		$entries = [];
		foreach ( self::split_list( $input ) as $entry ) {
			$entry = trim( $entry );
			if ( $entry === '' ) {
				continue;
			}

			if ( strpos( $entry, '/' ) !== false ) {
				[ $subnet, $bits ] = explode( '/', $entry, 2 );
				$subnet = self::normalize_ip( trim( $subnet ) );
				$bits = trim( $bits );
				if ( ! filter_var( $subnet, FILTER_VALIDATE_IP ) || ! ctype_digit( $bits ) ) {
					continue;
				}

				$bits_int = (int) $bits;
				$max_bits = filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ? 32 : 128;
				if ( $bits_int < 0 || $bits_int > $max_bits ) {
					continue;
				}

				$packed = @inet_pton( $subnet );
				if ( $packed === false ) {
					continue;
				}

				$normalized_subnet = @inet_ntop( $packed );
				if ( $normalized_subnet === false ) {
					continue;
				}

				$entries[ $normalized_subnet . '/' . $bits_int ] = true;
				continue;
			}

			if ( $allow_wildcards && strpos( $entry, '*' ) !== false ) {
				if ( self::is_valid_ipv4_wildcard_pattern( $entry ) ) {
					$entries[ $entry ] = true;
				}
				continue;
			}

			$ip = self::normalize_ip( $entry );
			if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
				$packed = @inet_pton( $ip );
				if ( $packed === false ) {
					continue;
				}

				$normalized_ip = @inet_ntop( $packed );
				if ( $normalized_ip === false ) {
					continue;
				}

				$entries[ $normalized_ip ] = true;
			}
		}

		return array_keys( $entries );
	}

	/**
	 * @param string|array<int, string> $input
	 * @return array<int, string>
	 */
	public static function normalize_user_agent_patterns( $input, int $min_length = 3 ): array {
		$patterns = [];
		foreach ( self::split_list( $input ) as $pattern ) {
			$pattern = strtolower( trim( preg_replace( '/\s+/', ' ', $pattern ) ?? '' ) );
			if ( $pattern === '' || strlen( $pattern ) < $min_length ) {
				continue;
			}

			$patterns[ $pattern ] = true;
		}

		return array_keys( $patterns );
	}

	/**
	 * @param string|array<int, string> $input
	 * @return array<int, string>
	 */
	public static function normalize_country_codes( $input ): array {
		$codes = [];
		foreach ( self::split_list( $input, true ) as $code ) {
			$code = strtoupper( trim( $code ) );
			if ( preg_match( '/^[A-Z]{2}$/', $code ) !== 1 ) {
				continue;
			}

			$codes[ $code ] = true;
		}

		return array_keys( $codes );
	}

	public static function classify_user_agent( string $ua, array $policy = [] ): string {
		$ua = strtolower( trim( $ua ) );
		if ( $ua === '' ) {
			return self::CLASS_MISSING_UA;
		}

		if ( in_array( $ua, self::NON_HTTP_UA_TOKENS, true ) ) {
			return self::CLASS_NONE;
		}

		if ( self::find_user_agent_pattern_match( $ua, (array) ( $policy['trusted_user_agents'] ?? [] ) ) !== null ) {
			return self::CLASS_TRUSTED_OVERRIDE;
		}

		foreach ( self::get_user_agent_matchers() as $class => $patterns ) {
			if ( self::contains_any( $ua, $patterns ) ) {
				return $class;
			}
		}

		return self::CLASS_NONE;
	}

	public static function get_bot_policy_source( array $policy, string $bot_class ): ?string {
		if ( empty( $policy['bot_policy_enabled'] ) ) {
			return null;
		}

		$profile = self::sanitize_profile( (string) ( $policy['bot_policy_profile'] ?? self::PROFILE_BALANCED ) );
		$block_map = self::get_profile_block_map( $profile );
		if ( isset( $block_map[ $bot_class ] ) ) {
			return $block_map[ $bot_class ];
		}

		// Toggle also covers the data-scraping AI class (CCBot, Bytespider), so Basic catches them too.
		if (
			! empty( $policy['block_ai_training_crawlers'] )
			&& ( $bot_class === self::CLASS_AI_TRAINING_CRAWLER || $bot_class === self::CLASS_AI_CRAWLER )
		) {
			return 'ai_training_crawler_block';
		}

		return null;
	}

	// Whether to forward-confirm a Google/Bing UA claim. Verifying only grants the bypass; failing never blocks.
	public static function should_verify_search_engine_claim( bool $bot_policy_enabled, string $profile ): bool {
		return $bot_policy_enabled && self::sanitize_profile( $profile ) !== self::PROFILE_SEARCH_FRIENDLY;
	}

	// Strict is the only profile that blocks a proven spoof; indeterminate never counts as proof.
	public static function should_block_spoofed_search_engine( bool $bot_policy_enabled, string $profile ): bool {
		return $bot_policy_enabled && self::sanitize_profile( $profile ) === self::PROFILE_STRICT;
	}

	// Deprecated no-op. Old deployed WAF bootstraps still call this name; keep it until they age out.
	// TODO: remove in a future major version.
	public static function blocks_spoofed_search_engine( bool $bot_policy_enabled, string $profile ): bool {
		return false;
	}

	/**
	 * @param array<string, mixed> $policy
	 * @param array<string, mixed> $request
	 * @return array<string, mixed>
	 */
	public static function evaluate( array $policy, array $request ): array {
		$ip = self::normalize_ip( (string) ( $request['ip'] ?? '' ) );
		$ua = (string) ( $request['user_agent'] ?? '' );
		$country_code = strtoupper( trim( (string) ( $request['country_code'] ?? '' ) ) );
		$country_lookup_performed = ! empty( $request['country_lookup_performed'] );
		$bot_class = self::classify_user_agent( $ua, $policy );

		if ( $ip !== '' ) {
			$matched_ip = self::find_ip_match( $ip, (array) ( $policy['allowlisted_ips'] ?? [] ) );
			if ( $matched_ip !== null ) {
				return self::decision( 'allow_bypass', 'allowlisted_ip', 10, $bot_class, null, $matched_ip );
			}
		}

		$matched_ua = self::find_user_agent_pattern_match( $ua, (array) ( $policy['trusted_user_agents'] ?? [] ) );
		if ( $matched_ua !== null ) {
			return self::decision( 'allow_bypass', 'trusted_user_agent', 11, $bot_class, null, $matched_ua );
		}

		if ( $ip !== '' ) {
			$matched_ip = self::find_ip_match( $ip, (array) ( $policy['blocked_ips'] ?? [] ) );
			if ( $matched_ip !== null ) {
				return self::decision( 'block', 'blocked_ip', 20, $bot_class, 'IP Ban', $matched_ip );
			}
		}

		$matched_ua = self::find_user_agent_pattern_match( $ua, (array) ( $policy['blocked_user_agents'] ?? [] ) );
		if ( $matched_ua !== null ) {
			return self::decision( 'block', 'blocked_user_agent', 21, $bot_class, 'UA Ban', $matched_ua );
		}

		if ( ! empty( $request['sensitive_path_match'] ) ) {
			return self::decision( 'block', 'sensitive_path', 22, $bot_class, 'Sensitive Path' );
		}

		if ( $ip !== '' ) {
			$matched_safe_service = self::find_ip_match( $ip, (array) ( $policy['safe_service_ranges'] ?? [] ) );
			if ( $matched_safe_service !== null ) {
				return self::decision( 'allow_safe_service', 'safe_service_range', 23, $bot_class, null, $matched_safe_service );
			}
		}

		if ( ! empty( $request['verified_search_exception'] ) ) {
			$matched_engine = strtolower( trim( (string) ( $request['verified_search_engine'] ?? '' ) ) );
			return self::decision( 'allow_bypass', 'verified_search_engine', 24, $bot_class, null, $matched_engine !== '' ? $matched_engine : null );
		}

		$country_decision = self::evaluate_country_block( $policy, $country_code, $country_lookup_performed );
		if ( $country_decision !== null ) {
			return self::decision(
				'block',
				'blocked_country',
				25,
				$bot_class,
				'Country Block: ' . $country_decision,
				$country_decision
			);
		}

		if ( ! empty( $request['community_blocked'] ) ) {
			return self::decision( 'block', 'community_blocklist', 30, $bot_class, 'Community Blocklist' );
		}

		$bot_policy_source = self::get_bot_policy_source( $policy, $bot_class );
		if ( $bot_policy_source !== null && ! self::block_source_waived_for_endpoint( $bot_policy_source, $request ) ) {
			return self::decision( 'block', $bot_policy_source, 31, $bot_class, 'Bot Ban', $bot_class );
		}

		return self::decision( 'allow', 'none', 90, $bot_class );
	}

	/**
	 * @param array<string, mixed> $request
	 */
	private static function block_source_waived_for_endpoint( string $source, array $request ): bool {
		return ! empty( $request['is_api_request'] )
			&& in_array( $source, self::ENDPOINT_SCOPED_BLOCK_SOURCES, true );
	}

	// No WordPress calls, so the WAF bootstrap and the in-WP runtime agree.
	public static function is_api_request_path( string $path, string $query = '', string $rest_prefix = 'wp-json' ): bool {
		// Decode once so an encoded separator can't disguise the path.
		$path = strtolower( str_replace( '\\', '/', trim( rawurldecode( trim( $path ) ) ) ) );
		$path = preg_replace( '#/+#', '/', $path ) ?? $path;
		if ( $path === '' ) {
			return false;
		}

		// A dot segment means the served path differs from the matched one.
		foreach ( explode( '/', $path ) as $segment ) {
			if ( $segment === '..' || $segment === '.' ) {
				return false;
			}
		}

		$rest_prefix = trim( strtolower( trim( $rest_prefix ) ), '/' );
		if ( $rest_prefix === '' ) {
			$rest_prefix = 'wp-json';
		}

		// REST: /wp-json/... anywhere in the path, or the ?rest_route= form.
		if ( str_contains( $path, '/' . $rest_prefix . '/' ) || str_ends_with( $path, '/' . $rest_prefix ) ) {
			return true;
		}

		if ( $query !== '' ) {
			$params = [];
			parse_str( ltrim( trim( $query ), '?' ), $params );
			if ( array_key_exists( 'rest_route', $params ) ) {
				return true;
			}
		}

		// Endpoints WordPress itself designates for machine callers.
		$basename = basename( $path );

		return in_array( $basename, [ 'xmlrpc.php', 'admin-ajax.php', 'admin-post.php', 'wp-cron.php' ], true );
	}

	public static function normalize_ip( string $ip ): string {
		return \SiteFort\Modules\Firewall\SearchTrust\SearchTrustCore::normalize_ip( $ip );
	}

	/**
	 * @param array<int, string> $entries
	 */
	public static function find_ip_match( string $ip, array $entries ): ?string {
		$ip = self::normalize_ip( $ip );
		if ( $ip === '' ) {
			return null;
		}

		foreach ( $entries as $entry ) {
			if ( self::ip_matches( $ip, (string) $entry ) ) {
				return (string) $entry;
			}
		}

		return null;
	}

	/**
	 * @param array<int, string> $patterns
	 */
	public static function find_user_agent_pattern_match( string $ua, array $patterns ): ?string {
		$ua = strtolower( trim( $ua ) );
		if ( $ua === '' ) {
			return null;
		}

		foreach ( $patterns as $pattern ) {
			$pattern = strtolower( trim( (string) $pattern ) );
			if ( $pattern !== '' && str_contains( $ua, $pattern ) ) {
				return $pattern;
			}
		}

		return null;
	}

	public static function ip_matches( string $ip, string $entry ): bool {
		$ip = self::normalize_ip( $ip );
		$entry = trim( $entry );
		if ( $ip === '' || $entry === '' ) {
			return false;
		}

		$entry = self::normalize_ip( $entry );
		if ( $ip === $entry ) {
			return true;
		}

		if ( strpos( $entry, '/' ) !== false ) {
			return self::ip_in_cidr( $ip, $entry );
		}

		if ( strpos( $entry, '*' ) !== false ) {
			if ( ! self::is_valid_ipv4_wildcard_pattern( $entry ) ) {
				return false;
			}

			$segments = explode( '.', $entry );
			$regex_parts = array_map(
				static function ( string $segment ): string {
					return $segment === '*' ? '\d{1,3}' : preg_quote( $segment, '/' );
				},
				$segments
			);

			return preg_match( '/^' . implode( '\.', $regex_parts ) . '$/', $ip ) === 1;
		}

		$a = @inet_pton( $ip );
		$b = @inet_pton( $entry );
		return $a !== false && $b !== false && $a === $b;
	}

	public static function ip_entries_overlap( string $left, string $right ): bool {
		$left_range  = self::ip_entry_to_range( $left );
		$right_range = self::ip_entry_to_range( $right );
		if ( $left_range === null || $right_range === null ) {
			return false;
		}

		if ( $left_range['family'] !== $right_range['family'] ) {
			return false;
		}

		return strcmp( $left_range['start'], $right_range['end'] ) <= 0
			&& strcmp( $right_range['start'], $left_range['end'] ) <= 0;
	}

	public static function user_agent_patterns_overlap( string $left, string $right ): bool {
		$left  = strtolower( trim( preg_replace( '/\s+/', ' ', $left ) ?? '' ) );
		$right = strtolower( trim( preg_replace( '/\s+/', ' ', $right ) ?? '' ) );
		if ( $left === '' || $right === '' ) {
			return false;
		}

		return str_contains( $left, $right ) || str_contains( $right, $left );
	}

	private static function evaluate_country_block( array $policy, string $country_code, bool $country_lookup_performed = true ): ?string {
		$mode = (string) ( $policy['country_mode'] ?? 'off' );
		$countries = (array) ( $policy['country_codes'] ?? [] );
		if ( $mode === 'off' || empty( $countries ) ) {
			return null;
		}

		if ( $country_code === '' ) {
			// Unknown-country allow-list blocking only applies when the caller
			// actually performed a geo lookup. Bootstrap passes false when it
			// does not have a trusted country source and runtime must fall back.
			if ( $mode === 'allow' && $country_lookup_performed ) {
				return 'Unknown Country';
			}

			return null;
		}

		$in_list = in_array( $country_code, $countries, true );
		if ( $mode === 'block' && $in_list ) {
			return $country_code;
		}

		if ( $mode === 'allow' && ! $in_list ) {
			return $country_code;
		}

		return null;
	}

	/**
	 * @return array{family:int,start:string,end:string}|null
	 */
	private static function ip_entry_to_range( string $entry ): ?array {
		$entry = trim( $entry );
		if ( $entry === '' ) {
			return null;
		}

		if ( strpos( $entry, '*' ) !== false ) {
			if ( ! self::is_valid_ipv4_wildcard_pattern( $entry ) ) {
				return null;
			}

			$start_parts = [];
			$end_parts   = [];
			foreach ( explode( '.', $entry ) as $part ) {
				if ( $part === '*' ) {
					$start_parts[] = '0';
					$end_parts[]   = '255';
					continue;
				}

				$start_parts[] = $part;
				$end_parts[]   = $part;
			}

			$start = @inet_pton( implode( '.', $start_parts ) );
			$end   = @inet_pton( implode( '.', $end_parts ) );
			if ( $start === false || $end === false ) {
				return null;
			}

			return [
				'family' => 4,
				'start'  => $start,
				'end'    => $end,
			];
		}

		if ( strpos( $entry, '/' ) !== false ) {
			$parts = explode( '/', $entry, 2 );
			if ( count( $parts ) !== 2 ) {
				return null;
			}

			$subnet     = self::normalize_ip( trim( $parts[0] ) );
			$prefix_raw = trim( $parts[1] );
			if ( ! filter_var( $subnet, FILTER_VALIDATE_IP ) || ! ctype_digit( $prefix_raw ) ) {
				return null;
			}

			$packed = @inet_pton( $subnet );
			if ( $packed === false ) {
				return null;
			}

			$prefix_len = (int) $prefix_raw;
			$max_bits   = strlen( $packed ) * 8;
			if ( $prefix_len < 0 || $prefix_len > $max_bits ) {
				return null;
			}

			$mask = str_repeat( "\xff", (int) floor( $prefix_len / 8 ) );
			$remainder = $prefix_len % 8;
			if ( $remainder > 0 ) {
				$mask .= chr( 0xff << ( 8 - $remainder ) & 0xff );
			}
			$mask = str_pad( $mask, strlen( $packed ), "\x00" );
			$inverse_mask = ~$mask;

			return [
				'family' => strlen( $packed ),
				'start'  => $packed & $mask,
				'end'    => $packed | $inverse_mask,
			];
		}

		$ip = self::normalize_ip( $entry );
		if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
			return null;
		}

		$packed = @inet_pton( $ip );
		if ( $packed === false ) {
			return null;
		}

		return [
			'family' => strlen( $packed ),
			'start'  => $packed,
			'end'    => $packed,
		];
	}

	private static function decision(
		string $action,
		string $source,
		int $precedence,
		string $bot_class,
		?string $reason = null,
		?string $matched_rule = null
	): array {
		return [
			'action' => $action,
			'source' => $source,
			'precedence' => $precedence,
			'reason' => $reason,
			'matched_rule' => $matched_rule,
			'bot_class' => $bot_class,
			'bypass' => in_array( $action, [ 'allow_bypass', 'allow_safe_service' ], true ),
		];
	}

	private static function contains_any( string $ua, array $patterns ): bool {
		foreach ( $patterns as $pattern ) {
			if ( $pattern === '' ) {
				continue;
			}

			if ( $pattern[0] === '#' ) {
				if ( preg_match( $pattern, $ua ) === 1 ) {
					return true;
				}
				continue;
			}

			if ( str_contains( $ua, $pattern ) ) {
				return true;
			}
		}

		return false;
	}

	// Higher-risk classes are matched first.
	private static function get_user_agent_matchers(): array {
		return [
			self::CLASS_MALICIOUS_SCANNER => self::MALICIOUS_SIGNATURES,
			self::CLASS_POPULAR_AI_CRAWLER => self::POPULAR_AI_PATTERNS,
			self::CLASS_AI_TRAINING_CRAWLER => self::AI_TRAINING_CRAWLER_PATTERNS,
			self::CLASS_AI_CRAWLER => self::AI_PATTERNS,
			self::CLASS_SCRIPTED_HTTP_CLIENT => self::SCRIPTED_HTTP_CLIENT_PATTERNS,
			self::CLASS_SEARCH_ENGINE => self::SEARCH_ENGINE_PATTERNS,
			self::CLASS_USER_TRIGGERED_FETCHER => self::USER_TRIGGERED_FETCHER_PATTERNS,
			self::CLASS_SERVICE_CRAWLER => self::SERVICE_PATTERNS,
			self::CLASS_SEO_CRAWLER => self::SEO_PATTERNS,
			self::CLASS_UNKNOWN_AUTOMATION => self::AUTOMATION_KEYWORDS,
		];
	}

	private static function get_profile_block_map( string $profile ): array {
		switch ( $profile ) {
			case self::PROFILE_SEARCH_FRIENDLY:
				return [
					self::CLASS_MALICIOUS_SCANNER => 'malicious_bot_signature',
				];
			case self::PROFILE_STRICT:
				return [
					self::CLASS_MALICIOUS_SCANNER => 'malicious_bot_signature',
					self::CLASS_AI_CRAWLER => 'ai_crawler_block',
					self::CLASS_SCRIPTED_HTTP_CLIENT => 'scripted_http_client_block',
					self::CLASS_UNKNOWN_AUTOMATION => 'unknown_automation_block',
					self::CLASS_MISSING_UA => 'missing_user_agent_block',
				];
			default:
				return [
					self::CLASS_MALICIOUS_SCANNER => 'malicious_bot_signature',
					self::CLASS_AI_CRAWLER => 'ai_crawler_block',
					self::CLASS_SCRIPTED_HTTP_CLIENT => 'scripted_http_client_block',
					self::CLASS_MISSING_UA => 'missing_user_agent_block',
				];
		}
	}

	// Classes whose weak-404 noise shouldn't feed the scanner ban ladder.
	public static function is_recognized_crawler_class( string $bot_class ): bool {
		return in_array(
			$bot_class,
			[
				self::CLASS_SEARCH_ENGINE,
				self::CLASS_SEO_CRAWLER,
				self::CLASS_SERVICE_CRAWLER,
				self::CLASS_POPULAR_AI_CRAWLER,
				self::CLASS_USER_TRIGGERED_FETCHER,
				self::CLASS_AI_TRAINING_CRAWLER,
				self::CLASS_AI_CRAWLER,
			],
			true
		);
	}

	// Per-profile category flags for the admin card, derived from the block map so it can't drift from enforcement.
	public static function get_bot_policy_matrix(): array {
		$category_classes = [
			'hacking_tools'      => self::CLASS_MALICIOUS_SCANNER,
			'data_scraping_bots' => self::CLASS_AI_CRAWLER,
			'automated_scripts'  => self::CLASS_SCRIPTED_HTTP_CLIENT,
			'unrecognized_bots'  => self::CLASS_UNKNOWN_AUTOMATION,
			'missing_user_agent' => self::CLASS_MISSING_UA,
		];

		$matrix = [];
		foreach ( [ self::PROFILE_SEARCH_FRIENDLY, self::PROFILE_BALANCED, self::PROFILE_STRICT ] as $profile ) {
			$block_map = self::get_profile_block_map( $profile );
			$flags = [];
			foreach ( $category_classes as $category => $class ) {
				$flags[ $category ] = isset( $block_map[ $class ] );
			}
			$matrix[ $profile ] = $flags;
		}

		return $matrix;
	}

	private static function ip_in_cidr( string $ip, string $cidr ): bool {
		$parts = explode( '/', $cidr, 2 );
		if ( count( $parts ) !== 2 ) {
			return false;
		}

		$subnet = self::normalize_ip( $parts[0] );
		$prefix_len = (int) $parts[1];
		$ip_bin = @inet_pton( $ip );
		$subnet_bin = @inet_pton( $subnet );

		if ( $ip_bin === false || $subnet_bin === false || strlen( $ip_bin ) !== strlen( $subnet_bin ) ) {
			return false;
		}

		$max_bits = strlen( $ip_bin ) * 8;
		if ( $prefix_len < 0 || $prefix_len > $max_bits ) {
			return false;
		}

		$mask = str_repeat( "\xff", (int) floor( $prefix_len / 8 ) );
		$remainder = $prefix_len % 8;
		if ( $remainder > 0 ) {
			$mask .= chr( 0xff << ( 8 - $remainder ) & 0xff );
		}
		$mask = str_pad( $mask, strlen( $ip_bin ), "\x00" );

		return ( $ip_bin & $mask ) === ( $subnet_bin & $mask );
	}

	private static function is_valid_ipv4_wildcard_pattern( string $value ): bool {
		$parts = explode( '.', $value );
		if ( count( $parts ) !== 4 ) {
			return false;
		}

		$seen_wildcard = false;
		foreach ( $parts as $part ) {
			if ( $part === '*' ) {
				$seen_wildcard = true;
				continue;
			}

			if ( $seen_wildcard || ! ctype_digit( $part ) ) {
				return false;
			}

			$octet = (int) $part;
			if ( $octet < 0 || $octet > 255 ) {
				return false;
			}
		}

		return $seen_wildcard;
	}

	/**
	 * @param string|array<int, string> $input
	 * @return array<int, string>
	 */
	private static function split_list( $input, bool $split_whitespace = false ): array {
		if ( is_array( $input ) ) {
			return array_values(
				array_filter(
					array_map(
						static fn( $item ): string => trim( (string) $item ),
						$input
					),
					static fn( string $item ): bool => $item !== ''
				)
			);
		}

		$pattern = $split_whitespace ? '/[\s,]+/' : '/[\r\n,]+/';
		$parts = preg_split( $pattern, (string) $input, -1, PREG_SPLIT_NO_EMPTY );
		if ( ! is_array( $parts ) ) {
			return [];
		}

		return array_values(
			array_filter(
				array_map(
					static fn( string $item ): string => trim( $item ),
					$parts
				),
				static fn( string $item ): bool => $item !== ''
			)
		);
	}
}
