<?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\Util\PHP;

use const PHP_BINARY;
use const PHP_SAPI;
use function array_keys;
use function array_merge;
use function array_values;
use function assert;
use function count;
use function explode;
use function file_get_contents;
use function file_put_contents;
use function function_exists;
use function ini_get_all;
use function is_array;
use function is_file;
use function is_resource;
use function is_string;
use function preg_match;
use function proc_open;
use function sprintf;
use function str_contains;
use function str_replace;
use function str_starts_with;
use function sys_get_temp_dir;
use function tempnam;
use function tmpfile;
use function trim;
use function unlink;
use function xdebug_is_debugger_active;
use PHPUnit\Event\Facade;
use PHPUnit\Event\Facade as EventFacade;
use PHPUnit\Framework\Test;
use PHPUnit\Framework\TestRunner\ChildProcessResultProcessor;
use PHPUnit\Runner\CodeCoverage;
use SebastianBergmann\Environment\Runtime;

/**
 * @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 readonly class JobRunner
{
    private ChildProcessResultProcessor $processor;

    public function __construct(ChildProcessResultProcessor $processor)
    {
        $this->processor = $processor;
    }

    /**
     * @param non-empty-string  $processResultFile
     * @param ?non-empty-string $processResultNonce
     */
    public function runTestJob(Job $job, string $processResultFile, Test $test, ?string $processResultNonce = null): void
    {
        $result = $this->run($job);

        $processResult = '';

        if (is_file($processResultFile)) {
            $processResult = file_get_contents($processResultFile);

            assert($processResult !== false);

            @unlink($processResultFile);
        }

        $this->processor->process(
            $test,
            $processResult,
            $result->stderr(),
            $processResultNonce,
        );

        EventFacade::emitter()->childProcessFinished($job->reason(), $result->stdout(), $result->stderr());
    }

    /**
     * @throws PhpProcessException
     */
    public function run(Job $job): Result
    {
        $temporaryFile = null;

        if ($job->hasInput()) {
            $temporaryFile = tempnam(sys_get_temp_dir(), 'phpunit_');

            if ($temporaryFile === false ||
                file_put_contents($temporaryFile, $job->code()) === false) {
                // @codeCoverageIgnoreStart
                throw new PhpProcessException(
                    'Unable to write temporary file',
                );
                // @codeCoverageIgnoreEnd
            }

            $job = new Job(
                $job->input(),
                $job->reason(),
                $job->phpSettings(),
                $job->environmentVariables(),
                $job->arguments(),
                null,
                $job->redirectErrors(),
                $job->requiresXdebug(),
            );
        }

        assert($temporaryFile !== '');

        $running = $this->startProcess($job, $temporaryFile);

        $running->write($job->code());
        $running->closeStdin();

        return $running->wait();
    }

    /**
     * Spawn the code of the given job as a long-running worker process whose
     * standard input remains open as a control channel.
     *
     * The job's code is written to a temporary file that is executed by the
     * worker process; in contrast to run(), the code is not piped through
     * standard input, leaving it available for the caller to send subsequent
     * commands to the worker.
     *
     * @throws PhpProcessException
     */
    public function start(Job $job): RunningJob
    {
        $temporaryFile = tempnam(sys_get_temp_dir(), 'phpunit_');

        if ($temporaryFile === false ||
            file_put_contents($temporaryFile, $job->code()) === false) {
            // @codeCoverageIgnoreStart
            throw new PhpProcessException(
                'Unable to write temporary file',
            );
            // @codeCoverageIgnoreEnd
        }

        return $this->startProcess($job, $temporaryFile);
    }

    /**
     * @param ?non-empty-string $temporaryFile
     *
     * @throws PhpProcessException
     */
    private function startProcess(Job $job, ?string $temporaryFile): RunningJob
    {
        $environmentVariables = null;

        if ($job->hasEnvironmentVariables()) {
            /** @phpstan-ignore nullCoalesce.variable */
            $serverVariables = $_SERVER ?? [];

            unset($serverVariables['argv'], $serverVariables['argc']);

            $environmentVariables = [];

            foreach ($serverVariables as $key => $value) {
                if (!is_string($key)) {
                    continue;
                }

                if (is_array($value)) {
                    continue;
                }

                $environmentVariables[$key] = $value;
            }

            $environmentVariables = array_merge($environmentVariables, $job->environmentVariables());
        }

        $mergedOutputStream = null;

        if ($job->redirectErrors()) {
            $mergedOutputStream = tmpfile();

            if ($mergedOutputStream === false) {
                // @codeCoverageIgnoreStart
                throw new PhpProcessException('Unable to create temporary file for redirected output');
                // @codeCoverageIgnoreEnd
            }

            $pipeSpec = [
                0 => ['pipe', 'r'],
                1 => $mergedOutputStream,
                2 => $mergedOutputStream,
            ];
        } else {
            $pipeSpec = [
                0 => ['pipe', 'r'],
                1 => ['pipe', 'w'],
                2 => ['pipe', 'w'],
            ];
        }

        $process = proc_open(
            $this->buildCommand($job, $temporaryFile),
            $pipeSpec,
            $pipes,
            null,
            $environmentVariables,
        );

        if (!is_resource($process)) {
            // @codeCoverageIgnoreStart
            throw new PhpProcessException(
                'Unable to spawn worker process',
            );
            // @codeCoverageIgnoreEnd
        }

        Facade::emitter()->childProcessStarted($job->reason());

        return new RunningJob($process, $pipes, $mergedOutputStream, $temporaryFile);
    }

    /**
     * @return non-empty-list<string>
     */
    private function buildCommand(Job $job, ?string $file): array
    {
        $runtime     = new Runtime;
        $command     = [PHP_BINARY];
        $phpSettings = $job->phpSettings();

        $xdebugModeConfiguredExplicitly = false;

        foreach ($phpSettings as $phpSetting) {
            if (str_starts_with($phpSetting, 'xdebug.mode')) {
                $xdebugModeConfiguredExplicitly = true;

                break;
            }
        }

        if ($runtime->hasPCOV()) {
            $pcovSettings = ini_get_all('pcov');

            assert($pcovSettings !== false);

            // The settings forwarded from the current process must be applied
            // before the settings configured for the job so that the latter,
            // for instance a "pcov.directory" set in the --INI-- section of a
            // PHPT test, take precedence: the value of the last -d flag wins.
            $phpSettings = array_merge(
                $runtime->getCurrentSettings(
                    array_keys($pcovSettings),
                ),
                $phpSettings,
            );
        } elseif ($runtime->hasXdebug()) {
            assert(function_exists('xdebug_is_debugger_active'));

            $xdebugSettings = ini_get_all('xdebug');

            assert($xdebugSettings !== false);

            // The settings forwarded from the current process must be applied
            // before the settings configured for the job so that the latter
            // take precedence: the value of the last -d flag wins.
            $phpSettings = array_merge(
                $runtime->getCurrentSettings(
                    array_keys($xdebugSettings),
                ),
                $phpSettings,
            );

            if (
                !$xdebugModeConfiguredExplicitly &&
                !CodeCoverage::instance()->isActive() &&
                xdebug_is_debugger_active() === false &&
                !$job->requiresXdebug()
            ) {
                // disable xdebug to speedup test execution
                $phpSettings['xdebug.mode'] = 'xdebug.mode=off';
            }
        }

        $phpSettings = array_merge($phpSettings, $this->cliIniOverrides($phpSettings));

        $command = array_merge($command, $this->settingsToParameters(array_values($phpSettings)));

        if (PHP_SAPI === 'phpdbg') {
            // @codeCoverageIgnoreStart
            $command[] = '-qrr';

            if ($file === null) {
                $command[] = 's=';
            }
            // @codeCoverageIgnoreEnd
        }

        if ($file !== null) {
            $command[] = '-f';
            $command[] = $file;
        }

        if ($job->hasArguments()) {
            if ($file === null) {
                $command[] = '--';
            }

            foreach ($job->arguments() as $argument) {
                $command[] = trim($argument);
            }
        }

        return $command;
    }

    /**
     * Detects INI settings that cannot be set via ini_set() (PHP_INI_SYSTEM
     * and PHP_INI_PERDIR) and whose current value differs from the value
     * configured in INI files.
     *
     * These settings must be forwarded as -d flags to child processes
     * because the @ini_set() calls in GlobalState::getIniSettingsAsString()
     * cannot change them at runtime.
     *
     * @param array<array-key, string> $alreadySet
     *
     * @return array<string, string>
     */
    private function cliIniOverrides(array $alreadySet): array
    {
        $overrides = (new Runtime)->getSettingsNotChangeableAtRuntime();

        foreach ($overrides as $key => $value) {
            foreach ($alreadySet as $existing) {
                if (str_starts_with($existing, $key . '=')) {
                    unset($overrides[$key]);

                    break;
                }
            }
        }

        return $overrides;
    }

    /**
     * @param list<string> $settings
     *
     * @throws PhpProcessException
     *
     * @return list<string>
     */
    private function settingsToParameters(array $settings): array
    {
        $buffer = [];

        foreach ($settings as $setting) {
            $buffer[] = '-d';
            $buffer[] = $this->processSettingValue($setting);
        }

        return $buffer;
    }

    /**
     * Rejects "name=value" INI settings whose value contains a line-break
     * character. A newline cannot legitimately appear in a PHP INI value and
     * would, if forwarded unchanged, be parsed by the child process as a
     * directive separator — turning a single setting into an attacker-
     * controlled sequence of directives.
     *
     * Otherwise quotes the value portion only when it contains characters
     * PHP's INI parser would interpret as metacharacters: `;` starts a
     * comment, `"` is a string delimiter, `=` is the assignment operator
     * that would otherwise be parsed as starting a new directive, `$` starts
     * `${...}` variable interpolation, and `{}|&~![()^]` are operators in
     * unquoted values (a Windows short path such as `C:\Users\RUNNER~1`
     * would otherwise trigger `PHP: syntax error, unexpected '~'`).
     *
     * Quoting is avoided for plain values so that boolean keywords such as
     * `On` / `Off` keep their special INI semantics; wrapping them in quotes
     * turns them into the literal strings `"On"` / `"Off"` and breaks
     * settings like `output_buffering`.
     *
     * @throws PhpProcessException
     */
    private function processSettingValue(string $setting): string
    {
        $parts = explode('=', $setting, 2);

        if (count($parts) !== 2) {
            return $setting;
        }

        [$name, $value] = $parts;

        if (str_contains($value, "\n") || str_contains($value, "\r")) {
            throw new PhpProcessException(
                sprintf(
                    'PHP setting "%s" contains a line-break character, which is not permitted',
                    $name,
                ),
            );
        }

        if (preg_match('/[;"=${}|&~!\[()^\]]/', $value) !== 1) {
            return $setting;
        }

        // Inside a double-quoted INI value, \\ collapses to \ and \" to ",
        // so both characters must be escaped to survive the round-trip
        return $name . '="' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
    }
}
