<?php declare(strict_types=1);
/*
 * This file is part of PHPUnit.
 *
 * (c) Sebastian Bergmann <sebastian@phpunit.de>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
namespace PHPUnit\Runner\Phpt;

use const DEBUG_BACKTRACE_IGNORE_ARGS;
use const DIRECTORY_SEPARATOR;
use function array_filter;
use function array_values;
use function assert;
use function basename;
use function debug_backtrace;
use function dirname;
use function explode;
use function extension_loaded;
use function file_exists;
use function file_get_contents;
use function in_array;
use function is_array;
use function is_file;
use function is_string;
use function ltrim;
use function ob_get_clean;
use function ob_start;
use function preg_match;
use function preg_replace;
use function realpath;
use function sprintf;
use function str_contains;
use function str_replace;
use function str_starts_with;
use function strncasecmp;
use function substr;
use function trim;
use function unlink;
use function unserialize;
use PHPUnit\Event\Code\Phpt;
use PHPUnit\Event\Code\ThrowableBuilder;
use PHPUnit\Event\Facade as EventFacade;
use PHPUnit\Event\NoPreviousThrowableException;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\ExecutionOrderDependency;
use PHPUnit\Framework\ExpectationFailedException;
use PHPUnit\Framework\IncompleteTestError;
use PHPUnit\Framework\PhptAssertionFailedError;
use PHPUnit\Framework\Reorderable;
use PHPUnit\Framework\SelfDescribing;
use PHPUnit\Framework\Test;
use PHPUnit\Runner\CodeCoverage;
use PHPUnit\Runner\CodeCoverageFileExistsException;
use PHPUnit\Runner\Exception;
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry;
use PHPUnit\Util\PHP\Job;
use PHPUnit\Util\PHP\JobRunnerRegistry;
use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData;
use SebastianBergmann\CodeCoverage\InvalidArgumentException;
use SebastianBergmann\CodeCoverage\ReflectionException;
use SebastianBergmann\CodeCoverage\Test\TestSize;
use SebastianBergmann\CodeCoverage\Test\TestStatus;
use SebastianBergmann\CodeCoverage\TestIdMissingException;
use SebastianBergmann\CodeCoverage\UnintentionallyCoveredCodeException;
use staabm\SideEffectsDetector\SideEffect;
use staabm\SideEffectsDetector\SideEffectsDetector;

/**
 * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
 *
 * @internal This class is not covered by the backward compatibility promise for PHPUnit
 *
 * @see https://qa.php.net/phpt_details.php
 *
 * @phpstan-type CoverageFiles array{coverage: non-empty-string, job: non-empty-string}
 */
