<?php
declare(strict_types=1);

namespace Celena\Core\Template;

use RuntimeException;

/**
 * Лексер + парсер + кодогенерация .tpl → PHP.
 *
 * Поддерживаемый синтаксис:
 *   {var}                          переменная (auto-escape)
 *   {var.sub}                      доступ по точке (массив или объект)
 *   {var|raw}                      без экранирования
 *   {tag param="x"}                inline-тег (если зарегистрирован)
 *   {include file="header.tpl"}    инклюд
 *   {lang key="..."}               перевод
 *   {async block="x" url="/y"}     async-плейсхолдер
 *   {* ... *}                      комментарий (вырезается)
 *   [if {var}="x"] ... [else] ... [/if]
 *   [if {var}]   ... [/if]         truthy-проверка
 *   [name p="x"] ... [/name]       block-тег
 *
 * PHP-блоки запрещены: <?php, <?= экранируются как текст.
 */
final class Compiler
{
    public function __construct(private readonly TagRegistry $tags)
    {
    }

    public function compile(string $source, string $relativePath = ''): string
    {
        // Безопасность: вырезаем PHP-теги в исходнике (они недопустимы в .tpl).
        $source = preg_replace('/<\?(php|=)?/i', '&lt;?', $source) ?? $source;
        // Удаляем комментарии шаблонизатора.
        $source = preg_replace('/\{\*[\s\S]*?\*\}/u', '', $source) ?? $source;

        // Защищаем содержимое <script> и <style> от парсинга блочных и if-тегов:
        // CSS-селекторы вида [data-del], [class~="x"] не должны восприниматься как блочные теги.
        // НО внутри script мы по-прежнему позволяем inline-теги {lang ...}, {price ...} и т.п.,
        // чтобы JS-генерируемая разметка (innerHTML и т.п.) могла быть переведена.
        $protected = [];
        $self = $this;
        $source = preg_replace_callback(
            '#<(script|style)\b[^>]*>[\s\S]*?</\1>#ui',
            function (array $m) use (&$protected, $self): string {
                // Шаг 1: внутри script-блока заранее заменяем inline-теги на
                // их PHP-эквивалент через callInline — это попадёт в финальный output.
                $body = preg_replace_callback(
                    '#\{([a-zA-Z_][\w\-\.]*)((?:\|[a-zA-Z_]+)?)((?:\s+[a-zA-Z_][\w\-]*\s*=\s*"[^"]*")*)\s*\}#u',
                    function (array $mm) use ($self): string {
                        return $self->compileInlineFromMatch($mm);
                    },
                    $m[0]
                ) ?? $m[0];
                $ph = "\x01PROTECTED" . count($protected) . "\x02";
                $protected[] = $body;
                return $ph;
            },
            $source
        ) ?? $source;

        $tokens = $this->tokenize($source);
        // Возвращаем защищённые блоки в text-токены.
        foreach ($tokens as &$t) {
            if ($t['type'] === 'text' && str_contains((string) ($t['value'] ?? ''), "\x01PROTECTED")) {
                $val = (string) $t['value'];
                foreach ($protected as $i => $block) {
                    $val = str_replace("\x01PROTECTED{$i}\x02", $block, $val);
                }
                $t['value'] = $val;
            }
        }
        unset($t);
        $ast = $this->parse($tokens, $endIndex);
        $php = $this->emit($ast);

        $header = "<?php\n"
            . "/* Compiled from: " . $relativePath . " */\n"
            . "/** @var array \$vars */\n"
            . "/** @var \\Celena\\Core\\Template\\Engine \$__e */\n"
            . "?>";
        return $header . $php;
    }

