<?php

declare(strict_types=1);

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao\Tools\IsolatedTests;

use Contao\CoreBundle\Tests\PhpunitExtension\GlobalStateWatcher;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;

#[AsCommand(
    name: 'contao:run-tests-isolated',
    description: 'Runs the unit tests isolated from each other.',
)]
class RunTestsIsolatedCommand extends Command
{
    private readonly string|false $phpPath;

    public function __construct(private readonly string $projectDir)
    {
        parent::__construct();

        $this->phpPath = (new PhpExecutableFinder())->find();
    }

    protected function configure(): void
    {
        $this
            ->addOption('depth', null, InputOption::VALUE_REQUIRED, '1 for test classes, 2 for test methods, 3 for every single provider data set', '3')
            ->setHelp(
                <<<'EOT'
                    The command runs each unit test completely isolated from the others, starting
                    a new PHPUnit process for each test class, method, or data set. This gives us
                    "real" isolation rather than shared state, unlike the PHPUnit option
                    --process-isolation does.
                    EOT,
            )
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        if (false === $this->phpPath) {
            throw new \RuntimeException('The php executable could not be found.');
        }

        $depth = (int) $input->getOption('depth');

        if ($depth < 1 || $depth > 3) {
            throw new \InvalidArgumentException('--depth must be an integer between 1 and 3');
        }

        $php = [
            $this->phpPath,
            '-dmemory_limit='.\ini_get('memory_limit'),
        ];

        if (OutputInterface::VERBOSITY_DEBUG === $output->getVerbosity()) {
            $php[] = '-ddisplay_errors=-1';
            $php[] = '-ddisplay_startup_errors=-1';
        }

        $phpunit = $this->projectDir.'/vendor/bin/phpunit';
        $listOutput = new BufferedOutput();

        $this->executeCommand([...$php, $phpunit, '--list-tests'], $listOutput);

        $tests = [[], [], []];

        foreach (preg_split('/\r?\n/', $listOutput->fetch()) as $line) {
            if (preg_match('/^ - (\S+)(::[^\s#"]+)(.*)$/', $line, $matches)) {
                $tests[0][] = $matches[1];

                if ($depth > 1) {
                    $tests[1][] = $matches[1].$matches[2];
                }

                if ($matches[3] && $depth > 2) {
                    $tests[2][] .= $matches[1].$matches[2].' with data set '.$matches[3];
                }
            }
        }

        $tests = array_values(array_unique(array_merge(...$tests)));

        $commandFlags = array_filter([
            $output->isDecorated() ? '--colors=always' : '',
            $this->getVerbosityFlag($output),
        ]);

        $failedTests = [];

        foreach ($tests as $test) {
            // Skip if the whole class, or the test with all data sets failed already
            foreach ($failedTests as $failedTest) {
                if (str_starts_with($test, $failedTest)) {
                    continue 2;
                }
            }

            $filter = preg_quote($test);
            $output->writeln("> vendor/bin/phpunit --filter '".str_replace("'", '\'"\'"\'', $filter)."'");

            $buffer = new BufferedOutput();

            try {
                $this->executeCommand([...$php, $phpunit, '--extension', GlobalStateWatcher::class, '--filter', $filter, ...$commandFlags], $buffer);

                // Clear previously written line
                $output->write("\e[1A\e[K");
            } catch (\Throwable) {
                $failedTests[] = $test;
                $output->writeln($buffer->fetch());
            }
        }

        if ($failedTests) {
            $output->writeln("<error>Failed executing tests:\n - ".implode("\n - ", $failedTests).'</error>');

            return 1;
        }

        $output->writeln('<info>Good job! All tests green 💪.</info>');

        return 0;
    }

    /**
     * Executes a console command in its own process and streams the output.
     */
    private function executeCommand(array $command, OutputInterface $output): void
    {
        $process = new Process($command);

        // Increase the timeout according to contao/manager-bundle (see #54)
        $process->setTimeout(500);
        $process->run(static fn (string $type, string $buffer) => $output->write($buffer));

        if (!$process->isSuccessful()) {
            throw new \RuntimeException(\sprintf('An error occurred while executing the "%s" command: %s', implode(' ', $command), $process->getErrorOutput()));
        }
    }

    private function getVerbosityFlag(OutputInterface $output): string
    {
        return match ($output->getVerbosity()) {
            OutputInterface::VERBOSITY_DEBUG => '-vvv',
            OutputInterface::VERBOSITY_VERY_VERBOSE => '-vv',
            OutputInterface::VERBOSITY_VERBOSE => '-v',
            default => '',
        };
    }
}
