<?php

declare(strict_types=1);

namespace ParaTest\WrapperRunner;

use Generator;
use ParaTest\Options;
use PHPUnit\Event\Facade as EventFacade;
use PHPUnit\Framework\Test;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Runner\Extension\ExtensionBootstrapper;
use PHPUnit\Runner\Extension\Facade as ExtensionFacade;
use PHPUnit\Runner\Extension\PharLoader;
use PHPUnit\Runner\Phpt\TestCase as PhptTestCase;
use PHPUnit\Runner\ResultCache\DefaultResultCache;
use PHPUnit\Runner\ResultCache\NullResultCache;
use PHPUnit\Runner\TestSuiteSorter;
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use PHPUnit\TextUI\Command\Result;
use PHPUnit\TextUI\Command\WarmCodeCoverageCacheCommand;
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
use PHPUnit\TextUI\Configuration\PhpHandler;
use PHPUnit\TextUI\Configuration\TestSuiteBuilder;
use PHPUnit\TextUI\TestSuiteFilterProcessor;
use ReflectionClass;
use ReflectionProperty;
use Symfony\Component\Console\Output\OutputInterface;

use function array_keys;
use function array_merge;
use function array_slice;
use function assert;
use function ceil;
use function count;
use function is_int;
use function is_string;
use function mt_srand;
use function ob_get_clean;
use function ob_start;
use function preg_quote;
use function sprintf;
use function str_starts_with;
use function strlen;
use function substr;

/** @internal */
final readonly class SuiteLoader
{
    public int $testCount;
    /** @var list<non-empty-string> */
    public array $tests;

    public function __construct(
        private Options $options,
        OutputInterface $output,
        CodeCoverageFilterRegistry $codeCoverageFilterRegistry,
    ) {
        (new PhpHandler())->handle($this->options->configuration->php());

        if ($this->options->configuration->hasBootstrap()) {
            $bootstrapFilename = $this->options->configuration->bootstrap();
            include_once $bootstrapFilename;
            EventFacade::emitter()->testRunnerBootstrapFinished($bootstrapFilename);
        }

        if (! $this->options->configuration->noExtensions()) {
            if ($this->options->configuration->hasPharExtensionDirectory()) {
                (new PharLoader())->loadPharExtensionsInDirectory(
                    $this->options->configuration->pharExtensionDirectory(),
                );
            }

            $extensionFacade       = new ExtensionFacade();
            $extensionBootstrapper = new ExtensionBootstrapper(
                $this->options->configuration,
                $extensionFacade,
            );

            foreach ($this->options->configuration->extensionBootstrappers() as $bootstrapper) {
                $extensionBootstrapper->bootstrap(
                    $bootstrapper['className'],
                    $bootstrapper['parameters'],
                );
            }
        }

        TestResultFacade::init();
        EventFacade::instance()->seal();

        $testSuite = (new TestSuiteBuilder())->build($this->options->configuration);

        if ($this->options->hasShard()) {
            $this->shardTests($testSuite);
        }

        if ($this->options->configuration->executionOrder() === TestSuiteSorter::ORDER_RANDOMIZED) {
            mt_srand($this->options->configuration->randomOrderSeed());
        }

        if (
            $this->options->configuration->executionOrder() !== TestSuiteSorter::ORDER_DEFAULT ||
            $this->options->configuration->executionOrderDefects() !== TestSuiteSorter::ORDER_DEFAULT ||
            $this->options->configuration->resolveDependencies()
        ) {
            $resultCache = new NullResultCache();
            if ($this->options->configuration->cacheResult()) {
                $resultCache = new DefaultResultCache($this->options->configuration->testResultCacheFile());
                $resultCache->load();
            }

            (new TestSuiteSorter($resultCache))->reorderTestsInSuite(
                $testSuite,
                $this->options->configuration->executionOrder(),
                $this->options->configuration->resolveDependencies(),
                $this->options->configuration->executionOrderDefects(),
            );
        }

        (new TestSuiteFilterProcessor())->process($this->options->configuration, $testSuite);

        $this->testCount = count($testSuite);

        $files = [];
        $tests = [];
        foreach ($this->loadFiles($testSuite) as $file => $test) {
            $files[$file] = null;

            if ($test instanceof PhptTestCase) {
                $tests[] = $file;
            } else {
                $name = $test->name();
                if ($test->providedData() !== []) {
                    $dataName = $test->dataName();
                    if ($this->options->functional) {
                        $name = sprintf('/%s%s$/', preg_quote($name, '/'), preg_quote($test->dataSetAsString(), '/'));
                    } else {
                        if (is_int($dataName)) {
                            $name .= '#' . $dataName;
                        } else {
                            $name .= '@' . $dataName;
                        }
                    }
                } else {
                    $name = sprintf('/%s$/', $name);
                }

                $tests[] = "$file\0$name";
            }
        }

        $this->tests = $this->options->functional
            ? $tests
            : array_keys($files);

        if (! $this->options->configuration->hasCoverageReport()) {
            return;
        }

        ob_start();
        $result       = (new WarmCodeCoverageCacheCommand(
            $this->options->configuration,
            $codeCoverageFilterRegistry,
        ))->execute();
        $ob_get_clean = ob_get_clean();
        assert($ob_get_clean !== false);
        $output->write($ob_get_clean);
        $output->write($result->output());
        if ($result->shellExitCode() !== Result::SUCCESS) {
            exit($result->shellExitCode());
        }
    }

    /** @return Generator<non-empty-string, (PhptTestCase|TestCase)> */
    private function loadFiles(TestSuite $testSuite): Generator
    {
        foreach ($testSuite as $test) {
            if ($test instanceof TestSuite) {
                yield from $this->loadFiles($test);

                continue;
            }

            if ($test instanceof PhptTestCase) {
                $refProperty = new ReflectionProperty(PhptTestCase::class, 'filename');
                $filename    = $refProperty->getValue($test);
                assert(is_string($filename) && $filename !== '');
                $filename = $this->stripCwd($filename);

                yield $filename => $test;

                continue;
            }

            if ($test instanceof TestCase) {
                $refClass = new ReflectionClass($test);
                $filename = $refClass->getFileName();
                assert(is_string($filename));
                $filename = $this->stripCwd($filename);

                yield $filename => $test;

                continue;
            }
        }
    }

    /**
     * @param non-empty-string $filename
     *
     * @return non-empty-string
     */
    private function stripCwd(string $filename): string
    {
        if (! str_starts_with($filename, $this->options->cwd)) {
            return $filename;
        }

        $substr = substr($filename, 1 + strlen($this->options->cwd));
        assert($substr !== '');

        return $substr;
    }

    private function shardTests(TestSuite $suite): void
    {
        $tests = $this->extractTestsInSuite($suite);

        $shards        = $this->options->totalShards;
        $current       = $this->options->currentShard - 1; // 0 indexed. Shard 1 is in reality shard 0
        $total         = count($tests);
        $testsPerShard = (int) ceil($total / $shards);
        $offset        = $testsPerShard * $current;

        $suite->setTests(array_slice($tests, $offset, $testsPerShard));
    }

    /** @return list<Test> */
    private function extractTestsInSuite(TestSuite $suite): array
    {
        $extractedTests = [];
        $suiteItems     = $suite->tests();

        foreach ($suiteItems as $item) {
            if ($item instanceof TestSuite) {
                $extractedTests = array_merge($extractedTests, $this->extractTestsInSuite($item));
            } else {
                $extractedTests[] = $item;
            }
        }

        return $extractedTests;
    }
}
