<?php
/**
 * ConsentLogs class
 *
 * Handles installed products related REST API endpoints for the SureCookie plugin.
 *
 * @package SureCookie\Inc\API
 */

namespace SureCookie\Inc\API;

use SureCookie\Inc\Database\ConsentLog as DBConsentLog;
use SureCookie\Inc\Functions\Sanitize;
use SureCookie\Inc\Functions\SendJson;
use SureCookie\Inc\Functions\Settings;
use SureCookie\Inc\Functions\Validate;
use SureCookie\Inc\Traits\GetInstance;
use SureCookie\Inc\Traits\IpManager;
use SureCookie\Inc\Utils\LocalizeData;
use WP_Error;
use WP_REST_Server;

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

/**
 * Class ConsentLogs
 *
 * Handles this related REST API endpoints.
 */
class ConsentLogs extends Base {
	use GetInstance, IpManager;

	/**
	 * Route Get Logs
	 */
	protected const GET_LOGS = '/consent-logs/get-logs';

	/**
	 * Mark logs viewed route.
	 */
	protected const MARK_LOGS_VIEWED = '/consent-logs/mark-logs-viewed';

	/**
	 * Route Create a Log
	 */
	protected const CREATE_LOG = '/consent-logs/create-log';

	/**
	 * Route generate a pdf
	 */
	protected const GENERATE_PDF = '/consent-logs/generate-pdf';

	/**
	 * Route get unique countries
	 */
	protected const UNIQUE_COUNTRIES = '/consent-logs/unique-countries';

	/**
	 * Delete a log
	 */
	protected const DELETE_LOG = '/consent-logs/delete-log';

	/**
	 * Delete all log
	 */
	protected const DELETE_ALL_LOG = '/consent-logs/delete-all-logs';

	/**
	 * Maximum allowed size for the JSON-encoded preferences payload.
	 */
	private const MAX_PREFERENCES_LENGTH = 4096;

	/**
	 * Maximum consent log submissions allowed per IP in one window.
	 */
	private const RATE_LIMIT_MAX_ATTEMPTS = 10;

	/**
	 * Fixed window length (in seconds) for consent log rate limiting.
	 */
	private const RATE_LIMIT_WINDOW_SECONDS = 300;

	/**
	 * Number of transient shards used for rate limit counters.
	 *
	 * Using shard buckets keeps transient row count bounded (constant) instead of
	 * creating one transient key per IP address.
	 */
	private const RATE_LIMIT_SHARDS = 64;

	/**
	 * Transient key prefix for fixed-window consent log rate limiting.
	 */
	private const RATE_LIMIT_TRANSIENT_PREFIX = 'surecookie_consent_rate_';

