<?php

/**
 * @package Dotclear
 *
 * @copyright Olivier Meunier & Association Dotclear
 * @copyright AGPL-3.0
 */
declare(strict_types=1);

namespace Dotclear\Helper\Html\Template;

use ArrayObject;
use Dotclear\Helper\File\Files;
use Dotclear\Helper\File\Path;
use Dotclear\Helper\Html\Html;
use Exception;

/**
 * @class Template
 */
class Template
{
    // Constants

    public const CACHE_FOLDER = 'cbtpl';

    /**
     * Use cache for compiled template files
     */
    public bool $use_cache = true;

    /**
     * Stack of node blocks callbacks
     *
     * @var        array<string, callable>
     */
    protected array $blocks = [];

    /**
     * Stack of node values callbacks
     *
     * @var        array<string, callable>
     */
    protected array $values = [];

    /**
     * Remove PHP from template file
     */
    protected bool $remove_php = true;

    /**
     * Unknown node value callback
     *
     * @var        callable|null
     */
    protected $unknown_value_handler;

    /**
     * Unknown node block callback
     *
     * @var        callable|null
     */
    protected $unknown_block_handler;

    /**
     * Stack of template file paths
     *
     * @var        array<string>
     */
    protected array $tpl_path = [];

    /**
     * Cache directory
     */
    protected string $cache_dir;

    /**
     * Parent file
     *
     * May be a filename or "__parent__"
     */
    protected string $parent_file = '';

    /**
     * Stack of compiled template files
     *
     * @var        array<string>
     */
    protected array $compile_stack = [];

    /**
     * Stack of parent template files
     *
     * @var        array<string>
     */
    protected array $parent_stack = [];

    // Inclusion variables

    /**
     * Super globals
     *
     * @var        array<string>
     */
    protected static array $superglobals = ['GLOBALS', '_SERVER', '_GET', '_POST', '_COOKIE', '_FILES', '_ENV', '_REQUEST', '_SESSION'];

    /**
     * Working output buffer
     *
     * @var        string|false
     */
    protected static string|bool $_r;

    /**
     * Constructs a new instance.
     *
     * @param      string  $cache_dir  The cache dir
     * @param      string  $self_name  The self name
     */
    public function __construct(
        string $cache_dir,
        /**
         * Instance self name
         *
         * Will be use in compiled template to call instance method or use instance properties
         */
        private readonly string $self_name
    ) {
        $this->setCacheDir($cache_dir);

        $this->addValue('include', $this->includeFile(...));
        $this->addBlock('Block', $this->blockSection(...));
    }

    /**
     * Node value "include" callback
     *
     * Syntax: {{tpl:include src="filename"}}
     *
     * @param      array<string, mixed>|ArrayObject<string, mixed>  $attr   The attribute
     */
    public function includeFile($attr): string
    {
        if (!isset($attr['src'])) {
            return '';
        }

        $filename = is_string($filename = $attr['src']) ? $filename : '';
        if ($filename === '') {
            return'';
        }

        $src = Path::clean($filename);

        $tpl_file = $this->getFilePath($src);
        if (!$tpl_file) {
            return '';
        }

        return
        '<?php try { echo ' . $this->self_name . "->getData('" . str_replace("'", "\'", $src) . "'); " . '} catch (Exception) {} ?>' . "\n";
    }

    /**
     * Node block "Block" callback
     *
     * Syntax: <tpl:Block name="name-of-block">[content]</tpl:Block>
     *
     * @param      array<string, mixed>|ArrayObject<string, mixed>  $attr     The attribute
     * @param      string                                           $content  The content
     */
    public function blockSection($attr, string $content): string
    {
        // Ignore attributes and return block content only
        return $content;
    }

