<?php
/**
 * The plugin cache-control class for X-LiteSpeed-Cache-Control.
 *
 * Provides helpers for determining cacheability, emitting cache-control headers,
 * and honoring various LiteSpeed Cache configuration options.
 *
 * @package     LiteSpeed
 * @since       1.1.3
 */

namespace LiteSpeed;

defined( 'WPINC' ) || exit();

/**
 * Class Control
 *
 * Handles cache-control flags, TTL calculation, redirection checks,
 * role-based exclusions, and final header output.
 */
class Control extends Root {

	const LOG_TAG = '💵';

	const BM_CACHEABLE        = 1;
	const BM_PRIVATE          = 2;
	const BM_SHARED           = 4;
	const BM_NO_VARY          = 8;
	const BM_FORCED_CACHEABLE = 32;
	const BM_PUBLIC_FORCED    = 64;
	const BM_STALE            = 128;
	const BM_NOTCACHEABLE     = 256;

	const X_HEADER = 'X-LiteSpeed-Cache-Control';

	/**
	 * Bitmask control flags for current request.
	 *
	 * @var int
	 */
	protected static $_control = 0;

	/**
	 * Custom TTL for current request (seconds).
	 *
	 * @var int
	 */
	protected static $_custom_ttl = 0;

	/**
	 * Mapping of HTTP status codes to custom TTLs.
	 *
	 * @var array<string,int|string>
	 */
	private $_response_header_ttls = [];

	/**
	 * Init cache control.
	 *
	 * @since  1.6.2
	 * @return void
	 */
	public function init() {
		/**
		 * Add vary filter for Role Excludes.
		 *
		 * @since  1.6.2
		 */
		add_filter( 'litespeed_vary', [ $this, 'vary_add_role_exclude' ] );

		// 301 redirect hook.
		add_filter( 'wp_redirect', [ $this, 'check_redirect' ], 10, 2 );

		// Load response header conf.
		$this->_response_header_ttls = $this->conf( Base::O_CACHE_TTL_STATUS );
		foreach ( $this->_response_header_ttls as $k => $v ) {
			$v = explode( ' ', $v );
			if ( empty( $v[0] ) || empty( $v[1] ) ) {
				continue;
			}
			$this->_response_header_ttls[ $v[0] ] = $v[1];
		}

		if ( $this->conf( Base::O_PURGE_STALE ) ) {
			$this->set_stale();
		}
	}

	/**
	 * Exclude role from optimization filter.
	 *
	 * @since  1.6.2
	 * @access public
	 *
	 * @param array<string,mixed> $vary Existing vary map.
	 * @return array<string,mixed>
	 */
	public function vary_add_role_exclude( $vary ) {
		if ( $this->in_cache_exc_roles() ) {
			$vary['role_exclude_cache'] = 1;
		}

		return $vary;
	}

	/**
	 * Check if one user role is in exclude cache group settings.
	 *
	 * @since 1.6.2
	 * @since 3.0 Moved here from conf.cls
	 * @access public
	 *
	 * @param  string|null $role The user role.
	 * @return string|false      Comma-separated roles if set, otherwise false.
	 */
	public function in_cache_exc_roles( $role = null ) {
		// Get user role.
		if ( null === $role ) {
			$role = Router::get_role();
		}

		if ( ! $role ) {
			return false;
		}

		$roles = explode( ',', $role );
		$found = array_intersect( $roles, $this->conf( Base::O_CACHE_EXC_ROLES ) );

		return $found ? implode( ',', $found ) : false;
	}

	/**
	 * 1. Initialize cacheable status for `wp` hook
	 * 2. Hook error page tags for cacheable pages
	 *
	 * @since 1.1.3
	 * @access public
	 * @return void
	 */
	public function init_cacheable() {
		// Hook `wp` to mark default cacheable status.
		// NOTE: Any process that does NOT run into `wp` hook will not get cacheable by default.
		add_action( 'wp', [ $this, 'set_cacheable' ], 5 );

		// Hook WP REST to be cacheable.
		if ( $this->conf( Base::O_CACHE_REST ) ) {
			add_action( 'rest_api_init', [ $this, 'set_cacheable' ], 5 );
		}

		// AJAX cache.
		$ajax_cache = $this->conf( Base::O_CACHE_AJAX_TTL );
		foreach ( $ajax_cache as $v ) {
			$v = explode( ' ', $v );
			if ( empty( $v[0] ) || empty( $v[1] ) ) {
				continue;
			}
			add_action(
				'wp_ajax_nopriv_' . $v[0],
				function () use ( $v ) {
					self::set_custom_ttl( $v[1] );
					self::force_cacheable( 'ajax Cache setting for action ' . $v[0] );
				},
				4
			);
		}

		// Check error page.
		add_filter( 'status_header', [ $this, 'check_error_codes' ], 10, 2 );
	}

