<?php
/**
 * Cloudflare Turnstile Module
 *
 * Integrates Cloudflare Turnstile with WordPress forms. Turnstile is a
 * privacy-focused alternative to CAPTCHAs—validates users without puzzles,
 * doesn't track visitors, and is completely free.
 *
 * @package CloudflareTurnstile
 * @since 1.0.0
 */

namespace CloudflareTurnstile;

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

use WP_PowerSuite\Captcha\AbstractCaptchaModule;
use WP_PowerSuite\Captcha\Shared\CaptchaRuntime;
use WP_PowerSuite\Captcha\Shared\ClientIp;
use WP_PowerSuite\Captcha\Shared\Counters;
use WP_PowerSuite\Captcha\Shared\DashboardWidget;
use WP_PowerSuite\Captcha\Shared\LoginLayoutStyles;
use WP_PowerSuite\Captcha\Shared\NetworkSettings;
use WP_PowerSuite\Captcha\Shared\OptimizerCompat;
use WP_PowerSuite\Captcha\Shared\SecretCipher;
use WP_PowerSuite\Captcha\Shared\WidgetLayoutStyles;
use WP_PowerSuite\Captcha\Shared\SecretRedactor;
use WP_PowerSuite\Captcha\Shared\SkipRules;
use WP_PowerSuite\Captcha\Shared\TokenReader;
use WP_PowerSuite\Captcha\Shared\CaptchaFormContext;
use WP_PowerSuite\Captcha\Shared\WooCheckoutContext;
use WP_PowerSuite\Core\OptionStoreModuleSettingsRepository;

require_once __DIR__ . '/FeatureGate.php';
require_once __DIR__ . '/ExtendedIntegrations.php';
require_once __DIR__ . '/FormIntegrations.php';
require_once __DIR__ . '/includes/PremiumSkipRules.php';

/**
 * Cloudflare Turnstile Module Class
 *
 * @since 1.0.0
 */
class Module extends AbstractCaptchaModule {

	private const MODULE_ID = 'cloudflare-turnstile';
	public const MODULE_VERSION = '1.6.16';
	public const SCRIPT_HANDLE = 'wpps-cloudflare-turnstile';
	/** @var string Store API namespace for WooCommerce Blocks checkout token extension */
	public const WOO_STORE_API_NAMESPACE = 'wp-powersuite-turnstile';
	private const DISABLE_SUBMIT_HANDLE = 'wpps-turnstile-disable-submit';
	private const FORM_RESET_HANDLE = 'wpps-turnstile-form-reset';
	private const WOO_CHECKOUT_FRONT_HANDLE = 'wpps-turnstile-woo-checkout-front';
	private const TURNSTILE_SCRIPT = 'https://challenges.cloudflare' . '.com/turnstile/v0/api.js'; // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Cloudflare Turnstile widget API.
	private const SITEVERIFY_URL = 'https://challenges.cloudflare' . '.com/turnstile/v0/siteverify'; // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Cloudflare Turnstile siteverify API.
	private const ENCRYPT_KEY = 'wpps_turnstile_v1';
	private const TOKEN_MAX_LEN = 2048;
	/** Marker lifetime. Kept generous: a shopper may sit on a checkout form for a long while. */
	private const FAILSAFE_ALLOW_TTL = 3600;

	/** Replays allowed per marker — enough for retrying a rejected form, too few to spray one marker. */
	private const FAILSAFE_ALLOW_MAX_USES = 3;

	/** @var string|null Last verify failure reason for user-facing messages (network|api). */
	private static ?string $last_verify_failure = null;

	private ?array $settings_cache = null;

	/**
	 * Successful siteverify outcomes this request only (Turnstile tokens are single-use; WordPress may validate twice).
	 *
	 * @var array<string, true>
	 */
	private static array $siteverify_ok_memo = [];

	/** @var bool Whether Turnstile api.js has been queued this request */
	private static bool $script_queued = false;

	/** @var bool script_loader_tag filter registered (lazy; only when api.js is enqueued) */
	private static bool $script_loader_tag_registered = false;

	/** @var bool Whether we already attached {@see enqueueTurnstileScriptForCustomLoginUrl()} to template_redirect. */
	private static bool $custom_login_url_template_redirect_attached = false;

	/** @var bool Prevent duplicate classic checkout validation in one request */
	private static bool $woo_classic_checkout_gate_done = false;

	/** @var bool Classic checkout hook already printed the widget */
	private static bool $woo_checkout_classic_emitted = false;

	/** @var bool Blocks checkout payment block already prefixed with the widget */
	private static bool $woo_checkout_block_emitted = false;

	/** @var bool Store API checkout extension already registered this request */
	private static bool $woo_store_api_registered = false;

	/** @var bool|null Cached answer: can we reach Cloudflare Turnstile scripts from this server? */
	private static ?bool $turnstile_endpoint_reachable = null;

	public function getId(): string {
		return self::MODULE_ID;
	}

	public function isConfigured(): bool {
		$settings = $this->getSettings();
		return ! empty( $settings['site_key'] ) && ! empty( $settings['secret_key'] );
	}

	public function getName(): string {
		return __( 'Cloudflare Turnstile', 'wppowersuite-lite' );
	}

	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	public function getDescription(): string {
		return __( 'Bot protection with Cloudflare Turnstile on logins, forms, comments, and WooCommerce—low hassle for real people.', 'wppowersuite-lite' );
	}

	public function getContext(): string|array {
		return 'both';
	}

	public function getDependencies(): array {
		return [];
	}

	protected function registerHooks(): void {
		$settings = $this->getSettings();
		$site_key = $settings['site_key'] ?? '';
		$secret_key = $settings['secret_key'] ?? '';

		if ( empty( $site_key ) || empty( $secret_key ) ) {
			return;
		}

		if ( CaptchaRuntime::needsWpLoginScreenAssets( $settings ) ) {
			add_action( 'login_enqueue_scripts', [ $this, 'enqueueTurnstileScript' ] );
			add_action( 'login_enqueue_scripts', [ $this, 'enqueueLoginTurnstileLayoutStyles' ], 100 );
			add_action( 'init', [ $this, 'registerCustomLoginUrlTurnstileHook' ], 999 );
		}

		add_filter( 'wpps_verify_turnstile', [ $this, 'filterVerifyTurnstile' ], 10, 3 );

		// WordPress Core Forms — widget data-action must match verifyToken(..., $action)
		// or siteverify JSON will omit / mismatch `action` and verification will fail.
		if ( ! empty( $settings['protect_login'] ) ) {
			add_action( 'login_form', [ $this, 'renderLoginWidget' ] );
			add_filter( 'authenticate', [ $this, 'validateLogin' ], 30, 3 );
		}

		if ( ! empty( $settings['protect_registration'] ) ) {
			add_action( 'register_form', [ $this, 'renderRegisterWidget' ] );
			add_filter( 'registration_errors', [ $this, 'validateRegistration' ], 10, 3 );
		}

		if ( ! empty( $settings['protect_password_reset'] ) ) {
			add_action( 'lostpassword_form', [ $this, 'renderLostPasswordWidget' ] );
			add_filter( 'allow_password_reset', [ $this, 'validatePasswordReset' ], 10, 2 );
			add_action( 'resetpass_form', [ $this, 'renderWidgetOnResetPass' ], 10, 1 );
			add_action( 'validate_password_reset', [ $this, 'validateResetPassForm' ], 10, 2 );
		}

		// Independent hooks; woo_reviews only registers when protect_comments is off,
		// to avoid rendering or validating twice on product pages where both apply.
		if ( ! empty( $settings['protect_comments'] ) ) {
			add_action( 'comment_form_after_fields', [ $this, 'renderCommentWidget' ] );
			add_action( 'comment_form_logged_in_after', [ $this, 'renderCommentWidget' ] );
			add_action( 'comment_form_top', [ $this, 'renderCommentTurnstileErrorNotice' ] );
			add_filter( 'preprocess_comment', [ $this, 'validateComment' ] );
		}

		if ( ! empty( $settings['woo_reviews'] ) && empty( $settings['protect_comments'] ) ) {
			add_action( 'comment_form_after_fields', [ $this, 'maybeRenderWidgetForReviews' ] );
			add_action( 'comment_form_logged_in_after', [ $this, 'maybeRenderWidgetForReviews' ] );
			add_action( 'comment_form_top', [ $this, 'renderCommentTurnstileErrorNotice' ] );
			add_filter( 'preprocess_comment', [ $this, 'validateProductReview' ] );
		}

		add_action( 'wp_powersuite_module_toggled', [ self::class, 'onModuleToggled' ], 10, 3 );

		// WooCommerce
		if ( class_exists( 'WooCommerce' ) ) {
			$this->initWooCommerce( $settings );
		}

		if ( is_admin() ) {
			add_action( 'init', [ $this, 'registerCaptchaDashboardWidget' ], 5 );
		}

		if ( is_admin() ) {
			add_action( 'admin_enqueue_scripts', [ $this, 'enqueueAdminWidgetLayoutStyles' ], 15 );
		}

		if ( CaptchaRuntime::hasWooCommerceIntegration( $settings ) ) {
			add_action( 'before_woocommerce_init', [ $this, 'declareWooCommerceCompatibility' ] );
		}

		if ( ! empty( $settings['ajax_comment_reset'] ) && ! empty( $settings['protect_comments'] ) ) {
			add_action( 'wp_enqueue_scripts', [ $this, 'enqueueCommentAjaxTurnstileReset' ], 20 );
		}

		if ( class_exists( FormIntegrations::class, false ) ) {
			FormIntegrations::boot( $this );
		}

		if ( class_exists( ExtendedIntegrations::class, false ) ) {
			ExtendedIntegrations::boot( $this );
		}
	}

	/**
	 * Register dashboard row after init so getName() does not trigger translations on plugins_loaded (WP 6.7+).
	 */
	public function registerCaptchaDashboardWidget(): void {
		if ( ! is_admin() ) {
			return;
		}
		DashboardWidget::register( self::MODULE_ID, $this->getName() );
	}

