<?php

declare(strict_types=1);

namespace SiteFort\Modules\Firewall\SafeNetwork;

use SiteFort\Includes\GuardedDataFile;
use SiteFort\Includes\IpUtils;
use SiteFort\Modules\Firewall\ExtendedProtection\Manager;
use SiteFort\Modules\Firewall\RuleEngine;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

final class SafeNetworkRanges {
	public const CRON_HOOK                  = 'sitefort_safe_network_ranges_update';
	public const PRE_SAFE_NETWORK_CRON_HOOK = 'sitefort_verified_bot_ranges_update';

	public const SET_GOOGLEBOT   = 'googlebot';
	public const SET_BINGBOT     = 'bingbot';
	public const SET_CLOUDFLARE  = 'cloudflare';
	public const SET_CUSTOM_SAFE = 'custom.safe';

	private const STORAGE_SUBDIR                  = 'safe-network-ranges';
	private const STORAGE_FILE                    = 'ranges.json.php';
	private const STATUS_OPTION                   = 'sitefort_safe_network_ranges_status';
	private const PRE_SAFE_NETWORK_STORAGE_SUBDIR = 'verified-bot-ranges';

	private const SCHEMA_VERSION = 1;
	private const DEFAULT_TTL    = 86400;
	private const MIN_TTL        = 21600;
	private const MAX_TTL        = 259200;

	// Forces a full refetch when stored ranges were normalized by older rules.
	private const NORMALIZATION_VERSION = 2;
	private const TTL_REFRESH_MARGIN    = 3600;
	private const MAX_FUTURE_SKEW       = 86400;

	private const MAX_TOTAL_RANGES        = 20000;
	private const MAX_PROVIDER_SET_RANGES = 10000;
	private const MAX_CUSTOM_SAFE_RANGES  = 1000;
	private const MAX_CIDR_LENGTH         = 64;

	private static ?array $resolved_cache = null;
	private static $stored_payload_cache = false;
	private static $bundled_data_cache = false;

	// Keep in sync with PROVIDER_MIN_PREFIX in IntelDB.
	private const PROVIDER_MIN_PREFIX = [
		self::SET_GOOGLEBOT  => [ 4 => 22, 6 => 48 ],
		self::SET_BINGBOT    => [ 4 => 20, 6 => 48 ],
		self::SET_CLOUDFLARE => [ 4 => 13, 6 => 29 ],
	];

	private const PROVIDER_SEED_RANGES = [
		self::SET_GOOGLEBOT => [
			'66.249.64.0/27',
			'192.178.4.0/27',
			'2001:4860:4801:10::/64',
		],
		self::SET_BINGBOT => [
			'157.55.39.0/24',
			'207.46.13.0/24',
			'40.77.167.0/24',
			'13.66.139.0/24',
			'199.30.24.0/23',
			'204.79.197.200/29',
		],
		self::SET_CLOUDFLARE => [
			'173.245.48.0/20',
			'103.21.244.0/22',
			'103.22.200.0/22',
			'103.31.4.0/22',
			'141.101.64.0/18',
			'108.162.192.0/18',
			'190.93.240.0/20',
			'188.114.96.0/20',
			'197.234.240.0/22',
			'198.41.128.0/17',
			'162.158.0.0/15',
			'104.16.0.0/13',
			'104.24.0.0/14',
			'172.64.0.0/13',
			'131.0.72.0/22',
			'2400:cb00::/32',
			'2606:4700::/32',
			'2803:f800::/32',
			'2405:b500::/32',
			'2405:8100::/32',
			'2a06:98c0::/29',
			'2c0f:f248::/32',
		],
		self::SET_CUSTOM_SAFE => [],
	];

	public static function known_set_ids(): array {
		return [
			self::SET_GOOGLEBOT,
			self::SET_BINGBOT,
			self::SET_CLOUDFLARE,
			self::SET_CUSTOM_SAFE,
		];
	}

	private static function provider_set_ids(): array {
		return [
			self::SET_GOOGLEBOT,
			self::SET_BINGBOT,
			self::SET_CLOUDFLARE,
		];
	}