    /**
     * Sets the template path(s).
     *
     * Arguments may be a string or an array of string
     */
    public function setPath(): void
    {
        /**
         * @var array<array-key, mixed>
         */
        $args = [];

        foreach (func_get_args() as $value) {
            if (is_array($value)) {
                $args = [...$args, ...array_values($value)];
            } else {
                $args[] = $value;
            }
        }

        $paths = [];

        foreach ($args as $value) {
            if (is_string($value)) {
                $path = Path::real($value);
                if ($path !== false) {
                    $paths[] = $path;
                }
            }
        }

        $this->tpl_path = array_unique($paths);
    }

    /**
     * Prepend a template path.
     *
     * @param   string  $path   The path to prepend
     */
    public function prependPath(string $path): void
    {
        $this->setPath($path, $this->getPath());
    }

    /**
     * Append a template path.
     *
     * @param   string  $path   The path to append
     */
    public function appendPath(string $path): void
    {
        $this->setPath($this->getPath(), $path);
    }

    /**
     * Gets the template paths.
     *
     * @return     array<string>
     */
    public function getPath(): array
    {
        return $this->tpl_path;
    }

    /**
     * Sets the cache dir.
     *
     * @param      string     $dir    The dir
     *
     * @throws     Exception
     */
    public function setCacheDir(string $dir): void
    {
        if (!is_dir($dir)) {
            throw new Exception($dir . ' is not a valid directory.');
        }

        if (!is_writable($dir)) {
            throw new Exception($dir . ' is not writable.');
        }

        $this->cache_dir = Path::real($dir) . '/';
    }

    /**
     * Adds node blocks callbacks.
     *
     * @see        self::addBlock()
     *
     *
     * @param      array<string, callable>  $blocks      The blocks
     */
    public function addBlocks(array $blocks): void
    {
        foreach ($blocks as $name => $callback) {
            if (is_string($name)) { // @phpstan-ignore function.alreadyNarrowedType ((PHPDoc only))
                $this->addBlock($name, $callback);
            }
        }
    }

    /**
     * Adds a node block callback.
     *
     * The callback signature must be: callback(array $attr, string &$content)
     *
     * @param      string               $name      The name
     * @param      callable             $callback  The callback
     *
     * @throws     Exception
     */
    public function addBlock(string $name, $callback): void
    {
        if (!is_callable($callback)) {  // @phpstan-ignore function.alreadyNarrowedType (waiting for add a callable type in method signature)
            throw new Exception('No valid callback for ' . $name);
        }

        $this->blocks[$name] = $callback;
    }

    /**
     * Adds node values callbacks.
     *
     * @see        self::addValue()
     *
     * @param      array<string, callable>  $values      The values
     */
    public function addValues(array $values): void
    {
        foreach ($values as $name => $callback) {
            if (is_string($name)) { // @phpstan-ignore function.alreadyNarrowedType ((PHPDoc only))
                $this->addValue($name, $callback);
            }
        }
    }

    /**
     * Adds a node value callback.
     *
     * The callback signature must be: callback(array $attr [, string $str_attr])
     *
     * @param      string               $name      The name
     * @param      callable             $callback  The callback
     *
     * @throws     Exception
     */
    public function addValue(string $name, $callback): void
    {
        if (!is_callable($callback)) {  // @phpstan-ignore function.alreadyNarrowedType (waiting for add a callable type in method signature)
            throw new Exception('No valid callback for ' . $name);
        }

        $this->values[$name] = $callback;
    }

    /**
     * Determines if node block exists.
     *
     * @param      string  $name   The name
     *
     * @return     bool    True if block exists, False otherwise.
     */
    public function blockExists(string $name): bool
    {
        return isset($this->blocks[$name]);
    }

    /**
     * Determines if node value exists.
     *
     * @param      string  $name   The name
     *
     * @return     bool    True if value exists, False otherwise.
     */
    public function valueExists(string $name): bool
    {
        return isset($this->values[$name]);
    }

