<?php
namespace W3TCL\Minify;

/**
 * Class HTTP_Encoder
 * @package Minify
 * @subpackage HTTP
 */

/**
 * Encode and send gzipped/deflated content
 *
 * The "Vary: Accept-Encoding" header is sent. If the client allows encoding,
 * Content-Encoding and Content-Length are added.
 *
 * <code>
 * // Send a CSS file, compressed if possible
 * $he = new HTTP_Encoder(array(
 *     'content' => file_get_contents($cssFile)
 *     ,'type' => 'text/css'
 * ));
 * $he->encode();
 * $he->sendAll();
 * </code>
 *
 * <code>
 * // Shortcut to encoding output
 * header('Content-Type: text/css'); // needed if not HTML
 * HTTP_Encoder::output($css);
 * </code>
 *
 * <code>
 * // Just sniff for the accepted encoding
 * $encoding = HTTP_Encoder::getAcceptedEncoding();
 * </code>
 *
 * For more control over headers, use getHeaders() and getData() and send your
 * own output.
 *
 * Note: If you don't need header mgmt, use PHP's native gzencode, gzdeflate,
 * and gzcompress functions for gzip, deflate, and compress-encoding
 * respectively.
 *
 * @package Minify
 * @subpackage HTTP
 * @author Stephen Clay <steve@mrclay.org>
 */
class HTTP_Encoder {

	/**
	 * Should the encoder allow HTTP encoding to IE6?
	 *
	 * If you have many IE6 users and the bandwidth savings is worth troubling
	 * some of them, set this to true.
	 *
	 * By default, encoding is only offered to IE7+. When this is true,
	 * getAcceptedEncoding() will return an encoding for IE6 if its user agent
	 * string contains "SV1". This has been documented in many places as "safe",
	 * but there seem to be remaining, intermittent encoding bugs in patched
	 * IE6 on the wild web.
	 *
	 * @var bool
	 */
	public static $encodeToIe6 = true;


	/**
	 * Default compression level for zlib operations
	 *
	 * This level is used if encode() is not given a $compressionLevel
	 *
	 * @var int
	 */
	public static $compressionLevel = 6;


	/**
	 * Get an HTTP Encoder object
	 *
	 * @param array $spec options
	 *
	 * 'content': (string required) content to be encoded
	 *
	 * 'type': (string) if set, the Content-Type header will have this value.
	 *
	 * 'method: (string) only set this if you are forcing a particular encoding
	 * method. If not set, the best method will be chosen by getAcceptedEncoding()
	 * The available methods are 'gzip', 'deflate', 'compress', and '' (no
	 * encoding)
	 */
	public function __construct($spec)
	{
		$this->_useMbStrlen = (function_exists('mb_strlen')
							   && (ini_get('mbstring.func_overload') !== '')
							   && ((int)ini_get('mbstring.func_overload') & 2));
		$this->_content = $spec['content'];
		$this->_headers['Content-Length'] = $this->_useMbStrlen
			? (string)mb_strlen($this->_content, '8bit')
			: (string)strlen($this->_content);
		if (isset($spec['type'])) {
			$this->_headers['Content-Type'] = $spec['type'];
		}
		if (isset($spec['method'])
			&& in_array($spec['method'], array('gzip', 'deflate', '')))
		{
			$this->_encodeMethod = array($spec['method'], $spec['method']);
		} else {
			$this->_encodeMethod = self::getAcceptedEncoding();
		}
	}

	/**
	 * Get content in current form
	 *
	 * Call after encode() for encoded content.
	 *
	 * @return string
	 */
	public function getContent()
	{
		return $this->_content;
	}

	/**
	 * Get array of output headers to be sent
	 *
	 * E.g.
	 * <code>
	 * array(
	 *     'Content-Length' => '615'
	 *     ,'Content-Encoding' => 'x-gzip'
	 *     ,'Vary' => 'Accept-Encoding'
	 * )
	 * </code>
	 *
	 * @return array
	 */
	public function getHeaders()
	{
		return $this->_headers;
	}

	/**
	 * Send output headers
	 *
	 * You must call this before headers are sent and it probably cannot be
	 * used in conjunction with zlib output buffering / mod_gzip. Errors are
	 * not handled purposefully.
	 *
	 * @see getHeaders()
	 */
	public function sendHeaders()
	{
		foreach ($this->_headers as $name => $val) {
			header($name . ': ' . $val);
		}
	}

	/**
	 * Send output headers and content
	 *
	 * A shortcut for sendHeaders() and echo getContent()
	 *
	 * You must call this before headers are sent and it probably cannot be
	 * used in conjunction with zlib output buffering / mod_gzip. Errors are
	 * not handled purposefully.
	 */
	public function sendAll()
	{
		$this->sendHeaders();
		echo wp_kses(
			$this->_content,
			Util_Ui::get_allowed_html_for_wp_kses_from_content( $this->_content )
		);
	}