	public static function needs_update(): bool {
		$payload = self::read_stored_payload();
		if ( ! is_array( $payload ) ) {
			return true;
		}

		if ( (int) ( $payload['normalization_version'] ?? 0 ) !== self::NORMALIZATION_VERSION ) {
			return true;
		}

		if ( self::normalize_feed_generated_at( $payload['generated_at'] ?? null ) <= 0 ) {
			return true;
		}

		$sets = isset( $payload['sets'] ) && is_array( $payload['sets'] ) ? self::filter_stored_range_map( $payload['sets'] ) : [];
		if ( ! self::set_map_has_all_provider_ranges( $sets ) ) {
			return true;
		}

		$updated_at = isset( $payload['updated_at'] ) ? (int) $payload['updated_at'] : 0;
		if ( $updated_at <= 0 ) {
			return true;
		}

		$ttl = self::normalize_ttl( $payload['ttl'] ?? self::DEFAULT_TTL );
		return ( time() - $updated_at ) > ( $ttl - self::TTL_REFRESH_MARGIN );
	}

	public static function get_stored_etag(): string {
		$payload = self::read_stored_payload();
		if ( ! is_array( $payload ) ) {
			return '';
		}

		if ( (int) ( $payload['normalization_version'] ?? 0 ) !== self::NORMALIZATION_VERSION ) {
			return '';
		}

		if ( self::normalize_feed_generated_at( $payload['generated_at'] ?? null ) <= 0 ) {
			return '';
		}

		$etag = is_string( $payload['etag'] ?? null ) ? trim( $payload['etag'] ) : '';

		return strlen( $etag ) <= 200 ? $etag : '';
	}

	public static function get_rollback_floor(): int {
		$payload   = self::read_stored_payload();
		$stored_at = is_array( $payload ) ? self::normalize_feed_generated_at( $payload['generated_at'] ?? null ) : 0;

		$bundled    = self::bundled_data();
		$bundled_at = is_array( $bundled ) ? self::normalize_feed_generated_at( $bundled['generated_at'] ?? null ) : 0;

		return max( $stored_at, $bundled_at, 0 );
	}

	public static function normalize_feed_generated_at( $generated_at ): int {
		if ( ! is_numeric( $generated_at ) ) {
			return 0;
		}

		$generated_at = (int) $generated_at;
		if ( $generated_at <= 0 || $generated_at > time() + self::MAX_FUTURE_SKEW ) {
			return 0;
		}

		return $generated_at;
	}

	public static function mark_checked(): bool {
		$payload = self::read_stored_payload();
		if ( ! is_array( $payload ) || ! isset( $payload['sets'] ) || ! is_array( $payload['sets'] ) ) {
			return false;
		}

		if ( self::normalize_feed_generated_at( $payload['generated_at'] ?? null ) <= 0 ) {
			return false;
		}

		$sets = self::sanitize_range_map( $payload['sets'] );
		if ( ! self::set_map_has_all_provider_ranges( $sets ) ) {
			return false;
		}

		$payload['updated_at']            = time();
		$payload['sets']                  = $sets;
		$payload['normalization_version'] = self::NORMALIZATION_VERSION;
		$encoded                          = wp_json_encode( $payload );

		if ( ! is_string( $encoded ) ) {
			return false;
		}

		self::$resolved_cache       = null;
		self::$stored_payload_cache = false;
		$written = GuardedDataFile::write_payload_atomic( self::storage_path(), $encoded );
		if ( $written ) {
			self::delete_previous_storage();
		}

		return $written;
	}

	public static function get_update_status(): array {
		return self::read_update_status();
	}

	public static function record_update_success(): void {
		$now    = time();
		$status = self::read_update_status();
		update_option(
			self::STATUS_OPTION,
			[
				'last_attempt_at'      => $now,
				'last_success_at'      => $now,
				'last_error_at'        => 0,
				'last_error_code'      => '',
				'last_error_message'   => '',
				'consecutive_failures' => 0,
				'last_etag'            => self::get_stored_etag(),
				'first_failure_at'     => 0,
				'updated_at'           => $now,
				'previous_success_at'  => (int) ( $status['last_success_at'] ?? 0 ),
			],
			false
		);
	}