    /** @return list<array{type:string, value?:mixed, name?:string, params?:array, expr?:string, raw?:bool}> */
    private function tokenize(string $source): array
    {
        $tokens = [];
        $len = strlen($source);
        $cursor = 0;
        $textBuf = '';

        // Регэкспы для каждого вида специального токена в текущей позиции.
        $patterns = [
            'block_open'  => '#^\[([a-zA-Z_][\w\-]*)((?:\s+[a-zA-Z_][\w\-]*\s*=\s*"[^"]*")*)\s*\]#u',
            'block_close' => '#^\[/([a-zA-Z_][\w\-]*)\]#u',
            'if_open'     => '#^\[if\s+([^\]]+?)\]#u',
            'elseif'      => '#^\[else\s+if\s+([^\]]+?)\]#u',
            'else'        => '#^\[else\]#u',
            'if_close'    => '#^\[/if\]#u',
            'inline'      => '#^\{([a-zA-Z_][\w\-\.]*)((?:\|[a-zA-Z_]+)?)((?:\s+[a-zA-Z_][\w\-]*\s*=\s*"[^"]*")*)\s*\}#u',
        ];

        while ($cursor < $len) {
            $matched = false;
            $rest = substr($source, $cursor);

            // Пробуем сначала "if" варианты, затем block_open (иначе [if уйдёт в block_open как имя 'if').
            foreach (['if_close','elseif','else','if_open','block_close','block_open','inline'] as $key) {
                if (preg_match($patterns[$key], $rest, $m)) {
                    if ($textBuf !== '') {
                        $tokens[] = ['type' => 'text', 'value' => $textBuf];
                        $textBuf = '';
                    }
                    $tokens[] = $this->makeToken($key, $m);
                    $cursor += strlen($m[0]);
                    $matched = true;
                    break;
                }
            }

            if (!$matched) {
                // Проверка на специальный символ начала ({ или [) — но он не сложился в токен → буферим как обычный.
                $textBuf .= $source[$cursor];
                $cursor++;
            }
        }

        if ($textBuf !== '') {
            $tokens[] = ['type' => 'text', 'value' => $textBuf];
        }
        return $tokens;
    }

    /** @return array{type:string, ...} */
    private function makeToken(string $key, array $m): array
    {
        return match ($key) {
            'block_open'  => [
                'type' => 'block_open',
                'name' => strtolower($m[1]),
                'params' => $this->parseParams($m[2] ?? ''),
            ],
            'block_close' => ['type' => 'block_close', 'name' => strtolower($m[1])],
            'if_open'     => ['type' => 'if_open', 'expr' => trim($m[1])],
            'elseif'      => ['type' => 'elseif', 'expr' => trim($m[1])],
            'else'        => ['type' => 'else'],
            'if_close'    => ['type' => 'if_close'],
            'inline'      => [
                'type' => 'inline',
                'name' => strtolower($m[1]),
                'raw'  => isset($m[2]) && $m[2] === '|raw',
                'filter' => isset($m[2]) && $m[2] !== '' && $m[2] !== '|raw' ? ltrim($m[2], '|') : '',
                'params' => $this->parseParams($m[3] ?? ''),
            ],
            default       => ['type' => 'text', 'value' => $m[0]],
        };
    }

    /**
     * Подставляем inline-тег прямо во время фазы protect-скриптов:
     * принимает результат preg_match по inline-регексу и возвращает PHP-эквивалент.
     *
     * Для зарегистрированных inline-тегов — вызов callInline().
     * Для всего прочего — переменная (с dot-нотацией): значение экранируется
     * через e_js() (безопасно как внутри JS-строк, так и для CSS-значений).
     * Темы используют это для проброса настроек в window.CELENA_SHOP и т.п.
     *
     * @param array<int, string> $m
     */
    public function compileInlineFromMatch(array $m): string
    {
        $name = strtolower($m[1] ?? '');
        if ($name === '') {
            return $m[0];
        }
        if ($this->tags->hasInline($name)) {
            $params = $this->parseParams($m[3] ?? '');
            return '<?= $__e->callInline(' . var_export($name, true) . ', '
                . $this->paramsToPhp($params, true) . ', $vars) ?>';
        }
        // Переменная внутри <script>/<style>. raw-фильтр пропускает экранирование.
        $isRaw = isset($m[2]) && $m[2] === '|raw';
        $accessor = $this->varAccessor($name);
        return $isRaw
            ? '<?= ' . $accessor . ' ?>'
            : '<?= e_js(' . $accessor . ') ?>';
    }

    /** @return array<string, string> */
    private function parseParams(string $raw): array
    {
        $params = [];
        if ($raw === '') {
            return $params;
        }
        if (preg_match_all('/([a-zA-Z_][\w\-]*)\s*=\s*"([^"]*)"/u', $raw, $matches, PREG_SET_ORDER)) {
            foreach ($matches as $m) {
                $params[$m[1]] = $m[2];
            }
        }
        return $params;
    }