	/**
	 * Register API routes.
	 *
	 * @since 0.0.1
	 * @return void
	 */
	public function register_routes(): void {
		register_rest_route(
			$this->get_api_namespace(),
			self::GET_LOGS,
			[
				'methods'             => WP_REST_Server::READABLE, // GET method.
				'callback'            => [ $this, 'get_consent_logs' ],
				'permission_callback' => [ $this, 'validate_permission' ],
				'args'                => [
					'page'       => [
						'required'          => false,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'limit'      => [
						'required'          => false,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'search'     => [
						'required'          => false,
						'type'              => 'string',
						'description'       => __( 'Search by IP address or Session ID', 'surecookie' ),
						'sanitize_callback' => 'sanitize_text_field',
					],
					'log_action' => [
						'required'          => false,
						'type'              => 'string',
						'enum'              => [ '', 'accepted', 'declined', 'partially_accepted', 'received', 'shared' ],
						'sanitize_callback' => 'sanitize_text_field',
					],
					'country'    => [
						'required'          => false,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					],
				],
			]
		);

		register_rest_route(
			$this->get_api_namespace(),
			self::MARK_LOGS_VIEWED,
			[
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'mark_logs_viewed' ],
				'permission_callback' => [ $this, 'validate_permission' ],
			]
		);

		register_rest_route(
			$this->get_api_namespace(),
			self::CREATE_LOG,
			[
				'methods'             => WP_REST_Server::CREATABLE, // POST method.
				'callback'            => [ $this, 'update_consent_log' ],
				'permission_callback' => [ $this, 'validate_origin' ],
				'args'                => [
					'preferences'       => [
						'required' => true,
						'type'     => 'string',
					],
					'action_type'       => [
						'required' => true,
						'type'     => 'string',
					],
					'session_id'        => [
						'required'          => true,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'consent_log_token' => [
						'required'          => false,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					],
				],
			]
		);

		register_rest_route(
			$this->get_api_namespace(),
			self::GENERATE_PDF,
			[
				'methods'             => WP_REST_Server::READABLE, // Admin -- GET method.
				'callback'            => [ $this, 'get_pdf_prerequisites' ],
				'permission_callback' => [ $this, 'validate_permission' ],
				'args'                => [
					'log_id' => [
						'required'          => true,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
				],
			]
		);

		register_rest_route(
			$this->get_api_namespace(),
			self::UNIQUE_COUNTRIES,
			[
				'methods'             => WP_REST_Server::READABLE, // Admin -- GET method.
				'callback'            => [ $this, 'get_unique_countries' ],
				'permission_callback' => [ $this, 'validate_permission' ],
			]
		);

		register_rest_route(
			$this->get_api_namespace(),
			self::DELETE_LOG,
			[
				'methods'             => WP_REST_Server::DELETABLE, // Admin -- DELETE method.
				'callback'            => [ $this, 'delete_consent_log' ],
				'permission_callback' => [ $this, 'validate_permission' ],
				'args'                => [
					'log_id' => [
						'required' => true,
						'oneOf'    => [
							[
								'type' => 'integer',
							],
							[
								'type'     => 'array',
								'items'    => [
									'type' => 'integer',
								],
								'maxItems' => 500,
							],
						],
					],
				],
			]
		);

		register_rest_route(
			$this->get_api_namespace(),
			self::DELETE_ALL_LOG,
			[
				'methods'             => WP_REST_Server::DELETABLE, // Admin -- GET method.
				'callback'            => [ $this, 'delete_all_consent_logs' ],
				'permission_callback' => [ $this, 'validate_permission' ],
			]
		);
	}

	/**
	 * Validate request origin for public consent logging endpoint.
	 *
	 * Requires a valid consent_log_token on every request, and additionally
	 * enforces same-origin when Referer or Origin is present. The token gate
	 * defends against spoofed-header forgery; the host check defends against
	 * any token leaked via cached HTML being replayed cross-origin.
	 *
	 * @param \WP_REST_Request $request Request object.
	 * @since 0.0.0-alpha.1
	 * @return bool|WP_Error True when valid, error on invalid origin.
	 */
	public function validate_origin( $request ) {
		$token = sanitize_text_field( (string) $request->get_param( 'consent_log_token' ) );
		if ( ! LocalizeData::is_valid_consent_log_token( $token ) ) {
			return new WP_Error(
				'surecookie_invalid_token',
				__( 'Request origin could not be verified.', 'surecookie' ),
				[ 'status' => 403 ]
			);
		}

		$invalid_origin_error = static function () {
			return new WP_Error(
				'surecookie_invalid_origin',
				__( 'Request origin is not allowed.', 'surecookie' ),
				[ 'status' => 403 ]
			);
		};

		$referer = $request->get_header( 'referer' );
		$origin  = $request->get_header( 'origin' );

		$request_url = '';
		if ( ! empty( $referer ) && is_string( $referer ) ) {
			$request_url = $referer;
		} elseif ( ! empty( $origin ) && is_string( $origin ) ) {
			$request_url = $origin;
		}

		// No headers — token already validated above is sufficient.
		if ( $request_url === '' ) {
			return true;
		}

		$site_url  = get_site_url();
		$site_host = wp_parse_url( $site_url, PHP_URL_HOST );
		$site_port = wp_parse_url( $site_url, PHP_URL_PORT );
		$site_ssl  = wp_parse_url( $site_url, PHP_URL_SCHEME );

		$request_host = wp_parse_url( $request_url, PHP_URL_HOST );
		$request_port = wp_parse_url( $request_url, PHP_URL_PORT );
		$request_ssl  = wp_parse_url( $request_url, PHP_URL_SCHEME );

		// Reject malformed URLs or cross-origin hosts.
		if ( empty( $site_host ) || empty( $request_host ) ) {
			return $invalid_origin_error();
		}

		if ( strtolower( $site_host ) !== strtolower( $request_host ) ) {
			return $invalid_origin_error();
		}

		// If request has explicit scheme, enforce same scheme.
		if ( ! empty( $site_ssl ) && ! empty( $request_ssl ) && strtolower( $site_ssl ) !== strtolower( $request_ssl ) ) {
			return $invalid_origin_error();
		}

		// If either side has explicit port, enforce same port.
		if ( ( ! empty( $site_port ) || ! empty( $request_port ) ) && (int) $site_port !== (int) $request_port ) {
			return $invalid_origin_error();
		}

		// Same origin.
		return true;
	}

	/**
	 * Mark the latest consent log as viewed for the current admin user.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
	 * @since 1.2.0
	 * @return void
	 */
	public function mark_logs_viewed( $request ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required REST callback signature.
		$user_id = get_current_user_id();

		if ( ! $user_id ) {
			SendJson::error(
				[
					'message' => __( 'Unable to determine the current user.', 'surecookie' ),
				],
				403
			);
			return;
		}

		DBConsentLog::mark_logs_viewed_for_user( $user_id );

		SendJson::success(
			[
				'message'        => __( 'Consent logs marked as viewed.', 'surecookie' ),
				'last_viewed_id' => DBConsentLog::get_last_viewed_log_id_for_user( $user_id ),
			]
		);
	}

	/**
	 * Get admin settings
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
	 * @since 0.0.1
	 * @return void
	 */
	public function get_consent_logs( $request ): void {
		$limit   = max( 1, (int) ( $request->get_param( 'limit' ) ?? 10 ) );
		$page    = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) );
		$search  = $request->get_param( 'search' ) ?? '';
		$action  = $request->get_param( 'log_action' ) ?? '';
		$country = $request->get_param( 'country' ) ?? '';

		// IP addresses are anonymized before storage (last octet zeroed for IPv4).
		// If the search term is a valid IP, anonymize it so it matches the stored value.
		if ( ! empty( $search ) && filter_var( $search, FILTER_VALIDATE_IP ) ) {
			$search = self::anonymize_ip( $search );
		}

		$offset = ( $page - 1 ) * $limit;

		$logs   = DBConsentLog::get_filtered( $search, $action, $country, $limit, $offset );
		$counts = DBConsentLog::count_all_actions( $search, $country, $action );

		SendJson::success(
			[
				'message' => __( 'Consent logs retrieved successfully.', 'surecookie' ),
				'data'    => [
					'logs'  => array_map(
						static function ( $log ) {
							$log['country']      = $log['country'] ?? '';
							$log['is_forwarded'] = (int) ( $log['is_forwarded'] ?? 0 );
							$log['origin_site']  = Sanitize::text( $log['origin_site'] ?? '' );
							$forwarded_to        = json_decode( (string) ( $log['forwarded_to'] ?? '' ), true );
							$log['forwarded_to'] = is_array( $forwarded_to ) ? array_map( 'esc_url_raw', array_values( array_filter( $forwarded_to, 'is_string' ) ) ) : [];
							unset( $log['session_id'] );
							return $log;
						},
						$logs
					),
					'count' => [
						'total'          => $counts['total'],
						'total_accepted' => $counts['accepted'],
						'total_declined' => $counts['declined'],
						'total_partial'  => $counts['partially_accepted'],
					],
				],
			]
		);
	}

	/**
	 * Update admin settings
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
	 * @since 0.0.1
	 * @return void
	 */
	public function update_consent_log( $request ): void {
		$consent_logging_enabled = Settings::get( 'consent_logging_enabled' );
		if ( ! $consent_logging_enabled ) {
			SendJson::error(
				[
					'message' => __( 'Consent processed successfully (logging disabled)', 'surecookie' ),
				]
			);
			return;
		}

		// Security: Validate request origin to prevent cross-site consent submission.
		$origin_check = $this->validate_origin( $request );
		if ( is_wp_error( $origin_check ) ) {
			SendJson::error(
				[
					'message' => $origin_check->get_error_message(),
					'code'    => 'invalid_origin',
				],
				403
			);
			return;
		}

		$raw_ip = self::get_client_ip();
		if ( $this->is_rate_limited( $raw_ip ) ) {
			SendJson::error(
				[
					'message' => __( 'Too many requests. Please try again later.', 'surecookie' ),
					'code'    => 'too_many_requests',
				],
				429
			);
			return;
		}

		// Get preferences and action type.
		$preferences = $request->get_param( 'preferences' );
		$preferences = is_string( $preferences ) ? wp_unslash( $preferences ) : '';
		$action_type = $request->get_param( 'action_type' );
		$action_type = ! empty( $action_type ) ? sanitize_text_field( wp_unslash( $action_type ) ) : 'partially_accepted';

		$session_id = $request->get_param( 'session_id' );
		$session_id = ! empty( $session_id ) ? sanitize_text_field( wp_unslash( $session_id ) ) : '';

		if ( Validate::empty_string( $preferences ) ) {
			SendJson::error(
				[
					'message' => __( 'Preferences should not be empty.', 'surecookie' ),
				]
			);
			return;
		}

		if ( strlen( $preferences ) > self::MAX_PREFERENCES_LENGTH ) {
			SendJson::error(
				[
					'message' => __( 'Preferences data is too large.', 'surecookie' ),
				]
			);
			return;
		}

		$preferences = json_decode( $preferences, true );

		if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $preferences ) ) {
			SendJson::error(
				[
					'message' => __( 'Invalid preferences format.', 'surecookie' ),
				]
			);
			return;
		}

