<?php

namespace XF\Template;

use XF\Language;
use XF\Template\Compiler\Ast;
use XF\Template\Compiler\CodeScope;
use XF\Template\Compiler\Exception;
use XF\Template\Compiler\Func\AbstractFn;
use XF\Template\Compiler\Lexer;
use XF\Template\Compiler\Parser;
use XF\Template\Compiler\Syntax\AbstractSyntax;
use XF\Template\Compiler\Syntax\Expression;
use XF\Template\Compiler\Syntax\Quoted;
use XF\Template\Compiler\Syntax\Str;
use XF\Template\Compiler\Syntax\Tag;
use XF\Template\Compiler\Syntax\Variable;
use XF\Template\Compiler\Tag\AbstractTag;

use function is_array, is_string, strlen;

class Compiler
{
	public $finalVarName = '$__finalCompiled';
	public $variableContainer = '$__vars';
	public $templaterVariable = '$__templater';
	public $macroArgumentsVariable = '$__arguments';
	public $extensionsVariable = '$__extensions';

	public $defaultContext = [
		'escape' => true,
	];

	/**
	 * @var AbstractTag[]
	 */
	protected $tags = [];

	/**
	 * @var AbstractFn[]
	 */
	protected $functions = [];

	/**
	 * @var null|Language
	 */
	protected $language = null;

	/** @var CodeScope */
	protected $codeScope;

	protected $macros = [];

	protected $extends;

	protected $extensions = [];

	/**
	 * @var Tag|null
	 */
	protected $currentMacro = null;

	protected $defaultTags = [
		'ad' => 'Ad',
		'assetupload' => 'AssetUploadRow',
		'assetuploadrow' => 'AssetUploadRow',
		'avatar' => 'Avatar',
		'breadcrumb' => 'Breadcrumb',
		'button' => 'Button',
		'callback' => 'Callback',
		'captcha' => 'Captcha',
		'captcharow' => 'CaptchaRow',
		'checkbox' => 'CheckBoxRow',
		'checkboxrow' => 'CheckBoxRow',
		'copyright' => 'Copyright',
		'codeeditor' => 'CodeEditorRow',
		'codeeditorrow' => 'CodeEditorRow',
		'corejs' => 'CoreJs',
		'csrf' => 'Csrf',
		'css' => 'Css',
		'datalist' => 'DataList',
		'datarow' => 'DataRow',
		'date' => 'Date',
		'dateinput' => 'DateInputRow',
		'dateinputrow' => 'DateInputRow',
		'datetimeinput' => 'DateTimeInputRow',
		'datetimeinputrow' => 'DateTimeInputRow',
		'description' => 'Description',
		'editor' => 'EditorRow',
		'editorrow' => 'EditorRow',
		'extends' => 'ExtendsTag',
		'extension' => 'Extension',
		'extensionparent' => 'ExtensionParent',
		'extensionvalue' => 'ExtensionValue',
		'fa' => 'FontAwesome',
		'foreach' => 'ForeachTag',
		'form' => 'Form',
		'formrow' => 'FormRow',
		'h1' => 'H1',
		'head' => 'Head',
		'hiddenval' => 'HiddenVal',
		'if' => 'IfTag',
		'include' => 'IncludeTag',
		'inforow' => 'InfoRow',
		'js' => 'Js',
		'likes' => 'Likes',
		'macro' => 'Macro',
		'mustache' => 'Mustache',
		'numberbox' => 'NumberBoxRow',
		'numberboxrow' => 'NumberBoxRow',
		'page' => 'Page',
		'pageaction' => 'PageAction',
		'pagenav' => 'PageNav',
		'passwordbox' => 'PasswordBoxRow',
		'passwordboxrow' => 'PasswordBoxRow',
		'prefixinput' => 'PrefixInputRow',
		'prefixinputrow' => 'PrefixInputRow',
		'profilebanner' => 'ProfileBanner',
		'radio' => 'RadioRow',
		'radiorow' => 'RadioRow',
		'radiotabs' => 'RadioTabs',
		'react' => 'React',
		'reaction' => 'Reaction',
		'reactions' => 'Reactions',
		'redirect' => 'RedirectInput',
		'select' => 'SelectRow',
		'selectrow' => 'SelectRow',
		'set' => 'Set',
		'showignored' => 'ShowIgnored',
		'sidebar' => 'Sidebar',
		'sidenav' => 'SideNav',
		'submitrow' => 'SubmitRow',
		'telbox' => 'TelBoxRow',
		'telboxrow' => 'TelBoxRow',
		'textarea' => 'TextAreaRow',
		'textarearow' => 'TextAreaRow',
		'textbox' => 'TextBoxRow',
		'textboxrow' => 'TextBoxRow',
		'timeinput' => 'TimeInputRow',
		'timeinputrow' => 'TimeInputRow',
		'title' => 'Title',
		'tokeninput' => 'TokenInputRow',
		'tokeninputrow' => 'TokenInputRow',
		'trim' => 'Trim',
		'upload' => 'UploadRow',
		'uploadrow' => 'UploadRow',
		'useractivity' => 'UserActivity',
		'userblurb' => 'UserBlurb',
		'userbanners' => 'UserBanners',
		'username' => 'Username',
		'usertitle' => 'UserTitle',
		'widget' => 'Widget',
		'widgetpos' => 'WidgetPos',
		'wrap' => 'Wrap',
	];

