<?php
/**
 * API Rate Limiter for the `seovaultai/v1` REST namespace.
 *
 * @package SEOvaultSEO\Api
 */

namespace SEOvaultSEO\Api;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Pre-auth and post-auth rate limiting for the `seovaultai/v1` REST
 * namespace (Program B). SEC-013 defined default limit constants, the
 * filterable limits map, and the exemption filter hook. SEC-014 adds
 * OPTIONS preflight detection and a shared early-return gate so CORS
 * preflight is never rate-limited. SEC-015 adds the pre-auth failed-key
 * counter per IP (`record_auth_failure()`), returning
 * `seovaultai_rate_limited` / 429 with `Retry-After` when the
 * filterable `auth_fail_ip` budget is exceeded. SEC-016 adds the
 * generous global per-IP abuse throttle (`check_global_ip()` /
 * `global_ip`, default 300/min) for all namespace requests including
 * authenticated ones. SEC-017 adds post-auth route bucket classification
 * via {@see get_route_bucket()} (`read` / `write` / `import` / `media`).
 * SEC-018 adds per key-hash bucket enforcement ({@see check_bucket()},
 * transient `seovault_rl_bucket_{key_hash}_{bucket}`). SEC-019/020 wire
 * pre-auth and post-auth checks into `Router::check_site_key()`.
 * SEC-021 exempts same-origin WP-admin SEO import (`/seo/import`) via
 * {@see is_same_origin_admin_request()} and the built-in
 * {@see EXEMPT_FILTER} callback. SEC-023 adds failed-auth lockout:
 * warning audit at 10 fails / 15 min, hard lockout at 20 fails / 15 min
 * (`seovault_rl_lockout_{ip_hash}`, error `seovaultai_auth_lockout`).
 *
 * ## Exemption rules (SEC-021)
 *
 * Rate limiting is skipped when {@see should_rate_limit()} is false:
 * 1. HTTP `OPTIONS` preflight (always).
 * 2. Any callback on {@see EXEMPT_FILTER} returning true.
 *
 * The plugin registers a default {@see EXEMPT_FILTER} callback that
 * returns true only when **both** are true:
 * - The route bucket is `import` (`/seo/import`, not `/seo/import/summary`)
 * - {@see is_same_origin_admin_request()} is true
 *
 * {@see is_same_origin_admin_request()} is true when:
 * - `WP_CLI` or `DOING_CRON` (server-side), or
 * - The current user can `manage_options` **and** either:
 *   - `Origin` or `Referer` matches {@see home_url()} host, or
 *   - Neither `Origin` nor `Referer` is present (cookie/admin same-site
 *     request without CORS headers)
 *
 * External clients (no `manage_options` session) are never exempted by
 * the built-in callback, even on `/seo/import`.
 *
 * Filters:
 * - {@see LIMITS_FILTER} (`seovaultai_api_rate_limits`) — override the
 *   full limits config array (keys → `{ limit, window }` maps).
 * - {@see EXEMPT_FILTER} (`seovaultai_api_rate_limit_exempt`) — return
 *   true to skip rate limiting for the current request (e.g. same-origin
 *   admin bulk import; wired in SEC-021).
 *
 * Default values are intentionally generous so normal SEOvault web-app
 * and admin traffic is unaffected; they only block obvious abuse. See
 * Appendix A of the security-upgrade spec.
 */
class RateLimiter {

	/**
	 * Filter used to override the full rate-limits config array.
	 * Callbacks receive the default limits map and must return an array
	 * keyed by limit name (`auth_fail_ip`, `global_ip`, `read`, `write`,
	 * etc.), each value an array with `limit` (int) and `window` (int
	 * seconds).
	 */
	const LIMITS_FILTER = 'seovaultai_api_rate_limits';

	/**
	 * Filter used to exempt the current request from rate limiting.
	 * Default is `false`. Callbacks receive the optional
	 * `\WP_REST_Request` (or `null` when called without one) and should
	 * return a bool. The plugin registers {@see filter_exempt_admin_import()}
	 * in {@see init()} (SEC-021).
	 */
	const EXEMPT_FILTER = 'seovaultai_api_rate_limit_exempt';

	/**
	 * Register built-in exemption hooks (SEC-021).
	 *
	 * Called from `Router::init()`.
	 *
	 * @return void
	 */
	public static function init() {
		add_filter( self::EXEMPT_FILTER, array( __CLASS__, 'filter_exempt_admin_import' ), 10, 2 );
	}

	/**
	 * Default: max failed-key attempts per IP before 429 (SEC-015).
	 * Window: {@see DEFAULT_AUTH_FAIL_IP_WINDOW}.
	 */
	const DEFAULT_AUTH_FAIL_IP_LIMIT = 20;

	/**
	 * Default window (seconds) for {@see DEFAULT_AUTH_FAIL_IP_LIMIT}.
	 * 15 minutes.
	 */
	const DEFAULT_AUTH_FAIL_IP_WINDOW = 900;

	/**
	 * Default: max requests per IP across the namespace before 429
	 * (SEC-016). Very high — only blocks obvious abuse.
	 * Window: {@see DEFAULT_GLOBAL_IP_WINDOW}.
	 */
	const DEFAULT_GLOBAL_IP_LIMIT = 300;