	/**
	 * Check if the page returns any error code.
	 *
	 * @since 1.0.13.1
	 * @access public
	 *
	 * @param string $status_header Status header.
	 * @param int    $code          HTTP status code.
	 * @return string               Original status header.
	 */
	public function check_error_codes( $status_header, $code ) {
		if ( array_key_exists( $code, $this->_response_header_ttls ) ) {
			if ( self::is_cacheable() && ! $this->_response_header_ttls[ $code ] ) {
				self::set_nocache( '[Ctrl] TTL is set to no cache [status_header] ' . $code );
			}

			// Set TTL.
			self::set_custom_ttl( $this->_response_header_ttls[ $code ] );
		} elseif ( self::is_cacheable() ) {
			$first = substr( $code, 0, 1 );
			if ( '4' === $first || '5' === $first ) {
				self::set_nocache( '[Ctrl] 4xx/5xx default to no cache [status_header] ' . $code );
			}
		}

		// Set cache tag.
		if ( in_array( $code, Tag::$error_code_tags, true ) ) {
			Tag::add( Tag::TYPE_HTTP . $code );
		}

		// Give the default status_header back.
		return $status_header;
	}

	/**
	 * Set no vary setting.
	 *
	 * @access public
	 * @since 1.1.3
	 * @return void
	 */
	public static function set_no_vary() {
		if ( self::is_no_vary() ) {
			return;
		}
		self::$_control |= self::BM_NO_VARY;
		self::debug( 'X Cache_control -> no-vary', 3 );
	}

	/**
	 * Get no vary setting.
	 *
	 * @access public
	 * @since 1.1.3
	 * @return bool
	 */
	public static function is_no_vary() {
		return self::$_control & self::BM_NO_VARY;
	}

	/**
	 * Set stale.
	 *
	 * @access public
	 * @since 1.1.3
	 * @return void
	 */
	public function set_stale() {
		if ( self::is_stale() ) {
			return;
		}
		self::$_control |= self::BM_STALE;
		self::debug( 'X Cache_control -> stale' );
	}

	/**
	 * Get stale.
	 *
	 * @access public
	 * @since 1.1.3
	 * @return bool
	 */
	public static function is_stale() {
		return self::$_control & self::BM_STALE;
	}

	/**
	 * Set cache control to shared private.
	 *
	 * @access public
	 * @since 1.1.3
	 *
	 * @param string|false $reason The reason to mark shared, or false.
	 * @return void
	 */
	public static function set_shared( $reason = false ) {
		if ( self::is_shared() ) {
			return;
		}
		self::$_control |= self::BM_SHARED;
		self::set_private();

		if ( ! is_string( $reason ) ) {
			$reason = false;
		}

		if ( $reason ) {
			$reason = "( $reason )";
		}
		self::debug( 'X Cache_control -> shared ' . $reason );
	}

	/**
	 * Check if is shared private.
	 *
	 * @access public
	 * @since 1.1.3
	 * @return bool
	 */
	public static function is_shared() {
		return (bool) ( self::$_control & self::BM_SHARED ) && self::is_private();
	}

	/**
	 * Set cache control to forced public.
	 *
	 * @access public
	 * @since 1.7.1
	 *
	 * @param string|false $reason Reason text or false.
	 * @return void
	 */
	public static function set_public_forced( $reason = false ) {
		if ( self::is_public_forced() ) {
			return;
		}
		self::$_control |= self::BM_PUBLIC_FORCED;

		if ( ! is_string( $reason ) ) {
			$reason = false;
		}

		if ( $reason ) {
			$reason = "( $reason )";
		}
		self::debug( 'X Cache_control -> public forced ' . $reason );
	}

