<?php
/*
 * This file may be called before WordPress is fully initialized. See the README file for info.
 */

namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress;

class Request {
	/**
	 * @var Request - The request instance for current request.
	 */
	private static $current_request = null;

	/**
	 * @var string - The normalized path for the current request. This is not sanitized. Only to be used for comparison purposes.
	 */
	private $request_uri = false;

	/**
	 * @var array - The GET parameters and cookies for the current request. Everything considered in the cache key.
	 */
	private $request_parameters;

	/**
	 * Gets the singleton request instance.
	 *
	 * @return Request The instance of the class.
	 */
	public static function current() {
		if ( self::$current_request === null ) {
			self::$current_request = new self(
				// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
				isset( $_SERVER['REQUEST_URI'] ) ? Boost_Cache_Utils::normalize_request_uri( $_SERVER['REQUEST_URI'] ) : false,
				// Set the cookies and get parameters for the current request. Sometimes these arrays are modified by WordPress or other plugins.
				// We need to cache them here so they can be used for the cache key later. We don't need to sanitize them, as they are only used for comparison.
				array(
					'cookies' => $_COOKIE, // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
					'get'     => $_GET,    // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
				)
			);
		}

		return self::$current_request;
	}

	public function __construct( $uri, $parameters ) {
		$this->request_uri        = $uri;
		$this->request_parameters = $parameters;
	}

	public function get_uri() {
		return $this->request_uri;
	}

	/**
	 * Returns the parameters for the current request.
	 *
	 * @return array The parameters for the current request, made up of cookies and get parameters.
	 */
	public function get_parameters() {
		/**
		 * Filters the parameters for the current request to identify the cache key.
		 *
		 * @since 3.8.0
		 *
		 * @param array $parameters The parameters for the current request, made up of cookies and get parameters.
		 */
		return apply_filters( 'jetpack_boost_cache_parameters', $this->request_parameters );
	}

	/**
	 * Returns true if the current request has a fatal error.
	 *
	 * @return bool
	 */
	private function is_fatal_error() {
		$error = error_get_last();
		if ( $error === null ) {
			return false;
		}

		$fatal_errors = array(
			E_ERROR,
			E_PARSE,
			E_CORE_ERROR,
			E_COMPILE_ERROR,
			E_USER_ERROR,
		);

		return in_array( $error['type'], $fatal_errors, true );
	}

