<?php

/**
 * This file is part of CodeIgniter 4 framework.
 *
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace CodeIgniter\View;

use CodeIgniter\Autoloader\FileLocatorInterface;
use CodeIgniter\View\Exceptions\ViewException;
use Config\View as ViewConfig;
use ParseError;
use Psr\Log\LoggerInterface;

/**
 * Class for parsing pseudo-vars
 *
 * @phpstan-type parser_callable (callable(mixed): mixed)
 * @phpstan-type parser_callable_string (callable(mixed): mixed)&string
 *
 * @see \CodeIgniter\View\ParserTest
 */
class Parser extends View
{
    use ViewDecoratorTrait;

    /**
     * Left delimiter character for pseudo vars
     *
     * @var string
     */
    public $leftDelimiter = '{';

    /**
     * Right delimiter character for pseudo vars
     *
     * @var string
     */
    public $rightDelimiter = '}';

    /**
     * Left delimiter characters for conditionals
     */
    protected string $leftConditionalDelimiter = '{';

    /**
     * Right delimiter characters for conditionals
     */
    protected string $rightConditionalDelimiter = '}';

    /**
     * Stores extracted noparse blocks.
     *
     * @var list<string>
     */
    protected $noparseBlocks = [];

    /**
     * Stores any plugins registered at run-time.
     *
     * @var         array<string, callable|list<string>|string>
     * @phpstan-var array<string, array<parser_callable_string>|parser_callable_string|parser_callable>
     */
    protected $plugins = [];

    /**
     * Stores the context for each data element
     * when set by `setData` so the context is respected.
     *
     * @var array<string, mixed>
     */
    protected $dataContexts = [];

    /**
     * Constructor
     *
     * @param FileLocatorInterface|null $loader
     */
    public function __construct(
        ViewConfig $config,
        ?string $viewPath = null,
        $loader = null,
        ?bool $debug = null,
        ?LoggerInterface $logger = null,
    ) {
        // Ensure user plugins override core plugins.
        $this->plugins = $config->plugins;

        parent::__construct($config, $viewPath, $loader, $debug, $logger);
    }

    /**
     * Parse a template
     *
     * Parses pseudo-variables contained in the specified template view,
     * replacing them with any data that has already been set.
     *
     * @param array<string, mixed>|null $options Reserved for 3rd-party uses since
     *                                           it might be needed to pass additional info
     *                                           to other template engines.
     */
    public function render(string $view, ?array $options = null, ?bool $saveData = null): string
    {
        $start = microtime(true);
        if ($saveData === null) {
            $saveData = $this->config->saveData;
        }

        $fileExt = pathinfo($view, PATHINFO_EXTENSION);
        $view    = ($fileExt === '') ? $view . '.php' : $view; // allow Views as .html, .tpl, etc (from CI3)

        $cacheName = $options['cache_name'] ?? str_replace('.php', '', $view);

        // Was it cached?
        if (isset($options['cache']) && ($output = cache($cacheName))) {
            $this->logPerformance($start, microtime(true), $view);

            return $output;
        }

        $file = $this->viewPath . $view;

        if (! is_file($file)) {
            $fileOrig = $file;
            $file     = $this->loader->locateFile($view, 'Views');

            // locateFile() will return false if the file cannot be found.
            if ($file === false) {
                throw ViewException::forInvalidFile($fileOrig);
            }
        }

        if ($this->tempData === null) {
            $this->tempData = $this->data;
        }

        $template = file_get_contents($file);
        $output   = $this->parse($template, $this->tempData, $options);
        $this->logPerformance($start, microtime(true), $view);

        if ($saveData) {
            $this->data = $this->tempData;
        }

        $output = $this->decorateOutput($output);

        // Should we cache?
        if (isset($options['cache'])) {
            cache()->save($cacheName, $output, (int) $options['cache']);
        }
        $this->tempData = null;

        return $output;
    }

    /**
     * Parse a String
     *
     * Parses pseudo-variables contained in the specified string,
     * replacing them with any data that has already been set.
     *
     * @param array<string, mixed>|null $options Reserved for 3rd-party uses since
     *                                           it might be needed to pass additional info
     *                                           to other template engines.
     */
    public function renderString(string $template, ?array $options = null, ?bool $saveData = null): string
    {
        $start = microtime(true);
        if ($saveData === null) {
            $saveData = $this->config->saveData;
        }

        if ($this->tempData === null) {
            $this->tempData = $this->data;
        }

        $output = $this->parse($template, $this->tempData, $options);

        $this->logPerformance($start, microtime(true), $this->excerpt($template));

        if ($saveData) {
            $this->data = $this->tempData;
        }

        $this->tempData = null;

        return $output;
    }