	/**
	 * Check if is public forced.
	 *
	 * @access public
	 * @since 1.7.1
	 * @return bool
	 */
	public static function is_public_forced() {
		return self::$_control & self::BM_PUBLIC_FORCED;
	}

	/**
	 * Set cache control to private.
	 *
	 * @access public
	 * @since 1.1.3
	 *
	 * @param string|false $reason The reason to set private.
	 * @return void
	 */
	public static function set_private( $reason = false ) {
		if ( self::is_private() ) {
			return;
		}
		self::$_control |= self::BM_PRIVATE;

		if ( ! is_string( $reason ) ) {
			$reason = false;
		}

		if ( $reason ) {
			$reason = "( $reason )";
		}
		self::debug( 'X Cache_control -> private ' . $reason );
	}

	/**
	 * Check if is private.
	 *
	 * @access public
	 * @since 1.1.3
	 * @return bool
	 */
	public static function is_private() {
		// if ( defined( 'LITESPEED_GUEST' ) && LITESPEED_GUEST ) {
			// return false;
		// }

		return (bool) ( self::$_control & self::BM_PRIVATE ) && ! self::is_public_forced();
	}

	/**
	 * Initialize cacheable status in `wp` hook, if not call this, by default it will be non-cacheable.
	 *
	 * @access public
	 * @since 1.1.3
	 *
	 * @param string|false $reason Reason text or false.
	 * @return void
	 */
	public function set_cacheable( $reason = false ) {
		self::$_control |= self::BM_CACHEABLE;

		if ( ! is_string( $reason ) ) {
			$reason = false;
		}

		if ( $reason ) {
			$reason = ' [reason] ' . $reason;
		}
		self::debug( 'Cache_control init on' . $reason );
	}

	/**
	 * This will disable non-cacheable BM.
	 *
	 * @access public
	 * @since 2.2
	 *
	 * @param string|false $reason Reason text or false.
	 * @return void
	 */
	public static function force_cacheable( $reason = false ) {
		self::$_control |= self::BM_FORCED_CACHEABLE;

		if ( ! is_string( $reason ) ) {
			$reason = false;
		}

		if ( $reason ) {
			$reason = ' [reason] ' . $reason;
		}
		self::debug( 'Forced cacheable' . $reason );
	}

	/**
	 * Switch to nocacheable status.
	 *
	 * @access public
	 * @since 1.1.3
	 *
	 * @param string|false $reason The reason to no cache.
	 * @return void
	 */
	public static function set_nocache( $reason = false ) {
		self::$_control |= self::BM_NOTCACHEABLE;

		if ( ! is_string( $reason ) ) {
			$reason = false;
		}

		if ( $reason ) {
			$reason = "( $reason )";
		}
		self::debug( 'X Cache_control -> no Cache ' . $reason, 5 );
	}

	/**
	 * Check current notcacheable bit set.
	 *
	 * @access public
	 * @since 1.1.3
	 * @return bool True if notcacheable bit is set, otherwise false.
	 */
	public static function isset_notcacheable() {
		return self::$_control & self::BM_NOTCACHEABLE;
	}

	/**
	 * Check current force cacheable bit set.
	 *
	 * @access public
	 * @since   2.2
	 * @return bool
	 */
	public static function is_forced_cacheable() {
		return self::$_control & self::BM_FORCED_CACHEABLE;
	}

	/**
	 * Check current cacheable status.
	 *
	 * @access public
	 * @since 1.1.3
	 * @return bool True if is still cacheable, otherwise false.
	 */
	public static function is_cacheable() {
		if ( defined( 'LSCACHE_NO_CACHE' ) && LSCACHE_NO_CACHE ) {
			self::debug( 'LSCACHE_NO_CACHE constant defined' );
			return false;
		}

		// Guest mode always cacheable
		// if ( defined( 'LITESPEED_GUEST' ) && LITESPEED_GUEST ) {
			// return true;
		// }

		// If it's forced public cacheable.
		if ( self::is_public_forced() ) {
			return true;
		}

		// If it's forced cacheable.
		if ( self::is_forced_cacheable() ) {
			return true;
		}

		return ! self::isset_notcacheable() && ( self::$_control & self::BM_CACHEABLE );
	}

