<?php
/**
 * Dedicated Sender.
 *
 * The class is responsible for spawning dedicated Sync requests.
 *
 * @package automattic/jetpack-sync
 */

namespace Automattic\Jetpack\Sync;

use WP_Error;
/**
 * Class to manage Sync spawning.
 * The purpose of this class is to provide the means to unblock Sync
 * from running in the shutdown hook of regular requests by spawning a
 * dedicated Sync request instead which will trigger Sync to run.
 */
class Dedicated_Sender {

	/**
	 * The transient name for storing the response code
	 * after spawning a dedicated sync test request.
	 */
	const DEDICATED_SYNC_CHECK_TRANSIENT = 'jetpack_sync_dedicated_sync_spawn_check';

	/**
	 * Validation string to check if the endpoint is working correctly.
	 *
	 * This is extracted and not hardcoded, as we might want to change it in the future.
	 */
	const DEDICATED_SYNC_VALIDATION_STRING = 'DEDICATED SYNC OK';

	/**
	 * Option name to use to keep the current request lock.
	 *
	 * The option format is `microtime(true)`.
	 */
	const DEDICATED_SYNC_REQUEST_LOCK_OPTION_NAME = 'jetpack_sync_dedicated_spawn_lock';

	/**
	 * What's the timeout for the request lock in seconds.
	 *
	 * 5 seconds as default value seems sane, but we might want to adjust that in the future.
	 */
	const DEDICATED_SYNC_REQUEST_LOCK_TIMEOUT = 5;

	/**
	 * The query parameter name to use when passing the current lock id.
	 */
	const DEDICATED_SYNC_REQUEST_LOCK_QUERY_PARAM_NAME = 'request_lock_id';

	/**
	 * The name of the transient to use to temporarily disable enabling of Dedicated sync.
	 */
	const DEDICATED_SYNC_TEMPORARY_DISABLE_FLAG = 'jetpack_sync_dedicated_sync_temp_disable';

	/**
	 * Filter a URL to check if Dedicated Sync is enabled.
	 * We need to remove slashes and then run it through `urldecode` as sometimes the
	 * URL is in an encoded form, depending on server configuration.
	 *
	 * @param string $url The URL to filter.
	 *
	 * @return string
	 */
	public static function prepare_url_for_dedicated_request_check( $url ) {
		return urldecode( $url );
	}
	/**
	 * Check if this request should trigger Sync to run.
	 *
	 * @access public
	 *
	 * @return boolean True if this is a 'jetpack/v4/sync/spawn-sync', false otherwise.
	 */
	public static function is_dedicated_sync_request() {
		/**
		 * Check $_SERVER['REQUEST_URI'] first, to see if we're in the right context.
		 * This is done to make sure we can hook in very early in the initialization of WordPress to
		 * be able to send sync requests to the backend as fast as possible, without needing to continue
		 * loading things for the request.
		 */
		if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
			return false;
		}

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Recommended
		$check_url = self::prepare_url_for_dedicated_request_check( wp_unslash( $_SERVER['REQUEST_URI'] ) );
		if ( strpos( $check_url, 'jetpack/v4/sync/spawn-sync' ) !== false ) {
			return true;
		}

		/**
		 * If the above check failed, we might have an issue with detecting calls to the REST endpoint early on.
		 * Sometimes, like when permalinks are disabled, the REST path is sent via the `rest_route` GET parameter.
		 * We want to check it too, to make sure we managed to cover more cases and be more certain we actually
		 * catch calls to the endpoint.
		 */
		if ( ! isset( $_GET['rest_route'] ) ) { //phpcs:ignore WordPress.Security.NonceVerification.Recommended
			return false;
		}

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Recommended
		$check_url = self::prepare_url_for_dedicated_request_check( wp_unslash( $_GET['rest_route'] ) );
		if ( strpos( $check_url, 'jetpack/v4/sync/spawn-sync' ) !== false ) {
			return true;
		}