    /**
     * Sets several pieces of view data at once.
     * In the Parser, we need to store the context here
     * so that the variable is correctly handled within the
     * parsing itself, and contexts (including raw) are respected.
     *
     * @param         array<string, mixed>                      $data
     * @param         non-empty-string|null                     $context The context to escape it for.
     *                                                                   If 'raw', no escaping will happen.
     * @phpstan-param null|'html'|'js'|'css'|'url'|'attr'|'raw' $context
     */
    public function setData(array $data = [], ?string $context = null): RendererInterface
    {
        if ($context !== null && $context !== '') {
            foreach ($data as $key => &$value) {
                if (is_array($value)) {
                    foreach ($value as &$obj) {
                        $obj = $this->objectToArray($obj);
                    }
                } else {
                    $value = $this->objectToArray($value);
                }

                $this->dataContexts[$key] = $context;
            }
        }

        $this->tempData ??= $this->data;
        $this->tempData = array_merge($this->tempData, $data);

        return $this;
    }

    /**
     * Parse a template
     *
     * Parses pseudo-variables contained in the specified template,
     * replacing them with the data in the second param
     *
     * @param array<string, mixed> $data
     * @param array<string, mixed> $options Future options
     */
    protected function parse(string $template, array $data = [], ?array $options = null): string
    {
        if ($template === '') {
            return '';
        }

        // Remove any possible PHP tags since we don't support it
        // and parseConditionals needs it clean anyway...
        $template = str_replace(['<?', '?>'], ['&lt;?', '?&gt;'], $template);

        $template = $this->parseComments($template);
        $template = $this->extractNoparse($template);

        // Replace any conditional code here so we don't have to parse as much
        $template = $this->parseConditionals($template);

        // Handle any plugins before normal data, so that
        // it can potentially modify any template between its tags.
        $template = $this->parsePlugins($template);

        // Parse stack for each parse type (Single and Pairs)
        $replaceSingleStack = [];
        $replacePairsStack  = [];

        // loop over the data variables, saving regex and data
        // for later replacement.
        foreach ($data as $key => $val) {
            $escape = true;

            if (is_array($val)) {
                $escape              = false;
                $replacePairsStack[] = [
                    'replace' => $this->parsePair($key, $val, $template),
                    'escape'  => $escape,
                ];
            } else {
                $replaceSingleStack[] = [
                    'replace' => $this->parseSingle($key, (string) $val),
                    'escape'  => $escape,
                ];
            }
        }

        // Merge both stacks, pairs first + single stacks
        // This allows for nested data with the same key to be replaced properly
        $replace = array_merge($replacePairsStack, $replaceSingleStack);

        // Loop over each replace array item which
        // holds all the data to be replaced
        foreach ($replace as $replaceItem) {
            // Loop over the actual data to be replaced
            foreach ($replaceItem['replace'] as $pattern => $content) {
                $template = $this->replaceSingle($pattern, $content, $template, $replaceItem['escape']);
            }
        }

        return $this->insertNoparse($template);
    }

    /**
     * Parse a single key/value, extracting it
     *
     * @return array<string, string>
     */
    protected function parseSingle(string $key, string $val): array
    {
        $pattern = '#' . $this->leftDelimiter . '!?\s*' . preg_quote($key, '#')
            . '(?(?=\s*\|\s*)(\s*\|*\s*([|\w<>=\(\),:.\-\s\+\\\\/]+)*\s*))(\s*)!?'
            . $this->rightDelimiter . '#ums';

        return [$pattern => $val];
    }