	/**
	 * Set a custom TTL to use with the request if needed.
	 *
	 * @access public
	 * @since 1.1.3
	 *
	 * @param int|string   $ttl    An integer or numeric string to use as the TTL.
	 * @param string|false $reason Optional reason text.
	 * @return void
	 */
	public static function set_custom_ttl( $ttl, $reason = false ) {
		if ( is_numeric( $ttl ) ) {
			self::$_custom_ttl = (int) $ttl;
			self::debug( 'X Cache_control TTL -> ' . $ttl . ( $reason ? ' [reason] ' . $ttl : '' ) );
		}
	}

	/**
	 * Generate final TTL.
	 *
	 * @access public
	 * @since 1.1.3
	 * @return int
	 */
	public function get_ttl() {
		if ( 0 !== self::$_custom_ttl ) {
			return (int) self::$_custom_ttl;
		}

		// Check if is in timed url list or not.
		$timed_urls      = Utility::wildcard2regex( $this->conf( Base::O_PURGE_TIMED_URLS ) );
		$timed_urls_time = $this->conf( Base::O_PURGE_TIMED_URLS_TIME );
		if ( $timed_urls && $timed_urls_time ) {
			$current_url = Tag::build_uri_tag( true );
			// Use time limit ttl.
			$scheduled_time = strtotime( $timed_urls_time );
			$ttl            = $scheduled_time - current_time('timestamp'); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp
			if ( $ttl < 0 ) {
				$ttl += 86400; // add one day
			}
			foreach ( $timed_urls as $v ) {
				if ( false !== strpos( $v, '*' ) ) {
					if ( preg_match( '#' . $v . '#iU', $current_url ) ) {
						self::debug( 'X Cache_control TTL is limited to ' . $ttl . ' due to scheduled purge regex ' . $v );
						return $ttl;
					}
				} elseif ( $v === $current_url ) {
					self::debug( 'X Cache_control TTL is limited to ' . $ttl . ' due to scheduled purge rule ' . $v );
					return $ttl;
				}
			}
		}

		// Private cache uses private ttl setting.
		if ( self::is_private() ) {
			return (int) $this->conf( Base::O_CACHE_TTL_PRIV );
		}

		if ( is_front_page() ) {
			return (int) $this->conf( Base::O_CACHE_TTL_FRONTPAGE );
		}

		$feed_ttl = (int) $this->conf( Base::O_CACHE_TTL_FEED );
		if ( is_feed() && $feed_ttl > 0 ) {
			return $feed_ttl;
		}

		if ( $this->cls( 'REST' )->is_rest() || $this->cls( 'REST' )->is_internal_rest() ) {
			return (int) $this->conf( Base::O_CACHE_TTL_REST );
		}

		return (int) $this->conf( Base::O_CACHE_TTL_PUB );
	}

	/**
	 * Check if need to set no cache status for redirection or not.
	 *
	 * @access public
	 * @since 1.1.3
	 *
	 * @param string $location Redirect location.
	 * @param int    $status   HTTP status.
	 * @return string          Redirect location.
	 */
	public function check_redirect( $location, $status ) {
		$script_uri = '';
		if ( !empty( $_SERVER['SCRIPT_URI'] ) ) {
			$script_uri = sanitize_text_field( wp_unslash( $_SERVER['SCRIPT_URI'] ) );
		} elseif ( !empty( $_SERVER['REQUEST_URI'] ) ) {
			$home       = trailingslashit( home_url() );
			$script_uri = $home . ltrim( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), '/' );
		}

