<?php
/**
 * Google reCAPTCHA Extension
 *
 * Adds bot protection to storefront forms. Gated on the 'recaptcha' extension
 * toggle. Supports v2 (checkbox) and v3 (invisible, score-based), chosen by the
 * admin via the 'recaptcha_version' setting.
 *
 * Settings read from tc_settings section = 'recaptcha':
 *   recaptcha_version            v2 | v3
 *   recaptcha_site_key           v2 site key
 *   recaptcha_secret             v2 secret
 *   recaptcha_theme              light | dark   (v2 widget)
 *   recaptcha_size               normal | compact (v2 widget)
 *   recaptcha_v3_site_key        v3 site key
 *   recaptcha_v3_secret          v3 secret
 *   recaptcha_v3_success_score   v3 minimum passing score
 *   protect_login | protect_register | protect_checkout
 *   protect_reviews | protect_contact   (per-surface on/off)
 *
 * Surfaces are heterogeneous:
 *   - login / register : server-rendered template forms (standard POST). The
 *     template handler calls RecaptchaExtension::passes('login'|'register').
 *   - reviews          : [titancart_review_form] shortcode, REST submit. Widget
 *     rendered via the existing 'titancart_review_form_extra_fields' hook;
 *     verified in the review REST handler.
 *   - checkout (guest) : JS place-order -> REST. Widget rendered via the
 *     'titancart_recaptcha_field' hook in checkout.php; verified in checkout.
 *   - contact          : no core contact form exists; the surface is wired
 *     defensively so a theme contact form can opt in via the same hook + helper.
 *
 * Client script (Google api.js) is loaded lazily: a widget render flags the
 * page as needing the script, which is then printed once in wp_footer. This
 * avoids loading Google on pages that host no protected form.
 *
 * @package TitanCart\Extensions
 */

namespace TitanCart\Extensions;

use TitanCart\REST\Extensions\ExtensionsController;

defined( 'ABSPATH' ) || exit;

class RecaptchaExtension {

	/** @var array<string,string>|null Cached settings. */
	private static $settings = null;

	/** @var bool Whether at least one widget rendered (so we print the script). */
	private static $needs_script = false;

	/** @var array<int,array{surface:string,id:string}> Rendered v3 widgets needing token injection. */
	private static $v3_widgets = [];

	/** Map of surface => the v3 action label sent to Google. */
	const ACTIONS = [
		'login'    => 'tc_login',
		'register' => 'tc_register',
		'checkout' => 'tc_checkout',
		'review'   => 'tc_review',
		'contact'  => 'tc_contact',
	];

	public static function init(): void {
		if ( ! ExtensionsController::is_active( 'recaptcha' ) ) {
			return;
		}

		// Nothing to render or verify until the active version has keys.
		if ( ! self::is_configured() ) {
			return;
		}

		// Login gate for the storefront login panel that delegates to wp-login.php
		// (account/login-register.php). Marker-scoped via $_POST['tc_rc_login'] so
		// wp-admin and programmatic logins are NEVER affected. Registered before the
		// front-end guard because the wp-login.php POST is not an is_admin() context
		// but must still be gated.
		if ( self::protected_surface( 'login' ) ) {
			add_filter( 'authenticate', [ self::class, 'authenticate_gate' ], 30, 1 );
		}

		// Front-end only.
		if ( is_admin() ) {
			return;
		}

		// Generic render hook templates can call: do_action('titancart_recaptcha_field', 'login').
		add_action( 'titancart_recaptcha_field', [ self::class, 'render_widget' ], 10, 1 );

		// Reviews already expose an extra-fields hook — reuse it (no template edit needed).
		if ( self::protected_surface( 'review' ) ) {
			add_action( 'titancart_review_form_extra_fields', [ self::class, 'render_review_widget' ], 20 );
		}

		// Print Google's script once, late, only if a widget rendered.
		add_action( 'wp_footer', [ self::class, 'print_footer_script' ], 1 ); // WP.org R1 🅱1: was 99; must run BEFORE wp_print_footer_scripts (20) so the wp_enqueue_script conversion below can print. $needs_script is settled during body render, so any wp_footer priority sees the final flag.
	}

	/* ══════════════════════════════════════════════════════════════════════
	 * SETTINGS
	 * ══════════════════════════════════════════════════════════════════════ */

