<?php

namespace Livewire\Features\SupportIslands\Compiler;

use Illuminate\Support\Facades\File;
use Illuminate\Support\Arr;
use Livewire\Compiler\Parser\Parser;

class IslandCompiler
{
    protected string $mutableContents;

    protected ?string $imports;

    public function __construct(
        public string $pathSignature,
        public string $contents,
    ) {
        $this->mutableContents = $contents;

        // Islands are extracted into separately compiled views, so any imports
        // from the top of the view need to be carried into each island...
        $this->imports = $this->extractImports();
    }

    public static function compile(string $pathSignature, string $contents): string
    {
        $compiler = new self($pathSignature, $contents);

        return $compiler->process();
    }

    public function process(): string
    {
        $compiler = $this->getHackedBladeCompiler();

        $currentNestingLevel = 1;
        $maxNestingLevel = $currentNestingLevel;
        $startDirectiveCount = 1;

        $compiler->directive('island', function ($expression) use (&$currentNestingLevel, &$maxNestingLevel, &$startDirectiveCount) {
            $maxNestingLevel = max($maxNestingLevel, $currentNestingLevel);

            return '[STARTISLAND:' . $startDirectiveCount++ . ':' . $currentNestingLevel++ . ']('.$expression.')';
        });

        $compiler->directive('endisland', function () use (&$currentNestingLevel) {
            return '[ENDISLAND:' . --$currentNestingLevel . ']';
        });

        if ($currentNestingLevel !== 1) {
            throw new \Exception('Start @island directive found without a matching @endisland directive');
        }

        // Strip Blade comments so @island directives inside them are ignored...
        [$contents, $comments] = $this->stripBladeComments($this->mutableContents);

        $result = $compiler->compileStatementsMadePublic($contents);

        $result = $this->restoreSetAsideDirectives($result, $compiler->setAsideDirectives);

        $result = $this->restoreBladeComments($result, $comments);

        for ($i=$maxNestingLevel; $i >= $currentNestingLevel; $i--) {
            $result = preg_replace_callback('/(\[STARTISLAND:([0-9]+):' . $i . '\])\(((?:[^()]++|\((?3)\))*)\)(.*?)(\[ENDISLAND:' . $i . '\])/s', function ($matches) use ($i) {
                $occurrence = $matches[2];
                $innerContent = $matches[4];
                $expression = $matches[3];

                if (str_contains($innerContent, '@placeholder')) {
                    $innerContent = str_replace('@placeholder', '<'.'?php if (isset($__placeholder)) { ob_start(); } if (isset($__placeholder)): ?'.'>', $innerContent);
                    $innerContent = str_replace('@endplaceholder', '<'.'?php endif; if (isset($__placeholder)) { echo ob_get_clean(); return; } ?'.'>', $innerContent);

                } else {
                    $innerContent = '<'.'?php if (isset($__placeholder)) { echo $__placeholder; return; } ?'.'>' . "\n\n" . $innerContent;
                }

                return $this->compileIsland(
                    occurrence: $occurrence,
                    nestingLevel: $i,
                    expression: $expression,
                    innerContent: $innerContent,
                );
            }, $result);
        }

        return $result;
    }

    public function compileIsland(int $occurrence, int $nestingLevel, string $expression, string $innerContent): string
    {
        // Get the cached path for the extracted island...
        $hash = $this->getPathBasedHash($this->pathSignature);
        $token = $hash . '-' . $occurrence;
        $cachedPath = self::getCachedPathFromToken($token);

        // Build the output directive for the island...
        $output = '@island';

        if (trim($expression) === '') {
            $output .= '(token: \'' . $token . '\')';
        } else {
            $output .= '(' . $expression . ', token: \'' . $token . '\')';
        }

        // Inject scope provider code at the top of the island view...
        $scopeProviderCode = $this->generateScopeProviderCode($expression);
        $innerContent = $scopeProviderCode . $innerContent;

        // Carry imports from the top of the view into the extracted island view...
        if ($this->imports) {
            $innerContent = $this->imports . "\n\n" . $innerContent;
        }

        // Ensure the cached directory exists...
        File::ensureDirectoryExists(dirname($cachedPath), 0777);

        // Write the cached island to the file system...
        file_put_contents($cachedPath, $innerContent);

        app('livewire.compiler')->cacheManager->prepareGeneratedFileForCompilation($cachedPath);

        return $output;
    }

