<?php

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao;

use MatthiasMullie\Minify;


/**
 * Parses and outputs template files
 *
 * The class supports loading template files, adding variables to them and then
 * printing them to the screen. It functions as abstract parent class for the
 * two core classes "BackendTemplate" and "FrontendTemplate".
 *
 * Usage:
 *
 *     $template = new BackendTemplate();
 *     $template->name = 'Leo Feyer';
 *     $template->output();
 *
 * @property string $style
 * @property array  $cssID
 * @property string $class
 * @property string $inColumn
 * @property string $headline
 * @property array  $hl
 *
 * @author Leo Feyer <https://github.com/leofeyer>
 */
abstract class Template extends \BaseTemplate
{

	/**
	 * Output buffer
	 * @var string
	 */
	protected $strBuffer;

	/**
	 * Content type
	 * @var string
	 */
	protected $strContentType;

	/**
	 * Template data
	 * @var array
	 */
	protected $arrData = array();

	/**
	 * Valid JavaScipt types
	 * @var array
	 * @see http://www.w3.org/TR/html5/scripting-1.html#scriptingLanguages
	 */
	protected static $validJavaScriptTypes = array
	(
		'application/ecmascript',
		'application/javascript',
		'application/x-ecmascript',
		'application/x-javascript',
		'text/ecmascript',
		'text/javascript',
		'text/javascript1.0',
		'text/javascript1.1',
		'text/javascript1.2',
		'text/javascript1.3',
		'text/javascript1.4',
		'text/javascript1.5',
		'text/jscript',
		'text/livescript',
		'text/x-ecmascript',
		'text/x-javascript',
	);


	/**
	 * Create a new template object
	 *
	 * @param string $strTemplate    The template name
	 * @param string $strContentType The content type (defaults to "text/html")
	 */
	public function __construct($strTemplate='', $strContentType='text/html')
	{
		parent::__construct();

		$this->strTemplate = $strTemplate;
		$this->strContentType = $strContentType;
	}


	/**
	 * Set an object property
	 *
	 * @param string $strKey   The property name
	 * @param mixed  $varValue The property value
	 */
	public function __set($strKey, $varValue)
	{
		$this->arrData[$strKey] = $varValue;
	}


	/**
	 * Return an object property
	 *
	 * @param string $strKey The property name
	 *
	 * @return mixed The property value
	 */
	public function __get($strKey)
	{
		if (isset($this->arrData[$strKey]))
		{
			if (is_object($this->arrData[$strKey]) && is_callable($this->arrData[$strKey]))
			{
				return $this->arrData[$strKey]();
			}

			return $this->arrData[$strKey];
		}

		return parent::__get($strKey);
	}


	/**
	 * Execute a callable and return the result
	 *
	 * @param string $strKey    The name of the key
	 * @param array  $arrParams The parameters array
	 *
	 * @return mixed The callable return value
	 *
	 * @throws \InvalidArgumentException If the callable does not exist
	 */
	public function __call($strKey, $arrParams)
	{
		if (!isset($this->arrData[$strKey]) || !is_callable($this->arrData[$strKey]))
		{
			throw new \InvalidArgumentException("$strKey is not set or not a callable");
		}

		return call_user_func_array($this->arrData[$strKey], $arrParams);
	}


	/**
	 * Check whether a property is set
	 *
	 * @param string $strKey The property name
	 *
	 * @return boolean True if the property is set
	 */
	public function __isset($strKey)
	{
		return isset($this->arrData[$strKey]);
	}


	/**
	 * Set the template data from an array
	 *
	 * @param array $arrData The data array
	 */
	public function setData($arrData)
	{
		$this->arrData = $arrData;
	}


	/**
	 * Return the template data as array
	 *
	 * @return array The data array
	 */
	public function getData()
	{
		return $this->arrData;
	}


	/**
	 * Set the template name
	 *
	 * @param string $strTemplate The template name
	 */
	public function setName($strTemplate)
	{
		$this->strTemplate = $strTemplate;
	}


	/**
	 * Return the template name
	 *
	 * @return string The template name
	 */
	public function getName()
	{
		return $this->strTemplate;
	}


