<?php
/**
 * The plugin vary class to manage X-LiteSpeed-Vary
 *
 * @since      	1.1.3
 */
namespace LiteSpeed;
defined( 'WPINC' ) || exit;

class Vary extends Instance {
	protected static $_instance;

	const X_HEADER = 'X-LiteSpeed-Vary';

	private static $_vary_name = '_lscache_vary'; // this default vary cookie is used for logged in status check
	private static $_vary_cookies = array(); // vary header only!
	private static $_default_vary_val = array();
	private static $_can_change_vary = false; // Currently only AJAX used this

	/**
	 * Adds the actions used for setting up cookies on log in/out.
	 *
	 * Also checks if the database matches the rewrite rule.
	 *
	 * @since 1.0.4
	 */
	protected function __construct() {
		// logged in user
		if ( Router::is_logged_in() ) {
			// If not esi, check cache logged-in user setting
			if ( ! Router::esi_enabled() ) {
				// If cache logged-in, then init cacheable to private
				if ( Conf::val( Base::O_CACHE_PRIV ) ) {
					add_action( 'wp_logout', __NAMESPACE__ . '\Purge::purge_on_logout' );

					Control::init_cacheable();
					Control::set_private( 'logged in user' );
				}
				// No cache for logged-in user
				else {
					Control::set_nocache( 'logged in user' );
				}
			}
			// ESI is on, can be public cache
			else {
				// Need to make sure vary is using group id
				Control::init_cacheable();
			}

			// register logout hook to clear login status
			add_action( 'clear_auth_cookie', array( $this, 'remove_logged_in' ) );

		}
		else {
			// Set vary cookie for logging in user, otherwise the user will hit public with vary=0 (guest version)
			add_action( 'set_logged_in_cookie', array( $this, 'add_logged_in' ), 10, 4 );
			add_action( 'wp_login', __NAMESPACE__ . '\Purge::purge_on_logout' );

			Control::init_cacheable();

			// Check `login page` cacheable setting because they don't go through main WP logic
			add_action( 'login_init', __NAMESPACE__ . '\Tag::check_login_cacheable', 5 );

		}

		// Add comment list ESI
		add_filter( 'comments_array', array( $this, 'check_commenter' ) );

		// Set vary cookie for commenter.
		add_action( 'set_comment_cookies', array( $this, 'append_commenter' ) );

		/**
		 * Don't change for REST call because they don't carry on user info usually
		 * @since 1.6.7
		 */
		add_action( 'rest_api_init', function(){
			Debug2::debug( '[Vary] Rest API init disabled vary change' );
			add_filter( 'litespeed_can_change_vary', '__return_false' );
		} );

		/******** Below to the end is only for cookie name setting check ********/
		// Get specific cookie name
		$db_cookie = Conf::val( Base::O_CACHE_LOGIN_COOKIE ); // [3.0] todo: check if works in network's sites

		// If no vary set in rewrite rule
		if ( ! isset($_SERVER['LSCACHE_VARY_COOKIE']) ) {
			if ( $db_cookie ) {
				// Display cookie error msg to admin
				if ( is_multisite() ? is_network_admin() : is_admin() ) {
					Admin_Display::show_error_cookie();
				}
				Control::set_nocache('vary cookie setting error');
				return;
			}
			return;
		}
		// If db setting does not exist, skip checking db value
		if ( ! $db_cookie ) {
			return;
		}

		// beyond this point, need to make sure db vary setting is in $_SERVER env.
		$vary_arr = explode(',', $_SERVER['LSCACHE_VARY_COOKIE']);

		if ( in_array($db_cookie, $vary_arr) ) {
			self::$_vary_name = $db_cookie;
			return;
		}

		if ( is_multisite() ? is_network_admin() : is_admin() ) {
			Admin_Display::show_error_cookie();
		}
		Control::set_nocache('vary cookie setting lost error');
	}

	/**
	 * Hooked to the comments_array filter.
	 *
	 * Check if the user accessing the page has the commenter cookie.
	 *
	 * If the user does not want to cache commenters, just check if user is commenter.
	 * Otherwise if the vary cookie is set, unset it. This is so that when the page is cached, the page will appear as if the user was a normal user.
	 * Normal user is defined as not a logged in user and not a commenter.
	 *
	 * @since 1.0.4
	 * @access public
	 * @global type $post
	 * @param array $comments The current comments to output
	 * @return array The comments to output.
	 */
	public function check_commenter( $comments ) {
		/**
		 * Hook to bypass pending comment check for comment related plugins compatibility
		 * @since 2.9.5
		 */
		if ( apply_filters( 'litespeed_vary_check_commenter_pending', true ) ) {
			$pending = false;
			foreach ( $comments as $comment ) {
				if ( ! $comment->comment_approved ) {// current user has pending comment
					$pending = true;
					break;
				}
			}

			// No pending comments, don't need to add private cache
			if ( ! $pending ) {
				$this->remove_commenter();

				// Remove commenter prefilled info if exists, for public cache
				foreach( $_COOKIE as $cookie_name => $cookie_value ) {
					if ( strlen( $cookie_name ) >= 15 && strpos( $cookie_name, 'comment_author_' ) === 0 ) {
						unset( $_COOKIE[ $cookie_name ] );
					}
				}

				return $comments;
			}
		}

		// Current user/visitor has pending comments
		// set vary=2 for next time vary lookup
		$this->add_commenter();

		if ( Conf::val( Base::O_CACHE_COMMENTER ) ) {
			Control::set_private( 'existing commenter' );
		}
		else {
			Control::set_nocache( 'existing commenter' );
		}

		return $comments;
	}