	public static function record_update_failure( \WP_Error $error ): void {
		$now       = time();
		$status    = self::read_update_status();
		$failures  = max( 0, (int) ( $status['consecutive_failures'] ?? 0 ) ) + 1;
		$first_at  = (int) ( $status['first_failure_at'] ?? 0 );
		$code      = self::sanitize_status_text( $error->get_error_code(), 80 );
		$message   = self::sanitize_status_text( $error->get_error_message(), 300 );
		if ( $message === '' ) {
			$message = 'Unknown safe network range update failure.';
		}

		update_option(
			self::STATUS_OPTION,
			[
				'last_attempt_at'      => $now,
				'last_success_at'      => (int) ( $status['last_success_at'] ?? 0 ),
				'last_error_at'        => $now,
				'last_error_code'      => $code,
				'last_error_message'   => $message,
				'consecutive_failures' => $failures,
				'last_etag'            => self::get_stored_etag(),
				'first_failure_at'     => $first_at > 0 ? $first_at : $now,
				'updated_at'           => $now,
			],
			false
		);

		if ( function_exists( 'sitefort_log' ) ) {
			\sitefort_log(
				'SiteFort Firewall: safe network range update failed'
				. ( $code !== '' ? ' [' . $code . ']' : '' )
				. ': ' . $message
			);
		}
	}

	public static function get_all_sets(): array {
		if ( self::$resolved_cache !== null ) {
			return self::$resolved_cache;
		}

		$stored = self::read_stored_sets();
		$bundled  = self::bundled_snapshot();
		$fallback = self::PROVIDER_SEED_RANGES;
		$resolved = [];

		foreach ( self::known_set_ids() as $set_id ) {
			if ( array_key_exists( $set_id, $stored ) && ( $set_id === self::SET_CUSTOM_SAFE || $stored[ $set_id ] !== [] ) ) {
				$resolved[ $set_id ] = $stored[ $set_id ];
				continue;
			}

			if ( array_key_exists( $set_id, $bundled ) && ( $set_id === self::SET_CUSTOM_SAFE || $bundled[ $set_id ] !== [] ) ) {
				$resolved[ $set_id ] = $bundled[ $set_id ];
				continue;
			}

			$resolved[ $set_id ] = $fallback[ $set_id ] ?? [];
		}

		self::$resolved_cache = $resolved;
		return $resolved;
	}

	public static function get_set_ranges( string $set_id ): array {
		$set_id = strtolower( trim( $set_id ) );
		return self::get_all_sets()[ $set_id ] ?? [];
	}

	public static function get_cloudflare_ranges(): array {
		return self::get_set_ranges( self::SET_CLOUDFLARE );
	}

	public static function get_custom_safe_ranges(): array {
		return self::get_set_ranges( self::SET_CUSTOM_SAFE );
	}

	public static function is_ip_in_set( string $ip, string $set_id ): bool {
		$ranges = self::get_set_ranges( $set_id );
		if ( $ranges === [] ) {
			return false;
		}

		return IpUtils::ip_in_range_list( RuleEngine::normalize_ip( $ip ), $ranges );
	}

	public static function is_ip_in_custom_safe_ranges( string $ip ): bool {
		return self::is_ip_in_set( $ip, self::SET_CUSTOM_SAFE );
	}

	public static function get_search_engine_ranges_for_config(): array {
		return [
			self::SET_GOOGLEBOT => self::get_set_ranges( self::SET_GOOGLEBOT ),
			self::SET_BINGBOT   => self::get_set_ranges( self::SET_BINGBOT ),
		];
	}

