<?php

namespace Livewire\Blaze\Parser;

use Livewire\Blaze\Parser\Tokens\TagSelfCloseToken;
use Livewire\Blaze\Parser\Tokens\SlotCloseToken;
use Livewire\Blaze\Parser\Tokens\SlotOpenToken;
use Livewire\Blaze\Parser\Tokens\TagCloseToken;
use Livewire\Blaze\Parser\Tokens\TagOpenToken;
use Livewire\Blaze\Parser\Tokens\TextToken;
use Livewire\Blaze\Parser\Tokens\Token;
use Livewire\Blaze\Support\LaravelRegex;

/**
 * Finite state machine that lexes Blade templates into component/slot/text tokens.
 */
class Tokenizer
{
    protected array $prefixes = [
        'flux:' => [
            'namespace' => 'flux::',
            'slot' => 'x-slot',
        ],
        'x:' => [
            'namespace' => '',
            'slot' => 'x-slot',
        ],
        'x-' => [
            'namespace' => '',
            'slot' => 'x-slot',
        ],
    ];

    protected string $content = '';

    protected int $position = 0;

    protected int $length = 0;

    protected array $tokens = [];

    protected string $buffer = '';

    protected ?Token $currentToken = null;

    protected array $tagStack = [];

    protected string $currentPrefix = '';

    protected string $currentSlotPrefix = '';

    /**
     * Tokenize a Blade template into an array of tokens.
     */
    public function tokenize(string $template): array
    {
        $this->tokens = [];
        $this->buffer = '';
        $this->currentToken = null;
        $this->tagStack = [];
        $this->currentPrefix = '';
        $this->currentSlotPrefix = '';

        $state = TokenizerState::TEXT;

        foreach (token_get_all($template) as $token) {
            if (is_array($token) && $token[0] === T_INLINE_HTML) {
                $this->position = 0;
                $this->content = $token[1];
                $this->length = strlen($token[1]);

                while (!$this->isAtEnd()) {
                    $state = match ($state) {
                        TokenizerState::TEXT => $this->handleTextState(),
                        TokenizerState::TAG_OPEN => $this->handleTagOpenState(),
                        TokenizerState::TAG_CLOSE => $this->handleTagCloseState(),
                        TokenizerState::SLOT_OPEN => $this->handleSlotOpenState(),
                        TokenizerState::SLOT_CLOSE => $this->handleSlotCloseState(),
                        TokenizerState::SHORT_SLOT => $this->handleShortSlotState(),
                        default => throw new \RuntimeException("Unknown state: $state"),
                    };
                }
            } else {
                // If we hit a non-HTML code inside a tag token, we should discard that token
                // and consider everything buffered so far as plain text.
                $this->currentToken = null;

                $state = TokenizerState::TEXT;

                $this->buffer .= is_array($token) ? $token[1] : $token;
            }
        }

        $this->flushBuffer();

        return $this->tokens;
    }

    /**
     * Process text state, detecting component/slot tag boundaries.
     */
    protected function handleTextState(): TokenizerState
    {
        $char = $this->current();

        if ($char === '<') {
            $this->flushBuffer();

            if ($slotInfo = $this->matchSlotOpen()) {
                $this->currentSlotPrefix = $slotInfo['prefix'];

                if ($slotInfo['isShort']) {
                    $this->currentToken = new SlotOpenToken(slotStyle: 'short', prefix: $slotInfo['prefix']);

                    return TokenizerState::SHORT_SLOT;
                } else {
                    $this->currentToken = new SlotOpenToken(slotStyle: 'standard', prefix: $slotInfo['prefix']);

                    return TokenizerState::SLOT_OPEN;
                }
            }

            if ($slotInfo = $this->matchSlotClose()) {
                $this->currentToken = new SlotCloseToken();

                $this->currentSlotPrefix = $slotInfo['prefix'];

                if ($this->current() === ':') {
                    $this->advance();
                }

                return TokenizerState::SLOT_CLOSE;
            }

            if ($prefixInfo = $this->matchComponentOpen()) {
                $this->currentPrefix = $prefixInfo['prefix'];

                $this->currentToken = new TagOpenToken(
                    name: '',
                    prefix: $prefixInfo['prefix'],
                    namespace: $prefixInfo['namespace']
                );

                return TokenizerState::TAG_OPEN;
            }

            if ($this->peek(1) === '/' && ($prefixInfo = $this->matchComponentClose())) {
                $this->currentPrefix = $prefixInfo['prefix'];

                $this->currentToken = new TagCloseToken(
                    name: '',
                    prefix: $prefixInfo['prefix'],
                    namespace: $prefixInfo['namespace']
                );

                return TokenizerState::TAG_CLOSE;
            }
        }

        $this->advance();

        return TokenizerState::TEXT;
    }

