<?php

declare(strict_types=1);

namespace Pest\Plugins\Tia;

use Symfony\Component\Process\ExecutableFinder;
use Symfony\Component\Process\Process;

/**
 * @internal
 */
final class JsModuleGraph
{
    private const int NODE_TIMEOUT_SECONDS = 180;

    private const string CACHE_FILE = 'js-module-graph.cache.json';

    /**
     * @var list<string>
     */
    public const array VITE_CONFIG_NAMES = [
        'vite.config.ts',
        'vite.config.js',
        'vite.config.mjs',
        'vite.config.cjs',
        'vite.config.mts',
    ];

    /**
     * @var list<string>
     */
    private const array PAGE_DIR_CANDIDATES = [
        'resources/js/Pages',
        'resources/js/pages',
        'assets/js/Pages',
        'assets/js/pages',
        'assets/Pages',
        'assets/pages',
    ];

    /**
     * @var list<string>
     */
    private const array PAGE_EXTENSIONS = [
        'vue', 'svelte',
        'tsx', 'jsx',
        'ts', 'js',
        'mts', 'cts', 'mjs', 'cjs',
    ];

    /**
     * @return array<string, list<string>>
     */
    public static function build(string $projectRoot): array
    {
        $result = self::resolve($projectRoot);

        return $result ?? [];
    }

    /**
     * @return array<string, list<string>>|null
     */
    public static function buildStrict(string $projectRoot): ?array
    {
        return self::resolve($projectRoot);
    }

    public static function isApplicable(string $projectRoot): bool
    {
        if (! self::hasViteConfig($projectRoot)) {
            return false;
        }

        return self::firstExistingPagesDir($projectRoot) !== null;
    }

    private static function firstExistingPagesDir(string $projectRoot): ?string
    {
        foreach (self::PAGE_DIR_CANDIDATES as $rel) {
            $abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);

            if (! is_dir($abs)) {
                continue;
            }

            if (! self::matchesDiskCasing($projectRoot, $rel)) {
                continue;
            }

            if (self::dirHasPageFile($abs)) {
                return $abs;
            }
        }

