<?php
/**
 * Class responsible for verifying tokens returned by Recaptcha.
 *
 * @package Gravity_Forms\Gravity_Forms_RECAPTCHA
 */

namespace Gravity_Forms\Gravity_Forms_RECAPTCHA;

use GFCommon;
use stdClass;

/**
 * Class Token_Verifier
 *
 * @since   1.0
 *
 * @package Gravity_Forms\Gravity_Forms_RECAPTCHA
 */
class Token_Verifier {
	/**
	 * Error code returned if a token or secret is missing.
	 *
	 * @since 1.0
	 */
	const ERROR_CODE_MISSING_TOKEN_OR_SECRET = 'gravityformsrecaptcha-missing-token-or-secret';

	/**
	 * Error code returned if the token cannot be verified.
	 *
	 * @since 1.0
	 */
	const ERROR_CODE_CANNOT_VERIFY_TOKEN = 'gravityforms-cannot-verify-token';

	/**
	 * Instance of the add-on class.
	 *
	 * @since 1.0
	 * @var GF_RECAPTCHA
	 */
	private $addon;

	/**
	 * Class instance.
	 *
	 * @since 1.0
	 * @var RECAPTCHA_API
	 */
	private $api;

	/**
	 * Minimum score the Recaptcha API can return before a form submission is marked as spam.
	 *
	 * @since 1.0
	 * @var float
	 */
	private $score_threshold;

	/**
	 * Token generated by the Recaptcha service that requires validation.
	 *
	 * @since 1.0
	 * @var string
	 */
	private $token;

	/**
	 * Recaptcha application secret used to verify the token.
	 *
	 * @since 1.0
	 * @var string
	 */
	private $secret;

	/**
	 * Result of the recaptcha request.
	 *
	 * @var stdClass
	 */
	private $recaptcha_result;

	/**
	 * The reCAPTCHA action.
	 *
	 * @since 1.4 Previously a dynamic property.
	 *
	 * @var string
	 */
	private $action;

	/**
	 * Token_Verifier constructor.
	 *
	 * @since 1.0
	 *
	 * @param GF_RECAPTCHA  $addon Instance of the GF_RECAPTCHA add-on.
	 * @param RECAPTCHA_API $api   Instance of the Recaptcha API.
	 */
	public function __construct( GF_RECAPTCHA $addon, RECAPTCHA_API $api ) {
		$this->addon = $addon;
		$this->api   = $api;
	}

	/**
	 * Initializes this object for use.
	 *
	 * @param string $token The reCAPTCHA token.
	 * @param string $action The reCAPTCHA action.
	 *
	 * @since 1.0
	 */
	public function init( $token = '', $action = '' ) {
		$this->token           = $token;
		$this->action          = $action;
		$this->secret          = $this->addon->get_plugin_settings_instance()->get_recaptcha_key( 'secret_key_v3' );
		$this->score_threshold = $this->addon->get_plugin_setting( 'score_threshold_v3', 0.5 );
	}

	/**
	 * Get the reCAPTCHA result.
	 *
	 * Returns a stdClass if it's already been processed.
	 *
	 * @since 1.0
	 *
	 * @return stdClass|null
	 */
	public function get_recaptcha_result() {
		return $this->recaptcha_result;
	}

	/**
	 * Validate that the reCAPTCHA response data has the required properties and meets expectations.
	 *
	 * @since 1.0
	 *
	 * @param array $response_data The response data to validate.
	 *
	 * @return bool
	 */
	private function validate_response_data( $response_data ) {
		if (
			! empty( $response_data->{'error-codes'} )
			|| ( property_exists( $response_data, 'success' ) && $response_data->success !== true )
		) {
			return false;
		}

		$validation_properties = array( 'hostname', 'action', 'success', 'score', 'challenge_ts' );
		$response_properties   = array_filter(
			$validation_properties,
			function( $property ) use ( $response_data ) {
				return property_exists( $response_data, $property );
			}
		);

		if ( count( $validation_properties ) !== count( $response_properties ) ) {
			return false;
		}

		return (
			$response_data->success
			&& $this->verify_hostname( $response_data->hostname )
			&& $this->verify_action( $response_data->action )
			&& $this->verify_score( $response_data->score )
			&& $this->verify_timestamp( $response_data->challenge_ts )
		);
	}

