<?php

namespace SugarCalendarBookings\Antispam\Captcha;

use SugarCalendarBookings\Options;
use SugarCalendarBookings\Plugin;

/**
 * Base class for captcha token verifiers.
 *
 * Concretes declare provider-specific constants (endpoint, option keys, filter
 * names, script handle/URL, conflict hosts) and override the provider hooks
 * (`get_fail_message_default()`, `get_frontend_config()`,
 * `get_script_render_arg()`) where needed.
 *
 * @since 1.5.0
 */
abstract class AbstractCaptchaVerifier implements CaptchaVerifierInterface {

	/**
	 * Provider siteverify endpoint. Subclasses MUST override.
	 *
	 * @since 1.5.0
	 *
	 * @var string
	 */
	public const ENDPOINT_URL = '';

	/**
	 * UI category this provider belongs to. Subclasses MUST override.
	 *
	 * Maps the provider to the settings-tab card it appears under
	 * (e.g. 'recaptcha', 'turnstile'). Multiple providers may share a category
	 * when they share option keys.
	 *
	 * @since 1.5.0
	 *
	 * @var string
	 */
	public const CATEGORY = '';

	/**
	 * Stored option key for the provider's secret. Subclasses MUST override.
	 *
	 * @since 1.5.0
	 *
	 * @var string
	 */
	public const SECRET_OPTION_KEY = '';

	/**
	 * Filter name applied when resolving the secret. Subclasses MUST override.
	 *
	 * @since 1.5.0
	 *
	 * @var string
	 */
	public const SECRET_FILTER_NAME = '';

	/**
	 * Stored option key for the provider's site key. Subclasses MUST override.
	 *
	 * @since 1.5.0
	 *
	 * @var string
	 */
	public const SITE_KEY_OPTION_KEY = '';

	/**
	 * Stored option key for the provider's theme. Subclasses MUST override.
	 *
	 * @since 1.5.0
	 *
	 * @var string
	 */
	public const THEME_OPTION_KEY = '';

	/**
	 * Stored option key for the user-facing fail message. Subclasses MUST override.
	 *
	 * @since 1.5.0
	 *
	 * @var string
	 */
	public const FAIL_MESSAGE_OPTION_KEY = '';

	/**
	 * Registered handle for the provider's third-party script. Subclasses MUST override.
	 *
	 * @since 1.5.0
	 *
	 * @var string
	 */
	public const SCRIPT_HANDLE = '';

	/**
	 * Provider api.js URL. Subclasses MUST override.
	 *
	 * @since 1.5.0
	 *
	 * @var string
	 */
	public const SCRIPT_API_URL = '';

	/**
	 * URL substrings used by the no-conflict dequeue to detect foreign provider scripts.
	 *
	 * @since 1.5.0
	 *
	 * @var string[]
	 */
	public const CONFLICT_HOSTS = [];

	/**
	 * Whether this captcha method renders a user-visible widget.
	 *
	 * False for silent variants that have no renderable surface at all
	 * (honeypots, proof-of-work). reCAPTCHA v3 still counts as visible since
	 * its inline badge can be rendered in the admin preview. Drives the
	 * preview-widget visibility on the Anti-Spam settings tab.
	 *
	 * @since 1.5.0
	 *
	 * @var bool
	 */
	public const HAS_VISIBLE_WIDGET = true;

	/**
	 * Sentinel hostname echoed by the provider when its documented public
	 * test keys are in use. Empty string disables the check.
	 *
	 * Two ways to declare test-key acceptance, depending on what the provider
	 * gives you:
	 *
	 *   1. The provider echoes a fabricated sentinel hostname that does not
	 *      correspond to a real domain (e.g. reCAPTCHA's `testkey.google.com`).
	 *      Set this constant; the default `is_test_key_response()` will match.
	 *
	 *   2. The provider echoes a real, registrable hostname (e.g. Turnstile
	 *      echoes `example.com`) but exposes a separate explicit "this was a
	 *      test key" signal elsewhere in the response. Leave this constant
	 *      empty and override `is_test_key_response()` to read that signal —
	 *      the hostname alone is not a safe sentinel because a real site
	 *      could legitimately match it.
	 *
	 * @since 1.5.0
	 *
	 * @var string
	 */
	public const TEST_KEY_HOSTNAME = '';