	private static function settings(): array {
		if ( self::$settings !== null ) {
			return self::$settings;
		}

		$db   = titancart_db();
		$rows = $db->getArray(
			"SELECT `key`, value FROM {$db->table('settings')} WHERE section = ?s",
			'recaptcha'
		);

		$vals = [];
		foreach ( $rows as $r ) {
			$vals[ $r['key'] ] = $r['value'];
		}

		self::$settings = [
			'recaptcha_version'          => $vals['recaptcha_version']          ?? 'v2',
			'recaptcha_site_key'         => $vals['recaptcha_site_key']         ?? '',
			'recaptcha_secret'           => $vals['recaptcha_secret']           ?? '',
			'recaptcha_theme'            => $vals['recaptcha_theme']            ?? 'light',
			'recaptcha_size'             => $vals['recaptcha_size']             ?? 'normal',
			'recaptcha_v3_site_key'      => $vals['recaptcha_v3_site_key']      ?? '',
			'recaptcha_v3_secret'        => $vals['recaptcha_v3_secret']        ?? '',
			'recaptcha_v3_success_score' => $vals['recaptcha_v3_success_score'] ?? '0.5',
			'protect_login'              => ( $vals['protect_login']    ?? '1' ) === '1',
			'protect_register'           => ( $vals['protect_register'] ?? '1' ) === '1',
			'protect_checkout'           => ( $vals['protect_checkout'] ?? '0' ) === '1',
			'protect_reviews'            => ( $vals['protect_reviews']  ?? '1' ) === '1',
			'protect_contact'            => ( $vals['protect_contact']  ?? '1' ) === '1',
		];

		return self::$settings;
	}

	public static function active_version(): string {
		return self::settings()['recaptcha_version'] === 'v3' ? 'v3' : 'v2';
	}

	private static function site_key(): string {
		$s = self::settings();
		return self::active_version() === 'v3' ? $s['recaptcha_v3_site_key'] : $s['recaptcha_site_key'];
	}

	private static function secret(): string {
		$s = self::settings();
		return self::active_version() === 'v3' ? $s['recaptcha_v3_secret'] : $s['recaptcha_secret'];
	}

	private static function min_score(): float {
		return (float) self::settings()['recaptcha_v3_success_score'];
	}

	/** True when the active version has both its site key and secret set. */
	public static function is_configured(): bool {
		return self::site_key() !== '' && self::secret() !== '';
	}

	/**
	 * Client-side config for a surface submitted via fetch/XHR (reviews, checkout),
	 * where Google's auto-submit interception can't fire. Returns null when the
	 * surface isn't protected so the caller can cheaply skip all token logic.
	 *
	 * @param string $surface login|register|checkout|review|contact
	 * @return array{version:string,siteKey:string,action:string}|null
	 */
	public static function client_config( string $surface ): ?array {
		if ( ! self::protected_surface( $surface ) ) {
			return null;
		}
		return [
			'version' => self::active_version(),
			'siteKey' => self::site_key(),
			'action'  => self::ACTIONS[ $surface ] ?? 'tc_form',
		];
	}

	/**
	 * Whether a given surface should be gated right now.
	 * Maps 'reviews' setting to the 'review' surface name used internally.
	 */
	public static function protected_surface( string $surface ): bool {
		if ( ! ExtensionsController::is_active( 'recaptcha' ) || ! self::is_configured() ) {
			return false;
		}
		$s   = self::settings();
		$map = [
			'login'    => 'protect_login',
			'register' => 'protect_register',
			'checkout' => 'protect_checkout',
			'review'   => 'protect_reviews',
			'contact'  => 'protect_contact',
		];
		$key = $map[ $surface ] ?? '';
		return $key !== '' && ! empty( $s[ $key ] );
	}

	/* ══════════════════════════════════════════════════════════════════════
	 * CLIENT — widget render + script
	 * ══════════════════════════════════════════════════════════════════════ */

	/** Hook target for the review extra-fields action. */
	public static function render_review_widget(): void {
		self::render_widget( 'review' );
	}

