<?php

declare(strict_types=1);

namespace SiteFort\Modules\Firewall\SearchTrust;

final class SearchTrustDnsVerifier {
	public const TRUST_VERIFIED = 'verified';
	public const TRUST_SPOOFED = 'spoofed';
	public const TRUST_INDETERMINATE = 'indeterminate';

	// DNS/forward-lookup failures return indeterminate (uncached) so a transient outage never blocks a real crawler.
	/**
	 * @param callable(string):mixed                         $read_cache
	 * @param callable(string,bool,int):void                 $write_cache
	 * @param callable(string,array<string,mixed>):void|null $touch_cache
	 * @param callable(string):mixed|null                    $acquire_lock
	 * @param callable(mixed):void|null                      $release_lock
	 * @param callable(string,string):bool|null              $range_lookup
	 * @param callable():bool|null                           $dns_gate
	 * @param callable(float):void|null                      $dns_report
	 */
	public static function classify(
		string $ip,
		string $engine,
		callable $read_cache,
		callable $write_cache,
		?callable $touch_cache = null,
		?callable $acquire_lock = null,
		?callable $release_lock = null,
		?callable $range_lookup = null,
		?callable $dns_gate = null,
		?callable $dns_report = null
	): string {
		$ip = SearchTrustCore::normalize_ip( $ip );
		$engine = strtolower( trim( $engine ) );
		if ( $ip === '' || ! filter_var( $ip, FILTER_VALIDATE_IP ) || $engine === '' ) {
			return self::TRUST_INDETERMINATE;
		}

		if ( empty( SearchTrustCore::get_verified_search_dns_suffixes( $engine ) ) ) {
			return self::TRUST_INDETERMINATE;
		}

		// In-range means verified with no rDNS. A negative is never concluded from
		// ranges alone: DNS below always runs before an IP is called a spoof.
		if ( $range_lookup !== null && $range_lookup( $ip, $engine ) === true ) {
			return self::TRUST_VERIFIED;
		}

		$cache_key = SearchTrustCore::build_verified_search_cache_key( $engine, $ip );
		$indeterminate_key = $cache_key . '_ind';
		$cached_decision = self::cached_decision(
			$read_cache( $cache_key ),
			$cache_key,
			$read_cache,
			$touch_cache,
			$acquire_lock,
			$release_lock,
			false
		);
		if ( is_bool( $cached_decision ) ) {
			return self::status_from_bool( $cached_decision );
		}

		$lock_token = null;
		$lock_acquired = false;

		if ( $acquire_lock !== null ) {
			$lock_token = $acquire_lock( $cache_key );
			$lock_acquired = $lock_token !== null && $lock_token !== false;
			if ( ! $lock_acquired ) {
				$cached_decision = self::cached_decision(
					$read_cache( $cache_key ),
					$cache_key,
					null,
					null,
					null,
					null,
					true
				);
				return is_bool( $cached_decision ) ? self::status_from_bool( $cached_decision ) : self::TRUST_INDETERMINATE;
			}
		}

		try {
			if ( $lock_acquired ) {
				$cached_decision = self::cached_decision(
					$read_cache( $cache_key ),
					$cache_key,
					null,
					null,
					null,
					null,
					false
				);
				if ( is_bool( $cached_decision ) ) {
					return self::status_from_bool( $cached_decision );
				}
			}

			// A recently-seen indeterminate result throttles repeat rDNS without ever marking the IP spoofed.
			if ( is_array( SearchTrustCore::normalize_verified_search_cache_record( $read_cache( $indeterminate_key ) ) ) ) {
				return self::TRUST_INDETERMINATE;
			}

			// Gate the timeout-less resolver call; a declined gate is unproven, not spoofed.
			if ( $dns_gate !== null && $dns_gate() !== true ) {
				return self::TRUST_INDETERMINATE;
			}

			// Time the resolver calls only, so the breaker reads DNS latency.
			$dns_started = microtime( true );

			try {
				$hostname = @gethostbyaddr( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Reverse resolution failure is treated as indeterminate, not a spoof.
				$hostname = is_string( $hostname ) ? rtrim( strtolower( trim( $hostname ) ), '.' ) : '';

				if ( $hostname === '' || $hostname === $ip ) {
					$write_cache( $indeterminate_key, false, SearchTrustCore::get_verified_search_cache_ttl_indeterminate() );
					return self::TRUST_INDETERMINATE;
				}

				if ( ! SearchTrustCore::hostname_matches_verified_search_engine( $hostname, $engine ) ) {
					$write_cache( $cache_key, false, SearchTrustCore::get_verified_search_cache_ttl( false ) );
					return self::TRUST_SPOOFED;
				}

				$forward_ips = self::get_forward_dns_ips( $hostname );
				if ( empty( $forward_ips ) ) {
					$write_cache( $indeterminate_key, false, SearchTrustCore::get_verified_search_cache_ttl_indeterminate() );
					return self::TRUST_INDETERMINATE;
				}

				$verified = in_array( $ip, $forward_ips, true );
				$write_cache( $cache_key, $verified, SearchTrustCore::get_verified_search_cache_ttl( $verified ) );

				return $verified ? self::TRUST_VERIFIED : self::TRUST_SPOOFED;
			} finally {
				if ( $dns_report !== null ) {
					$dns_report( microtime( true ) - $dns_started );
				}
			}
		} finally {
			if ( $lock_acquired && $release_lock !== null ) {
				$release_lock( $lock_token );
			}
		}
	}

	private static function status_from_bool( bool $verified ): string {
		return $verified ? self::TRUST_VERIFIED : self::TRUST_SPOOFED;
	}

	private static function cached_decision(
		$cached,
		string $cache_key,
		?callable $read_cache,
		?callable $touch_cache,
		?callable $acquire_lock,
		?callable $release_lock,
		bool $allow_stale_grace
	): ?bool {
		$record = SearchTrustCore::normalize_verified_search_cache_record( $cached );
		if ( ! is_array( $record ) ) {
			return null;
		}

		if ( empty( $record['verified'] ) ) {
			return false;
		}

		if ( ! SearchTrustCore::is_verified_search_cache_record_within_hard_age( $record ) ) {
			return $allow_stale_grace && SearchTrustCore::is_verified_search_cache_record_within_stale_grace( $record )
				? true
				: null;
		}

		if ( SearchTrustCore::should_refresh_verified_search_cache_record( $record ) ) {
			self::touch_cached_record( $cache_key, $record, $read_cache, $touch_cache, $acquire_lock, $release_lock );
		}

		return true;
	}

	/**
	 * @param array{verified: bool, expires_at: int, updated_at: int, verified_at: int, last_seen_at: int} $record
	 */
	private static function touch_cached_record(
		string $cache_key,
		array $record,
		?callable $read_cache,
		?callable $touch_cache,
		?callable $acquire_lock,
		?callable $release_lock
	): void {
		if ( $touch_cache === null ) {
			return;
		}

		$lock_token = null;
		$lock_acquired = false;
		if ( $acquire_lock !== null ) {
			$lock_token = $acquire_lock( $cache_key );
			$lock_acquired = $lock_token !== null && $lock_token !== false;
			if ( ! $lock_acquired ) {
				return;
			}
		}

		try {
			if ( $read_cache !== null ) {
				$latest = SearchTrustCore::normalize_verified_search_cache_record( $read_cache( $cache_key ) );
				if ( is_array( $latest ) ) {
					$record = $latest;
				}
			}

			if (
				empty( $record['verified'] )
				|| ! SearchTrustCore::is_verified_search_cache_record_within_hard_age( $record )
				|| ! SearchTrustCore::should_refresh_verified_search_cache_record( $record )
			) {
				return;
			}

			$touch_cache( $cache_key, SearchTrustCore::refresh_verified_search_cache_record( $record ) );
		} finally {
			if ( $lock_acquired && $release_lock !== null ) {
				$release_lock( $lock_token );
			}
		}
	}

	/**
	 * @return array<int, string>
	 */
	private static function get_forward_dns_ips( string $hostname ): array {
		$resolved = [];

		if ( function_exists( 'dns_get_record' ) && defined( 'DNS_A' ) && defined( 'DNS_AAAA' ) ) {
			$records = @dns_get_record( $hostname, DNS_A | DNS_AAAA ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Verified-search DNS checks fail closed when forward resolution is unavailable.

			if ( is_array( $records ) ) {
				foreach ( $records as $record ) {
					if ( ! is_array( $record ) ) {
						continue;
					}

					$candidate = '';
					if ( isset( $record['ip'] ) && is_string( $record['ip'] ) ) {
						$candidate = $record['ip'];
					} elseif ( isset( $record['ipv6'] ) && is_string( $record['ipv6'] ) ) {
						$candidate = $record['ipv6'];
					}

					$candidate = SearchTrustCore::normalize_ip( $candidate );
					if ( $candidate !== '' && filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
						$resolved[ $candidate ] = true;
					}
				}
			}
		}

		if ( empty( $resolved ) ) {
			$fallback = @gethostbynamel( $hostname ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Verified-search DNS checks fail closed when IPv4 forward resolution is unavailable.

			if ( is_array( $fallback ) ) {
				foreach ( $fallback as $candidate ) {
					$candidate = SearchTrustCore::normalize_ip( is_string( $candidate ) ? $candidate : '' );
					if ( $candidate !== '' && filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
						$resolved[ $candidate ] = true;
					}
				}
			}
		}

		return array_keys( $resolved );
	}
}