	/**
	 * Verify the token against the provider's siteverify endpoint.
	 *
	 * @since 1.5.0
	 *
	 * @param string $token   Token captured from the client.
	 * @param string $context Verification context (e.g. 'booking_form_create').
	 *
	 * @return bool
	 */
	final public function verify( string $token, string $context = '' ): bool {

		if ( $token === '' ) {
			return false;
		}

		$response = wp_remote_post(
			static::ENDPOINT_URL,
			[
				'blocking' => true,
				'timeout'  => 5,
				'body'     => [
					'secret'   => $this->get_secret_key(),
					'response' => $token,
					'remoteip' => self::get_remote_ip(),
				],
			]
		);

		if ( is_wp_error( $response ) ) {
			return false;
		}

		$body = wp_remote_retrieve_body( $response );

		if ( empty( $body ) ) {
			return false;
		}

		$decoded = json_decode( $body );

		if ( empty( $decoded ) ) {
			return false;
		}

		if ( ! $this->is_hostname_valid( $decoded ) ) {
			return false;
		}

		return $this->is_response_valid( $decoded, $context );
	}

	/**
	 * Verify the provider-reported hostname matches the current site origin.
	 *
	 * Both reCAPTCHA and Turnstile echo the `hostname` the token was issued
	 * for. If a `site_key` leaks, this prevents an attacker from solving
	 * captchas on their own domain and replaying the tokens against this site.
	 *
	 * The strict origin check applies only when `should_check_hostname()`
	 * returns true. Providers without a hostname concept opt out via that
	 * predicate.
	 *
	 * The dev/CI exemption for provider test-key responses is gated by
	 * `SCBOOKINGS_DEV_MODE` so the bypass branch is unreachable in production
	 * builds where the constant is never defined.
	 *
	 * @since 1.5.0
	 *
	 * @param object $decoded Decoded JSON response.
	 *
	 * @return bool
	 */
	protected function is_hostname_valid( object $decoded ): bool {

		if ( ! $this->should_check_hostname() ) {
			return true;
		}

		if (
			defined( 'SCBOOKINGS_DEV_MODE' )
			&&
			SCBOOKINGS_DEV_MODE
			&&
			$this->is_test_key_response( $decoded )
		) {
			return true;
		}

		if ( empty( $decoded->hostname ) ) {
			return false;
		}

		$site_host = (string) wp_parse_url( home_url(), PHP_URL_HOST );

		return (string) $decoded->hostname === $site_host;
	}

	/**
	 * Whether this provider's siteverify response carries an issuing hostname
	 * that must be matched against the current site origin.
	 *
	 * True for any provider that verifies via a third-party HTTP endpoint and
	 * echoes the issuing hostname (reCAPTCHA, Turnstile, hCaptcha). Override
	 * and return false for providers without a hostname concept — server-side
	 * math challenges, honeypot fields, or proof-of-work verifiers that bind
	 * tokens via the shared secret rather than origin. Such providers should
	 * still verify authenticity through their own means inside
	 * `is_response_valid()`.
	 *
	 * @since 1.5.0
	 *
	 * @return bool
	 */
	protected function should_check_hostname(): bool {

		return true;
	}

	/**
	 * Whether this response was produced by the provider's documented public
	 * test keys.
	 *
	 * Only consulted by `is_hostname_valid()` when `SCBOOKINGS_DEV_MODE` is
	 * true — in production builds (where the constant is never defined) this
	 * method is dead code and the strict hostname binding is the only path.
	 *
	 * The default reads `TEST_KEY_HOSTNAME`; concretes whose signal is not a
	 * hostname (e.g. Turnstile's `metadata.result_with_testing_key`) override
	 * this method.
	 *
	 * @since 1.5.0
	 *
	 * @param object $decoded Decoded JSON response.
	 *
	 * @return bool
	 */
	protected function is_test_key_response( object $decoded ): bool {

		return static::TEST_KEY_HOSTNAME !== ''
			&& ( $decoded->hostname ?? '' ) === static::TEST_KEY_HOSTNAME;
	}