		if ( '' !== $script_uri ) {
			self::debug( '301 from ' . $script_uri );
			self::debug( '301 to ' . $location );

			$to_check = [ PHP_URL_SCHEME, PHP_URL_HOST, PHP_URL_PATH, PHP_URL_QUERY ];

			$is_same_redirect = true;

			$query_string = ! empty( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '';
			foreach ( $to_check as $v ) {
				$url_parsed = PHP_URL_QUERY === $v ? $query_string : wp_parse_url( $script_uri, $v );

				$target = wp_parse_url( $location, $v );

				self::debug( 'Compare [from] ' . $url_parsed . ' [to] ' . $target );

				if ( PHP_URL_QUERY === $v ) {
					$url_parsed = $url_parsed ? urldecode( $url_parsed ) : '';
					$target     = $target ? urldecode( $target ) : '';
					if ( '&' === substr( $url_parsed, -1 ) ) {
						$url_parsed = substr( $url_parsed, 0, -1 );
					}
				}

				if ( $url_parsed !== $target ) {
					$is_same_redirect = false;
					self::debug( '301 different redirection' );
					break;
				}
			}

			if ( $is_same_redirect ) {
				self::set_nocache( '301 to same url' );
			}
		}

		return $location;
	}

	/**
	 * Sets up the Cache Control header.
	 *
	 * @since 1.1.3
	 * @access public
	 * @return string empty string if empty, otherwise the cache control header.
	 */
	public function output() {
		$esi_hdr = '';
		if ( ESI::has_esi() ) {
			$esi_hdr = ',esi=on';
		}

		$hdr = self::X_HEADER . ': ';

		// phpcs:ignore WordPress.NamingConventions.ValidHookName.NotLowercase
		if ( defined( 'DONOTCACHEPAGE' ) && apply_filters( 'litespeed_const_DONOTCACHEPAGE', DONOTCACHEPAGE ) ) {
			self::debug( '❌ forced no cache [reason] DONOTCACHEPAGE const' );
			$hdr .= 'no-cache' . $esi_hdr;
			return $hdr;
		}

		// Guest mode directly return cacheable result
		// if ( defined( 'LITESPEED_GUEST' ) && LITESPEED_GUEST ) {
		// If is POST, no cache
		// if ( defined( 'LSCACHE_NO_CACHE' ) && LSCACHE_NO_CACHE ) {
		// self::debug( "[Ctrl] ❌ forced no cache [reason] LSCACHE_NO_CACHE const" );
		// $hdr .= 'no-cache';
		// }
		// else if( $_SERVER[ 'REQUEST_METHOD' ] !== 'GET' ) {
		// self::debug( "[Ctrl] ❌ forced no cache [reason] req not GET" );
		// $hdr .= 'no-cache';
		// }
		// else {
		// $hdr .= 'public';
		// $hdr .= ',max-age=' . $this->get_ttl();
		// }

		// $hdr .= $esi_hdr;

		// return $hdr;
		// }

		// Fix cli `uninstall --deactivate` fatal err

		if (!self::is_cacheable()) {
			$hdr .= 'no-cache' . $esi_hdr;
			return $hdr;
		}

		if ( self::is_shared() ) {
			$hdr .= 'shared,private';
		} elseif ( self::is_private() ) {
			$hdr .= 'private';
		} else {
			$hdr .= 'public';
		}

		if ( self::is_no_vary() ) {
			$hdr .= ',no-vary';
		}

		$hdr .= ',max-age=' . $this->get_ttl() . $esi_hdr;
		return $hdr;
	}