	/**
	 * Check if default vary has a value
	 *
	 * @since 1.1.3
	 * @access public
	 */
	public static function has_vary() {
		if ( empty( $_COOKIE[ self::$_vary_name ] ) ) {
			return false;
		}
		return $_COOKIE[ self::$_vary_name ];
	}

	/**
	 * Append user status with logged in
	 *
	 * @since 1.1.3
	 * @since 1.6.2 Removed static referral
	 * @access public
	 */
	public function add_logged_in( $logged_in_cookie = false, $expire = false, $expiration = false, $uid = false ) {
		Debug2::debug( '[Vary] add_logged_in' );

		/**
		 * NOTE: Run before `$this->_update_default_vary()` to make vary changeable
		 * @since  2.2.2
		 */
		self::can_ajax_vary();

		// If the cookie is lost somehow, set it
		$this->_update_default_vary( $uid, $expire );
	}

	/**
	 * Remove user logged in status
	 *
	 * @since 1.1.3
	 * @since 1.6.2 Removed static referral
	 * @access public
	 */
	public function remove_logged_in() {
		Debug2::debug( '[Vary] remove_logged_in' );

		/**
		 * NOTE: Run before `$this->_update_default_vary()` to make vary changeable
		 * @since  2.2.2
		 */
		self::can_ajax_vary();

		// Force update vary to remove login status
		$this->_update_default_vary( -1 );
	}

	/**
	 * Allow vary can be changed for ajax calls
	 *
	 * @since 2.2.2
	 * @since 2.6 Changed to static
	 * @access public
	 */
	public static function can_ajax_vary() {
		Debug2::debug( '[Vary] _can_change_vary -> true' );
		self::$_can_change_vary = true;
	}

	/**
	 * Check if can change default vary
	 *
	 * @since 1.6.2
	 * @access private
	 */
	private function can_change_vary() {
		// Don't change for ajax due to ajax not sending webp header
		if ( Router::is_ajax() ) {
			if ( ! self::$_can_change_vary ) {
				Debug2::debug( '[Vary] can_change_vary bypassed due to ajax call' );
				return false;
			}
		}

		/**
		 * POST request can set vary to fix #820789 login "loop" guest cache issue
		 * @since 1.6.5
		 */
		if ( $_SERVER["REQUEST_METHOD"] !== 'GET' && $_SERVER["REQUEST_METHOD"] !== 'POST' ) {
			Debug2::debug( '[Vary] can_change_vary bypassed due to method not get/post' );
			return false;
		}

		/**
		 * Disable vary change if is from crawler
		 * @since  2.9.8 To enable woocommerce cart not empty warm up (@Taba)
		 */
		if ( ! empty( $_SERVER[ 'HTTP_USER_AGENT' ] ) && strpos( $_SERVER[ 'HTTP_USER_AGENT' ], Crawler::FAST_USER_AGENT ) === 0 ) {
			Debug2::debug( '[Vary] can_change_vary bypassed due to crawler' );
			return false;
		}

		if ( ! apply_filters( 'litespeed_can_change_vary', true ) ) {
			Debug2::debug( '[Vary] can_change_vary bypassed due to litespeed_can_change_vary hook' );
			return false;
		}

		return true;
	}

	/**
	 * Update default vary
	 *
	 * @since 1.6.2
	 * @since  1.6.6.1 Add ran check to make it only run once ( No run multiple times due to login process doesn't have valid uid )
	 * @access private
	 */
	private function _update_default_vary( $uid = false, $expire = false ) {
		// Make sure header output only run once
		if ( ! defined( 'LITESPEED_DID_' . __FUNCTION__ ) ) {
			define( 'LITESPEED_DID_' . __FUNCTION__, true );
		}
		else {
			Debug2::debug2( "[Vary] _update_default_vary bypassed due to run already" );
			return;
		}

		// If the cookie is lost somehow, set it
		$vary = $this->finalize_default_vary( $uid );
		$current_vary = self::has_vary();
		if ( $current_vary !== $vary && $current_vary !== 'commenter' && $this->can_change_vary() ) {
			// $_COOKIE[ self::$_vary_name ] = $vary; // not needed

			// save it
			if ( ! $expire ) {
				$expire = time() + 2 * DAY_IN_SECONDS;
			}
			self::_cookie( $vary, $expire );
			Debug2::debug( "[Vary] set_cookie ---> $vary" );
			Control::set_nocache( 'changing default vary' . " $current_vary => $vary" );
		}
	}