		$preferences = wp_json_encode( $preferences );

		if ( ! is_string( $preferences ) || $preferences === '' ) {
			SendJson::error(
				[
					'message' => __( 'Invalid preferences format.', 'surecookie' ),
				]
			);
			return;
		}

		if ( empty( $session_id ) ) {
			SendJson::error(
				[
					'message' => __( 'Session ID is required.', 'surecookie' ),
				]
			);
			return;
		}

		// Get current user ID (if logged in).
		$user_id = get_current_user_id();

		// Validate action type against ENUM values.
		$allowed_actions = [ 'accepted', 'declined', 'partially_accepted' ];
		if ( ! in_array( $action_type, $allowed_actions, true ) ) {
			// Default to partially_accepted for invalid values.
			$action_type = 'partially_accepted';
		}

		// Reject an 'accepted' log when Accept All is disabled for this site or region.
		// The filter lets Pro apply per-region stricter-wins (global AND region); the
		// default value is the global setting alone.
		if ( $action_type === 'accepted' ) {
			$accept_all_allowed = (bool) Settings::get( 'accept_all_enabled' );
			/**
			 * Filter whether the Accept All action is permitted for the current request.
			 *
			 * Pro (Geographic Targeting) narrows this per region. Free plugin defaults
			 * to the global toggle. Never broaden the value — only narrow.
			 *
			 * @param bool             $accept_all_allowed Effective Accept All state.
			 * @param \WP_REST_Request $request            The inbound REST request.
			 */
			$accept_all_allowed = (bool) apply_filters(
				'surecookie_accept_all_effective_enabled',
				$accept_all_allowed,
				$request
			);

			if ( ! $accept_all_allowed ) {
				SendJson::error(
					[
						'message' => __( 'Accept All is not enabled for this site.', 'surecookie' ),
						'code'    => 'surecookie_accept_all_disabled',
					],
					422
				);
				return;
			}
		}