	protected $defaultFunctions = [
		'empty' => 'EmptyFn',
		'extension_value' => 'ExtensionValue',
		'include' => 'IncludeFn',
		'phrase' => 'Phrase',
		'preescaped' => 'PreEscaped',
		'vars' => 'Vars',
	];

	public function __construct(array $tags = [], array $functions = [], $withDefault = true)
	{
		if ($withDefault)
		{
			$this->setDefaultTags();
			$this->setDefaultFunctions();
		}

		$this->setTags($tags);
		$this->setFunctions($functions);
	}

	public function setLanguage(Language $language)
	{
		$this->language = $language;
	}

	public function resetLanguage()
	{
		$this->language = null;
	}

	/**
	 * @return null|Language
	 */
	public function getLanguage()
	{
		return $this->language;
	}

	public function compile($string, ?Language $language = null)
	{
		return $this->compileAst($this->compileToAst($string), $language);
	}

	/**
	 * @param string $string
	 * @param array $placeholders
	 *
	 * @return null|Ast
	 */
	public function compileToAst($string, array $placeholders = [])
	{
		$lexer = new Lexer();
		$parser = new Parser();
		$parser->placeholders = $placeholders;
		$tokens = $lexer->tokenize($string);
		foreach ($tokens AS $token)
		{
			$parser->doParse($token[0], $token[1]);
			$parser->line = $token[2];
		}
		$parser->doParse(0, 0);

		return $parser->ast;
	}

	public function stringAst($string)
	{
		return new Ast([
			new Str($string, 1),
		]);
	}

	public function reset()
	{
		$this->codeScope = new CodeScope($this->finalVarName, $this);
		$this->macros = [];
		$this->extends = null;
		$this->extensions = [];
		$this->currentMacro = null;

		foreach ($this->tags AS $tag)
		{
			$tag->reset();
		}
		foreach ($this->functions AS $function)
		{
			$function->reset();
		}
	}

	public function compileAst(Ast $ast, ?Language $language = null)
	{
		$this->reset();

		$oldLanguage = $this->language;
		if ($language)
		{
			$this->language = $language;
		}

		$this->traverseBlockChildren($ast->children, $this->defaultContext);
		$code = $this->getCompletedTemplateCode();

		$this->language = $oldLanguage;

		return $code;
	}

	/**
	 * @param AbstractSyntax[] $children
	 * @param array $context
	 *
	 * @return $this
	 */
	public function traverseBlockChildren(array $children, array $context)
	{
		foreach ($children AS $child)
		{
			$this->inline($child->compile($this, $context, false));
		}

		return $this;
	}

	/**
	 * @param AbstractSyntax[] $list
	 * @param array $context
	 *
	 * @return string
	 */
	public function compileInlineList(array $list, array $context)
	{
		$output = [];

		foreach ($list AS $item)
		{
			$code = $item->compile($this, $context, true);
			if (is_string($code) && $code != '')
			{
				$output[] = $code;
			}
		}

		if ($output)
		{
			return $this->simplifyInlineCode(implode(' . ', $output));
		}
		else
		{
			return "''";
		}
	}

	public function compileToArraySyntax($syntax, $name, array $context)
	{
		if (is_array($syntax))
		{
			$compiled = $this->compileInlineList($syntax, $context);
		}
		else if ($syntax instanceof AbstractSyntax)
		{
			$compiled = $syntax->compile($this, $context, true);
		}
		else
		{
			throw new \InvalidArgumentException("Syntax argument must be AbstractSyntax object or array");
		}

		return "\n" . $this->indent() . "\t"
			. $this->getStringCode($name) . ' => ' . $compiled . ",";
	}

