<?php
/**
 *
 * @package fense-block-vpn-proxy
 */

if ( !defined( 'ABSPATH' ) ) exit; // Exit if accessed directly

class FENSE_BPVT_Bot_Verifier
{
	
	/** @var int Cache TTL (seconds) for a confirmed crawler. */
	private $ttl_pass = DAY_IN_SECONDS;
	
	/**
	 * @var int Cache TTL (seconds) for a failed / spoofed claim. Kept short so
	 *          a genuine crawler that rotates onto a new IP, or that hit a
	 *          transient DNS error, recovers within the hour.
	 */
	private $ttl_fail = HOUR_IN_SECONDS;
	
	/**
	 * Is this request a cryptographically verified search engine?
	 *
	 * @param string $ip Visitor IP (already resolved via get_the_user_ip()).
	 * @param string $ua Raw user agent string.
	 * @return bool
	 */
	public function is_verified_bot( string $ip, string $ua ): bool
	{
		try {
			$ip = trim( $ip );
			
			if ( '' === $ip || '' === $ua ) {
				return false;
			}
			
			// Tier 1 — does the UA even claim to be a known crawler?
			$hosts = $this->match_claim( $ua );
			if ( empty( $hosts ) ) {
				return false;
			}
			
			// Tier 3 — cached verdict for this IP?
			$cache_key = 'bpvt_bot_' . md5( $ip );
			$cached = FENSE_BPVT_Cache::wp_get( $cache_key );
			if ( false !== $cached ) {
				return ( 'pass' === $cached );
			}
			
			// Tier 2 — prove the claim with FCrDNS.
			$pass = $this->fcrDns( $ip, $hosts );
			
			FENSE_BPVT_Cache::wp_set( $cache_key, $pass ? 'pass' : 'fail', $pass ? $this->ttl_pass : $this->ttl_fail );
			
			return $pass;
		} catch ( Throwable $e ) {
			// Fail open: a DNS or registry error must never block a request.
			return false;
		}
	}
	
	/**
	 * Returns the trusted host suffixes for the crawler the UA claims to be,
	 * or an empty array if it matches no known crawler.
	 *
	 * @param string $ua
	 * @return array
	 */
	private function match_claim( string $ua ): array
	{
		foreach ( $this->registry() as $crawler ) {
			if ( empty( $crawler['ua'] ) || empty( $crawler['hosts'] ) ) {
				continue;
			}
			if ( preg_match( '/(' . $crawler['ua'] . ')/i', $ua ) ) {
				return (array)$crawler['hosts'];
			}
		}
		return [];
	}
	
	/**
	 * Forward-Confirmed Reverse DNS.
	 *
	 * @param string $ip
	 * @param array $trusted_hosts Suffixes the reverse hostname must end with.
	 * @return bool
	 */
	private function fcrDns( string $ip, array $trusted_hosts ): bool
	{
		// Reverse: IP -> hostname.
		$host = @gethostbyaddr( $ip );
		if ( !$host || $host === $ip ) {
			return false;
		}
		$host = strtolower( rtrim( $host, '.' ) );
		
		// The hostname must belong to one of the crawler's official domains.
		$belongs = false;
		foreach ( $trusted_hosts as $suffix ) {
			$suffix = strtolower( $suffix );
			if ( substr( $host, -strlen( $suffix ) ) === $suffix ) {
				$belongs = true;
				break;
			}
		}
		if ( !$belongs ) {
			return false;
		}
		
		// Forward: the hostname must resolve back to the original IP.
		return $this->forward_confirms( $host, $ip );
	}
	
	/**
	 * Confirms the hostname forward-resolves to the given IP (v4 or v6).
	 *
	 * @param string $host
	 * @param string $ip
	 * @return bool
	 */
	private function forward_confirms( string $host, string $ip ): bool
	{
		if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
			$list = gethostbynamel( $host );
			return is_array( $list ) && in_array( $ip, $list, true );
		}
		
		if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
			$target = @inet_pton( $ip );
			$records = @dns_get_record( $host, DNS_AAAA );
			if ( false !== $target && is_array( $records ) ) {
				foreach ( $records as $record ) {
					if ( isset( $record['ipv6'] ) && @inet_pton( $record['ipv6'] ) === $target ) {
						return true;
					}
				}
			}
		}
		
		return false;
	}
	
	/**
	 * Registry of crawlers that publish a verifiable reverse-DNS domain.
	 *
	 * Only engines that can be *proven* via FCrDNS are listed — a UA pattern
	 * without an official hostname is just a trust list, which is what we are
	 * trying to get away from. Extend or restrict with the filter below.
	 *
	 * Each entry: 'ua' => '<regex alternation>', 'hosts' => [ '.suffix', ... ].
	 *
	 * @return array
	 */
	private function registry(): array
	{
		$crawlers = [
			'google' => [
				'ua' => 'googlebot|google\.com\/bot|google-inspectiontool|storebot-google|googleother|adsbot-google|mediapartners-google|feedfetcher-google|apis-google',
				'hosts' => [ '.googlebot.com', '.google.com' ],
			],
			'bing' => [
				'ua' => 'bingbot|bingpreview|adidxbot|msnbot',
				'hosts' => [ '.search.msn.com' ],
			],
			'yandex' => [
				'ua' => 'yandex',
				'hosts' => [ '.yandex.com', '.yandex.net', '.yandex.ru' ],
			],
			'apple' => [
				'ua' => 'applebot',
				'hosts' => [ '.applebot.apple.com' ],
			],
			'baidu' => [
				'ua' => 'baiduspider',
				'hosts' => [ '.baidu.com', '.baidu.jp', '.crawl.baidu.com', '.crawl.baidu.jp' ],
			],
		];
		
		/**
		 * Filter the verified-crawler registry.
		 *
		 * Example — add a custom engine:
		 *   add_filter( 'fense_bpvt_verified_crawlers', function ( $c ) {
		 *       $c['seznam'] = array( 'ua' => 'seznambot', 'hosts' => array( '.seznam.cz' ) );
		 *       return $c;
		 *   } );
		 *
		 * @param array $crawlers
		 */
		return (array)apply_filters( 'fense_bpvt_verified_crawlers', $crawlers );
	}
}