    /**
     * Determines if ndoe tag (value or block) exists.
     *
     * @param      string  $name   The name
     *
     * @return     bool    True if tag exists, False otherwise.
     */
    public function tagExists(string $name): bool
    {
        if ($this->blockExists($name)) {
            return true;
        }

        return $this->valueExists($name);
    }

    /**
     * Gets the node value callback.
     *
     * @param      string  $name   The value name
     *
     * @return     callable|false    The block callback.
     */
    public function getValueCallback(string $name)
    {
        if ($this->valueExists($name)) {
            return $this->values[$name];
        }

        return false;
    }

    /**
     * Gets the node block callback.
     *
     * @param      string  $name   The block name
     *
     * @return     callable|false    The block callback.
     */
    public function getBlockCallback(string $name)
    {
        if ($this->blockExists($name)) {
            return $this->blocks[$name];
        }

        return false;
    }

    /**
     * Gets the node blocks list.
     *
     * @return     array<string>  The blocks list.
     */
    public function getBlocksList(): array
    {
        return array_keys($this->blocks);
    }

    /**
     * Gets the node values list.
     *
     * @return     array<string>  The values list.
     */
    public function getValuesList(): array
    {
        return array_keys($this->values);
    }

    /**
     * Add on the fly custom header to all files.
     *
     * @return     string  The custom content
     */
    protected function addFileHeader(): string
    {
        return '';
    }

    /**
     * Gets the template file fullpath, creating it if not exist or not in cache and recent enough.
     *
     * @param      string     $file   The file
     *
     * @throws     Exception
     */
    public function getFile(string $file): string
    {
        $tpl_file = $this->getFilePath($file);

        if (!$tpl_file) {
            throw new Exception('No template found for ' . $file);
        }

        $dest_file = $this->getFileCachePath($tpl_file);

        clearstatcache();
        $stat_f = false;
        $stat_d = false;
        if (file_exists($dest_file)) {
            $stat_f = stat($tpl_file);
            $stat_d = stat($dest_file);
        }

        # We create template if:
        # - dest_file doest not exists
        # - we don't want cache
        # - dest_file size == 0
        # - tpl_file is more recent thant dest_file
        if (!$stat_d || !$this->use_cache || $stat_d['size'] == 0 || ($stat_f !== false && $stat_f['mtime'] > $stat_d['mtime'])) {
            Files::makeDir(dirname($dest_file), true);

            if (($fp = @fopen($dest_file, 'wb')) === false) {
                throw new Exception('Unable to create cache file');
            }

            $fc = $this->addFileHeader() . $this->compileFile($tpl_file, !$this->use_cache);
            fwrite($fp, $fc);
            fclose($fp);
            Files::inheritChmod($dest_file);
        }

        return $dest_file;
    }

    /**
     * Gets the file cache path.
     *
     * @param      string  $tpl_file  The tpl file (full path)
     *
     * @return     string  The file cache path.
     */
    public function getFileCachePath(string $tpl_file): string
    {
        $file_md5 = md5($tpl_file);

        return sprintf(
            '%s/%s/%s/%s/%s.php',
            $this->cache_dir,
            self::CACHE_FOLDER,
            substr($file_md5, 0, 2),
            substr($file_md5, 2, 2),
            $file_md5
        );
    }

    /**
     * Gets the file path.
     *
     * @param      string       $file   The file
     */
    public function getFilePath(string $file): false|string
    {
        foreach ($this->tpl_path as $p) {
            if (file_exists($p . '/' . $file)) {
                return $p . '/' . $file;
            }
        }

        return false;
    }

    /**
     * Gets the parent file path.
     *
     * @param      string       $previous_path  The previous path
     * @param      string       $file           The file
     */
    public function getParentFilePath(string $previous_path, string $file): false|string
    {
        $check_file = false;
        foreach ($this->tpl_path as $p) {
            if ($check_file && file_exists($p . '/' . $file)) {
                return $p . '/' . $file;
            }

            if ($p == $previous_path) {
                $check_file = true;
            }
        }

        return false;
    }