	public function forceToExpression(AbstractSyntax $input)
	{
		$originalInput = $input;

		if ($input instanceof Quoted)
		{
			$input = $input->parts;
		}
		else if ($input instanceof Str)
		{
			$input = $input->content;
		}
		else if ($input instanceof AbstractSyntax)
		{
			return $input;
		}

		$input = (array) $input;

		$placeholders = [];
		$placeholderId = 0;

		$expression = '{{ ';
		foreach ($input AS $part)
		{
			if (is_string($part))
			{
				$expression .= $part;
			}
			else if ($part instanceof Str)
			{
				$expression .= $part->content;
			}
			else
			{
				$expression .= " ##$placeholderId ";
				$placeholders[$placeholderId] = $part;
				$placeholderId++;
			}
		}
		$expression .= ' }}';

		try
		{
			/** @var AbstractSyntax $output */
			$output = $this->compileToAst($expression, $placeholders)->children[0];
			if ($output instanceof Expression)
			{
				/** @var Expression $output */
				$output = $output->expression;
			}
			$output->line = $originalInput->line;
			return $output;
		}
		catch (Exception $e)
		{
			throw $originalInput->exception(\XF::phrase('expected_valid_expression'));
		}
	}

	public function compileForcedExpression(AbstractSyntax $input, array $context)
	{
		$context['escape'] = false;
		return $this->forceToExpression($input)->compile($this, $context, true);
	}

	/**
	 * @param AbstractSyntax $syntax
	 *
	 * @return Variable
	 *
	 * @throws Exception
	 */
	public function requireSimpleVariable(AbstractSyntax $syntax)
	{
		if ($syntax instanceof Variable)
		{
			if ($syntax->isSimple())
			{
				return $syntax;
			}
		}
		else if ($syntax instanceof Str)
		{
			if (strlen($syntax->content))
			{
				$parts = explode('.', $syntax->content);
				$name = array_shift($parts);
				if (preg_match('#^\$' . Lexer::LITERAL_REGEX . '$#siU', $name))
				{
					$name = substr($name, 1);
					$dimensions = [];
					$matched = true;

					foreach ($parts AS $part)
					{
						if (preg_match('#^' . Lexer::LITERAL_REGEX . '$#siU', $part))
						{
							$dimensions[] = ['array', new Str($part, $syntax->line)];
						}
						else
						{
							$matched = false;
							break;
						}
					}

					if ($matched)
					{
						return new Variable($name, $dimensions, [], $syntax->line);
					}
				}
			}
		}

		throw new Exception(\XF::string([
			\XF::phrase('line_x', ['line' => $syntax->line]), ': ',
			\XF::phrase('expected_simple_variable_reference_but_did_not_receive_one'),
		]));
	}

	public function compileSimpleVariable(AbstractSyntax $input, array $context)
	{
		return $this->requireSimpleVariable($input)->compile($this, $context, true);
	}

	public function getStringCode($string)
	{
		return "'" . addcslashes($string, "\\'") . "'";
	}

	public function simplifyInlineCode($code)
	{
		//$code = preg_replace('#(?<!\\\\)\' \. \'#', '', $code);

		return $code;
	}

	public function defineMacro($name, $functionCode)
	{
		$nameString = $this->getStringCode($name);
		$this->macros[$name] = "{$nameString} => {$functionCode}";
	}

	public function getMacros()
	{
		return $this->macros;
	}

	public function setExtendsCode($extendsCode)
	{
		$this->extends = "function({$this->templaterVariable}, array {$this->variableContainer}) { return {$extendsCode}; }";
	}

	public function getExtendsCode()
	{
		return $this->extends;
	}

	public function defineExtension($name, $functionCode, Tag $extensionTag)
	{
		$nameString = $this->getStringCode($name);

		$extensionCode = "{$nameString} => function({$this->templaterVariable}, array {$this->variableContainer}, {$this->extensionsVariable} = null)
{
	{$functionCode}
}";

		if ($this->currentMacro)
		{
			// macro scoped extension
			if (isset($this->currentMacro->extensions[$name]))
			{
				throw $extensionTag->exception(\XF::phrase('extension_x_already_defined', ['name' => $name]));
			}

			$this->currentMacro->extensions[$name] = $extensionCode;
		}
		else
		{
			if (isset($this->extensions[$name]))
			{
				throw $extensionTag->exception(\XF::phrase('extension_x_already_defined', ['name' => $name]));
			}

			$this->extensions[$name] = $extensionCode;
		}
	}