	/**
	 * Set the output format
	 *
	 * @param string $strFormat The output format
	 */
	public function setFormat($strFormat)
	{
		$this->strFormat = $strFormat;
	}


	/**
	 * Return the output format
	 *
	 * @return string The output format
	 */
	public function getFormat()
	{
		return $this->strFormat;
	}


	/**
	 * Print all template variables to the screen using print_r
	 */
	public function showTemplateVars()
	{
		echo "<pre>\n";
		print_r($this->arrData);
		echo "</pre>\n";
	}


	/**
	 * Print all template variables to the screen using var_dump
	 */
	public function dumpTemplateVars()
	{
		dump($this->arrData);
	}


	/**
	 * Parse the template file and return it as string
	 *
	 * @return string The template markup
	 */
	public function parse()
	{
		if ($this->strTemplate == '')
		{
			return '';
		}

		// HOOK: add custom parse filters
		if (isset($GLOBALS['TL_HOOKS']['parseTemplate']) && is_array($GLOBALS['TL_HOOKS']['parseTemplate']))
		{
			foreach ($GLOBALS['TL_HOOKS']['parseTemplate'] as $callback)
			{
				$this->import($callback[0]);
				$this->{$callback[0]}->{$callback[1]}($this);
			}
		}

		return parent::parse();
	}


	/**
	 * Parse the template file and print it to the screen
	 */
	public function output()
	{
		if (!$this->strBuffer)
		{
			$this->strBuffer = $this->parse();
		}

		// Minify the markup
		$this->strBuffer = $this->minifyHtml($this->strBuffer);

		header('Vary: User-Agent', false);
		header('Content-Type: ' . $this->strContentType . '; charset=' . \Config::get('characterSet'));

		// CORS support
		\Controller::setCorsHeaders();

		// Add the debug bar
		if (\Config::get('debugMode') && !\Config::get('hideDebugBar') && !isset($_GET['popup']))
		{
			$this->strBuffer = str_replace('</body>', $this->getDebugBar() . '</body>', $this->strBuffer);
		}

		echo $this->strBuffer;

		// Flush the output buffers (see #6962)
		$this->flushAllData();

		// HOOK: add custom logic
		if (isset($GLOBALS['TL_HOOKS']['postFlushData']) && is_array($GLOBALS['TL_HOOKS']['postFlushData']))
		{
			foreach ($GLOBALS['TL_HOOKS']['postFlushData'] as $callback)
			{
				$this->import($callback[0]);
				$this->{$callback[0]}->{$callback[1]}($this->strBuffer, $this);
			}
		}
	}


	/**
	 * Return the debug bar string
	 *
	 * @return string The debug bar markup
	 */
	protected function getDebugBar()
	{
		$intReturned = 0;
		$intAffected = 0;

		// Count the totals (see #3884)
		if (is_array($GLOBALS['TL_DEBUG']['database_queries']))
		{
			foreach ($GLOBALS['TL_DEBUG']['database_queries'] as $k=>$v)
			{
				$intReturned += $v['return_count'];
				$intAffected += $v['affected_count'];
				unset($GLOBALS['TL_DEBUG']['database_queries'][$k]['return_count']);
				unset($GLOBALS['TL_DEBUG']['database_queries'][$k]['affected_count']);
			}
		}

		$intElapsed = (microtime(true) - TL_START);

		$strDebug = sprintf(
			"<!-- indexer::stop -->\n"
			. '<div id="contao-debug">'
			. '<p>'
				. '<span class="debug-time">Execution time: %s ms</span>'
				. '<span class="debug-memory">Memory usage: %s</span>'
				. '<span class="debug-db">Database queries: %d</span>'
				. '<span class="debug-rows">Rows: %d returned, %s affected</span>'
				. '<span class="debug-models">Registered models: %d</span>'
				. '<span id="debug-tog">&nbsp;</span>'
			. '</p>'
			. '<div><pre>',
			$this->getFormattedNumber(($intElapsed * 1000), 0),
			$this->getReadableSize(memory_get_peak_usage()),
			count($GLOBALS['TL_DEBUG']['database_queries']),
			$intReturned,
			$intAffected,
			\Model\Registry::getInstance()->count()
		);

		ksort($GLOBALS['TL_DEBUG']);

		ob_start();
		print_r($GLOBALS['TL_DEBUG']);
		$strDebug .= ob_get_contents();
		ob_end_clean();

		unset($GLOBALS['TL_DEBUG']);

		$strDebug .= '</pre></div></div>'
			. $this->generateInlineScript(
				"(function($) {"
					. "$(document.body).addClass('debug-enabled " . \Input::cookie('CONTAO_CONSOLE') . "');"
					. "$('debug-tog').addEvent('click',function(e) {"
						. "$(document.body).toggleClass('debug-closed');"
						. "Cookie.write('CONTAO_CONSOLE',$(document.body).hasClass('debug-closed')?'debug-closed':'',{path:'" . (TL_PATH ?: '/') . "'});"
					. "});"
				. "})(document.id);",
				($this->strFormat == 'xhtml')
			)
			. "\n<!-- indexer::continue -->\n\n"
		;

		return $strDebug;
	}