	/**
	 * HPOS / Blocks declarations for WooCommerce when the Turnstile module is active.
	 */
	public function declareWooCommerceCompatibility(): void {
		if ( ! class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
			return;
		}
		$main = defined( 'WP_POWERSUITE_PLUGIN_FILE' ) ? WP_POWERSUITE_PLUGIN_FILE : '';
		if ( $main === '' ) {
			return;
		}
		\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', $main, true );
		\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', $main, true );
		\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'product_block_editor', $main, true );
	}

	public function enqueueCommentAjaxTurnstileReset(): void {
		if ( ! $this->isConfigured() || ! is_singular() || ! comments_open() ) {
			return;
		}
		$js = <<<'JS'
document.addEventListener("DOMContentLoaded",function(){document.body.addEventListener("click",function(e){if(e.target.matches(".comment-reply-link, #cancel-comment-reply-link")){if(window.turnstile&&typeof window.turnstile.reset==="function"){try{window.turnstile.reset(".comment-form .cf-turnstile");}catch(err){}}}});});
JS;
		wp_register_script( 'wpps-turnstile-comment-reset', false, [], self::MODULE_VERSION, true );
		wp_enqueue_script( 'wpps-turnstile-comment-reset' );
		wp_add_inline_script( 'wpps-turnstile-comment-reset', $js );
	}

	/** @return array<string, mixed> */
	public function getCaptchaSettings(): array {
		return $this->getSettings();
	}

	public function verifySubmittedToken( string $action = '' ): bool {
		return $this->verifyToken( $this->getTokenFromRequest(), $action );
	}

	public function verifyCaptchaToken( string $token, string $action = '' ): bool {
		return $this->verifyToken( $token, $action );
	}

	public function postedCaptchaToken( ?array $form_data = null ): string {
		$t = $this->getTokenFromRequest();
		if ( $t !== '' ) {
			return $t;
		}
		if ( is_array( $form_data ) && $form_data !== [] ) {
			return $this->extractTurnstileTokenFromNestedArray( $form_data );
		}

		return '';
	}

	public function captchaErrorMessage( string $default, string $context ): string {
		return $this->errorMessage( $default, $context );
	}

	/**
	 * Initialize WooCommerce integrations.
	 *
	 * @param array<string, mixed> $settings Module settings.
	 * @return void
	 */
	private function initWooCommerce( array $settings ): void {
		if ( ! empty( $settings['woo_login'] ) ) {
			add_action( 'woocommerce_login_form', [ $this, 'renderWooLoginWidget' ] );
			add_filter( 'woocommerce_process_login_errors', [ $this, 'validateWooLogin' ], 10, 3 );
		}

		if ( ! empty( $settings['woo_registration'] ) ) {
			add_action( 'woocommerce_register_form', [ $this, 'renderWooRegisterWidget' ] );
			add_filter( 'woocommerce_registration_errors', [ $this, 'validateWooRegistration' ], 10, 3 );
		}

		if ( ! empty( $settings['woo_lost_password'] ) ) {
			add_action( 'woocommerce_lostpassword_form', [ $this, 'renderLostPasswordWidget' ] );
			add_filter( 'allow_password_reset', [ $this, 'validatePasswordReset' ], 10, 2 );
		}

		if ( ! empty( $settings['woo_checkout'] ) ) {
			add_action( 'woocommerce_review_order_before_submit', [ $this, 'renderWooCheckoutWidget' ], 5 );
			// Woo’s recommended placement: immediately before the Place Order button.
			// Do not client-side insertInto `.wc-block-checkout__actions*` (React #60).
			add_filter( 'render_block_woocommerce/checkout-actions-block', [ $this, 'prependTurnstileToCheckoutActionsBlock' ], 999, 1 );
			add_action( 'woocommerce_checkout_process', [ $this, 'validateWooCheckout' ] );
			add_action( 'woocommerce_store_api_checkout_update_order_from_request', [ $this, 'validateWooCheckoutStoreApi' ], 10, 2 );
			// WooCommerce often fires woocommerce_loaded before this module boots
			// (plugin load order). Missing that hook means the Store API extension
			// never registers, Blocks checkout strips the token, and place-order fails.
			if ( did_action( 'woocommerce_loaded' ) ) {
				$this->registerWooCheckoutStoreApiExtension();
			} else {
				add_action( 'woocommerce_loaded', [ $this, 'registerWooCheckoutStoreApiExtension' ], 20 );
			}
			add_action( 'wp_enqueue_scripts', [ $this, 'enqueueWooCheckoutFrontScript' ], 30 );
		}

	}

	/**
	 * Queue Turnstile api.js (login screen / early bootstrap).
	 *
	 * @return void
	 */
	public function enqueueTurnstileScript(): void {
		$this->ensureTurnstileScriptQueued();
	}

	/**
	 * Ensure wp-login forms keep Turnstile bounded inside the login form.
	 *
	 * Complements inline widths on {@see renderWidget()} for themes that tighten `#loginform` children.
	 *
	 * @return void
	 */
	public function enqueueLoginTurnstileLayoutStyles(): void {
		if ( ! CaptchaRuntime::needsWpLoginScreenAssets( $this->getSettings() ) ) {
			return;
		}
		LoginLayoutStyles::enqueue();
	}

	/**
	 * Load api.js in wp-admin for Basic Forms previews (same pattern as reCAPTCHA).
	 *
	 * @return void
	 */
	public function enqueueAdminWidgetLayoutStyles(): void {
		if ( ! $this->isConfigured() ) {
			return;
		}
		if ( ! CaptchaRuntime::shouldEnqueueAdminAssets( $this->getSettings() ) ) {
			return;
		}
		WidgetLayoutStyles::enqueueAdmin();
	}

	public function enqueueTurnstileInAdminForBasicForms(): void {
		if ( ! CaptchaRuntime::isBasicFormsAdminScreen() ) {
			return;
		}
		$this->ensureTurnstileScriptQueued();
	}

	/**
	 * Queue api.js once; used by wp-login enqueue, custom login URL, and any widget render.
	 *
	 * Avoids loading Turnstile on every frontend page — only when a hook outputs a widget or login needs assets.
	 *
	 * api.js always loads in implicit mode so every `.cf-turnstile` widget on the page still renders.
	 * Checkout opts its own container out of the implicit scan (see `explicit_only` in {@see renderWidget()}).
	 *
	 * @return void
	 */
	private function ensureTurnstileScriptQueued(): void {
		$settings = $this->getSettings();
		if ( empty( $settings['site_key'] ) ) {
			return;
		}

		if ( self::$script_queued ) {
			return;
		}
		self::$script_queued = true;

		if ( ! self::$script_loader_tag_registered ) {
			self::$script_loader_tag_registered = true;
			add_filter( 'script_loader_tag', [ $this, 'filterTurnstileScriptLoaderTag' ], 10, 2 );
		}

		// Combined/deferred/delayed api.js never renders the widget, leaving visitors
		// with a verification error they cannot act on.
		OptimizerCompat::protectScripts(
			[
				'challenges.cloudflare' . '.com/turnstile', // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- optimizer exclusion for the Turnstile service script.
				'turnstile',
				'cloudflare-turnstile/assets',
			]
		);

		/*
		 * Footer + defer: api.js must run after `.cf-turnstile` markup exists for implicit render.
		 * Avoid async+defer together (async wins and can race ahead of the widget HTML).
		 */
		wp_enqueue_script(
			self::SCRIPT_HANDLE,
			self::TURNSTILE_SCRIPT,
			[],
			self::MODULE_VERSION,
			true
		);
		wp_script_add_data( self::SCRIPT_HANDLE, 'defer', true );

		// Tokens are single-use: AJAX forms (Basic Forms, CF7, WPForms, GF, Elementor,
		// Fluent) keep the page — and the redeemed widget — after a submission, so the
		// next attempt would post a dead token. This refreshes widgets post-submission.
		$form_reset = $this->moduleAssetUrl( 'assets/form-reset.js' );
		if ( $form_reset !== '' ) {
			wp_enqueue_script(
				self::FORM_RESET_HANDLE,
				$form_reset,
				[ 'jquery', self::SCRIPT_HANDLE ],
				self::MODULE_VERSION,
				true
			);
		}
	}

	/**
	 * Prevent Cloudflare Rocket Loader from deferring Turnstile (matches ASE / Switchboard behavior).
	 *
	 * @param string $tag    Script HTML.
	 * @param string $handle Handle.
	 * @return string
	 */
	public function filterTurnstileScriptLoaderTag( string $tag, string $handle ): string {
		if ( $handle !== self::SCRIPT_HANDLE || str_contains( $tag, 'data-cfasync' ) ) {
			return $tag;
		}

		// Insert after `<' . 'script` so minifiers / attribute order cannot break matching on `src=`.
		$tag_name = 'script';
		$updated  = preg_replace( '/^<' . $tag_name . '\b/i', '<' . $tag_name . ' data-cfasync="false"', $tag, 1 );

		return is_string( $updated ) && $updated !== '' ? $updated : $tag;
	}

	/**
	 * Attach the custom-login `template_redirect` callback only if another module
	 * may flag those requests, so the handler does not run on unrelated pages.
	 *
	 * @return void
	 */
	public function registerCustomLoginUrlTurnstileHook(): void {
		if ( self::$custom_login_url_template_redirect_attached ) {
			return;
		}
		if ( ! CaptchaRuntime::needsWpLoginScreenAssets( $this->getSettings() ) || ! has_filter( 'wp_powersuite_is_custom_login_url_request' ) ) {
			return;
		}
		self::$custom_login_url_template_redirect_attached = true;
		add_action( 'template_redirect', [ $this, 'enqueueTurnstileScriptForCustomLoginUrl' ], 0 );
	}

	/**
	 * Pre-enqueue Turnstile when Change Login URL masks wp-login.php (request never hits normal wp_enqueue_scripts).
	 *
	 * @return void
	 */
	public function enqueueTurnstileScriptForCustomLoginUrl(): void {
		if ( ! CaptchaRuntime::needsWpLoginScreenAssets( $this->getSettings() ) ) {
			return;
		}
		if ( ! apply_filters( 'wp_powersuite_is_custom_login_url_request', false ) ) {
			return;
		}
		$this->enqueueTurnstileScript();
		LoginLayoutStyles::enqueueStandalone();
	}

	/**
	 * Render Turnstile widget.
	 *
	 * @param string               $action  Optional action name for the widget.
	 * @param array<string, mixed> $options Optional. `match_container_width` (bool) for narrow forms (Woo, Basic Forms).
	 * @return void
	 */
	public function renderWidget( string $action = '', array $options = [] ): void {
		$settings = $this->getSettings();
		$options  = WidgetLayoutStyles::mergeRenderOptions( $settings, $options );
		$site_key = $settings['site_key'] ?? '';
		if ( empty( $site_key ) ) {
			return;
		}

		if ( SkipRules::shouldSkip( is_array( $settings['skip_rules'] ?? null ) ? $settings['skip_rules'] : [] ) ) {
			return;
		}

		/*
		 * Never cold-probe Cloudflare during HTML render — that was a blocking wp_remote_get
		 * (up to 5s) that delayed the widget. Failsafe UI uses a cached probe only; verification
		 * may still probe when a token is missing. Always print the widget for visitors.
		 */
		$failsafe_mode = (string) ( $settings['failsafe_mode'] ?? 'off' );
		if ( in_array( $failsafe_mode, [ 'allow', 'recaptcha' ], true ) ) {
			self::maybeScheduleBackgroundReachabilityProbe();
			if ( self::isCloudflareApiUnreachableCached() ) {
				if ( $failsafe_mode === 'allow' ) {
					$token = self::createFailsafeAllowToken();
					if ( $token !== '' ) {
						echo '<input type="hidden" name="wpps_turnstile_failsafe" value="' . esc_attr( $token ) . '" />';
					}
				} elseif ( $failsafe_mode === 'recaptcha' ) {
					$r_site = trim( (string) ( $settings['failsafe_recaptcha_site_key'] ?? '' ) );
					if ( $r_site !== '' ) {
						wp_enqueue_script( 'wpps-google-recaptcha', 'https://www.google.com/recaptcha/api.js', [], self::MODULE_VERSION, true );
						echo '<input type="hidden" name="wpps_turnstile_failsafe" value="recaptcha" />';
						echo '<div class="g-recaptcha" data-sitekey="' . esc_attr( $r_site ) . '"></div>';
						return;
					}
				}
			}
		}

		$this->ensureTurnstileScriptQueued();

		$theme = $settings['theme'] ?? 'auto';

		$appearance = (string) ( $settings['widget_visible_appearance'] ?? 'interaction-only' );
		if ( ! in_array( $appearance, [ 'always', 'interaction-only' ], true ) ) {
			$appearance = 'interaction-only';
		}

		$core_form_fit = ! empty( $options['match_container_width'] );

		$html_id          = isset( $options['html_id'] ) ? trim( (string) $options['html_id'] ) : '';
		$html_id          = preg_replace( '/[^a-zA-Z0-9_-]/', '', $html_id );
		$unlock_selectors = isset( $options['unlock_selectors'] ) ? trim( (string) $options['unlock_selectors'] ) : '';
		$submit_callback  = isset( $options['submit_callback'] ) ? preg_replace( '/[^a-zA-Z0-9_]/', '', (string) $options['submit_callback'] ) : '';

		$disable_submit = ! empty( $settings['disable_submit'] ) && $unlock_selectors !== '';
		if ( $disable_submit ) {
			$this->enqueueDisableSubmitScript();
			$san_unlock = preg_replace( '/[^#\.a-zA-Z0-9 _\-:\[\]=,"\']/u', '', $unlock_selectors );
			if ( $san_unlock !== '' ) {
				\WP_PowerSuite\Core\InlineAssets::style(
					'wpps-turnstile-unlock',
					$san_unlock . '{pointer-events:none!important;opacity:0.5!important;}'
				);
			}
		}

		$attrs = [
			'data-sitekey'    => $site_key,
			'data-theme'      => $theme,
			'data-size'       => WidgetLayoutStyles::turnstileApiSize( $settings ),
			'data-appearance' => $appearance,
		];

		$lang = trim( (string) ( $settings['widget_language'] ?? '' ) );
		if ( $lang !== '' ) {
			$attrs['data-language'] = $lang;
		}

		$attrs['data-retry']           = 'auto';
		$attrs['data-retry-interval']  = '1000';
		$attrs['data-refresh-expired'] = 'auto';
		$attrs['data-refresh-timeout'] = (string) ( $settings['refresh_timeout'] ?? 'auto' );

		if ( $disable_submit && $submit_callback !== '' ) {
			$attrs['data-callback'] = $submit_callback;
		}

		WidgetLayoutStyles::printFrontEndOnce();

		$cushion_class = 'wpps-captcha-cushion';
		if ( $core_form_fit ) {
			$cushion_class .= ' wpps-captcha-core-form-fit';
		}

		// Checkout opts out of the `cf-turnstile` implicit scan so woo-checkout.js owns the
		// widget lifecycle (Store API token, fragment refresh, reset between attempts).
		$widget_class = ! empty( $options['explicit_only'] )
			? 'wpps-turnstile-widget wpps-turnstile-explicit'
			: 'cf-turnstile wpps-turnstile-widget';

		echo '<div class="' . esc_attr( $cushion_class ) . '" style="clear:both;margin-bottom:16px;">';
		echo '<div class="wpps-turnstile-embed">';
		echo '<div class="' . esc_attr( $widget_class ) . '"';
		if ( $html_id !== '' ) {
			echo ' id="' . esc_attr( $html_id ) . '"';
		}
		foreach ( $attrs as $key => $val ) {
			echo ' ' . esc_attr( $key ) . '="' . esc_attr( (string) $val ) . '"';
		}
		if ( is_string( $action ) && $action !== '' ) {
			echo ' data-action="' . esc_attr( $action ) . '"';
		}
		echo '></div>';
		echo '</div></div>';
	}

	public function enqueueDisableSubmitScript(): void {
		if ( wp_script_is( self::DISABLE_SUBMIT_HANDLE, 'enqueued' ) ) {
			return;
		}
		$url = $this->moduleAssetUrl( 'assets/disable-submit.js' );
		if ( $url === '' ) {
			return;
		}
		wp_enqueue_script(
			self::DISABLE_SUBMIT_HANDLE,
			$url,
			[ self::SCRIPT_HANDLE ],
			self::MODULE_VERSION,
			true
		);
	}

	public function filterVerifyTurnstile( bool $verified, string $token, string $action = '' ): bool {
		if ( $verified ) {
			return true;
		}

		return $this->verifyToken( $token, $action );
	}

	/**
	 * When "Disable submit until solved" is on, pass unlock selectors + a global callback defined in disable-submit.js.
	 *
	 * @return array<string, string>
	 */
	public function disableSubmitPair( string $js_callback, string $unlock_selectors ): array {
		if ( empty( $this->getSettings()['disable_submit'] ) ) {
			return [];
		}
		$cb = preg_replace( '/[^a-zA-Z0-9_]/', '', $js_callback );
		if ( $cb === '' ) {
			return [];
		}
		$san = preg_replace( '/[^#\.a-zA-Z0-9 _\-:\[\]=,"\']/u', '', $unlock_selectors );
		if ( $san === '' ) {
			return [];
		}

		return [
			'submit_callback'  => $cb,
			'unlock_selectors' => $san,
		];
	}

	private function isRequestPathWpLogin(): bool {
		$login_path = wp_parse_url( wp_login_url(), PHP_URL_PATH );
		$req_uri    = \WP_PowerSuite\Core\RequestSanitizer::requestUri();
		$req_path   = wp_parse_url( $req_uri, PHP_URL_PATH );
		if ( ! is_string( $login_path ) || $login_path === '' || ! is_string( $req_path ) || $req_path === '' ) {
			return false;
		}

		return untrailingslashit( $login_path ) === untrailingslashit( $req_path );
	}

	private function isWpLoginRegisterScreen(): bool {
		if ( ! $this->isRequestPathWpLogin() ) {
			return false;
		}
		$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( (string) $_REQUEST['action'] ) ) : '';

		return $action === 'register';
	}

	/**
	 * Render Turnstile on wp-login.php with data-action matching validateLogin().
	 *
	 * @return void
	 */
	public function renderLoginWidget(): void {
		if ( CaptchaRuntime::isMagicLoginLoginScreen() ) {
			return;
		}
		$s = $this->getSettings();
		if ( ! empty( $s['login_canonical_only'] ) && ! $this->isRequestPathWpLogin() ) {
			return;
		}
		$this->renderWidget(
			'login',
			$this->disableSubmitPair( 'turnstileWPCallback', '#wp-submit' )
		);
	}

	/**
	 * Render Turnstile on registration with data-action matching validateRegistration().
	 *
	 * @return void
	 */
	public function renderRegisterWidget(): void {
		$s = $this->getSettings();
		if ( ! empty( $s['register_canonical_only'] ) && ! $this->isWpLoginRegisterScreen() ) {
			return;
		}
		$this->renderWidget(
			'register',
			$this->disableSubmitPair( 'turnstileWPCallback', '#wp-submit' )
		);
	}

	/**
	 * Render Turnstile on lost-password flows with data-action matching validatePasswordReset().
	 *
	 * @return void
	 */
	public function renderLostPasswordWidget(): void {
		$pair = [];
		if ( current_filter() === 'woocommerce_lostpassword_form' ) {
			$pair = $this->disableSubmitPair( 'turnstileWooResetCallback', '.woocommerce-ResetPassword .button' );
		} else {
			$pair = $this->disableSubmitPair( 'turnstileWPCallback', '#wp-submit' );
		}

		$this->renderWidget( 'lostpassword', $pair );
	}

	/**
	 * Render Turnstile on comments with data-action matching validateComment().
	 *
	 * @return void
	 */
	public function renderCommentWidget(): void {
		$this->renderWidget(
			'comment',
			$this->disableSubmitPair(
				'turnstileWppsCommentCallback',
				'#commentform input#submit, #commentform button[type="submit"]'
			)
		);
	}

	/**
	 * Render Turnstile on WooCommerce login with data-action matching validateWooLogin().
	 *
	 * @return void
	 */
	public function renderWooLoginWidget(): void {
		$this->renderWidget(
			'woo_login',
			$this->disableSubmitPair( 'turnstileWooLoginCallback', '.woocommerce-form-login__submit' )
		);
	}

	/**
	 * Render Turnstile on WooCommerce registration with data-action matching validateWooRegistration().
	 *
	 * @return void
	 */
	public function renderWooRegisterWidget(): void {
		$this->renderWidget(
			'woo_register',
			$this->disableSubmitPair( 'turnstileWooRegisterCallback', '.woocommerce-form-register__submit' )
		);
	}

	private function renderWooCheckoutWidgetMarkup(): void {
		$s = $this->getSettings();
		if ( ! empty( $s['woo_checkout_guest_only'] ) && is_user_logged_in() ) {
			return;
		}
		// Unlock CSS still applied via disableSubmitPair; checkout JS owns the success callback
		// so Store API extension data and data-action stay in sync after fragment refresh.
		$opts = [
			'html_id'       => 'cf-turnstile-woo-checkout',
			'explicit_only' => true,
		];
		if ( ! empty( $s['disable_submit'] ) ) {
			$opts['unlock_selectors'] = '#place_order, .wc-block-components-checkout-place-order-button, .wc-block-checkout__actions_row .wc-block-components-button';
			// Placeholder callback name kept for disable-submit.js unlock helper; woo-checkout.js wires the real callback.
			$opts['submit_callback'] = 'turnstileWooCheckoutCallback';
		}
		$this->renderWidget( 'woo_checkout', $opts );
	}

	/**
	 * Render Turnstile on checkout with data-action matching validateWooCheckout().
	 *
	 * @return void
	 */
	public function renderWooCheckoutWidget(): void {
		if ( self::$woo_checkout_classic_emitted ) {
			return;
		}
		self::$woo_checkout_classic_emitted = true;
		$this->renderWooCheckoutWidgetMarkup();
	}

	/**
	 * Woo Blocks: mount the widget immediately before the Place Order button.
	 *
	 * Official WooCommerce guidance targets `checkout-actions-block`. Client-side
	 * insertBefore into `.wc-block-checkout__actions_row` is what threw React #60 —
	 * this only prints SSR markup; woo-checkout.js must not invent nodes inside
	 * React-owned parents.
	 *
	 * @param string $block_content Rendered block HTML.
	 * @return string
	 */
	public function prependTurnstileToCheckoutActionsBlock( string $block_content ): string {
		if ( ! function_exists( 'is_checkout' ) || ! is_checkout() ) {
			return $block_content;
		}
		$s = $this->getSettings();
		if ( empty( $s['woo_checkout'] ) ) {
			return $block_content;
		}
		if ( ! empty( $s['woo_checkout_guest_only'] ) && is_user_logged_in() ) {
			return $block_content;
		}
		if ( SkipRules::shouldSkip( is_array( $s['skip_rules'] ?? null ) ? $s['skip_rules'] : [] ) ) {
			return $block_content;
		}

		if ( self::$woo_checkout_block_emitted ) {
			return $block_content;
		}
		self::$woo_checkout_block_emitted = true;

		ob_start();
		echo '<div id="wpps-turnstile-blocks-host" class="wpps-captcha-cushion" style="clear:both;margin:0 0 16px;">';
		$this->renderWooCheckoutWidgetMarkup();
		echo '</div>';
		return (string) ob_get_clean() . $block_content;
	}

	/**
	 * Register Store API extension so Blocks checkout can POST our namespace + token.
	 *
	 * @return void
	 */
	public function registerWooCheckoutStoreApiExtension(): void {
		if ( self::$woo_store_api_registered ) {
			return;
		}
		if ( ! function_exists( 'woocommerce_store_api_register_endpoint_data' ) ) {
			return;
		}

		$schema_type = 'array';
		$abstract    = \Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema::class;
		if ( class_exists( $abstract ) && defined( $abstract . '::ARRAY_TYPE' ) ) {
			$schema_type = constant( $abstract . '::ARRAY_TYPE' );
		}

		woocommerce_store_api_register_endpoint_data(
			[
				'endpoint'        => 'checkout',
				'namespace'       => self::WOO_STORE_API_NAMESPACE,
				'schema_callback' => static function () {
					return [
						'token' => [
							'description' => __( 'Turnstile response token.', 'wppowersuite-lite' ),
							'type'        => 'string',
							'context'     => [ 'view', 'edit' ],
							'readonly'    => false,
						],
					];
				},
				'schema_type'     => $schema_type,
			]
		);
		self::$woo_store_api_registered = true;
	}

	/**
	 * Public URL for a file next to this module (Lite path or Pro overlay).
	 */
	private function moduleAssetUrl( string $relative ): string {
		if ( function_exists( 'wp_powersuite_module_asset_url' ) ) {
			$url = wp_powersuite_module_asset_url( __FILE__, $relative );
			if ( is_string( $url ) && $url !== '' ) {
				return $url;
			}
		}

		return plugins_url( $relative, __FILE__ );
	}

	/**
	 * Validate extension token on Woo Blocks checkout (Store API).
	 *
	 * @param \WC_Order|\WC_Order|null $order Order instance.
	 * @param \WP_REST_Request         $request Request.
	 * @return void
	 */
	public function validateWooCheckoutStoreApi( $order, $request ): void {
		unset( $order );
		if ( ! $request instanceof \WP_REST_Request ) {
			return;
		}

		$s = $this->getSettings();
		if ( ! empty( $s['woo_checkout_guest_only'] ) && is_user_logged_in() ) {
			return;
		}
		if ( SkipRules::shouldSkip( is_array( $s['skip_rules'] ?? null ) ? $s['skip_rules'] : [] ) ) {
			return;
		}
		// Apple Pay / Google Pay / Link sheets replace the checkout form, so the widget is
		// never shown or solvable — requiring a token would reject every wallet order.
		if ( WooCheckoutContext::isExpressPayStoreApi( $request ) ) {
			return;
		}

		$params = $request->get_json_params();
		if ( ! is_array( $params ) ) {
			$params = [];
		}
		$ext   = $params['extensions'][ self::WOO_STORE_API_NAMESPACE ] ?? [];
		$token = is_array( $ext ) && isset( $ext['token'] ) ? (string) $ext['token'] : '';
		if ( $token === '' ) {
			foreach ( [ 'cf-turnstile-response', 'cf_turnstile_response' ] as $key ) {
				if ( isset( $params[ $key ] ) && is_string( $params[ $key ] ) && $params[ $key ] !== '' ) {
					$token = $params[ $key ];
					break;
				}
			}
		}
		if ( $token === '' ) {
			$token = $this->getTokenFromRequest();
		}
		if ( strlen( $token ) > self::TOKEN_MAX_LEN ) {
			$token = '';
		}

		if ( $this->verifyToken( $token, 'woo_checkout' ) ) {
			return;
		}

		$msg = $this->errorMessage( __( 'Please complete the security verification to complete your order.', 'wppowersuite-lite' ), 'woo_checkout' );

		if ( class_exists( \Automattic\WooCommerce\StoreApi\Exceptions\RouteException::class ) ) {
			throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
				'wpps_turnstile_checkout',
				esc_html( wp_strip_all_tags( $msg ) ),
				400
			);
		}

		throw new \RuntimeException( esc_html( wp_strip_all_tags( $msg ) ) );
	}

	public function enqueueWooCheckoutFrontScript(): void {
		$s = $this->getSettings();
		if ( empty( $s['woo_checkout'] ) ) {
			return;
		}
		if ( ! function_exists( 'is_checkout' ) || ! is_checkout() ) {
			return;
		}
		if ( ! empty( $s['woo_checkout_guest_only'] ) && is_user_logged_in() ) {
			return;
		}
		if ( SkipRules::shouldSkip( is_array( $s['skip_rules'] ?? null ) ? $s['skip_rules'] : [] ) ) {
			return;
		}

		$script_url = $this->moduleAssetUrl( 'assets/woo-checkout.js' );
		if ( $script_url === '' ) {
			return;
		}

		$this->ensureTurnstileScriptQueued();
		if ( ! empty( $s['disable_submit'] ) ) {
			$this->enqueueDisableSubmitScript();
		}

		$deps = [ 'jquery', self::SCRIPT_HANDLE ];
		if ( wp_script_is( 'wc-blocks-checkout', 'registered' ) ) {
			$deps[] = 'wc-blocks-checkout';
		}

		wp_enqueue_script(
			self::WOO_CHECKOUT_FRONT_HANDLE,
			$script_url,
			$deps,
			self::MODULE_VERSION,
			true
		);
		wp_localize_script(
			self::WOO_CHECKOUT_FRONT_HANDLE,
			'wpPsTurnstileWoo',
			[
				'ns'                  => self::WOO_STORE_API_NAMESPACE,
				'siteKey'             => (string) ( $s['site_key'] ?? '' ),
				'action'              => 'woo_checkout',
				'theme'               => $s['theme'] ?? 'auto',
				'apiSize'             => WidgetLayoutStyles::turnstileApiSize( $s ),
				'appearance'          => $s['widget_visible_appearance'] ?? 'interaction-only',
				'language'            => (string) ( $s['widget_language'] ?? '' ),
				'refreshTimeout'      => (string) ( $s['refresh_timeout'] ?? 'auto' ),
			]
		);
	}

	/**
	 * Render widget for product reviews (WooCommerce) - only on product single.
	 *
	 * @return void
	 */
	public function maybeRenderWidgetForReviews(): void {
		if ( ! is_singular( 'product' ) ) {
			return;
		}
		$this->renderWidget(
			'woo_review',
			$this->disableSubmitPair(
				'turnstileWppsCommentCallback',
				'#commentform input#submit, #commentform button[type="submit"]'
			)
		);
	}

	/**
	 * Cached probe: when Cloudflare cannot be reached, optional failsafe modes apply.
	 * May perform a short network check (used on verify, not during widget HTML render).
	 */
	public static function isCloudflareApiUnreachable(): bool {
		if ( self::$turnstile_endpoint_reachable !== null ) {
			return ! self::$turnstile_endpoint_reachable;
		}
		self::$turnstile_endpoint_reachable = self::measureTurnstileEndpointReachable();
		return ! self::$turnstile_endpoint_reachable;
	}

	/**
	 * Transient / request-memo only — never blocks on HTTP. Used while printing widgets.
	 */
	private static function isCloudflareApiUnreachableCached(): bool {
		if ( self::$turnstile_endpoint_reachable !== null ) {
			return ! self::$turnstile_endpoint_reachable;
		}
		$cached = get_transient( 'wpps_turnstile_cf_probe' );
		if ( $cached === '1' ) {
			self::$turnstile_endpoint_reachable = true;
			return false;
		}
		if ( $cached === '0' ) {
			self::$turnstile_endpoint_reachable = false;
			return true;
		}

		// Unknown: assume reachable so first paint is never delayed by a server-side probe.
		return false;
	}

	/**
	 * Warm the reachability cache after the response is sent (failsafe modes only).
	 */
	private static function maybeScheduleBackgroundReachabilityProbe(): void {
		static $scheduled = false;
		if ( $scheduled ) {
			return;
		}
		if ( get_transient( 'wpps_turnstile_cf_probe' ) !== false ) {
			return;
		}
		$scheduled = true;
		add_action( 'shutdown', [ self::class, 'runBackgroundReachabilityProbe' ], 0 );
	}

	/**
	 * @return void
	 */
	public static function runBackgroundReachabilityProbe(): void {
		if ( get_transient( 'wpps_turnstile_cf_probe' ) !== false ) {
			return;
		}
		self::measureTurnstileEndpointReachable();
	}

	private static function measureTurnstileEndpointReachable(): bool {
		$cached = get_transient( 'wpps_turnstile_cf_probe' );
		if ( $cached === '1' ) {
			return true;
		}
		if ( $cached === '0' ) {
			return false;
		}

		$response = wp_remote_get(
			self::TURNSTILE_SCRIPT . '?render=explicit',
			[
				'timeout'     => 2,
				'redirection' => 1,
			]
		);
		$ok = false;
		if ( ! is_wp_error( $response ) ) {
			$code = (int) wp_remote_retrieve_response_code( $response );
			$ok   = $code >= 200 && $code < 400;
		}

		set_transient( 'wpps_turnstile_cf_probe', $ok ? '1' : '0', 15 * MINUTE_IN_SECONDS );

		return $ok;
	}

	private function verifyGoogleRecaptchaResponse( string $response, array $settings ): bool {
		$secret = trim( (string) ( $settings['failsafe_recaptcha_secret_key'] ?? '' ) );
		if ( $secret === '' || $response === '' || strlen( $response ) > 8192 ) {
			return false;
		}

		$r = wp_remote_post(
			'https://www.google.com/recaptcha/api/siteverify',
			[
				'timeout' => 12,
				'body'    => [
					'secret'   => $secret,
					'response' => $response,
				],
			]
		);

		if ( is_wp_error( $r ) ) {
			return false;
		}

		$body = json_decode( (string) wp_remote_retrieve_body( $r ), true );

		return is_array( $body ) && ! empty( $body['success'] );
	}

	/**
	 * Validate Turnstile token.
	 *
	 * @param string $token Token from form submission.
	 * @param string $action Optional action to verify.
	 * @return bool True if valid.
	 */
	private function verifyToken( string $token, string $action = '' ): bool {
		self::$last_verify_failure = null;

		// Roadmap #28: skip allowed roles / IPs before touching siteverify to keep staff
		// flows friction-free and to avoid consuming tokens unnecessarily.
		$settings = $this->getSettings();
		if ( SkipRules::shouldSkip( $settings['skip_rules'] ?? [] ) ) {
			return true;
		}

		// When a Turnstile token is present, always try siteverify — do not short-circuit on the server probe,
		// which can disagree with whether siteverify or the visitor widget actually works.
		$token_present = $token !== '' && strlen( $token ) <= self::TOKEN_MAX_LEN;
		if ( ! $token_present && self::isCloudflareApiUnreachable() ) {
			$failsafe_mode = $settings['failsafe_mode'] ?? 'off';
			$marker        = isset( $_POST['wpps_turnstile_failsafe'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['wpps_turnstile_failsafe'] ) ) : '';
			if ( $failsafe_mode === 'allow' && self::verifyFailsafeAllowToken( $marker ) ) {
				return true;
			}
			if ( $failsafe_mode === 'recaptcha' && $marker === 'recaptcha' ) {
				$gr = isset( $_POST['g-recaptcha-response'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['g-recaptcha-response'] ) ) : '';
				if ( $gr !== '' && $this->verifyGoogleRecaptchaResponse( $gr, $settings ) ) {
					return true;
				}
			}
		}

		if ( $token === '' || strlen( $token ) > self::TOKEN_MAX_LEN ) {
			$this->recordOutcome( false );
			return false;
		}

		$memo_key = hash( 'sha256', $action . "\0" . $token );
		if ( isset( self::$siteverify_ok_memo[ $memo_key ] ) ) {
			return true;
		}

		$secret_key = $settings['secret_key'] ?? '';
		if ( empty( $secret_key ) ) {
			$this->recordOutcome( false );
			return false;
		}

		// Siteverify only accepts secret, response, remoteip (optional), idempotency_key — NOT "action".
		// Sending action in the POST body can yield bad-request / failed verification (see Cloudflare Turnstile docs).
		$body = [
			'secret'   => $secret_key,
			'response' => $token,
		];

		// Only a publicly routable IP can match what Cloudflare saw solve the challenge;
		// loopback/private addresses (local stacks, misconfigured proxies) can only
		// mismatch and cause spurious failures, so omit the optional field instead.
		$ip = ClientIp::resolvePublicOrEmpty();
		if ( $ip !== '' ) {
			$body['remoteip'] = $ip;
		}

		$response = wp_remote_post(
			self::SITEVERIFY_URL,
			[
				'body'    => $body,
				'timeout' => 15,
			]
		);

		if ( is_wp_error( $response ) ) {
			self::$last_verify_failure = 'network';
			$this->recordOutcome( false );
			return false;
		}

		$raw  = wp_remote_retrieve_body( $response );
		$data = json_decode( $raw, true );

		if ( ! is_array( $data ) || empty( $data['success'] ) ) {
			self::$last_verify_failure = 'api';
			if ( is_array( $data ) && ! empty( $data['error-codes'] ) ) {
				SecretRedactor::safeLog( 'Turnstile siteverify error-codes: ' . wp_json_encode( $data['error-codes'] ), [ $secret_key ] );
			}
			$this->recordOutcome( false );
			return false;
		}

		// Cloudflare's documented test keys return a canned payload with no `action` and a
		// placeholder hostname, so the two assertions below can never pass with them and every
		// protected form fails while the widget still shows success. Honour Cloudflare's own
		// marker for that response so QA can use the published test keys end to end. Nothing
		// real is relaxed: test secrets accept any dummy token from anyone, so a site
		// configured with them has no protection regardless of these checks.
		$is_testing_key = isset( $data['metadata']['result_with_testing_key'] )
			&& true === $data['metadata']['result_with_testing_key'];

		// Action is returned in the verification JSON when data-action is set on the widget.
		// Some widget modes omit it even after a valid solve — treat a missing action as
		// unknown, and only reject a concrete mismatch (e.g. a login token posted to checkout).
		if ( $action !== '' && ! $is_testing_key ) {
			$got = isset( $data['action'] ) ? (string) $data['action'] : '';
			if ( $got !== '' && ! hash_equals( $action, $got ) ) {
				self::$last_verify_failure = 'api';
				$this->recordOutcome( false );
				return false;
			}
		}

		// Cloudflare recommends validating hostname from siteverify against expected front-end hosts.
		if ( ! $is_testing_key && ! $this->hostnameMatchesSite( isset( $data['hostname'] ) ? (string) $data['hostname'] : '' ) ) {
			self::$last_verify_failure = 'api';
			SecretRedactor::safeLog( 'Turnstile siteverify hostname mismatch: ' . (string) ( $data['hostname'] ?? '' ), [ $secret_key ] );
			$this->recordOutcome( false );
			return false;
		}

		self::$siteverify_ok_memo[ $memo_key ] = true;
		$this->recordOutcome( true );

		return true;
	}

	/**
	 * Whether a siteverify hostname is allowed for this site.
	 *
	 * Enforced by default for real keys. Cloudflare test-key responses are exempted before
	 * this method because their canned hostname cannot match the current site.
	 *
	 * @param string $hostname Hostname from Cloudflare siteverify JSON.
	 */
	private function hostnameMatchesSite( string $hostname ): bool {
		if ( ! apply_filters( 'wp_powersuite_turnstile_enforce_hostname', true ) ) {
			return true;
		}

		$hostname = strtolower( trim( $hostname ) );
		if ( $hostname === '' ) {
			// Older / partial responses may omit hostname; do not hard-fail when absent.
			return true;
		}

		$allowed = [];
		foreach ( [ home_url(), site_url() ] as $url ) {
			$host = wp_parse_url( (string) $url, PHP_URL_HOST );
			if ( ! is_string( $host ) || $host === '' ) {
				continue;
			}
			$host = strtolower( $host );
			$allowed[] = $host;
			if ( str_starts_with( $host, 'www.' ) ) {
				$allowed[] = substr( $host, 4 );
			} else {
				$allowed[] = 'www.' . $host;
			}
		}
		$allowed = array_values( array_unique( array_filter( $allowed ) ) );

		/**
		 * Filter allowed Turnstile siteverify hostnames.
		 *
		 * @param list<string> $allowed  Lowercase hostnames.
		 * @param string       $hostname Hostname returned by Cloudflare.
		 */
		$allowed = apply_filters( 'wp_powersuite_turnstile_allowed_hostnames', $allowed, $hostname );
		if ( ! is_array( $allowed ) || $allowed === [] ) {
			return true;
		}

		foreach ( $allowed as $host ) {
			$host = strtolower( trim( (string) $host ) );
			if ( $host !== '' && hash_equals( $host, $hostname ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Record a pass/fail event for the shared dashboard counters (roadmap item #29).
	 */
	private function recordOutcome( bool $passed ): void {
		Counters::record( self::MODULE_ID, $passed );
	}

	/**
	 * Resolve a user-facing failure message, filterable through the cross-module
	 * `wpps_captcha_error_message` filter (roadmap #19).
	 */
	private function errorMessage( string $default, string $context ): string {
		if ( self::$last_verify_failure === 'network' ) {
			$default = __( 'Security verification is temporarily unavailable. Please try again in a moment.', 'wppowersuite-lite' );
		}
		$filtered = apply_filters( 'wpps_captcha_error_message', $default, $context, self::MODULE_ID );
		return is_string( $filtered ) && $filtered !== '' ? $filtered : $default;
	}

	/**
	 * Get Turnstile token from request.
	 *
	 * Form submissions always populate $_POST — reading from $_REQUEST risks picking up
	 * a stale GET/cookie value and verifying a token the user never actually submitted.
	 * JSON-body submissions are covered by getTurnstileTokenFromJsonRequestBody().
	 */
	private function getTokenFromRequest(): string {
		foreach ( [ 'cf-turnstile-response', 'cf_turnstile_response' ] as $k ) {
			$t = TokenReader::fromPost( $k, self::TOKEN_MAX_LEN );
			if ( $t !== '' ) {
				return apply_filters( 'wp_powersuite_turnstile_response_token', $t );
			}
		}

		$from_json = $this->getTurnstileTokenFromJsonRequestBody();
		if ( $from_json !== '' && strlen( $from_json ) > self::TOKEN_MAX_LEN ) {
			$from_json = '';
		}
		return apply_filters( 'wp_powersuite_turnstile_response_token', $from_json );
	}

	/**
	 * Extract token from JSON body (REST-style submissions).
	 *
	 * @return string
	 */
	private function getTurnstileTokenFromJsonRequestBody(): string {
		$ctype = \WP_PowerSuite\Core\RequestSanitizer::server( 'CONTENT_TYPE' );
		if ( $ctype === '' ) {
			$ctype = \WP_PowerSuite\Core\RequestSanitizer::server( 'HTTP_CONTENT_TYPE' );
		}
		if ( $ctype === '' || stripos( $ctype, 'application/json' ) === false ) {
			return '';
		}

		$raw = file_get_contents( 'php://input' );
		if ( ! is_string( $raw ) || $raw === '' ) {
			return '';
		}

		$data = json_decode( $raw, true );
		if ( ! is_array( $data ) ) {
			return '';
		}

		return $this->extractTurnstileTokenFromNestedArray( $data );
	}

	/**
	 * Find Turnstile response in arrays from form plugins (nested $_POST-style payloads).
	 *
	 * @param array<string, mixed> $data Data.
	 * @return string
	 */
	private function extractTurnstileTokenFromNestedArray( array $data ): string {
		$keys = [ 'cf-turnstile-response', 'cf_turnstile_response' ];
		foreach ( $keys as $k ) {
			if ( isset( $data[ $k ] ) && is_string( $data[ $k ] ) && $data[ $k ] !== '' ) {
				return $this->sanitizeTurnstileToken( $data[ $k ] );
			}
		}
		foreach ( [ 'data', 'posted_data', 'fields', 'response' ] as $nest ) {
			if ( empty( $data[ $nest ] ) || ! is_array( $data[ $nest ] ) ) {
				continue;
			}
			$inner = $data[ $nest ];
			foreach ( $keys as $k ) {
				if ( isset( $inner[ $k ] ) && is_string( $inner[ $k ] ) && $inner[ $k ] !== '' ) {
					return $this->sanitizeTurnstileToken( $inner[ $k ] );
				}
			}
		}

		return '';
	}

	/**
	 * Normalize an opaque Turnstile token without mutating valid provider characters.
	 */
	private function sanitizeTurnstileToken( string $token ): string {
		$token = str_replace( "\0", '', $token );
		$token = preg_replace( '/[\r\n\t]+/', '', $token ) ?? '';
		$token = trim( $token );
		if ( $token === '' || strlen( $token ) > self::TOKEN_MAX_LEN ) {
			return '';
		}

		return $token;
	}

	/**
	 * Whether this request is WooCommerce My Account login (not wp-login.php).
	 *
	 * WC validates Turnstile in {@see validateWooLogin()} with action `woo_login`. {@see wp_signon()}
	 * then runs `authenticate`; core {@see validateLogin()} must not re-verify the same token as `login`
	 * (wrong action / single-use duplicate).
	 *
	 * @return bool
	 */
	private function isWooCommerceMyAccountLoginSubmission(): bool {
		// WooCommerce uses woocommerce-login-nonce + username; wp-login.php always POSTs log (username field).
		return isset( $_POST['woocommerce-login-nonce'] ) && ! isset( $_POST['log'] );
	}

	/**
	 * Whether this request is WooCommerce registration on My Account.
	 *
	 * Same pattern as login: skip core registration_errors when Woo handler uses `woo_register`.
	 *
	 * @return bool
	 */
	private function isWooCommerceRegistrationSubmission(): bool {
		return CaptchaFormContext::isWooMyAccountRegistrationPost();
	}

	/**
	 * Whether this request is an actual wp-login.php credential submission.
	 *
	 * WordPress calls wp_signon() on GET for the login screen as part of its
	 * normal flow. We should only enforce Turnstile when credentials were posted.
	 *
	 * @return bool
	 */
	private function isCoreLoginSubmission(): bool {
		$request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
		if ( $request_method !== 'POST' ) {
			return false;
		}

		return isset( $_POST['log'], $_POST['pwd'] );
	}

	/**
	 * Validate WordPress login.
	 *
	 * @param \WP_User|\WP_Error|null $user User or error.
	 * @param string                  $username Username.
	 * @param string                  $password Password.
	 * @return \WP_User|\WP_Error|null
	 */
	public function validateLogin( $user, string $username, string $password ) {
		$settings = $this->getSettings();
		if ( ! empty( $settings['woo_login'] ) && $this->isWooCommerceMyAccountLoginSubmission() ) {
			return $user;
		}
		// PMPro frontend login posts log/pwd too; ExtendedIntegrations verifies action `pmp_login`.
		// Only stand down when PMPro is actually loaded — otherwise a forged
		// `pmpro_login_form_used` field would skip captcha with no replacement check.
		if ( ! empty( $settings['integration_pmp'] ) && CaptchaFormContext::isPmproFrontendAuthPost() ) {
			return $user;
		}
		if ( ! $this->isCoreLoginSubmission() ) {
			return $user;
		}
		if ( ! empty( $settings['login_canonical_only'] ) && ! $this->isRequestPathWpLogin() ) {
			return $user;
		}

		if ( $this->verifyToken( $this->getTokenFromRequest(), 'login' ) ) {
			return $user;
		}
		return new \WP_Error(
			'turnstile_failed',
			$this->errorMessage( __( 'Please complete the security verification to log in.', 'wppowersuite-lite' ), 'login' )
		);
	}

	/**
	 * Validate WordPress registration.
	 *
	 * @param \WP_Error $errors Registration errors.
	 * @param string    $sanitized_user_login Sanitized login.
	 * @param string    $user_email User email.
	 * @return \WP_Error
	 */
	public function validateRegistration( \WP_Error $errors, string $sanitized_user_login, string $user_email ): \WP_Error {
		$settings = $this->getSettings();
		if ( ! empty( $settings['woo_registration'] ) && $this->isWooCommerceRegistrationSubmission() ) {
			return $errors;
		}
		if ( ! empty( $settings['register_canonical_only'] ) && ! $this->isWpLoginRegisterScreen() ) {
			return $errors;
		}

		if ( ! $this->verifyToken( $this->getTokenFromRequest(), 'register' ) ) {
			$errors->add( 'turnstile_failed', $this->errorMessage( __( 'Please complete the security verification to register.', 'wppowersuite-lite' ), 'register' ) );
		}
		return $errors;
	}

	/**
	 * Validate password reset.
	 *
	 * @param bool|\WP_Error $allow Whether to allow reset.
	 * @param int            $user_id User ID.
	 * @return bool|\WP_Error
	 */
	public function validatePasswordReset( $allow, int $user_id ) {
		// Only validate on lost password form submission (user_login is set).
		if ( ! isset( $_POST['user_login'] ) ) {
			return $allow;
		}
		// PMPro lost-password form; ExtendedIntegrations verifies action `pmp_lostpassword`.
		$settings = $this->getSettings();
		if ( ! empty( $settings['integration_pmp'] ) && CaptchaFormContext::isPmproFrontendAuthPost() ) {
			return $allow;
		}

		if ( $this->verifyToken( $this->getTokenFromRequest(), 'lostpassword' ) ) {
			return $allow;
		}

		return new \WP_Error(
			'turnstile_failed',
			$this->errorMessage( __( 'Please complete the security verification to reset your password.', 'wppowersuite-lite' ), 'lostpassword' )
		);
	}

	/**
	 * Render Turnstile on the set-new-password screen (after email reset link).
	 *
	 * @param \WP_User $user User resetting password.
	 */
	public function renderWidgetOnResetPass( $user ): void {
		unset( $user );
		$this->renderWidget(
			'resetpass',
			$this->disableSubmitPair( 'turnstileWPCallback', '#wp-submit' )
		);
	}

	/**
	 * Validate Turnstile when submitting the new password on resetpass action.
	 *
	 * @param \WP_Error          $errors Error object.
	 * @param \WP_User|\WP_Error $user   User or error from key check.
	 */
	public function validateResetPassForm( \WP_Error $errors, $user ): void {
		if ( $user instanceof \WP_Error ) {
			return;
		}
		if ( ! isset( $_POST['pass1'] ) ) {
			return;
		}
		if ( $this->verifyToken( $this->getTokenFromRequest(), 'resetpass' ) ) {
			return;
		}
		$errors->add(
			'turnstile_failed',
			$this->errorMessage( __( 'Please complete the security verification to reset your password.', 'wppowersuite-lite' ), 'resetpass' )
		);
	}

	/**
	 * Validate comment submission.
	 *
	 * @param array<string, mixed> $comment_data Comment data.
	 * @return array<string, mixed>
	 */
	public function validateComment( array $comment_data ): array {
		if ( apply_filters( 'wp_powersuite_turnstile_skip_verification', false, 'comment', $comment_data ) ) {
			return $comment_data;
		}

		self::$last_verify_failure = null;
		if ( $this->verifyToken( $this->getTokenFromRequest(), 'comment' ) ) {
			return $comment_data;
		}

		$message = $this->errorMessage( __( 'Please complete the security verification to post a comment.', 'wppowersuite-lite' ), 'comment' );
		$referer = wp_get_referer();
		if ( is_string( $referer ) && $referer !== '' ) {
			wp_safe_redirect( add_query_arg( 'wpps_turnstile_error', '1', $referer ) );
			exit;
		}

		wp_die(
			esc_html( $message ),
			esc_html__( 'Verification Required', 'wppowersuite-lite' ),
			[ 'response' => 403 ]
		);
	}

	/**
	 * Show a comment-form notice after a failed Turnstile verification redirect.
	 */
	public function renderCommentTurnstileErrorNotice(): void {
		if ( ! isset( $_GET['wpps_turnstile_error'] ) || sanitize_text_field( wp_unslash( (string) $_GET['wpps_turnstile_error'] ) ) !== '1' ) {
			return;
		}
		echo '<p class="wpps-turnstile-comment-error" role="alert" style="color:#b32d2e;margin-bottom:12px;">';
		echo esc_html( $this->errorMessage( __( 'Please complete the security verification to post a comment.', 'wppowersuite-lite' ), 'comment' ) );
		echo '</p>';
	}

	/**
	 * Validate product review (WooCommerce) when protect_comments is off.
	 *
	 * @param array<string, mixed> $comment_data Comment data.
	 * @return array<string, mixed>
	 */
	public function validateProductReview( array $comment_data ): array {
		$post_id = isset( $comment_data['comment_post_ID'] ) ? (int) $comment_data['comment_post_ID'] : 0;
		if ( $post_id <= 0 || get_post_type( $post_id ) !== 'product' ) {
			return $comment_data;
		}

		if ( apply_filters( 'wp_powersuite_turnstile_skip_verification', false, 'woo_review', $comment_data ) ) {
			return $comment_data;
		}

		self::$last_verify_failure = null;
		if ( $this->verifyToken( $this->getTokenFromRequest(), 'woo_review' ) ) {
			return $comment_data;
		}

		$message = $this->errorMessage( __( 'Please complete the security verification to post a review.', 'wppowersuite-lite' ), 'woo_review' );
		$referer = wp_get_referer();
		if ( is_string( $referer ) && $referer !== '' ) {
			wp_safe_redirect( add_query_arg( 'wpps_turnstile_error', '1', $referer ) );
			exit;
		}

		wp_die(
			esc_html( $message ),
			esc_html__( 'Verification Required', 'wppowersuite-lite' ),
			[ 'response' => 403 ]
		);
	}

	/**
	 * Validate WooCommerce login.
	 *
	 * @param \WP_Error $errors Errors.
	 * @param string    $username Username.
	 * @param string    $password Password.
	 * @return \WP_Error
	 */
	public function validateWooLogin( \WP_Error $errors, string $username, string $password ): \WP_Error {
		if ( ! $this->verifyToken( $this->getTokenFromRequest(), 'woo_login' ) ) {
			$errors->add( 'turnstile_failed', $this->errorMessage( __( 'Please complete the security verification to log in.', 'wppowersuite-lite' ), 'woo_login' ) );
		}
		return $errors;
	}

	/**
	 * Validate WooCommerce registration.
	 *
	 * @param \WP_Error $errors Errors.
	 * @param string    $username Username.
	 * @param string    $email Email.
	 * @return \WP_Error
	 */
	public function validateWooRegistration( \WP_Error $errors, string $username, string $email ): \WP_Error {
		// Account creation during checkout re-enters this filter in the same request that
		// already verified the (single-use) checkout token, and the checkout form never
		// rendered a register widget — checkout validation owns that request.
		if ( WooCheckoutContext::registrationIsPartOfCheckout() ) {
			return $errors;
		}
		if ( ! $this->verifyToken( $this->getTokenFromRequest(), 'woo_register' ) ) {
			$errors->add( 'turnstile_failed', $this->errorMessage( __( 'Please complete the security verification to register.', 'wppowersuite-lite' ), 'woo_register' ) );
		}
		return $errors;
	}

	/**
	 * Validate WooCommerce checkout.
	 *
	 * @return void
	 */
	public function validateWooCheckout(): void {
		if ( ! function_exists( 'WC' ) || ! WC()->cart ) {
			return;
		}

		if ( ! WooCheckoutContext::shouldEnforceClassicCheckoutCaptcha() ) {
			return;
		}

		$s = $this->getSettings();
		if ( ! empty( $s['woo_checkout_guest_only'] ) && is_user_logged_in() ) {
			return;
		}
		// Wallet buttons (payment request) submit without the checkout form, so no widget
		// was ever shown for this attempt.
		if ( WooCheckoutContext::isExpressPayClassic() ) {
			return;
		}

		if ( self::$woo_classic_checkout_gate_done ) {
			return;
		}
		self::$woo_classic_checkout_gate_done = true;

		if ( $this->verifyToken( $this->getTokenFromRequest(), 'woo_checkout' ) ) {
			return;
		}

		wc_add_notice(
			$this->errorMessage( __( 'Please complete the security verification to complete your order.', 'wppowersuite-lite' ), 'woo_checkout' ),
			'error'
		);
	}

	/**
	 * Encrypt a secret for storage. Returns empty string on empty input or failure.
	 * Static so Ajax save handler can call without instantiating the module.
	 *
	 * @param string $value Plaintext secret.
	 */
	public static function encryptSecret( string $value ): string {
		return SecretCipher::encrypt( $value, self::ENCRYPT_KEY );
	}

	/**
	 * Decrypt a secret. Returns plaintext for legacy (unprefixed) values for backwards
	 * compatibility with secrets stored before Phase 1.
	 *
	 * @param string $value Stored secret value.
	 */
	public static function decryptSecret( string $value ): string {
		return SecretCipher::decrypt( $value, self::ENCRYPT_KEY );
	}

	/**
	 * Mask value used in admin form to preserve an existing secret without exposing it.
	 */
	public static function getSecretMask(): string {
		return SecretCipher::MASK;
	}

	/**
	 * Get settings.
	 *
	 * @return array<string, mixed>
	 */
	private function getSettings(): array {
		if ( $this->settings_cache !== null ) {
			return $this->settings_cache;
		}

		$settings = $this->moduleSettings()->all( [] );
		if ( ! is_array( $settings ) ) {
			$settings = [];
		}

		// Roadmap #32: multisite network-level fallback. Single-site installs short-circuit
		// inside NetworkSettings::resolve(), so this costs nothing off-network.
		$site_key   = NetworkSettings::resolve( trim( $settings['site_key'] ?? '' ), self::MODULE_ID, 'site_key' );
		$secret_raw = trim( $settings['secret_key'] ?? '' );
		if ( $secret_raw === '' ) {
			// NetworkSettings::resolve decrypts encrypted network secrets.
			$secret_raw = NetworkSettings::resolve( '', self::MODULE_ID, 'secret_key' );
		}

		$skip_raw   = isset( $settings['skip_rules'] ) && is_array( $settings['skip_rules'] ) ? $settings['skip_rules'] : [];
		$skip_rules = [
			'enabled'      => ! empty( $skip_raw['enabled'] ),
			'roles'        => isset( $skip_raw['roles'] ) && is_array( $skip_raw['roles'] ) ? array_map( 'sanitize_key', $skip_raw['roles'] ) : [],
			'ips'          => isset( $skip_raw['ips'] ) && is_array( $skip_raw['ips'] ) ? array_values( array_filter( array_map( 'trim', $skip_raw['ips'] ) ) ) : [],
			'user_agents'  => isset( $skip_raw['user_agents'] ) && is_array( $skip_raw['user_agents'] ) ? array_values( array_filter( array_map( 'trim', $skip_raw['user_agents'] ) ) ) : [],
		];

		$lang = isset( $settings['widget_language'] ) ? trim( (string) $settings['widget_language'] ) : '';
		if ( $lang !== '' && ! preg_match( '/^[a-zA-Z_-]{2,20}$/', $lang ) ) {
			$lang = '';
		}

		$vis_app = isset( $settings['widget_visible_appearance'] ) ? sanitize_key( (string) $settings['widget_visible_appearance'] ) : 'interaction-only';
		if ( ! in_array( $vis_app, [ 'always', 'interaction-only' ], true ) ) {
			$vis_app = 'interaction-only';
		}

		$refresh_to = isset( $settings['refresh_timeout'] ) ? trim( (string) $settings['refresh_timeout'] ) : 'auto';
		if ( $refresh_to !== 'auto' && ! preg_match( '/^[0-9]+$/', $refresh_to ) ) {
			$refresh_to = 'auto';
		}

		$failsafe_mode = isset( $settings['failsafe_mode'] ) ? sanitize_key( (string) $settings['failsafe_mode'] ) : 'off';
		if ( ! in_array( $failsafe_mode, [ 'off', 'allow', 'recaptcha' ], true ) ) {
			$failsafe_mode = 'off';
		}

		$failsafe_recaptcha_secret_raw = trim( $settings['failsafe_recaptcha_secret_key'] ?? '' );

		$this->settings_cache = [
			'site_key'               => $site_key,
			'secret_key'             => self::decryptSecret( $secret_raw ),
			'theme'                  => in_array( $settings['theme'] ?? 'auto', [ 'light', 'dark', 'auto' ], true ) ? ( $settings['theme'] ?? 'auto' ) : 'auto',
			'widget_full_width'      => ! isset( $settings['widget_full_width'] ) || ! empty( $settings['widget_full_width'] ),
			'widget_language'        => $lang,
			'widget_visible_appearance' => $vis_app,
			'refresh_timeout'        => $refresh_to,
			'disable_submit'         => ! empty( $settings['disable_submit'] ),
			'ajax_comment_reset'     => ! empty( $settings['ajax_comment_reset'] ),
			'login_canonical_only'   => ! empty( $settings['login_canonical_only'] ),
			'register_canonical_only' => ! empty( $settings['register_canonical_only'] ),
			'failsafe_mode'          => $failsafe_mode,
			'failsafe_recaptcha_site_key' => sanitize_text_field( (string) ( $settings['failsafe_recaptcha_site_key'] ?? '' ) ),
			'failsafe_recaptcha_secret_key' => self::decryptSecret( $failsafe_recaptcha_secret_raw ),
			'perf_compat'            => ! empty( $settings['perf_compat'] ),
			'resource_hints'         => ! empty( $settings['resource_hints'] ),
			'blocksy_compat'         => ! empty( $settings['blocksy_compat'] ),
			'integration_gravity'    => ! empty( $settings['integration_gravity'] ),
			'integration_jetpack'    => ! empty( $settings['integration_jetpack'] ),
			'integration_bbpress'    => ! empty( $settings['integration_bbpress'] ),
			'integration_buddypress' => ! empty( $settings['integration_buddypress'] ),
			'integration_ultimate_member' => ! empty( $settings['integration_ultimate_member'] ),
			'integration_memberpress' => ! empty( $settings['integration_memberpress'] ),
			'integration_edd'        => ! empty( $settings['integration_edd'] ),
			'integration_pmp'        => ! empty( $settings['integration_pmp'] ),
			'integration_mc4wp'    => ! empty( $settings['integration_mc4wp'] ),
			'woo_checkout_guest_only' => ! empty( $settings['woo_checkout_guest_only'] ),
			'protect_login'        => ! empty( $settings['protect_login'] ),
			'protect_registration' => ! empty( $settings['protect_registration'] ),
			'protect_password_reset' => ! empty( $settings['protect_password_reset'] ),
			'protect_comments'      => ! empty( $settings['protect_comments'] ),
			'woo_login'             => ! empty( $settings['woo_login'] ),
			'woo_registration'      => ! empty( $settings['woo_registration'] ),
			'woo_lost_password'     => ! empty( $settings['woo_lost_password'] ),
			'woo_checkout'          => ! empty( $settings['woo_checkout'] ),
			'woo_reviews'           => ! empty( $settings['woo_reviews'] ),
			'basic_forms'           => ! empty( $settings['basic_forms'] ),
			'cf7'                   => ! empty( $settings['cf7'] ),
			'wpforms'               => ! empty( $settings['wpforms'] ),
			'formidable'            => ! empty( $settings['formidable'] ),
			'fluent_forms'          => ! empty( $settings['fluent_forms'] ),
			'elementor_forms'       => ! empty( $settings['elementor_forms'] ),
			'skip_rules'            => $skip_rules,
		];

		$this->settings_cache = FeatureGate::applyPremiumTierLimits( $this->settings_cache );

		return $this->settings_cache;
	}

	/**
	 * Admin "Test connection" path (roadmap #13). Runs a one-off siteverify against a
	 * user-supplied token using current stored secret; returns structured status to
	 * the admin AJAX handler.
	 *
	 * @return array{success:bool,message:string,codes:array<int,string>}
	 */
	public static function runTestConnection( string $token ): array {
		$settings = OptionStoreModuleSettingsRepository::forModule( self::MODULE_ID )->all( [] );
		$secret   = is_array( $settings ) ? self::decryptSecret( trim( $settings['secret_key'] ?? '' ) ) : '';
		if ( $secret === '' ) {
			return [
				'success' => false,
				'message' => __( 'No secret key configured.', 'wppowersuite-lite' ),
				'codes'   => [],
			];
		}
		if ( $token === '' ) {
			return [
				'success' => false,
				'message' => __( 'A token is required to perform the connection test.', 'wppowersuite-lite' ),
				'codes'   => [],
			];
		}
		if ( strlen( $token ) > self::TOKEN_MAX_LEN ) {
			return [
				'success' => false,
				'message' => __( 'Token is too long.', 'wppowersuite-lite' ),
				'codes'   => [ 'token-too-long' ],
			];
		}
		$resp = wp_remote_post(
			self::SITEVERIFY_URL,
			[
				'body'    => [
					'secret'   => $secret,
					'response' => $token,
				],
				'timeout' => 15,
			]
		);
		if ( is_wp_error( $resp ) ) {
			return [
				'success' => false,
				'message' => __( 'Could not reach Cloudflare siteverify — check that this server can reach Cloudflare.', 'wppowersuite-lite' ) . ' (' . $resp->get_error_message() . ')',
				'codes'   => [ 'network-error' ],
			];
		}
		$data  = json_decode( wp_remote_retrieve_body( $resp ), true );
		$ok    = is_array( $data ) && ! empty( $data['success'] );
		$codes = is_array( $data ) && isset( $data['error-codes'] ) && is_array( $data['error-codes'] )
			? array_map( 'strval', $data['error-codes'] )
			: [];
		if ( $ok ) {
			$host    = is_array( $data ) && isset( $data['hostname'] ) ? (string) $data['hostname'] : '';
			$message = $host !== ''
				? sprintf(
					/* translators: %s: hostname reported by Cloudflare. */
					__( 'Success — verified token for %s.', 'wppowersuite-lite' ),
					$host
				)
				: __( 'Success — token verified.', 'wppowersuite-lite' );
			return [ 'success' => true, 'message' => $message, 'codes' => [] ];
		}
		$message = __( 'Verification failed.', 'wppowersuite-lite' );
		if ( in_array( 'invalid-input-secret', $codes, true ) ) {
			$message = __( 'Invalid secret key — copy the key from the Cloudflare Turnstile dashboard again.', 'wppowersuite-lite' );
		} elseif ( in_array( 'missing-input-secret', $codes, true ) ) {
			$message = __( 'No secret key was sent to Cloudflare — is the field empty?', 'wppowersuite-lite' );
		} elseif ( in_array( 'invalid-input-response', $codes, true ) ) {
			$message = __( 'The Turnstile token was rejected by Cloudflare (likely domain mismatch between your Turnstile site and this site).', 'wppowersuite-lite' );
		} elseif ( in_array( 'timeout-or-duplicate', $codes, true ) ) {
			$message = __( 'Token already used or expired (Turnstile tokens are single-use and short-lived).', 'wppowersuite-lite' );
		} elseif ( in_array( 'bad-request', $codes, true ) ) {
			$message = __( 'Cloudflare rejected the request format. Try again.', 'wppowersuite-lite' );
		} elseif ( in_array( 'internal-error', $codes, true ) ) {
			$message = __( 'Cloudflare reported an internal error. Try again in a moment.', 'wppowersuite-lite' );
		}
		return [ 'success' => false, 'message' => $message, 'codes' => $codes ];
	}

	/**
	 * Delete cached Cloudflare reachability probe (module disable / key reset).
	 */
	public static function clearProbeTransient(): void {
		delete_transient( 'wpps_turnstile_cf_probe' );
	}

	/**
	 * @param string               $module_id Module ID.
	 * @param bool                 $enabled   Whether the module was enabled.
	 * @param array<string, mixed> $metadata  Module metadata.
	 */
	public static function onModuleToggled( string $module_id, bool $enabled, array $metadata = [] ): void {
		unset( $metadata );
		if ( $module_id !== self::MODULE_ID ) {
			return;
		}
		if ( ! $enabled ) {
			self::clearProbeTransient();
		}
	}

	/**
	 * HMAC-signed failsafe marker (short-lived; not forgeable without the site salt).
	 *
	 * Carries a random nonce so each rendered form gets a distinct marker. Signing only a
	 * timestamp made every visitor in a given second share one marker, so a single harvested
	 * value let a bot post to every protected form for the whole TTL.
	 */
	private static function createFailsafeAllowToken(): string {
		$expiry  = time() + self::FAILSAFE_ALLOW_TTL;
		$nonce   = wp_generate_password( 16, false, false );
		$payload = $expiry . '|' . $nonce;

		return base64_encode( $payload . '|' . hash_hmac( 'sha256', $payload, wp_salt( 'auth' ) ) );
	}

	private static function verifyFailsafeAllowToken( string $token ): bool {
		if ( $token === '' || strlen( $token ) > 256 ) {
			return false;
		}
		$decoded = base64_decode( $token, true );
		if ( ! is_string( $decoded ) ) {
			return false;
		}
		$parts = explode( '|', $decoded );
		if ( count( $parts ) !== 3 ) {
			return false;
		}
		[ $expiry_raw, $nonce, $sig ] = $parts;

		$expiry = (int) $expiry_raw;
		$now    = time();
		if ( $expiry <= $now || $expiry > $now + self::FAILSAFE_ALLOW_TTL || $nonce === '' ) {
			return false;
		}

		$expected = hash_hmac( 'sha256', $expiry_raw . '|' . $nonce, wp_salt( 'auth' ) );
		if ( ! hash_equals( $expected, $sig ) ) {
			return false;
		}

		return self::consumeFailsafeAllowToken( $nonce, $expiry - $now );
	}

	/**
	 * Bound how many submissions one marker can carry, so a valid marker cannot be replayed
	 * indefinitely while Cloudflare is unreachable.
	 *
	 * @param string $nonce      Random component of the marker.
	 * @param int    $ttl_left   Seconds until the marker expires.
	 */
	private static function consumeFailsafeAllowToken( string $nonce, int $ttl_left ): bool {
		$key  = 'wpps_ts_fs_' . substr( hash( 'sha256', $nonce ), 0, 32 );
		$uses = (int) get_transient( $key );
		if ( $uses >= self::FAILSAFE_ALLOW_MAX_USES ) {
			return false;
		}
		set_transient( $key, $uses + 1, max( 60, $ttl_left ) );

		return true;
	}
}