    /**
     * Gets the template file content.
     *
     * @param      string  $________    The template filename
     * @param      bool    $globals     Declare non superglobals in $GLOBALS as global PHP variables?
     *
     * @return     string  The data.
     */
    public function getData(string $________, bool $globals = true): string
    {
        if ($globals) {
            // Cope with global variables
            foreach (array_keys($GLOBALS) as $global_var) {
                if (is_string($global_var) && !in_array($global_var, self::$superglobals)) {
                    global ${$global_var};
                }
            }
        }

        $dest_file = $this->getFile($________);
        ob_start();
        if (ini_get('display_errors')) {
            include $dest_file;
        } else {
            @include $dest_file;
        }

        self::$_r = (string) ob_get_contents();
        ob_end_clean();

        return self::$_r;
    }

    /**
     * Gets the compiled tree.
     *
     * @param      string   $file   The file
     * @param      string   $err    The error
     *
     * @return     TplNode  The compiled tree.
     */
    protected function getCompiledTree(string $file, string &$err): TplNode
    {
        $fc = (string) file_get_contents($file);

        $this->compile_stack[] = $file;

        // Remove every PHP tags
        if ($this->remove_php) {
            $fc = (string) preg_replace('/<\?(?=php|=|\s).*?\?>/ms', '', $fc);
        }

        // Transform what could be considered as PHP short tags
        $fc = (string) preg_replace(
            '/(<\?(?!php|=|\s))(.*?)(\?>)/ms',
            '<?php echo "$1"; ?>$2<?php echo "$3"; ?>',
            $fc
        );

        // Remove template comments <!-- #... -->
        $fc = (string) preg_replace('/(^\s*)?<!-- #(.*?)-->/ms', '', $fc);

        # = tpl: blocks whitespace control syntax =
        # <tpl:...> or {{tpl:...}}
        #   No whitespace processing (original lexer behaviour)
        #
        # <~tpl:...> or {{~tpl:...}}
        #   Remove whitespaces before tag, only on the current line
        #   (stop processing at the beginning of line if reached)
        #
        # <tpl:...~> or {{tpl:...~}}
        #   Remove whitespaces after tag, up to and including one newline
        #   (stop processing at the end of line if reached)
        #

        # whitespace range definition - \s is not used on purpose here,
        # because the matching is done using the multiline option. Using
        # \s would consider newlines as valid whitespace.
        $ws = '[ \t]*';

        # wsnl adds an optional newline at the end of the previous
        # whitespace definition.
        $wsnl = $ws . "\n?";

        $blocks_re = '#'
           # opening <tpl> blocks
           . '(<tpl:\w+[^~>]*>)|'                      # no processing, plain block
           . "(<tpl:\w+[^~>]*~>){$wsnl}|"              # <tpl:...~>
           . "{$ws}(<~tpl:\w+[^~>]*>)|"                # <~tpl:...>
           . "{$ws}(<~tpl:\w+[^~>]*~>){$wsnl}|"        # <~tpl:...~>
           # closing </tpl> blocks
           . '(</tpl:\w+[^~]>)|'                       # no processing, plain block
           . "(</tpl:\w+~>){$wsnl}|"                   # </tpl:...~>
           . "{$ws}(</~tpl:\w+[^~]>)|"                 # </~tpl:...>
           . "{$ws}(</~tpl:\w+~>){$wsnl}|"             # </~tpl:...~>
           # brace {{tpl}} blocks
           . '({{tpl:\w+[^~}]*}})|'                    # no processing, plain block
           . "({{tpl:\w+[^~}]*~}}){$wsnl}|"            # {{tpl:...~}}
           . "{$ws}({{~tpl:\w+[^~}]*}})|"              # {{~tpl:...}}
           . "{$ws}({{~tpl:\w+[^~}]*~}}){$wsnl}"       # {{~tpl:...~}}
           . '#msu';

        // Lexer part : split file into small pieces
        // each array entry will be either a tag or plain text
        $blocks = preg_split(
            $blocks_re,
            $fc,
            -1,
            PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
        );

        // Next : build semantic tree from tokens.
        $rootNode          = new TplNode();
        $node              = $rootNode;
        $errors            = [];
        $this->parent_file = '';
        if ($blocks !== false) {
            foreach ($blocks as $block) {
                $isblock = preg_match('#<~?tpl:(\w+)(?:(\s+.*?)~?>|~?>)|</~?tpl:(\w+)~?>|{{~?tpl:(\w+)(\s(.*?))?~?}}#ms', $block, $match);
                if ($isblock == 1) {
                    if (substr($match[0] ?? '', 1, 1) === '/') {
                        // Closing tag, check if it matches current opened node
                        $tag = $match[3] ?? '';
                        if (($node instanceof TplNodeBlock) && $node->getTag() === $tag) {
                            $node->setClosing();
                            $node = $node->getParent();
                        } else {
                            // Closing tag does not match opening tag
                            // Search if it closes a parent tag
                            $search = $node;
                            while ($search?->getTag() != 'ROOT' && $search?->getTag() != $tag) {
                                $search = $search?->getParent();
                            }

                            if ($search?->getTag() == $tag) {
                                $errors[] = sprintf(
                                    __('Did not find closing tag for block <tpl:%s>. Content has been ignored.'),
                                    Html::escapeHTML($node?->getTag())
                                );
                                $search?->setClosing();
                                $node = $search?->getParent();
                            } else {
                                $errors[] = sprintf(
                                    __('Unexpected closing tag </tpl:%s> found.'),
                                    $tag
                                );
                            }
                        }
                    } elseif (str_starts_with($match[0] ?? '', '{')) {
                        // Value tag
                        $tag      = $match[4] ?? '';
                        $str_attr = '';
                        $attr     = [];
                        if (isset($match[6])) {
                            $str_attr = $match[6];
                            $attr     = $this->getAttrs($match[6]);
                        }

                        if (strtolower($tag) === 'extends') {
                            if (isset($attr['parent']) && is_string($attr['parent']) && $this->parent_file === '') {
                                $this->parent_file = $attr['parent'];
                            }
                        } elseif (strtolower($tag) === 'parent') {
                            $node?->addChild(new TplNodeValueParent($tag, $attr, $str_attr));
                        } else {
                            $node?->addChild(new TplNodeValue($tag, $attr, $str_attr));
                        }
                    } else {
                        // Opening tag, create new node and dive into it
                        $tag = $match[1] ?? '';
                        if ($tag === 'Block') {
                            $newnode = new TplNodeBlockDefinition($tag, isset($match[2]) ? $this->getAttrs($match[2]) : []);
                        } else {
                            $newnode = new TplNodeBlock($tag, isset($match[2]) ? $this->getAttrs($match[2]) : []);
                        }

                        $node?->addChild($newnode);
                        $node = $newnode;
                    }
                } else {
                    // Simple text
                    $node?->addChild(new TplNodeText($block));
                }
            }
        }

        if (($node instanceof TplNodeBlock) && !$node->isClosed()) {
            $errors[] = sprintf(
                __('Did not find closing tag for block <tpl:%s>. Content has been ignored.'),
                Html::escapeHTML($node->getTag())
            );
        }

        $err = '';
        if ($errors !== []) {
            $err = "\n\n<!-- \n" . __('WARNING: the following errors have been found while parsing template file :') . "\n * " . implode("\n * ", $errors) . "\n -->\n";
        }

        return $rootNode;
    }

