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

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

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

require_once __DIR__ . '/FeatureGate.php';
wp_powersuite_require_freemium_file( 'google-recaptcha', 'ExtendedIntegrations.php' );
wp_powersuite_require_freemium_file( 'google-recaptcha', 'FormIntegrations.php' );
wp_powersuite_require_freemium_file( 'google-recaptcha', 'includes/PremiumSkipRules.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.24';
	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';

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

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

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

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

		if ( class_exists( ExtendedIntegrations::class, false ) ) {
			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 );
			// Woo’s recommended placement: immediately before the Place Order button.
			add_filter( 'render_block_woocommerce/checkout-actions-block', [ $this, 'prependRecaptchaToCheckoutActionsBlock' ], 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 );
		}
	}

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

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

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

	public function postedCaptchaToken( ?array $form_data = null ): string {
		return $this->resolveGrecaptchaToken( $form_data );
	}

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

	/**
	 * 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,
			self::MODULE_VERSION,
			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 );
			// 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';
			echo '<div';
			if ( $html_id !== '' ) {
				echo ' id="' . esc_attr( $html_id ) . '"';
			}
			echo ' class="' . esc_attr( $classes ) . '" data-sitekey="' . esc_attr( $site_key ) . '" data-theme="' . esc_attr( $theme ) . '" data-size="' . esc_attr( $size ) . '"';
			if ( $disable_gate && $submit_callback !== '' ) {
				echo ' data-callback="' . esc_attr( $submit_callback ) . '"';
			}
			echo '></div>';
		}
		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.
	 */
	/**
	 * Woo Blocks: mount the widget immediately before the Place Order button.
	 *
	 * @param string $block_content Rendered block HTML.
	 */
	public function prependRecaptchaToCheckoutActionsBlock( 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-grecaptcha-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.
	 */
	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',
				esc_html( wp_strip_all_tags( $msg ) ),
				400
			);
		}

		throw new \RuntimeException( esc_html( 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"]'
			)
		);
	}

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

		if ( ! $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 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;
		}
		// PMPro frontend login; ExtendedIntegrations verifies action `pmp_login`.
		// Only stand down when PMPro is actually loaded — otherwise a forged
		// `pmpro_login_form_used` field would skip captcha with no replacement check.
		if ( ! empty( $settings['integration_pmp'] ) && CaptchaFormContext::isPmproFrontendAuthPost() ) {
			return $user;
		}
		if ( ! $this->isCoreLoginSubmission() ) {
			return $user;
		}
		if ( ! empty( $settings['login_canonical_only'] ) && ! $this->isRequestPathWpLogin() ) {
			return $user;
		}

		if ( $this->verifyToken( $this->getTokenFromRequest(), 'login' ) ) {
			return $user;
		}
		return new \WP_Error(
			'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;
		}
		$settings = $this->getSettings();
		if ( ! empty( $settings['integration_pmp'] ) && CaptchaFormContext::isPmproFrontendAuthPost() ) {
			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 CaptchaFormContext::isWooMyAccountRegistrationPost();
	}

	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 ( ! WooCheckoutContext::shouldEnforceClassicCheckoutCaptcha() ) {
			return;
		}

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

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

		if ( $this->verifyToken( $this->getTokenFromRequest(), 'woo_checkout' ) ) {
			return;
		}
		wc_add_notice(
			$this->errorMessage( __( 'Please complete the reCAPTCHA verification to complete your order.', 'wp-powersuite' ), 'woo_checkout' ),
			'error'
		);
	}


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

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

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

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


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