	/**
	 * Generate all `control` tags before output.
	 *
	 * @access public
	 * @since 1.1.3
	 * @return void
	 */
	public function finalize() {
		// if ( defined( 'LITESPEED_GUEST' ) && LITESPEED_GUEST ) {
			// return;
		// }

		if ( is_preview() ) {
			self::set_nocache( 'preview page' );
			return;
		}

		// Check if has metabox non-cacheable setting or not.
		if ( file_exists( LSCWP_DIR . 'src/metabox.cls.php' ) && $this->cls( 'Metabox' )->setting( 'litespeed_no_cache' ) ) {
			self::set_nocache( 'per post metabox setting' );
			return;
		}

		// Check if URI is forced public cache.
		$excludes = $this->conf( Base::O_CACHE_FORCE_PUB_URI );
		$req_uri  = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
		$hit      = Utility::str_hit_array( $req_uri, $excludes, true );
		if ( $hit ) {
			list( $result, $this_ttl ) = $hit;
			self::set_public_forced( 'Setting: ' . $result );
			self::debug( 'Forced public cacheable due to setting: ' . $result );
			if ( $this_ttl ) {
				self::set_custom_ttl( $this_ttl );
			}
		}

		if ( self::is_public_forced() ) {
			return;
		}

		// Check if URI is forced cache.
		$excludes = $this->conf( Base::O_CACHE_FORCE_URI );
		$hit      = Utility::str_hit_array( $req_uri, $excludes, true );
		if ( $hit ) {
			list( $result, $this_ttl ) = $hit;
			self::force_cacheable();
			self::debug( 'Forced cacheable due to setting: ' . $result );
			if ( $this_ttl ) {
				self::set_custom_ttl( $this_ttl );
			}
		}

		// if is not cacheable, terminate check.
		// Even no need to run 3rd party hook.
		if ( ! self::is_cacheable() ) {
			self::debug( 'not cacheable before ctrl finalize' );
			return;
		}

		// Apply 3rd party filter.
		// NOTE: Hook always needs to run asap because some 3rd party set is_mobile in this hook.
		do_action( 'litespeed_control_finalize', defined( 'LSCACHE_IS_ESI' ) ? LSCACHE_IS_ESI : false ); // Pass ESI block id.

		// if is not cacheable, terminate check.
		if ( ! self::is_cacheable() ) {
			self::debug( 'not cacheable after api_control' );
			return;
		}

		// Check litespeed setting to set cacheable status.
		if ( ! $this->_setting_cacheable() ) {
			self::set_nocache();
			return;
		}

		// If user has password cookie, do not cache (moved from vary).
		global $post;
		if ( ! empty( $post->post_password ) && isset( $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] ) ) {
			self::set_nocache( 'pswd cookie' );
			return;
		}

		// The following check to the end is ONLY for mobile.
		$is_mobile_conf = apply_filters( 'litespeed_is_mobile', false );
		if ( ! $this->conf( Base::O_CACHE_MOBILE ) ) {
			if ( $is_mobile_conf ) {
				self::set_nocache( 'mobile' );
			}
			return;
		}

