<?php
/**
 * Simple CAPTCHA Module
 *
 * Provides human verification using math problems, custom text questions,
 * or image challenges. All processed locally without external services.
 *
 * @package SimpleCaptcha
 * @since 1.0.0
 */

namespace SimpleCaptcha;

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\SkipRules;

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

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

/**
 * Simple CAPTCHA Module Class
 *
 * @since 1.0.0
 */
class Module extends AbstractCaptchaModule {

	private const MODULE_ID = 'simple-captcha';
	private const MODULE_VERSION = '1.2.2';
	private const IMAGE_REFRESH_HANDLE = 'wpps-simple-captcha-image-refresh';
	private const COMMENT_RESET_HANDLE = 'wpps-simple-captcha-comment-reset';
	private const MAX_FAILS_PER_CHALLENGE = 5;
	/** @var string Store API namespace for WooCommerce Blocks checkout */
	public const WOO_STORE_API_NAMESPACE = 'wp-powersuite-simple-captcha';
	private const WOO_CHECKOUT_FRONT_HANDLE = 'wpps-simple-captcha-woo-checkout';
	private const TRANSIENT_PREFIX = 'wpps_sc_';
	private const TRANSIENT_EXPIRE = 600;
	private const KEY_LENGTH = 16;
	private const RATE_LIMIT_IMAGE = 80;
	private const RATE_LIMIT_REFRESH = 25;
	private const RATE_LIMIT_VERIFY = 40;
	private const RATE_WINDOW = 60;
	private const ANSWER_MAX_LEN = 100;

	private static bool $captcha_style_enqueued  = false;
	private static bool $captcha_generator_loaded = false;

	/** @var array<string, true> Successful verify() outcomes this request (wp_signon may validate twice). */
	private static array $verify_ok_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 custom-login template_redirect hook is attached */
	private static bool $custom_login_url_template_redirect_attached = false;

	private ?array $settings_cache = null;

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

	/**
	 * Simple Captcha has no external keys — once enabled it is always configured
	 * (challenges generate themselves). Used by the cross-module admin banner.
	 */
	public function isConfigured(): bool {
		return true;
	}

	public function getName(): string {
		return __( 'Simple CAPTCHA', 'wp-powersuite' );
	}

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

	public function getDescription(): string {
		return __( 'Ask a quick math question, custom prompt, or image check before someone can submit a form or log in. Everything runs on your server—no third-party CAPTCHA account required.', 'wp-powersuite' );
	}

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

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

