<?php

/*
 * This file is part of the Symfony MakerBundle package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Bundle\MakerBundle\Test;

use Composer\InstalledVersions;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\InputStream;

/**
 * @author Sadicov Vladimir <sadikoff@gmail.com>
 * @author Nicolas Philippe <nikophil@gmail.com>
 *
 * @internal
 */
final class MakerTestEnvironment
{
    // Config used for creating tmp flex project and test app's
    private const GIT_CONFIG = 'git config user.name "symfony" && git config user.email "test@symfony.com" && git config commit.gpgsign false && git config user.signingkey false';

    public const GENERATED_FILES_REGEX = '#(?:created|updated):\s(?:.*\\\\)*(.*\.[a-z]{3,4}).*(?:\\\\n)?#ui';

    private Filesystem $fs;
    private string $packageName;
    private string $rootPath;
    private string $cachePath;
    private string $flexPath;
    private string $fixturesPath;
    private string $path;
    private MakerTestProcess $runnedMakerProcess;
    private bool $isWindows;

    private function __construct(
        private MakerTestDetails $testDetails,
    ) {
        $this->isWindows = '\\' === \DIRECTORY_SEPARATOR;

        $this->fs = new Filesystem();
        $composerPackage = InstalledVersions::getRootPackage();
        $this->packageName = $composerPackage['name'];
        $this->rootPath = realpath($composerPackage['install_path']);
        $cachePath = $this->rootPath.'/tests/tmp/cache';

        if (!$this->fs->exists($cachePath)) {
            $this->fs->mkdir($cachePath);
        }

        $this->cachePath = realpath($cachePath);
        $targetVersion = $this->getTargetSkeletonVersion();
        $this->flexPath = $this->cachePath.'/flex_project'.$targetVersion;
        $this->fixturesPath = $this->rootPath.'/tests/fixtures/';

        $directoryName = $targetVersion ?: 'current';
        if (str_ends_with($directoryName, '.*')) {
            $directoryName = substr($directoryName, 0, -2);
        }

        $this->path = $this->cachePath.\DIRECTORY_SEPARATOR.$testDetails->getUniqueCacheDirectoryName().'_'.$directoryName;
    }

    public static function create(MakerTestDetails $testDetails): self
    {
        return new self($testDetails);
    }

    public function getFixturesPath(string $path = ''): string
    {
        return $this->fixturesPath.$path;
    }

    public function getPath(): string
    {
        return $this->path;
    }

    public function readFile(string $path): string
    {
        if (!file_exists($this->path.'/'.$path)) {
            throw new \InvalidArgumentException(\sprintf('Cannot find file "%s"', $path));
        }

        return file_get_contents($this->path.'/'.$path);
    }

    private function changeRootNamespaceIfNeeded(): void
    {
        if ('App' === ($rootNamespace = $this->testDetails->getRootNamespace())) {
            return;
        }

        $replacements = [
            [
                'filename' => 'composer.json',
                'find' => '"App\\\\": "src/"',
                'replace' => '"'.$rootNamespace.'\\\\": "src/"',
            ],
            [
                'filename' => 'src/Kernel.php',
                'find' => 'namespace App',
                'replace' => 'namespace '.$rootNamespace,
            ],
            [
                'filename' => 'bin/console',
                'find' => 'use App\\Kernel',
                'replace' => 'use '.$rootNamespace.'\\Kernel',
            ],
            [
                'filename' => 'public/index.php',
                'find' => 'use App\\Kernel',
                'replace' => 'use '.$rootNamespace.'\\Kernel',
            ],
            [
                'filename' => 'config/services.yaml',
                'find' => 'App\\',
                'replace' => $rootNamespace.'\\',
            ],
            [
                'filename' => '.env.test',
                'find' => 'KERNEL_CLASS=\'App\Kernel\'',
                'replace' => 'KERNEL_CLASS=\''.$rootNamespace.'\Kernel\'',
            ],
        ];

        if ($this->fs->exists($this->path.'/config/packages/doctrine.yaml')) {
            $replacements[] = [
                'filename' => 'config/packages/doctrine.yaml',
                'find' => 'App',
                'replace' => $rootNamespace,
            ];
        }

        $this->processReplacements($replacements, $this->path);
        $this->runCommand('composer dump-autoload');
    }