	/**
	 * Get vary name
	 *
	 * @since 1.9.1
	 * @access public
	 */
	public function get_vary_name() {
		return self::$_vary_name;
	}

	/**
	 * Check if one user role is in vary group settings
	 *
	 * @since 1.2.0
	 * @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_vary_group( $role ) {
		$group = 0;
		$vary_groups = Conf::val( Base::O_CACHE_VARY_GROUP );
		if ( array_key_exists( $role, $vary_groups ) ) {
			$group = $vary_groups[ $role ];
		}
		elseif ( $role === 'administrator' ) {
			$group = 99;
		}

		if ( $group ) {
			Debug2::debug2( '[Vary] role in vary_group [group] ' . $group );
		}

		return $group;
	}

	/**
	 * Finalize default vary
	 *
	 *  Get user vary tag based on admin_bar & role
	 *
	 * NOTE: Login process will also call this because it does not call wp hook as normal page loading
	 *
	 * @since 1.6.2
	 * @access public
	 */
	public function finalize_default_vary( $uid = false ) {
		$vary = self::$_default_vary_val;

		if ( ! $uid ) {
			$uid = get_current_user_id();
		}
		else {
			Debug2::debug( '[Vary] uid: ' . $uid );
		}

		// get user's group id
		$role = Router::get_role( $uid );

		if ( $uid > 0 && $role ) {
			$vary[ 'logged-in' ] = 1;

			// parse role group from settings
			if ( $role_group = $this->in_vary_group( $role ) ) {
				$vary[ 'role' ] = $role_group;
			}

			// Get admin bar set
			// see @_get_admin_bar_pref()
			$pref = get_user_option( 'show_admin_bar_front', $uid );
			Debug2::debug2( '[Vary] show_admin_bar_front: ' . $pref );
			$admin_bar = $pref === false || $pref === 'true';

			if ( $admin_bar ) {
				$vary[ 'admin_bar' ] = 1;
				Debug2::debug2( '[Vary] admin bar : true' );
			}

		}
		else {
			// Guest user
			Debug2::debug( '[Vary] role id: failed, guest' );

		}

		/**
		 * Add filter
		 * @since 1.6 Added for Role Excludes for optimization cls
		 * @since 1.6.2 Hooked to webp
		 * @since 3.0 Used by 3rd hooks too
		 */
		$vary = apply_filters( 'litespeed_vary', $vary );

		if ( ! $vary ) {
			return false;
		}

		ksort( $vary );
		$res = array();
		foreach ( $vary as $key => $val ) {
			$res[] = $key . ':' . $val;
		}

		$res = implode( ';', $res );
		if ( defined( 'LSCWP_LOG' ) ) {
			return $res;
		}
		// Encrypt in production
		return md5( Conf::val( Base::HASH ) . $res );

	}

	/**
	 * Append user status with commenter
	 *
	 * This is ONLY used when submit a comment
	 *
	 * @since 1.1.6
	 * @access public
	 */
	public function append_commenter() {
		$this->add_commenter( true );
	}

	/**
	 * Correct user status with commenter
	 *
	 * @since 1.1.3
	 * @access private
	 * @param  boolean $from_redirect If the request is from redirect page or not
	 */
	private function add_commenter( $from_redirect = false ) {
		// If the cookie is lost somehow, set it
		if ( self::has_vary() !== 'commenter' ) {
			// $_COOKIE[ self::$_vary_name ] = 'commenter'; // not needed

			// save it
			// only set commenter status for current domain path
			self::_cookie( 'commenter', time() + apply_filters( 'comment_cookie_lifetime', 30000000 ), self::_relative_path( $from_redirect ) );
			Control::set_nocache( 'adding commenter status' );
		}
	}

	/**
	 * Remove user commenter status
	 *
	 * @since 1.1.3
	 * @access private
	 */
	private function remove_commenter() {
		if ( self::has_vary() === 'commenter' ) {
			// remove logged in status from global var
			// unset( $_COOKIE[ self::$_vary_name ] ); // not needed

			// save it
			self::_cookie( false, false, self::_relative_path() );
			Control::set_nocache( 'removing commenter status' );
		}
	}