	/**
	 * Render the reCAPTCHA field for a surface. Safe to call unconditionally —
	 * outputs nothing if the surface isn't protected.
	 *
	 * @param string $surface One of login|register|checkout|review|contact.
	 */
	public static function render_widget( string $surface ): void {
		if ( ! self::protected_surface( $surface ) ) {
			return;
		}

		self::$needs_script = true;
		$site_key = esc_attr( self::site_key() );

		if ( self::active_version() === 'v3' ) {
			// Invisible: a hidden field per form, populated by the footer script on submit.
			$wid = 'tc-rc-' . $surface . '-' . wp_rand( 1000, 9999 );
			$action = self::ACTIONS[ $surface ] ?? 'tc_form';
			self::$v3_widgets[] = [ 'surface' => $surface, 'id' => $wid, 'action' => $action ];
			echo '<input type="hidden" name="g-recaptcha-response" class="tc-recaptcha-v3-field" data-tc-rc="' . esc_attr( $wid ) . '" value="">';
			return;
		}

		// v2 checkbox widget. Google's api.js auto-renders any .g-recaptcha and
		// posts its token as g-recaptcha-response within the enclosing form.
		$s = self::settings();
		echo '<div class="tc-recaptcha tc-recaptcha--v2" style="margin:14px 0;">';
		echo '<div class="g-recaptcha" data-sitekey="' . esc_attr( $site_key ) . '"'
			. ' data-theme="' . esc_attr( $s['recaptcha_theme'] ) . '"'
			. ' data-size="' . esc_attr( $s['recaptcha_size'] ) . '"></div>';
		echo '</div>';
	}

