<?php
/**
 * Centralised Geo Service for location resolution
 *
 * Resolves visitor location using CDN/server geo headers first,
 * falling back to a local MMDB database lookup.
 *
 * @link       https://personalizewp.com/
 * @since      3.5.0
 *
 * @package    PersonalizeWP
 */

namespace PersonalizeWP;

use PersonalizeWP\Traits\SingletonTrait;

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

/**
 * Geo Service class.
 *
 * Provides a single point of geo resolution for all location-based conditions.
 */
class Geo_Service {

	use SingletonTrait;

	/**
	 * GeoIP2 Database Reader instance.
	 *
	 * @var \GeoIp2\Database\Reader|null
	 */
	private $geo_reader = null;

	/**
	 * Cached geo result for the current request.
	 *
	 * @var object|null
	 */
	private $cached_result = null;

	/**
	 * IP address the cached result was resolved for.
	 *
	 * @var string|null
	 */
	private $cached_ip = null;

	/**
	 * CDN header mappings for geo detection.
	 *
	 * Each entry defines a provider with country and optional region header keys.
	 *
	 * @var array
	 */
	private $cdn_headers = [
		'cloudflare'  => [
			'country' => 'HTTP_CF_IPCOUNTRY',
			'region'  => 'HTTP_CF_REGION',
		],
		'cloudfront'  => [
			'country' => 'HTTP_CLOUDFRONT_VIEWER_COUNTRY',
			'region'  => 'HTTP_CLOUDFRONT_VIEWER_COUNTRY_REGION',
		],
		'sucuri'      => [
			'country' => 'HTTP_X_SUCURI_COUNTRY',
			'region'  => '',
		],
		'fastly'      => [
			'country' => 'HTTP_FASTLY_GEO_COUNTRY',
			'region'  => 'HTTP_FASTLY_GEO_REGION',
		],
		'keycdn'      => [
			'country' => 'HTTP_X_GEO_COUNTRY',
			'region'  => 'HTTP_X_GEO_REGION',
		],
		'nginx'       => [
			'country' => 'HTTP_X_COUNTRY_CODE',
			'region'  => 'HTTP_X_REGION_CODE',
		],
	];

	/**
	 * Resolve the visitor's geographic location.
	 *
	 * Checks CDN/server headers first, then falls back to MMDB database lookup.
	 * Results are cached per-request and in the object cache for MMDB lookups.
	 *
	 * @since 3.5.0
	 *
	 * @param string $ip_address Optional. IPv4 or IPv6 address. Defaults to current visitor.
	 *
	 * @return object Object with 'country' and 'subdivision' properties (ISO codes).
	 */
	public function resolve( $ip_address = '' ) {

		if ( empty( $ip_address ) ) {
			$ip_address = $this->get_ip_address();
		}

		// Return cached result if resolving the same IP.
		if ( $this->cached_ip === $ip_address && null !== $this->cached_result ) {
			return $this->cached_result;
		}

		$this->cached_ip = $ip_address;

		// Try CDN/server headers first (instant, no I/O).
		$result = $this->resolve_from_headers();

		// Fall back to MMDB database if no CDN headers found.
		if ( null === $result && ! empty( $ip_address ) ) {
			$result = $this->resolve_from_database( $ip_address );
		}

		// Default empty result.
		if ( null === $result ) {
			$result = $this->make_result( '', '' );
		}

		/**
		 * Filters the resolved geo result.
		 *
		 * @since 3.5.0
		 *
		 * @param object $result     Object with 'country' and 'subdivision' properties.
		 * @param string $ip_address The IP address that was resolved.
		 */
		$result = apply_filters( 'personalizewp_geo_resolved', $result, $ip_address );

		$this->cached_result = $result;

		return $result;
	}

	/**
	 * Get the visitor's country ISO code.
	 *
	 * @since 3.5.0
	 *
	 * @param string $ip_address Optional. IPv4 or IPv6 address.
	 *
	 * @return string Two-letter ISO 3166-1 country code, or empty string.
	 */
	public function get_country( $ip_address = '' ) {
		return $this->resolve( $ip_address )->country;
	}

	/**
	 * Get the visitor's subdivision/state ISO code.
	 *
	 * @since 3.5.0
	 *
	 * @param string $ip_address Optional. IPv4 or IPv6 address.
	 *
	 * @return string ISO 3166-2 subdivision code (e.g., 'CA' for California), or empty string.
	 */
	public function get_subdivision( $ip_address = '' ) {
		return $this->resolve( $ip_address )->subdivision;
	}

	/**
	 * Get the visitor's IP address from the server.
	 *
	 * @since 3.5.0
	 *
	 * @return string Sanitized IP address, or empty string.
	 */
	public function get_ip_address() {
		return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
	}

