<?php

/**
 * THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
 * WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
 *
 * @package fusionLib
 * @copyright Copyright (c) 2007 - 2017 fusionLib. All rights reserved.
 * @link http://fusionlib.com
 */

/**
 * flTemplateEngine Class
 *
 * Class to implement an extensible template engine which supports
 * snippets (code blocks), chunks (html blocks) and placeholders (variables).
 *
 * Syntax:
 * [[snippet]] or [[!snippet]] for non-cached
 * [[$chunk]] or [[!$chunk]] for non-cached
 * [[+placeholder]] or [[!+placeholder]] for non-cached
 *
 * All but placeholders can take parameters e.g. [[snippet &var=`1` &var=`2`]]
 * in the case of a chunk placeholders of the same name as the parameters are
 * replaced with the passed variables.
 *
 * @package fusionLib
 */
class flTemplateEngine {
	/**
	 * Array holding placeholder values.
	 *
	 * @var array
	 */
	protected $placeholders = array();

	/**
	 * Cache of chunks and those registered through registerChunk.
	 *
	 * @var array
	 */
	protected $chunkCache = array();

	/**
	 * Cache of snippets and those registered through registerSnippet.
	 *
	 * @var array
	 */
	protected $snippetCache = array();

	/**
	 * The optional cache object.
	 *
	 * @var flCache
	 */
	protected $cache = null;

	/**
	 * The lifetime for the page in the cache.
	 *
	 * @var int
	 */
	protected $lifetime = null;

	/**
	 * The code buffer object.
	 *
	 * @var flBufferCode
	 */
	protected $codeBuffer = null;

	/**
	 * The theme to use within the themes folder.
	 *
	 * @var string
	 */
	protected $theme = '';

	/**
	 * The child theme to use.
	 *
	 * @var string
	 */
	protected $childTheme = '';

	/**
	 * The CSS framework to use.
	 *
	 * @var string
	 */
	protected $cssFramework = '';

	/**
	 * Supported tag characters.
	 *
	 * @var string
	 */
	protected $tagChars = '+$*';

	/**
	 * The timestamp of the last documents full cache build.
	 *
	 * @var int
	 */
	protected $lastDocFullCacheTime = 0;

	/**
	 * The database engine to read template elements from.
	 *
	 * @var flDb
	 */
	static protected $db = null;

	/**
	 * Set the database object to use.
	 *
	 * @param flDb $db The database object or null to not use a database.
	 * @return flDb The previous database object or null.
	 */
	static function setDB($db) {
		$old = self::$db;
		self::$db = $db;
		return $old;
	}

	/**
	 * Set the value of a placeholder.
	 *
	 * @param mixed $n The name of the placeholder or an associative array or an object.
	 * @param mixed $v The value to set for the placeholder only used if $n is a string.
	 */
	function setPlaceholder($n, $v = null) {
		if(is_array($n))
			$this->placeholders = array_merge($this->placeholders, $n);
		else if(is_object($n))
			$this->placeholders = array_merge($this->placeholders, get_object_vars($n));
		else
			$this->placeholders[$n] = $v;
	}

	/**
	 * Unset an existing placeholder.
	 *
	 * @param string $name The name of the placeholder to unset.
	 */
	function unsetPlaceholder($name) {
		unset($this->placeholders[$name]);
	}

	/**
	 * Get the value of the placeholder.
	 *
	 * @param string $name The name of the placeholder to get the value of.
	 * @param string $default The default value to return.
	 * @return string The value of the placeholder.
	 */
	function getPlaceholder($name, $default = '') {
		return isset($this->placeholders[$name]) ? $this->placeholders[$name] : $default;
	}

	/**
	 * Register a chunk with the chunk cache.
	 *
	 * @param string $name The name for the chunk.
	 * @param string $html The HTML for the chunk.
	 */
	function registerChunk($name, $html) {
		$this->chunkCache[$name] = $html;
	}