	/**
	 * Determine the client's best encoding method from the HTTP Accept-Encoding
	 * header.
	 *
	 * If no Accept-Encoding header is set, or the browser is IE before v6 SP2,
	 * this will return ('', ''), the "identity" encoding.
	 *
	 * A syntax-aware scan is done of the Accept-Encoding, so the method must
	 * be non 0. The methods are favored in order of gzip, deflate, then
	 * compress. Deflate is always smallest and generally faster, but is
	 * rarely sent by servers, so client support could be buggier.
	 *
	 * @param bool $allowCompress allow the older compress encoding
	 *
	 * @param bool $allowDeflate allow the more recent deflate encoding
	 *
	 * @return array two values, 1st is the actual encoding method, 2nd is the
	 * alias of that method to use in the Content-Encoding header (some browsers
	 * call gzip "x-gzip" etc.)
	 */
	public static function getAcceptedEncoding($allowCompress = true, $allowDeflate = true)
	{
		// @link http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html

		if (! isset($_SERVER['HTTP_ACCEPT_ENCODING'])
			|| \W3TC\Util_Environment::is_zlib_enabled()
			|| headers_sent()
			|| self::isBuggyIe())
		{
			return array('', '');
		}
		$ae = sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_ENCODING'] ) );
		// brotli checks (quick)
		if (0 === strpos($ae, 'br,')
			|| strpos($ae, ', br') === strlen($ae) - 4
		) {
			if (function_exists('brotli_compress'))
				return array('br', 'br');
		}
		// brotli checks (slow)
		if (preg_match(
				'@(?:^|,)\\s*((?:x-)?br)\\s*(?:$|,|;\\s*q=(?:0\\.|1))@'
				,$ae
				,$m)) {
			if (function_exists('brotli_compress'))
				return array('br', $m[1]);
		}
		// gzip checks (quick)
		if (0 === strpos($ae, 'gzip,')             // most browsers
			|| 0 === strpos($ae, 'deflate, gzip,') // opera
		) {
			if (function_exists('gzencode'))
				return array('gzip', 'gzip');
		}
		// gzip checks (slow)
		if (preg_match(
				'@(?:^|,)\\s*((?:x-)?gzip)\\s*(?:$|,|;\\s*q=(?:0\\.|1))@'
				,$ae
				,$m)) {
			return array('gzip', $m[1]);
		}

		return array('', '');
	}

	/**
	 * Encode (compress) the content
	 *
	 * If the encode method is '' (none) or compression level is 0, or the 'zlib'
	 * extension isn't loaded, we return false.
	 *
	 * Then the appropriate gz_* function is called to compress the content. If
	 * this fails, false is returned.
	 *
	 * The header "Vary: Accept-Encoding" is added. If encoding is successful,
	 * the Content-Length header is updated, and Content-Encoding is also added.
	 *
	 * @param int $compressionLevel given to zlib functions. If not given, the
	 * class default will be used.
	 *
	 * @return bool success true if the content was actually compressed
	 */
	public function encode($compressionLevel = null)
	{
		if (! self::isBuggyIe()) {
			$this->_headers['Vary'] = 'Accept-Encoding';
		}
		if (null === $compressionLevel) {
			$compressionLevel = self::$compressionLevel;
		}
		if ('' === $this->_encodeMethod[0]
			|| ($compressionLevel == 0)
			|| !extension_loaded('zlib'))
		{
			return false;
		}
		if ($this->_encodeMethod[0] === 'br') {
			$encoded = brotli_compress($this->_content);
		} elseif ($this->_encodeMethod[0] === 'deflate') {
			$encoded = gzdeflate($this->_content, $compressionLevel);
		} elseif ($this->_encodeMethod[0] === 'gzip') {
			$encoded = gzencode($this->_content, $compressionLevel);
		} else {
			$encoded = gzcompress($this->_content, $compressionLevel);
		}
		if (false === $encoded) {
			return false;
		}
		$this->_headers['Content-Length'] = $this->_useMbStrlen
			? (string)mb_strlen($encoded, '8bit')
			: (string)strlen($encoded);
		$this->_headers['Content-Encoding'] = $this->_encodeMethod[1];
		$this->_content = $encoded;
		return true;
	}

	/**
	 * Encode and send appropriate headers and content
	 *
	 * This is a convenience method for common use of the class
	 *
	 * @param string $content
	 *
	 * @param int $compressionLevel given to zlib functions. If not given, the
	 * class default will be used.
	 *
	 * @return bool success true if the content was actually compressed
	 */
	public static function output($content, $compressionLevel = null)
	{
		if (null === $compressionLevel) {
			$compressionLevel = self::$compressionLevel;
		}
		$he = new HTTP_Encoder(array('content' => $content));
		$ret = $he->encode($compressionLevel);
		$he->sendAll();
		return $ret;
	}

	/**
	 * Is the browser an IE version earlier than 6 SP2?
	 *
	 * @return bool
	 */
	public static function isBuggyIe()
	{
		if (empty($_SERVER['HTTP_USER_AGENT'])) {
			return false;
		}
		$ua = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) );
		// quick escape for non-IEs
		if (0 !== strpos($ua, 'Mozilla/4.0 (compatible; MSIE ')
			|| false !== strpos($ua, 'Opera')) {
			return false;
		}
		// no regex = faaast
		$version = (float)substr($ua, 30);
		return self::$encodeToIe6
			? ($version < 6 || ($version == 6 && false === strpos($ua, 'SV1')))
			: ($version < 7);
	}

	protected $_content = '';
	protected $_headers = array();
	protected $_encodeMethod = array('', '');
	protected $_useMbStrlen = false;
}