	/**
	 * Default window (seconds) for {@see DEFAULT_GLOBAL_IP_LIMIT}.
	 * 1 minute.
	 */
	const DEFAULT_GLOBAL_IP_WINDOW = 60;

	/**
	 * Default: max authenticated read requests per key-hash per window
	 * (SEC-018). Window: {@see DEFAULT_READ_WINDOW}.
	 */
	const DEFAULT_READ_LIMIT = 120;

	/**
	 * Default window (seconds) for {@see DEFAULT_READ_LIMIT}.
	 * 1 minute.
	 */
	const DEFAULT_READ_WINDOW = 60;

	/**
	 * Default: max authenticated write requests per key-hash per window
	 * (SEC-018). Window: {@see DEFAULT_WRITE_WINDOW}.
	 */
	const DEFAULT_WRITE_LIMIT = 40;

	/**
	 * Default window (seconds) for {@see DEFAULT_WRITE_LIMIT}.
	 * 1 minute.
	 */
	const DEFAULT_WRITE_WINDOW = 60;

	/**
	 * Default: max authenticated import requests per key-hash per window
	 * (SEC-018). Window: {@see DEFAULT_IMPORT_WINDOW}.
	 */
	const DEFAULT_IMPORT_LIMIT = 10;

	/**
	 * Default window (seconds) for {@see DEFAULT_IMPORT_LIMIT}.
	 * 1 minute.
	 */
	const DEFAULT_IMPORT_WINDOW = 60;

	/**
	 * Default: max authenticated media-upload requests per key-hash per
	 * window (SEC-018). Window: {@see DEFAULT_MEDIA_WINDOW}.
	 */
	const DEFAULT_MEDIA_LIMIT = 20;

	/**
	 * Default window (seconds) for {@see DEFAULT_MEDIA_LIMIT}.
	 * 1 minute.
	 */
	const DEFAULT_MEDIA_WINDOW = 60;

	/**
	 * Default: failed-auth count that triggers a lockout *warning* audit
	 * (SEC-023). Window: {@see DEFAULT_AUTH_LOCKOUT_WARNING_WINDOW}.
	 */
	const DEFAULT_AUTH_LOCKOUT_WARNING_LIMIT = 10;

	/**
	 * Default window (seconds) for the lockout-warning threshold.
	 * 15 minutes (same sliding window as auth-fail / lockout).
	 */
	const DEFAULT_AUTH_LOCKOUT_WARNING_WINDOW = 900;

	/**
	 * Default: failed-auth count that triggers a hard lockout (Program C /
	 * SEC-023). Window: {@see DEFAULT_AUTH_LOCKOUT_IP_WINDOW} (lockout duration).
	 */
	const DEFAULT_AUTH_LOCKOUT_IP_LIMIT = 20;

	/**
	 * Default lockout duration (seconds) for {@see DEFAULT_AUTH_LOCKOUT_IP_LIMIT}.
	 * 15 minutes.
	 */
	const DEFAULT_AUTH_LOCKOUT_IP_WINDOW = 900;

	/**
	 * Limit-config key: failed site-key attempts per IP.
	 */
	const KEY_AUTH_FAIL_IP = 'auth_fail_ip';

	/**
	 * Limit-config key: all namespace requests per IP (pre-auth abuse throttle).
	 */
	const KEY_GLOBAL_IP = 'global_ip';

	/**
	 * Limit-config key: authenticated reads per key-hash.
	 */
	const KEY_READ = 'read';

	/**
	 * Limit-config key: authenticated writes per key-hash.
	 */
	const KEY_WRITE = 'write';

	/**
	 * Limit-config key: authenticated SEO import per key-hash.
	 */
	const KEY_IMPORT = 'import';

	/**
	 * Limit-config key: authenticated media upload per key-hash.
	 */
	const KEY_MEDIA = 'media';

	/**
	 * Limit-config key: failed-auth count that logs a lockout warning (SEC-023).
	 */
	const KEY_AUTH_LOCKOUT_WARNING = 'auth_lockout_warning';

	/**
	 * Limit-config key: failed-auth lockout threshold / duration per IP.
	 */
	const KEY_AUTH_LOCKOUT_IP = 'auth_lockout_ip';

	/**
	 * Transient key prefix for the per-IP failed-auth counter (SEC-015).
	 * Full key: `{prefix}{ip_hash}` — never includes the raw IP.
	 */
	const TRANSIENT_AUTH_FAIL_PREFIX = 'seovault_rl_auth_fail_';

	/**
	 * Transient key prefix for the per-IP global request counter (SEC-016).
	 * Full key: `{prefix}{ip_hash}` — never includes the raw IP.
	 */
	const TRANSIENT_GLOBAL_IP_PREFIX = 'seovault_rl_global_ip_';

	/**
	 * Transient key prefix for post-auth per key-hash + bucket counters
	 * (SEC-018). Full key: `{prefix}{key_hash}_{bucket}` — never includes
	 * the raw site key.
	 */
	const TRANSIENT_BUCKET_PREFIX = 'seovault_rl_bucket_';

	/**
	 * Transient key prefix for the per-IP hard lockout flag (SEC-023).
	 * Full key: `{prefix}{ip_hash}` — never includes the raw IP.
	 */
	const TRANSIENT_LOCKOUT_PREFIX = 'seovault_rl_lockout_';