	/**
	 * Generate relative path for cookie
	 *
	 * @since 1.1.3
	 * @access private
	 * @param  boolean $from_redirect If the request is from redirect page or not
	 */
	private static function _relative_path( $from_redirect = false ) {
		$path = false;
		$tag = $from_redirect ? 'HTTP_REFERER' : 'SCRIPT_URL';
		if ( ! empty( $_SERVER[ $tag ] ) ) {
			$path = parse_url( $_SERVER[ $tag ] );
			$path = ! empty( $path[ 'path' ] ) ? $path[ 'path' ] : false;
			Debug2::debug( '[Vary] Cookie Vary path: ' . $path );
		}
		return $path;
	}

	/**
	 * Builds the vary header.
	 *
	 * Currently, this only checks post passwords.
	 *
	 * @since 1.0.13
	 * @access public
	 * @global $post
	 * @return mixed false if the user has the postpass cookie. Empty string
	 * if the post is not password protected. Vary header otherwise.
	 */
	public static function finalize() {
		return self::get_instance()->_finalize();

	}

	private function _finalize() {
		// Finalize default vary
		$this->_update_default_vary();

		/**
		 * Non caccheable page can still set vary ( for logged in process )
		 * @since  1.6.6.1
		 */
		// if ( ! Control::is_cacheable() ) {
		// 	Debug2::debug2( 'Vary: bypass finalize due to not cacheable' );
		// 	return false;
		// }

		$tp_cookies = $this->_format_vary_cookies();
		global $post;
		if ( ! empty($post->post_password) ) {
			if ( isset($_COOKIE['wp-postpass_' . COOKIEHASH]) ) {
				Debug2::debug( '[Vary] finalize bypassed due to password protected vary ' );
				// If user has password cookie, do not cache
				Control::set_nocache('password protected vary');
				return;
			}

			$tp_cookies[] = 'cookie=wp-postpass_' . COOKIEHASH;
		}

		if ( empty($tp_cookies) ) {
			Debug2::debug2( '[Vary] no custimzed vary ' );
			return;
		}

		return self::X_HEADER . ': ' . implode(',', $tp_cookies);

	}

	/**
	 * Gets vary cookies that are already added for the current page.
	 *
	 * @since 1.0.13
	 * @access private
	 * @return array An array of all vary cookies currently added.
	 */
	private function _format_vary_cookies() {
		/**
		 * To add new varys, use hook `API::filter_vary_cookies()` before here
		 */
		do_action( 'litespeed_vary_add' );

		/**
		 * Give a filter to manipulate vary
		 * @since 2.7.1
		 */
		$cookies = apply_filters( 'litespeed_vary_cookies', self::$_vary_cookies );
		if ( $cookies !== self::$_vary_cookies ) {
			Debug2::debug( '[Vary] vary changed by filter [Old] ' . var_export( self::$_vary_cookies, true ) . ' [New] ' . var_export( $cookies, true )  );
		}

		if ( ! empty( $cookies ) ) {
			$cookies = array_filter( array_unique( $cookies ) );
		}

		if ( empty($cookies) ) {
			return false;
		}

		foreach ($cookies as $key => $val) {
			$cookies[$key] = 'cookie=' . $val;
		}

		return $cookies;
	}

	/**
	 * Adds vary to the list of vary cookies for the current page.
	 * This is to add a new vary cookie
	 *
	 * @since 1.0.13
	 * @deprecated 2.7.1 Use filter `litespeed_vary_cookies` instead.
	 * @access public
	 * @param mixed $vary A string or array of vary cookies to add to the current list.
	 */
	public static function add( $vary ) {
		if ( ! is_array( $vary ) ) {
			$vary = array( $vary );
		}

		error_log( 'Deprecated since LSCWP 2.7.1! [Vary] Add new vary ' . var_export( $vary, true ) );

		self::$_vary_cookies = array_merge(self::$_vary_cookies, $vary);
	}

	/**
	 * Append child value to default vary
	 *
	 * @since 2.6
	 * @access public
	 */
	public static function append( $name, $val ) {
		self::$_default_vary_val[ $name ] = $val;
	}

	/**
	 * Set the vary cookie.
	 *
	 * If vary cookie changed, must set non cacheable.
	 *
	 * @since 1.0.4
	 * @access private
	 * @param integer $val The value to update.
	 * @param integer $expire Expire time.
	 * @param boolean $path False if use wp root path as cookie path
	 */
	private static function _cookie($val = false, $expire = false, $path = false) {
		if ( ! $val ) {
			$expire = 1;
		}

		/**
		 * Add HTTPS bypass in case clients use both HTTP and HTTPS version of site
		 * @since 1.7
		 */
		$is_ssl = Conf::val( Base::O_UTIL_NO_HTTPS_VARY ) ? false : is_ssl();

		setcookie( self::$_vary_name, $val, $expire, $path?: COOKIEPATH, COOKIE_DOMAIN, $is_ssl, true );
	}

}