	/**
	 * Get the list of CDN providers whose geo headers should be trusted.
	 *
	 * Returns only the providers enabled via the `personalizewp_trusted_geo_providers` filter.
	 * Header-based geo detection is disabled by default until an administrator
	 * explicitly opts a provider in for their hosting environment.
	 *
	 * To disable header-based geo detection entirely, return an empty array:
	 *     add_filter( 'personalizewp_trusted_geo_providers', '__return_empty_array' );
	 *
	 * To trust only Cloudflare:
	 *     add_filter( 'personalizewp_trusted_geo_providers', fn() => [ 'cloudflare' ] );
	 *
	 * @since 3.5.0
	 *
	 * @return array Associative array of provider => header config, filtered to trusted providers only.
	 */
	private function get_trusted_cdn_headers() {

		/**
		 * Filters which CDN/server geo-header providers are trusted.
		 *
		 * CDN geo headers can be spoofed if a client connects directly to the origin
		 * server, bypassing the CDN. Header-based detection is therefore disabled by
		 * default. Opt providers in only when your infrastructure strips client-
		 * supplied geo headers or the request source is validated.
		 *
		 * Valid provider keys: 'cloudflare', 'cloudfront', 'sucuri', 'fastly', 'keycdn', 'nginx'.
		 *
		 * @since 3.5.0
		 *
		 * @param string[] $providers Array of provider keys to trust. Default: none.
		 */
		$trusted = apply_filters( 'personalizewp_trusted_geo_providers', [] );

		if ( empty( $trusted ) || ! is_array( $trusted ) ) {
			return [];
		}

		return array_intersect_key( $this->cdn_headers, array_flip( $trusted ) );
	}

	/**
	 * Determine whether geo headers are trusted for the current request.
	 *
	 * Administrators can provide trusted proxy/CDN CIDR ranges via
	 * `personalizewp_trusted_geo_proxy_cidrs`, or override the decision
	 * entirely with `personalizewp_trust_geo_headers_for_request`.
	 *
	 * @since 3.5.0
	 *
	 * @param string $provider CDN provider key.
	 *
	 * @return bool
	 */
	private function should_trust_geo_headers_for_request( $provider ) {
		$remote_addr = $this->get_ip_address();
		$is_trusted  = false;

		/**
		 * Filters the trusted proxy/CDN CIDR ranges for geo-header validation.
		 *
		 * Provide an array of CIDR strings or exact IPs to confirm that the current
		 * request originated from trusted infrastructure before geo headers are used.
		 *
		 * Example:
		 *     add_filter(
		 *         'personalizewp_trusted_geo_proxy_cidrs',
		 *         fn( $cidrs, $provider ) => 'cloudflare' === $provider ? [ '203.0.113.0/24' ] : $cidrs,
		 *         10,
		 *         2
		 *     );
		 *
		 * @since 3.5.0
		 *
		 * @param string[] $cidrs      CIDR ranges or exact IPs to trust. Default empty.
		 * @param string   $provider   CDN provider key currently being evaluated.
		 * @param string   $remote_addr Current request remote IP address.
		 */
		$trusted_cidrs = apply_filters( 'personalizewp_trusted_geo_proxy_cidrs', [], $provider, $remote_addr );

		if ( ! empty( $remote_addr ) && is_array( $trusted_cidrs ) ) {
			foreach ( $trusted_cidrs as $cidr ) {
				if ( $this->ip_matches_cidr( $remote_addr, $cidr ) ) {
					$is_trusted = true;
					break;
				}
			}
		}

		/**
		 * Filters whether geo headers should be trusted for the current request.
		 *
		 * This runs after CIDR validation and allows advanced hosting setups to
		 * integrate with their own trusted-proxy detection logic.
		 *
		 * @since 3.5.0
		 *
		 * @param bool   $is_trusted  Whether the current request is trusted. Default false.
		 * @param string $provider    CDN provider key currently being evaluated.
		 * @param string $remote_addr Current request remote IP address.
		 */
		return (bool) apply_filters( 'personalizewp_trust_geo_headers_for_request', $is_trusted, $provider, $remote_addr );
	}

	/**
	 * Attempt to resolve geo data from CDN/server HTTP headers.
	 *
	 * Only checks headers from providers listed in the trusted providers filter.
	 * Administrators should configure this filter to match their hosting setup
	 * to prevent clients from spoofing geo headers by bypassing the CDN.
	 *
	 * @since 3.5.0
	 *
	 * @return object|null Result object if headers found, null otherwise.
	 */
	private function resolve_from_headers() {

		$trusted_headers = $this->get_trusted_cdn_headers();

		foreach ( $trusted_headers as $provider => $headers ) {
			if ( ! $this->should_trust_geo_headers_for_request( $provider ) ) {
				continue;
			}

			$country_header = $headers['country'];
			$region_header  = $headers['region'];

			if ( ! empty( $_SERVER[ $country_header ] ) ) {
				$country = strtoupper( sanitize_text_field( wp_unslash( $_SERVER[ $country_header ] ) ) );

				// Skip placeholder values.
				if ( 'XX' === $country || 'T1' === $country ) {
					continue;
				}

				$subdivision = '';
				if ( ! empty( $region_header ) && ! empty( $_SERVER[ $region_header ] ) ) {
					$subdivision = strtoupper( sanitize_text_field( wp_unslash( $_SERVER[ $region_header ] ) ) );
				}

				return $this->make_result( $country, $subdivision );
			}
		}

		return null;
	}