	/**
	 * Transient key prefix for the one-shot lockout-warning flag (SEC-023).
	 * Full key: `{prefix}{ip_hash}` — never includes the raw IP.
	 */
	const TRANSIENT_LOCKOUT_WARNING_PREFIX = 'seovault_rl_lockout_warn_';

	/**
	 * WP_Error code returned when a rate limit is exceeded.
	 */
	const ERROR_CODE_RATE_LIMITED = 'seovaultai_rate_limited';

	/**
	 * WP_Error code returned when the IP is under a failed-auth lockout (SEC-023).
	 */
	const ERROR_CODE_AUTH_LOCKOUT = 'seovaultai_auth_lockout';

	/**
	 * Build the default (unfiltered) limits map.
	 *
	 * Each entry is `array( 'limit' => int, 'window' => int )` where
	 * `window` is in seconds. Values are generous by design — see class
	 * docblock and Appendix A of the security-upgrade spec.
	 *
	 * @return array<string, array{limit: int, window: int}>
	 */
	public static function get_default_limits(): array {
		return array(
			self::KEY_AUTH_FAIL_IP         => array(
				'limit'  => self::DEFAULT_AUTH_FAIL_IP_LIMIT,
				'window' => self::DEFAULT_AUTH_FAIL_IP_WINDOW,
			),
			self::KEY_GLOBAL_IP            => array(
				'limit'  => self::DEFAULT_GLOBAL_IP_LIMIT,
				'window' => self::DEFAULT_GLOBAL_IP_WINDOW,
			),
			self::KEY_READ                 => array(
				'limit'  => self::DEFAULT_READ_LIMIT,
				'window' => self::DEFAULT_READ_WINDOW,
			),
			self::KEY_WRITE                => array(
				'limit'  => self::DEFAULT_WRITE_LIMIT,
				'window' => self::DEFAULT_WRITE_WINDOW,
			),
			self::KEY_IMPORT               => array(
				'limit'  => self::DEFAULT_IMPORT_LIMIT,
				'window' => self::DEFAULT_IMPORT_WINDOW,
			),
			self::KEY_MEDIA                => array(
				'limit'  => self::DEFAULT_MEDIA_LIMIT,
				'window' => self::DEFAULT_MEDIA_WINDOW,
			),
			self::KEY_AUTH_LOCKOUT_WARNING => array(
				'limit'  => self::DEFAULT_AUTH_LOCKOUT_WARNING_LIMIT,
				'window' => self::DEFAULT_AUTH_LOCKOUT_WARNING_WINDOW,
			),
			self::KEY_AUTH_LOCKOUT_IP      => array(
				'limit'  => self::DEFAULT_AUTH_LOCKOUT_IP_LIMIT,
				'window' => self::DEFAULT_AUTH_LOCKOUT_IP_WINDOW,
			),
		);
	}

	/**
	 * Get the effective rate-limits config (SEC-013).
	 *
	 * Applies {@see LIMITS_FILTER}. If a callback returns a non-array,
	 * the default limits are used unchanged.
	 *
	 * @return array<string, array{limit: int, window: int}>
	 */
	public static function get_limits(): array {
		$defaults = self::get_default_limits();
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Constant value is 'seovaultai_api_rate_limits'.
		$filtered = apply_filters( self::LIMITS_FILTER, $defaults );

		if ( ! is_array( $filtered ) ) {
			return $defaults;
		}

		return $filtered;
	}

	/**
	 * Whether the current request should skip rate limiting via the
	 * host exemption filter (SEC-013 / SEC-021).
	 *
	 * Applies {@see EXEMPT_FILTER}. Default is `false` unless a callback
	 * (including the built-in admin-import exemption) returns true.
	 * OPTIONS preflight is handled separately by
	 * {@see is_options_request()} / {@see should_rate_limit()} and does
	 * not rely on this filter.
	 *
	 * @param \WP_REST_Request|null $request Optional current request.
	 * @return bool
	 */
	public static function is_exempt( $request = null ): bool {
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Constant value is 'seovaultai_api_rate_limit_exempt'.
		return (bool) apply_filters( self::EXEMPT_FILTER, false, $request );
	}

	/**
	 * Whether this is a same-origin WP-admin (or server-side) request (SEC-021).
	 *
	 * True when:
	 * - Running under `WP_CLI` or `DOING_CRON`, or
	 * - The current user can `manage_options` and either Origin/Referer
	 *   matches {@see home_url()}, or neither header is present.
	 *
	 * @param \WP_REST_Request|null $request Optional current request.
	 * @return bool
	 */
	public static function is_same_origin_admin_request( $request = null ): bool {
		if ( defined( 'WP_CLI' ) && WP_CLI ) {
			return true;
		}

		if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
			return true;
		}

		if ( ! current_user_can( 'manage_options' ) ) {
			return false;
		}

		$origin  = '';
		$referer = '';
		if ( $request instanceof \WP_REST_Request ) {
			$origin  = (string) $request->get_header( 'Origin' );
			$referer = (string) $request->get_header( 'Referer' );
		}

		// Logged-in admin with no CORS/Referer headers (same-site cookie request).
		if ( '' === $origin && '' === $referer ) {
			return true;
		}