	/**
	 * Resolve the client IP from REMOTE_ADDR only.
	 *
	 * Intentionally ignores X-Forwarded-For and friends — a spoofable header
	 * would let an attacker rotate apparent IPs to evade provider-side abuse
	 * heuristics. Sites behind a trusted proxy that need the real client IP
	 * should set REMOTE_ADDR at the web-server layer.
	 *
	 * @since 1.5.0
	 *
	 * @return string
	 */
	private static function get_remote_ip(): string {

		$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';

		return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
	}

	/**
	 * Resolve the provider secret key from the stored option, with filter applied.
	 *
	 * @since 1.5.0
	 *
	 * @return string
	 */
	public static function resolve_secret_key(): string {

		$stored = (string) Plugin::get( Options::class )->get( static::SECRET_OPTION_KEY );

		return static::filter_secret_key( $stored );
	}

	/**
	 * Apply the provider's secret-key filter to an already-known stored value.
	 *
	 * @since 1.5.0
	 *
	 * @param string $stored Raw stored option value.
	 *
	 * @return string
	 */
	public static function filter_secret_key( string $stored ): string {

		return (string) apply_filters( static::SECRET_FILTER_NAME, $stored ); // phpcs:ignore
	}

	/**
	 * Site key for the provider (no filter applied).
	 *
	 * @since 1.5.0
	 *
	 * @return string
	 */
	public function get_site_key(): string {

		return (string) Plugin::get( Options::class )->get( static::SITE_KEY_OPTION_KEY );
	}

	/**
	 * Stored theme for the provider widget.
	 *
	 * @since 1.5.0
	 *
	 * @return string
	 */
	public function get_theme(): string {

		return (string) Plugin::get( Options::class )->get( static::THEME_OPTION_KEY );
	}

	/**
	 * User-facing failure message — stored value, or the concrete's i18n default.
	 *
	 * @since 1.5.0
	 *
	 * @return string
	 */
	public function get_fail_message(): string {

		$stored = (string) Plugin::get( Options::class )->get( static::FAIL_MESSAGE_OPTION_KEY );

		return $stored !== '' ? $stored : $this->get_fail_message_default();
	}

	/**
	 * Provider-specific frontend config block included as `config` in the
	 * localized booking-form payload.
	 *
	 * @since 1.5.0
	 *
	 * @return array
	 */
	abstract public function get_frontend_config(): array;

	/**
	 * Value of the `render` query arg appended to the api.js URL.
	 *
	 * Default 'explicit' suits V2 + Turnstile. RecaptchaV3 overrides.
	 *
	 * @since 1.5.0
	 *
	 * @return string
	 */
	public function get_script_render_arg(): string {

		return 'explicit';
	}

	/**
	 * Whether both the site key and (resolved) secret key are configured.
	 *
	 * Drives the admin preview UI and the captcha-API enqueue — neither makes
	 * sense without a complete key pair.
	 *
	 * @since 1.5.0
	 *
	 * @return bool
	 */
	public function has_complete_keys(): bool {

		return $this->get_site_key() !== '' && static::resolve_secret_key() !== '';
	}

	/**
	 * Concrete's translatable fallback for `get_fail_message()`.
	 *
	 * Public so the settings UI can render the canonical default in the
	 * Fail Message input placeholder without duplicating the literal.
	 *
	 * @since 1.5.0
	 *
	 * @return string
	 */
	abstract public function get_fail_message_default(): string;

	/**
	 * Instance accessor used during the verify() flow.
	 *
	 * @since 1.5.0
	 *
	 * @return string
	 */
	protected function get_secret_key(): string {

		return static::resolve_secret_key();
	}

	/**
	 * Decide whether a successfully decoded response should be accepted.
	 *
	 * @since 1.5.0
	 *
	 * @param object $decoded Decoded JSON response from the siteverify endpoint.
	 * @param string $context Verification context (e.g. 'booking_form_create').
	 *
	 * @return bool
	 */
	protected function is_response_valid( object $decoded, string $context ): bool {

		return ! empty( $decoded->success );
	}
}