	/**
	 * Test if a chunk is registered or cached.
	 *
	 * @param string $name The name of the chunk to test for.
	 * @return bool true if the chunk is registered or loaded into the chunk cache.
	 */
	function chunkLoaded($name) {
		return isset($this->chunkCache[$name]);
	}

	/**
	 * Get a chunk and return it unmodified.
	 *
	 * @param string $name The name of the chunk to get.
	 * @return string The HTML that is the chunk or '' if no chunk.
	 */
	function getChunk($name) {
		if(!isset($this->chunkCache[$name])) {
			$content = false;

			// Load from the database if available
			if(self::$db !== null) {
				if(empty($this->theme))
					$content = self::$db->fetchOne('SELECT content FROM fl_chunks WHERE chunkname=? && themeid=0', $name);
				else
					$content = self::$db->fetchOne('SELECT content FROM fl_chunks LEFT JOIN fl_themes USING(themeid) WHERE fl_chunks.chunkname=? && (fl_chunks.themeid=0 OR fl_themes.themename=?) ORDER BY fl_chunks.themeid DESC LIMIT 1', $name, $this->childTheme);
			}

			// Try theme & child theme
			if($content === false && !empty($this->theme)) {
				$f = FLTHEMEPATH . $this->theme . '/';
				if(!empty($this->childTheme) && is_readable($f . $this->childTheme . '/' . $name . '.html'))
					$content = @file_get_contents($f . $this->childTheme . '/' . $name . '.html');
				if($content === false && is_readable($f . $name . '.html'))
					$content = @file_get_contents($f . $name . '.html');
			}

			// If not loaded try app/modules in filesystem
			if($content === false) {
				@list($module, $d) = explode('.', $name, 2);
				$f = ($d === null ? FLAPPPATH : (FLMODPATH . $module . '/')) . 'chunks/' . $name;
				if(!empty($this->cssFramework) && is_readable($f . $this->cssFramework . '.html'))
					$content = @file_get_contents($f . $this->cssFramework . '.html');
				else if(is_readable($f . '.html'))
					$content = @file_get_contents($f . '.html');
			}

			$this->chunkCache[$name] = $content ? $content : '';
		}

		return $this->chunkCache[$name];
	}

	/**
	 * Get the contents of a named chunk parsing it and replacing tags.
	 *
	 * @param string $name The name of the chunk to get.
	 * @param array $placeholders Name value pair array of placeholders.
	 * @return string The HTML that is the chunk or '' if no chunk.
	 */
	function parseChunk($name, $placeholders = array()) {
		return $this->parseString($this->getChunk($name), $placeholders);
	}

	/**
	 * Register a snippet with the snippet cache.
	 *
	 * @param string $name The name of the snippet.
	 * @param callback $fn The callable function.
	 * The 1st parameter is the template engine flTemplateEngine and the 2nd is the array
	 * of parameters.
	 * @throws flTemplateEngineException
	 */
	function registerSnippet($name, $fn) {
		if(!is_callable($fn))
			throw new flTemplateEngineException("Function is not callable when registering snippet '$name'.");
		$this->snippetCache[$name] = $fn;
	}

