<?php
/**
 * Simple transient-based rate limiter for chatbot requests.
 *
 * @package AIPT\Api\Chatbot
 * @since 2.5.0
 */

namespace AIPT\Api\Chatbot;

/**
 * RateLimiter class
 *
 * Implements fixed-window rate limiting using WordPress transients.
 * Supports session-only or session+hashed-IP keying modes.
 *
 * IMPORTANT: This implementation uses a "best-effort" approach without atomic
 * operations. Under very high concurrent load, a small number of requests may
 * slip through before rate limiting kicks in due to the get-check-set race
 * condition. This is an intentional trade-off for simplicity and is acceptable
 * for most WordPress installations. The counter self-corrects when the window
 * expires.
 *
 * @see https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use
 */
class RateLimiter {

	/**
	 * Transient key prefix for rate limit counters.
	 */
	private const TRANSIENT_PREFIX = 'aipt_chat_rate_';

	/**
	 * Settings instance.
	 *
	 * @var ChatbotSettings
	 */
	private ChatbotSettings $settings;

	/**
	 * Constructor.
	 *
	 * @param ChatbotSettings|null $settings Settings instance.
	 */
	public function __construct( ?ChatbotSettings $settings = null ) {
		$this->settings = $settings ?? new ChatbotSettings();
	}

	/**
	 * Check if request is allowed under rate limit.
	 *
	 * Implements fixed-window rate limiting by storing window start time in transient.
	 * This avoids extra DB queries while providing accurate retry_after values.
	 *
	 * @param string $session_id Visitor session ID.
	 * @return array{allowed: bool, retry_after: int} Rate limit status.
	 */
	public function check( string $session_id ): array {
		if ( ! $this->isEnabled() ) {
			return [ 'allowed' => true, 'retry_after' => 0 ];
		}

		$key    = $this->buildKey( $session_id );
		$limit  = $this->getMaxRequests();
		$window = $this->getWindowSeconds();

		$data = get_transient( $key );

		if ( false === $data || ! is_array( $data ) ) {
			$window_start = time();
			set_transient( $key, [ 'count' => 1, 'window_start' => $window_start ], $window );
			return [ 'allowed' => true, 'retry_after' => 0 ];
		}

		$count        = isset( $data['count'] ) ? (int) $data['count'] : 0;
		$window_start = isset( $data['window_start'] ) ? (int) $data['window_start'] : time();
		$remaining    = max( 0, ( $window_start + $window ) - time() );

		if ( $remaining <= 0 ) {
			$window_start = time();
			set_transient( $key, [ 'count' => 1, 'window_start' => $window_start ], $window );
			return [ 'allowed' => true, 'retry_after' => 0 ];
		}

		if ( $count >= $limit ) {
			return [
				'allowed'     => false,
				'retry_after' => $remaining,
			];
		}

		set_transient( $key, [ 'count' => $count + 1, 'window_start' => $window_start ], $remaining );

		return [ 'allowed' => true, 'retry_after' => 0 ];
	}

	/**
	 * Check if rate limiting is enabled.
	 *
	 * @return bool True if enabled.
	 */
	public function isEnabled(): bool {
		return (bool) $this->settings->get( 'rate_limit.enabled', true );
	}

	/**
	 * Get maximum requests per window.
	 *
	 * @return int Maximum requests.
	 */
	public function getMaxRequests(): int {
		return (int) $this->settings->get( 'rate_limit.max_requests', 10 );
	}

	/**
	 * Get rate limit window in seconds.
	 *
	 * @return int Window seconds.
	 */
	public function getWindowSeconds(): int {
		return (int) $this->settings->get( 'rate_limit.window_seconds', 60 );
	}

