<?php
/**
 * Google reCAPTCHA Module
 *
 * Integrates Google reCAPTCHA v2/v3 with WordPress forms. Protects login,
 * registration, comments, WooCommerce, and third-party form plugins from
 * spam and bots.
 *
 * @package GoogleRecaptcha
 * @since 1.0.0
 */

namespace GoogleRecaptcha;

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\SecretRedactor;
use WP_PowerSuite\Captcha\Shared\SkipRules;
use WP_PowerSuite\Captcha\Shared\TokenReader;
use WP_PowerSuite\Captcha\Shared\WidgetLayoutStyles;
use WP_PowerSuite\Captcha\Shared\WooCheckoutContext;
use WP_PowerSuite\Core\OptionStoreModuleSettingsRepository;

require_once __DIR__ . '/ExtendedIntegrations.php';
require_once __DIR__ . '/FeatureGate.php';

/**
 * Google reCAPTCHA Module Class
 *
 * @since 1.0.0
 */
class Module extends AbstractCaptchaModule {

	private const MODULE_ID = 'google-recaptcha';
	public const MODULE_VERSION = '1.4.21';
	public const SCRIPT_HANDLE = 'wpps-google-recaptcha';
	private const DISABLE_SUBMIT_HANDLE = 'wpps-grecaptcha-disable-submit';
	/** @var string Store API namespace for WooCommerce Blocks checkout token extension */
	public const WOO_STORE_API_NAMESPACE = 'wp-powersuite-recaptcha';
	private const WOO_CHECKOUT_FRONT_HANDLE = 'wpps-grecaptcha-woo-checkout-front';
	private const COMMENT_AJAX_RESET_HANDLE = 'wpps-grecaptcha-comment-reset';
	private const FORM_RESET_HANDLE = 'wpps-grecaptcha-form-reset';
	private const RECAPTCHA_SCRIPT_V2 = 'https://www.google.com/recaptcha/api.js';
	private const RECAPTCHA_SCRIPT_V3 = 'https://www.google.com/recaptcha/api.js';
	private const SITEVERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';

	/**
	 * Google's published v2 test keys, which always verify but report Google's own hostname.
	 *
	 * @see https://developers.google.com/recaptcha/docs/faq
	 */
	private const GOOGLE_TEST_SITE_KEY   = '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI';
	private const GOOGLE_TEST_SECRET_KEY = '6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe';
	private const ENCRYPT_KEY = 'wpps_grecaptcha_v1';

	private ?array $settings_cache = null;

	/** @var string|null Last siteverify failure: `network` or `api`. */
	private static ?string $last_verify_failure = null;

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

	/**
	 * Cache of verifyToken() outcomes for a single request.
	 *
	 * Google tokens are single-use; WordPress login can fire `authenticate` twice
	 * in one lifecycle (e.g. auth_cookie then signon). Memoising the action+token
	 * pair avoids a second siteverify call that would legitimately fail.
	 *
	 * @var array<string,bool>
	 */
	private static array $verify_memo = [];