	/**
	 * Run a snippet.
	 *
	 * @param string $name The name of the snippet to run.
	 * @param array $params Array of name value pairs to pass to the snippet.
	 * @return string The results of executing the snippet.
	 */
	function runSnippet($name, $params = array()) {
		// If not already loaded load snippet
		if(!isset($this->snippetCache[$name])) {
			$code = false;

			// Load from database
			if(self::$db !== null)
				$code = self::$db->fetchOne('SELECT content FROM fl_snippets WHERE snippetname=?', $name);

			// Try theme
			if(!$code && !empty($this->theme) && is_readable($f = FLTHEMEPATH . $this->theme . "/snippets/$name.php")) {
				if($code = @file_get_contents($f))
					$code = preg_replace('/\?>.*$/s', '', preg_replace('/.*<\?php/s', '', $code));
			}

			// If not loaded then try the file system
			if(!$code) {
				@list($module, $d) = explode('.', $name, 2);
				if(is_readable($f = ($d === null ? FLAPPPATH : (FLMODPATH . $module . '/')) . "snippets/$name.php") && ($code = @file_get_contents($f)))
					$code = preg_replace('/\?>.*$/s', '', preg_replace('/.*<\?php/s', '', $code));
			}

			if(!$code) $code = '';
			$this->snippetCache[$name] = create_function('$tplEngine,$_params', "extract(\$_params); $code");
		}

		ob_start();
		call_user_func($this->snippetCache[$name], $this, $params);
		return ob_get_clean();
	}

	/**
	 * Private function to parse a tag and execute it if not cached,
	 * parsed data is sent to the output.
	 *
	 * @param int $pos The position in the data.
	 * @param string $source The input data.
	 * @param int $length The length of the data.
	 * @param bool $dirty Flags if data updated.
	 * @param bool $keepNotCached true to keep uncached tags as uncached.
	 */
	private function _parseTag(& $pos, $source, $length, & $dirty, $keepNotCached) {

		$curPos = $pos;
		$params = array();
		$isCached = $isOpen = true;
		$type = $var = $val = '';

		// Test if cached
		if(@$source[$pos] == '!') {
			$pos++;
			$isCached = false;
		}

		// Tag type
		if(strpos($this->tagChars, @$source[$pos]) !== false)
			$type = $source[$pos++];

		// Get the name
		$tag = '';
		while($pos < $length && (ctype_alnum($source[$pos]) || $source[$pos] == '_' || $source[$pos] == '.'))
			$tag .= $source[$pos++];

		// Check it looks valid
		if($pos < $length && (!empty($tag) && ($source[$pos] == '?' || $source[$pos] == ']' || $source[$pos] == ' ' || $source[$pos] == '&'))) {

			// Consume ? if present
			if($source[$pos] == '?' )
				$pos++;

			$inValue = false;
			while($pos < $length) {
				if($inValue && $source[$pos] == '[' && @$source[$pos+1] == '[') {
					$pos+=2;
					ob_start();
					$this->_parseTag($pos, $source, $length, $dirty, $keepNotCached);
					$val .= ob_get_clean();
				}
				else if($source[$pos] == ']' && @$source[$pos+1] == ']') {
					$pos+=2;
					$isOpen = false;
					break;
				}
				else if(!$inValue && $source[$pos] == '&') {
					$pos++;

					// Ignore a placeholder with parameters
					if($type == '+')
						break;

					// &amp; is same as &
					if($source[$pos] == 'a' && substr($source, $pos, 4) == 'amp;')
						$pos+=4;

					$var = $val = '';

					// get the parameter name
					while($pos < $length && (ctype_alnum($source[$pos]) || $source[$pos] == '_')) {
						$var .= $source[$pos++];
					}

					// Check for =`
					if(@$source[$pos] != '=' || @$source[$pos+1] != '`')
						break;
					$pos += 2;
					$inValue = true;
				}
				else if($inValue) {
					if($source[$pos] == '`') {
						$inValue = false;
						$params[$var] = $val;
					}
					else
						$val .= $source[$pos];
					$pos++;
				}
				else if(ctype_space($source[$pos]))
					$pos++;
				else {
					$pos++;
					break;
				}
			}
		}

		// If get to end without ]] then output as text
		if($isOpen)
			echo '[[' . substr($source, $curPos, $pos - $curPos);
		// If cached then process the tag
		else if($isCached) {
			$dirty = true;
			$this->_processTag($type, $tag, $params);
		}
		// Else convert to cached tag
		else {
			if($keepNotCached)
				echo "[[!{$type}{$tag}";
			else {
				$dirty = true;
				echo "[[{$type}{$tag}";
			}
			if(count($params)) {
				foreach($params as $n => $v)
					echo " &{$n}=`$v`";
			}
			echo ']]';
		}
	}