	protected function registerHooks(): void {
		$settings = $this->getSettings();

		if ( CaptchaRuntime::needsWpLoginScreenAssets( $settings ) ) {
			add_action( 'login_enqueue_scripts', [ $this, 'enqueueCaptchaStyles' ] );
			add_action( 'login_enqueue_scripts', [ $this, 'enqueueLoginCaptchaLayoutStyles' ], 100 );
			add_action( 'init', [ $this, 'registerCustomLoginUrlCaptchaHook' ], 999 );
		}

		$captcha_type = $settings['captcha_type'] ?? 'math';
		if ( 'image' === ( $captcha_type ?? 'math' ) ) {
			add_action( 'wp_ajax_wpps_simple_captcha_image', [ $this, 'ajaxOutputCaptchaImage' ] );
			add_action( 'wp_ajax_nopriv_wpps_simple_captcha_image', [ $this, 'ajaxOutputCaptchaImage' ] );
			add_action( 'wp_ajax_wpps_simple_captcha_refresh', [ $this, 'ajaxRefreshImageCaptcha' ] );
			add_action( 'wp_ajax_nopriv_wpps_simple_captcha_refresh', [ $this, 'ajaxRefreshImageCaptcha' ] );
		}

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

		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, 'renderWidget' ] );
			add_filter( 'registration_errors', [ $this, 'validateRegistration' ], 10, 3 );
		}

		if ( ! empty( $settings['protect_password_reset'] ) ) {
			add_action( 'lostpassword_form', [ $this, 'renderWidget' ] );
			add_action( 'resetpass_form', [ $this, 'renderWidgetOnResetPass' ], 10, 1 );
			add_filter( 'allow_password_reset', [ $this, 'validatePasswordReset' ], 10, 2 );
			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_filter( 'comment_form_submit_field', [ $this, 'injectCommentWidget' ], 10, 2 );
			add_action( 'comment_form_top', [ $this, 'renderCommentSimpleCaptchaErrorNotice' ] );
			add_filter( 'preprocess_comment', [ $this, 'validateComment' ] );
		}

		if ( ! empty( $settings['woo_reviews'] ) && empty( $settings['protect_comments'] ) ) {
			add_filter( 'comment_form_submit_field', [ $this, 'injectCommentWidgetForReviews' ], 10, 2 );
			add_action( 'comment_form_top', [ $this, 'renderCommentSimpleCaptchaErrorNotice' ] );
			add_filter( 'preprocess_comment', [ $this, 'validateProductReview' ] );
		}

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

		$this->initContactForm7( $settings );
		$this->initWPForms( $settings );
		$this->initFormidableForms( $settings );
		$this->initFluentForms( $settings );
		$this->initBasicForms( $settings );
		$this->initElementorForms( $settings );

		add_filter( 'wpps_verify_simple_captcha', [ $this, 'filterVerify' ], 10, 3 );
		add_filter( 'wpps_captcha_error_message', [ $this, 'filterCaptchaErrorMessage' ], 10, 3 );
		add_filter( 'wpps_verify_basic_forms_captcha', [ $this, 'filterVerifyBasicForms' ], 10, 3 );

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

		ExtendedIntegrations::boot( $this );
	}

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

	public function enqueueLoginCaptchaLayoutStyles(): void {
		if ( ! CaptchaRuntime::needsWpLoginScreenAssets( $this->getSettings() ) ) {
			return;
		}
		LoginLayoutStyles::enqueue();
	}

	public function registerCustomLoginUrlCaptchaHook(): 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, 'enqueueCaptchaStylesForCustomLoginUrl' ], 0 );
	}

	public function enqueueCaptchaStylesForCustomLoginUrl(): void {
		if ( ! CaptchaRuntime::needsWpLoginScreenAssets( $this->getSettings() ) ) {
			return;
		}
		if ( ! apply_filters( 'wp_powersuite_is_custom_login_url_request', false ) ) {
			return;
		}
		$this->enqueueCaptchaStyles();
		LoginLayoutStyles::enqueueStandalone();
	}

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

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

	public function filterCaptchaErrorMessage( string $default, string $context = '', string $module_id = '' ): string {
		if ( $module_id !== '' && $module_id !== self::MODULE_ID ) {
			return $default;
		}
		return $this->messageForFailedVerification( $this->getKeyFromRequest(), $this->getAnswerFromRequest() );
	}

	public function filterVerify( $pre, string $key, string $answer ): bool {
		return $this->verify( $key, $answer );
	}

	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_basic_forms_entry_data', [ $this, 'filterBasicFormsEntryData' ], 10, 3 );
	}

	/**
	 * Store the text CAPTCHA question in the entry payload so the admin modal can show it.
	 *
	 * @param array<string, mixed> $data
	 * @param int                  $form_id
	 * @param array<string, mixed> $settings
	 * @return array<string, mixed>
	 */
	public function filterBasicFormsEntryData( array $data, int $form_id, array $settings ): array {
		unset( $form_id );
		if ( ( $settings['captcha_provider'] ?? '' ) !== 'simple' ) {
			return $data;
		}
		if ( ( $this->getSettings()['captcha_type'] ?? 'math' ) !== 'text' ) {
			return $data;
		}
		$q = isset( $_POST['wpps_simple_captcha_question'] ) ? (string) wp_unslash( $_POST['wpps_simple_captcha_question'] ) : '';
		$q = sanitize_text_field( substr( $q, 0, 500 ) );
		if ( $q === '' ) {
			return $data;
		}
		$label = __( 'CAPTCHA question', 'wp-powersuite' );
		$key   = $label;
		$n     = 1;
		while ( array_key_exists( $key, $data ) ) {
			$n++;
			$key = $label . ' (' . $n . ')';
		}
		$data[ $key ] = $q;
		return $data;
	}

	public function filterVerifyBasicForms( bool $verified, int $form_id, array $settings ): bool {
		if ( ( $settings['captcha_provider'] ?? '' ) !== 'simple' ) {
			return $verified;
		}
		$key    = isset( $_POST['wpps_simple_captcha_key'] ) ? sanitize_text_field( wp_unslash( $_POST['wpps_simple_captcha_key'] ) ) : '';
		$answer = isset( $_POST['wpps_simple_captcha_answer'] ) ? sanitize_text_field( wp_unslash( $_POST['wpps_simple_captcha_answer'] ) ) : '';
		return $this->verify( $key, $answer );
	}

	public function renderWidgetForBasicForms( int $form_id, array $settings ): void {
		if ( empty( $settings['enable_spam_protection'] ) ) {
			return;
		}
		if ( ( $settings['captcha_provider'] ?? '' ) !== 'simple' ) {
			return;
		}
		$this->renderWidget( 'basic_forms_' . $form_id, true );
	}

	private function initWooCommerce( array $settings ): void {
		if ( ! empty( $settings['woo_login'] ) ) {
			add_action( 'woocommerce_login_form', [ $this, 'renderWidget' ] );
			add_filter( 'woocommerce_process_login_errors', [ $this, 'validateWooLogin' ], 10, 3 );
		}

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

		if ( ! empty( $settings['woo_lost_password'] ) ) {
			add_action( 'woocommerce_lostpassword_form', [ $this, 'renderWidget' ] );
			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, 'prependCaptchaToCheckoutPaymentBlock' ], 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 );
		}
	}

	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 );
		// WPForms Lite no longer fires wpforms_process_validate_form; use initial_errors (see WPForms_Process::validate()).
		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, 'prependCaptchaToFormidableSubmit' ], 10, 2 );
		add_filter( 'frm_validate_entry', [ $this, 'validateFormidable' ], 20, 3 );
	}

	private function initFluentForms( array $settings ): void {
		if ( empty( $settings['fluent_forms'] ) || ! defined( 'FLUENTFORM' ) ) {
			return;
		}
		add_action( 'fluentform/render_item_submit_button', [ $this, 'renderFluentFormsBeforeSubmit' ], 5, 2 );
		add_filter( 'fluentform/validation_errors', [ $this, 'validateFluentForms' ], 10, 4 );
	}

	/**
	 * Elementor Pro Forms: register a real Form field (server-rendered). Add “Simple CAPTCHA” in the Form widget.
	 */
	private function initElementorForms( array $settings ): void {
		if ( empty( $settings['elementor_forms'] ) || ! defined( 'ELEMENTOR_PRO_VERSION' ) || ! did_action( 'elementor/loaded' ) ) {
			return;
		}
		add_action( 'elementor_pro/forms/fields/register', [ $this, 'registerElementorProFormField' ] );
	}

	/**
	 * @param \ElementorPro\Modules\Forms\Registrars\Form_Fields_Registrar|mixed $form_fields_registrar
	 */
	public function registerElementorProFormField( $form_fields_registrar ): void {
		if ( ! class_exists( '\ElementorPro\Modules\Forms\Fields\Field_Base' ) || ! is_object( $form_fields_registrar ) || ! method_exists( $form_fields_registrar, 'register' ) ) {
			return;
		}
		require_once __DIR__ . '/includes/elementor-pro-form-field.php';
		$form_fields_registrar->register( new Elementor_Pro_Form_Field( $this ) );
	}

	/**
	 * Output Simple CAPTCHA inside an Elementor Pro Form “Simple CAPTCHA” field.
	 */
	public function renderForElementorField( string $context_key, string $html_id_suffix ): void {
		$this->renderWidget( $context_key, false, $html_id_suffix );
	}

	/**
	 * @return bool True if verification passed or was skipped.
	 */
	public function validateSimpleCaptchaRequest(): bool {
		if ( $this->shouldSkip() ) {
			return true;
		}
		return $this->verify( $this->getKeyFromRequest(), $this->getAnswerFromRequest() );
	}

	/**
	 * User-facing error when {@see validateSimpleCaptchaRequest()} is false.
	 */
	public function getSimpleCaptchaRequestErrorMessage(): string {
		return $this->messageForFailedVerification( $this->getKeyFromRequest(), $this->getAnswerFromRequest() );
	}

	/**
	 * GD image CAPTCHA only — load only when image mode is used (saves parse for math/text).
	 */
	private function loadCaptchaGenerator(): void {
		if ( self::$captcha_generator_loaded ) {
			return;
		}
		self::$captcha_generator_loaded = true;
		require_once __DIR__ . '/includes/class-captcha-generator.php';
	}

	public function enqueueCaptchaStyles(): void {
		if ( self::$captcha_style_enqueued ) {
			return;
		}
		self::$captcha_style_enqueued = true;
		wp_enqueue_style(
			'wpps-simple-captcha',
			WP_POWERSUITE_PLUGIN_URL . 'modules/simple-captcha/assets/captcha.css',
			[],
			self::MODULE_VERSION
		);
		if ( ( $this->getSettings()['captcha_type'] ?? 'math' ) === 'image' ) {
			$this->enqueueImageRefreshScript();
		}
	}

	public function enqueueImageRefreshScript(): void {
		if ( ! defined( 'WP_POWERSUITE_PLUGIN_URL' ) || wp_script_is( self::IMAGE_REFRESH_HANDLE, 'enqueued' ) ) {
			return;
		}
		wp_enqueue_script(
			self::IMAGE_REFRESH_HANDLE,
			WP_POWERSUITE_PLUGIN_URL . 'modules/simple-captcha/assets/image-refresh.js',
			[],
			self::MODULE_VERSION,
			true
		);
		wp_localize_script(
			self::IMAGE_REFRESH_HANDLE,
			'wppsSimpleCaptchaRefresh',
			[
				'ajaxUrl' => admin_url( 'admin-ajax.php' ),
				'nonce'   => wp_create_nonce( 'wpps_captcha_refresh' ),
			]
		);
	}

	public function enqueueCommentResetScript(): void {
		if ( ! defined( 'WP_POWERSUITE_PLUGIN_URL' ) || ! is_singular() || ! comments_open() ) {
			return;
		}
		if ( wp_script_is( self::COMMENT_RESET_HANDLE, 'enqueued' ) ) {
			return;
		}
		wp_enqueue_script(
			self::COMMENT_RESET_HANDLE,
			WP_POWERSUITE_PLUGIN_URL . 'modules/simple-captcha/assets/comment-reset.js',
			[],
			self::MODULE_VERSION,
			true
		);
	}

	/**
	 * @param array<string, mixed> $payload Challenge data (answer/code/answers).
	 * @return array<string, mixed>
	 */
	private function challengePayload( array $payload ): array {
		$settings = $this->getSettings();
		$base     = [
			'created' => time(),
			'fails'   => 0,
		];
		if ( ! empty( $settings['anti_bot_honeypot'] ) ) {
			$base['hp'] = 'wpps_sc_hp_' . bin2hex( random_bytes( 4 ) );
		}
		return array_merge( $base, $payload );
	}

	/**
	 * @param array<string, mixed> $payload
	 * @return array<string, mixed> Stored payload (for honeypot field name).
	 */
	private function storeChallenge( string $key, array $payload ): array {
		$full = $this->challengePayload( $payload );
		set_transient( self::TRANSIENT_PREFIX . $key, $full, self::TRANSIENT_EXPIRE );
		return $full;
	}

	private function printHoneypotField( array $stored ): void {
		$hp = isset( $stored['hp'] ) ? (string) $stored['hp'] : '';
		if ( $hp === '' || ! preg_match( '/^wpps_sc_hp_[a-f0-9]{8}$/', $hp ) ) {
			return;
		}
		?>
		<div class="wpps-sc-honeypot" aria-hidden="true">
			<label for="<?php echo esc_attr( $hp ); ?>"><?php esc_html_e( 'Leave this field empty', 'wp-powersuite' ); ?></label>
			<input type="text" name="<?php echo esc_attr( $hp ); ?>" id="<?php echo esc_attr( $hp ); ?>" value="" tabindex="-1" autocomplete="off" />
		</div>
		<?php
	}

	public function ajaxOutputCaptchaImage(): void {
		if ( ( $this->getSettings()['captcha_type'] ?? 'math' ) !== 'image' ) {
			status_header( 404 );
			exit;
		}

		if ( $this->isRateLimited( 'img', self::RATE_LIMIT_IMAGE ) ) {
			status_header( 429 );
			exit;
		}

		$id = isset( $_GET['id'] ) ? sanitize_text_field( wp_unslash( $_GET['id'] ) ) : '';
		if ( ! $this->isValidCaptchaKey( $id ) ) {
			status_header( 404 );
			exit;
		}

		$stored = get_transient( self::TRANSIENT_PREFIX . $id );
		if ( ! is_array( $stored ) || empty( $stored['code'] ) ) {
			status_header( 404 );
			exit;
		}

		$this->loadCaptchaGenerator();

		$settings = $this->getSettings();
		$style    = $settings['image_style'] ?? 'distorted';

		CaptchaGenerator::outputImage( (string) $stored['code'], $style );
	}

	public function ajaxRefreshImageCaptcha(): void {
		if ( ( $this->getSettings()['captcha_type'] ?? 'math' ) !== 'image' ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ], 404 );
		}

		if ( $this->isRateLimited( 'refresh', self::RATE_LIMIT_REFRESH ) ) {
			wp_send_json_error( [ 'message' => __( 'Too many requests. Please wait a moment.', 'wp-powersuite' ) ], 429 );
		}
		if ( ! wp_verify_nonce( isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '', 'wpps_captcha_refresh' ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ] );
		}
		$this->loadCaptchaGenerator();

		$settings   = $this->getSettings();
		$length     = max( 4, min( 8, (int) ( $settings['image_length'] ?? 5 ) ) );
		$case_sensitive = ! empty( $settings['image_case_sensitive'] );

		$code = CaptchaGenerator::generateCode( $length );
		$key = self::generateCaptchaSecretKey();

		$this->storeChallenge( $key, [ 'code' => $code, 'case_sensitive' => $case_sensitive ] );

		$img_url = add_query_arg(
			[ 'action' => 'wpps_simple_captcha_image', 'id' => $key ],
			admin_url( 'admin-ajax.php' )
		);

		wp_send_json_success( [ 'key' => $key, 'img_url' => $img_url ] );
	}

	public function renderLoginWidget(): void {
		if ( CaptchaRuntime::isMagicLoginLoginScreen() ) {
			return;
		}
		$this->renderWidget();
	}

	public function renderWidget( $action = '', bool $force_show = false, string $html_id_suffix = '' ): void {
		$this->enqueueCaptchaStyles();

		if ( ! $force_show && $this->shouldSkip() ) {
			return;
		}

		$settings = $this->getSettings();
		$type     = $settings['captcha_type'] ?? 'math';

		if ( $type === 'math' ) {
			$this->renderMathWidget( $html_id_suffix );
		} elseif ( $type === 'text' ) {
			$this->renderTextWidget( $html_id_suffix );
		} elseif ( $type === 'image' ) {
			$this->renderImageWidget( $html_id_suffix );
		}
	}

	private function shouldSkip(): bool {
		$settings = $this->getSettings();
		// Skip-rules (roles / IPs) — must suppress render as well as verify.
		if ( SkipRules::shouldSkip( is_array( $settings['skip_rules'] ?? null ) ? $settings['skip_rules'] : [] ) ) {
			return true;
		}
		if ( empty( $settings['skip_logged_in'] ) ) {
			return false;
		}
		return is_user_logged_in();
	}

	private function renderMathWidget( string $html_id_suffix = '' ): void {
		$answer_id = 'wpps_simple_captcha_answer' . $html_id_suffix;
		$settings  = $this->getSettings();
		$difficulty = $settings['math_difficulty'] ?? 'easy';
		$problem = $this->generateMathProblem( $difficulty );
		$stored  = $this->storeChallenge( $problem['key'], [ 'answer' => (string) $problem['answer'] ] );
		?>
		<div class="wpps-simple-captcha-wrap wpps-simple-captcha wpps-captcha-math" aria-live="polite">
		<?php $this->printHoneypotField( $stored ); ?>
			<div class="wpps-captcha-row">
				<label for="<?php echo esc_attr( $answer_id ); ?>" class="wpps-captcha-math-line">
					<span class="wpps-captcha-math-question" aria-hidden="true"><?php echo esc_html( $problem['question'] ); ?> =</span>
					<input type="hidden" name="wpps_simple_captcha_key" value="<?php echo esc_attr( $problem['key'] ); ?>" />
					<input
						type="text"
						name="wpps_simple_captcha_answer"
						id="<?php echo esc_attr( $answer_id ); ?>"
						value=""
						size="5"
						autocomplete="off"
						required
						class="wpps-captcha-input"
						aria-label="<?php echo esc_attr( sprintf( /* translators: %s: e.g. "2 + 3" */ __( 'Your answer to %s =', 'wp-powersuite' ), $problem['question'] ) ); ?>"
					/>
				</label>
			</div>
		</div>
		<?php
	}

	private function renderTextWidget( string $html_id_suffix = '' ): void {
		$questions = $this->getEffectiveTextQuestions();
		if ( $questions === [] ) {
			return;
		}

		$idx = array_rand( $questions );
		$q   = $questions[ $idx ];
		$key = self::generateCaptchaSecretKey();

		$answer_id = 'wpps_simple_captcha_answer' . $html_id_suffix;

		$stored = $this->storeChallenge( $key, [ 'answers' => $q['answers'] ] );
		?>
		<div class="wpps-simple-captcha-wrap wpps-simple-captcha wpps-captcha-text" aria-live="polite">
		<?php $this->printHoneypotField( $stored ); ?>
			<label for="<?php echo esc_attr( $answer_id ); ?>" class="wpps-captcha-label">
				<?php echo esc_html( $q['question'] ); ?>
			</label>
			<div class="wpps-captcha-row">
				<input type="hidden" name="wpps_simple_captcha_key" value="<?php echo esc_attr( $key ); ?>" />
				<input type="hidden" name="wpps_simple_captcha_question" value="<?php echo esc_attr( $q['question'] ); ?>" />
				<input type="text" name="wpps_simple_captcha_answer" id="<?php echo esc_attr( $answer_id ); ?>" value="" autocomplete="off" required class="wpps-captcha-input" />
			</div>
		</div>
		<?php
	}

	private function renderImageWidget( string $html_id_suffix = '' ): void {
		$this->loadCaptchaGenerator();

		$settings   = $this->getSettings();
		$length     = max( 4, min( 8, (int) ( $settings['image_length'] ?? 5 ) ) );
		$case_sensitive = ! empty( $settings['image_case_sensitive'] );

		$code = CaptchaGenerator::generateCode( $length );
		$key = self::generateCaptchaSecretKey();

		$answer_id = 'wpps_simple_captcha_answer' . $html_id_suffix;

		$stored = $this->storeChallenge( $key, [ 'code' => $code, 'case_sensitive' => $case_sensitive ] );
		$img_url = add_query_arg(
			[ 'action' => 'wpps_simple_captcha_image', 'id' => $key ],
			admin_url( 'admin-ajax.php' )
		);
		?>
		<div class="wpps-simple-captcha-wrap wpps-simple-captcha wpps-captcha-image" aria-live="polite">
		<?php $this->printHoneypotField( $stored ); ?>
			<label for="<?php echo esc_attr( $answer_id ); ?>" class="wpps-captcha-label">
				<?php esc_html_e( 'Enter the characters you see in the image:', 'wp-powersuite' ); ?>
			</label>
			<div class="wpps-captcha-image-wrap">
				<img src="<?php echo esc_url( $img_url ); ?>" alt="<?php esc_attr_e( 'CAPTCHA', 'wp-powersuite' ); ?>" class="wpps-captcha-img" />
				<button type="button" class="wpps-captcha-refresh" aria-label="<?php esc_attr_e( 'Refresh CAPTCHA', 'wp-powersuite' ); ?>">↻</button>
			</div>
			<div class="wpps-captcha-row">
				<input type="hidden" name="wpps_simple_captcha_key" value="<?php echo esc_attr( $key ); ?>" />
				<input type="text" name="wpps_simple_captcha_answer" id="<?php echo esc_attr( $answer_id ); ?>" value="" size="<?php echo esc_attr( $length + 2 ); ?>" autocomplete="off" required class="wpps-captcha-input" />
			</div>
		</div>
		<?php
	}

	private function generateMathProblem( string $difficulty ): array {
		$key = self::generateCaptchaSecretKey();

		if ( $difficulty === 'hard' ) {
			$a = random_int( 10, 99 );
			$b = random_int( 2, 9 );
			$op = [ '+', '-', '*' ][ random_int( 0, 2 ) ];
		} elseif ( $difficulty === 'medium' ) {
			$a = random_int( 5, 20 );
			$b = random_int( 2, 9 );
			$op = [ '+', '-' ][ random_int( 0, 1 ) ];
		} else {
			$a = random_int( 1, 10 );
			$b = random_int( 1, 10 );
			$op = '+';
		}

		if ( $op === '-' && $b > $a ) {
			[ $a, $b ] = [ $b, $a ];
		}

		$answer = match ( $op ) {
			'+' => $a + $b,
			'-' => $a - $b,
			'*' => $a * $b,
			default => $a + $b,
		};

		$question = $a . ' ' . $op . ' ' . $b;

		return [ 'key' => $key, 'question' => $question, 'answer' => $answer ];
	}

	public function injectCommentWidget( string $submit_field, array $args ): string {
		ob_start();
		$this->renderWidget( 'comment' );
		$widget = ob_get_clean();
		return $widget . $submit_field;
	}

	public function injectCommentWidgetForReviews( string $submit_field, array $args ): string {
		if ( ! is_singular( 'product' ) ) {
			return $submit_field;
		}
		ob_start();
		$this->renderWidget( 'woo_review' );
		$widget = ob_get_clean();
		return $widget . $submit_field;
	}

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

	/**
	 * @param mixed $form_data Form data from WPForms.
	 */
	public function renderWPFormsWidget( $form_data ): void {
		if ( ! is_array( $form_data ) || empty( $form_data['id'] ) ) {
			return;
		}
		$fid = absint( $form_data['id'] );
		$this->renderWidget( 'wpforms_' . $fid, false, '-wpf-' . $fid );
	}

	/**
	 * Built-in text questions when none are configured (keeps text CAPTCHA usable out of the box).
	 *
	 * @return array<int, array{question: string, answers: array<int, string>}>
	 */
	private function getBuiltinTextQuestions(): array {
		return [
			[
				'question' => __( 'What color is the sky on a clear day?', 'wp-powersuite' ),
				'answers'  => [ 'blue' ],
			],
			[
				'question' => __( 'What is the opposite of hot?', 'wp-powersuite' ),
				'answers'  => [ 'cold' ],
			],
			[
				'question' => __( 'How many days are in a week?', 'wp-powersuite' ),
				'answers'  => [ '7', 'seven' ],
			],
			[
				'question' => __( 'What animal says "meow"?', 'wp-powersuite' ),
				'answers'  => [ 'cat', 'a cat', 'cats' ],
			],
			[
				'question' => __( 'What comes after Monday?', 'wp-powersuite' ),
				'answers'  => [ 'tuesday' ],
			],
			[
				'question' => __( 'What season comes after winter?', 'wp-powersuite' ),
				'answers'  => [ 'spring' ],
			],
			[
				'question' => __( 'What is 2 + 2?', 'wp-powersuite' ),
				'answers'  => [ '4', 'four' ],
			],
			[
				'question' => __( 'What planet do we live on?', 'wp-powersuite' ),
				'answers'  => [ 'earth' ],
			],
		];
	}

	/**
	 * Built-in text questions for settings UI and documentation (same pool as when custom list is empty).
	 *
	 * @return array<int, array{question: string, answers: array<int, string>}>
	 */
	public function getBuiltinTextQuestionDefinitions(): array {
		return $this->getBuiltinTextQuestions();
	}

	/**
	 * Normalize a stored row to question + list of acceptable answers (pipe-separated string supported).
	 *
	 * @param array<string, mixed> $q Raw row from settings.
	 * @return array{question: string, answers: array<int, string>}|null
	 */
	private function normalizeTextQuestionRow( array $q ): ?array {
		$question = trim( (string) ( $q['question'] ?? '' ) );
		if ( $question === '' ) {
			return null;
		}

		$answers = [];
		if ( isset( $q['answers'] ) && is_array( $q['answers'] ) ) {
			foreach ( $q['answers'] as $a ) {
				$t = trim( (string) $a );
				if ( $t !== '' ) {
					$answers[] = $t;
				}
			}
		}

		if ( $answers === [] && isset( $q['answer'] ) ) {
			$answers = self::splitAnswerVariants( (string) $q['answer'] );
		}

		if ( $answers === [] ) {
			return null;
		}

		return [
			'question' => $question,
			'answers'  => $answers,
		];
	}

	/**
	 * Split admin "answer" field on | for multiple acceptable answers (e.g. 7|seven).
	 *
	 * @return array<int, string>
	 */
	private static function splitAnswerVariants( string $raw ): array {
		$parts = preg_split( '/\s*\|\s*/', $raw );
		if ( ! is_array( $parts ) ) {
			return [];
		}
		$out = [];
		foreach ( $parts as $p ) {
			$t = trim( (string) $p );
			if ( $t !== '' ) {
				$out[] = $t;
			}
		}
		return $out;
	}

	/**
	 * Custom questions from settings, or built-in defaults when empty.
	 *
	 * @return array<int, array{question: string, answers: array<int, string>}>
	 */
	private function getEffectiveTextQuestions(): array {
		$settings = $this->getSettings();
		$raw      = $settings['text_questions'] ?? [];
		if ( ! is_array( $raw ) ) {
			$raw = [];
		}

		$normalized = [];
		foreach ( $raw as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			$n = $this->normalizeTextQuestionRow( $row );
			if ( $n !== null ) {
				$normalized[] = $n;
			}
		}

		if ( $normalized !== [] ) {
			return $normalized;
		}

		return $this->getBuiltinTextQuestions();
	}

	public function renderWooCheckoutWidget(): void {
		if ( self::$woo_checkout_classic_emitted ) {
			return;
		}
		self::$woo_checkout_classic_emitted = true;
		$this->renderWooCheckoutWidgetMarkup();
	}

	private function renderWooCheckoutWidgetMarkup(): void {
		$s = $this->getSettings();
		if ( ! empty( $s['woo_checkout_guest_only'] ) && is_user_logged_in() ) {
			return;
		}
		$this->renderWidget( 'woo_checkout', false, '-woo-checkout' );
	}

	/**
	 * @param string $block_content Rendered block HTML.
	 */
	public function prependCaptchaToCheckoutPaymentBlock( 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 ( $this->shouldSkip() ) {
			return $block_content;
		}
		if ( self::$woo_checkout_block_emitted ) {
			return $block_content;
		}
		self::$woo_checkout_block_emitted = true;
		ob_start();
		echo '<div id="wpps-simple-captcha-woo-checkout" class="wpps-captcha-woo-checkout">';
		$this->renderWooCheckoutWidgetMarkup();
		echo '</div>';
		return (string) ob_get_clean() . $block_content;
	}

	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 [
						'key'    => [
							'description' => __( 'Simple CAPTCHA session key.', 'wp-powersuite' ),
							'type'        => 'string',
							'context'     => [ 'view', 'edit' ],
							'readonly'    => false,
						],
						'answer' => [
							'description' => __( 'Simple CAPTCHA answer.', 'wp-powersuite' ),
							'type'        => 'string',
							'context'     => [ 'view', 'edit' ],
							'readonly'    => false,
						],
					];
				},
				'schema_type'     => $schema_type,
			]
		);
	}

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

		$key    = $this->getKeyFromRequest();
		$answer = $this->getAnswerFromRequest();

		$params = $request->get_json_params();
		if ( is_array( $params ) ) {
			$ext = $params['extensions'][ self::WOO_STORE_API_NAMESPACE ] ?? [];
			if ( is_array( $ext ) ) {
				if ( $key === '' && isset( $ext['key'] ) ) {
					$key = sanitize_text_field( (string) $ext['key'] );
				}
				if ( $answer === '' && isset( $ext['answer'] ) ) {
					$answer = sanitize_text_field( (string) $ext['answer'] );
				}
			}
		}

		if ( $this->verify( $key, $answer ) ) {
			return;
		}

		$msg = $this->messageForFailedVerification( $key, $answer );

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

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

	public function enqueueWooCheckoutFrontScript(): void {
		$s = $this->getSettings();
		if ( empty( $s['woo_checkout'] ) ) {
			return;
		}
		if ( ! function_exists( 'is_checkout' ) || ! is_checkout() ) {
			return;
		}
		if ( ! defined( 'WP_POWERSUITE_PLUGIN_URL' ) ) {
			return;
		}

		$deps = [ 'jquery' ];
		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/simple-captcha/assets/woo-checkout.js',
			$deps,
			self::MODULE_VERSION,
			true
		);
		wp_localize_script(
			self::WOO_CHECKOUT_FRONT_HANDLE,
			'wpPsSimpleCaptchaWoo',
			[ 'ns' => self::WOO_STORE_API_NAMESPACE ]
		);
	}

	/**
	 * Render CAPTCHA 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' );
	}

	/**
	 * Validate CAPTCHA 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->shouldSkip() ) {
			return;
		}
		if ( $this->verify( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ) ) {
			return;
		}
		$errors->add( 'simple_captcha_failed', $this->messageForFailedVerification( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ) );
	}

	/**
	 * Transient lookup key — must stay alphanumeric; do not use wp_generate_password()
	 * because the {@see random_password} filter can inject symbols and break validation.
	 */
	private static function generateCaptchaSecretKey(): string {
		try {
			return bin2hex( random_bytes( 8 ) );
		} catch ( \Throwable $e ) {
			$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
			$key   = '';
			for ( $i = 0; $i < self::KEY_LENGTH; $i++ ) {
				$key .= $chars[ random_int( 0, 61 ) ];
			}
			return $key;
		}
	}

	private function isValidCaptchaKey( string $key ): bool {
		return strlen( $key ) === self::KEY_LENGTH && ctype_alnum( $key );
	}

	/**
	 * Simple per-IP rate limit for anonymous AJAX (abuse mitigation).
	 */
	private function isRateLimited( string $bucket, int $max ): bool {
		$ip = ClientIp::resolve();
		if ( $ip === '' ) {
			$ip = '0';
		}
		$tkey = 'wpps_sc_rl_' . $bucket . '_' . md5( $ip );
		$n    = (int) get_transient( $tkey );
		if ( $n >= $max ) {
			return true;
		}
		set_transient( $tkey, $n + 1, self::RATE_WINDOW );
		return false;
	}

	private function verify( string $key, string $answer ): bool {
		// Skip rules: trusted users — before transient lookup so we do not burn one-time keys.
		if ( SkipRules::shouldSkip( $this->getSettings()['skip_rules'] ?? [] ) ) {
			return true;
		}

		$answer = substr( trim( $answer ), 0, self::ANSWER_MAX_LEN );
		$memo_key = hash( 'sha256', $key . "\0" . strtolower( $answer ) );
		if ( isset( self::$verify_ok_memo[ $memo_key ] ) ) {
			return true;
		}

		if ( $this->isRateLimited( 'verify', self::RATE_LIMIT_VERIFY ) ) {
			Counters::record( self::MODULE_ID, false );
			return false;
		}

		if ( ! $this->isValidCaptchaKey( $key ) || $answer === '' ) {
			Counters::record( self::MODULE_ID, false );
			return false;
		}

		$stored = get_transient( self::TRANSIENT_PREFIX . $key );
		if ( ! is_array( $stored ) ) {
			Counters::record( self::MODULE_ID, false );
			return false;
		}

		if ( $this->isHoneypotTripped( $stored ) || $this->isSubmittedTooFast( $stored ) ) {
			delete_transient( self::TRANSIENT_PREFIX . $key );
			Counters::record( self::MODULE_ID, false );
			return false;
		}

		if ( isset( $stored['answers'] ) && is_array( $stored['answers'] ) ) {
			$given = strtolower( trim( $answer ) );
			foreach ( $stored['answers'] as $valid ) {
				$candidate = strtolower( trim( (string) $valid ) );
				if ( $candidate !== '' && $given === $candidate ) {
					delete_transient( self::TRANSIENT_PREFIX . $key );
					self::$verify_ok_memo[ $memo_key ] = true;
					Counters::record( self::MODULE_ID, true );
					return true;
				}
			}
			$this->recordFailedAttempt( $key, $stored );
			Counters::record( self::MODULE_ID, false );
			return false;
		}

		if ( isset( $stored['answer'] ) ) {
			$ok = strtolower( trim( $answer ) ) === strtolower( trim( (string) $stored['answer'] ) );
			if ( $ok ) {
				delete_transient( self::TRANSIENT_PREFIX . $key );
				self::$verify_ok_memo[ $memo_key ] = true;
			}
			if ( ! $ok ) {
				$this->recordFailedAttempt( $key, $stored );
			}
			Counters::record( self::MODULE_ID, $ok );
			return $ok;
		}

		if ( isset( $stored['code'] ) ) {
			$expected       = (string) $stored['code'];
			$case_sensitive = ! empty( $stored['case_sensitive'] );
			$ok             = $case_sensitive
				? ( trim( $answer ) === $expected )
				: ( strtolower( trim( $answer ) ) === strtolower( $expected ) );
			if ( $ok ) {
				delete_transient( self::TRANSIENT_PREFIX . $key );
				self::$verify_ok_memo[ $memo_key ] = true;
			}
			if ( ! $ok ) {
				$this->recordFailedAttempt( $key, $stored );
			}
			Counters::record( self::MODULE_ID, $ok );
			return $ok;
		}

		Counters::record( self::MODULE_ID, false );
		return false;
	}

	/**
	 * @param array<string, mixed> $stored
	 */
	private function isHoneypotTripped( array $stored ): bool {
		if ( empty( $this->getSettings()['anti_bot_honeypot'] ) ) {
			return false;
		}
		$hp = isset( $stored['hp'] ) ? (string) $stored['hp'] : '';
		if ( $hp === '' || ! preg_match( '/^wpps_sc_hp_[a-f0-9]{8}$/', $hp ) ) {
			return false;
		}
		$val = isset( $_POST[ $hp ] ) ? (string) wp_unslash( $_POST[ $hp ] ) : '';
		return trim( $val ) !== '';
	}

	/**
	 * @param array<string, mixed> $stored
	 */
	private function isSubmittedTooFast( array $stored ): bool {
		if ( empty( $this->getSettings()['anti_bot_timing'] ) ) {
			return false;
		}
		$created = isset( $stored['created'] ) ? (int) $stored['created'] : 0;
		if ( $created <= 0 ) {
			return false;
		}
		$min = max( 1, min( 30, (int) ( $this->getSettings()['min_submit_seconds'] ?? 2 ) ) );
		return ( time() - $created ) < $min;
	}

	/**
	 * @param array<string, mixed> $stored
	 */
	private function recordFailedAttempt( string $key, array $stored ): void {
		$fails = (int) ( $stored['fails'] ?? 0 ) + 1;
		if ( $fails >= self::MAX_FAILS_PER_CHALLENGE ) {
			delete_transient( self::TRANSIENT_PREFIX . $key );
			return;
		}
		$stored['fails'] = $fails;
		set_transient( self::TRANSIENT_PREFIX . $key, $stored, self::TRANSIENT_EXPIRE );
	}

	/**
	 * User-visible text when verify() fails (empty vs wrong vs missing session).
	 * If "Error message" is set in module settings, it overrides all failure cases.
	 */
	private function messageForFailedVerification( string $key, string $answer ): string {
		$custom = trim( (string) ( $this->getSettings()['error_message'] ?? '' ) );
		if ( $custom !== '' ) {
			return $custom;
		}

		if ( trim( $answer ) === '' ) {
			return __( 'Please complete the verification.', 'wp-powersuite' );
		}

		if ( ! $this->isValidCaptchaKey( $key ) ) {
			return __( 'Verification session is invalid. Please refresh the page and try again.', 'wp-powersuite' );
		}

		$stored = get_transient( self::TRANSIENT_PREFIX . $key );
		if ( ! is_array( $stored ) ) {
			return __( 'Verification has expired. Please refresh the page and try again.', 'wp-powersuite' );
		}

		return __( 'That answer is incorrect. Please try again.', 'wp-powersuite' );
	}

	private function getKeyFromRequest(): string {
		return isset( $_POST['wpps_simple_captcha_key'] ) ? sanitize_text_field( wp_unslash( $_POST['wpps_simple_captcha_key'] ) ) : '';
	}

	private function getAnswerFromRequest(): string {
		$raw = isset( $_POST['wpps_simple_captcha_answer'] ) ? (string) wp_unslash( $_POST['wpps_simple_captcha_answer'] ) : '';
		if ( $raw === '' ) {
			return '';
		}
		$raw = substr( $raw, 0, self::ANSWER_MAX_LEN );
		return sanitize_text_field( $raw );
	}

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

	public function validateLogin( $user, string $username, string $password ) {
		$settings = $this->getSettings();
		// WooCommerce My Account runs its own `woocommerce_process_login_errors` validator;
		// skip here so we do not double-consume the one-time transient and block a valid submission.
		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->verify( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ) ) {
			return $user;
		}
		return new \WP_Error( 'simple_captcha_failed', $this->messageForFailedVerification( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ) );
	}

	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->shouldSkip() ) {
			return $errors;
		}
		if ( ! $this->verify( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ) ) {
			$errors->add( 'simple_captcha_failed', $this->messageForFailedVerification( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ) );
		}
		return $errors;
	}

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

	/**
	 * Whether this request is WooCommerce My Account login (not wp-login.php).
	 */
	private function isWooCommerceMyAccountLoginSubmission(): bool {
		return isset( $_POST['woocommerce-login-nonce'] ) && ! isset( $_POST['log'] );
	}

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

	public function validatePasswordReset( $allow, int $user_id ) {
		if ( ! isset( $_POST['user_login'] ) ) {
			return $allow;
		}
		if ( $this->shouldSkip() ) {
			return $allow;
		}
		if ( $this->verify( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ) ) {
			return $allow;
		}
		return new \WP_Error( 'simple_captcha_failed', $this->messageForFailedVerification( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ) );
	}

	public function validateComment( array $comment_data ): array {
		if ( $this->shouldSkip() ) {
			return $comment_data;
		}
		if ( $this->verify( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ) ) {
			return $comment_data;
		}

		$message = $this->messageForFailedVerification( $this->getKeyFromRequest(), $this->getAnswerFromRequest() );
		$referer = wp_get_referer();
		if ( is_string( $referer ) && $referer !== '' ) {
			wp_safe_redirect( add_query_arg( 'wpps_simple_captcha_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;
		}
		if ( $this->shouldSkip() ) {
			return $comment_data;
		}
		if ( $this->verify( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ) ) {
			return $comment_data;
		}

		$message = $this->messageForFailedVerification( $this->getKeyFromRequest(), $this->getAnswerFromRequest() );
		$referer = wp_get_referer();
		if ( is_string( $referer ) && $referer !== '' ) {
			wp_safe_redirect( add_query_arg( 'wpps_simple_captcha_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 Simple CAPTCHA verification redirect.
	 */
	public function renderCommentSimpleCaptchaErrorNotice(): void {
		if ( ! isset( $_GET['wpps_simple_captcha_error'] ) || sanitize_text_field( wp_unslash( (string) $_GET['wpps_simple_captcha_error'] ) ) !== '1' ) {
			return;
		}
		echo '<p class="wpps-simple-captcha-comment-error" role="alert" style="color:#b32d2e;margin-bottom:12px;">';
		echo esc_html( $this->messageForFailedVerification( '', '' ) );
		echo '</p>';
	}

	public function validateWooLogin( \WP_Error $errors, string $username, string $password ): \WP_Error {
		if ( $this->shouldSkip() ) {
			return $errors;
		}
		if ( ! $this->verify( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ) ) {
			$errors->add( 'simple_captcha_failed', $this->messageForFailedVerification( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ) );
		}
		return $errors;
	}

	public function validateWooRegistration( \WP_Error $errors, string $username, string $email ): \WP_Error {
		if ( $this->shouldSkip() ) {
			return $errors;
		}
		if ( ! $this->verify( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ) ) {
			$errors->add( 'simple_captcha_failed', $this->messageForFailedVerification( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ) );
		}
		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;
		}
		if ( $this->shouldSkip() ) {
			return;
		}

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

		if ( $this->verify( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ) ) {
			return;
		}
		wc_add_notice( $this->messageForFailedVerification( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ), 'error' );
	}

	public function injectCf7Widget( string $content ): string {
		ob_start();
		$this->renderWidget( 'cf7' );
		$widget = ob_get_clean();
		return $this->injectCf7MarkupBeforeSubmit( $content, $widget );
	}

	/**
	 * `wpcf7_form_elements` is inner markup only (no trailing </form>). Place CAPTCHA before submit, not after all fields.
	 *
	 * @param string $inner_html Filtered CF7 inner HTML.
	 * @param string $inject     Markup to insert (may be empty).
	 */
	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;
		}
		if ( $this->shouldSkip() ) {
			return false;
		}
		return ! $this->verify( $this->getKeyFromRequest(), $this->getAnswerFromRequest() );
	}

	/**
	 * WPForms stores errors as $errors[ $form_id ]['header'|'footer'|field_id]; 'form' is ignored.
	 *
	 * @param array<string, mixed> $errors    Errors by form ID.
	 * @param array<string, mixed> $form_data Parsed form definition.
	 * @return array<string, mixed>
	 */
	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;
		}

		if ( $this->shouldSkip() ) {
			return $errors;
		}

		$key    = $this->getKeyFromRequest();
		$answer = $this->getAnswerFromRequest();

		if ( $this->verify( $key, $answer ) ) {
			return $errors;
		}

		if ( ! isset( $errors[ $form_id ] ) || ! is_array( $errors[ $form_id ] ) ) {
			$errors[ $form_id ] = [];
		}

		$errors[ $form_id ]['footer'] = $this->messageForFailedVerification( $key, $answer );

		return $errors;
	}

	/**
	 * Formidable applies this in {@see FrmEntryValidate::validate()}; non-field keys (e.g. captcha) are rendered by FrmFormsHelper::show_error().
	 *
	 * @param array<string, mixed>        $errors Field-level and custom error keys.
	 * @param array<string, mixed>        $values Sanitized entry POST (form_id, item_meta, …).
	 * @param array<string, mixed>|null   $args   Contains exclude, posted_fields (Formidable 5.0.04+).
	 */
	public function validateFormidable( array $errors, array $values, $args = null ): array {
		unset( $args );
		if ( $this->shouldSkip() ) {
			return $errors;
		}
		if ( ! $this->verify( $this->getKeyFromRequest(), $this->getAnswerFromRequest() ) ) {
			$errors['captcha'] = $this->messageForFailedVerification( $this->getKeyFromRequest(), $this->getAnswerFromRequest() );
		}
		return $errors;
	}

	/**
	 * Output CAPTCHA immediately before the Fluent Forms submit button (not at form top).
	 *
	 * @param array<string, mixed> $submit_button Submit field definition.
	 * @param object                 $form        Form object.
	 */
	public function renderFluentFormsBeforeSubmit( $submit_button, $form ): void {
		unset( $submit_button );
		if ( ! is_object( $form ) ) {
			return;
		}
		$suffix = isset( $form->id ) ? '-ff-' . (int) $form->id : '';
		echo '<div class="ff-el-group wpps-fluent-simple-captcha" style="margin-bottom:12px;">';
		$this->renderWidget( isset( $form->id ) ? 'fluent_' . (int) $form->id : 'fluent_form', false, $suffix );
		echo '</div>';
	}

	/**
	 * @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 );
		if ( $this->shouldSkip() ) {
			return $errors;
		}
		list( $key, $answer ) = $this->getCaptchaPairFromFluentRequest( $form_data );
		if ( $this->verify( $key, $answer ) ) {
			return $errors;
		}
		$errors['wpps_simple_captcha'] = $this->messageForFailedVerification( $key, $answer );
		return $errors;
	}

	/**
	 * Fluent Forms may nest fields under data.data or only send them in $_POST — read both.
	 *
	 * @param array<string, mixed> $fluent_submission Fluent submission array.
	 * @return array{0: string, 1: string} Key and answer for verify().
	 */
	private function getCaptchaPairFromFluentRequest( array $fluent_submission ): array {
		$key = isset( $_POST['wpps_simple_captcha_key'] ) ? sanitize_text_field( wp_unslash( $_POST['wpps_simple_captcha_key'] ) ) : '';
		$ans = isset( $_POST['wpps_simple_captcha_answer'] ) ? sanitize_text_field( wp_unslash( $_POST['wpps_simple_captcha_answer'] ) ) : '';

		if ( $key !== '' || $ans !== '' ) {
			return [ $key, $ans ];
		}

		$nested = $fluent_submission;
		if ( isset( $fluent_submission['data'] ) && is_array( $fluent_submission['data'] ) ) {
			$nested = $fluent_submission['data'];
		}

		if ( is_array( $nested ) ) {
			if ( isset( $nested['wpps_simple_captcha_key'] ) ) {
				$key = sanitize_text_field( (string) $nested['wpps_simple_captcha_key'] );
			}
			if ( isset( $nested['wpps_simple_captcha_answer'] ) ) {
				$ans = sanitize_text_field( (string) $nested['wpps_simple_captcha_answer'] );
			}
		}

		return [ $key, $ans ];
	}

	private function getSettings(): array {
		if ( $this->settings_cache !== null ) {
			return $this->settings_cache;
		}
		$settings = $this->moduleSettings()->all( [] );
		if ( ! is_array( $settings ) ) {
			$settings = [];
		}
		$skip_raw   = isset( $settings['skip_rules'] ) && is_array( $settings['skip_rules'] ) ? $settings['skip_rules'] : [];
		$skip_rules = [
			'enabled'     => ! empty( $skip_raw['enabled'] ),
			'roles'       => isset( $skip_raw['roles'] ) && is_array( $skip_raw['roles'] ) ? array_map( 'sanitize_key', $skip_raw['roles'] ) : [],
			'ips'         => isset( $skip_raw['ips'] ) && is_array( $skip_raw['ips'] ) ? array_values( array_filter( array_map( 'trim', $skip_raw['ips'] ) ) ) : [],
			'user_agents' => isset( $skip_raw['user_agents'] ) && is_array( $skip_raw['user_agents'] ) ? array_values( array_filter( array_map( 'trim', $skip_raw['user_agents'] ) ) ) : [],
		];

		$this->settings_cache = [
			'captcha_type'         => in_array( $settings['captcha_type'] ?? 'math', [ 'math', 'text', 'image' ], true ) ? ( $settings['captcha_type'] ?? 'math' ) : 'math',
			'math_difficulty'      => in_array( $settings['math_difficulty'] ?? 'easy', [ 'easy', 'medium', 'hard' ], true ) ? ( $settings['math_difficulty'] ?? 'easy' ) : 'easy',
			'text_questions'       => is_array( $settings['text_questions'] ?? null ) ? $settings['text_questions'] : [],
			'image_style'          => in_array( $settings['image_style'] ?? 'distorted', [ 'distorted', 'simple' ], true ) ? ( $settings['image_style'] ?? 'distorted' ) : 'distorted',
			'image_length'         => max( 4, min( 8, (int) ( $settings['image_length'] ?? 5 ) ) ),
			'image_case_sensitive' => ! empty( $settings['image_case_sensitive'] ),
			'skip_logged_in'       => ! isset( $settings['skip_logged_in'] ) || ! empty( $settings['skip_logged_in'] ),
			'error_message'        => trim( (string) ( $settings['error_message'] ?? '' ) ),
			'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'] ),
			'woo_reviews'            => ! empty( $settings['woo_reviews'] ),
			'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'] ),
			'cf7'                  => ! empty( $settings['cf7'] ),
			'wpforms'              => ! empty( $settings['wpforms'] ),
			'formidable'           => ! empty( $settings['formidable'] ),
			'fluent_forms'         => ! empty( $settings['fluent_forms'] ),
			'elementor_forms'    => ! empty( $settings['elementor_forms'] ),
			'basic_forms'          => ! empty( $settings['basic_forms'] ),
			'skip_rules'              => $skip_rules,
			'anti_bot_honeypot'       => ! isset( $settings['anti_bot_honeypot'] ) || ! empty( $settings['anti_bot_honeypot'] ),
			'anti_bot_timing'         => ! isset( $settings['anti_bot_timing'] ) || ! empty( $settings['anti_bot_timing'] ),
			'min_submit_seconds'      => max( 1, min( 30, (int) ( $settings['min_submit_seconds'] ?? 2 ) ) ),
			'ajax_comment_reset'      => ! isset( $settings['ajax_comment_reset'] ) || ! empty( $settings['ajax_comment_reset'] ),
		];

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

		return $this->settings_cache;
	}
}