    /**
     * Рекурсивный парсер: собирает плоский список токенов в дерево, корректно вкладывая [if] и блоки.
     * @param list<array> $tokens
     * @return list<array>
     */
    private function parse(array $tokens, ?int &$endIndex = null, int $start = 0, ?string $stopBlock = null, bool $stopOnIf = false): array
    {
        $nodes = [];
        $i = $start;
        $count = count($tokens);
        while ($i < $count) {
            $t = $tokens[$i];

            // Проверяем условия завершения.
            if ($stopBlock !== null && $t['type'] === 'block_close' && $t['name'] === $stopBlock) {
                $endIndex = $i;
                return $nodes;
            }
            if ($stopOnIf && in_array($t['type'], ['if_close', 'else', 'elseif'], true)) {
                $endIndex = $i;
                return $nodes;
            }

            if ($t['type'] === 'if_open') {
                $branches = [];
                $branches[] = ['expr' => $t['expr'], 'body' => $this->parse($tokens, $end, $i + 1, null, true)];
                $i = $end;
                while ($i < $count && $tokens[$i]['type'] === 'elseif') {
                    $expr = $tokens[$i]['expr'];
                    $branches[] = ['expr' => $expr, 'body' => $this->parse($tokens, $end, $i + 1, null, true)];
                    $i = $end;
                }
                $elseBody = null;
                if ($i < $count && $tokens[$i]['type'] === 'else') {
                    $elseBody = $this->parse($tokens, $end, $i + 1, null, true);
                    $i = $end;
                }
                if ($i >= $count || $tokens[$i]['type'] !== 'if_close') {
                    throw new RuntimeException('Template: missing [/if]');
                }
                $nodes[] = ['type' => 'if', 'branches' => $branches, 'else' => $elseBody];
                $i++;
                continue;
            }

            if ($t['type'] === 'block_open') {
                $body = $this->parse($tokens, $end, $i + 1, $t['name']);
                if ($end >= $count) {
                    throw new RuntimeException("Template: missing [/{$t['name']}]");
                }
                $nodes[] = ['type' => 'block', 'name' => $t['name'], 'params' => $t['params'], 'body' => $body];
                $i = $end + 1;
                continue;
            }

            // text / inline → пропускаем как есть
            $nodes[] = $t;
            $i++;
        }
        $endIndex = $i;
        return $nodes;
    }

    /** @param list<array> $nodes */
    private function emit(array $nodes): string
    {
        $out = '';
        foreach ($nodes as $node) {
            $out .= match ($node['type']) {
                'text'   => $this->emitText($node['value']),
                'inline' => $this->emitInline($node),
                'if'     => $this->emitIf($node),
                'block'  => $this->emitBlock($node),
                default  => '',
            };
        }
        return $out;
    }

    private function emitText(string $text): string
    {
        // Просто HTML — выводим как есть.
        return $text;
    }

    private function emitInline(array $node): string
    {
        $name = $node['name'];
        $params = $node['params'];

        // Если это зарегистрированный inline-тег → вызываем его.
        if ($this->tags->hasInline($name)) {
            return '<?= $__e->callInline(' . var_export($name, true) . ', '
                . $this->paramsToPhp($params, true) . ', $vars) ?>';
        }

        // Иначе — это переменная (с возможным dot-доступом).
        $accessor = $this->varAccessor($name);
        $filter = $node['filter'] ?? '';

        // Размерные фильтры: thumb / medium / large — подменяют URL на вариант нужного размера.
        if (in_array($filter, ['thumb', 'medium', 'large'], true)) {
            return '<?= e(\\Celena\\Core\\Support\\ImageVariant::url((string) ' . $accessor . ', ' . var_export($filter, true) . ')) ?>';
        }

        return $node['raw']
            ? '<?= ' . $accessor . ' ?>'
            : '<?= e(' . $accessor . ') ?>';
    }

    private function emitIf(array $node): string
    {
        $out = '';
        foreach ($node['branches'] as $i => $branch) {
            $kw = $i === 0 ? 'if' : 'elseif';
            $out .= '<?php ' . $kw . ' (' . $this->compileExpression($branch['expr']) . '): ?>';
            $out .= $this->emit($branch['body']);
        }
        if ($node['else'] !== null) {
            $out .= '<?php else: ?>';
            $out .= $this->emit($node['else']);
        }
        $out .= '<?php endif; ?>';
        return $out;
    }