    /**
     * Process tag open state, extracting the component name and attributes.
     */
    protected function handleTagOpenState(): TokenizerState
    {
        if ($name = $this->matchTagName()) {
            $this->currentToken->name = $name;

            $this->tagStack[] = $name;

            $this->advance(strlen($name));

            $this->collectAttributes();

            if ($this->current() === '/' && $this->peek() === '>') {
                $this->currentToken = new TagSelfCloseToken(
                    name: $this->currentToken->name,
                    prefix: $this->currentToken->prefix,
                    namespace: $this->currentToken->namespace,
                    attributes: $this->currentToken->attributes,
                );

                array_pop($this->tagStack);

                $this->advance(2);

                $this->emitToken();

                return TokenizerState::TEXT;
            }

            if ($this->current() === '>') {
                $this->advance();

                $this->emitToken();

                return TokenizerState::TEXT;
            }
        }

        $this->advance();

        return TokenizerState::TAG_OPEN;
    }

    /**
     * Process closing tag state, extracting the component name.
     */
    protected function handleTagCloseState(): TokenizerState
    {
        if ($name = $this->matchTagName()) {
            $this->currentToken->name = $name;

            array_pop($this->tagStack);

            $this->advance(strlen($name));
        }

        if ($this->current() === '>') {
            $this->advance();

            $this->emitToken();

            return TokenizerState::TEXT;
        }

        $this->advance();

        return TokenizerState::TAG_CLOSE;
    }

    /**
     * Process standard slot tag state.
     */
    protected function handleSlotOpenState(): TokenizerState
    {
        $this->collectAttributes();

        // Extract and remove the name attribute from the collected attributes.
        foreach ($this->currentToken->attributes as $i => $attr) {
            if (preg_match('/^name="([^"]+)"$/', $attr, $matches)) {
                $this->currentToken->name = $matches[1];

                unset($this->currentToken->attributes[$i]);

                $this->currentToken->attributes = array_values($this->currentToken->attributes);

                break;
            }
        }

        if ($this->current() === '>') {
            $this->advance();

            $this->emitToken();

            return TokenizerState::TEXT;
        }

        $this->advance();

        return TokenizerState::SLOT_OPEN;
    }

    /**
     * Process closing slot tag state.
     */
    protected function handleSlotCloseState(): TokenizerState
    {
        if ($name = $this->matchSlotName()) {
            $this->currentToken->name = $name;

            $this->advance(strlen($name));
        }

        if ($this->current() === '>') {
            $this->advance();

            $this->emitToken();

            return TokenizerState::TEXT;
        }

        $this->advance();

        return TokenizerState::SLOT_CLOSE;
    }

    /**
     * Process short slot syntax state (<x-slot:name>).
     */
    protected function handleShortSlotState(): TokenizerState
    {
        if ($name = $this->matchSlotName()) {
            $this->currentToken->name = $name;

            $this->advance(strlen($name));

            $this->collectAttributes();

            if ($this->current() === '>') {
                $this->advance();

                $this->emitToken();

                return TokenizerState::TEXT;
            }
        }

        $this->advance();

        return TokenizerState::SHORT_SLOT;
    }

    /**
     * Collect all attributes on the current token, splitting on unquoted/unbracketed whitespace.
     * Stops at > or /> without consuming them.
     */
    protected function collectAttributes(): void
    {
        $attrString = '';
        $inSingleQuote = false;
        $inDoubleQuote = false;
        $braceCount = 0;
        $bracketCount = 0;
        $parenCount = 0;

        while (!$this->isAtEnd()) {
            $char = $this->current();

            $prevChar = $this->position > 0 ? $this->content[$this->position - 1] : '';

            if ($char === '"' && !$inSingleQuote && $prevChar !== '\\') {
                $inDoubleQuote = !$inDoubleQuote;
            } elseif ($char === "'" && !$inDoubleQuote && $prevChar !== '\\') {
                $inSingleQuote = !$inSingleQuote;
            }

            if (!$inSingleQuote && !$inDoubleQuote) {
                match($char) {
                    '{' => $braceCount++,
                    '}' => $braceCount--,
                    '[' => $bracketCount++,
                    ']' => $bracketCount--,
                    '(' => $parenCount++,
                    ')' => $parenCount--,
                    default => null
                };
            }

            $isNested = $inSingleQuote || $inDoubleQuote
                || $braceCount > 0 || $bracketCount > 0 || $parenCount > 0;

            // Tag end — flush and stop (don't consume).
            if (($char === '>' || ($char === '/' && $this->peek() === '>')) && !$isNested) {
                break;
            }

            // Space outside nesting — flush current attribute and skip.
            if ($char === ' ' && !$isNested) {
                if ($attrString !== '') {
                    $this->currentToken->attributes[] = $attrString;

                    $attrString = '';
                }

                $this->advance();

                continue;
            }

            $attrString .= $char;

            $this->advance();
        }

        if ($attrString !== '') {
            $this->currentToken->attributes[] = $attrString;
        }
    }