    /**
     * Parse a tag pair
     *
     * Parses tag pairs: {some_tag} string... {/some_tag}
     *
     * @param array<string, mixed> $data
     *
     * @return array<string, string>
     */
    protected function parsePair(string $variable, array $data, string $template): array
    {
        // Holds the replacement patterns and contents
        // that will be used within a preg_replace in parse()
        $replace = [];

        // Find all matches of space-flexible versions of {tag}{/tag} so we
        // have something to loop over.
        preg_match_all(
            '#' . $this->leftDelimiter . '\s*' . preg_quote($variable, '#') . '\s*' . $this->rightDelimiter . '(.+?)' .
            $this->leftDelimiter . '\s*/' . preg_quote($variable, '#') . '\s*' . $this->rightDelimiter . '#us',
            $template,
            $matches,
            PREG_SET_ORDER,
        );

        /*
         * Each match looks like:
         *
         * $match[0] {tag}...{/tag}
         * $match[1] Contents inside the tag
         */
        foreach ($matches as $match) {
            // Loop over each piece of $data, replacing
            // its contents so that we know what to replace in parse()
            $str = '';  // holds the new contents for this tag pair.

            foreach ($data as $row) {
                // Objects that have a `toArray()` method should be
                // converted with that method (i.e. Entities)
                if (is_object($row) && method_exists($row, 'toArray')) {
                    $row = $row->toArray();
                }
                // Otherwise, cast as an array and it will grab public properties.
                elseif (is_object($row)) {
                    $row = (array) $row;
                }

                $temp  = [];
                $pairs = [];
                $out   = $match[1];

                foreach ($row as $key => $val) {
                    // For nested data, send us back through this method...
                    if (is_array($val)) {
                        $pair = $this->parsePair($key, $val, $match[1]);

                        if ($pair !== []) {
                            $pairs[array_keys($pair)[0]] = true;

                            $temp = array_merge($temp, $pair);
                        }

                        continue;
                    }

                    if (is_object($val)) {
                        $val = 'Class: ' . $val::class;
                    } elseif (is_resource($val)) {
                        $val = 'Resource';
                    }

                    $temp['#' . $this->leftDelimiter . '!?\s*' . preg_quote($key, '#') . '(?(?=\s*\|\s*)(\s*\|*\s*([|\w<>=\(\),:.\-\s\+\\\\/]+)*\s*))(\s*)!?' . $this->rightDelimiter . '#us'] = $val;
                }

                // Now replace our placeholders with the new content.
                foreach ($temp as $pattern => $content) {
                    $out = $this->replaceSingle($pattern, $content, $out, ! isset($pairs[$pattern]));
                }

                $str .= $out;
            }

            $escapedMatch = preg_quote($match[0], '#');

            $replace['#' . $escapedMatch . '#us'] = $str;
        }

        return $replace;
    }

    /**
     * Removes any comments from the file. Comments are wrapped in {# #} symbols:
     *
     *      {# This is a comment #}
     */
    protected function parseComments(string $template): string
    {
        return preg_replace('/\{#.*?#\}/us', '', $template);
    }

    /**
     * Extracts noparse blocks, inserting a hash in its place so that
     * those blocks of the page are not touched by parsing.
     */
    protected function extractNoparse(string $template): string
    {
        $pattern = '/\{\s*noparse\s*\}(.*?)\{\s*\/noparse\s*\}/ums';

        /*
         * $matches[][0] is the raw match
         * $matches[][1] is the contents
         */
        if (preg_match_all($pattern, $template, $matches, PREG_SET_ORDER) >= 1) {
            foreach ($matches as $match) {
                // Create a hash of the contents to insert in its place.
                $hash                       = md5($match[1]);
                $this->noparseBlocks[$hash] = $match[1];
                $template                   = str_replace($match[0], "noparse_{$hash}", $template);
            }
        }

        return $template;
    }

    /**
     * Re-inserts the noparsed contents back into the template.
     */
    public function insertNoparse(string $template): string
    {
        foreach ($this->noparseBlocks as $hash => $replace) {
            $template = str_replace("noparse_{$hash}", $replace, $template);
            unset($this->noparseBlocks[$hash]);
        }

        return $template;
    }