	/**
	 * Verify the submission data.
	 *
	 * @since 1.0
	 *
	 * @param string $token The Recapatcha token.
	 *
	 * @return bool
	 */
	public function verify_submission( $token ) {

		$data = \GFCache::get( 'recaptcha_' . $token, $found );
		if ( $found ) {
			$this->addon->log_debug( __METHOD__ . '(): Using cached reCAPTCHA result: ' . print_r( $data, true ) );
			$this->recaptcha_result = $data;

			return true;
		}

		$this->addon->log_debug( __METHOD__ . '(): Verifying reCAPTCHA submission.' );

		if ( empty( $token ) ) {
			$this->addon->log_debug( __METHOD__ . '(): Could not verify the submission because no token was found.' . PHP_EOL );
			return false;
		}

		$this->init( $token, 'submit' );

		$data = $this->get_response_data( $this->api->verify_token( $token, $this->addon->get_plugin_settings_instance()->get_recaptcha_key( 'secret_key_v3' ) ) );

		if ( is_wp_error( $data ) ) {
			$this->addon->log_debug( __METHOD__ . '(): Validating the reCAPTCHA response has failed due to the following: ' . $data->get_error_message() );
			wp_send_json_error(
				array(
					'error' => $data->get_error_message(),
					'code'  => self::ERROR_CODE_CANNOT_VERIFY_TOKEN,
				)
			);
		}

		if ( ! $this->validate_response_data( $data ) ) {
			$this->addon->log_debug(
				__METHOD__ . '(): Could not validate the token request from the reCAPTCHA service. ' . PHP_EOL
				. "token: {$token}" . PHP_EOL
				. "response: " . print_r( $data, true ) . PHP_EOL // @codingStandardsIgnoreLine
			);
			return false;
		}

		// @codingStandardsIgnoreLine
		$this->addon->log_debug( __METHOD__ . '(): Validated reCAPTCHA: ' . print_r( $data, true ) );
		$this->recaptcha_result = $data;

		// Caching result for 1 hour.
		\GFCache::set( 'recaptcha_' . $token, $data, true, 60 * 60 );

		return true;
	}

	/**
	 * Get the data from the response.
	 *
	 * @since 1.0
	 *
	 * @param WP_Error|string $response The response from the API request.
	 *
	 * @return mixed
	 */
	private function get_response_data( $response ) {
		if ( is_wp_error( $response ) ) {
			return $response;
		}

		return json_decode( wp_remote_retrieve_body( $response ) );
	}

	/**
	 * Verify the reCAPTCHA hostname.
	 *
	 * @since 1.0
	 *
	 * @param string $hostname Verify that the host name returned matches the site.
	 *
	 * @return bool
	 */
	private function verify_hostname( $hostname ) {
		if ( ! has_filter( 'gform_recaptcha_valid_hostnames' ) ) {
			$this->addon->log_debug( __METHOD__ . '(): gform_recaptcha_valid_hostnames filter not implemented. Skipping.' );
			return true;
		}

		$this->addon->log_debug( __METHOD__ . '(): gform_recaptcha_valid_hostnames filter detected. Verifying hostname.' );

		/**
		 * Filter for the set of hostnames considered valid by this site.
		 *
		 * Google returns a 'hostname' value in reCAPTCHA verification results. We validate against this value to ensure
		 * that the data is good. By default, we use only the WordPress installation's home URL, but have extended
		 * this via a filter so developers can define an array of hostnames to allow.
		 *
		 * @since 1.0
		 *
		 * @param array $valid_hostnames {
		 *      An indexed array of valid hostname strings. Example:
		 *      array( 'example.com', 'another-example.com' )
		 * }
		 */
		$valid_hostnames = apply_filters(
			'gform_recaptcha_valid_hostnames',
			array(
				wp_parse_url( get_home_url(), PHP_URL_HOST ),
			)
		);

		return is_array( $valid_hostnames ) ? in_array( $hostname, $valid_hostnames, true ) : false;
	}

	/**
	 * Verify the reCAPTCHA action.
	 *
	 * @since 1.0
	 *
	 * @param string $action The reCAPTCHA result action.
	 *
	 * @return bool
	 */
	private function verify_action( $action ) {
		$this->addon->log_debug( __METHOD__ . '(): Verifying action from reCAPTCHA response.' );

		return $this->action === $action;
	}

	/**
	 * Verify that the score is valid.
	 *
	 * @since 1.0
	 *
	 * @param float $score The reCAPTCHA v3 score.
	 *
	 * @return bool
	 */
	private function verify_score( $score ) {
		$this->addon->log_debug( __METHOD__ . '(): Verifying score from reCAPTCHA response.' );

		return is_float( $score ) && $score >= 0.0 && $score <= 1.0;
	}

	/**
	 * Verify that the timestamp of the submission is valid.
	 *
	 * Google allows a reCAPTCHA token to be valid for two minutes. On multi-page forms, we generate a new token with
	 * the advancement of each page, but the timestamp that's returned is always the same. Thus, we'll allow a longer
	 * time frame for form submissions before considering them to be invalid.
	 *
	 * @since 1.0
	 *
	 * @param string $challenge_ts The challenge timestamp from the reCAPTCHA service.
	 *
	 * @return bool
	 */
	private function verify_timestamp( $challenge_ts ) {
		$this->addon->log_debug( __METHOD__ . '(): Verifying timestamp from reCAPTCHA response.' );

		return ( gmdate( time() ) - strtotime( $challenge_ts ) ) <= 24 * HOUR_IN_SECONDS;
	}

	/**
	 * Get the score from the Recaptcha result.
	 *
	 * @since 1.0
	 *
	 * @return float
	 */
	public function get_score() {
		if ( empty( $this->recaptcha_result ) || ! property_exists( $this->recaptcha_result, 'score' ) ) {
			return $this->addon->is_preview() ? 0.9 : 0.0;
		}

		return (float) $this->recaptcha_result->score;
	}

	/**
	 * Get the decoded response data from the API.
	 *
	 * @param string $token The validation token.
	 * @param string $secret The stored secret key from the settings page.
	 *
	 * @since 1.0
	 *
	 * @return WP_Error|mixed|string
	 */
	public function verify( $token, $secret ) {
		return $this->get_response_data( $this->api->verify_token( $token, $secret ) );
	}
}