    public function prepareDirectory(): void
    {
        // Copy MakerBundle to a "repo" directory for tests
        if (!file_exists($makerRepoPath = \sprintf('%s/maker-repo', $this->cachePath))) {
            MakerTestProcess::create(['git', 'clone', $this->rootPath, $makerRepoPath], $this->cachePath)->run();
        }

        if (!$this->fs->exists($this->flexPath)) {
            try {
                $this->buildFlexSkeleton();
            } catch (\Exception $e) {
                $this->fs->remove($this->flexPath);

                throw $e;
            }
        }

        if (!$this->fs->exists($this->path)) {
            try {
                // let's do some magic here git is faster than copy
                MakerTestProcess::create(['git', 'clone', $this->flexPath, $this->path], \dirname($this->flexPath))->run();

                // In Window's we have to require MakerBundle in each project - git clone doesn't symlink well
                if ($this->isWindows) {
                    $this->composerRequireMakerBundle($this->path);
                }

                // install any missing dependencies
                $dependencies = $this->determineMissingDependencies();
                if ($dependencies) {
                    // -v actually silences the "very" verbose output in case of an error
                    $composerProcess = MakerTestProcess::create(\sprintf('composer require %s -v', implode(' ', $dependencies)), $this->path)
                        ->run(true)
                    ;

                    if (!$composerProcess->isSuccessful()) {
                        throw new \Exception(\sprintf('Error running command: composer require "%s" -v. Output: "%s". Error: "%s"', implode(' ', $dependencies), $composerProcess->getOutput(), $composerProcess->getErrorOutput()));
                    }
                }

                $this->changeRootNamespaceIfNeeded();

                file_put_contents($this->path.'/.gitignore', "var/cache/\nvendor/\n");

                MakerTestProcess::create(\sprintf('git diff --quiet || ( %s && git add . && git commit -a -m "second commit" )', self::GIT_CONFIG),
                    $this->path
                )->run();
            } catch (\Exception $e) {
                $this->fs->remove($this->path);

                throw $e;
            }
        } else {
            MakerTestProcess::create('git reset --hard && git clean -fd', $this->path)->run();
            $this->fs->remove($this->path.'/var/cache');
        }
    }

    /**
     * @param string|list<string> $command
     */
    public function runCommand(string|array $command): MakerTestProcess
    {
        return MakerTestProcess::create($command, $this->path)->run();
    }

    public function runMaker(array $inputs, string $argumentsString = '', bool $allowedToFail = false, array $envVars = []): MakerTestProcess
    {
        // Let's remove cache
        $this->fs->remove($this->path.'/var/cache');

        $testProcess = $this->createInteractiveCommandProcess(
            commandName: $this->testDetails->getMaker()::getCommandName(),
            userInputs: $inputs,
            argumentsString: $argumentsString,
            envVars: $envVars,
        );

        $this->runnedMakerProcess = $testProcess->run($allowedToFail);

        return $this->runnedMakerProcess;
    }

    public function getGeneratedFilesFromOutputText(): array
    {
        $output = $this->runnedMakerProcess->getOutput();

        $matches = [];

        preg_match_all(self::GENERATED_FILES_REGEX, $output, $matches, \PREG_PATTERN_ORDER);

        return array_map('trim', $matches[1]);
    }

    public function fileExists(string $file): bool
    {
        return $this->fs->exists($this->path.'/'.$file);
    }

    public function runTwigCSLint(string $file): MakerTestProcess
    {
        if (!file_exists($this->rootPath.'/tools/twigcs/vendor/bin/twigcs')) {
            throw new \Exception('twigcs not found: run: "composer upgrade -W --working-dir=tools/twigcs".');
        }

        return MakerTestProcess::create(\sprintf('php tools/twigcs/vendor/bin/twigcs --config ./tools/twigcs/.twig_cs.dist %s', $this->path.'/'.$file), $this->rootPath)
                               ->run(true);
    }

    private function buildFlexSkeleton(): void
    {
        $targetVersion = $this->getTargetSkeletonVersion();
        $versionString = $targetVersion ? \sprintf(':%s', $targetVersion) : '';

        $flexProjectDir = \sprintf('%s/flex_project%s', $this->cachePath, $targetVersion);

        MakerTestProcess::create(
            \sprintf('composer create-project symfony/skeleton%s %s --prefer-dist --no-progress --keep-vcs', $versionString, $flexProjectDir),
            $this->cachePath
        )->run();

        $rootPath = str_replace('\\', '\\\\', $this->rootPath);

        $this->addMakerBundleRepoToComposer($flexProjectDir);

        // In Linux, git plays well with symlinks - we can add maker to the flex skeleton.
        if (!$this->isWindows) {
            $this->composerRequireMakerBundle($flexProjectDir);
        }

        // fetch a few packages needed for testing
        MakerTestProcess::create('composer require phpunit:1.1.* browser-kit symfony/css-selector --prefer-dist --no-progress --no-suggest', $this->flexPath)
                        ->run();

        $replacements = [
            // temporarily ignoring indirect deprecations - see #237
            [
                'filename' => '.env.test',
                'find' => 'SYMFONY_DEPRECATIONS_HELPER=999999',
                'replace' => 'SYMFONY_DEPRECATIONS_HELPER=max[self]=0',
                'allow_not_found' => true, // Not present in PHPUnit 11+ recipe
            ],
            // do not explicitly set the PHPUnit version
            [
                'filename' => 'phpunit.xml.dist',
                'find' => '<server name="SYMFONY_PHPUNIT_VERSION" value="9.6" />',
                'replace' => '',
                'allow_not_found' => true, // Not present in PHPUnit 10+ recipe
            ],
        ];
        $this->processReplacements($replacements, $this->flexPath);
        // end of temp code

        file_put_contents($this->flexPath.'/.gitignore', "var/cache/\n");

        // Force adding vendor/ dir to Git repo in case users exclude it in global .gitignore
        MakerTestProcess::create(\sprintf('git init && %s && git add . && git add vendor/ -f && git commit -a -m "first commit"', self::GIT_CONFIG),
            $this->flexPath
        )->run();
    }