    /**
     * Compile a template file
     *
     * @param      string     $file     The file
     * @param      bool       $force    Force compilation even if already in parent stack
     *
     * @throws     Exception
     */
    protected function compileFile(string $file, bool $force = false): string
    {
        $tree = null;
        $err  = '';
        while (true) {
            if ($file && ($force || !in_array($file, $this->parent_stack))) {
                $tree = $this->getCompiledTree($file, $err);

                if ($this->parent_file === '__parent__') {
                    $this->parent_stack[] = $file;
                    $newfile              = $this->getParentFilePath(dirname($file), basename($file));
                    if (!$newfile) {
                        throw new Exception('No template found for ' . basename($file));
                    }

                    $file = $newfile;
                } elseif ($this->parent_file !== '') {
                    $this->parent_stack[] = $file;
                    $file                 = $this->getFilePath($this->parent_file);
                    if ($file === false) {
                        throw new Exception('No template found for ' . $this->parent_file);
                    }
                } else {
                    return $tree->compile($this) . $err;
                }
            } else {
                if ($tree instanceof TplNode) {
                    return $tree->compile($this) . $err;
                }

                return '';
            }
        }
    }

    /**
     * Compile block node
     *
     * @param      string                                           $tag      The tag
     * @param      array<string, mixed>|ArrayObject<string, mixed>  $attr     The attribute
     * @param      string                                           $content  The content
     */
    public function compileBlockNode(string $tag, $attr, string $content): string
    {
        $res = '';

        if (isset($this->blocks[$tag])) {
            $ret = call_user_func($this->blocks[$tag], $attr, $content);
        } elseif (is_callable($this->unknown_block_handler)) {
            $ret = call_user_func($this->unknown_block_handler, $tag, $attr, $content);
        } else {
            $ret = '';
        }

        if (is_string($ret)) {
            $res .= $ret;
        }

        return $res;
    }