	/** @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 Whether we already attached {@see enqueueRecaptchaScriptForCustomLoginUrl()} to template_redirect. */
	private static bool $custom_login_url_template_redirect_attached = false;

	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 __( 'Google reCAPTCHA', 'wp-powersuite' );
	}

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

	public function getDescription(): string {
		return __( 'Google reCAPTCHA on logins, forms, comments, and WooCommerce to block bots and spam signups.', '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;
		}

		// Login screen: load api.js only when core/Woo login flows need it (same scope as Turnstile).
		if ( CaptchaRuntime::needsWpLoginScreenAssets( $settings ) ) {
			add_action( 'login_enqueue_scripts', [ $this, 'enqueueRecaptchaScript' ] );
			add_action( 'login_enqueue_scripts', [ $this, 'enqueueLoginRecaptchaLayoutStyles' ], 100 );
			add_action( 'init', [ $this, 'registerCustomLoginUrlRecaptchaHook' ], 999 );
		}

		// Allow super-admin to configure per-provider counters from the dashboard widget.
		if ( is_admin() ) {
			add_action( 'init', [ $this, 'registerCaptchaDashboardWidget' ], 5 );
		}

		// WordPress Core Forms — widget data-action must match verifyToken(..., $action),
		// otherwise the action baked into the v3 token won't match the server-side hash_equals check.
		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, 'renderCommentRecaptchaErrorNotice' ] );
			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, 'renderCommentRecaptchaErrorNotice' ] );
			add_filter( 'preprocess_comment', [ $this, 'validateProductReview' ] );
		}

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

		// WooCommerce
		if ( class_exists( 'WooCommerce' ) ) {
			add_action( 'before_woocommerce_init', [ $this, 'declareWooCommerceCompatibility' ] );
			$this->initWooCommerce( $settings );
		}

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

		add_filter( 'wpps_verify_google_recaptcha', [ $this, 'filterVerifyRecaptcha' ], 10, 3 );

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

		ExtendedIntegrations::boot( $this );
	}

	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 );
			add_filter( 'render_block_woocommerce/checkout-payment-block', [ $this, 'prependRecaptchaToCheckoutPaymentBlock' ], 20, 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 );
		}
	}

	/**
	 * HPOS / Blocks declarations for WooCommerce when this 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 );
	}

	/**
	 * Attach custom-login `template_redirect` only when another module may flag those requests.
	 */
	public function registerCustomLoginUrlRecaptchaHook(): 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, 'enqueueRecaptchaScriptForCustomLoginUrl' ], 0 );
	}

	/**
	 * Pre-enqueue reCAPTCHA when Change Login URL masks wp-login.php.
	 */
	public function enqueueRecaptchaScriptForCustomLoginUrl(): void {
		if ( ! CaptchaRuntime::needsWpLoginScreenAssets( $this->getSettings() ) ) {
			return;
		}
		if ( ! apply_filters( 'wp_powersuite_is_custom_login_url_request', false ) ) {
			return;
		}
		$this->enqueueRecaptchaScript();
		LoginLayoutStyles::enqueueStandalone();
	}

	/**
	 * 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() );
	}

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

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

	private function initFormidableForms( array $settings ): void {
		if ( empty( $settings['formidable'] ) || ! class_exists( 'FrmFormsController' ) ) {
			return;
		}
		add_filter( 'frm_submit_button_html', [ $this, 'prependRecaptchaToFormidableSubmit' ], 10, 2 );
		add_filter( 'frm_validate_entry', [ $this, 'validateFormidable' ], 20, 2 );
	}

	private function initFluentForms( array $settings ): void {
		if ( empty( $settings['fluent_forms'] ) || ! defined( 'FLUENTFORM' ) ) {
			return;
		}
		// Render immediately before the submit button (not at the top of the form).
		// `form_element_start` fires once at form open which pushed the widget above
		// every field; `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 );
	}

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

	public function filterVerifyBasicForms( bool $verified, int $form_id, array $settings ): bool {
		if ( ! CaptchaRuntime::basicFormsCaptchaActive( $settings ) ) {
			return $verified;
		}
		$provider = $settings['captcha_provider'] ?? '';
		if ( $provider !== 'google' ) {
			return $verified;
		}
		return $this->verifyToken( $this->getTokenFromRequest(), 'basic_forms_' . $form_id );
	}

	public function renderWidgetForBasicForms( int $form_id, array $settings ): void {
		if ( ! CaptchaRuntime::basicFormsCaptchaActive( $settings ) ) {
			return;
		}
		$provider = $settings['captcha_provider'] ?? '';
		if ( $provider !== 'google' ) {
			return;
		}
		$fid = (int) $form_id;
		// Explicit render so V2Init always stores data-wpps-gr-wid for reliable AJAX reset.
		$opts = [ 'explicit_only' => true ];
		if ( ! empty( $this->getSettings()['disable_submit'] ) && ( $this->getSettings()['version'] ?? 'v2' ) === 'v2' ) {
			$this->ensureRecaptchaScriptQueued();
			$cb  = 'wppsGrBfUnlock' . $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 = array_merge( $opts, $this->disableSubmitPair( $cb, $sel ) );
		}
		$this->renderWidget( 'basic_forms_' . $fid, $opts );
	}

	/** @return array<string, mixed> Normalized settings for integrations / admin parity with Turnstile. */
	public function getCaptchaSettings(): array {
		return $this->getSettings();
	}

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

	/**
	 * Optional `unlock_selectors` + `submit_callback` for v2 when "Disable submit until solved" is enabled.
	 *
	 * @return array<string, string>
	 */
	public function unlockSubmitOpts( string $js_callback, string $unlock_selectors ): array {
		return $this->disableSubmitPair( $js_callback, $unlock_selectors );
	}

	/**
	 * @return array<string, string>
	 */
	private 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,
		];
	}

	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/google-recaptcha/assets/disable-submit.js',
			[ self::SCRIPT_HANDLE ],
			self::MODULE_VERSION,
			true
		);
	}

	public function enqueueRecaptchaScript(): void {
		$this->ensureRecaptchaScriptQueued();
	}

	/**
	 * Shared wp-login layout rules (left-aligned, no transform clipping).
	 */
	public function enqueueLoginRecaptchaLayoutStyles(): void {
		if ( ! CaptchaRuntime::needsWpLoginScreenAssets( $this->getSettings() ) ) {
			return;
		}
		LoginLayoutStyles::enqueue();
	}

	/**
	 * Load reCAPTCHA api.js in wp-admin so Basic Forms (preview/editor) v2 works.
	 *
	 * @return void
	 */
	public function enqueueAdminWidgetLayoutStyles(): void {
		if ( ! $this->isConfigured() ) {
			return;
		}
		if ( ! CaptchaRuntime::shouldEnqueueAdminAssets( $this->getSettings() ) ) {
			return;
		}
		WidgetLayoutStyles::enqueueAdmin();
	}

	public function enqueueRecaptchaInAdminForBasicForms(): void {
		$s = $this->getSettings();
		if ( empty( $s['basic_forms'] ) || ! CaptchaRuntime::isBasicFormsAdminScreen() ) {
			return;
		}
		$this->ensureRecaptchaScriptQueued();
	}

	/**
	 * @param array<string, mixed> $settings Normalized settings.
	 */
	private function buildRecaptchaScriptUrl( string $version, string $site_key, array $settings ): string {
		$base = $version === 'v3' ? self::RECAPTCHA_SCRIPT_V3 : self::RECAPTCHA_SCRIPT_V2;
		$args = $version === 'v3' ? [ 'render' => $site_key ] : [];

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

		return $args !== [] ? add_query_arg( $args, $base ) : $base;
	}

	private function ensureRecaptchaScriptQueued(): void {
		if ( self::$script_queued ) {
			return;
		}
		$settings = $this->getSettings();
		$site_key = $settings['site_key'] ?? '';
		if ( empty( $site_key ) ) {
			return;
		}
		self::$script_queued = true;

		add_filter( 'script_loader_tag', [ $this, 'filterRecaptchaScriptLoaderTag' ], 10, 2 );

		// Combined/deferred/delayed api.js never renders the widget, leaving visitors
		// with a verification error they cannot act on.
		OptimizerCompat::protectScripts(
			[
				'google.com/recaptcha',
				'gstatic.com/recaptcha',
				'recaptcha',
				'google-recaptcha/assets',
			]
		);

		$version    = $settings['version'] ?? 'v2';
		$script_url = $this->buildRecaptchaScriptUrl( $version, $site_key, $settings );

		// v2: explicit render for late-injected divs; implicit handles initial DOM via g-recaptcha.
		if ( $version === 'v2' ) {
			$v2_before = <<<'WPPS_V2'
(function(){
function wppsGrecaptchaV2Run(){
if(typeof grecaptcha==="undefined"||!grecaptcha.ready){
if((window.wppsGrecaptchaV2Retry=(window.wppsGrecaptchaV2Retry||0)+1)<30){setTimeout(wppsGrecaptchaV2Run,60);}return;
}
window.wppsGrecaptchaV2Retry=0;
grecaptcha.ready(function(){
var nodes=document.querySelectorAll("div.wpps-g-recaptcha-v2:not([data-wpps-grecaptcha-done]):not(#wpps-grecaptcha-woo-checkout)");
var i,el,sk,th,sz,ifr;
for(i=0;i<nodes.length;i++){
el=nodes[i];
if(el.id==="wpps-grecaptcha-woo-checkout"){continue;}
ifr=el.querySelector("iframe");
if(ifr){el.setAttribute("data-wpps-grecaptcha-done","1");continue;}
sk=el.getAttribute("data-sitekey");
if(!sk){continue;}
el.setAttribute("data-wpps-grecaptcha-done","1");
th=el.getAttribute("data-theme")||"light";
sz=el.getAttribute("data-size")||"normal";
try{var cbn=el.getAttribute("data-callback");var ropts={sitekey:sk,theme:th,size:sz};if(cbn){ropts.callback=function(){try{if(window[cbn]&&typeof window[cbn]==="function"){window[cbn]();}}catch(ex){}};}var wid=grecaptcha.render(el,ropts);el.setAttribute("data-wpps-gr-wid",String(wid));}catch(e){
el.removeAttribute("data-wpps-grecaptcha-done");
}
}
});
}
window.wppsGrecaptchaV2Init=wppsGrecaptchaV2Run;
if(typeof jQuery!=="undefined"){
jQuery(document).on("wpcf7invalid wpcf7spam wpcf7mailfailed wpcf7mailsent",function(){
setTimeout(function(){if(window.wppsGrecaptchaV2Init){window.wppsGrecaptchaV2Init();}},400);
});
jQuery(document).on("fluentform_submission_failed",function(){
setTimeout(function(){if(window.wppsGrecaptchaV2Init){window.wppsGrecaptchaV2Init();}},400);
});
}
document.addEventListener("DOMContentLoaded",function(){
if(typeof window.wppsGrecaptchaV2Init!=="function"){return;}
setTimeout(function(){window.wppsGrecaptchaV2Init();},0);
});
})();
WPPS_V2;
			wp_add_inline_script( self::SCRIPT_HANDLE, $v2_before, 'before' );
		}

		$script_deps = $version === 'v2' ? [ 'jquery' ] : [];
		wp_enqueue_script(
			self::SCRIPT_HANDLE,
			$script_url,
			$script_deps,
			null,
			true
		);
		// Defer only: async+defer with onload was racing v2; Promise.reject without catch stuck v3 UI.
		wp_script_add_data( self::SCRIPT_HANDLE, 'defer', true );

		// v2 tokens are single-use: AJAX forms keep the page — and the redeemed checkbox —
		// after a submission, so the next attempt would post a dead token. v3 fetches a
		// fresh token per submit and needs nothing here.
		if ( $version === 'v2' && defined( 'WP_POWERSUITE_PLUGIN_URL' ) ) {
			wp_enqueue_script(
				self::FORM_RESET_HANDLE,
				WP_POWERSUITE_PLUGIN_URL . 'modules/google-recaptcha/assets/form-reset.js',
				[ 'jquery', self::SCRIPT_HANDLE ],
				self::MODULE_VERSION,
				true
			);
		}

		// v3: execute() is async. On first submit, prevent the event from reaching the
		// form (WPCF7/AJAX need a real token) — preventDefault + stopPropagation +
		// stopImmediatePropagation, then requestSubmit for a second pass. Without
		// stopPropagation, CF7 runs the first submit with an empty g-recaptcha-response.
		if ( $version === 'v3' ) {
			$refresh_js = <<<'WPPS_GRECAPTCHA_V3'
(function(){
var sk=__SITE_KEY__;
document.addEventListener("submit",function(e){
var t=e.target,f=(t&&t.tagName)?(t.tagName.toLowerCase()==="form"?t:t.form):null;
if(!f||!f.querySelector(".wpps-recaptcha-v3-token")){return;}
if(f.getAttribute("data-wpps-recaptcha-v3-resubmit")==="1"){
f.removeAttribute("data-wpps-recaptcha-v3-resubmit");
return;
}
e.preventDefault();
e.stopImmediatePropagation();
e.stopPropagation();
var runDone=function(){
f.setAttribute("data-wpps-recaptcha-v3-resubmit","1");
if(typeof f.requestSubmit==="function"){f.requestSubmit();}
else{HTMLFormElement.prototype.submit.call(f);}
};
try{
grecaptcha.ready(function(){
if(typeof grecaptcha==="undefined"||!grecaptcha.execute){runDone();return;}
var inps=f.querySelectorAll(".wpps-recaptcha-v3-token");
var n=inps.length,i,promises=[],skRef=sk,onFail=function(){runDone();};
if(n<1){runDone();return;}
for(i=0;i<n;i++){(function(inp){
var act=inp.getAttribute("data-action")||"form";
promises.push(grecaptcha.execute(skRef,{action:act}).then(function(tok){inp.value=tok;}));
})(inps[i]);}
if(typeof Promise!=="undefined"&&promises.length){
Promise.all(promises).then(runDone).catch(onFail);
}else{
grecaptcha.execute(skRef,{action:inps[0].getAttribute("data-action")||"form"}).then(function(tok){
if(inps[0]){inps[0].value=tok;}
runDone();
}).catch(onFail);
}
});
}catch(err){runDone();}
},true);
})();
WPPS_GRECAPTCHA_V3;
			$refresh_js = str_replace( '__SITE_KEY__', wp_json_encode( $site_key ), $refresh_js );
			wp_add_inline_script( self::SCRIPT_HANDLE, $refresh_js, 'after' );
		}
	}

	/**
	 * Prevent Cloudflare Rocket Loader from deferring the reCAPTCHA api.js.
	 *
	 * @param string $tag    Script HTML.
	 * @param string $handle Handle.
	 * @return string
	 */
	public function filterRecaptchaScriptLoaderTag( 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;
	}

	/**
	 * @param string               $action  Verification action (v3) or logical surface label.
	 * @param array<string, mixed> $options Optional: `html_id`, `unlock_selectors`, `submit_callback`.
	 */
	public function renderWidget( string $action = '', array $options = [] ): void {
		$settings = $this->getSettings();
		$site_key = $settings['site_key'] ?? '';
		if ( empty( $site_key ) ) {
			return;
		}

		// Honor skip rules at render time so exempt users (roles / allowed IPs)
		// never see a widget — not just bypass verification. Without this check
		// an admin with "skip for administrators" on would still render the widget.
		if ( SkipRules::shouldSkip( is_array( $settings['skip_rules'] ?? null ) ? $settings['skip_rules'] : [] ) ) {
			return;
		}

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

		$version      = $settings['version'] ?? 'v2';
		$disable_gate = ! empty( $settings['disable_submit'] ) && $version === 'v2' && $unlock_selectors !== '';
		$this->ensureRecaptchaScriptQueued();
		if ( $disable_gate ) {
			$this->enqueueDisableSubmitScript();
			echo '<style>' . esc_html( $unlock_selectors ) . '{pointer-events:none!important;opacity:0.5!important;}</style>';
		}

		WidgetLayoutStyles::printFrontEndOnce();

		$theme = $settings['theme'] ?? 'light';
		$size  = 'normal';

		echo '<div class="wpps-captcha-cushion" style="clear:both;margin-bottom:16px;">';
		if ( $version === 'v3' ) {
			$action_attr = is_string( $action ) && $action !== '' ? $action : 'form';
			$this->renderV3Widget( $action_attr, $options );
		} else {
			$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 ) . '"' : '';
			$cb_attr = ( $disable_gate && $submit_callback !== '' ) ? ' data-callback="' . esc_attr( $submit_callback ) . '"' : '';
			// Omit the Google `g-recaptcha` class when explicit-only so api.js implicit scan
			// cannot claim checkout (Store API token must come from woo-checkout.js).
			$classes = ! empty( $options['explicit_only'] )
				? 'wpps-g-recaptcha-v2'
				: 'g-recaptcha wpps-g-recaptcha-v2';
			printf(
				'<div%s class="%s" data-sitekey="%s" data-theme="%s" data-size="%s"%s></div>',
				$id_attr,
				esc_attr( $classes ),
				esc_attr( $site_key ),
				esc_attr( $theme ),
				esc_attr( $size ),
				$cb_attr
			);
		}
		echo '</div>';
	}

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

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

	/**
	 * @param array<string, mixed> $options Optional `html_id` for the hidden input.
	 */
	private function renderV3Widget( string $action, array $options = [] ): void {
		// No inline grecaptcha here — in-body output runs before api.js, throws,
		// and can break the rest of the form (e.g. Contact Form 7). Submit handler
		// in ensureRecaptchaScriptQueued() fills the token.
		$html_id = isset( $options['html_id'] ) ? trim( (string) $options['html_id'] ) : '';
		$html_id = preg_replace( '/[^a-zA-Z0-9_-]/', '', $html_id );
		$id      = $html_id !== '' ? $html_id : 'wpps-recaptcha-v3-' . wp_rand( 1000, 9999 );
		printf(
			'<input type="hidden" id="%s" name="g-recaptcha-response" class="wpps-recaptcha-v3-token" data-action="%s" value="" />',
			esc_attr( $id ),
			esc_attr( $action )
		);
	}

	public function renderLoginWidget(): void {
		if ( CaptchaRuntime::isMagicLoginLoginScreen() ) {
			return;
		}
		$s = $this->getSettings();
		if ( ! empty( $s['login_canonical_only'] ) && ! $this->isRequestPathWpLogin() ) {
			return;
		}
		$this->renderWidget(
			'login',
			$this->unlockSubmitOpts( 'wppsGrWpCallback', '#wp-submit' )
		);
	}

	public function renderRegisterWidget(): void {
		$s = $this->getSettings();
		if ( ! empty( $s['register_canonical_only'] ) && ! $this->isWpLoginRegisterScreen() ) {
			return;
		}
		$this->renderWidget(
			'register',
			$this->unlockSubmitOpts( 'wppsGrWpCallback', '#wp-submit' )
		);
	}

	public function renderLostPasswordWidget(): void {
		if ( current_filter() === 'woocommerce_lostpassword_form' ) {
			$this->renderWidget( 'lostpassword', $this->unlockSubmitOpts( 'wppsGrWooResetCallback', '.woocommerce-ResetPassword .button' ) );
			return;
		}
		$this->renderWidget(
			'lostpassword',
			$this->unlockSubmitOpts( 'wppsGrWpCallback', '#wp-submit' )
		);
	}

	public function renderCommentWidget(): void {
		$this->renderWidget(
			'comment',
			$this->unlockSubmitOpts(
				'wppsGrCommentCallback',
				'#commentform input#submit, #commentform button[type="submit"]'
			)
		);
	}

	public function renderWooLoginWidget(): void {
		$this->renderWidget( 'woo_login', $this->unlockSubmitOpts( 'wppsGrWooLoginCallback', '.woocommerce-form-login__submit' ) );
	}

	public function renderWooRegisterWidget(): void {
		$this->renderWidget( 'woo_register', $this->unlockSubmitOpts( 'wppsGrWooRegisterCallback', '.woocommerce-form-register__submit' ) );
	}

	private function renderWooCheckoutWidgetMarkup(): void {
		$s = $this->getSettings();
		if ( ! empty( $s['woo_checkout_guest_only'] ) && is_user_logged_in() ) {
			return;
		}
		// Checkout JS owns render + success callback (Store API extension data + unlock).
		$opts = [
			'html_id'       => 'wpps-grecaptcha-woo-checkout',
			'explicit_only' => true,
		];
		if ( ( $s['version'] ?? 'v2' ) === 'v2' && ! 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';
			$opts['submit_callback']  = 'wppsGrWooCheckoutCallback';
		}
		$this->renderWidget( 'woo_checkout', $opts );
	}

	/**
	 * Classic shortcode checkout — widget before place order (once per request).
	 */
	public function renderWooCheckoutWidget(): void {
		if ( self::$woo_checkout_classic_emitted ) {
			return;
		}
		self::$woo_checkout_classic_emitted = true;
		$this->renderWooCheckoutWidgetMarkup();
	}

	/**
	 * Woo Blocks: place the widget ahead of the payment block so it participates in checkout layout.
	 *
	 * @param string $block_content Rendered block HTML.
	 */
	public function prependRecaptchaToCheckoutPaymentBlock( 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();
		$this->renderWooCheckoutWidgetMarkup();
		return (string) ob_get_clean() . $block_content;
	}

	/**
	 * Register Store API extension so Blocks checkout can POST our namespace + token.
	 */
	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' => __( 'Google reCAPTCHA 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.
	 */
	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 ( [ 'g-recaptcha-response', 'g_recaptcha_response' ] as $key ) {
				if ( isset( $params[ $key ] ) && is_string( $params[ $key ] ) && $params[ $key ] !== '' ) {
					$token = $params[ $key ];
					break;
				}
			}
		}
		if ( $token === '' ) {
			$token = $this->getTokenFromRequest();
		}
		$token = $this->sanitizeRecaptchaToken( $token );

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

		$msg = $this->errorMessage( __( 'Please complete the reCAPTCHA 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_recaptcha_checkout',
				wp_strip_all_tags( $msg ),
				400
			);
		}

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

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

	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->ensureRecaptchaScriptQueued();
		if ( ( $s['version'] ?? 'v2' ) === 'v2' && ! 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/google-recaptcha/assets/woo-checkout.js',
			$deps,
			self::MODULE_VERSION,
			true
		);
		wp_localize_script(
			self::WOO_CHECKOUT_FRONT_HANDLE,
			'wpPsGrecaptchaWoo',
			[
				'ns'                   => self::WOO_STORE_API_NAMESPACE,
				'version'              => $s['version'] ?? 'v2',
				'siteKey'              => $s['site_key'] ?? '',
				'action'               => 'woo_checkout',
				'theme'                => $s['theme'] ?? 'light',
				'size'                 => 'normal',
				'missingTokenMessage'  => __( 'Please complete the reCAPTCHA verification to complete your order.', 'wp-powersuite' ),
				'v3UnavailableMessage' => __( 'Security verification is unavailable. Please refresh and try again.', 'wp-powersuite' ),
				'v3FailedMessage'      => __( 'Security verification failed. Please try again.', 'wp-powersuite' ),
			]
		);
	}

	public function maybeRenderWidgetForReviews(): void {
		if ( ! is_singular( 'product' ) ) {
			return;
		}
		$this->renderWidget(
			'woo_review',
			$this->unlockSubmitOpts(
				'wppsGrCommentCallback',
				'#commentform input#submit, #commentform button[type="submit"]'
			)
		);
	}

	/**
	 * @param string                 $button Current submit button HTML.
	 * @param array<string, mixed>   $args   Formidable args (includes `form`).
	 * @return string
	 */
	public function prependRecaptchaToFormidableSubmit( $button, $args ) {
		$form = ( is_array( $args ) && ! empty( $args['form'] ) && is_object( $args['form'] ) ) ? $args['form'] : null;
		if ( $form === null ) {
			return (string) $button;
		}
		$form_id = isset( $form->id ) ? absint( $form->id ) : 0;
		ob_start();
		$this->renderWidget( $form_id > 0 ? 'formidable_' . $form_id : 'formidable_form', $this->unlockSubmitOpts( 'wppsGrFormidableCallback', '.frm_forms .frm_button_submit' ) );
		$before = (string) ob_get_clean();
		return $before . $button;
	}

	private function verifyToken( string $token, string $action = '' ): bool {
		self::$last_verify_failure = null;
		$settings = $this->getSettings();

		// Role/IP skip rules bypass the siteverify call entirely and count as a pass.
		if ( SkipRules::shouldSkip( $settings['skip_rules'] ?? [] ) ) {
			return true;
		}

		if ( empty( $token ) ) {
			$this->recordOutcome( false );
			return false;
		}

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

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

		$body = [
			'secret'   => $secret_key,
			'response' => $token,
		];

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

		$version = $settings['version'] ?? 'v2';
		$is_v3   = $version === 'v3';

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

		if ( is_wp_error( $response ) ) {
			SecretRedactor::safeLog( 'reCAPTCHA siteverify transport failed: ' . $response->get_error_message(), [ $secret_key ] );
			self::$last_verify_failure = 'network';
			self::$verify_memo[ $memo_key ] = false;
			$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( [ 'recaptcha_error_codes' => $data['error-codes'] ], [ $secret_key ] );
			}
			self::$verify_memo[ $memo_key ] = false;
			$this->recordOutcome( false );
			return false;
		}

		if ( $is_v3 ) {
			if ( ! isset( $data['score'] ) || ! is_numeric( $data['score'] ) ) {
				self::$last_verify_failure     = 'api';
				self::$verify_memo[ $memo_key ] = false;
				$this->recordOutcome( false );
				return false;
			}
			$threshold = (float) ( $settings['v3_score_threshold'] ?? 0.5 );
			if ( (float) $data['score'] < $threshold ) {
				self::$last_verify_failure     = 'api';
				self::$verify_memo[ $memo_key ] = false;
				$this->recordOutcome( false );
				return false;
			}

			if ( $action !== '' ) {
				$got = isset( $data['action'] ) ? (string) $data['action'] : '';
				if ( $got === '' || ! hash_equals( $action, $got ) ) {
					self::$last_verify_failure     = 'api';
					self::$verify_memo[ $memo_key ] = false;
					$this->recordOutcome( false );
					return false;
				}
			}
		}

		// Google's published v2 test pair always verifies but reports Google's own hostname
		// instead of this site's, so the check below would reject every submission made with
		// them. Skipping it for that exact documented pair lets QA use them end to end without
		// loosening anything for real keys — and those keys pass for everyone anyway, so a site
		// configured with them is unprotected either way.
		if ( ! $this->usingGoogleTestKeys( $settings ) && ! $this->hostnameMatchesSite( isset( $data['hostname'] ) ? (string) $data['hostname'] : '' ) ) {
			self::$last_verify_failure = 'api';
			SecretRedactor::safeLog( 'reCAPTCHA siteverify hostname mismatch: ' . (string) ( $data['hostname'] ?? '' ), [ $secret_key ] );
			self::$verify_memo[ $memo_key ] = false;
			$this->recordOutcome( false );
			return false;
		}

		self::$verify_memo[ $memo_key ] = true;
		$this->recordOutcome( true );
		return true;
	}

	/**
	 * Whether the configured keys are Google's published v2 test pair.
	 *
	 * @see https://developers.google.com/recaptcha/docs/faq
	 *
	 * @param array<string,mixed> $settings Module settings.
	 */
	private function usingGoogleTestKeys( array $settings ): bool {
		$site   = (string) ( $settings['site_key'] ?? '' );
		$secret = (string) ( $settings['secret_key'] ?? '' );

		return $site !== ''
			&& $secret !== ''
			&& hash_equals( self::GOOGLE_TEST_SITE_KEY, $site )
			&& hash_equals( self::GOOGLE_TEST_SECRET_KEY, $secret );
	}

	/**
	 * Whether a siteverify hostname is allowed for this site.
	 */
	private function hostnameMatchesSite( string $hostname ): bool {
		$hostname = strtolower( trim( $hostname ) );
		if ( $hostname === '' ) {
			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 reCAPTCHA siteverify hostnames.
		 *
		 * @param list<string> $allowed  Lowercase hostnames.
		 * @param string       $hostname Hostname returned by Google.
		 */
		$allowed = apply_filters( 'wp_powersuite_recaptcha_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 the pass/fail counter for this provider. Centralised here so every
	 * code path through verifyToken() contributes exactly one counter entry.
	 */
	private function recordOutcome( bool $passed ): void {
		Counters::record( self::MODULE_ID, $passed );
	}

	/**
	 * Render the standard error message, filterable via {@see wpps_captcha_error_message}
	 * (roadmap item #19). `$context` lets themes/integrations tailor the text per surface
	 * (login / register / checkout / ...).
	 */
	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' );
		}
		$msg = apply_filters( 'wpps_captcha_error_message', $default, $context, self::MODULE_ID );
		return is_string( $msg ) && $msg !== '' ? $msg : $default;
	}

	private function getTokenFromRequest(): string {
		return TokenReader::fromPost( 'g-recaptcha-response' );
	}

	/**
	 * Find a g-recaptcha response token in nested form payloads (AJAX/Fluent, etc.).
	 *
	 * @param array<string, mixed> $data Posted or decoded form data.
	 * @return string
	 */
	private function extractGrecaptchaTokenFromArray( array $data ): string {
		$keys = [ 'g-recaptcha-response', 'g_recaptcha_response' ];
		foreach ( $keys as $k ) {
			if ( isset( $data[ $k ] ) && is_string( $data[ $k ] ) && $data[ $k ] !== '' ) {
				return $this->sanitizeRecaptchaToken( $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->sanitizeRecaptchaToken( $inner[ $k ] );
				}
			}
		}

		return '';
	}

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

		return $token;
	}

	/**
	 * Token from main POST, then from optional nested $form_data (Fluent, WPForms).
	 */
	private function resolveGrecaptchaToken( ?array $form_data = null ): string {
		$t = $this->getTokenFromRequest();
		if ( $t !== '' ) {
			return $t;
		}
		if ( is_array( $form_data ) && $form_data !== [] ) {
			return $this->extractGrecaptchaTokenFromArray( $form_data );
		}

		return '';
	}

	/**
	 * Whether this request is an actual wp-login.php credential submission.
	 *
	 * WordPress calls wp_signon() on GET for the login screen; the authenticate
	 * filter must not treat that as a failed captcha.
	 */
	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'] );
	}

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

	public function validateLogin( $user, string $username, string $password ) {
		$settings = $this->getSettings();
		// WooCommerce My Account runs its own `woocommerce_process_login_errors` validator
		// with action `woo_login`; skip here so we do not double-verify a single-use token
		// (and mismatch actions in v3).
		if ( ! empty( $settings['woo_login'] ) && $this->isWooCommerceMyAccountLoginSubmission() ) {
			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(
			'recaptcha_failed',
			$this->errorMessage( __( 'Please complete the reCAPTCHA verification to log in.', 'wp-powersuite' ), 'login' )
		);
	}

	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( 'recaptcha_failed', $this->errorMessage( __( 'Please complete the reCAPTCHA verification to register.', 'wp-powersuite' ), 'register' ) );
		}
		return $errors;
	}

	public function validatePasswordReset( $allow, int $user_id ) {
		if ( ! isset( $_POST['user_login'] ) ) {
			return $allow;
		}
		if ( $this->verifyToken( $this->getTokenFromRequest(), 'lostpassword' ) ) {
			return $allow;
		}
		return new \WP_Error(
			'recaptcha_failed',
			$this->errorMessage( __( 'Please complete the reCAPTCHA verification to reset your password.', 'wp-powersuite' ), 'lostpassword' )
		);
	}

	/**
	 * Render widget 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->unlockSubmitOpts( 'wppsGrWpCallback', '#wp-submit' )
		);
	}

	/**
	 * Validate reCAPTCHA 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(
			'recaptcha_failed',
			$this->errorMessage( __( 'Please complete the reCAPTCHA verification to reset your password.', 'wp-powersuite' ), 'resetpass' )
		);
	}

	/**
	 * Whether this request is WooCommerce My Account login (not wp-login.php).
	 *
	 * WC validates in validateWooLogin() with action `woo_login`; core validateLogin() must not re-verify the same
	 * token (wrong action / single-use replay).
	 */
	private function isWooCommerceMyAccountLoginSubmission(): bool {
		return isset( $_POST['woocommerce-login-nonce'] ) && ! isset( $_POST['log'] );
	}

	/**
	 * Whether this request is WooCommerce registration on My Account (not wp-signup.php).
	 */
	private function isWooCommerceRegistrationSubmission(): bool {
		return isset( $_POST['woocommerce-register-nonce'] );
	}

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

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

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

	public function validateProductReview( array $comment_data ): array {
		if ( ! is_singular( 'product' ) ) {
			return $comment_data;
		}
		self::$last_verify_failure = null;
		if ( $this->verifyToken( $this->getTokenFromRequest(), 'woo_review' ) ) {
			return $comment_data;
		}

		$message = $this->errorMessage( __( 'Please complete the reCAPTCHA 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_recaptcha_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 reCAPTCHA verification redirect.
	 */
	public function renderCommentRecaptchaErrorNotice(): void {
		if ( ! isset( $_GET['wpps_recaptcha_error'] ) || sanitize_text_field( wp_unslash( (string) $_GET['wpps_recaptcha_error'] ) ) !== '1' ) {
			return;
		}
		echo '<p class="wpps-recaptcha-comment-error" role="alert" style="color:#b32d2e;margin-bottom:12px;">';
		echo esc_html( $this->errorMessage( __( 'Please complete the reCAPTCHA verification to post a comment.', 'wp-powersuite' ), 'comment' ) );
		echo '</p>';
	}

	public function validateWooLogin( \WP_Error $errors, string $username, string $password ): \WP_Error {
		if ( ! $this->verifyToken( $this->getTokenFromRequest(), 'woo_login' ) ) {
			$errors->add( 'recaptcha_failed', $this->errorMessage( __( 'Please complete the reCAPTCHA verification to log in.', 'wp-powersuite' ), 'woo_login' ) );
		}
		return $errors;
	}

	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( 'recaptcha_failed', $this->errorMessage( __( 'Please complete the reCAPTCHA verification to register.', 'wp-powersuite' ), 'woo_register' ) );
		}
		return $errors;
	}

	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 reCAPTCHA verification to complete your order.', 'wp-powersuite' ), 'woo_checkout' ),
			'error'
		);
	}

	public function injectCf7Widget( string $content ): string {
		$settings = $this->getSettings();
		$site_key = $settings['site_key'] ?? '';
		if ( empty( $site_key ) ) {
			return $content;
		}

		ob_start();
		$this->renderWidget( 'cf7', $this->unlockSubmitOpts( 'wppsGrCF7Callback', '.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;
	}

	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 ) ) {
			// Exempt user/IP: no widget was rendered, do not require a response.
			return false;
		}
		$posted = null;
		if ( is_object( $submission ) && method_exists( $submission, 'get_posted_data' ) ) {
			$posted = $submission->get_posted_data();
			$posted = is_array( $posted ) ? $posted : null;
		}
		$token = $this->resolveGrecaptchaToken( $posted );
		// Always run verifyToken so empty-token denial stays consistent.
		return ! $this->verifyToken( $token, 'cf7' );
	}

	/**
	 * Render widget on WPForms submit area with the matching per-form action.
	 *
	 * Renderer previously received no action and defaulted to `form`, while the validator
	 * verifies `wpforms_<id>` — this made v3 action-verification always fail.
	 *
	 * @param mixed $form_data Form data from WPForms (array with id).
	 */
	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->unlockSubmitOpts( 'wppsGrWPFCallback', '.wpforms-submit' ) );
	}

	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->resolveGrecaptchaToken( $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 reCAPTCHA verification.', 'wp-powersuite' ), 'wpforms' );
		}
		return $errors;
	}

	public function validateFormidable( array $errors, array $values ): array {
		// Widget renders with action `formidable_<id>`; match it here so v3 action verification succeeds.
		$form_id = isset( $values['form_id'] ) ? absint( $values['form_id'] ) : 0;
		$action  = $form_id > 0 ? 'formidable_' . $form_id : 'formidable_form';
		if ( ! $this->verifyToken( $this->getTokenFromRequest(), $action ) ) {
			$errors['recaptcha'] = $this->errorMessage( __( 'Please complete the reCAPTCHA verification.', 'wp-powersuite' ), 'formidable' );
		}
		return $errors;
	}

	/**
	 * Render widget inside Fluent Forms' form_element_start action.
	 *
	 * @param object $form Fluent Forms form object.
	 */
	public function renderWidgetFluentForms( $submit_field, $form = null ): void {
		unset( $submit_field );
		$form_id = 'form';
		if ( is_object( $form ) && isset( $form->id ) ) {
			$form_id = (string) $form->id;
		}
		$this->renderWidget( 'fluent_' . $form_id, $this->unlockSubmitOpts( 'wppsGrFluentCallback', '.fluentform .ff-btn-submit' ) );
	}

	/**
	 * @param array<string, mixed> $errors
	 * @param array<string, mixed> $form_data
	 */
	public function validateFluentForms( array $errors, array $form_data, $form, array $fields ): array {
		unset( $fields );
		$form_id = ( is_object( $form ) && isset( $form->id ) ) ? $form->id : '0';
		$token   = $this->resolveGrecaptchaToken( $form_data );
		if ( ! $this->verifyToken( $token, 'fluent_' . $form_id ) ) {
			$errors['recaptcha'] = $this->errorMessage( __( 'Please complete the reCAPTCHA verification.', 'wp-powersuite' ), 'fluent_forms' );
		}
		return $errors;
	}

	/**
	 * 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;
	}

	private static function normalizeWidgetLanguage( $value ): string {
		$lang = trim( (string) $value );
		if ( $lang === '' || ! preg_match( '/^[a-zA-Z_-]{2,20}$/', $lang ) ) {
			return '';
		}

		return $lang;
	}

	private function getSettings(): array {
		if ( $this->settings_cache !== null ) {
			return $this->settings_cache;
		}
		$settings = $this->moduleSettings()->all( [] );
		if ( ! is_array( $settings ) ) {
			$settings = [];
		}

		// Multisite network-level fallback (roadmap item #32): fall back to a super-admin
		// defined network key only when the per-site value is empty. Never overrides.
		$site_key_raw   = trim( (string) ( $settings['site_key'] ?? '' ) );
		$secret_raw     = trim( (string) ( $settings['secret_key'] ?? '' ) );
		$site_key_final = NetworkSettings::resolve( $site_key_raw, self::MODULE_ID, 'site_key' );
		$secret_stored  = NetworkSettings::resolve( $secret_raw, self::MODULE_ID, 'secret_key' );

		$skip_rules_raw = isset( $settings['skip_rules'] ) && is_array( $settings['skip_rules'] ) ? $settings['skip_rules'] : [];

		$this->settings_cache = [
			'site_key'              => $site_key_final,
			'secret_key'            => self::decryptSecret( $secret_stored ),
			'version'               => in_array( $settings['version'] ?? 'v2', [ 'v2', 'v3' ], true ) ? ( $settings['version'] ?? 'v2' ) : 'v2',
			'theme'                 => in_array( $settings['theme'] ?? 'light', [ 'light', 'dark' ], true ) ? ( $settings['theme'] ?? 'light' ) : 'light',
			'widget_language'       => self::normalizeWidgetLanguage( $settings['widget_language'] ?? '' ),
			'v3_score_threshold'    => (float) ( $settings['v3_score_threshold'] ?? 0.5 ),
			'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_checkout_guest_only' => ! empty( $settings['woo_checkout_guest_only'] ),
			'login_canonical_only'   => ! empty( $settings['login_canonical_only'] ),
			'register_canonical_only' => ! empty( $settings['register_canonical_only'] ),
			'ajax_comment_reset'     => ! empty( $settings['ajax_comment_reset'] ),
			'disable_submit'         => ! empty( $settings['disable_submit'] ),
			'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_reviews'           => ! empty( $settings['woo_reviews'] ),
			'cf7'                   => ! empty( $settings['cf7'] ),
			'wpforms'               => ! empty( $settings['wpforms'] ),
			'formidable'            => ! empty( $settings['formidable'] ),
			'fluent_forms'          => ! empty( $settings['fluent_forms'] ),
			'basic_forms'           => ! empty( $settings['basic_forms'] ),
			'elementor_forms'       => ! empty( $settings['elementor_forms'] ),
			'skip_rules'            => [
				'enabled'      => ! empty( $skip_rules_raw['enabled'] ),
				'roles'        => isset( $skip_rules_raw['roles'] ) && is_array( $skip_rules_raw['roles'] ) ? array_map( 'sanitize_key', $skip_rules_raw['roles'] ) : [],
				'ips'          => isset( $skip_rules_raw['ips'] ) && is_array( $skip_rules_raw['ips'] ) ? array_map( 'strval', $skip_rules_raw['ips'] ) : [],
				'user_agents'  => isset( $skip_rules_raw['user_agents'] ) && is_array( $skip_rules_raw['user_agents'] ) ? array_values( array_filter( array_map( 'trim', $skip_rules_raw['user_agents'] ) ) ) : [],
			],
		];

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

		return $this->settings_cache;
	}

	/**
	 * Elementor Pro Forms integration (roadmap item #26).
	 *
	 * The `elementor_pro/forms/pre_render` hook fires BEFORE Elementor outputs
	 * any of the form HTML, so anything we echoed there landed outside the
	 * <form> tag and was never posted back on submit. Instead we inject a
	 * widget container right before the submit button via a small JS runner,
	 * and validate server-side with `elementor_pro/forms/validation`.
	 */
	private function initElementorForms( array $settings ): void {
		if ( empty( $settings['elementor_forms'] ) || ! did_action( 'elementor/loaded' ) ) {
			return;
		}
		// reCAPTCHA API must be ready before our footer injector runs — enqueue
		// early. (wp_enqueue_script is a no-op by the time wp_footer fires.)
		add_action( 'wp_enqueue_scripts', [ $this, 'enqueueRecaptchaForElementor' ] );
		add_action( 'wp_footer', [ $this, 'printElementorInjectorScript' ], 30 );
		add_action( 'elementor_pro/forms/validation', [ $this, 'validateElementorFormSubmission' ], 10, 2 );
	}

	/**
	 * Enqueue reCAPTCHA API on frontend when Elementor forms integration is on.
	 */
	public function enqueueRecaptchaForElementor(): void {
		if ( is_admin() || ! $this->currentRequestMayContainElementorForm() ) {
			return;
		}
		$this->ensureRecaptchaScriptQueued();
		$s = $this->getSettings();
		if ( ! empty( $s['disable_submit'] ) && ( $s['version'] ?? 'v2' ) === 'v2' ) {
			$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 );
	}

	/**
	 * Client-side widget injector — finds every Elementor form on the page and
	 * appends a reCAPTCHA widget immediately before the submit button. Works
	 * for both v2 (visible checkbox / invisible) and v3 (hidden token refresh).
	 */
	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;
		}
		if ( SkipRules::shouldSkip( is_array( $settings['skip_rules'] ?? null ) ? $settings['skip_rules'] : [] ) ) {
			return;
		}
		WidgetLayoutStyles::printFrontEndOnce();
		$version        = isset( $settings['version'] ) ? (string) $settings['version'] : 'v2';
		$theme          = isset( $settings['theme'] ) ? (string) $settings['theme'] : 'light';
		$disable_submit = ! empty( $settings['disable_submit'] ) && $version === 'v2';
		$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 );
		?>