	/**
	 * Build rate limit key from session ID and optional hashed IP.
	 *
	 * @param string $session_id Session identifier.
	 * @return string Transient key.
	 */
	private function buildKey( string $session_id ): string {
		$key_mode = $this->settings->get( 'rate_limit.key_mode', 'session' );
		$key      = $session_id;

		if ( 'session_ip_hashed' === $key_mode ) {
			$ip        = $this->getClientIp();
			$hashed_ip = $this->hashIp( $ip );
			$key      .= '_' . $hashed_ip;
		}

		return self::TRANSIENT_PREFIX . md5( $key );
	}

	/**
	 * Hash IP address using SHA-256 with site-specific salt.
	 *
	 * Never stores or logs the raw IP address.
	 *
	 * @param string $ip IP address to hash.
	 * @return string Hashed IP (64 character hex string).
	 */
	private function hashIp( string $ip ): string {
		return hash( 'sha256', $ip . wp_salt( 'auth' ) );
	}

	/**
	 * Check if request is allowed under IP-based rate limit.
	 *
	 * Provides a secondary rate limit check based on hashed client IP.
	 * This prevents session_id spoofing from bypassing rate limits.
	 *
	 * @param string $client_ip Client IP address.
	 * @return array{allowed: bool, retry_after: int} Rate limit status.
	 */
	public function checkByIp( string $client_ip ): array {
		if ( ! $this->isEnabled() ) {
			return [ 'allowed' => true, 'retry_after' => 0 ];
		}

		$hashed_ip = $this->hashIp( $client_ip );
		$key       = self::TRANSIENT_PREFIX . 'ip_' . $hashed_ip;
		$limit     = $this->getMaxRequests();
		$window    = $this->getWindowSeconds();

		$data = get_transient( $key );

		if ( false === $data || ! is_array( $data ) ) {
			$window_start = time();
			set_transient( $key, [ 'count' => 1, 'window_start' => $window_start ], $window );
			return [ 'allowed' => true, 'retry_after' => 0 ];
		}

		$count        = isset( $data['count'] ) ? (int) $data['count'] : 0;
		$window_start = isset( $data['window_start'] ) ? (int) $data['window_start'] : time();
		$remaining    = max( 0, ( $window_start + $window ) - time() );

		if ( $remaining <= 0 ) {
			$window_start = time();
			set_transient( $key, [ 'count' => 1, 'window_start' => $window_start ], $window );
			return [ 'allowed' => true, 'retry_after' => 0 ];
		}

		if ( $count >= $limit ) {
			return [
				'allowed'     => false,
				'retry_after' => $remaining,
			];
		}

		set_transient( $key, [ 'count' => $count + 1, 'window_start' => $window_start ], $remaining );

		return [ 'allowed' => true, 'retry_after' => 0 ];
	}

	/**
	 * Check if poll request is allowed under rate limit.
	 *
	 * Uses more lenient limits than chat requests since polling is frequent
	 * but lightweight. Defaults to 60 requests per 60 seconds.
	 *
	 * @param string $session_id Visitor session ID.
	 * @return array{allowed: bool, retry_after: int} Rate limit status.
	 */
	public function checkPoll( string $session_id ): array {
		if ( ! $this->isEnabled() ) {
			return [ 'allowed' => true, 'retry_after' => 0 ];
		}

		$key    = self::TRANSIENT_PREFIX . 'poll_' . md5( $session_id );
		$limit  = $this->getPollMaxRequests();
		$window = $this->getPollWindowSeconds();

		$data = get_transient( $key );

		if ( false === $data || ! is_array( $data ) ) {
			$window_start = time();
			set_transient( $key, [ 'count' => 1, 'window_start' => $window_start ], $window );
			return [ 'allowed' => true, 'retry_after' => 0 ];
		}

		$count        = isset( $data['count'] ) ? (int) $data['count'] : 0;
		$window_start = isset( $data['window_start'] ) ? (int) $data['window_start'] : time();
		$remaining    = max( 0, ( $window_start + $window ) - time() );

		if ( $remaining <= 0 ) {
			$window_start = time();
			set_transient( $key, [ 'count' => 1, 'window_start' => $window_start ], $window );
			return [ 'allowed' => true, 'retry_after' => 0 ];
		}

		if ( $count >= $limit ) {
			return [
				'allowed'     => false,
				'retry_after' => $remaining,
			];
		}

		set_transient( $key, [ 'count' => $count + 1, 'window_start' => $window_start ], $remaining );

		return [ 'allowed' => true, 'retry_after' => 0 ];
	}