	/**
	 * Check whether an IP address matches a CIDR range or exact IP.
	 *
	 * @since 3.5.0
	 *
	 * @param string $ip   IP address to test.
	 * @param string $cidr CIDR range or exact IP.
	 *
	 * @return bool
	 */
	private function ip_matches_cidr( $ip, $cidr ) {
		$ip   = trim( (string) $ip );
		$cidr = trim( (string) $cidr );

		if ( '' === $ip || '' === $cidr ) {
			return false;
		}

		if ( false === strpos( $cidr, '/' ) ) {
			return $ip === $cidr;
		}

		list( $subnet, $prefix_length ) = array_pad( explode( '/', $cidr, 2 ), 2, null );

		if ( null === $prefix_length || '' === $subnet || ! is_numeric( $prefix_length ) ) {
			return false;
		}

		$ip_binary     = inet_pton( $ip );
		$subnet_binary = inet_pton( $subnet );

		if ( false === $ip_binary || false === $subnet_binary || strlen( $ip_binary ) !== strlen( $subnet_binary ) ) {
			return false;
		}

		$prefix_length = (int) $prefix_length;
		$max_bits      = 8 * strlen( $ip_binary );

		if ( $prefix_length < 0 || $prefix_length > $max_bits ) {
			return false;
		}

		$full_bytes = intdiv( $prefix_length, 8 );
		$extra_bits = $prefix_length % 8;

		if ( $full_bytes > 0 && substr( $ip_binary, 0, $full_bytes ) !== substr( $subnet_binary, 0, $full_bytes ) ) {
			return false;
		}

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

		$mask = ( 0xFF << ( 8 - $extra_bits ) ) & 0xFF;

		return ( ord( $ip_binary[ $full_bytes ] ) & $mask ) === ( ord( $subnet_binary[ $full_bytes ] ) & $mask );
	}

	/**
	 * Resolve geo data from the local MMDB database.
	 *
	 * Uses the WordPress object cache to store results, reducing
	 * repeated MMDB lookups for the same IP address.
	 *
	 * @since 3.5.0
	 *
	 * @param string $ip_address IPv4 or IPv6 address.
	 *
	 * @return object|null Result object, or null if database unavailable.
	 */
	private function resolve_from_database( $ip_address ) {

		$db_manager = Geo_Database_Manager::instance();
		if ( ! $db_manager->is_database_available() ) {
			return null;
		}

		// Check object cache first.
		$cache_key = 'geo_' . md5( $ip_address );
		$cached    = wp_cache_get( $cache_key, 'personalizewp' );
		if ( false !== $cached && is_object( $cached ) ) {
			return $cached;
		}

		// Lazy-load the MMDB reader.
		if ( null === $this->geo_reader ) {
			try {
				$this->geo_reader = new \GeoIp2\Database\Reader( $db_manager->get_database_path() );
			} catch ( \Exception $e ) {
				return null;
			}
		}

		try {
			$record      = $this->geo_reader->city( $ip_address );
			$country     = ! empty( $record->country->isoCode ) ? $record->country->isoCode : '';
			// Prefer ISO code; fall back to name-based lookup for databases
			// like DB-IP Lite that provide names without codes (e.g. "England" without "ENG").
			$subdivision = ! empty( $record->mostSpecificSubdivision->isoCode )
				? $record->mostSpecificSubdivision->isoCode
				: $this->resolve_subdivision_code_from_name( $country, $record->mostSpecificSubdivision->name );

			$result = $this->make_result( $country, $subdivision );

			// Cache in object cache for 1 hour.
			wp_cache_set( $cache_key, $result, 'personalizewp', HOUR_IN_SECONDS );

			return $result;
		} catch ( \Exception $e ) {
			return null;
		}
	}

	/**
	 * Resolve a subdivision ISO code from its name by looking up the subdivisions data.
	 *
	 * Handles databases like DB-IP Lite that return subdivision names (e.g. "England")
	 * without ISO codes.
	 *
	 * @since 3.5.0
	 *
	 * @param string $country Country ISO code.
	 * @param string $name    Subdivision name from the database.
	 *
	 * @return string ISO subdivision code, or empty string if not found.
	 */
	private function resolve_subdivision_code_from_name( $country, $name ) {
		if ( empty( $country ) || empty( $name ) ) {
			return '';
		}

		$subdivisions = \personalizewp()->get_subdivisions( $country, true );
		if ( empty( $subdivisions ) ) {
			return '';
		}

		// Reverse lookup: find the code for the given name.
		$code = array_search( $name, $subdivisions, true );
		return false !== $code ? $code : '';
	}

	/**
	 * Create a standardized geo result object.
	 *
	 * @since 3.5.0
	 *
	 * @param string $country     ISO 3166-1 country code.
	 * @param string $subdivision ISO 3166-2 subdivision code.
	 *
	 * @return object
	 */
	private function make_result( $country, $subdivision ) {
		return (object) [
			'country'     => $country,
			'subdivision' => $subdivision,
		];
	}
}