        return null;
    }

    private static function matchesDiskCasing(string $projectRoot, string $relative): bool
    {
        $current = rtrim($projectRoot, DIRECTORY_SEPARATOR);

        foreach (explode('/', $relative) as $segment) {
            $entries = @scandir($current);

            if ($entries === false || ! in_array($segment, $entries, true)) {
                return false;
            }

            $current .= DIRECTORY_SEPARATOR.$segment;
        }

        return true;
    }

    private static function dirHasPageFile(string $dir): bool
    {
        try {
            $iterator = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
                \RecursiveIteratorIterator::LEAVES_ONLY,
                \RecursiveIteratorIterator::CATCH_GET_CHILD,
            );
        } catch (\UnexpectedValueException) {
            return false;
        }

        /** @var \SplFileInfo $file */
        foreach ($iterator as $file) {
            if (! $file->isFile()) {
                continue;
            }

            if (in_array(strtolower($file->getExtension()), self::PAGE_EXTENSIONS, true)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return array<string, list<string>>|null
     */
    private static function resolve(string $projectRoot): ?array
    {
        $fingerprint = self::fingerprint($projectRoot);

        if ($fingerprint !== null) {
            $cached = self::readCache($projectRoot, $fingerprint);

            if ($cached !== null) {
                return $cached;
            }
        }

        $process = self::buildNodeProcess($projectRoot);

        if (! $process instanceof Process) {
            return null;
        }

        $process->run();

        if (! $process->isSuccessful()) {
            return null;
        }

        $result = self::parseNodeOutput($process->getOutput());

        if ($result !== null && $fingerprint !== null) {
            self::writeCache($projectRoot, $fingerprint, $result);
        }

        return $result;
    }

    private static function buildNodeProcess(string $projectRoot): ?Process
    {
        if (! self::hasViteConfig($projectRoot)) {
            return null;
        }

        $nodeModules = $projectRoot.DIRECTORY_SEPARATOR.'node_modules';

        if (! is_dir($nodeModules.DIRECTORY_SEPARATOR.'vite') && ! is_dir($nodeModules.DIRECTORY_SEPARATOR.'rolldown')) {
            return null;
        }

        $nodeBinary = (new ExecutableFinder)->find('node');

        if ($nodeBinary === null) {
            return null;
        }

        $helperPath = dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bin'.DIRECTORY_SEPARATOR.'pest-tia-vite-deps.mjs';

        if (! is_file($helperPath)) {
            return null;
        }

        $process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot);
        $process->setTimeout(self::NODE_TIMEOUT_SECONDS);

        return $process;
    }

    /**
     * @return array<string, list<string>>|null
     */
    private static function parseNodeOutput(string $output): ?array
    {
        /** @var mixed $decoded */
        $decoded = json_decode($output, true);

        if (! is_array($decoded)) {
            return null;
        }

        $out = [];

        foreach ($decoded as $path => $components) {
            if (! is_string($path)) {
                continue;
            }
            if (! is_array($components)) {
                continue;
            }
            $names = [];

            foreach ($components as $component) {
                if (is_string($component) && $component !== '') {
                    $names[] = $component;
                }
            }

            if ($names !== []) {
                sort($names);
                $out[$path] = $names;
            }
        }

        ksort($out);

        return $out;
    }

    private static function fingerprint(string $projectRoot): ?string
    {
        $parts = [];

        foreach (self::VITE_CONFIG_NAMES as $name) {
            $path = $projectRoot.DIRECTORY_SEPARATOR.$name;

            if (! is_file($path)) {
                continue;
            }

            $stat = @stat($path);
            $bytes = @file_get_contents($path);

            $parts[] = 'config:'.$name
                .':'.($stat === false ? '0' : (string) $stat['mtime'])
                .':'.($stat === false ? '0' : (string) $stat['size'])
                .':'.($bytes === false ? '' : hash('sha256', $bytes));
        }

        if ($parts === []) {
            return null;
        }

        $override = getenv('TIA_VITE_PAGES_DIR');

        if (is_string($override) && $override !== '') {
            $parts[] = 'pagesDirOverride:'.$override;
        }

        $pagesDir = self::firstExistingPagesDir($projectRoot);

        if ($pagesDir !== null) {
            $parts[] = 'pagesDir:'.str_replace($projectRoot.DIRECTORY_SEPARATOR, '', $pagesDir);
        }

        $jsRoot = $pagesDir !== null ? dirname($pagesDir) : null;

        if ($jsRoot !== null && is_dir($jsRoot)) {
            $entries = [];

            try {
                $iterator = new \RecursiveIteratorIterator(
                    new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
                    \RecursiveIteratorIterator::LEAVES_ONLY,
                    \RecursiveIteratorIterator::CATCH_GET_CHILD,
                );

                /** @var \SplFileInfo $file */
                foreach ($iterator as $file) {
                    if (! $file->isFile()) {
                        continue;
                    }

                    $entries[] = $file->getPathname()
                        .':'.$file->getSize()
                        .':'.$file->getMTime();
                }
            } catch (\UnexpectedValueException|\RuntimeException) {
                return null;
            }

            sort($entries);

            $parts[] = 'js:'.hash('sha256', implode("\n", $entries));
        }

        return hash('sha256', implode('|', $parts));
    }

    /**
     * @return array<string, list<string>>|null
     */
    private static function readCache(string $projectRoot, string $fingerprint): ?array
    {
        $path = self::cachePath($projectRoot);

        if (! is_file($path)) {
            return null;
        }

        $raw = @file_get_contents($path);

        if ($raw === false) {
            return null;
        }

        /** @var mixed $decoded */
        $decoded = json_decode($raw, true);

        if (! is_array($decoded)) {
            return null;
        }

        if (($decoded['fingerprint'] ?? null) !== $fingerprint) {
            return null;
        }

        $graph = $decoded['graph'] ?? null;

        if (! is_array($graph)) {
            return null;
        }

        $out = [];

        foreach ($graph as $key => $value) {
            if (! is_string($key)) {
                continue;
            }
            if (! is_array($value)) {
                continue;
            }
            $names = [];

            foreach ($value as $name) {
                if (is_string($name) && $name !== '') {
                    $names[] = $name;
                }
            }

            $out[$key] = $names;
        }

        return $out;
    }

    /**
     * @param  array<string, list<string>>  $graph
     */
    private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void
    {
        $path = self::cachePath($projectRoot);
        $dir = dirname($path);

        if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
            return;
        }

        $payload = json_encode([
            'fingerprint' => $fingerprint,
            'graph' => $graph,
        ]);

        if ($payload === false) {
            return;
        }

        $tmp = $path.'.tmp.'.bin2hex(random_bytes(4));

        if (@file_put_contents($tmp, $payload) === false) {
            return;
        }

        if (! @rename($tmp, $path)) {
            @unlink($tmp);
        }
    }

    private static function cachePath(string $projectRoot): string
    {
        return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::CACHE_FILE;
    }

    private static function hasViteConfig(string $projectRoot): bool
    {
        return array_any(self::VITE_CONFIG_NAMES, fn (string $name): bool => is_file($projectRoot.DIRECTORY_SEPARATOR.$name));
    }
}