	/**
	 * Minify the HTML markup preserving pre, script, style and textarea tags
	 *
	 * @param string $strHtml The HTML markup
	 *
	 * @return string The minified HTML markup
	 */
	public function minifyHtml($strHtml)
	{
		// The feature has been disabled
		if (!\Config::get('minifyMarkup') || \Config::get('debugMode'))
		{
			return $strHtml;
		}

		// Split the markup based on the tags that shall be preserved
		$arrChunks = preg_split('@(</?pre[^>]*>)|(</?script[^>]*>)|(</?style[^>]*>)|( ?</?textarea[^>]*>)@i', $strHtml, -1, PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY);

		$strHtml = '';
		$blnPreserveNext = false;
		$blnOptimizeNext = false;
		$strType = null;

		// Check for valid JavaScript types (see #7927)
		$isJavaScript = function ($strChunk)
		{
			$typeMatch = array();

			if (preg_match('/\stype\s*=\s*(?:(?J)(["\'])\s*(?<type>.*?)\s*\1|(?<type>[^\s>]+))/i', $strChunk, $typeMatch) && !in_array(strtolower($typeMatch['type']), static::$validJavaScriptTypes))
			{
				return false;
			}

			if (preg_match('/\slanguage\s*=\s*(?:(?J)(["\'])\s*(?<type>.*?)\s*\1|(?<type>[^\s>]+))/i', $strChunk, $typeMatch) && !in_array('text/' . strtolower($typeMatch['type']), static::$validJavaScriptTypes))
			{
				return false;
			}

			return true;
		};

		// Recombine the markup
		foreach ($arrChunks as $strChunk)
		{
			if (strncasecmp($strChunk, '<pre', 4) === 0 || strncasecmp(ltrim($strChunk), '<textarea', 9) === 0)
			{
				$blnPreserveNext = true;
			}
			elseif (strncasecmp($strChunk, '<script', 7) === 0)
			{
				if ($isJavaScript($strChunk))
				{
					$blnOptimizeNext = true;
					$strType = 'js';
				}
				else
				{
					$blnPreserveNext = true;
				}
			}
			elseif (strncasecmp($strChunk, '<style', 6) === 0)
			{
				$blnOptimizeNext = true;
				$strType = 'css';
			}
			elseif ($blnPreserveNext)
			{
				$blnPreserveNext = false;
			}
			elseif ($blnOptimizeNext)
			{
				$blnOptimizeNext = false;

				// Minify inline scripts
				if ($strType == 'js')
				{
					$objMinify = new Minify\JS();
					$objMinify->add($strChunk);
					$strChunk = $objMinify->minify();
				}
				elseif ($strType == 'css')
				{
					$objMinify = new Minify\CSS();
					$objMinify->add($strChunk);
					$strChunk = $objMinify->minify();
				}
			}
			else
			{
				// Remove line indentations and trailing spaces
				$strChunk = str_replace("\r", '', $strChunk);
				$strChunk = preg_replace(array('/^[\t ]+/m', '/[\t ]+$/m', '/\n\n+/'), array('', '', "\n"), $strChunk);
			}

			$strHtml .= $strChunk;
		}

		return trim($strHtml);
	}