	public static function store_feed( array $feed, array $meta = [] ): bool {
		if ( (int) ( $feed['schema_version'] ?? 0 ) !== self::SCHEMA_VERSION ) {
			return false;
		}

		if ( ! isset( $feed['sets'] ) || ! is_array( $feed['sets'] ) ) {
			return false;
		}

		$incoming_sets = $feed['sets'];
		$payload_sets  = self::read_stored_sets();

		foreach ( self::provider_set_ids() as $set_id ) {
			if ( ! array_key_exists( $set_id, $incoming_sets ) || ! is_array( $incoming_sets[ $set_id ] ) ) {
				return false;
			}
		}

		foreach ( self::known_set_ids() as $set_id ) {
			if ( ! array_key_exists( $set_id, $incoming_sets ) ) {
				continue;
			}

			if ( ! is_array( $incoming_sets[ $set_id ] ) ) {
				return false;
			}

			$dropped    = 0;
			$normalized = self::normalize_range_list( $incoming_sets[ $set_id ], $set_id, $dropped );
			if ( $normalized === null ) {
				return false;
			}

			if ( $set_id !== self::SET_CUSTOM_SAFE && $normalized === [] ) {
				return false;
			}

			if ( $dropped > 0 && $set_id !== self::SET_CUSTOM_SAFE && function_exists( 'sitefort_log' ) ) {
				\sitefort_log(
					sprintf(
						'SiteFort Firewall: dropped %d %s range(s) from the signed safe network feed during normalization.',
						$dropped,
						$set_id
					)
				);
			}

			$payload_sets[ $set_id ] = $normalized;
		}

		foreach ( self::known_set_ids() as $set_id ) {
			if ( $set_id !== self::SET_CUSTOM_SAFE && isset( $payload_sets[ $set_id ] ) && $payload_sets[ $set_id ] === [] ) {
				unset( $payload_sets[ $set_id ] );
			}
		}

		if ( self::count_ranges( $payload_sets ) > self::MAX_TOTAL_RANGES ) {
			return false;
		}

		$generated_at = self::normalize_feed_generated_at( $feed['generated_at'] ?? null );
		if ( $generated_at <= 0 ) {
			return false;
		}

		if ( $generated_at < self::get_rollback_floor() ) {
			return false;
		}

		$payload = [
			'schema_version'        => self::SCHEMA_VERSION,
			'generated_at'          => $generated_at,
			'updated_at'            => time(),
			'ttl'                   => self::normalize_ttl( $feed['ttl'] ?? self::DEFAULT_TTL ),
			'normalization_version' => self::NORMALIZATION_VERSION,
			'sets'                  => self::sanitize_range_map( $payload_sets ),
		];
		$etag = is_string( $meta['etag'] ?? null ) ? trim( $meta['etag'] ) : '';
		if ( $etag !== '' && strlen( $etag ) <= 200 ) {
			$payload['etag'] = $etag;
		}

		$encoded = wp_json_encode( $payload );

		if ( ! is_string( $encoded ) ) {
			return false;
		}

		self::$resolved_cache       = null;
		self::$stored_payload_cache = false;
		$written = GuardedDataFile::write_payload_atomic( self::storage_path(), $encoded );
		if ( $written && self::set_map_has_all_provider_ranges( $payload['sets'] ) ) {
			self::delete_previous_storage();
		}

		return $written;
	}

	private static function storage_path(): string {
		return Manager::get_data_dir() . '/' . self::STORAGE_SUBDIR . '/' . self::STORAGE_FILE;
	}

	private static function previous_storage_path(): string {
		return Manager::get_data_dir() . '/' . self::PRE_SAFE_NETWORK_STORAGE_SUBDIR . '/' . self::STORAGE_FILE;
	}

	private static function read_stored_sets(): array {
		$payload  = self::read_stored_payload();
		$current  = self::read_current_stored_sets();
		if ( self::set_map_has_all_provider_ranges( $current ) ) {
			return $current;
		}

		if ( is_array( $payload ) && ! self::is_current_payload_usable( $payload ) ) {
			return $current;
		}

		$previous = self::read_previous_stored_sets();

		foreach ( [ self::SET_GOOGLEBOT, self::SET_BINGBOT ] as $set_id ) {
			if ( empty( $current[ $set_id ] ) && ! empty( $previous[ $set_id ] ) ) {
				$current[ $set_id ] = $previous[ $set_id ];
			}
		}

		return $current;
	}