	/**
	 * Internal function to process a tag, override to add tags.
	 *
	 * @param string $type The type character.
	 * @param string $tag The tag string.
	 * @param array $params Array of parameters passed to the tag.
	 */
	protected function _processTag($type, $tag, $params) {
		if($type == '+' || $type == '*') {
			if(isset($this->placeholders[$tag]))
				echo $this->placeholders[$tag];
		}
		else if($type == '$')
			echo $this->_parseLoop($this->getChunk($tag), true, $params);
		else if($type == '')
			echo $this->runSnippet($tag, $params);
	}

	/**
	 * Internal function to run a parse loop.
	 *
	 * @param string $string The string to parse.
	 * @param bool $keepNotCached true if to keep non-cached tags untouched or false to expand them to cachable.
	 * @param array $params Placeholder parameters.
	 * @return string The parsed string.
	 */
	private function _parseLoop($string, $keepNotCached, $params) {
		// Save the current placeholders & merge in new placeholders
		$savedPlaceholders = $this->placeholders;
		$this->placeholders = array_merge($this->placeholders, $params);

		$loops = 10;
		do {
			$dirty = false;
			$string = $this->_parseString($string, $dirty, $keepNotCached);
		} while(--$loops && $dirty);

		// Restore placeholders
		$this->placeholders = $savedPlaceholders;

		return $string;
	}

	/**
	 * Internal function to parse a string replacing tags as required.
	 *
	 * @param string $string The string to parse.
	 * @param bool $dirty Set to true if any tags were expanded.
	 * @param bool $keepNotCached true if to keep non-cached tags untouched or false to expand them to cachable.
	 * @return string The parsed string.
	 */
	private function _parseString($string, & $dirty, $keepNotCached) {
		ob_start();

		$pos = 0;
		$length = strlen($string);

		while($pos < $length) {
			if(($p = strpos($string, '[', $pos)) === false) {
				echo substr($string, $pos);
				break;
			}
			else if(@$string[$p+1] == '[') {
				echo substr($string, $pos, $p - $pos);
				$pos = $p + 2;
				$this->_parseTag($pos, $string, $length, $dirty, $keepNotCached);
			}
			else {
				$p++;
				echo substr($string, $pos, $p - $pos);
				$pos = $p;
			}
		}

		return ob_get_clean();
	}

	/**
	 * Parse a string replacing tags as required.
	 *
	 * @param string $string The string to parse.
	 * @param array $placeholders Name value pair array of placeholders.
	 * @return string The parsed string.
	 */
	function parseString($string, $placeholders = array()) {
		return $this->_parseLoop($string, false, $placeholders);
	}

	/**
	 * Set the theme to use.
	 *
	 * @param string $theme The name of the theme to use.
	 * @return flTemplateEngine This object.
	 */
	function setTheme($theme) {
		$this->theme = $theme;
		return $this;
	}

	/**
	 * Set the child theme to use.
	 *
	 * @param string $childTheme The name of the child theme to use.
	 * @return flTemplateEngine This object.
	 */
	function setChildTheme($childTheme) {
		$this->childTheme = $childTheme;
		return $this;
	}

	/**
	 * Set the name of the CSS framework to use.
	 *
	 * @param string $cssFramework The name of the CSS framework.
	 * @return flTemplateEngine This object.
	 */
	function setCSSFramework($cssFramework) {
		$this->cssFramework = empty($cssFramework) ? '' : ".$cssFramework";
		return $this;
	}

	/**
	 * Set the cache to use for parseDocument.
	 *
	 * @param flCache $cache The optional cache object.
	 * @param int $lifetime The optional life time for the page in the cache.
	 * @return flTemplateEngine This object.
	 */
	function setDocumentCache($cache, $lifetime = null) {
		$this->cache = $cache;
		$this->lifetime = $lifetime;
		return $this;
	}