	/**
	 * Get maximum poll requests per window.
	 *
	 * More lenient than chat requests. Defaults to 60 per minute.
	 *
	 * @return int Maximum poll requests.
	 */
	public function getPollMaxRequests(): int {
		return (int) $this->settings->get( 'rate_limit.poll_max_requests', 60 );
	}

	/**
	 * Get poll rate limit window in seconds.
	 *
	 * @return int Window seconds.
	 */
	public function getPollWindowSeconds(): int {
		return (int) $this->settings->get( 'rate_limit.poll_window_seconds', 60 );
	}

	/**
	 * Get client IP address securely.
	 *
	 * Uses REMOTE_ADDR as the most reliable source by default.
	 * Only trusts proxy headers when the request comes from a known/trusted proxy IP.
	 *
	 * @return string Client IP address.
	 */
	public function getClientIp(): string {

		$remote_addr = '';
		if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
			$remote_addr = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
			if ( ! filter_var( $remote_addr, FILTER_VALIDATE_IP ) ) {
				$remote_addr = '';
			}
		}

		$trusted_proxies  = $this->getTrustedProxyRanges();
		$is_trusted_proxy = $this->isIpInRanges( $remote_addr, $trusted_proxies );

		if ( $is_trusted_proxy ) {

			if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) && ! empty( $_SERVER['HTTP_CF_RAY'] ) ) {
				$ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
				if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
					return $ip;
				}
			}

			if ( ! empty( $_SERVER['HTTP_X_REAL_IP'] ) ) {
				$ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REAL_IP'] ) );
				if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
					return $ip;
				}
			}

			if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
				$ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) );
				if ( strpos( $ip, ',' ) !== false ) {
					$ip = trim( explode( ',', $ip )[0] );
				}
				if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
					return $ip;
				}
			}
		}

		return $remote_addr ?: '0.0.0.0';
	}

	/**
	 * Get trusted proxy IP ranges.
	 *
	 * Returns Cloudflare and localhost IP ranges.
	 * Can be extended via filter for custom proxy configurations.
	 *
	 * @return array Array of CIDR ranges.
	 */
	private function getTrustedProxyRanges(): array {
		$default_ranges = [

			'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',

			'127.0.0.0/8',
			'10.0.0.0/8',
			'172.16.0.0/12',
			'192.168.0.0/16',
		];

		/**
		 * Filter trusted proxy IP ranges.
		 *
		 * @since 2.5.0
		 * @param array $ranges Array of CIDR ranges.
		 */
		return apply_filters( 'aipt_chatbot_trusted_proxy_ranges', $default_ranges );
	}

	/**
	 * Check if an IP is within any of the given CIDR ranges.
	 *
	 * @param string $ip     IP address to check.
	 * @param array  $ranges Array of CIDR ranges.
	 * @return bool True if IP is in any range.
	 */
	private function isIpInRanges( string $ip, array $ranges ): bool {
		if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
			return false;
		}

		$ip_long = ip2long( $ip );
		if ( false === $ip_long ) {
			return false;
		}

		foreach ( $ranges as $range ) {
			if ( strpos( $range, '/' ) === false ) {

				if ( $ip === $range ) {
					return true;
				}
				continue;
			}

			list( $range_ip, $mask ) = explode( '/', $range, 2 );
			$range_long = ip2long( $range_ip );
			if ( false === $range_long ) {
				continue;
			}

			$mask      = (int) $mask;
			$mask_long = -1 << ( 32 - $mask );

			if ( ( $ip_long & $mask_long ) === ( $range_long & $mask_long ) ) {
				return true;
			}
		}

		return false;
	}
}