	public function getExtensions()
	{
		return $this->extensions;
	}

	public function setCurrentMacro(?Tag $tag = null)
	{
		if ($tag && $tag->name !== 'macro')
		{
			throw new \LogicException("Tag passed into setCurrentMacro is not a macro (received {$tag->name} instead)");
		}

		$this->currentMacro = $tag;
	}

	public function getCurrentMacro()
	{
		return $this->currentMacro;
	}

	protected function getCompletedTemplateCode()
	{
		$parts = [];
		if ($this->extends)
		{
			$parts[] = "'extends' => {$this->extends}";
		}
		if ($this->extensions)
		{
			$extensions = implode(",\n", $this->extensions);
			$parts[] = "'extensions' => array({$extensions})";
		}

		if ($this->macros)
		{
			$macros = implode(",\n", $this->macros);
			$parts[] = "'macros' => array({$macros})";
		}

		$output = implode("\n", $this->getOutput());
		$parts[] = "'code' => function({$this->templaterVariable}, array {$this->variableContainer}, {$this->extensionsVariable} = null)
{
	{$this->finalVarName} = '';
{$output}
	return {$this->finalVarName};
}";
		return "return array(\n" . implode(",\n", $parts) . "\n);";
	}

	public function getCodeScope()
	{
		return $this->codeScope;
	}

	public function setCodeScope(CodeScope $codeScope)
	{
		$this->codeScope = $codeScope;
	}

	public function getOutput()
	{
		return $this->codeScope->getOutput();
	}

	public function write($code)
	{
		$this->codeScope->write($code);
		return $this;
	}

	public function inline($code)
	{
		$this->codeScope->inline($code);
		return $this;
	}

	public function currentVar()
	{
		return $this->codeScope->currentVar();
	}

	public function pushTempVar($init = true)
	{
		return $this->codeScope->pushTempVar($init);
	}

	public function pushVar($var)
	{
		$this->codeScope->pushVar($var);
		return $this;
	}

	public function popVar()
	{
		return $this->codeScope->popVar();
	}

	public function getTempVar()
	{
		return $this->codeScope->getTempVar();
	}

	public function indent()
	{
		return $this->codeScope->indent();
	}

	public function pushIndent()
	{
		$this->codeScope->pushIndent();
		return $this;
	}

	public function popIndent()
	{
		$this->codeScope->popIndent();
		return $this;
	}

	/**
	 * @param string $name
	 *
	 * @return AbstractTag|false
	 */
	public function getTag($name)
	{
		return $this->tags[$name] ?? false;
	}

	public function setDefaultTags()
	{
		return $this->setTags($this->defaultTags);
	}

	public function setTags(array $tags)
	{
		foreach ($tags AS $name => $tag)
		{
			$this->setTag($name, $tag);
		}

		return $this;
	}

	public function setTag($name, $tag)
	{
		if (is_string($tag))
		{
			$class = $tag[0] == '\\' ? $tag : __NAMESPACE__ . '\\Compiler\\Tag\\' . $tag;
			$tag = new $class($name);
		}
		if (!($tag instanceof AbstractTag))
		{
			throw new \InvalidArgumentException("Tag must be a class name or object that is an instance of AbstractTag");
		}

		$this->tags[$name] = $tag;

		return $this;
	}

	/**
	 * @param string $name
	 *
	 * @return AbstractFn|false
	 */
	public function getFunction($name)
	{
		$name = strtolower($name);
		return $this->functions[$name] ?? false;
	}

	public function setDefaultFunctions()
	{
		return $this->setFunctions($this->defaultFunctions);
	}

	public function setFunctions(array $functions)
	{
		foreach ($functions AS $name => $function)
		{
			$this->setFunction($name, $function);
		}

		return $this;
	}

	public function setFunction($name, $function)
	{
		$name = strtolower($name);

		if (is_string($function))
		{
			$class = $function[0] == '\\' ? $function : __NAMESPACE__ . '\\Compiler\\Func\\' . $function;
			$function = new $class($name);
		}
		if (!($function instanceof AbstractFn))
		{
			throw new \InvalidArgumentException("Function must be a class name or object that is an instance of AbstractFn");
		}

		$this->functions[$name] = $function;

		return $this;
	}
}
