<?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\Framework\TestRunner;

use function assert;
use function bin2hex;
use function defined;
use function get_include_path;
use function hrtime;
use function random_bytes;
use function serialize;
use function sprintf;
use function sys_get_temp_dir;
use function tempnam;
use function unlink;
use function var_export;
use PHPUnit\Event\Facade as EventFacade;
use PHPUnit\Event\NoPreviousThrowableException;
use PHPUnit\Event\TestRunner\ChildProcessReason;
use PHPUnit\Framework\Exception;
use PHPUnit\Framework\ProcessIsolationException;
use PHPUnit\Framework\TestCase;
use PHPUnit\Runner\CodeCoverage;
use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry;
use PHPUnit\TextUI\Configuration\SourceMapper;
use PHPUnit\Util\GlobalState;
use PHPUnit\Util\PHP\Job;
use PHPUnit\Util\PHP\JobRunnerRegistry;
use ReflectionClass;
use SebastianBergmann\Template\InvalidArgumentException;
use SebastianBergmann\Template\Template;

/**
 * @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
 */
final class SeparateProcessTestRunner
{
    private static ?string $sourceMapFile = null;

    /**
     * @throws \PHPUnit\Runner\Exception
     * @throws \PHPUnit\Util\Exception
     * @throws Exception
     * @throws InvalidArgumentException
     * @throws NoPreviousThrowableException
     * @throws ProcessIsolationException
     */
    public function run(TestCase $test, bool $preserveGlobalState, bool $requiresXdebug): void
    {
        $class = new ReflectionClass($test);

        $bootstrap     = '';
        $constants     = '';
        $globals       = '';
        $includedFiles = '';
        $iniSettings   = '';

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

        if ($preserveGlobalState) {
            $constants         = GlobalState::getConstantsAsString();
            $globalStateResult = GlobalState::exportGlobals();
            $globals           = $globalStateResult->globalsString();
            $includedFiles     = GlobalState::getIncludedFilesAsString();
            $iniSettings       = GlobalState::getIniSettingsAsString();

            foreach ($globalStateResult->skippedGlobals() as $skipped) {
                EventFacade::emitter()->testTriggeredPhpunitWarning(
                    $test->valueObjectForEvents(),
                    sprintf(
                        'Global variable %s was not preserved because it %s',
                        $skipped['name'],
                        $skipped['reason'],
                    ),
                );
            }
        }

        $coverage = CodeCoverage::instance()->isActive() ? 'true' : 'false';

        // the branches below that are excluded from code coverage are only
        // taken when PHPUnit is used from its PHAR distribution, whereas code
        // coverage is only collected when PHPUnit is used from a Composer
        // installation
        if (defined('PHPUNIT_COMPOSER_INSTALL')) {
            $composerAutoload = var_export(PHPUNIT_COMPOSER_INSTALL, true);
        } else {
            // @codeCoverageIgnoreStart
            $composerAutoload = '\'\'';
            // @codeCoverageIgnoreEnd
        }

        if (defined('__PHPUNIT_PHAR__')) {
            // @codeCoverageIgnoreStart
            $phar = var_export(__PHPUNIT_PHAR__, true);
            // @codeCoverageIgnoreEnd
        } else {
            $phar = '\'\'';
        }

        $data            = var_export(serialize($test->providedData()), true);
        $dataName        = var_export($test->dataName(), true);
        $dependencyInput = var_export(serialize($test->dependencyInput()), true);
        $includePath     = var_export(get_include_path(), true);
        // must do these fixes because TestCaseMethod.tpl has unserialize('{data}') in it, and we can't break BC
        // the lines above used to use addcslashes() rather than var_export(), which breaks null byte escape sequences
        // $dataName is not quoted in the template so that an integer data set name does not become a string
        $data                    = "'." . $data . ".'";
        $dependencyInput         = "'." . $dependencyInput . ".'";
        $includePath             = "'." . $includePath . ".'";
        $offset                  = hrtime();
        $serializedConfiguration = $this->saveConfigurationForChildProcess();
        $processResultFile       = $this->pathForCachedSourceMap();

        if ($processResultFile === false || $processResultFile === '') {
            // @codeCoverageIgnoreStart
            throw new ProcessIsolationException;
            // @codeCoverageIgnoreEnd
        }

        $processResultNonce = bin2hex(random_bytes(16));
        $sourceMapFile      = $this->sourceMapFileForChildProcess();

        $file = $class->getFileName();

        assert($file !== false);

        $var = [
            'bootstrap'                      => $bootstrap,
            'composerAutoload'               => $composerAutoload,
            'phar'                           => $phar,
            'filename'                       => $file,
            'className'                      => $class->getName(),
            'methodName'                     => $test->name(),
            'collectCodeCoverageInformation' => $coverage,
            'data'                           => $data,
            'dataName'                       => $dataName,
            'dependencyInput'                => $dependencyInput,
            'repetition'                     => (string) $test->repetition(),
            'totalRepetitions'               => (string) $test->totalRepetitions(),
            'attempt'                        => (string) $test->attempt(),
            'maxAttempts'                    => (string) $test->maxAttempts(),
            'constants'                      => $constants,
            'globals'                        => $globals,
            'include_path'                   => $includePath,
            'included_files'                 => $includedFiles,
            'iniSettings'                    => $iniSettings,
            'name'                           => $test->name(),
            'offsetSeconds'                  => (string) $offset[0],
            'offsetNanoseconds'              => (string) $offset[1],
            'serializedConfiguration'        => $serializedConfiguration,
            'processResultFile'              => $processResultFile,
            'processResultNonce'             => $processResultNonce,
            'sourceMapFile'                  => $sourceMapFile,
        ];

        $template = new Template(__DIR__ . '/templates/method.tpl');

        $template->setVar($var);

        $code = $template->render();

        assert($code !== '');

        JobRunnerRegistry::runTestJob(new Job($code, ChildProcessReason::TestRequiringProcessIsolation, requiresXdebug: $requiresXdebug), $processResultFile, $test, $processResultNonce);

        @unlink($serializedConfiguration);
    }

    private function sourceMapFileForChildProcess(): string
    {
        if (self::$sourceMapFile !== null) {
            return self::$sourceMapFile;
        }

        if (!ConfigurationRegistry::get()->source()->notEmpty()) {
            self::$sourceMapFile = '';

            return self::$sourceMapFile;
        }

        $path = $this->pathForCachedSourceMap();

        if ($path === false) {
            // @codeCoverageIgnoreStart
            self::$sourceMapFile = '';

            return self::$sourceMapFile;
            // @codeCoverageIgnoreEnd
        }

        if (!SourceMapper::saveTo($path, ConfigurationRegistry::get()->source())) {
            // @codeCoverageIgnoreStart
            self::$sourceMapFile = '';

            return self::$sourceMapFile;
            // @codeCoverageIgnoreEnd
        }

        self::$sourceMapFile = $path;

        return self::$sourceMapFile;
    }

    /**
     * @throws ProcessIsolationException
     */
    private function saveConfigurationForChildProcess(): string
    {
        $path = $this->pathForCachedSourceMap();

        if ($path === false) {
            // @codeCoverageIgnoreStart
            throw new ProcessIsolationException;
            // @codeCoverageIgnoreEnd
        }

        if (!ConfigurationRegistry::saveTo($path)) {
            // @codeCoverageIgnoreStart
            throw new ProcessIsolationException;
            // @codeCoverageIgnoreEnd
        }

        return $path;
    }

    private function pathForCachedSourceMap(): false|string
    {
        return tempnam(sys_get_temp_dir(), 'phpunit_');
    }
}