		$home = home_url();
		return self::url_matches_home( $origin, $home ) || self::url_matches_home( $referer, $home );
	}

	/**
	 * Built-in {@see EXEMPT_FILTER} callback: exempt same-origin admin
	 * SEO import batches (SEC-021).
	 *
	 * @param bool                  $exempt  Current exemption state.
	 * @param \WP_REST_Request|null $request Current request.
	 * @return bool
	 */
	public static function filter_exempt_admin_import( $exempt, $request = null ): bool {
		if ( $exempt ) {
			return true;
		}

		if ( ! $request instanceof \WP_REST_Request ) {
			return false;
		}

		$bucket = self::get_route_bucket( (string) $request->get_route(), (string) $request->get_method() );
		if ( self::KEY_IMPORT !== $bucket ) {
			return false;
		}

		return self::is_same_origin_admin_request( $request );
	}

	/**
	 * Whether a URL's host matches the site home URL host.
	 *
	 * @param string $url  Candidate Origin or Referer URL.
	 * @param string $home Site home URL.
	 * @return bool
	 */
	private static function url_matches_home( string $url, string $home ): bool {
		$url = trim( $url );
		if ( '' === $url ) {
			return false;
		}

		$url_host  = wp_parse_url( $url, PHP_URL_HOST );
		$home_host = wp_parse_url( $home, PHP_URL_HOST );

		if ( ! is_string( $url_host ) || '' === $url_host || ! is_string( $home_host ) || '' === $home_host ) {
			return false;
		}

		return strtolower( $url_host ) === strtolower( $home_host );
	}

	/**
	 * Whether the request is an HTTP OPTIONS (CORS) preflight (SEC-014).
	 *
	 * Comparison is case-insensitive so a non-normalized method string
	 * still matches. WordPress normally uppercases via
	 * `WP_REST_Request::set_method()`, but callers may pass raw values.
	 *
	 * @param \WP_REST_Request $request Current request.
	 * @return bool
	 */
	public static function is_options_request( \WP_REST_Request $request ): bool {
		return 'OPTIONS' === strtoupper( (string) $request->get_method() );
	}

	/**
	 * Whether rate-limit counters should run for this request (SEC-014).
	 *
	 * Shared early-return gate used by every limit check:
	 * - OPTIONS preflight → never rate limited
	 * - {@see is_exempt()} → skip when a host filter opts out
	 *
	 * @param \WP_REST_Request $request Current request.
	 * @return bool True when counters / 429 logic should run.
	 */
	public static function should_rate_limit( \WP_REST_Request $request ): bool {
		if ( self::is_options_request( $request ) ) {
			return false;
		}

		if ( self::is_exempt( $request ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Evaluate rate limits for a request (SEC-014 / SEC-016 entry point).
	 *
	 * Always returns `true` for OPTIONS (and for exempt requests).
	 * Runs the pre-auth global IP abuse throttle ({@see check_global_ip()}).
	 * Post-auth buckets land in SEC-018. Auth-fail counting is handled
	 * separately via {@see record_auth_failure()} on the `check_site_key`
	 * failure path. Full Router wiring of this entry point is SEC-019.
	 *
	 * @param \WP_REST_Request $request Current request.
	 * @return true|\WP_Error `true` when allowed; `WP_Error` with status
	 *                        429 when limited.
	 */
	public static function check( \WP_REST_Request $request ) {
		if ( ! self::should_rate_limit( $request ) ) {
			return true;
		}

		return self::check_global_ip( $request );
	}

	/**
	 * Build the transient key for the per-IP auth-fail counter (SEC-015).
	 *
	 * Uses {@see AuditLog::hash_ip()} so the raw IP never appears in the
	 * transient name. Empty IP hashes to `''`, producing a shared bucket
	 * for requests with no `REMOTE_ADDR` (e.g. some CLI contexts).
	 *
	 * @param string $ip_hash SHA-256 hex digest from {@see AuditLog::hash_ip()}.
	 * @return string Transient name; never contains a raw IP address.
	 */
	public static function get_auth_fail_transient_key( string $ip_hash ): string {
		return self::TRANSIENT_AUTH_FAIL_PREFIX . $ip_hash;
	}

	/**
	 * Build the transient key for the per-IP hard lockout flag (SEC-023).
	 *
	 * Format: `seovault_rl_lockout_{ip_hash}`. Never contains the raw IP.
	 *
	 * @param string $ip_hash SHA-256 hex digest from {@see AuditLog::hash_ip()}.
	 * @return string Transient name.
	 */
	public static function get_lockout_transient_key( string $ip_hash ): string {
		return self::TRANSIENT_LOCKOUT_PREFIX . $ip_hash;
	}

	/**
	 * Build the transient key for the one-shot lockout-warning flag (SEC-023).
	 *
	 * @param string $ip_hash SHA-256 hex digest from {@see AuditLog::hash_ip()}.
	 * @return string Transient name.
	 */
	public static function get_lockout_warning_transient_key( string $ip_hash ): string {
		return self::TRANSIENT_LOCKOUT_WARNING_PREFIX . $ip_hash;
	}

	/**
	 * Whether the IP hash is currently under a hard auth lockout (SEC-023).
	 *
	 * @param string $ip_hash SHA-256 hex digest.
	 * @return bool
	 */
	public static function is_locked_out( string $ip_hash ): bool {
		return false !== get_transient( self::get_lockout_transient_key( $ip_hash ) );
	}

	/**
	 * Set the hard lockout transient for an IP hash (SEC-023).
	 *
	 * @param string $ip_hash SHA-256 hex digest.
	 * @param int    $window  Lockout duration in seconds.
	 * @return void
	 */
	public static function set_lockout( string $ip_hash, int $window ): void {
		set_transient( self::get_lockout_transient_key( $ip_hash ), 1, max( 1, $window ) );
	}

	/**
	 * Build the auth-lockout `WP_Error` (429 + Retry-After) (SEC-023).
	 *
	 * Distinct from {@see make_rate_limited_error()} so clients can tell
	 * lockout from ordinary rate limiting (`seovaultai_auth_lockout`).
	 *
	 * @param int $retry_after Seconds until the client should retry.
	 * @return \WP_Error
	 */
	public static function make_auth_lockout_error( int $retry_after ): \WP_Error {
		$retry_after = max( 1, $retry_after );

		if ( ! headers_sent() ) {
			header( 'Retry-After: ' . $retry_after );
		}

		return new \WP_Error(
			self::ERROR_CODE_AUTH_LOCKOUT,
			__( 'Too many failed authentication attempts. Please try again later.', 'seovault-ai' ),
			array(
				'status'      => 429,
				'retry_after' => $retry_after,
				'headers'     => array(
					'Retry-After' => (string) $retry_after,
				),
			)
		);
	}

	/**
	 * Clear the auth-fail counter and lockout/warning flags for an IP hash (SEC-025).
	 *
	 * Called after successful `hash_equals` so a legitimate client that
	 * previously mistyped the key is not stuck counting toward lockout,
	 * and so an admin-cleared or naturally expired lockout path is mirrored
	 * when auth succeeds from that IP (lockout itself still blocks until
	 * cleared/expired — this only runs on the success path past
	 * {@see check_lockout()}).
	 *
	 * @param string $ip_hash SHA-256 hex digest from {@see AuditLog::hash_ip()}.
	 * @return void
	 */
	public static function clear_auth_failures_for_ip( string $ip_hash ): void {
		delete_transient( self::get_auth_fail_transient_key( $ip_hash ) );
		delete_transient( self::get_lockout_transient_key( $ip_hash ) );
		delete_transient( self::get_lockout_warning_transient_key( $ip_hash ) );
	}

	/**
	 * Clear auth-fail / lockout state for the current request IP (SEC-025).
	 *
	 * @param \WP_REST_Request|null $request Unused; kept for call-site symmetry.
	 * @return void
	 */
	public static function clear_auth_failures( $request = null ): void {
		unset( $request );
		$ip_hash = AuditLog::hash_ip( AuditLog::get_request_ip() );
		self::clear_auth_failures_for_ip( $ip_hash );
	}

	/**
	 * Delete all plugin rate-limit / lockout transients (SEC-026).
	 *
	 * Clears every option whose name starts with `_transient_seovault_rl_`
	 * via {@see delete_transient()} so the object cache is also flushed.
	 * Used by the Security-tab "Clear API lockouts" control.
	 *
	 * @return int Number of rate-limit / lockout transients deleted.
	 */
	public static function clear_all_rate_limit_transients(): int {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Admin maintenance; plugin-owned transient prefix.
		$names = $wpdb->get_col(
			"SELECT option_name FROM {$wpdb->options}
			WHERE option_name LIKE '_transient_seovault_rl_%'
			  AND option_name NOT LIKE '_transient_timeout_%'"
		);

		if ( ! is_array( $names ) ) {
			$names = array();
		}

		$cleared = 0;
		foreach ( $names as $option_name ) {
			$key = (string) $option_name;
			if ( 0 === strpos( $key, '_transient_' ) ) {
				$key = substr( $key, strlen( '_transient_' ) );
			}
			delete_transient( $key );
			++$cleared;
		}

		// Sweep any orphaned timeout rows left behind.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			"DELETE FROM {$wpdb->options}
			WHERE option_name LIKE '_transient_timeout_seovault_rl_%'"
		);

		return $cleared;
	}

	/**
	 * Clear rate-limit / lockout transients for a single IP (SEC-026).
	 *
	 * When `$ip` is empty, falls back to {@see clear_all_rate_limit_transients()}.
	 * Hashes the IP before building keys so the raw address is never stored.
	 *
	 * @param string $ip Optional raw IP; empty clears all.
	 * @return int Number of known per-IP keys cleared, or all-clear count.
	 */
	public static function clear_rate_limits_for_ip( string $ip = '' ): int {
		$ip = trim( $ip );
		if ( '' === $ip ) {
			return self::clear_all_rate_limit_transients();
		}

		$ip_hash = AuditLog::hash_ip( $ip );
		$keys    = array(
			self::get_auth_fail_transient_key( $ip_hash ),
			self::get_lockout_transient_key( $ip_hash ),
			self::get_lockout_warning_transient_key( $ip_hash ),
			self::get_global_ip_transient_key( $ip_hash ),
		);

		$cleared = 0;
		foreach ( $keys as $key ) {
			if ( false !== get_transient( $key ) ) {
				++$cleared;
			}
			delete_transient( $key );
		}

		return $cleared;
	}

	/**
	 * Pre-auth check: block the request if the IP is locked out (SEC-023 / SEC-024).
	 *
	 * Intended to run early in `Router::check_site_key()` (after the global
	 * IP throttle). Because `check_site_key` is only the permission callback
	 * for the `seovaultai/v1` namespace, lockout never affects other WP REST
	 * routes (e.g. `/wp/v2/posts`). Respects {@see should_rate_limit()} so
	 * OPTIONS / exempt requests are never blocked by lockout.
	 *
	 * @param \WP_REST_Request $request Current request.
	 * @return true|\WP_Error `true` when not locked out; `WP_Error`
	 *                        (`seovaultai_auth_lockout`, status 429) when locked.
	 */
	public static function check_lockout( \WP_REST_Request $request ) {
		if ( ! self::should_rate_limit( $request ) ) {
			return true;
		}

		$ip_hash = AuditLog::hash_ip( AuditLog::get_request_ip() );
		if ( ! self::is_locked_out( $ip_hash ) ) {
			return true;
		}

		$config = self::get_limit_config( self::KEY_AUTH_LOCKOUT_IP );
		return self::make_auth_lockout_error( $config['window'] );
	}

	/**
	 * Build the transient key for the per-IP global request counter (SEC-016).
	 *
	 * Uses {@see AuditLog::hash_ip()} so the raw IP never appears in the
	 * transient name.
	 *
	 * @param string $ip_hash SHA-256 hex digest from {@see AuditLog::hash_ip()}.
	 * @return string Transient name; never contains a raw IP address.
	 */
	public static function get_global_ip_transient_key( string $ip_hash ): string {
		return self::TRANSIENT_GLOBAL_IP_PREFIX . $ip_hash;
	}

	/**
	 * Read the `{ limit, window }` config for a named limit key.
	 *
	 * Falls back to the matching default entry when the filtered map is
	 * missing the key or has a malformed value.
	 *
	 * @param string $key Limit key (e.g. {@see KEY_AUTH_FAIL_IP}).
	 * @return array{limit: int, window: int}
	 */
	public static function get_limit_config( string $key ): array {
		$limits   = self::get_limits();
		$defaults = self::get_default_limits();
		$fallback = isset( $defaults[ $key ] )
			? $defaults[ $key ]
			: array(
				'limit'  => 0,
				'window' => 60,
			);

		if ( ! isset( $limits[ $key ] ) || ! is_array( $limits[ $key ] ) ) {
			return $fallback;
		}

		$entry = $limits[ $key ];

		return array(
			'limit'  => isset( $entry['limit'] ) ? max( 0, (int) $entry['limit'] ) : (int) $fallback['limit'],
			'window' => isset( $entry['window'] ) ? max( 1, (int) $entry['window'] ) : (int) $fallback['window'],
		);
	}

	/**
	 * Current auth-fail count for an IP hash (0 when unset).
	 *
	 * @param string $ip_hash SHA-256 hex digest.
	 * @return int
	 */
	public static function get_auth_fail_count( string $ip_hash ): int {
		$count = get_transient( self::get_auth_fail_transient_key( $ip_hash ) );
		return false === $count ? 0 : max( 0, (int) $count );
	}

	/**
	 * Current global request count for an IP hash (0 when unset) (SEC-016).
	 *
	 * @param string $ip_hash SHA-256 hex digest.
	 * @return int
	 */
	public static function get_global_ip_count( string $ip_hash ): int {
		$count = get_transient( self::get_global_ip_transient_key( $ip_hash ) );
		return false === $count ? 0 : max( 0, (int) $count );
	}

	/**
	 * Build the rate-limited `WP_Error` (429 + Retry-After) (SEC-015).
	 *
	 * Sets the `Retry-After` HTTP header when headers have not been sent
	 * yet, and also stores `retry_after` in the error data for REST
	 * consumers / tests.
	 *
	 * @param int $retry_after Seconds until the client should retry.
	 * @return \WP_Error
	 */
	public static function make_rate_limited_error( int $retry_after ): \WP_Error {
		$retry_after = max( 1, $retry_after );

		if ( ! headers_sent() ) {
			header( 'Retry-After: ' . $retry_after );
		}

		return new \WP_Error(
			self::ERROR_CODE_RATE_LIMITED,
			__( 'Too many requests. Please try again later.', 'seovault-ai' ),
			array(
				'status'      => 429,
				'retry_after' => $retry_after,
				'headers'     => array(
					'Retry-After' => (string) $retry_after,
				),
			)
		);
	}

	/**
	 * Record a failed site-key attempt for the request IP (SEC-015 / SEC-023).
	 *
	 * Called from `Router::check_site_key()` only on the missing/invalid
	 * key paths — a valid key never increments this counter. Respects
	 * {@see should_rate_limit()} so OPTIONS / exempt requests are skipped.
	 *
	 * Default budget: {@see DEFAULT_AUTH_FAIL_IP_LIMIT} failures within
	 * {@see DEFAULT_AUTH_FAIL_IP_WINDOW} seconds (filterable via
	 * {@see LIMITS_FILTER} / `auth_fail_ip`). The attempt that pushes the
	 * count above the limit returns 429; earlier failures return `true`
	 * so the caller can still return the normal 401/403 auth error.
	 *
	 * SEC-023 lockout thresholds (filterable via `auth_lockout_warning` /
	 * `auth_lockout_ip`):
	 * - At the warning limit (default 10): optional `lockout_warning` audit
	 *   (once per window via a one-shot transient).
	 * - At the hard lockout limit (default 20): set
	 *   `seovault_rl_lockout_{ip_hash}` and return `seovaultai_auth_lockout`.
	 *
	 * Hard lockout takes precedence over the ordinary auth-fail rate-limit
	 * 429 when both thresholds are reached on the same attempt (defaults
	 * share limit 20).
	 *
	 * @param \WP_REST_Request $request Current request.
	 * @return true|\WP_Error `true` when still under the budget; `WP_Error`
	 *                        (`seovaultai_auth_lockout` or
	 *                        `seovaultai_rate_limited`, status 429) when
	 *                        the IP has exceeded a failed-key limit.
	 */
	public static function record_auth_failure( \WP_REST_Request $request ) {
		if ( ! self::should_rate_limit( $request ) ) {
			return true;
		}

		$config  = self::get_limit_config( self::KEY_AUTH_FAIL_IP );
		$limit   = $config['limit'];
		$window  = $config['window'];
		$ip_hash = AuditLog::hash_ip( AuditLog::get_request_ip() );
		$key     = self::get_auth_fail_transient_key( $ip_hash );
		$count   = self::get_auth_fail_count( $ip_hash );

		$lockout_config = self::get_limit_config( self::KEY_AUTH_LOCKOUT_IP );
		$lockout_limit  = $lockout_config['limit'];
		$lockout_window = $lockout_config['window'];
		$warning_config = self::get_limit_config( self::KEY_AUTH_LOCKOUT_WARNING );
		$warning_limit  = $warning_config['limit'];
		$warning_window = $warning_config['window'];

		// Already locked out — keep returning lockout 429.
		if ( self::is_locked_out( $ip_hash ) ) {
			return self::make_auth_lockout_error( $lockout_window );
		}

		// Already over auth-fail budget from prior failures — keep returning 429.
		if ( $limit > 0 && $count > $limit ) {
			return self::make_rate_limited_error( $window );
		}

		$new_count = $count + 1;
		set_transient( $key, $new_count, $window );

		// Warning threshold (default 10) — audit once per window.
		if ( $warning_limit > 0 && $new_count === $warning_limit ) {
			$warn_key = self::get_lockout_warning_transient_key( $ip_hash );
			if ( false === get_transient( $warn_key ) ) {
				set_transient( $warn_key, 1, $warning_window );
				AuditLog::log_lockout_warning( $request );
			}
		}

		// Hard lockout threshold (default 20) — block for lockout window.
		if ( $lockout_limit > 0 && $new_count >= $lockout_limit ) {
			self::set_lockout( $ip_hash, $lockout_window );
			return self::make_auth_lockout_error( $lockout_window );
		}

		if ( $limit > 0 && $new_count > $limit ) {
			return self::make_rate_limited_error( $window );
		}

		return true;
	}

	/**
	 * Pre-auth global abuse throttle per IP (SEC-016).
	 *
	 * Counts *all* namespace requests from an IP (authenticated and
	 * unauthenticated). Default budget is intentionally generous
	 * ({@see DEFAULT_GLOBAL_IP_LIMIT} / {@see DEFAULT_GLOBAL_IP_WINDOW})
	 * so only obvious abuse is blocked. Filterable via
	 * {@see LIMITS_FILTER} / `global_ip`.
	 *
	 * Designed to run before site-key validation (wired into
	 * `Router::check_site_key()` in SEC-019). Also invoked from
	 * {@see check()} for the shared entry point. Respects
	 * {@see should_rate_limit()} so OPTIONS / exempt requests are skipped.
	 *
	 * @param \WP_REST_Request $request Current request.
	 * @return true|\WP_Error `true` when under budget; `WP_Error`
	 *                        (`seovaultai_rate_limited`, status 429) when
	 *                        the IP has exceeded the global request limit.
	 */
	public static function check_global_ip( \WP_REST_Request $request ) {
		if ( ! self::should_rate_limit( $request ) ) {
			return true;
		}

		$config  = self::get_limit_config( self::KEY_GLOBAL_IP );
		$limit   = $config['limit'];
		$window  = $config['window'];
		$ip_hash = AuditLog::hash_ip( AuditLog::get_request_ip() );
		$key     = self::get_global_ip_transient_key( $ip_hash );
		$count   = self::get_global_ip_count( $ip_hash );

		// Already over budget — keep returning 429 without further increments.
		if ( $limit > 0 && $count > $limit ) {
			return self::make_rate_limited_error( $window );
		}

		$new_count = $count + 1;
		set_transient( $key, $new_count, $window );

		if ( $limit > 0 && $new_count > $limit ) {
			return self::make_rate_limited_error( $window );
		}

		return true;
	}

	/**
	 * Classify a route into a post-auth rate-limit bucket (SEC-017).
	 *
	 * Buckets:
	 * - `import` — path ends with `/seo/import` (not `/seo/import/summary`)
	 * - `media`  — path contains `/media/upload`
	 * - `read`   — safe read methods (`GET`, `HEAD`)
	 * - `write`  — write methods and any unknown/unsafe default
	 *
	 * Unknown routes default to `write` (safer: tighter budget).
	 *
	 * @param string $route  REST route path (e.g. `/seovaultai/v1/verify`).
	 * @param string $method HTTP method (case-insensitive).
	 * @return string One of `read`, `write`, `import`, `media`.
	 */
	public static function get_route_bucket( string $route, string $method ): string {
		$route  = self::normalize_route_path( $route );
		$method = strtoupper( trim( $method ) );

		// Path-specific buckets take precedence over HTTP method.
		if ( self::route_ends_with_path( $route, '/seo/import' ) ) {
			return self::KEY_IMPORT;
		}

		if ( false !== strpos( $route, '/media/upload' ) ) {
			return self::KEY_MEDIA;
		}

		if ( in_array( $method, array( 'GET', 'HEAD' ), true ) ) {
			return self::KEY_READ;
		}

		// POST/PUT/PATCH/DELETE and anything unrecognized → write.
		return self::KEY_WRITE;
	}

	/**
	 * Normalize a REST route path for bucket matching.
	 *
	 * Lowercases, strips query strings, and removes a trailing slash
	 * (except for `/`).
	 *
	 * @param string $route Raw route.
	 * @return string
	 */
	private static function normalize_route_path( string $route ): string {
		$route = strtolower( trim( $route ) );
		$query = strpos( $route, '?' );
		if ( false !== $query ) {
			$route = substr( $route, 0, $query );
		}

		if ( '/' !== $route ) {
			$route = untrailingslashit( $route );
		}

		return $route;
	}

	/**
	 * Whether a normalized route ends with the given path suffix.
	 *
	 * @param string $route  Normalized route.
	 * @param string $suffix Path suffix (e.g. `/seo/import`).
	 * @return bool
	 */
	private static function route_ends_with_path( string $route, string $suffix ): bool {
		$suffix = strtolower( $suffix );
		if ( $route === $suffix ) {
			return true;
		}

		$len = strlen( $suffix );
		if ( $len > strlen( $route ) ) {
			return false;
		}

		return substr( $route, -$len ) === $suffix;
	}

	/**
	 * SHA-256 hash of a site key for transient naming (SEC-018).
	 *
	 * Stable for the same key; the raw site key is never stored in
	 * transient names.
	 *
	 * @param string $site_key Raw site key (already validated by caller).
	 * @return string 64-char hex digest.
	 */
	public static function hash_site_key( string $site_key ): string {
		return hash( 'sha256', $site_key );
	}

	/**
	 * Build the transient key for a post-auth bucket counter (SEC-018).
	 *
	 * Format: `seovault_rl_bucket_{key_hash}_{bucket}`. Never contains
	 * the raw site key.
	 *
	 * @param string $key_hash SHA-256 hex digest from {@see hash_site_key()}.
	 * @param string $bucket   Bucket name from {@see get_route_bucket()}.
	 * @return string Transient name.
	 */
	public static function get_bucket_transient_key( string $key_hash, string $bucket ): string {
		return self::TRANSIENT_BUCKET_PREFIX . $key_hash . '_' . $bucket;
	}

	/**
	 * Current post-auth bucket count for a key-hash + bucket (0 when unset).
	 *
	 * @param string $key_hash SHA-256 hex digest.
	 * @param string $bucket   Bucket name.
	 * @return int
	 */
	public static function get_bucket_count( string $key_hash, string $bucket ): int {
		$count = get_transient( self::get_bucket_transient_key( $key_hash, $bucket ) );
		return false === $count ? 0 : max( 0, (int) $count );
	}

	/**
	 * Post-auth per key-hash + route-bucket rate limit (SEC-018).
	 *
	 * Intended to run after successful site-key validation (wired into
	 * `Router::check_site_key()` in SEC-020). Classifies the request via
	 * {@see get_route_bucket()}, then increments a transient keyed by
	 * `hash(site_key) + bucket`. Defaults (filterable): read 120/min,
	 * write 40/min, import 10/min, media 20/min.
	 *
	 * Respects {@see should_rate_limit()} so OPTIONS / exempt requests
	 * are skipped. Does not validate the site key — callers must only
	 * pass a key that already passed `hash_equals`.
	 *
	 * @param \WP_REST_Request $request  Current request.
	 * @param string           $site_key Validated site key (raw; hashed internally).
	 * @return true|\WP_Error `true` when under budget; `WP_Error`
	 *                        (`seovaultai_rate_limited`, status 429) when
	 *                        the key+bucket budget is exceeded.
	 */
	public static function check_bucket( \WP_REST_Request $request, string $site_key ) {
		if ( ! self::should_rate_limit( $request ) ) {
			return true;
		}

		$bucket   = self::get_route_bucket( (string) $request->get_route(), (string) $request->get_method() );
		$config   = self::get_limit_config( $bucket );
		$limit    = $config['limit'];
		$window   = $config['window'];
		$key_hash = self::hash_site_key( $site_key );
		$key      = self::get_bucket_transient_key( $key_hash, $bucket );
		$count    = self::get_bucket_count( $key_hash, $bucket );

		if ( $limit > 0 && $count > $limit ) {
			return self::make_rate_limited_error( $window );
		}

		$new_count = $count + 1;
		set_transient( $key, $new_count, $window );

		if ( $limit > 0 && $new_count > $limit ) {
			return self::make_rate_limited_error( $window );
		}

		return true;
	}
}