    protected function extractImports(): ?string
    {
        $prefix = explode('@island', $this->contents, 2)[0];

        $imports = [];

        // Collect `use` statements from leading PHP blocks (the compiler hoists
        // an SFC's imports into one of these at the top of the view)...
        while (preg_match('/\A\s*<\?php(.*?)\?>/s', $prefix, $matches)) {
            if ($statements = Parser::extractUseStatements($matches[1])) {
                $imports[] = "<?php\n" . $statements . "\n?>";
            }

            $prefix = substr($prefix, strlen($matches[0]));
        }

        // Collect `@use(...)` directives as-is and let Blade compile them within the island...
        if (preg_match_all('/(?<![@\w])@use\s*\([^)]*\)/', $prefix, $matches)) {
            $imports = array_merge($imports, $matches[0]);
        }

        return $imports ? implode("\n", $imports) : null;
    }

    protected function generateScopeProviderCode(string $expression): string
    {
        $directiveWithExtraction = '';

        // Only extract directive's "with" if there's an expression
        if (trim($expression) !== '') {
            $directiveWithExtraction = <<<PHP
// Extract directive's "with" parameter (overrides component properties)
\$__islandScope = (function(\$name = null, \$token = null, \$lazy = false, \$defer = false, \$always = false, \$skip = false, \$with = []) {
    return \$with;
})({$expression});
if (!empty(\$__islandScope)) {
    extract(\$__islandScope, EXTR_OVERWRITE);
}


PHP;
        }

        // Always include runtime "with" extraction (even if directive has no parameters)
        return <<<PHP
<?php
{$directiveWithExtraction}// Extract runtime "with" parameter if provided (overrides everything)
if (isset(\$__runtimeWith) && is_array(\$__runtimeWith) && !empty(\$__runtimeWith)) {
    extract(\$__runtimeWith, EXTR_OVERWRITE);
}
?>

PHP;
    }

    public static function getCachedPathFromToken(string $token): string
    {
        $cachedDirectory = app('livewire.compiler')->cacheManager->cacheDirectory;

        return $cachedDirectory . '/islands/' . $token . '.blade.php';
    }

    public function getPathBasedHash(string $path): string
    {
        return app('livewire.compiler')->cacheManager->getHash(
            $this->pathSignature,
        );
    }

    protected function stripBladeComments(string $contents): array
    {
        $comments = [];

        $contents = preg_replace_callback('/\{\{--(.*?)--\}\}/s', function ($match) use (&$comments) {
            $placeholder = '[BLADE_COMMENT:' . count($comments) . ']';
            $comments[] = $match[0];
            return $placeholder;
        }, $contents);

        return [$contents, $comments];
    }

    protected function restoreBladeComments(string $contents, array $comments): string
    {
        return preg_replace_callback('/\[BLADE_COMMENT:(\d+)\]/', function ($match) use ($comments) {
            return $comments[(int) $match[1]];
        }, $contents);
    }

    protected function restoreSetAsideDirectives(string $contents, array $directives): string
    {
        return strtr($contents, $directives);
    }

    public function getHackedBladeCompiler()
    {
        $instance = new class (
            app('files'),
            rtrim(config('view.compiled'), '/\\') . '/livewire',
        ) extends \Illuminate\View\Compilers\BladeCompiler {
            public array $setAsideDirectives = [];

            protected string $setAsideDirectivePrefix = '[LIVEWIRE_DIRECTIVE:';

            /**
             * Make statement compilation public and reserve a placeholder prefix
             * that does not collide with authored view content...
             */
            public function compileStatementsMadePublic($template)
            {
                while (str_contains($template, $this->setAsideDirectivePrefix)) {
                    $this->setAsideDirectivePrefix .= '_';
                }

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

            protected function setAside($directive)
            {
                $placeholder = $this->setAsideDirectivePrefix . count($this->setAsideDirectives) . ']';

                $this->setAsideDirectives[$placeholder] = $directive;

                return $placeholder;
            }

            /**
             * Tweak this method to only process custom directives so we
             * can restrict rendering solely to @island related directives...
             */
            protected function compileStatement($match)
            {
                if (isset($this->customDirectives[$match[1]])) {
                    $compiled = $this->callCustomDirective($match[1], Arr::get($match, 3));

                    return isset($match[3]) ? $compiled : $compiled.$match[2];
                }

                // Blade moves forward as it replaces statements, so foreign directives
                // must be consumed during this island-only pass and restored afterward...
                return $this->setAside($match[0]);
            }
        };

        return $instance;
    }
}