	private static function read_current_stored_sets(): array {
		$payload = self::read_stored_payload();
		if ( ! is_array( $payload ) || ! isset( $payload['sets'] ) || ! is_array( $payload['sets'] ) ) {
			return [];
		}

		if ( ! self::is_current_payload_usable( $payload ) ) {
			return [];
		}

		return self::filter_stored_range_map( $payload['sets'] );
	}

	private static function is_current_payload_usable( array $payload ): bool {
		return (int) ( $payload['normalization_version'] ?? 0 ) === self::NORMALIZATION_VERSION
			&& self::normalize_feed_generated_at( $payload['generated_at'] ?? null ) > 0;
	}

	private static function read_previous_stored_sets(): array {
		$payload = self::read_payload_file( self::previous_storage_path() );
		if ( ! is_array( $payload ) || ! isset( $payload['ranges'] ) || ! is_array( $payload['ranges'] ) ) {
			return [];
		}

		return self::sanitize_range_map(
			[
				self::SET_GOOGLEBOT => $payload['ranges'][ self::SET_GOOGLEBOT ] ?? [],
				self::SET_BINGBOT   => $payload['ranges'][ self::SET_BINGBOT ] ?? [],
			]
		);
	}

	private static function delete_previous_storage(): void {
		$path = self::previous_storage_path();
		if ( is_file( $path ) ) {
			wp_delete_file( $path );
		}
	}

	private static function read_payload_file( string $path ): ?array {
		$payload = GuardedDataFile::read_payload( $path );
		if ( ! is_string( $payload ) || $payload === '' ) {
			return null;
		}

		$data = json_decode( $payload, true );
		return is_array( $data ) ? $data : null;
	}

	private static function read_stored_payload(): ?array {
		if ( self::$stored_payload_cache === false ) {
			self::$stored_payload_cache = self::read_payload_file( self::storage_path() );
		}

		return self::$stored_payload_cache;
	}

	private static function read_update_status(): array {
		$status = get_option( self::STATUS_OPTION, [] );
		return is_array( $status ) ? $status : [];
	}

	private static function sanitize_status_text( string $value, int $max_length ): string {
		$value = trim( wp_strip_all_tags( $value ) );
		$value = preg_replace( '/[\x00-\x1F\x7F]+/', ' ', $value );
		$value = is_string( $value ) ? trim( $value ) : '';

		if ( strlen( $value ) <= $max_length ) {
			return $value;
		}

		return substr( $value, 0, $max_length );
	}

	private static function bundled_data(): ?array {
		if ( self::$bundled_data_cache !== false ) {
			return self::$bundled_data_cache;
		}

		self::$bundled_data_cache = null;

		$path = __DIR__ . '/data/bundled-ranges.php';
		if ( is_file( $path ) && is_readable( $path ) ) {
			$data = include $path;
			if ( is_array( $data ) ) {
				self::$bundled_data_cache = $data;
			}
		}

		return self::$bundled_data_cache;
	}

	private static function bundled_snapshot(): array {
		$data = self::bundled_data();
		if ( ! is_array( $data ) || ! isset( $data['sets'] ) || ! is_array( $data['sets'] ) ) {
			return [];
		}

		return self::sanitize_range_map( $data['sets'] );
	}

	private static function sanitize_range_map( array $sets ): array {
		$out = [];
		foreach ( self::known_set_ids() as $set_id ) {
			if ( ! array_key_exists( $set_id, $sets ) || ! is_array( $sets[ $set_id ] ) ) {
				continue;
			}

			$normalized = self::normalize_range_list( $sets[ $set_id ], $set_id );
			if ( $normalized !== null ) {
				$out[ $set_id ] = $normalized;
			}
		}

		return $out;
	}

	private static function set_map_has_all_provider_ranges( array $sets ): bool {
		foreach ( self::provider_set_ids() as $set_id ) {
			if ( empty( $sets[ $set_id ] ) ) {
				return false;
			}
		}

		return true;
	}