		// Get country name from raw IP for geolocation accuracy, then anonymize before storage.
		$country    = self::get_country_name_from_ip( $raw_ip );
		$ip_address = self::anonymize_ip( $raw_ip );

		$result = DBConsentLog::upsert( $ip_address, $user_id, $session_id, $preferences, $action_type, $country );

		if ( $result ) {
			// Gather consent log counts for analytics.
			$existing_count = Settings::get( 'total_logs' );
			$new_count      = $existing_count + 1;
			Settings::update( 'total_logs', $new_count );
			SendJson::success(
				[
					'message'    => __( 'Consent saved successfully.', 'surecookie' ),
					'session_id' => $session_id,
					'action'     => $action_type,
				]
			);
			return;
		}

		SendJson::error(
			[
				'message' => __( 'Failed to save consent.', 'surecookie' ),
			]
		);
	}

	/**
	 * Get PDF prerequisites - returns all logs for the session of the given log ID.
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
	 * @since 0.0.1
	 * @return void
	 */
	public function get_pdf_prerequisites( $request ): void {
		$log_id = absint( $request->get_param( 'log_id' ) );

		if ( ! $log_id ) {
			SendJson::error(
				[
					'message' => __( 'Invalid log ID', 'surecookie' ),
				]
			);
			return;
		}

		// Fetch the requested log to get its session_id.
		$log = DBConsentLog::get_by_log_id( $log_id );

		if ( ! $log ) {
			SendJson::error(
				[
					'message' => __( 'Log not found.', 'surecookie' ),
				]
			);
			return;
		}

		$session_id = Sanitize::text( $log['session_id'] ?? '' );

		// Fetch all logs for this session.
		$session_logs = ! empty( $session_id ) ? DBConsentLog::get_by_session_id( $session_id ) : [ $log ];

		$sanitized_logs = array_map(
			static function ( $entry ) {
				$preferences  = json_decode( $entry['preferences'] ?? '', true );
				$forwarded_to = json_decode( (string) ( $entry['forwarded_to'] ?? '' ), true );

				return [
					'id'            => Sanitize::integer( $entry['id'] ?? '' ),
					'ip_address'    => Sanitize::text( $entry['ip_address'] ?? '' ),
					'timestamp'     => Sanitize::text( $entry['timestamp'] ?? '' ),
					'country'       => Sanitize::text( $entry['country'] ?? '' ),
					'action'        => Sanitize::text( $entry['action'] ?? '' ),
					'preferences'   => is_array( $preferences ) ? $preferences : [],
					'session_id'    => Sanitize::text( $entry['session_id'] ?? '' ),
					'geo_preset_id' => Sanitize::text( $entry['geo_preset_id'] ?? '' ),
					// Consent-forwarding fields — needed by the JS PDF generator.
					'is_forwarded'  => (int) ( $entry['is_forwarded'] ?? 0 ),
					'origin_site'   => Sanitize::text( $entry['origin_site'] ?? '' ),
					'forwarded_to'  => is_array( $forwarded_to ) ? array_map( 'esc_url_raw', array_values( array_filter( $forwarded_to, 'is_string' ) ) ) : [],
				];
			},
			$session_logs
		);

		SendJson::success(
			[
				'message'    => __( 'Log details retrieved successfully.', 'surecookie' ),
				'session_id' => $session_id,
				'logs'       => $sanitized_logs,
			]
		);
	}

	/**
	 * Get unique countries
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
	 * @since 0.0.1
	 * @return void
	 */
	public function get_unique_countries( $request ): void {
		global $wpdb;
		$table_name = $wpdb->prefix . SURECOOKIE_CONSENT_LOG_DB;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnsupportedPlaceholder, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Admin-only filter list; %i is a valid WP 6.2+ identifier placeholder.
		$countries = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT country FROM %i WHERE country IS NOT NULL AND country != %s', $table_name, '' ) );

		$countries = is_array( $countries )
			? array_values( array_unique( array_map( 'sanitize_text_field', $countries ) ) )
			: [];

		SendJson::success(
			[
				'message'   => __( 'Log details retrieved successfully.', 'surecookie' ),
				'countries' => $countries,
			]
		);
	}

	/**
	 * Delete a log or multiple logs
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
	 * @since 0.0.1
	 * @return void
	 */
	public function delete_consent_log( $request ): void {
		$log_id = $request->get_param( 'log_id' );

		// Handle both single ID and array of IDs.
		if ( is_array( $log_id ) ) {
			// Bulk delete.
			if ( empty( $log_id ) ) {
				SendJson::error(
					[
						'message' => __( 'Invalid log IDs', 'surecookie' ),
					]
				);
				return;
			}

			$result = DBConsentLog::delete_by_log_ids( $log_id );

			if ( $result === false ) {
				SendJson::error(
					[
						'message' => __( 'Failed to delete logs.', 'surecookie' ),
					]
				);
				return;
			}

			SendJson::success(
				[
					'message' => sprintf(
						/* translators: %d: number of logs deleted */
						__( '%d log(s) deleted successfully.', 'surecookie' ),
						$result
					),
					'deleted' => $result,
				]
			);
			return;
		}

		// Single delete.
		$log_id = absint( $log_id );
		if ( ! $log_id ) {
			SendJson::error(
				[
					'message' => __( 'Invalid log ID', 'surecookie' ),
				]
			);
			return;
		}

		$result = DBConsentLog::delete_by_log_id( $log_id );

		if ( $result ) {
			SendJson::success(
				[
					'message' => __( 'Log deleted successfully.', 'surecookie' ),
					'deleted' => $result,
				]
			);
			return;
		}

		SendJson::error(
			[
				'message' => __( 'Failed to delete log.', 'surecookie' ),
			]
		);
	}

	/**
	 * Delete all logs
	 *
	 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
	 * @since 0.0.1
	 * @return void
	 */
	public function delete_all_consent_logs( $request ): void {
		global $wpdb;
		$table = $wpdb->prefix . SURECOOKIE_CONSENT_LOG_DB;

		// Pre-fetch origin rows that were forwarded to partner subsites so the
		// cascade hook can erase forwarded copies *after* the local wipe succeeds.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnsupportedPlaceholder, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- One-shot admin delete; %i is a valid WP 6.2+ identifier placeholder.
		$rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, session_id, forwarded_to, is_forwarded FROM %i WHERE is_forwarded = 0 AND forwarded_to IS NOT NULL AND forwarded_to != %s AND forwarded_to != %s', $table, '', '[]' ), ARRAY_A );

		$ids  = [];
		$logs = [];
		if ( is_array( $rows ) ) {
			foreach ( $rows as $row ) {
				$id          = (int) $row['id'];
				$ids[]       = $id;
				$logs[ $id ] = $row;
			}
		}

		$result = DBConsentLog::get_instance()->use_delete_all();

		if ( $result && ! empty( $ids ) ) {
			// Fire AFTER the local wipe succeeds so partners never erase ahead of origin.
			do_action( 'surecookie_consent_log_deleted_bulk', $ids, $logs );
		}

		// 0 rows (already-empty table) is a success, not a failure.
		if ( $result !== false ) {
			SendJson::success(
				[
					'message' => __( 'All logs deleted successfully.', 'surecookie' ),
					'deleted' => (int) $result,
				]
			);
			return;
		}

		SendJson::error(
			[
				'message' => __( 'Failed to delete logs.', 'surecookie' ),
			]
		);
	}

	/**
	 * Check and increment per-IP consent log submission counters.
	 *
	 * Fixed-window + 64-shard transient strategy: bounds wp_options row count
	 * (≤128 active rows across two overlapping windows) regardless of IP cardinality.
	 * Race note: get/set is non-atomic so concurrent bursts may exceed the limit
	 * by a handful of requests — acceptable for an abuse-prevention control.
	 *
	 * @param string $raw_ip Client IP address.
	 * @return bool True when the request should be blocked.
	 * @since 0.0.1-beta.3
	 */
	private function is_rate_limited( string $raw_ip ): bool {
		$ip_hash      = wp_hash( $raw_ip );
		$window_index = (int) floor( time() / self::RATE_LIMIT_WINDOW_SECONDS );
		// crc32() can return signed ints; cast via unsigned string for stable,
		// non-negative shard assignment across 32-bit/64-bit PHP runtimes.
		$shard       = (int) ( sprintf( '%u', crc32( $ip_hash ) ) % self::RATE_LIMIT_SHARDS );
		$transient   = self::RATE_LIMIT_TRANSIENT_PREFIX . $window_index . '_' . $shard;
		$window_data = get_transient( $transient );
		$window_data = is_array( $window_data ) ? $window_data : [];

		$attempts = isset( $window_data[ $ip_hash ] ) ? (int) $window_data[ $ip_hash ] : 0;
		if ( $attempts >= self::RATE_LIMIT_MAX_ATTEMPTS ) {
			return true;
		}

		$window_data[ $ip_hash ] = $attempts + 1;
		// Keep bucket briefly past the window edge to avoid boundary races between
		// near-simultaneous requests that straddle expiration.
		set_transient( $transient, $window_data, self::RATE_LIMIT_WINDOW_SECONDS + MINUTE_IN_SECONDS );

		return false;
	}
}