    private function emitBlock(array $node): string
    {
        $bodyPhp = $this->emit($node['body']);
        $bodyClosure = "function (array \$vars) use (\$__e) { ob_start(); ?>"
            . $bodyPhp
            . "<?php return ob_get_clean(); }";
        return '<?= $__e->callBlock('
            . var_export($node['name'], true) . ', '
            . $this->paramsToPhp($node['params'], true)
            . ', $vars, ' . $bodyClosure . ') ?>';
    }

    private function paramsToPhp(array $params, bool $interpolate): string
    {
        if ($params === []) {
            return '[]';
        }
        $parts = [];
        foreach ($params as $k => $v) {
            $value = $interpolate ? $this->interpolateString($v) : var_export($v, true);
            $parts[] = var_export($k, true) . ' => ' . $value;
        }
        return '[' . implode(', ', $parts) . ']';
    }

    /**
     * Интерполяция: значение параметра может содержать {var}. Превращаем в конкатенацию.
     * Например: limit="{site.posts_per_page}" → ($vars['site']['posts_per_page'] ?? '')
     */
    private function interpolateString(string $raw): string
    {
        if (!str_contains($raw, '{')) {
            return var_export($raw, true);
        }
        $parts = [];
        $cursor = 0;
        $len = strlen($raw);
        while ($cursor < $len) {
            $open = strpos($raw, '{', $cursor);
            if ($open === false) {
                $parts[] = var_export(substr($raw, $cursor), true);
                break;
            }
            if ($open > $cursor) {
                $parts[] = var_export(substr($raw, $cursor, $open - $cursor), true);
            }
            $close = strpos($raw, '}', $open);
            if ($close === false) {
                $parts[] = var_export(substr($raw, $cursor), true);
                break;
            }
            $varName = substr($raw, $open + 1, $close - $open - 1);
            $parts[] = '(string) (' . $this->varAccessor($varName) . ')';
            $cursor = $close + 1;
        }
        return implode(' . ', $parts);
    }

    /** {user.name} → $__e->v($vars, 'user.name') */
    private function varAccessor(string $name): string
    {
        return '$__e->v($vars, ' . var_export($name, true) . ')';
    }

    /**
     * Компиляция выражения [if ...]:
     *   {var}               — truthy
     *   {var}="value"       — сравнение с литералом
     *   {var}={other}       — сравнение двух переменных
     *   {var}!="value", >=, <=, >, <
     *
     * Правая часть может быть либо строковым литералом в кавычках, либо переменной в {..}.
     */
    private function compileExpression(string $expr): string
    {
        $expr = trim($expr);
        // Две переменные: {a} <op> {b}
        if (preg_match('/^\{([\w\-\.]+)\}\s*(=|!=|>=|<=|>|<)\s*\{([\w\-\.]+)\}$/u', $expr, $m)) {
            $left = $this->varAccessor($m[1]);
            $op = $m[2] === '=' ? '==' : $m[2];
            $right = $this->varAccessor($m[3]);
            if (in_array($m[2], ['>', '<', '>=', '<='], true)) {
                return '((float)(' . $left . ') ' . $op . ' (float)(' . $right . '))';
            }
            return '((string)(' . $left . ') ' . $op . ' (string)(' . $right . '))';
        }
        // Переменная vs литерал
        if (preg_match('/^\{([\w\-\.]+)\}\s*(=|!=|>=|<=|>|<)\s*"([^"]*)"$/u', $expr, $m)) {
            $left = $this->varAccessor($m[1]);
            $op = $m[2] === '=' ? '==' : $m[2];
            $right = var_export($m[3], true);
            if (in_array($m[2], ['>', '<', '>=', '<='], true)) {
                return '((float)(' . $left . ') ' . $op . ' (float)(' . $right . '))';
            }
            return '((string)(' . $left . ') ' . $op . ' ' . $right . ')';
        }
        // Truthy
        if (preg_match('/^\{([\w\-\.]+)\}$/u', $expr, $m)) {
            return '!empty(' . $this->varAccessor($m[1]) . ')';
        }
        return 'false';
    }
}