	private static function filter_stored_range_map( array $sets ): array {
		$out = [];
		foreach ( self::known_set_ids() as $set_id ) {
			if ( ! array_key_exists( $set_id, $sets ) || ! is_array( $sets[ $set_id ] ) ) {
				continue;
			}

			$filtered = self::filter_stored_range_list( $sets[ $set_id ], $set_id );
			if ( $filtered !== null ) {
				$out[ $set_id ] = $filtered;
			}
		}

		return $out;
	}

	private static function filter_stored_range_list( array $ranges, string $set_id ): ?array {
		$max_count = $set_id === self::SET_CUSTOM_SAFE ? self::MAX_CUSTOM_SAFE_RANGES : self::MAX_PROVIDER_SET_RANGES;
		if ( count( $ranges ) > $max_count ) {
			return null;
		}

		$out = [];
		foreach ( $ranges as $range ) {
			if ( ! is_string( $range ) ) {
				continue;
			}

			$range = trim( $range );
			if ( $range === '' || strlen( $range ) > self::MAX_CIDR_LENGTH || strpos( $range, '*' ) !== false ) {
				continue;
			}

			$out[] = $range;
		}

		return $out;
	}

	private static function normalize_range_list( array $ranges, string $set_id, ?int &$dropped = null ): ?array {
		$dropped   = 0;
		$max_count = $set_id === self::SET_CUSTOM_SAFE ? self::MAX_CUSTOM_SAFE_RANGES : self::MAX_PROVIDER_SET_RANGES;
		if ( count( $ranges ) > $max_count ) {
			return null;
		}

		$strings = [];
		foreach ( $ranges as $range ) {
			if ( ! is_string( $range ) ) {
				continue;
			}

			$range = trim( $range );
			if ( $range === '' || strlen( $range ) > self::MAX_CIDR_LENGTH ) {
				continue;
			}

			$strings[] = $range;
		}

		$normalized = RuleEngine::normalize_ip_entries( $strings, false );
		$out        = [];
		foreach ( $normalized as $entry ) {
			if ( self::is_safe_public_entry( $entry, $set_id ) ) {
				$out[ $entry ] = true;
			}
		}

		$dropped = max( 0, count( $strings ) - count( $out ) );

		return array_keys( $out );
	}

	private static function is_safe_public_entry( string $entry, string $set_id ): bool {
		$entry = trim( $entry );
		if ( $entry === '' || strpos( $entry, '*' ) !== false ) {
			return false;
		}

		$slash = strpos( $entry, '/' );
		if ( $slash === false ) {
			return self::is_public_ip( $entry );
		}

		$ip     = trim( substr( $entry, 0, $slash ) );
		$prefix = trim( substr( $entry, $slash + 1 ) );
		if ( ! ctype_digit( $prefix ) || ! self::is_public_ip( $ip ) ) {
			return false;
		}

		$prefix_int = (int) $prefix;
		if ( $prefix_int === 0 ) {
			return false;
		}

		$is_ipv4    = (bool) filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 );
		$version    = $is_ipv4 ? 4 : 6;
		$max_prefix = $is_ipv4 ? 32 : 128;
		if ( $prefix_int > $max_prefix ) {
			return false;
		}

		$min_prefix = self::minimum_prefix_for_set( $set_id, $version );
		if ( $prefix_int < $min_prefix ) {
			return false;
		}

		return true;
	}

	private static function minimum_prefix_for_set( string $set_id, int $version ): int {
		if ( $set_id === self::SET_CUSTOM_SAFE ) {
			return $version === 4 ? 24 : 64;
		}

		return (int) ( self::PROVIDER_MIN_PREFIX[ $set_id ][ $version ] ?? 1 );
	}

	private static function is_public_ip( string $ip ): bool {
		return (bool) filter_var(
			$ip,
			FILTER_VALIDATE_IP,
			FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
		);
	}

	private static function normalize_ttl( $ttl ): int {
		$ttl = is_numeric( $ttl ) ? (int) $ttl : self::DEFAULT_TTL;
		return max( self::MIN_TTL, min( self::MAX_TTL, $ttl ) );
	}

	private static function count_ranges( array $sets ): int {
		$total = 0;
		foreach ( $sets as $ranges ) {
			$total += is_array( $ranges ) ? count( $ranges ) : 0;
		}

		return $total;
	}
}