    /**
     * Try to match a slot opening tag at the current position.
     */
    protected function matchSlotOpen(): ?array
    {
        foreach ($this->prefixes as $prefix => $config) {
            $slotPrefix = $config['slot'];

            if ($this->match('<\s*' . $slotPrefix . ':')) {
                return ['prefix' => $slotPrefix, 'isShort' => true];
            }

            if ($this->match('<\s*' . $slotPrefix . '(?!:)')) {
                return ['prefix' => $slotPrefix, 'isShort' => false];
            }
        }

        return null;
    }

    /**
     * Try to match a slot closing tag at the current position.
     */
    protected function matchSlotClose(): ?array
    {
        foreach ($this->prefixes as $prefix => $config) {
            $slotPrefix = $config['slot'];

            if ($this->match('<\/\s*' . $slotPrefix)) {
                return ['prefix' => $slotPrefix];
            }
        }

        return null;
    }

    /**
     * Try to match a component opening tag at the current position.
     */
    protected function matchComponentOpen(): ?array
    {
        foreach ($this->prefixes as $prefix => $config) {
            if ($this->match('<\s*' . $prefix)) {
                return [
                    'prefix' => $prefix,
                    'namespace' => $config['namespace'] ?? '',
                ];
            }
        }

        return null;
    }

    /**
     * Try to match a component closing tag at the current position.
     */
    protected function matchComponentClose(): ?array
    {
        foreach ($this->prefixes as $prefix => $config) {
            if ($this->match('<\/\s*' . $prefix)) {
                return [
                    'prefix' => $prefix,
                    'namespace' => $config['namespace'] ?? '',
                ];
            }
        }

        return null;
    }

    /**
     * Match a tag name at the current position.
     */
    protected function matchTagName(): ?string
    {
        if (preg_match(LaravelRegex::TAG_NAME, $this->remaining(), $matches)) {
            return $matches[0];
        }

        return null;
    }

    /**
     * Match a slot name (alphanumeric, hyphens) at the current position.
     */
    protected function matchSlotName(): ?string
    {
        if (preg_match(LaravelRegex::SLOT_INLINE_NAME, $this->remaining(), $matches)) {
            return $matches[0];
        }

        return null;
    }

    /**
     * Match a pattern at the current position and advance past it.
     */
    protected function match(string $pattern): bool
    {
        if (preg_match('/^' . $pattern . '/', $this->remaining(), $matches)) {
            $this->advance(strlen($matches[0]));

            return true;
        }

        return false;
    }

    /**
     * Get the character at the current position.
     */
    protected function current(): string
    {
        return $this->isAtEnd() ? '' : $this->content[$this->position];
    }

    /**
     * Peek at a character at an offset from the current position.
     */
    protected function peek(int $offset = 1): string
    {
        $pos = $this->position + $offset;

        return $pos >= $this->length ? '' : $this->content[$pos];
    }

    /**
     * Get the remaining content from the current position.
     */
    protected function remaining(): string
    {
        return substr($this->content, $this->position);
    }

    /**
     * Advance the position by a number of characters.
     */
    protected function advance(int $count = 1): void
    {
        $this->buffer .= substr($this->content, $this->position, $count);

        $this->position += $count;
    }

    /**
     * Check if the tokenizer has reached the end of input.
     */
    protected function isAtEnd(): bool
    {
        return $this->position >= $this->length;
    }

    /**
     * Emit the current token and discard the raw buffer.
     */
    protected function emitToken(): void
    {
        $this->tokens[] = $this->currentToken;

        $this->buffer = '';
    }

    /**
     * Emit any accumulated text buffer as a TextToken.
     */
    protected function flushBuffer(): void
    {
        if ($this->buffer !== '') {
            $this->tokens[] = new TextToken($this->buffer);

            $this->buffer = '';
        }
    }
}