<script>
(function () {
	var SITE_KEY = <?php echo wp_json_encode( $site_key ); ?>;
	var VERSION  = <?php echo wp_json_encode( $version ); ?>;
	var THEME    = <?php echo wp_json_encode( $theme ); ?>;
	var DISABLE_SUBMIT = <?php echo $disable_submit ? 'true' : 'false'; ?>;

	function inject(form) {
		if (!form || form.dataset.wppsRecaptchaInjected === '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-elementor-recaptcha elementor-field-group';
		wrap.style.margin = '0 0 1.25em 0';
		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, '');
		}
		if (VERSION === 'v3') {
			// Hidden input — v3 refresh logic from ensureRecaptchaScriptQueued()
			// will grab it on submit and fill it with a fresh token.
			var input = document.createElement('input');
			input.type = 'hidden';
			input.name = 'g-recaptcha-response';
			input.className = 'wpps-recaptcha-v3-token';
			input.setAttribute('data-action', formAction);
			wrap.appendChild(input);
		} else {
			var widget = document.createElement('div');
			widget.className = 'g-recaptcha wpps-g-recaptcha-v2';
			widget.setAttribute('data-sitekey', SITE_KEY);
			widget.setAttribute('data-theme', THEME);
			widget.setAttribute('data-size', 'normal');
			if (DISABLE_SUBMIT) {
				widget.setAttribute('data-callback', 'wppsGrElementorCallback');
			}
			wrap.appendChild(widget);
			if (DISABLE_SUBMIT && <?php echo wp_json_encode( $elementor_unlock_css ); ?>) {
				var st = document.createElement('style');
				st.textContent = <?php echo wp_json_encode( $elementor_unlock_css ); ?> + '{pointer-events:none!important;opacity:0.5!important;}';
				document.head.appendChild(st);
			}
			if (window.grecaptcha && typeof grecaptcha.ready === 'function') {
				grecaptcha.ready(function () {
					if (typeof window.wppsGrecaptchaV2Init === 'function') {
						window.wppsGrecaptchaV2Init();
					}
				});
			}
		}
		submit.parentNode.insertBefore(wrap, submit);
		form.dataset.wppsRecaptchaInjected = '1';
	}

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

	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', scan);
	} else {
		scan();
	}
	if (window.jQuery) {
		window.jQuery(document).on('elementor/popup/show', scan);
	}
	window.addEventListener('load', scan);
})();
</script>
		<?php
	}

	/**
	 * Validate Elementor form submission against siteverify.
	 *
	 * @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'] ) );
		}
		$settings = $this->getSettings();
		$is_v3    = ( $settings['version'] ?? 'v2' ) === 'v3';
		if ( $is_v3 ) {
			$action = $form_id !== '' ? 'elementor_' . preg_replace( '/[^a-zA-Z0-9_-]/', '', $form_id ) : 'elementor_form';
		} else {
			$action = '';
		}

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

	/**
	 * Public entrypoint used by the admin Test Connection AJAX to run a siteverify
	 * ping with a user-supplied token. Returns the raw response fields (no secret).
	 *
	 * @param string $token Site-generated token to verify.
	 * @return array{success:bool, message:string}
	 */
	public static function runTestConnection( string $token ): array {
		$settings = OptionStoreModuleSettingsRepository::forModule( self::MODULE_ID )->all( [] );
		if ( ! is_array( $settings ) ) {
			$settings = [];
		}
		$site_key   = trim( (string) NetworkSettings::resolve( (string) ( $settings['site_key'] ?? '' ), self::MODULE_ID, 'site_key' ) );
		$secret     = trim( (string) NetworkSettings::resolve( (string) ( $settings['secret_key'] ?? '' ), self::MODULE_ID, 'secret_key' ) );
		$secret_pt  = self::decryptSecret( $secret );
		if ( $site_key === '' || $secret_pt === '' ) {
			return [
				'success' => false,
				'message' => __( 'Both site key and secret key must be saved before testing.', 'wp-powersuite' ),
			];
		}
		if ( $token === '' ) {
			return [
				'success' => false,
				'message' => __( 'No test token submitted. Render the widget and try again.', 'wp-powersuite' ),
			];
		}
		$test_body = [
			'secret'   => $secret_pt,
			'response' => $token,
		];
		$remoteip  = ClientIp::resolvePublicOrEmpty();
		if ( $remoteip !== '' ) {
			$test_body['remoteip'] = $remoteip;
		}
		$response = wp_remote_post(
			self::SITEVERIFY_URL,
			[
				'timeout' => 15,
				'body'    => $test_body,
			]
		);
		if ( is_wp_error( $response ) ) {
			return [
				'success' => false,
				'message' => __( 'Could not reach Google siteverify — check the server can talk to www.google.com.', 'wp-powersuite' ) . ' (' . $response->get_error_message() . ')',
				'codes'   => [ 'network-error' ],
			];
		}
		$body  = json_decode( wp_remote_retrieve_body( $response ), true );
		$codes = is_array( $body ) && isset( $body['error-codes'] ) && is_array( $body['error-codes'] )
			? array_map( 'strval', $body['error-codes'] )
			: [];
		if ( is_array( $body ) && ! empty( $body['success'] ) ) {
			$host    = isset( $body['hostname'] ) ? (string) $body['hostname'] : '';
			$message = $host !== ''
				? sprintf(
					/* translators: %s: hostname reported by Google. */
					__( '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 reCAPTCHA admin console again.', 'wp-powersuite' );
		} elseif ( in_array( 'missing-input-secret', $codes, true ) ) {
			$message = __( 'No secret key was sent to Google — is the field empty?', 'wp-powersuite' );
		} elseif ( in_array( 'invalid-input-response', $codes, true ) ) {
			$message = __( 'Google rejected the token (likely domain mismatch: this site is not listed on your reCAPTCHA key).', 'wp-powersuite' );
		} elseif ( in_array( 'timeout-or-duplicate', $codes, true ) ) {
			$message = __( 'Token already used or expired — reCAPTCHA tokens are short-lived and single-use.', 'wp-powersuite' );
		} elseif ( in_array( 'bad-request', $codes, true ) ) {
			$message = __( 'Google rejected the request format. Try again.', 'wp-powersuite' );
		}
		return [ 'success' => false, 'message' => $message, 'codes' => $codes ];
	}
}