    /**
     * Compile value node
     *
     * @param      string                                           $tag       The tag
     * @param      array<string, mixed>|ArrayObject<string, mixed>  $attr      The attribute
     * @param      string                                           $str_attr  The string attribute
     */
    public function compileValueNode(string $tag, $attr, string $str_attr): string
    {
        $res = '';

        if (isset($this->values[$tag])) {
            $ret = call_user_func($this->values[$tag], $attr, ltrim($str_attr));
        } elseif (is_callable($this->unknown_value_handler)) {
            $ret = call_user_func($this->unknown_value_handler, $tag, $attr, $str_attr);
        } else {
            $ret = '';
        }

        if (is_string($ret)) {
            $res .= $ret;
        }

        return $res;
    }

    /**
     * Compile value
     *
     * @param      array<mixed>   $match  The match
     */
    protected function compileValue(array $match): string
    {
        $name = is_string($name = $match[1]) ? $name : '';
        if ($name === '') {
            return '';
        }

        $attr     = isset($match[2]) && is_string($match[2]) ? $this->getAttrs($match[2]) : [];
        $str_attr = is_scalar($str_attr = $match[2]) ? (string) $str_attr : '';

        $res = call_user_func($this->values[$name], $attr, ltrim($str_attr));

        return is_string($res) ? $res : '';
    }

    /**
     * Sets the unknown value handler.
     *
     * @param      callable|null  $callback  The callback
     */
    public function setUnknownValueHandler($callback): void
    {
        $this->unknown_value_handler = $callback;
    }

    /**
     * Sets the unknown block handler.
     *
     * @param      callable|null  $callback  The callback
     */
    public function setUnknownBlockHandler($callback): void
    {
        $this->unknown_block_handler = $callback;
    }

    /**
     * Gets the attributes.
     *
     * @param      string  $str    The string
     *
     * @return     array<string, mixed>   The attributes.
     */
    protected function getAttrs(string $str): array
    {
        $res = [];
        if (preg_match_all('|([a-zA-Z0-9_:-]+)="([^"]*)"|ms', $str, $m) > 0) {
            foreach ($m[1] as $i => $v) {
                $res[$v] = $m[2][$i];
            }
        }

        return $res;
    }
}