    /**
     * Parses any conditionals in the code, removing blocks that don't
     * pass so we don't try to parse it later.
     *
     * Valid conditionals:
     *  - if
     *  - elseif
     *  - else
     */
    protected function parseConditionals(string $template): string
    {
        $leftDelimiter  = preg_quote($this->leftConditionalDelimiter, '/');
        $rightDelimiter = preg_quote($this->rightConditionalDelimiter, '/');

        $pattern = '/'
            . $leftDelimiter
            . '\s*(if|elseif)\s*((?:\()?(.*?)(?:\))?)\s*'
            . $rightDelimiter
            . '/ums';

        /*
         * For each match:
         * [0] = raw match `{if var}`
         * [1] = conditional `if`
         * [2] = condition `do === true`
         * [3] = same as [2]
         */
        preg_match_all($pattern, $template, $matches, PREG_SET_ORDER);

        foreach ($matches as $match) {
            // Build the string to replace the `if` statement with.
            $condition = $match[2];

            $statement = $match[1] === 'elseif' ? '<?php elseif (' . $condition . '): ?>' : '<?php if (' . $condition . '): ?>';
            $template  = str_replace($match[0], $statement, $template);
        }

        $template = preg_replace(
            '/' . $leftDelimiter . '\s*else\s*' . $rightDelimiter . '/ums',
            '<?php else: ?>',
            $template,
        );
        $template = preg_replace(
            '/' . $leftDelimiter . '\s*endif\s*' . $rightDelimiter . '/ums',
            '<?php endif; ?>',
            $template,
        );

        // Parse the PHP itself, or insert an error so they can debug
        ob_start();

        if ($this->tempData === null) {
            $this->tempData = $this->data;
        }

        extract($this->tempData);

        try {
            eval('?>' . $template . '<?php ');
        } catch (ParseError) {
            ob_end_clean();

            throw ViewException::forTagSyntaxError(str_replace(['?>', '<?php '], '', $template));
        }

        return ob_get_clean();
    }

    /**
     * Over-ride the substitution field delimiters.
     *
     * @param string $leftDelimiter
     * @param string $rightDelimiter
     */
    public function setDelimiters($leftDelimiter = '{', $rightDelimiter = '}'): RendererInterface
    {
        $this->leftDelimiter  = $leftDelimiter;
        $this->rightDelimiter = $rightDelimiter;

        return $this;
    }

    /**
     * Over-ride the substitution conditional delimiters.
     *
     * @param string $leftDelimiter
     * @param string $rightDelimiter
     */
    public function setConditionalDelimiters($leftDelimiter = '{', $rightDelimiter = '}'): RendererInterface
    {
        $this->leftConditionalDelimiter  = $leftDelimiter;
        $this->rightConditionalDelimiter = $rightDelimiter;

        return $this;
    }

    /**
     * Handles replacing a pseudo-variable with the actual content. Will double-check
     * for escaping brackets.
     *
     * @param array|string $pattern
     * @param string       $content
     * @param string       $template
     */
    protected function replaceSingle($pattern, $content, $template, bool $escape = false): string
    {
        $content = (string) $content;

        // Replace the content in the template
        return preg_replace_callback($pattern, function ($matches) use ($content, $escape): string {
            // Check for {! !} syntax to not escape this one.
            if (
                str_starts_with($matches[0], $this->leftDelimiter . '!')
                && substr($matches[0], -1 - strlen($this->rightDelimiter)) === '!' . $this->rightDelimiter
            ) {
                $escape = false;
            }

            return $this->prepareReplacement($matches, $content, $escape);
        }, (string) $template);
    }

    /**
     * Callback used during parse() to apply any filters to the value.
     *
     * @param list<string> $matches
     */
    protected function prepareReplacement(array $matches, string $replace, bool $escape = true): string
    {
        $orig = array_shift($matches);

        // Our regex earlier will leave all chained values on a single line
        // so we need to break them apart so we can apply them all.
        $filters = (isset($matches[1]) && $matches[1] !== '') ? explode('|', $matches[1]) : [];

        if ($escape && $filters === [] && ($context = $this->shouldAddEscaping($orig))) {
            $filters[] = "esc({$context})";
        }

        return $this->applyFilters($replace, $filters);
    }

    /**
     * Checks the placeholder the view provided to see if we need to provide any autoescaping.
     *
     * @return false|string
     */
    public function shouldAddEscaping(string $key)
    {
        $escape = false;

        $key = trim(str_replace(['{', '}'], '', $key));

        // If the key has a context stored (from setData)
        // we need to respect that.
        if (array_key_exists($key, $this->dataContexts)) {
            if ($this->dataContexts[$key] !== 'raw') {
                return $this->dataContexts[$key];
            }
        }
        // No pipes, then we know we need to escape
        elseif (! str_contains($key, '|')) {
            $escape = 'html';
        }
        // If there's a `noescape` then we're definitely false.
        elseif (str_contains($key, 'noescape')) {
            $escape = false;
        }
        // If no `esc` filter is found, then we'll need to add one.
        elseif (preg_match('/\s+esc/u', $key) !== 1) {
            $escape = 'html';
        }

        return $escape;
    }