final readonly class TestCase implements Reorderable, SelfDescribing, Test
{
    /**
     * @var non-empty-string
     */
    private string $filename;

    /**
     * @param non-empty-string $filename
     */
    public function __construct(string $filename)
    {
        $this->filename = $filename;

        $this->ensureCoverageFileDoesNotExist();
    }

    public function count(): int
    {
        return 1;
    }

    /**
     * @throws \PHPUnit\Framework\Exception
     * @throws \SebastianBergmann\Template\InvalidArgumentException
     * @throws Exception
     * @throws InvalidArgumentException
     * @throws NoPreviousThrowableException
     * @throws ReflectionException
     * @throws TestIdMissingException
     * @throws UnintentionallyCoveredCodeException
     */
    public function run(): void
    {
        $emitter = EventFacade::emitter();
        $parser  = new Parser;

        $emitter->testPreparationStarted(
            $this->valueObjectForEvents(),
        );

        try {
            $sections = $parser->parse($this->filename);
        } catch (Exception $e) {
            $emitter->testPrepared($this->valueObjectForEvents());
            $emitter->testErrored($this->valueObjectForEvents(), ThrowableBuilder::from($e));
            $emitter->testFinished($this->valueObjectForEvents(), 0);

            return;
        }

        if (!isset($sections['FILE']) || $sections['FILE'] === '') {
            throw new InvalidPhptFileException;
        }

        $fileExternalPath = $this->filename;

        if (isset($sections['FILE_EXTERNAL_PATH']) && $sections['FILE_EXTERNAL_PATH'] !== '') {
            $fileExternalPath = $sections['FILE_EXTERNAL_PATH'];
        }

        $code                 = (new Renderer)->render($fileExternalPath, $sections['FILE']);
        $xfail                = false;
        $environmentVariables = [];
        $phpSettings          = $parser->parseIniSection($this->settings(CodeCoverage::instance()->isActive()));
        $input                = null;
        $arguments            = [];

        $emitter->testPrepared($this->valueObjectForEvents());

        if (isset($sections['INI'])) {
            $phpSettings = $parser->parseIniSection($sections['INI'], $phpSettings);
        }

        if (isset($sections['ENV'])) {
            $environmentVariables = $parser->parseEnvSection($sections['ENV']);
        }

        if ($this->shouldTestBeSkipped($sections, $phpSettings)) {
            return;
        }

        if (isset($sections['XFAIL'])) {
            $xfail = trim($sections['XFAIL']);
        }

        if (isset($sections['STDIN']) && $sections['STDIN'] !== '') {
            $input = $sections['STDIN'];
        }

        if (isset($sections['ARGS'])) {
            $arguments = array_values(
                array_filter(
                    explode(
                        ' ',
                        $sections['ARGS'],
                    ),
                    static fn (string $arg): bool => $arg !== '',
                ),
            );
        }

        if (CodeCoverage::instance()->isActive()) {
            // @codeCoverageIgnoreStart
            $codeCoverageCacheDirectory = null;

            if (CodeCoverage::instance()->codeCoverage()->cachesStaticAnalysis()) {
                $codeCoverageCacheDirectory = CodeCoverage::instance()->codeCoverage()->cacheDirectory();
            }

            $bootstrap = '';

            if (ConfigurationRegistry::get()->hasBootstrap()) {
                $bootstrap = ConfigurationRegistry::get()->bootstrap();
            }

            (new Renderer)->renderForCoverage(
                $code,
                CodeCoverage::instance()->collectsBranchCoverage(),
                CodeCoverage::instance()->collectsPathCoverage(),
                $codeCoverageCacheDirectory,
                $bootstrap,
                $this->coverageFiles(),
            );
            // @codeCoverageIgnoreEnd
        }

        $jobResult = JobRunnerRegistry::run(
            new Job(
                $code,
                $this->stringifyIni($phpSettings),
                $environmentVariables,
                $arguments,
                $input,
                true,
            ),
        );

        EventFacade::emitter()->childProcessFinished($jobResult->stdout(), $jobResult->stderr());

        if (TestResultFacade::wasInterrupted()) {
            $this->runClean($sections, CodeCoverage::instance()->isActive());

            $emitter->testFinished($this->valueObjectForEvents(), 0);

            return;
        }

        $output = $jobResult->stdout();

        if (CodeCoverage::instance()->isActive()) {
            // @codeCoverageIgnoreStart
            $coverage = $this->cleanupForCoverage();

            CodeCoverage::instance()->codeCoverage()->start($this->filename, TestSize::Large);

            CodeCoverage::instance()->codeCoverage()->append(
                $coverage,
                $this->filename,
                true,
                TestStatus::Unknown,
            );
            // @codeCoverageIgnoreEnd
        }

        $passed = true;

        try {
            $this->assertPhptExpectation($sections, $output);
        } catch (AssertionFailedError $e) {
            $failure = $e;

            if ($xfail !== false) {
                $failure = new IncompleteTestError($xfail, 0, $e);
            } elseif ($e instanceof ExpectationFailedException) {
                $comparisonFailure = $e->getComparisonFailure();

                if ($comparisonFailure !== null) {
                    $diff = $comparisonFailure->getDiff();
                } else {
                    $diff = $e->getMessage();
                }

                $comparisonDiff = '';

                if ($comparisonFailure !== null) {
                    $comparisonDiff = $diff;
                }

                $hint  = $this->locationHintFromDiff($diff, $sections);
                $trace = [];

                foreach ($hint as $h) {
                    $trace[] = [
                        'file'     => $h['file'],
                        'line'     => $h['line'],
                        'function' => '',
                        'type'     => '',
                    ];
                }

                foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS) as $frame) {
                    $file = '';
                    $line = 0;
                    $type = '';

                    if (isset($frame['file'])) {
                        $file = $frame['file'];
                    }

                    if (isset($frame['line'])) {
                        $line = $frame['line'];
                    }

                    if (isset($frame['type'])) {
                        $type = $frame['type'];
                    }

                    $trace[] = [
                        'file'     => $file,
                        'line'     => $line,
                        'function' => $frame['function'],
                        'type'     => $type,
                    ];
                }

                $failure = new PhptAssertionFailedError(
                    $e->getMessage(),
                    0,
                    $hint[0]['file'],
                    $hint[0]['line'],
                    $trace,
                    $comparisonDiff,
                );
            }

            if ($failure instanceof IncompleteTestError) {
                $emitter->testMarkedAsIncomplete($this->valueObjectForEvents(), ThrowableBuilder::from($failure));
            } else {
                $emitter->testFailed($this->valueObjectForEvents(), ThrowableBuilder::from($failure), null);
            }

            $passed = false;
        }

        if ($passed) {
            $emitter->testPassed($this->valueObjectForEvents());
        }

        $this->runClean($sections, CodeCoverage::instance()->isActive());

        $emitter->testFinished($this->valueObjectForEvents(), 1);
    }

    /**
     * Returns the name of the test case.
     */
    public function getName(): string
    {
        return $this->toString();
    }

    /**
     * Returns a string representation of the test case.
     */
    public function toString(): string
    {
        return $this->filename;
    }

    public function sortId(): string
    {
        return $this->filename;
    }

    /**
     * @return list<ExecutionOrderDependency>
     */
    public function provides(): array
    {
        return [];
    }

    /**
     * @return list<ExecutionOrderDependency>
     */
    public function requires(): array
    {
        return [];
    }

    /**
     * @internal This method is not covered by the backward compatibility promise for PHPUnit
     */
    public function valueObjectForEvents(): Phpt
    {
        return new Phpt($this->filename);
    }

    /**
     * @param array<non-empty-string, string> $sections
     *
     * @throws ExpectationFailedException
     */
    private function assertPhptExpectation(array $sections, string $output): void
    {
        $assertions = [
            'EXPECT'      => 'assertEquals',
            'EXPECTF'     => 'assertStringMatchesFormat',
            'EXPECTREGEX' => 'assertMatchesRegularExpression',
        ];

        $actual = preg_replace('/\r\n/', "\n", trim($output));

        assert($actual !== null);

        foreach ($assertions as $sectionName => $sectionAssertion) {
            if (isset($sections[$sectionName])) {
                $sectionContent = preg_replace('/\r\n/', "\n", trim($sections[$sectionName]));

                assert($sectionContent !== null);

                $expected = $sectionContent;

                if ($sectionName === 'EXPECTREGEX') {
                    $expected = "/{$sectionContent}/";
                }

                /** @phpstan-ignore staticMethod.dynamicName */
                Assert::$sectionAssertion($expected, $actual);

                return;
            }
        }
    }

    /**
     * @param array<non-empty-string, string>               $sections
     * @param array<non-empty-string, array<string>|string> $settings
     */
    private function shouldTestBeSkipped(array $sections, array $settings): bool
    {
        if (!isset($sections['SKIPIF']) || $sections['SKIPIF'] === '') {
            return false;
        }

        $skipIfCode = (new Renderer)->render($this->filename, $sections['SKIPIF']);

        if ($this->shouldRunInSubprocess($sections, $skipIfCode)) {
            $jobResult = JobRunnerRegistry::run(
                new Job(
                    $skipIfCode,
                    $this->stringifyIni($settings),
                ),
            );

            $output = $jobResult->stdout();

            EventFacade::emitter()->childProcessFinished($output, $jobResult->stderr());
        } else {
            $output = $this->runCodeInLocalSandbox($skipIfCode);
        }

        $this->triggerRunnerWarningOnPhpErrors('SKIPIF', $output);

        if (strncasecmp('skip', ltrim($output), 4) === 0) {
            $message = '';

            if (preg_match('/^\s*skip\s*(.+)\s*/i', $output, $skipMatch) === 1) {
                $message = substr($skipMatch[1], 2);
            }

            if ($message === '') {
                $message = 'Skipped';
            }

            EventFacade::emitter()->testSkipped(
                $this->valueObjectForEvents(),
                $message,
            );

            EventFacade::emitter()->testFinished($this->valueObjectForEvents(), 0);

            return true;
        }

        $sideEffects = (new SideEffectsDetector)->getSideEffects($skipIfCode);

        if (!str_contains($output, 'Parse error:') &&
            !str_contains($output, 'Fatal error:') &&
            !in_array(SideEffect::STANDARD_OUTPUT, $sideEffects, true) &&
            !in_array(SideEffect::SCOPE_POLLUTION, $sideEffects, true)) {
            EventFacade::emitter()->testConsideredRisky(
                $this->valueObjectForEvents(),
                'SKIPIF section does not produce output that could result in the test being skipped',
            );
        }

        return false;
    }

    /**
     * @param array<non-empty-string, string> $sections
     */
    private function shouldRunInSubprocess(array $sections, string $cleanCode): bool
    {
        if (isset($sections['INI'])) {
            // to get per-test INI settings, we need a dedicated subprocess
            return true;
        }

        $detector    = new SideEffectsDetector;
        $sideEffects = $detector->getSideEffects($cleanCode);

        if ($sideEffects === []) {
            // no side-effects
            return false;
        }

        foreach ($sideEffects as $sideEffect) {
            if ($sideEffect === SideEffect::STANDARD_OUTPUT) {
                // stdout is fine, we will catch it using output-buffering
                continue;
            }

            if ($sideEffect === SideEffect::INPUT_OUTPUT) {
                // IO is fine, as it doesn't pollute the main process
                continue;
            }

            return true;
        }

        return false;
    }

    private function runCodeInLocalSandbox(string $code): string
    {
        $code = preg_replace('/^<\?(?:php)?|\?>\s*+$/', '', $code);

        assert($code !== null);

        $code = preg_replace('/declare\S?\([^)]+\)\S?;/', '', $code);

        assert($code !== null);

        // wrap in immediately invoked function to isolate local-side-effects of $code from our own process
        $code = '(function() {' . $code . '})();';
        ob_start();
        @eval($code);

        return (string) ob_get_clean();
    }

    /**
     * @param array<non-empty-string, string> $sections
     */
    private function runClean(array $sections, bool $collectCoverage): void
    {
        if (!isset($sections['CLEAN']) || $sections['CLEAN'] === '') {
            return;
        }

        $cleanCode = (new Renderer)->render($this->filename, $sections['CLEAN']);

        if ($this->shouldRunInSubprocess($sections, $cleanCode)) {
            $jobResult = JobRunnerRegistry::run(
                new Job(
                    $cleanCode,
                    $this->settings($collectCoverage),
                ),
            );

            $output = $jobResult->stdout();

            EventFacade::emitter()->childProcessFinished($jobResult->stdout(), $jobResult->stderr());
        } else {
            $output = $this->runCodeInLocalSandbox($cleanCode);
        }

        $this->triggerRunnerWarningOnPhpErrors('CLEAN', $output);
    }

    /**
     * @codeCoverageIgnore
     *
     * @phpstan-ignore return.internalClass
     */
    private function cleanupForCoverage(): RawCodeCoverageData
    {
        /**
         * @phpstan-ignore staticMethod.internalClass
         */
        $coverage = RawCodeCoverageData::fromXdebugWithoutPathCoverage([]);
        $files    = $this->coverageFiles();

        $buffer = false;

        if (is_file($files['coverage'])) {
            $buffer = @file_get_contents($files['coverage']);
        }

        if ($buffer !== false) {
            $unserialized = @unserialize(
                $buffer,
                [
                    'allowed_classes' => [
                        /** @phpstan-ignore classConstant.internalClass */
                        RawCodeCoverageData::class,
                    ],
                ],
            );

            /** @phpstan-ignore instanceof.internalClass */
            if ($unserialized instanceof RawCodeCoverageData) {
                $coverage = $unserialized;
            }
        }

        foreach ($files as $file) {
            @unlink($file);
        }

        return $coverage;
    }

    /**
     * @return CoverageFiles
     */
    private function coverageFiles(): array
    {
        $realPath = realpath($this->filename);

        if ($realPath === false) {
            $realPath = $this->filename;
        }

        $baseDir  = dirname($realPath) . DIRECTORY_SEPARATOR;
        $basename = basename($this->filename, 'phpt');

        return [
            'coverage' => $baseDir . $basename . 'coverage',
            'job'      => $baseDir . $basename . 'php',
        ];
    }

    /**
     * @param array<non-empty-string, array<string>|string> $ini
     *
     * @return list<non-empty-string>
     */
    private function stringifyIni(array $ini): array
    {
        $settings = [];

        foreach ($ini as $key => $value) {
            if (is_array($value)) {
                foreach ($value as $val) {
                    $settings[] = $key . '=' . $val;
                }

                continue;
            }

            $settings[] = $key . '=' . $value;
        }

        return $settings;
    }

    /**
     * @param array<non-empty-string, string> $sections
     *
     * @return non-empty-list<array{file: non-empty-string, line: int}>
     */
    private function locationHintFromDiff(string $message, array $sections): array
    {
        $needle       = '';
        $previousLine = '';
        $block        = 'message';

        foreach (explode("\n", str_replace(["\r\n", "\r"], "\n", $message)) as $line) {
            $line = trim($line);

            if ($block === 'message' && $line === '--- Expected') {
                $block = 'expected';
            }

            if ($block === 'expected' && $line === '@@ @@') {
                $block = 'diff';
            }

            if ($block === 'diff') {
                if (str_starts_with($line, '+')) {
                    $needle = $this->cleanDiffLine($previousLine);

                    break;
                }

                if (str_starts_with($line, '-')) {
                    $needle = $this->cleanDiffLine($line);

                    break;
                }
            }

            if ($line !== '') {
                $previousLine = $line;
            }
        }

        return $this->locationHint($needle, $sections);
    }

    private function cleanDiffLine(string $line): string
    {
        if (preg_match('/^[\-+]([\'\"]?)(.*)\1$/', $line, $matches) === 1) {
            $line = $matches[2];
        }

        return $line;
    }

    /**
     * @param array<non-empty-string, string> $sections
     *
     * @return non-empty-list<array{file: non-empty-string, line: int}>
     */
    private function locationHint(string $needle, array $sections): array
    {
        $needle   = trim($needle);
        $realFile = realpath($this->filename);

        assert(is_string($realFile));
        assert($realFile !== '');

        if ($needle === '') {
            return [[
                'file' => $realFile,
                'line' => 1,
            ]];
        }

        $search = [
            // 'FILE',
            'EXPECT',
            'EXPECTF',
            'EXPECTREGEX',
        ];

        foreach ($search as $section) {
            if (!isset($sections[$section])) {
                continue;
            }

            if (isset($sections[$section . '_EXTERNAL'])) {
                $externalFile     = trim($sections[$section . '_EXTERNAL']);
                $externalRealPath = realpath(dirname($this->filename) . DIRECTORY_SEPARATOR . $externalFile);

                assert(is_string($externalRealPath) && $externalRealPath !== '');

                return [
                    [
                        'file' => $externalRealPath,
                        'line' => 1,
                    ],
                    [
                        'file' => $realFile,
                        'line' => $this->sectionOffset($sections, $section . '_EXTERNAL_offset') + 1,
                    ],
                ];
            }

            $sectionOffset = $this->sectionOffset($sections, $section . '_offset');
            $offset        = $sectionOffset + 1;

            foreach (explode("\n", str_replace(["\r\n", "\r"], "\n", $sections[$section])) as $line) {
                if (str_contains($line, $needle)) {
                    return [
                        [
                            'file' => $realFile,
                            'line' => $offset,
                        ],
                    ];
                }

                $offset++;
            }
        }

        return [
            [
                'file' => $realFile,
                'line' => 1,
            ],
        ];
    }

    /**
     * @return list<string>
     */
    private function settings(bool $collectCoverage): array
    {
        $settings = [
            'allow_url_fopen=1',
            'auto_append_file=',
            'auto_prepend_file=',
            'disable_functions=',
            'display_errors=1',
            'docref_ext=.html',
            'docref_root=',
            'error_append_string=',
            'error_prepend_string=',
            'error_reporting=-1',
            'html_errors=0',
            'log_errors=0',
            'open_basedir=',
            'output_buffering=Off',
            'output_handler=',
            'report_zend_debug=0',
        ];

        // @codeCoverageIgnoreStart
        if (extension_loaded('pcov')) {
            if ($collectCoverage) {
                $settings[] = 'pcov.enabled=1';
            } else {
                $settings[] = 'pcov.enabled=0';
            }
        }

        if (extension_loaded('xdebug')) {
            if ($collectCoverage) {
                $settings[] = 'xdebug.mode=coverage';
            }
        }
        // @codeCoverageIgnoreEnd

        return $settings;
    }

    private function triggerRunnerWarningOnPhpErrors(string $section, string $output): void
    {
        if (str_contains($output, 'Parse error:')) {
            EventFacade::emitter()->testRunnerTriggeredPhpunitWarning(
                sprintf(
                    '%s section triggered a parse error: %s',
                    $section,
                    $output,
                ),
            );
        }

        if (str_contains($output, 'Fatal error:')) {
            EventFacade::emitter()->testRunnerTriggeredPhpunitWarning(
                sprintf(
                    '%s section triggered a fatal error: %s',
                    $section,
                    $output,
                ),
            );
        }
    }

    /**
     * @param array<non-empty-string, string> $sections
     * @param non-empty-string                $key
     */
    private function sectionOffset(array $sections, string $key): int
    {
        assert(isset($sections[$key]));

        return (int) $sections[$key];
    }

    /**
     * @throws CodeCoverageFileExistsException
     */
    private function ensureCoverageFileDoesNotExist(): void
    {
        $files = $this->coverageFiles();

        if (file_exists($files['coverage'])) {
            throw new CodeCoverageFileExistsException(
                sprintf(
                    'File %s exists, PHPT test %s will not be executed',
                    $files['coverage'],
                    $this->filename,
                ),
            );
        }
    }
}