		$env_vary = isset( $_SERVER['LSCACHE_VARY_VALUE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['LSCACHE_VARY_VALUE'] ) ) : '';
		if ( !$env_vary && isset( $_SERVER['HTTP_X_LSCACHE_VARY_VALUE'] ) ) {
			$env_vary = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_LSCACHE_VARY_VALUE'] ) );
		}
		if ( $env_vary && false !== strpos( $env_vary, 'ismobile' ) ) {
			if ( ! wp_is_mobile() && ! $is_mobile_conf ) {
				self::set_nocache( 'is not mobile' ); // todo: no need to uncache, it will correct vary value in vary finalize anyways.
				return;
			}
		} elseif ( wp_is_mobile() || $is_mobile_conf ) {
			self::set_nocache( 'is mobile' );
			return;
		}
	}

	/**
	 * Check if is mobile for filter `litespeed_is_mobile` in API.
	 *
	 * @since 3.0
	 * @access public
	 * @return bool
	 */
	public static function is_mobile() {
		return wp_is_mobile();
	}

	/**
	 * Get request method w/ compatibility to X-Http-Method-Override.
	 *
	 * @since 6.2
	 * @return string
	 */
	private function _get_req_method() {
		if ( isset( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ) ) {
			$override = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ) );
			self::debug( 'X-Http-Method-Override -> ' . $override );
			if ( ! defined( 'LITESPEED_X_HTTP_METHOD_OVERRIDE' ) ) {
				define( 'LITESPEED_X_HTTP_METHOD_OVERRIDE', true );
			}
			return $override;
		}
		if ( isset( $_SERVER['REQUEST_METHOD'] ) ) {
			return sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) );
		}
		return 'unknown';
	}

	/**
	 * Check if a page is cacheable based on litespeed setting.
	 *
	 * @since 1.0.0
	 * @access private
	 * @return bool True if cacheable, false otherwise.
	 */
	private function _setting_cacheable() {
		// logged_in users already excluded, no hook added.

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( ! empty( $_REQUEST[ Router::ACTION ] ) ) {
			return $this->_no_cache_for( 'Query String Action' );
		}

		$method = $this->_get_req_method();
		if ( defined( 'LITESPEED_X_HTTP_METHOD_OVERRIDE' ) && LITESPEED_X_HTTP_METHOD_OVERRIDE && 'HEAD' === $method ) {
			return $this->_no_cache_for( 'HEAD method from override' );
		}
		if ( 'GET' !== $method && 'HEAD' !== $method ) {
			return $this->_no_cache_for( 'Not GET method: ' . $method );
		}

		if ( is_feed() && 0 === $this->conf( Base::O_CACHE_TTL_FEED ) ) {
			return $this->_no_cache_for( 'feed' );
		}

		if ( is_trackback() ) {
			return $this->_no_cache_for( 'trackback' );
		}

		if ( is_search() ) {
			return $this->_no_cache_for( 'search' );
		}

		// Check private cache URI setting.
		$excludes = $this->conf( Base::O_CACHE_PRIV_URI );
		$req_uri  = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
		$result   = Utility::str_hit_array( $req_uri, $excludes );
		if ( $result ) {
			self::set_private( 'Admin cfg Private Cached URI: ' . $result );
		}

		if ( ! self::is_forced_cacheable() ) {
			// Check if URI is excluded from cache.
			$excludes = $this->cls( 'Data' )->load_cache_nocacheable( $this->conf( Base::O_CACHE_EXC ) );
			$result   = Utility::str_hit_array( $req_uri, $excludes );
			if ( $result ) {
				return $this->_no_cache_for( 'Admin configured URI Do not cache: ' . $result );
			}

			// Check QS excluded setting.
			$excludes = $this->conf( Base::O_CACHE_EXC_QS );
			$qs_hit   = $this->_is_qs_excluded( $excludes );
			if ( ! empty( $excludes ) && $qs_hit ) {
				return $this->_no_cache_for( 'Admin configured QS Do not cache: ' . $qs_hit );
			}

			$excludes = $this->conf( Base::O_CACHE_EXC_CAT );
			if ( ! empty( $excludes ) && has_category( $excludes ) ) {
				return $this->_no_cache_for( 'Admin configured Category Do not cache.' );
			}

			$excludes = $this->conf( Base::O_CACHE_EXC_TAG );
			if ( ! empty( $excludes ) && has_tag( $excludes ) ) {
				return $this->_no_cache_for( 'Admin configured Tag Do not cache.' );
			}

			$excludes = $this->conf( Base::O_CACHE_EXC_COOKIES );
			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- names only, compared as keys.
			if ( ! empty( $excludes ) && ! empty( $_COOKIE ) ) {
				$cookie_hit = array_intersect( array_keys( $_COOKIE ), $excludes );
				if ( $cookie_hit ) {
					return $this->_no_cache_for( 'Admin configured Cookie Do not cache.' );
				}
			}

			$excludes = $this->conf( Base::O_CACHE_EXC_USERAGENTS );
			if ( ! empty( $excludes ) && isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
				$nummatches = preg_match( Utility::arr2regex( $excludes ), sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) );
				if ( $nummatches ) {
					return $this->_no_cache_for( 'Admin configured User Agent Do not cache.' );
				}
			}

			// Check if is exclude roles ( Need to set Vary too ).
			$result = $this->in_cache_exc_roles();
			if ( $result ) {
				return $this->_no_cache_for( 'Role Excludes setting ' . $result );
			}
		}

		return true;
	}

	/**
	 * Write a debug message for if a page is not cacheable.
	 *
	 * @since 1.0.0
	 * @access private
	 *
	 * @param string $reason An explanation for why the page is not cacheable.
	 * @return bool          Always false.
	 */
	private function _no_cache_for( $reason ) {
		self::debug( 'X Cache_control off - ' . $reason );
		return false;
	}

	/**
	 * Check if current request has qs excluded setting.
	 *
	 * @since  1.3
	 * @access private
	 *
	 * @param  array<int,string> $excludes QS excludes setting.
	 * @return bool|string                 False if not excluded, otherwise the hit qs list.
	 */
	private function _is_qs_excluded( $excludes ) {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( ! empty( $_GET ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$keys      = array_keys( $_GET );
			$intersect = array_intersect( $keys, $excludes );
			if ( $intersect ) {
				return implode( ',', $intersect );
			}
		}
		return false;
	}
}