		return false;
	}

	/**
	 * Send a request to run Sync for a certain sync queue
	 * through HTTP request that doesn't halt page loading.
	 *
	 * @access public
	 *
	 * @param \Automattic\Jetpack\Sync\Queue $queue Queue object.
	 *
	 * @return boolean|WP_Error True if spawned, WP_Error otherwise.
	 */
	public static function spawn_sync( $queue ) {
		if ( ! Settings::is_dedicated_sync_enabled() ) {
			return new WP_Error( 'dedicated_sync_disabled', 'Dedicated Sync flow is disabled.' );
		}

		if ( $queue->is_locked() ) {
			return new WP_Error( 'locked_queue_' . $queue->id );
		}

		if ( $queue->size() === 0 ) {
			return new WP_Error( 'empty_queue_' . $queue->id );
		}

		// Return early if we've gotten a retry-after header response that is not expired.
		$retry_time = get_option( Actions::RETRY_AFTER_PREFIX . $queue->id );
		if ( $retry_time && $retry_time >= microtime( true ) ) {
			return new WP_Error( 'retry_after_' . $queue->id );
		}

		// Don't sync if we are throttled.
		$sync_next_time = Sender::get_instance()->get_next_sync_time( $queue->id );
		if ( $sync_next_time > microtime( true ) ) {
			return new WP_Error( 'sync_throttled_' . $queue->id );
		}
		/**
		 * How much time to wait before we start suspecting Dedicated Sync is in trouble.
		 */
		$queue_send_time_threshold = 30 * MINUTE_IN_SECONDS;

		$queue_lag = $queue->lag();

		/**
		 * Try to acquire a request lock, so we don't spawn multiple requests at the same time.
		 * This should prevent cases where sites might have limits on the amount of simultaneous requests.
		 */
		$request_lock = self::try_lock_spawn_request();
		if ( ! $request_lock ) {
			return new WP_Error( 'dedicated_request_lock', 'Unable to acquire request lock' );
		}

		/**
		 * If the queue lag is bigger than the threshold, we want to check if Dedicated Sync is working correctly.
		 * We will do by sending a test request and disabling Dedicated Sync if it's not working. We will also exit early
		 * in case we send the test request since it is a blocking request.
		 */
		if ( $queue_lag > $queue_send_time_threshold ) {
			if ( false === get_transient( self::DEDICATED_SYNC_CHECK_TRANSIENT ) ) {
				if ( ! self::can_spawn_dedicated_sync_request() ) {
					self::on_dedicated_sync_lag_not_sending_threshold_reached();
					return new WP_Error( 'dedicated_sync_not_sending', 'Dedicated Sync is not successfully sending events' );
				}
				return true;
			}
		}

		$url = rest_url( 'jetpack/v4/sync/spawn-sync' );
		$url = add_query_arg( 'time', time(), $url ); // Enforce Cache busting.
		$url = add_query_arg( self::DEDICATED_SYNC_REQUEST_LOCK_QUERY_PARAM_NAME, $request_lock, $url );

		$args = array(
			'cookies'   => $_COOKIE,
			'blocking'  => false,
			'timeout'   => 0.01,
			/** This filter is documented in wp-includes/class-wp-http-streams.php */
			'sslverify' => apply_filters( 'https_local_ssl_verify', false ),
		);

		$result = wp_remote_get( $url, $args );
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return true;
	}

	/**
	 * Attempt to acquire a request lock.
	 *
	 * To avoid spawning multiple requests at the same time, we need to have a quick lock that will
	 * allow only a single request to continue if we try to spawn multiple at the same time.
	 *
	 * @return false|mixed|string
	 */
	public static function try_lock_spawn_request() {
		$current_microtime = (string) microtime( true );

		if ( wp_using_ext_object_cache() ) {
			if ( true !== wp_cache_add( self::DEDICATED_SYNC_REQUEST_LOCK_OPTION_NAME, $current_microtime, 'jetpack', self::DEDICATED_SYNC_REQUEST_LOCK_TIMEOUT ) ) {
				// Cache lock has been claimed already.
				return false;
			}
		}

		$current_lock_value = \Jetpack_Options::get_raw_option( self::DEDICATED_SYNC_REQUEST_LOCK_OPTION_NAME, null );

		if ( ! empty( $current_lock_value ) ) {
			// Check if time has passed to overwrite the lock - min 5s?
			if ( is_numeric( $current_lock_value ) && ( ( $current_microtime - $current_lock_value ) < self::DEDICATED_SYNC_REQUEST_LOCK_TIMEOUT ) ) {
				// Still in previous lock, quit
				return false;
			}

			// If the value is not numeric (float/current time), we want to just overwrite it and continue.
		}

		// Update. We don't want it to autoload, as we want to fetch it right before the checks.
		\Jetpack_Options::update_raw_option( self::DEDICATED_SYNC_REQUEST_LOCK_OPTION_NAME, $current_microtime, false );
		// Give some time for the update to happen
		usleep( wp_rand( 1000, 3000 ) );

		$updated_value = \Jetpack_Options::get_raw_option( self::DEDICATED_SYNC_REQUEST_LOCK_OPTION_NAME, null );

		if ( $updated_value === $current_microtime ) {
			return $current_microtime;
		}

		return false;
	}

	/**
	 * Attempt to release the request lock.
	 *
	 * @param string $lock_id The request lock that's currently being held.
	 *
	 * @return bool|WP_Error
	 */
	public static function try_release_lock_spawn_request( $lock_id = '' ) {
		// Try to get the lock_id from the current request if it's not supplied.
		if ( empty( $lock_id ) ) {
			$lock_id = self::get_request_lock_id_from_request();
		}

		// If it's still not a valid lock_id, throw an error and let the lock process figure it out.
		if ( empty( $lock_id ) || ! is_numeric( $lock_id ) ) {
			return new WP_Error( 'dedicated_request_lock_invalid', 'Invalid lock_id supplied for unlock' );
		}

		if ( wp_using_ext_object_cache() ) {
			if ( (string) $lock_id === wp_cache_get( self::DEDICATED_SYNC_REQUEST_LOCK_OPTION_NAME, 'jetpack', true ) ) {
				wp_cache_delete( self::DEDICATED_SYNC_REQUEST_LOCK_OPTION_NAME, 'jetpack' );
			}
		}

		// If this is the flow that has the lock, let's release it so we can spawn other requests afterwards
		$current_lock_value = \Jetpack_Options::get_raw_option( self::DEDICATED_SYNC_REQUEST_LOCK_OPTION_NAME, null );
		if ( (string) $lock_id === $current_lock_value ) {
			\Jetpack_Options::delete_raw_option( self::DEDICATED_SYNC_REQUEST_LOCK_OPTION_NAME );
			return true;
		}

		return false;
	}

	/**
	 * Try to get the request lock id from the current request.
	 *
	 * @return array|string|string[]|null
	 */
	public static function get_request_lock_id_from_request() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( ! isset( $_GET[ self::DEDICATED_SYNC_REQUEST_LOCK_QUERY_PARAM_NAME ] ) || ! is_numeric( $_GET[ self::DEDICATED_SYNC_REQUEST_LOCK_QUERY_PARAM_NAME ] ) ) {
			return null;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		return wp_unslash( $_GET[ self::DEDICATED_SYNC_REQUEST_LOCK_QUERY_PARAM_NAME ] );
	}

	/**
	 * Test Sync spawning functionality by making a request to the
	 * Sync spawning endpoint and storing the result (status code) in a transient.
	 *
	 * @since 1.34.0
	 *
	 * @return bool True if we got a successful response, false otherwise.
	 */
	public static function can_spawn_dedicated_sync_request() {
		$dedicated_sync_check_transient = self::DEDICATED_SYNC_CHECK_TRANSIENT;

		$dedicated_sync_response_body = get_transient( $dedicated_sync_check_transient );

		if ( false === $dedicated_sync_response_body ) {
			$url  = rest_url( 'jetpack/v4/sync/spawn-sync' );
			$url  = add_query_arg( 'time', time(), $url ); // Enforce Cache busting.
			$args = array(
				'cookies'   => $_COOKIE,
				'timeout'   => 30,
				/** This filter is documented in wp-includes/class-wp-http-streams.php */
				'sslverify' => apply_filters( 'https_local_ssl_verify', false ),
			);

			$response                     = wp_remote_get( $url, $args );
			$dedicated_sync_response_code = wp_remote_retrieve_response_code( $response );
			$dedicated_sync_response_body = trim( wp_remote_retrieve_body( $response ) );

			/**
			 * Limit the size of the body that we save in the transient to avoid cases where an error
			 * occurs and a whole generated HTML page is returned. We don't need to store the whole thing.
			 *
			 * The regexp check is done to make sure we can detect the string even if the body returns some additional
			 * output, like some caching plugins do when they try to pad the request.
			 */
			$regexp = '!' . preg_quote( self::DEDICATED_SYNC_VALIDATION_STRING, '!' ) . '!uis';
			if ( preg_match( $regexp, $dedicated_sync_response_body ) ) {
				$saved_response_body = self::DEDICATED_SYNC_VALIDATION_STRING;
			} else {
				$saved_response_body = time();
			}

			set_transient( $dedicated_sync_check_transient, $saved_response_body, HOUR_IN_SECONDS );

			// Send a bit more information to WordPress.com to help debugging issues.
			if ( $saved_response_body !== self::DEDICATED_SYNC_VALIDATION_STRING ) {
				$data = array(
					'timestamp'      => microtime( true ),
					'response_code'  => $dedicated_sync_response_code,
					'response_body'  => $dedicated_sync_response_body,

					// Send the flow type that was attempted.
					'sync_flow_type' => 'dedicated',
				);

				$sender = Sender::get_instance();

				$sender->send_action( 'jetpack_sync_flow_error_enable', $data );
			}
		}
		return self::DEDICATED_SYNC_VALIDATION_STRING === $dedicated_sync_response_body;
	}

	/**
	 * Disable dedicated sync and set a transient to prevent re-enabling it for some time.
	 *
	 * @return void
	 */
	public static function on_dedicated_sync_lag_not_sending_threshold_reached() {
		set_transient( self::DEDICATED_SYNC_TEMPORARY_DISABLE_FLAG, true, 6 * HOUR_IN_SECONDS );

		Settings::update_settings(
			array(
				'dedicated_sync_enabled' => 0,
			)
		);

		// Inform that we had to temporarily disable Dedicated Sync
		$data = array(
			'timestamp'      => microtime( true ),

			// Send the flow type that was attempted.
			'sync_flow_type' => 'dedicated',
		);

		$sender = Sender::get_instance();

		$sender->send_action( 'jetpack_sync_flow_error_temp_disable', $data );
	}

	/**
	 * Disable or enable Dedicated Sync sender based on the header value returned from WordPress.com
	 *
	 * @param string $dedicated_sync_header The Dedicated Sync header value - `on` or `off`.
	 *
	 * @return bool Whether Dedicated Sync is going to be enabled or not.
	 */
	public static function maybe_change_dedicated_sync_status_from_wpcom_header( $dedicated_sync_header ) {
		$dedicated_sync_enabled = 'on' === $dedicated_sync_header ? 1 : 0;

		// Prevent enabling of Dedicated sync via header flag if we're in an autoheal timeout.
		if ( $dedicated_sync_enabled ) {
			$check_transient = get_transient( self::DEDICATED_SYNC_TEMPORARY_DISABLE_FLAG );

			if ( $check_transient ) {
				// Something happened and Dedicated Sync should not be automatically re-enabled.
				return false;
			}
		}

		Settings::update_settings(
			array(
				'dedicated_sync_enabled' => $dedicated_sync_enabled,
			)
		);

		return Settings::is_dedicated_sync_enabled();
	}
}