	/**
	 * Generate the markup for a style sheet tag
	 *
	 * @param string  $href  The script path
	 * @param string  $media The media type string
	 * @param boolean $xhtml True if the output shall be XHTML compliant
	 *
	 * @return string The markup string
	 */
	public static function generateStyleTag($href, $media=null, $xhtml=false)
	{
		return '<link' . ($xhtml ? ' type="text/css"' : '') . ' rel="stylesheet" href="' . $href . '"' . (($media && $media != 'all') ? ' media="' . $media . '"' : '') . ($xhtml ? ' />' : '>');
	}


	/**
	 * Generate the markup for inline CSS code
	 *
	 * @param string  $script The CSS code
	 * @param boolean $xhtml  True if the output shall be XHTML compliant
	 *
	 * @return string The markup string
	 */
	public static function generateInlineStyle($script, $xhtml=false)
	{
		if ($xhtml)
		{
			return '<style type="text/css">' . "\n/* <![CDATA[ */\n" . $script . "\n/* ]]> */\n" . '</style>';
		}
		else
		{
			return '<style>' . $script . '</style>';
		}
	}


	/**
	 * Generate the markup for a JavaScript tag
	 *
	 * @param string  $src   The script path
	 * @param boolean $xhtml True if the output shall be XHTML compliant
	 * @param boolean $async True to add the async attribute
	 *
	 * @return string The markup string
	 */
	public static function generateScriptTag($src, $xhtml=false, $async=false)
	{
		return '<script' . ($xhtml ? ' type="text/javascript"' : '') . ' src="' . $src . '"' . ($async && !$xhtml ? ' async' : '') . '></script>';
	}


	/**
	 * Generate the markup for an inline JavaScript
	 *
	 * @param string  $script The JavaScript code
	 * @param boolean $xhtml  True if the output shall be XHTML compliant
	 *
	 * @return string The markup string
	 */
	public static function generateInlineScript($script, $xhtml=false)
	{
		if ($xhtml)
		{
			return '<script type="text/javascript">' . "\n/* <![CDATA[ */\n" . $script . "\n/* ]]> */\n" . '</script>';
		}
		else
		{
			return '<script>' . $script . '</script>';
		}
	}


	/**
	 * Generate the markup for an RSS feed tag
	 *
	 * @param string  $href   The script path
	 * @param string  $format The feed format
	 * @param string  $title  The feed title
	 * @param boolean $xhtml  True if the output shall be XHTML compliant
	 *
	 * @return string The markup string
	 */
	public static function generateFeedTag($href, $format, $title, $xhtml=false)
	{
		return '<link type="application/' . $format . '+xml" rel="alternate" href="' . $href . '" title="' . specialchars($title) . '"' . ($xhtml ? ' />' : '>');
	}


	/**
	 * Flush the output buffers
	 *
	 * @see Symfony\Component\HttpFoundation\Response
	 */
	public function flushAllData()
	{
		if (function_exists('fastcgi_finish_request'))
		{
			fastcgi_finish_request();
		}
		elseif (PHP_SAPI !== 'cli')
		{
			$status = ob_get_status(true);
			$level = count($status);

			while ($level-- > 0 && (!empty($status[$level]['del']) || (isset($status[$level]['flags']) && ($status[$level]['flags'] & PHP_OUTPUT_HANDLER_REMOVABLE) && ($status[$level]['flags'] & PHP_OUTPUT_HANDLER_FLUSHABLE))))
			{
				ob_end_flush();
			}

			flush();
		}
	}


	/**
	 * Print the IE6 warning
	 *
	 * @return string The warning message
	 *
	 * @deprecated The IE6 warning is now in the templates (e.g. be_install)
	 */
	public function showIE6warning()
	{
		return ''; // Backwards compatibility
	}
}