    private function processReplacements(array $replacements, string $rootDir): void
    {
        foreach ($replacements as $replacement) {
            $this->processReplacement($rootDir, $replacement['filename'], $replacement['find'], $replacement['replace'], $replacement['allow_not_found'] ?? false);
        }
    }

    public function processReplacement(string $rootDir, string $filename, string $find, string $replace, bool $allowNotFound = false): void
    {
        $path = realpath($rootDir.'/'.$filename);

        if (!$this->fs->exists($path)) {
            if ($allowNotFound) {
                return;
            }

            throw new \Exception(\sprintf('Could not find file "%s" to process replacements inside "%s"', $filename, $rootDir));
        }

        $contents = file_get_contents($path);
        if (!str_contains($contents, $find)) {
            if ($allowNotFound) {
                return;
            }

            throw new \Exception(\sprintf('Could not find "%s" inside "%s"', $find, $filename));
        }

        file_put_contents($path, str_replace($find, $replace, $contents));
    }

    public function createInteractiveCommandProcess(string $commandName, array $userInputs, string $argumentsString = '', array $envVars = []): MakerTestProcess
    {
        $envVars = array_merge(['SHELL_INTERACTIVE' => '1'], $envVars);

        // We don't need ansi coloring in tests!
        $process = MakerTestProcess::create(
            commandLine: \sprintf('php bin/console %s %s --no-ansi', $commandName, $argumentsString),
            cwd: $this->path,
            envVars: $envVars,
            timeout: 30
        );

        if ($userInputs) {
            $inputStream = new InputStream();

            // start the command with some input
            $inputStream->write(current($userInputs)."\n");

            $inputStream->onEmpty(static function () use ($inputStream, &$userInputs) {
                $nextInput = next($userInputs);
                if (false === $nextInput) {
                    $inputStream->close();
                } else {
                    $inputStream->write($nextInput."\n");
                }
            });

            $process->setInput($inputStream);
        }

        return $process;
    }

    public function getSymfonyVersionInApp(): int
    {
        $contents = file_get_contents($this->getPath().'/vendor/symfony/http-kernel/Kernel.php');
        $position = strpos($contents, 'VERSION_ID = ');

        return (int) substr($contents, $position + 13, 5);
    }

    public function doesClassExistInApp(string $class): bool
    {
        $classMap = require $this->getPath().'/vendor/composer/autoload_classmap.php';

        return isset($classMap[$class]);
    }

    /**
     * Executes the DependencyBuilder for the Maker command inside the
     * actual project, so we know exactly what dependencies we need or
     * don't need.
     */
    private function determineMissingDependencies(): array
    {
        $depBuilder = $this->testDetails->getDependencyBuilder();
        file_put_contents($this->path.'/dep_builder', serialize($depBuilder));
        file_put_contents($this->path.'/dep_runner.php', '<?php

require __DIR__."/vendor/autoload.php";
$depBuilder = unserialize(file_get_contents("dep_builder"));
$missingDependencies = array_merge(
    $depBuilder->getMissingDependencies(),
    $depBuilder->getMissingDevDependencies()
);
echo json_encode($missingDependencies);
        ');

        $process = MakerTestProcess::create('php dep_runner.php', $this->path)->run();
        $data = json_decode($process->getOutput(), true, 512, \JSON_THROW_ON_ERROR);

        unlink($this->path.'/dep_builder');
        unlink($this->path.'/dep_runner.php');

        return array_merge($data, $this->testDetails->getExtraDependencies());
    }

    public function getTargetSkeletonVersion(): ?string
    {
        return $_SERVER['SYMFONY_VERSION'] ?? '';
    }

    private function composerRequireMakerBundle(string $projectDirectory): void
    {
        MakerTestProcess::create(['composer', 'require', '--dev', $this->packageName], $projectDirectory)->run();

        $makerRepoSrcPath = \sprintf('%s/maker-repo/src', $this->cachePath);

        // DX - So we can test local changes without having to commit them.
        if (!is_link($makerRepoSrcPath)) {
            $this->fs->remove($makerRepoSrcPath);
            $this->fs->symlink(\sprintf('%s/src', $this->rootPath), $makerRepoSrcPath);
        }
    }

    /**
     * Adds symfony/maker-bundle as a "path" repository to composer.json.
     */
    private function addMakerBundleRepoToComposer(string $projectDirectory): void
    {
        $repo = [
            'type' => 'path',
            'url' => \sprintf('%s%smaker-repo', $this->cachePath, \DIRECTORY_SEPARATOR),
            'options' => [
                'versions' => [
                    $this->packageName => '9999.99', // Arbitrary version to avoid stability conflicts
                ],
            ],
        ];

        MakerTestProcess::create(['composer', 'repo', 'add', $this->packageName, json_encode($repo)], $projectDirectory)->run();
    }
}
