<?php
/**
 * The plugin cache-control class for X-Litespeed-Cache-Control
 *
 * @since      	1.1.3
 * @package    	LiteSpeed
 * @subpackage 	LiteSpeed/inc
 * @author     	LiteSpeed Technologies <info@litespeedtech.com>
 */
namespace LiteSpeed;

defined( 'WPINC' ) || exit;

class Control extends Root {
	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';

	protected static $_control = 0;
	protected static $_custom_ttl = 0;

	private $_response_header_ttls = array();

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

		// 301 redirect hook
		add_filter( 'wp_redirect', array( $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
	 */
	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 $role The user role
	 * @return int       The set value if already set
	 */
	public function in_cache_exc_roles( $role = null ) {
		// Get user role
		if ( $role === null ) {
			$role = Router::get_role();
		}

		if ( ! $role ) {
			return false;
		}

		return in_array( $role, $this->conf( Base::O_CACHE_EXC_ROLES ) ) ? $role : false;
	}

	/**
	 * 1. Initialize cacheable status for `wp` hook
	 * 2. Hook error page tags for cacheable pages
	 *
	 * @since 1.1.3
	 * @access public
	 */
	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', array( $this, 'set_cacheable' ), 5 );

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

		// Cache resources
		// NOTE: If any strange resource doesn't use normal WP logic `wp_loaded` hook, rewrite rule can handle it
		$cache_res = $this->conf( Base::O_CACHE_RES );
		if ( $cache_res ) {
			$uri = esc_url( $_SERVER["REQUEST_URI"] );// todo: check if need esc_url()
			$pattern = '!' . LSCWP_CONTENT_FOLDER . Htaccess::RW_PATTERN_RES . '!';
			if ( preg_match( $pattern, $uri ) ) {
				add_action( 'wp_loaded', array( $this, 'set_cacheable' ), 5 );
			}
		}

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


	/**
	 * Check if the page returns any error code.
	 *
	 * @since 1.0.13.1
	 * @access public
	 * @param $status_header
	 * @param $code
	 * @return $eror_status
	 */
	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 ] );
		}

		// Set cache tag
		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
	 */
	public static function set_no_vary() {
		if ( self::is_no_vary() ) {
			return;
		}
		self::$_control |= self::BM_NO_VARY;
		Debug2::debug( '[Ctrl] X Cache_control -> no-vary', 3 );
	}

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

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

	/**
	 * Get stale
	 *
	 * @access public
	 * @since 1.1.3
	 */
	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 $reason The reason to no cache
	 */
	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 )";
		}
		Debug2::debug( '[Ctrl] X Cache_control -> shared ' . $reason );
	}

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

	/**
	 * Set cache control to forced public
	 *
	 * @access public
	 * @since 1.7.1
	 */
	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 )";
		}
		Debug2::debug( '[Ctrl] X Cache_control -> public forced ' . $reason );
	}

	/**
	 * Check if is public forced
	 *
	 * @access public
	 * @since 1.7.1
	 */
	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 $reason The reason to no cache
	 */
	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 )";
		}
		Debug2::debug( '[Ctrl] X Cache_control -> private ' . $reason );
	}

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

		return 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
	 */
	public function set_cacheable( $reason = false ) {
		self::$_control |= self::BM_CACHEABLE;

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

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

	/**
	 * This will disable non-cacheable BM
	 *
	 * @access public
	 * @since 2.2
	 */
	public static function force_cacheable( $reason = false ) {
		self::$_control |= self::BM_FORCED_CACHEABLE;

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

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

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

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

		if ( $reason ) {
			$reason = "( $reason )";
		}
		Debug2::debug( '[Ctrl] X Cache_control -> no Cache ' . $reason, 2 );
	}

	/**
	 * 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
	 */
	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 ) {
			Debug2::debug( '[Ctrl] LSCACHE_NO_CACHE constant defined' );
			return false;
		}

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

		// If its forced public cacheable
		if ( self::is_public_forced() ) {
			return true;
		}

		// If its 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 mixed $ttl An integer or string to use as the TTL. Must be numeric.
	 */
	public static function set_custom_ttl( $ttl, $reason = false ) {
		if ( is_numeric( $ttl ) ) {
			self::$_custom_ttl = $ttl;
			Debug2::debug( '[Ctrl] X Cache_control TTL -> ' . $ttl . ( $reason ? ' [reason] ' . $ttl : '' ) );
		}
	}

	/**
	 * Generate final TTL.
	 *
	 * @access public
	 * @since 1.1.3
	 */
	public function get_ttl() {
		if ( self::$_custom_ttl != 0 ) {
			return 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 - time();
			if ( $ttl < 0 ) {
				$ttl += 86400;// add one day
			}
			foreach ( $timed_urls as $v ) {
				if ( strpos( $v, '*' ) !== false ) {
					if( preg_match( '#' . $v . '#iU', $current_url ) ) {
						Debug2::debug( '[Ctrl] X Cache_control TTL is limited to ' . $ttl . ' due to scheduled purge regex ' . $v );
						return $ttl;
					}
				}
				else {
					if ( $v == $current_url ) {
						Debug2::debug( '[Ctrl] 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 $this->conf( Base::O_CACHE_TTL_PRIV );
		}

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

		$feed_ttl = $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 $this->conf( Base::O_CACHE_TTL_REST );
		}

		return $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
	 */
	public function check_redirect( $location, $status ) { // TODO: some env don't have SCRIPT_URI but only REQUEST_URI, need to be compatible
		if ( ! empty( $_SERVER[ 'SCRIPT_URI' ] ) ) { // dont check $status == '301' anymore
			Debug2::debug( "[Ctrl] 301 from " . $_SERVER[ 'SCRIPT_URI' ] );
			Debug2::debug( "[Ctrl] 301 to $location" );

			$to_check = array(
				PHP_URL_SCHEME,
				PHP_URL_HOST,
				PHP_URL_PATH,
			);

			$is_same_redirect = true;

			foreach ( $to_check as $v ) {
				if ( parse_url( $_SERVER[ 'SCRIPT_URI' ], $v ) != parse_url( $location, $v ) ) {
					$is_same_redirect = false;
					Debug2::debug( "[Ctrl] 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() {
		$hdr = self::X_HEADER . ': ';

		if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
			$hdr .= 'no-cache';
			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 ) {
				$hdr .= 'no-cache';
			}
			else if( $_SERVER[ 'REQUEST_METHOD' ] !== 'GET' ) {
				$hdr .= 'no-cache';
			}
			else {
				$hdr .= 'public';
				$hdr .= ',max-age=' . $this->get_ttl();
			}

			return $hdr;
		}

		$esi_hdr = '';
		// Fix cli `uninstall --deactivate` fatal err
		if ( ESI::has_esi() ) {
			$esi_hdr = ',esi=on';
		}

		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
	 */
	public function finalize() {
		if ( defined( 'LITESPEED_GUEST' ) && LITESPEED_GUEST ) {
			return;
		}

		// Check if URI is forced public cache
		$excludes = $this->conf( Base::O_CACHE_FORCE_PUB_URI );
		$hit =  Utility::str_hit_array( $_SERVER[ 'REQUEST_URI' ], $excludes, true );
		if ( $hit ) {
			list( $result, $this_ttl ) = $hit;
			self::set_public_forced( 'Setting: ' . $result );
			Debug2::debug( '[Ctrl] 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( $_SERVER[ 'REQUEST_URI' ], $excludes, true );
		if ( $hit ) {
			list( $result, $this_ttl ) = $hit;
			self::force_cacheable();
			Debug2::debug( '[Ctrl] 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() ) {
			Debug2::debug( '[Ctrl] 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() ) {
			Debug2::debug( '[Ctrl] not cacheable after api_control' );
			return;
		}

		if ( is_preview() ) {
			self::set_nocache( 'preview page' );
			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]) ) {
			// If user has password cookie, do not cache
			self::set_nocache('pswd cookie');
			return;
		}

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

		$env_vary = isset( $_SERVER[ 'LSCACHE_VARY_VALUE' ] ) ? $_SERVER[ 'LSCACHE_VARY_VALUE' ] : false;
		if ( ! $env_vary ) {
			$env_vary = isset( $_SERVER[ 'HTTP_X_LSCACHE_VARY_VALUE' ] ) ? $_SERVER[ 'HTTP_X_LSCACHE_VARY_VALUE' ] : false;
		}
		if ( $env_vary && strpos( $env_vary, 'ismobile' ) !== false ) {
			if ( ! wp_is_mobile() && ! $is_mobile ) {
				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 ) {
			self::set_nocache( 'is mobile' );
			return;
		}

	}

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

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

		if( ! empty( $_REQUEST[ Router::ACTION ] ) ) {
			return $this->_no_cache_for( 'Query String Action' );
		}

		if ( $_SERVER[ 'REQUEST_METHOD' ] !== 'GET' ) {
			return $this->_no_cache_for('not GET method:' . $_SERVER["REQUEST_METHOD"]);
		}

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

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

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

//		if ( !defined('WP_USE_THEMES') || !WP_USE_THEMES ) {
//			return $this->_no_cache_for('no theme used');
//		}

		// Check private cache URI setting
		$excludes = $this->conf( Base::O_CACHE_PRIV_URI );
		$result = Utility::str_hit_array( $_SERVER[ 'REQUEST_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->conf( Base::O_CACHE_EXC );
			$result =  Utility::str_hit_array( $_SERVER[ 'REQUEST_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 );
			if ( ! empty( $excludes ) && $qs = $this->_is_qs_excluded( $excludes ) ) {
				return $this->_no_cache_for( 'Admin configured QS Do not cache: ' . $qs );
			}

			$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 );
			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 ), $_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 )
			if ( $result = $this->in_cache_exc_roles() ) {
				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 boolean Return false.
	 */
	private function _no_cache_for( $reason ) {
		Debug2::debug('[Ctrl] X Cache_control off - ' . $reason);
		return false;
	}

	/**
	 * Check if current request has qs excluded setting
	 *
	 * @since  1.3
	 * @access private
	 * @param  array  $excludes QS excludes setting
	 * @return boolean|string False if not excluded, otherwise the hit qs list
	 */
	private function _is_qs_excluded( $excludes ) {
		if ( ! empty( $_GET ) && $intersect = array_intersect( array_keys( $_GET ), $excludes ) ) {
			return implode( ',', $intersect );
		}
		return false;
	}

}