	/**
	 * Set the code buffer object to use.
	 *
	 * @param flBufferCode $codeBuffer Optional code buffer for use in the page.
	 * @return flTemplateEngine This object.
	 */
	function setCodeBuffer($codeBuffer) {
		$this->codeBuffer = $codeBuffer;
		return $this;
	}

	/**
	 * Get the code buffer object used for storing CSS & JS.
	 *
	 * @return flBufferCode The code buffer object.
	 */
	function getCodeBuffer() {
		if($this->codeBuffer === null) $this->codeBuffer = new flBufferCode();
		return $this->codeBuffer;
	}

	/**
	 * Pre-parse marked placeholders.
	 *
	 * @param array $matches The matches.
	 * @return string The replacement string.
	 */
	protected function _preReplace($matches) {
		if(isset($this->placeholders[$r = $matches[1]])) {
			$dirty = false;
			$r = $this->_parseString($this->placeholders[$r], $dirty, true);
		}
		return $r;
	}

	/**
	 * Parse a document.
	 *
	 * @param string $name The name of the template chunk to use for the document.
	 * @param string $docid Unique ID of the document.
	 * @param array $placeholders Name value pair array of placeholders.
	 * @return string The parsed document string.
	 */
	function parseDocument($name, $docid, $placeholders = array()) {

		$docid = 'flTemplateEngine_' . $docid;

		// Make sure a code buffer is available
		$this->getCodeBuffer();

		// If not cached
		if($this->cache === null || !($data = $this->cache->get($docid))) {
			// Parse tags & change non-cached tags to cached tags after pre-passing * placeholders
			$dirty = false;
			$this->placeholders = array_merge($this->placeholders, $placeholders);
			$data[0] = $this->_parseString($this->_parseLoop(
				preg_replace_callback(
					'/\[\[\*([a-z0-9_.]+)\]\]/i',
					array($this, '_preReplace'),
					$this->getChunk($name)
				),
				true, array()), $dirty, false
			);

			$buf = $this->codeBuffer->getHeadBuffer();
			if(!empty($buf))
				$data[0] = str_replace('</head>', $buf . '</head>', $data[0]);
			$buf = $this->codeBuffer->getBodyBuffer();
			if(!empty($buf))
				$data[0] = str_replace('</body>', $buf . '</body>', $data[0]);

			// Build the list of included files cached
			$data[1] = $this->codeBuffer->getJSFileList();
			$data[2] = $this->codeBuffer->getCSSFileList();

			// If fully cached then use cache server time as generation time so works over clusters
			$data[3] = $dirty ? 0 : ($this->cache !== null ? $this->cache->getServerTime() : time());

			// Cache the data
			if($this->cache !== null)
				$this->cache->store($docid, $data, $this->lifetime);
		}

		// Clear the code buffer
		$this->codeBuffer->clear()->setJSIgnoreList($data[1])->setCSSIgnoreList($data[2]);

		// If dirty do final parse, process all cached & non-cached tags until nothing left
		if(!$data[3]) {
			$data[0] = $this->parseString($data[0], $placeholders);

			$buf = $this->codeBuffer->getHeadBuffer();
			if(!empty($buf))
				$data[0] = str_replace('</head>', $buf . '</head>', $data[0]);
			$buf = $this->codeBuffer->getBodyBuffer();
			if(!empty($buf))
				$data[0] = str_replace('</body>', $buf . '</body>', $data[0]);
		}
		$this->lastDocFullCacheTime = $data[3];
		return $data[0];
	}

	/**
	 * The timestamp of the last full document parse.
	 *
	 * @return int Timestamp of full cache or 0 if document has dynamic content.
	 */
	function lastDocFullCacheTime() {
		return $this->lastDocFullCacheTime;
	}
}