	/** Print Google's script once, plus v3 submit-interception glue if needed. */
	public static function print_footer_script(): void {
		if ( ! self::$needs_script ) {
			return;
		}

		$version  = self::active_version();

		if ( $version === 'v3' ) {
			// WP.org R1 🅱1: reCAPTCHA v3 loader properly enqueued from Google's
			// origin (their terms require loading verbatim; no version arg). The
			// interception glue rides a stub handle — its ready() poller tolerates
			// any load order, so no 'after' attachment (which would block async).
			wp_enqueue_script( 'titancart-recaptcha-api', 'https://www.google.com/recaptcha/api.js?render=' . rawurlencode( self::site_key() ), [], null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- api.js URL is verbatim per Google.
			$widgets = wp_json_encode( array_map(
				static function ( $w ) {
					return [ 'id' => $w['id'], 'action' => $w['action'] ];
				},
				self::$v3_widgets
			) );
			// WP.org R2: glue JS is a NOWDOC (strtr() fills %%SLOT%% tokens), attached
			// via wp_add_inline_script (footer stub).
			$tc_rc_key     = wp_json_encode( self::site_key() );
			$tc_rc_widgets = $widgets ?: '[]';
			$tc_rc_js      = <<<'JS'
(function(){
	'use strict';
	var SITE_KEY = %%SITE_KEY%%;
	var WIDGETS = %%WIDGETS%%;
	function fieldFor(id){ return document.querySelector('.tc-recaptcha-v3-field[data-tc-rc="' + id + '"]'); }
	function ready(cb){ if (window.grecaptcha && window.grecaptcha.execute) { grecaptcha.ready(cb); } else { setTimeout(function(){ ready(cb); }, 150); } }
	WIDGETS.forEach(function(w){
		var field = fieldFor(w.id);
		if (!field) return;
		var form = field.closest('form');
		if (!form) return;
		var submitting = false;
		form.addEventListener('submit', function(e){
			if (submitting) return; // second pass after token set
			e.preventDefault();
			ready(function(){
				grecaptcha.execute(SITE_KEY, { action: w.action }).then(function(token){
					field.value = token;
					submitting = true;
					if (typeof form.requestSubmit === 'function') { form.requestSubmit(); } else { form.submit(); }
				}).catch(function(){ submitting = true; if (typeof form.requestSubmit === 'function') { form.requestSubmit(); } else { form.submit(); } });
			});
		}, true);
	});
})();
JS;
			$tc_rc_js = strtr( $tc_rc_js, [ '%%SITE_KEY%%' => $tc_rc_key, '%%WIDGETS%%' => $tc_rc_widgets ] );
			wp_register_script( 'titancart-recaptcha-inline', false, [], TITANCART_VERSION, true );
			wp_enqueue_script( 'titancart-recaptcha-inline' );
			wp_add_inline_script( 'titancart-recaptcha-inline', $tc_rc_js );
			return;
		}

		// v2 — api.js auto-renders .g-recaptcha; no extra glue required.
		// WP.org R1 🅱1: properly enqueued with async strategy (was a raw async/defer tag).
		wp_enqueue_script( 'titancart-recaptcha-api', 'https://www.google.com/recaptcha/api.js', [], null, [ 'in_footer' => true, 'strategy' => 'async' ] ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- api.js URL is verbatim per Google.
	}

	/* ══════════════════════════════════════════════════════════════════════
	 * SERVER — verification
	 * ══════════════════════════════════════════════════════════════════════ */

	/**
	 * Pull the token from the current request. Checks $_POST first (template
	 * forms + multipart), then the raw JSON body (REST/fetch submits).
	 */
	public static function token_from_request(): string {
		// WP.org R1 🅱2: every return path sanitized. The token is opaque
		// base64ish material consumed only as a POST field to Google's
		// siteverify API — sanitize_text_field is shape-preserving for valid
		// tokens and strips anything hostile from invalid ones.
		//
		// Nonce: this helper is READ-ONLY — it never performs a write. Every
		// caller (passes()) is itself inside a nonce-verified handler
		// (check_admin_referer on template forms, REST permission_callback +
		// X-WP-Nonce on fetch submits); the token is the anti-bot credential
		// verified against Google's siteverify, not a substitute for the nonce.
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		if ( isset( $_POST['g-recaptcha-response'] ) ) {
			return sanitize_text_field( (string) wp_unslash( $_POST['g-recaptcha-response'] ) );
		}
		if ( isset( $_POST['recaptcha_token'] ) ) {
			return sanitize_text_field( (string) wp_unslash( $_POST['recaptcha_token'] ) );
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing
		$raw = file_get_contents( 'php://input' );
		if ( $raw ) {
			$json = json_decode( $raw, true );
			if ( is_array( $json ) ) {
				if ( isset( $json['g-recaptcha-response'] ) ) {
					return sanitize_text_field( (string) $json['g-recaptcha-response'] );
				}
				if ( isset( $json['recaptcha_token'] ) ) {
					return sanitize_text_field( (string) $json['recaptcha_token'] );
				}
			}
		}
		return '';
	}

	/**
	 * Full verification result for a surface. Returns success=true (pass-through)
	 * when the surface isn't protected, so callers can guard unconditionally.
	 *
	 * @param string      $surface One of login|register|checkout|review|contact.
	 * @param string|null $token   Explicit token; null = read from request.
	 * @return array {success:bool, score:?float, action:?string, error:string}
	 */
	public static function verify_request( string $surface, ?string $token = null ): array {
		if ( ! self::protected_surface( $surface ) ) {
			return [ 'success' => true, 'score' => null, 'action' => null, 'error' => '' ];
		}

		$token   = $token === null ? self::token_from_request() : $token;
		$version = self::active_version();
		$action  = $version === 'v3' ? ( self::ACTIONS[ $surface ] ?? null ) : null;
		$ip      = titancart_client_ip() ?: null; // WP.org R1 🅱2: validated capture.

		return RecaptchaVerifier::verify(
			$token,
			$version,
			self::secret(),
			$action,
			self::min_score(),
			$ip
		);
	}

	/**
	 * Boolean convenience for callers that only need pass/fail.
	 * Unprotected surfaces always pass.
	 */
	public static function passes( string $surface, ?string $token = null ): bool {
		return ! empty( self::verify_request( $surface, $token )['success'] );
	}

	/**
	 * Filter callback for the WP `authenticate` filter (registered in init()).
	 * Gates the storefront login POST when the storefront form carries the
	 * `tc_rc_login` marker (set by account/login-register.php on the
	 * wp-login.php-delegated submit). Other auth contexts — wp-admin, XML-RPC,
	 * REST, programmatic logins — never set the marker, so this method passes
	 * them through unchanged.
	 *
	 * @param  \WP_User|\WP_Error|null $user Result from prior authenticate callbacks.
	 * @return \WP_User|\WP_Error|null
	 */
	public static function authenticate_gate( $user ) {
		// Out of scope: only the storefront login form sets this marker.
		// Marker read only (boolean presence, never stored); the login POST it
		// rides on is nonce-checked by the storefront form handler upstream.
		if ( empty( $_POST['tc_rc_login'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
			return $user;
		}
		// Don't pile on an existing error from earlier callbacks.
		if ( is_wp_error( $user ) ) {
			return $user;
		}
		if ( ! self::passes( 'login' ) ) {
			return new \WP_Error(
				'tc_recaptcha_failed',
				__( 'reCAPTCHA verification failed. Please try again.', 'titancart-lite' )
			);
		}
		return $user;
	}
}
