<?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;

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\WooCheckoutContext;
use WP_PowerSuite\Core\OptionStoreModuleSettingsRepository;

require_once __DIR__ . '/ExtendedIntegrations.php';
require_once __DIR__ . '/FeatureGate.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';
	private const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
	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 Whether Elementor Pro form hooks (injector, validation) are registered. */
	private static bool $elementor_forms_hooks_registered = 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|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', 'wp-powersuite' );
	}

	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.', 'wp-powersuite' );
	}

	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 );
		}

		// PowerSuite Basic Forms
		$this->initBasicForms( $settings );

		// Third-party form plugins
		$this->initContactForm7( $settings );
		$this->initWPForms( $settings );
		$this->initFormidableForms( $settings );
		$this->initFluentForms( $settings );
		$this->initElementorForms( $settings );

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

		if ( is_admin() ) {
			add_action( 'admin_enqueue_scripts', [ $this, 'enqueueAdminWidgetLayoutStyles' ], 15 );
		}
		if ( ! empty( $settings['basic_forms'] ) ) {
			add_action( 'admin_enqueue_scripts', [ $this, 'enqueueTurnstileInAdminForBasicForms' ], 5 );
		}

		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 );
		}

		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 );
	}

	/**
	 * Elementor Pro Forms integration (roadmap item #26).
	 *
	 * We do not use `elementor_pro/forms/pre_render` for HTML output (it runs before
	 * the form is printed). A footer script injects the widget before each submit.
	 * Registration is deferred to `elementor/loaded` if Elementor loads after this module.
	 */
	private function initElementorForms( array $settings ): void {
		if ( empty( $settings['elementor_forms'] ) ) {
			return;
		}
		if ( did_action( 'elementor/loaded' ) ) {
			$this->registerElementorProFormsIntegration();
		} else {
			add_action( 'elementor/loaded', [ $this, 'registerElementorProFormsIntegration' ] );
		}
	}

	/**
	 * Register script enqueue, footer injector, and form validation (once).
	 */
	public function registerElementorProFormsIntegration(): void {
		if ( self::$elementor_forms_hooks_registered ) {
			return;
		}
		$settings = $this->getSettings();
		if ( empty( $settings['elementor_forms'] ) || ! did_action( 'elementor/loaded' ) || ! defined( 'ELEMENTOR_PRO_VERSION' ) ) {
			return;
		}
		self::$elementor_forms_hooks_registered = true;
		add_action( 'wp_enqueue_scripts', [ $this, 'enqueueTurnstileForElementor' ] );
		add_action( 'wp_footer', [ $this, 'printElementorInjectorScript' ], 30 );
		add_action( 'elementor_pro/forms/validation', [ $this, 'validateElementorFormSubmission' ], 10, 2 );
	}

	/**
	 * Enqueue Turnstile API on frontend when Elementor forms integration is on.
	 * Separated from {@see ensureTurnstileScriptQueued()} because the injector
	 * runs in wp_footer and can't enqueue new scripts at that point.
	 */
	public function enqueueTurnstileForElementor(): void {
		if ( is_admin() || ! $this->currentRequestMayContainElementorForm() ) {
			return;
		}
		$this->ensureTurnstileScriptQueued();
		if ( ! empty( $this->getSettings()['disable_submit'] ) ) {
			$this->enqueueDisableSubmitScript();
		}
	}

	private function currentRequestMayContainElementorForm(): bool {
		if ( ! is_singular() ) {
			return false;
		}

		$post_id = get_queried_object_id();
		if ( $post_id <= 0 ) {
			return false;
		}

		$content = (string) get_post_field( 'post_content', $post_id );
		if ( strpos( $content, 'elementor-form' ) !== false || strpos( $content, '[elementor-template' ) !== false ) {
			return true;
		}

		$elementor_data = (string) get_post_meta( $post_id, '_elementor_data', true );
		return $elementor_data !== '' && ( strpos( $elementor_data, '"form_fields"' ) !== false || strpos( $elementor_data, '"form_name"' ) !== false );
	}

	/**
	 * Emits a lightweight script that scans the page for Elementor forms (on DOM
	 * ready AND on elementorFrontend hooks for popups / lazy-rendered widgets)
	 * and injects a Turnstile widget right before each form's submit button.
	 *
	 * The Turnstile API script is loaded by {@see ensureTurnstileScriptQueued()}
	 * whenever `elementor_forms` is enabled.
	 */
	public function printElementorInjectorScript(): void {
		if ( ! wp_script_is( self::SCRIPT_HANDLE, 'enqueued' ) && ! wp_script_is( self::SCRIPT_HANDLE, 'done' ) ) {
			return;
		}
		$settings = $this->getSettings();
		$site_key = isset( $settings['site_key'] ) ? (string) $settings['site_key'] : '';
		if ( $site_key === '' ) {
			return;
		}
		// Honor skip rules — no injection for exempt users.
		if ( SkipRules::shouldSkip( is_array( $settings['skip_rules'] ?? null ) ? $settings['skip_rules'] : [] ) ) {
			return;
		}
		$theme          = isset( $settings['theme'] ) ? (string) $settings['theme'] : 'auto';
		$api_size       = WidgetLayoutStyles::turnstileApiSize( $settings );
		$full_width     = WidgetLayoutStyles::widgetFullWidthEnabled( $settings );
		$appearance     = isset( $settings['widget_visible_appearance'] ) ? (string) $settings['widget_visible_appearance'] : 'interaction-only';
		if ( ! in_array( $appearance, [ 'always', 'interaction-only' ], true ) ) {
			$appearance = 'interaction-only';
		}
		$disable_submit  = ! empty( $settings['disable_submit'] );
		$elementor_unlock_raw = 'form.elementor-form button[type="submit"], form.elementor-form input[type="submit"], form.elementor-form .elementor-button[type="submit"]';
		$elementor_unlock_css = preg_replace( '/[^#\.a-zA-Z0-9 _\-:,]/', '', $elementor_unlock_raw );
		WidgetLayoutStyles::printFrontEndOnce();
		?>
<script>
(function () {
		var SITE_KEY = <?php echo wp_json_encode( $site_key ); ?>;
	var THEME    = <?php echo wp_json_encode( $theme ); ?>;
	var APPEARANCE = <?php echo wp_json_encode( $appearance ); ?>;
	var DISABLE_SUBMIT = <?php echo $disable_submit ? 'true' : 'false'; ?>;

	function inject(form) {
		if (!form || form.dataset.wppsTurnstileInjected === '1') { return; }
		var submit = form.querySelector('button[type="submit"], input[type="submit"], .elementor-button[type="submit"]');
		if (!submit) { return; }
		var wrap = document.createElement('div');
		wrap.className = 'wpps-captcha-cushion wpps-elementor-turnstile elementor-field-group';
		if (<?php echo $full_width ? 'true' : 'false'; ?>) {
			wrap.classList.add('wpps-captcha-core-form-fit');
		}
		wrap.style.margin = '0 0 1.25em 0';
		wrap.style.maxWidth = '100%';
		wrap.style.boxSizing = 'border-box';
		wrap.style.display = 'block';
		wrap.style.textAlign = 'left';
		var widget = document.createElement('div');
		widget.className = 'cf-turnstile wpps-turnstile-widget';
		widget.setAttribute('data-sitekey', SITE_KEY);
		widget.setAttribute('data-theme', THEME);
		widget.setAttribute('data-size', <?php echo wp_json_encode( $api_size ); ?>);
		widget.setAttribute('data-appearance', APPEARANCE);
		if (DISABLE_SUBMIT) {
			widget.setAttribute('data-callback', 'turnstileElementorCallback');
		}
		var formIdInput = form.querySelector('input[name="form_id"]');
		var formAction = 'elementor_form';
		if (formIdInput && formIdInput.value) {
			formAction = 'elementor_' + String(formIdInput.value).replace(/[^a-zA-Z0-9_-]/g, '');
		}
		widget.setAttribute('data-action', formAction);
		wrap.appendChild(widget);
		submit.parentNode.insertBefore(wrap, submit);
		if (DISABLE_SUBMIT) {
			var css = <?php echo wp_json_encode( $elementor_unlock_css ); ?>;
			if (css) {
				var st = document.createElement('style');
				st.textContent = css + '{pointer-events:none!important;opacity:0.5!important;}';
				document.head.appendChild(st);
			}
		}
		form.dataset.wppsTurnstileInjected = '1';
		// If the Turnstile script already ran, ask it to render the new widget.
		if (window.turnstile && typeof window.turnstile.render === 'function') {
			try { window.turnstile.render(widget); } catch (e) { /* already rendered */ }
		}
	}

	function scan() {
		document.querySelectorAll('form.elementor-form').forEach(inject);
	}

	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', scan);
	} else {
		scan();
	}

	// Catches popups, dynamically rendered forms, and editor previews.
	if (window.jQuery) {
		window.jQuery(document).on('elementor/popup/show', scan);
	}
	window.addEventListener('load', scan);
})();
</script>
		<?php
	}

	/**
	 * @param \ElementorPro\Modules\Forms\Classes\Form_Record|mixed $record
	 * @param \ElementorPro\Modules\Forms\Classes\Ajax_Handler|mixed $ajax_handler
	 */
	public function validateElementorFormSubmission( $record, $ajax_handler ): void {
		$form_id = '';
		if ( is_object( $record ) && method_exists( $record, 'get_form_settings' ) ) {
			$fs = $record->get_form_settings();
			if ( is_array( $fs ) && isset( $fs['id'] ) ) {
				$form_id = (string) $fs['id'];
			}
		}
		if ( $form_id === '' && isset( $_POST['form_id'] ) ) {
			$form_id = sanitize_text_field( wp_unslash( (string) $_POST['form_id'] ) );
		}
		$action = $form_id !== '' ? 'elementor_' . preg_replace( '/[^a-zA-Z0-9_-]/', '', $form_id ) : '';

		if ( $this->verifyToken( $this->getTokenFromRequest(), $action ) ) {
			return;
		}
		$message = $this->errorMessage( __( 'Please complete the security verification.', 'wp-powersuite' ), 'elementor_forms' );
		if ( is_object( $ajax_handler ) && method_exists( $ajax_handler, 'add_error' ) ) {
			$ajax_handler->add_error( 'wpps_turnstile', $message );
		}
	}

	/**
	 * Initialize Basic Forms integration.
	 *
	 * @param array<string, mixed> $settings Module settings.
	 * @return void
	 */
	private function initBasicForms( array $settings ): void {
		if ( empty( $settings['basic_forms'] ) ) {
			return;
		}
		add_action( 'wpps_basic_forms_before_submit', [ $this, 'renderWidgetForBasicForms' ], 10, 2 );
		add_filter( 'wpps_verify_basic_forms_captcha', [ $this, 'filterVerifyBasicForms' ], 10, 3 );
	}

	/**
	 * Verify Basic Forms CAPTCHA.
	 *
	 * @param bool  $verified Current verification state.
	 * @param int   $form_id  Form ID.
	 * @param array $settings Form settings.
	 * @return bool
	 */
	public function filterVerifyBasicForms( bool $verified, int $form_id, array $settings ): bool {
		if ( ! CaptchaRuntime::basicFormsCaptchaActive( $settings ) ) {
			return $verified;
		}
		$provider = $settings['captcha_provider'] ?? '';
		if ( $provider !== 'turnstile' ) {
			return $verified;
		}
		return $this->verifyToken( $this->getTokenFromRequest(), 'basic_forms_' . $form_id );
	}

	/**
	 * Render Turnstile widget for Basic Forms.
	 *
	 * @param int   $form_id  Form ID.
	 * @param array $settings Form settings.
	 * @return void
	 */
	public function renderWidgetForBasicForms( int $form_id, array $settings ): void {
		if ( ! CaptchaRuntime::basicFormsCaptchaActive( $settings ) ) {
			return;
		}
		$provider = $settings['captcha_provider'] ?? '';
		if ( $provider !== 'turnstile' ) {
			return;
		}
		$fid = (int) $form_id;

		$opts = [];
		if ( ! empty( $this->getSettings()['disable_submit'] ) ) {
			$this->ensureTurnstileScriptQueued();
			$cb  = 'wppsBfTsUnlock' . $fid;
			$sel = '.wpps-bf-form[data-form-id="' . $fid . '"] .wpps-bf-submit';
			wp_add_inline_script(
				self::SCRIPT_HANDLE,
				// setProperty with "important": the lock below is an !important rule, which a
				// plain inline style cannot override.
				'function ' . $cb . '(){document.querySelectorAll(' . wp_json_encode( $sel ) . ').forEach(function(el){el.style.setProperty("pointer-events","auto","important");el.style.setProperty("opacity","1","important");});}',
				'before'
			);
			$opts = [
				'submit_callback'  => $cb,
				'unlock_selectors' => $sel,
			];
		}

		$this->renderWidget( 'basic_forms_' . $fid, $opts );
	}

	/**
	 * 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 );
			add_action( 'woocommerce_loaded', [ $this, 'registerWooCheckoutStoreApiExtension' ], 20 );
			add_action( 'wp_enqueue_scripts', [ $this, 'enqueueWooCheckoutFrontScript' ], 30 );
		}

	}

	/**
	 * Initialize Contact Form 7 integration.
	 *
	 * @param array<string, mixed> $settings Module settings.
	 * @return void
	 */
	private function initContactForm7( array $settings ): void {
		if ( empty( $settings['cf7'] ) || ! class_exists( 'WPCF7' ) ) {
			return;
		}

		add_filter( 'wpcf7_form_elements', [ $this, 'injectCf7Widget' ], 20 );
		add_filter( 'wpcf7_spam', [ $this, 'validateCf7' ], 10, 2 );
	}

	/**
	 * Initialize WPForms integration.
	 *
	 * @param array<string, mixed> $settings Module settings.
	 * @return void
	 */
	private function initWPForms( array $settings ): void {
		if ( empty( $settings['wpforms'] ) || ! function_exists( 'wpforms' ) ) {
			return;
		}

		add_action( 'wpforms_display_submit_before', [ $this, 'renderWPFormsWidget' ], 10, 1 );
		add_filter( 'wpforms_process_initial_errors', [ $this, 'validateWPFormsInitialErrors' ], 10, 2 );
	}

	/**
	 * Initialize Formidable Forms integration.
	 *
	 * @param array<string, mixed> $settings Module settings.
	 * @return void
	 */
	private function initFormidableForms( array $settings ): void {
		if ( empty( $settings['formidable'] ) || ! class_exists( 'FrmFormsController' ) ) {
			return;
		}

		// `frm_entry_form` runs after the form body (often after the submit control).
		// `frm_submit_button_html` prepends HTML immediately before the submit button.
		add_filter( 'frm_submit_button_html', [ $this, 'prependTurnstileToFormidableSubmit' ], 10, 2 );
		add_filter( 'frm_validate_entry', [ $this, 'validateFormidable' ], 20, 2 );
	}

	/**
	 * Initialize Fluent Forms integration.
	 *
	 * @param array<string, mixed> $settings Module settings.
	 * @return void
	 */
	private function initFluentForms( array $settings ): void {
		if ( empty( $settings['fluent_forms'] ) || ! defined( 'FLUENTFORM' ) ) {
			return;
		}

		// `form_element_start` fires once at form open and caused the widget to sit
		// above all fields; `render_item_submit_button` fires in the submit field's
		// own render() so whatever we echo lands directly before the <button>.
		add_action( 'fluentform/render_item_submit_button', [ $this, 'renderWidgetFluentForms' ], 5, 2 );
		add_filter( 'fluentform/validation_errors', [ $this, 'validateFluentForms' ], 10, 4 );
		// Conversational forms never render a submit-button field — they read
		// `form.turnstile` from localized vars and mint a token themselves.
		add_filter( 'fluentform/global_form_vars', [ $this, 'injectFluentConversationalTurnstileVars' ], 20 );
		add_action( 'fluentform/conversational_enqueue_assets', [ $this, 'enqueueTurnstileForFluentConversational' ], 10, 1 );
		// Belt-and-suspenders: conversational submissions skip some validation filters.
		add_action( 'fluentform/before_insert_submission', [ $this, 'validateFluentFormsBeforeInsert' ], 10, 3 );
	}

	/**
	 * 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',
				'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,
			[],
			null,
			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.
		if ( defined( 'WP_POWERSUITE_PLUGIN_URL' ) ) {
			wp_enqueue_script(
				self::FORM_RESET_HANDLE,
				WP_POWERSUITE_PLUGIN_URL . 'modules/cloudflare-turnstile/assets/form-reset.js',
				[ '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=`.
		$updated = preg_replace( '/^<script\b/i', '<script 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', [], null, 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'] );

		$action_attr      = is_string( $action ) && $action !== '' ? ' data-action="' . esc_attr( $action ) . '"' : '';
		$html_id          = isset( $options['html_id'] ) ? trim( (string) $options['html_id'] ) : '';
		$html_id          = preg_replace( '/[^a-zA-Z0-9_-]/', '', $html_id );
		$id_attr          = $html_id !== '' ? ' id="' . esc_attr( $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 !== '' ) {
				echo '<style>' . esc_html( $san_unlock ) . '{pointer-events:none!important;opacity:0.5!important;}</style>';
			}
		}

		$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;
		}

		$attr_string = '';
		foreach ( $attrs as $key => $val ) {
			$attr_string .= ' ' . esc_attr( $key ) . '="' . esc_attr( (string) $val ) . '"';
		}

		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">';
		printf(
			'<div class="%s"%s%s%s></div>',
			esc_attr( $widget_class ),
			$id_attr,
			$attr_string,
			$action_attr
		);
		echo '</div></div>';
	}

	public function enqueueDisableSubmitScript(): void {
		if ( ! defined( 'WP_POWERSUITE_PLUGIN_URL' ) || wp_script_is( self::DISABLE_SUBMIT_HANDLE, 'enqueued' ) ) {
			return;
		}
		wp_enqueue_script(
			self::DISABLE_SUBMIT_HANDLE,
			WP_POWERSUITE_PLUGIN_URL . 'modules/cloudflare-turnstile/assets/disable-submit.js',
			[ 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    = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( (string) $_SERVER['REQUEST_URI'] ) : '';
		$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 ( ! 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.', 'wp-powersuite' ),
							'type'        => 'string',
							'context'     => [ 'view', 'edit' ],
							'readonly'    => false,
						],
					];
				},
				'schema_type'     => $schema_type,
			]
		);
	}

	/**
	 * 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.', 'wp-powersuite' ), 'woo_checkout' );

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

		throw new \RuntimeException( 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 ( ! defined( 'WP_POWERSUITE_PLUGIN_URL' ) ) {
			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;
		}

		$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,
			WP_POWERSUITE_PLUGIN_URL . 'modules/cloudflare-turnstile/assets/woo-checkout.js',
			$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 Turnstile before WPForms submit with per-form data-action.
	 *
	 * @param mixed $form_data Form data from WPForms (expects array with id).
	 * @return void
	 */
	public function renderWPFormsWidget( $form_data ): void {
		if ( ! is_array( $form_data ) || empty( $form_data['id'] ) ) {
			return;
		}
		$this->renderWidget(
			'wpforms_' . (string) absint( $form_data['id'] ),
			$this->disableSubmitPair( 'turnstileWPFCallback', '.wpforms-submit' )
		);
	}

	/**
	 * 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"]'
			)
		);
	}

	/**
	 * Prepend Turnstile immediately before the Formidable submit button.
	 *
	 * @param string $button Current submit button HTML.
	 * @param array<string, mixed> $args Formidable args (includes `form`).
	 * @return string
	 */
	public function prependTurnstileToFormidableSubmit( $button, $args ) {
		$form = ( is_array( $args ) && ! empty( $args['form'] ) && is_object( $args['form'] ) ) ? $args['form'] : null;
		if ( $form === null || ! isset( $form->id ) ) {
			return (string) $button;
		}
		ob_start();
		$this->renderWidget(
			'formidable_' . $form->id,
			$this->disableSubmitPair( 'turnstileFormidableCallback', '.frm_forms .frm_button_submit' )
		);
		$before = (string) ob_get_clean();
		return $before . $button;
	}

	/**
	 * 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 — compare server-side.
		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.
	 *
	 * Off by default: Cloudflare already rejects tokens from domains outside the widget's
	 * allowlist, and comparing against home_url()/site_url() false-fails on reverse proxies,
	 * mapped multisite domains, and staging clones — which surfaces as an unfixable
	 * "complete the security verification" error for the shopper.
	 *
	 * @param string $hostname Hostname from Cloudflare siteverify JSON.
	 */
	private function hostnameMatchesSite( string $hostname ): bool {
		if ( ! apply_filters( 'wp_powersuite_turnstile_enforce_hostname', false ) ) {
			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.', 'wp-powersuite' );
		}
		$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 = '';
		if ( ! empty( $_SERVER['CONTENT_TYPE'] ) ) {
			$ctype = (string) wp_unslash( $_SERVER['CONTENT_TYPE'] );
		} elseif ( ! empty( $_SERVER['HTTP_CONTENT_TYPE'] ) ) {
			$ctype = (string) wp_unslash( $_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 isset( $_POST['woocommerce-register-nonce'] );
	}

	/**
	 * 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'] ) && defined( 'PMPRO_VERSION' ) && ! empty( $_POST['pmpro_login_form_used'] ) ) {
			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.', 'wp-powersuite' ), '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.', 'wp-powersuite' ), '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'] ) && defined( 'PMPRO_VERSION' ) && ! empty( $_POST['pmpro_login_form_used'] ) ) {
			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.', 'wp-powersuite' ), '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.', 'wp-powersuite' ), '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.', 'wp-powersuite' ), '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', 'wp-powersuite' ),
			[ '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.', 'wp-powersuite' ), '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.', 'wp-powersuite' ), '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', 'wp-powersuite' ),
			[ '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.', 'wp-powersuite' ), '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.', 'wp-powersuite' ), 'woo_register' ) );
		}
		return $errors;
	}

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

		if ( function_exists( 'is_admin' ) && is_admin() && ! wp_doing_ajax() ) {
			return;
		}

		$wc_ajax = isset( $_REQUEST['wc-ajax'] ) ? sanitize_text_field( wp_unslash( (string) $_REQUEST['wc-ajax'] ) ) : '';
		if ( $wc_ajax !== '' && $wc_ajax !== 'checkout' ) {
			return;
		}

		if ( $wc_ajax === '' ) {
			$uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( (string) $_SERVER['REQUEST_URI'] ) : '';
			if ( $uri === '' || stripos( $uri, 'checkout' ) === false ) {
				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.', 'wp-powersuite' ), 'woo_checkout' ),
			'error'
		);
	}

	/**
	 * Inject Turnstile into Contact Form 7.
	 *
	 * @param string $content Form content.
	 * @return string
	 */
	public function injectCf7Widget( string $content ): string {
		$settings = $this->getSettings();
		if ( empty( $settings['site_key'] ) ) {
			return $content;
		}

		ob_start();
		$this->renderWidget(
			'cf7',
			$this->disableSubmitPair( 'turnstileCF7Callback', '.wpcf7-submit' )
		);
		$widget = (string) ob_get_clean();

		return $this->injectCf7MarkupBeforeSubmit( $content, $widget );
	}

	/**
	 * @param string $inner_html CF7 inner HTML from wpcf7_form_elements.
	 */
	private function injectCf7MarkupBeforeSubmit( string $inner_html, string $inject ): string {
		if ( $inject === '' ) {
			return $inner_html;
		}
		if ( preg_match( '/<input\b[^>]*\btype\s*=\s*["\']submit["\'][^>]*\/?>/i', $inner_html ) ) {
			return (string) preg_replace(
				'/(<input\b[^>]*\btype\s*=\s*["\']submit["\'][^>]*\/?>)/i',
				$inject . '$1',
				$inner_html,
				1
			);
		}
		if ( preg_match( '/<button\b[^>]*\btype\s*=\s*["\']submit["\'][^>]*>/i', $inner_html ) ) {
			return (string) preg_replace(
				'/(<button\b[^>]*\btype\s*=\s*["\']submit["\'][^>]*>)/i',
				$inject . '$1',
				$inner_html,
				1
			);
		}
		if ( stripos( $inner_html, '</form>' ) !== false ) {
			return (string) preg_replace( '/(<\/form>)/i', $inject . '$1', $inner_html, 1 );
		}
		return $inner_html . $inject;
	}

	/**
	 * Validate Contact Form 7.
	 *
	 * @param bool  $spam Is spam.
	 * @param \WPCF7_ContactForm $submission Submission.
	 * @return bool
	 */
	public function validateCf7( bool $spam, $submission ): bool {
		if ( $spam ) {
			return true;
		}
		$settings = $this->getSettings();
		$skip      = is_array( $settings['skip_rules'] ?? null ) ? $settings['skip_rules'] : [];
		if ( SkipRules::shouldSkip( $skip ) ) {
			return false;
		}
		$token = $this->getTokenFromRequest();
		if ( $token === '' && is_object( $submission ) && method_exists( $submission, 'get_posted_data' ) ) {
			$posted = $submission->get_posted_data();
			if ( is_array( $posted ) ) {
				$token = $this->extractTurnstileTokenFromNestedArray( $posted );
			}
		}
		// Always run verifyToken so failsafe / empty-token denial stay consistent.
		return ! $this->verifyToken( $token, 'cf7' );
	}

	/**
	 * Validate WPForms.
	 *
	 * @param array $errors Errors.
	 * @param array $form_data Form data.
	 * @param array $fields Fields.
	 * @return array
	 */
	public function validateWPFormsInitialErrors( array $errors, array $form_data ): array {
		$form_id = isset( $form_data['id'] ) ? absint( $form_data['id'] ) : 0;
		if ( $form_id <= 0 ) {
			return $errors;
		}
		$token = $this->getTokenFromRequest();
		if ( $token === '' ) {
			$token = $this->extractTurnstileTokenFromNestedArray( $form_data );
		}
		if ( ! $this->verifyToken( $token, 'wpforms_' . (string) $form_data['id'] ) ) {
			if ( ! isset( $errors[ $form_id ] ) || ! is_array( $errors[ $form_id ] ) ) {
				$errors[ $form_id ] = [];
			}
			$errors[ $form_id ]['footer'] = $this->errorMessage( __( 'Please complete the security verification.', 'wp-powersuite' ), 'wpforms' );
		}
		return $errors;
	}

	/**
	 * Validate Formidable Forms.
	 *
	 * @param array $errors Errors.
	 * @param array $values Values.
	 * @return array
	 */
	public function validateFormidable( array $errors, array $values ): array {
		$form_id = isset( $values['form_id'] ) ? absint( $values['form_id'] ) : 0;
		if ( $form_id <= 0 ) {
			return $errors;
		}
		if ( ! $this->verifyToken( $this->getTokenFromRequest(), 'formidable_' . $form_id ) ) {
			$errors['turnstile'] = $this->errorMessage( __( 'Please complete the security verification.', 'wp-powersuite' ), 'formidable' );
		}
		return $errors;
	}

	/**
	 * Render Turnstile for Fluent Forms (action hook).
	 *
	 * @param object $form Form object.
	 * @return void
	 */
	public function renderWidgetFluentForms( $submit_field, $form = null ): void {
		unset( $submit_field );
		// `render_item_submit_button` is called with ($submit_field, $form).
		// Fall back to $submit_field if $form wasn't passed, so older FF versions still work.
		$form_id = 'form';
		if ( is_object( $form ) && isset( $form->id ) ) {
			$form_id = (string) $form->id;
		}
		$this->renderWidget(
			'fluent_' . $form_id,
			$this->disableSubmitPair( 'turnstileFluentCallback', '.fluentform .ff-btn-submit' )
		);
	}

	/**
	 * Feed our Turnstile keys into Fluent conversational form JS.
	 *
	 * Conversational forms do not call `render_item_submit_button`. Their bundled JS
	 * only mints a token when `globalVars.form.turnstile.siteKey` is set — which is
	 * normally Fluent's own field. When our integration is on and the form has no
	 * Turnstile field of its own, inject ours so the built-in conversational path runs.
	 *
	 * @param array<string, mixed> $vars Localized conversational / form vars.
	 * @return array<string, mixed>
	 */
	public function injectFluentConversationalTurnstileVars( array $vars ): array {
		$settings = $this->getSettings();
		$site_key = (string) ( $settings['site_key'] ?? '' );
		if ( $site_key === '' || empty( $settings['fluent_forms'] ) ) {
			return $vars;
		}
		if ( ! isset( $vars['form'] ) || ! is_array( $vars['form'] ) ) {
			return $vars;
		}
		// Respect a Turnstile field already configured in Fluent Forms.
		if ( ! empty( $vars['form']['turnstile'] ) && is_array( $vars['form']['turnstile'] )
			&& ! empty( $vars['form']['turnstile']['siteKey'] ) ) {
			return $vars;
		}
		// interaction-only: conversational JS creates a temporary widget and waits for
		// a token — the only path that works without a visible field in the chat UI.
		$vars['form']['turnstile'] = [
			'siteKey'    => $site_key,
			'appearance' => 'interaction-only',
		];
		return $vars;
	}

	/**
	 * Ensure api.js is present on conversational form pages.
	 *
	 * @param mixed $form Form object from Fluent Forms.
	 */
	public function enqueueTurnstileForFluentConversational( $form ): void {
		unset( $form );
		$this->ensureTurnstileScriptQueued();
	}

	/**
	 * Whether this Fluent form is a conversational (conversion) form.
	 *
	 * @param object|int|string $form Form object or ID.
	 */
	private function isFluentConversationalForm( $form ): bool {
		$form_id = $this->fluentFormId( $form );
		if ( $form_id <= 0 ) {
			return false;
		}
		if ( class_exists( '\FluentForm\App\Helpers\Helper' )
			&& method_exists( '\FluentForm\App\Helpers\Helper', 'isConversionForm' ) ) {
			return (bool) \FluentForm\App\Helpers\Helper::isConversionForm( $form_id );
		}
		return false;
	}

	/**
	 * @param object|int|string $form Form object or ID.
	 */
	private function fluentFormId( $form ): int {
		if ( is_object( $form ) && isset( $form->id ) ) {
			return (int) $form->id;
		}
		if ( is_numeric( $form ) ) {
			return (int) $form;
		}
		return 0;
	}

	/**
	 * Whether the form already includes Fluent Forms' own Turnstile field.
	 *
	 * When it does, Fluent mints and verifies with its own keys — our secret must
	 * not also gate the submission (that would reject every valid Fluent token).
	 *
	 * @param object|int|string $form Form object or ID.
	 */
	private function fluentFormHasNativeTurnstile( $form ): bool {
		$form_id = $this->fluentFormId( $form );
		if ( $form_id <= 0 || ! function_exists( 'wpFluent' ) ) {
			return false;
		}
		try {
			$row = wpFluent()->table( 'fluentform_forms' )->find( $form_id );
		} catch ( \Throwable $e ) {
			return false;
		}
		if ( ! $row || empty( $row->form_fields ) ) {
			return false;
		}
		$fields = is_string( $row->form_fields ) ? json_decode( $row->form_fields, true ) : null;
		if ( ! is_array( $fields ) ) {
			return false;
		}
		$encoded = wp_json_encode( $fields );
		return is_string( $encoded ) && str_contains( $encoded, '"element":"turnstile"' );
	}

	/**
	 * Validate Fluent Forms.
	 *
	 * @param array  $errors Validation errors.
	 * @param array  $form_data Form data.
	 * @param object $form Form object.
	 * @param array  $fields Form fields.
	 * @return array
	 */
	public function validateFluentForms( array $errors, array $form_data, $form, array $fields ): array {
		unset( $fields );
		// Conversational + Fluent's own Turnstile field: Fluent verifies with its secret.
		if ( $this->isFluentConversationalForm( $form ) && $this->fluentFormHasNativeTurnstile( $form ) ) {
			return $errors;
		}
		$form_id = ( is_object( $form ) && isset( $form->id ) ) ? $form->id : '0';
		$token   = $this->getTokenFromRequest();
		if ( $token === '' ) {
			$token = $this->extractTurnstileTokenFromNestedArray( $form_data );
		}
		// Conversational JS never sets data-action, so requiring fluent_{id} would
		// consume the token then reject it for an empty action mismatch.
		$action = $this->isFluentConversationalForm( $form ) ? '' : ( 'fluent_' . $form_id );
		if ( ! $this->verifyToken( $token, $action ) ) {
			$errors['turnstile'] = $this->errorMessage( __( 'Please complete the security verification.', 'wp-powersuite' ), 'fluent_forms' );
		}
		return $errors;
	}

	/**
	 * Final gate for Fluent submissions (covers conversational paths that skip
	 * `fluentform/validation_errors`).
	 *
	 * @param array  $insert_data Submission insert payload.
	 * @param array  $data        Raw submitted data.
	 * @param object $form        Form object.
	 */
	public function validateFluentFormsBeforeInsert( $insert_data, $data, $form ): void {
		unset( $insert_data );
		// Standard forms are already gated by fluentform/validation_errors. Conversational
		// submissions often skip that filter, so this is their authoritative check.
		if ( ! $this->isFluentConversationalForm( $form ) ) {
			return;
		}
		if ( $this->fluentFormHasNativeTurnstile( $form ) ) {
			return;
		}
		$token = '';
		if ( is_array( $data ) ) {
			$token = $this->extractTurnstileTokenFromNestedArray( $data );
		}
		if ( $token === '' ) {
			$token = $this->getTokenFromRequest();
		}
		if ( $this->verifyToken( $token, '' ) ) {
			return;
		}
		wp_send_json(
			[
				'errors' => [
					'turnstile' => [
						$this->errorMessage( __( 'Please complete the security verification.', 'wp-powersuite' ), 'fluent_forms' ),
					],
				],
			],
			422
		);
	}

	/**
	 * 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.', 'wp-powersuite' ),
				'codes'   => [],
			];
		}
		if ( $token === '' ) {
			return [
				'success' => false,
				'message' => __( 'A token is required to perform the connection test.', 'wp-powersuite' ),
				'codes'   => [],
			];
		}
		if ( strlen( $token ) > self::TOKEN_MAX_LEN ) {
			return [
				'success' => false,
				'message' => __( 'Token is too long.', 'wp-powersuite' ),
				'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 the server can talk to challenges.cloudflare.com.', 'wp-powersuite' ) . ' (' . $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.', 'wp-powersuite' ),
					$host
				)
				: __( 'Success — token verified.', 'wp-powersuite' );
			return [ 'success' => true, 'message' => $message, 'codes' => [] ];
		}
		$message = __( 'Verification failed.', 'wp-powersuite' );
		if ( in_array( 'invalid-input-secret', $codes, true ) ) {
			$message = __( 'Invalid secret key — copy the key from the Cloudflare Turnstile dashboard again.', 'wp-powersuite' );
		} elseif ( in_array( 'missing-input-secret', $codes, true ) ) {
			$message = __( 'No secret key was sent to Cloudflare — is the field empty?', 'wp-powersuite' );
		} 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).', 'wp-powersuite' );
		} elseif ( in_array( 'timeout-or-duplicate', $codes, true ) ) {
			$message = __( 'Token already used or expired (Turnstile tokens are single-use and short-lived).', 'wp-powersuite' );
		} elseif ( in_array( 'bad-request', $codes, true ) ) {
			$message = __( 'Cloudflare rejected the request format. Try again.', 'wp-powersuite' );
		} elseif ( in_array( 'internal-error', $codes, true ) ) {
			$message = __( 'Cloudflare reported an internal error. Try again in a moment.', 'wp-powersuite' );
		}
		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;
	}
}