	public function is_url_excluded( $request_uri = '' ) {
		if ( $request_uri === '' ) {
			$request_uri = $this->request_uri;
		}

		// Check if the query parameters `jb-disable-modules` or `jb-generate-critical-css` exist.
		$request_parameters = $this->get_parameters();
		$query_params       = isset( $request_parameters['get'] ) ? $request_parameters['get'] : array();
		if ( isset( $query_params ) &&
			( isset( $query_params['jb-disable-modules'] ) || isset( $query_params['jb-generate-critical-css'] ) )
		) {
			return true;
		}

		$bypass_patterns = Boost_Cache_Settings::get_instance()->get_bypass_patterns();

		/**
		 * Filters the bypass patterns for the page cache.
		 * If you need to sanitize them, do it before passing them to this filter,
		 * as there's no sanitization done after this filter.
		 *
		 * @since 3.2.0
		 *
		 * @param array $bypass_patterns An array of regex patterns that define URLs that bypass caching.
		 */
		$bypass_patterns = apply_filters( 'jetpack_boost_cache_bypass_patterns', $bypass_patterns );

		$bypass_patterns[] = 'wp-.*\.php';
		foreach ( $bypass_patterns as $expr ) {
			if ( ! empty( $expr ) && preg_match( "~^$expr/?$~", $request_uri ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Returns true if the request is cacheable.
	 *
	 * If a request is in the backend, or is a POST request, or is not an
	 * html request, it is not cacheable.
	 * The filter boost_cache_cacheable can be used to override this.
	 *
	 * @return bool
	 */
	public function is_cacheable() {
		/**
		 * Determines if the request is considered cacheable.
		 *
		 * Can be used to prevent a request from being cached.
		 *
		 * @since 3.2.0
		 *
		 * @param bool $default_status The default cacheability status (true for cacheable).
		 * @param string $request_uri  The request URI to be evaluated for cacheability.
		 */
		if ( ! apply_filters( 'jetpack_boost_cache_request_cacheable', true, $this->request_uri ) ) {
			return false;
		}

		if ( defined( 'DONOTCACHEPAGE' ) ) {
			return false;
		}

		// do not cache post previews or customizer previews
		if ( ! empty( $_GET ) && ( isset( $_GET['preview'] ) || isset( $_GET['customize_changeset_uuid'] ) ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
			return false;
		}

		if ( $this->is_fatal_error() ) {
			return false;
		}

		if ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() ) {
			return false;
		}

		if ( $this->is_404() ) {
			return false;
		}

		if ( $this->is_feed() ) {
			return false;
		}

		if ( $this->is_backend() ) {
			return false;
		}

		if ( $this->is_bypassed_extension() ) {
			return false;
		}

		if ( isset( $_SERVER['REQUEST_METHOD'] ) && $_SERVER['REQUEST_METHOD'] !== 'GET' ) {
			return false;
		}

		if ( $this->is_url_excluded() ) {
			Logger::debug( 'Url excluded, not cached!' );
			return false;
		}

		if ( $this->is_module_disabled() ) {
			return false;
		}

		/**
		 * Filters the accept headers to determine if the request should be cached.
		 *
		 * This filter allows modification of the content types that browsers send
		 * to the server during a request. If the acceptable browser content type header (HTTP_ACCEPT)
		 * matches one of these content types the request will not be cached,
		 * or a cached file served to this visitor.
		 *
		 * @since 3.2.0
		 *
		 * @param array $accept_headers An array of header values that should prevent a request from being cached.
		 */
		$accept_headers = apply_filters( 'jetpack_boost_cache_accept_headers', array( 'application/json', 'application/activity+json', 'application/ld+json' ) );
		$accept_headers = array_map( 'strtolower', $accept_headers );
		// phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- $accept is checked and set below.
		$accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? strtolower( filter_var( $_SERVER['HTTP_ACCEPT'] ) ) : '';

		if ( $accept !== '' ) {
			foreach ( $accept_headers as $header ) {
				if ( str_contains( $accept, $header ) ) {
					return false;
				}
			}
		}

		return true;
	}

	/**
	 * Returns true if the request appears to be for something with a known file extension that is not
	 * usually HTML. e.g.:
	 * - *.txt (including robots.txt, license.txt)
	 * - *.ico (favicon.ico)
	 * - *.jpg, *.png, *.webm (image files).
	 */
	public function is_bypassed_extension() {
		$file_extension = pathinfo( $this->request_uri, PATHINFO_EXTENSION );

		return in_array(
			$file_extension,
			array(
				'txt',
				'ico',
				'jpg',
				'jpeg',
				'png',
				'webp',
				'gif',
			),
			true
		);
	}

	/**
	 * Returns true if the current request is one of the following:
	 * 1. wp-admin
	 * 2. wp-login.php, xmlrpc.php or wp-cron.php/cron request
	 * 3. WP_CLI
	 * 4. REST request.
	 *
	 * @return bool
	 */
	public function is_backend() {

		$is_backend = is_admin();
		if ( $is_backend ) {
			return $is_backend;
		}

		$script = isset( $_SERVER['PHP_SELF'] ) ? basename( $_SERVER['PHP_SELF'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
		if ( $script !== 'index.php' ) {
			if ( in_array( $script, array( 'wp-login.php', 'xmlrpc.php', 'wp-cron.php' ), true ) ) {
				$is_backend = true;
			}
		}

		if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
			$is_backend = true;
		}

		if ( PHP_SAPI === 'cli' || ( defined( 'WP_CLI' ) && constant( 'WP_CLI' ) ) ) {
			$is_backend = true;
		}

		if ( defined( 'REST_REQUEST' ) ) {
			$is_backend = true;
		}

		return $is_backend;
	}

	/**
	 * "Safe" version of WordPress' is_404 method. When called before WordPress' query is run, returns
	 * `null` (a falsey value) instead of outputting a _doing_it_wrong warning.
	 */
	public function is_404() {
		global $wp_query;

		if ( ! isset( $wp_query ) || ! function_exists( '\is_404' ) ) {
			return null;
		}

		return \is_404();
	}

	/**
	 * "Safe" version of WordPress' is_feed method. When called before WordPress' query is run, returns
	 * `null` (a falsey value) instead of outputting a _doing_it_wrong warning.
	 */
	public function is_feed() {
		global $wp_query;

		if ( ! isset( $wp_query ) || ! function_exists( '\is_feed' ) ) {
			return null;
		}

		return \is_feed();
	}

	/**
	 * Return true if the Page Cache module is disabled, or null if we don't know yet.
	 *
	 * If Status and Page_Cache are not available, it means the plugin is not loaded.
	 * This function will be called later when writing a cache file to disk.
	 * It's then that we can check if the module is active.
	 *
	 * @return null|bool
	 */
	public function is_module_disabled() {

		// A simple check to make sure we're in the output buffer callback.
		if ( ! function_exists( '\is_feed' ) ) {
			return null;
		}

		if (
			class_exists( '\Automattic\Jetpack_Boost\Lib\Status' ) &&
			class_exists( '\Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Page_Cache' )
		) {
			$page_cache_status = new \Automattic\Jetpack_Boost\Lib\Status(
				\Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Page_Cache::get_slug()
			);
			return ! $page_cache_status->get();
		} else {
			return true; // if the classes aren't available, the plugin isn't loaded.
		}
	}
}