    /**
     * Given a set of filters, will apply each of the filters in turn
     * to $replace, and return the modified string.
     *
     * @param list<string> $filters
     */
    protected function applyFilters(string $replace, array $filters): string
    {
        // Determine the requested filters
        foreach ($filters as $filter) {
            // Grab any parameter we might need to send
            preg_match('/\([\w<>=\/\\\,:.\-\s\+]+\)/u', $filter, $param);

            // Remove the () and spaces to we have just the parameter left
            $param = ($param !== []) ? trim($param[0], '() ') : null;

            // Params can be separated by commas to allow multiple parameters for the filter
            if ($param !== null && $param !== '') {
                $param = explode(',', $param);

                // Clean it up
                foreach ($param as &$p) {
                    $p = trim($p, ' "');
                }
            } else {
                $param = [];
            }

            // Get our filter name
            $filter = $param !== [] ? trim(strtolower(substr($filter, 0, strpos($filter, '(')))) : trim($filter);

            if (! array_key_exists($filter, $this->config->filters)) {
                continue;
            }

            // Filter it....
            // We can't know correct param types, so can't set `declare(strict_types=1)`.
            $replace = $this->config->filters[$filter]($replace, ...$param);
        }

        return (string) $replace;
    }

    // Plugins

    /**
     * Scans the template for any parser plugins, and attempts to execute them.
     * Plugins are delimited by {+ ... +}
     *
     * @return string
     */
    protected function parsePlugins(string $template)
    {
        foreach ($this->plugins as $plugin => $callable) {
            // Paired tags are enclosed in an array in the config array.
            $isPair   = is_array($callable);
            $callable = $isPair ? array_shift($callable) : $callable;

            // See https://regex101.com/r/BCBBKB/1
            $pattern = $isPair
                ? '#\{\+\s*' . $plugin . '([\w=\-_:\+\s\(\)/"@.]*)?\s*\+\}(.+?)\{\+\s*/' . $plugin . '\s*\+\}#uims'
                : '#\{\+\s*' . $plugin . '([\w=\-_:\+\s\(\)/"@.]*)?\s*\+\}#uims';

            /**
             * Match tag pairs
             *
             * Each match is an array:
             *   $matches[0] = entire matched string
             *   $matches[1] = all parameters string in opening tag
             *   $matches[2] = content between the tags to send to the plugin.
             */
            if (preg_match_all($pattern, $template, $matches, PREG_SET_ORDER) < 1) {
                continue;
            }

            foreach ($matches as $match) {
                $params = [];

                preg_match_all('/([\w-]+=\"[^"]+\")|([\w-]+=[^\"\s=]+)|(\"[^"]+\")|(\S+)/u', trim($match[1]), $matchesParams);

                foreach ($matchesParams[0] as $item) {
                    $keyVal = explode('=', $item);

                    if (count($keyVal) === 2) {
                        $params[$keyVal[0]] = str_replace('"', '', $keyVal[1]);
                    } else {
                        $params[] = str_replace('"', '', $item);
                    }
                }

                $template = $isPair
                    ? str_replace($match[0], $callable($match[2], $params), $template)
                    : str_replace($match[0], $callable($params), $template);
            }
        }

        return $template;
    }

    /**
     * Makes a new plugin available during the parsing of the template.
     *
     * @return $this
     */
    public function addPlugin(string $alias, callable $callback, bool $isPair = false)
    {
        $this->plugins[$alias] = $isPair ? [$callback] : $callback;

        return $this;
    }

    /**
     * Removes a plugin from the available plugins.
     *
     * @return $this
     */
    public function removePlugin(string $alias)
    {
        unset($this->plugins[$alias]);

        return $this;
    }

    /**
     * Converts an object to an array, respecting any
     * toArray() methods on an object.
     *
     * @param array<string, mixed>|bool|float|int|object|string|null $value
     *
     * @return array<string, mixed>|bool|float|int|string|null
     */
    protected function objectToArray($value)
    {
        // Objects that have a `toArray()` method should be
        // converted with that method (i.e. Entities)
        if (is_object($value) && method_exists($value, 'toArray')) {
            $value = $value->toArray();
        }
        // Otherwise, cast as an array and it will grab public properties.
        elseif (is_object($value)) {
            $value = (array) $value;
        }

        return $value;
    }
}
