<?php

declare (strict_types=1);
/**
 * This file is part of the Nette Framework (https://nette.org)
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
 */
namespace RectorPrefix202605\Nette\Utils;

use RectorPrefix202605\Nette;
/**
 * Represents a process, which can be started and controlled (reading output, writing input, waiting for completion).
 */
final class Process
{
    private ?float $timeout;
    private const PollInterval = 10000;
    private const DefaultTimeout = 60;
    private const StdIn = 0;
    private const StdOut = 1;
    private const StdErr = 2;
    /** @var resource */
    private $process;
    /** @var array<string, mixed>  result of proc_get_status() */
    private array $status = ['running' => \true];
    /** @var resource */
    private $inputPipe;
    /** @var array<int, resource|string[]>  pipe resources, or ['file', path, mode] descriptors for discarded output */
    private array $outputPipes = [];
    /** @var string[] */
    private array $outputBuffers = [];
    /** @var int[] Number of bytes already read from buffers. */
    private array $outputBufferOffsets = [];
    /** @var array<int, true> Output IDs whose target resource was supplied by the caller and must not be closed here. */
    private array $callerOutputs = [];
    private float $startTime;
    /**
     * Starts an executable with given arguments. Because the arguments are passed as an array, the shell is
     * never involved, so they need no escaping and there is no risk of shell injection.
     * @param  string         $executable Path to the executable binary.
     * @param  list<string>   $arguments  Arguments passed to the executable.
     * @param  string[]|null  $env        Environment variables or null to use the same environment as the current process.
     * @param  array<string, mixed>  $options  Additional options for proc_open(). On Windows the executable is launched directly, without cmd.exe.
     * @param  mixed          $stdin      Input: string, a readable resource (its content is copied to STDIN), a Process (its STDOUT is piped in), or null (STDIN stays open for writeStdInput()).
     * @param  mixed          $stdout     Output target: string filename, a writable resource backed by a real OS file descriptor (not php://memory etc.), false to discard, or null to capture into memory.
     * @param  mixed          $stderr     Error output target (same options as $stdout).
     * @param  string|null    $directory  Working directory.
     * @param  float|null     $timeout    Time limit in seconds, checked while waiting for or reading the process; null disables it.
     */
    public static function runExecutable(string $executable, array $arguments = [], ?array $env = null, array $options = [], $stdin = '', $stdout = null, $stderr = null, ?string $directory = null, ?float $timeout = self::DefaultTimeout): self
    {
        return new self(array_merge([$executable], $arguments), $env, $options, $directory, $stdin, $stdout, $stderr, $timeout);
    }
    /**
     * Starts a process from a command string interpreted by the shell (/bin/sh on POSIX, cmd.exe on Windows).
     * Because the shell parses the string, NEVER pass unescaped user input here - use runExecutable() for that.
     * @param  string         $command    Shell command to run.
     * @param  string[]|null  $env        Environment variables or null to use the same environment as the current process.
     * @param  array<string, mixed>  $options  Options for proc_open(), e.g. ['bypass_shell' => true] on Windows to skip cmd.exe.
     * @param  mixed          $stdin      Input: string, a readable resource (its content is copied to STDIN), a Process (its STDOUT is piped in), or null (STDIN stays open for writeStdInput()).
     * @param  mixed          $stdout     Output target: string filename, a writable resource backed by a real OS file descriptor (not php://memory etc.), false to discard, or null to capture into memory.
     * @param  mixed          $stderr     Error output target (same options as $stdout).
     * @param  string|null    $directory  Working directory.
     * @param  float|null     $timeout    Time limit in seconds, checked while waiting for or reading the process; null disables it.
     */
    public static function runCommand(string $command, ?array $env = null, array $options = [], $stdin = '', $stdout = null, $stderr = null, ?string $directory = null, ?float $timeout = self::DefaultTimeout): self
    {
        return new self($command, $env, $options, $directory, $stdin, $stdout, $stderr, $timeout);
    }
    /**
     * @param  list<string>|string  $command
     * @param  array<string, string>|null  $env
     * @param  array<string, mixed>  $options
     * @param mixed $stdin
     * @param mixed $stdout
     * @param mixed $stderr
     */
    private function __construct($command, ?array $env, array $options, ?string $directory, $stdin, $stdout, $stderr, ?float $timeout)
    {
        $this->timeout = $timeout;
        $descriptors = [self::StdIn => $this->createInputDescriptor($stdin), self::StdOut => $this->createOutputDescriptor(self::StdOut, $stdout), self::StdErr => $this->createOutputDescriptor(self::StdErr, $stderr)];
        $process = @proc_open($command, $descriptors, $pipes, $directory, $env, $options);
        if (!is_resource($process)) {
            throw new ProcessFailedException('Failed to start process: ' . Helpers::getLastError());
        }
        $this->process = $process;
        [$this->inputPipe, $this->outputPipes[self::StdOut], $this->outputPipes[self::StdErr]] = $pipes + $descriptors;
        if ($stdin instanceof self) {
            // the source process hands over its STDOUT pipe; from now on this process owns it
            unset($stdin->outputBuffers[self::StdOut], $stdin->outputBufferOffsets[self::StdOut], $stdin->outputPipes[self::StdOut]);
        }
        $this->writeInitialInput($stdin);
        $this->startTime = microtime(\true);
    }
    public function __destruct()
    {
        $this->outputBuffers = [];
        $this->terminate();
    }
    /**
     * Checks if the process is currently running.
     */
    public function isRunning(): bool
    {
        if (!$this->status['running']) {
            return \false;
        }
        $this->status = proc_get_status($this->process);
        if (!$this->status['running']) {
            $this->close();
        }
        return $this->status['running'];
    }
    /**
     * Finishes the process by waiting for its completion. While waiting, the captured output is read
     * continuously and kept in memory; an optional callback is invoked with each new output/error chunk.
     *
     * @param  (\Closure(string, string): void)|null  $callback
     */
    public function wait(?\Closure $callback = null): void
    {
        while ($this->isRunning()) {
            $this->enforceTimeout();
            $this->drainPipes();
            $this->dispatchCallback($callback);
            usleep(self::PollInterval);
        }
        $this->dispatchCallback($callback);
    }
    /**
     * Reads any new data from the captured pipes into the buffers, so a process producing more output
     * than the OS pipe buffer holds does not block. (On Windows the captured output is a file and never blocks.)
     */
    private function drainPipes(): void
    {
        foreach ([self::StdOut, self::StdErr] as $id) {
            $this->readFromPipe($id);
        }
    }
    /**
     * Terminates the running process if it is still running.
     */
    public function terminate(): void
    {
        if (!$this->isRunning()) {
            return;
        } elseif (Helpers::IsWindows) {
            exec("taskkill /F /T /PID {$this->getPid()} 2>&1");
        } else {
            proc_terminate($this->process, 9);
            // 9 = SIGKILL: cannot be trapped, so the following proc_close() won't hang
        }
        $this->status['running'] = \false;
        $this->close();
    }
    /**
     * Returns the process exit code. If the process is still running, waits until it finishes.
     */
    public function getExitCode(): int
    {
        $this->wait();
        return $this->status['exitcode'] ?? -1;
    }
    /**
     * Returns true if the process terminated with exit code 0.
     */
    public function isSuccess(): bool
    {
        return $this->getExitCode() === 0;
    }
    /**
     * Waits for the process to finish and throws ProcessFailedException if exit code is not zero.
     */
    public function ensureSuccess(): void
    {
        $code = $this->getExitCode();
        if ($code !== 0) {
            throw new ProcessFailedException("Process failed with non-zero exit code: {$code}");
        }
    }
    /**
     * Returns the PID of the running process, or null if it is not running.
     */
    public function getPid(): ?int
    {
        return $this->isRunning() ? $this->status['pid'] : null;
    }
    /**
     * Waits for the process to finish and returns everything it wrote to STDOUT.
     */
    public function getStdOutput(): string
    {
        $this->wait();
        if (!isset($this->outputBuffers[self::StdOut])) {
            throw new Nette\InvalidStateException('Cannot read output: it is not captured (it was redirected, discarded or piped).');
        }
        return $this->outputBuffers[self::StdOut];
    }
    /**
     * Waits for the process to finish and returns everything it wrote to STDERR.
     */
    public function getStdError(): string
    {
        $this->wait();
        if (!isset($this->outputBuffers[self::StdErr])) {
            throw new Nette\InvalidStateException('Cannot read output: it is not captured (it was redirected, discarded or piped).');
        }
        return $this->outputBuffers[self::StdErr];
    }
    /**
     * Returns the STDOUT data produced since the previous consumeStdOutput() call.
     * To read everything incrementally, poll `while ($p->isRunning())` calling this, then call it once more
     * after the loop; that last call returns whatever the process wrote just before the loop noticed it had exited.
     */
    public function consumeStdOutput(): string
    {
        return $this->consumeBuffer(self::StdOut);
    }
    /**
     * Returns the STDERR data produced since the previous consumeStdError() call. See consumeStdOutput().
     */
    public function consumeStdError(): string
    {
        return $this->consumeBuffer(self::StdErr);
    }
    /**
     * Returns newly available data from the specified buffer and advances the read pointer.
     */
    private function consumeBuffer(int $id): string
    {
        if (!isset($this->outputBuffers[$id])) {
            throw new Nette\InvalidStateException('Cannot read output: it is not captured (it was redirected, discarded or piped).');
        } elseif ($this->isRunning()) {
            $this->enforceTimeout();
            $this->readFromPipe($id);
        }
        return $this->extractNewData($id);
    }
    /**
     * Returns the buffered data not returned yet and advances the read pointer.
     */
    private function extractNewData(int $id): string
    {
        if (!isset($this->outputBuffers[$id])) {
            return '';
        }
        $res = (string) substr($this->outputBuffers[$id], $this->outputBufferOffsets[$id]);
        $this->outputBufferOffsets[$id] = strlen($this->outputBuffers[$id]);
        return $res;
    }
    /**
     * Writes data into the process' STDIN. If STDIN is closed, throws exception.
     */
    public function writeStdInput(string $string): void
    {
        if (!is_resource($this->inputPipe)) {
            throw new Nette\InvalidStateException('Cannot write to process: STDIN pipe is closed');
        }
        $this->writeToPipe($string);
    }
    /**
     * Writes the whole string to STDIN, handling partial writes. Stops (and lets fwrite() warn) on a broken pipe,
     * i.e. when the process stopped reading its STDIN.
     */
    private function writeToPipe(string $string): void
    {
        $length = strlen($string);
        for ($written = 0; $written < $length; $written += $bytes) {
            $bytes = fwrite($this->inputPipe, (string) substr($string, $written));
            if (!$bytes) {
                break;
            }
        }
    }
    /**
     * Closes the STDIN pipe, indicating no more data will be sent.
     */
    public function closeStdInput(): void
    {
        if (is_resource($this->inputPipe)) {
            fclose($this->inputPipe);
        }
    }
    /**
     * If a callback is given, invokes it with the output/error produced since the previous call.
     * @param  (\Closure(string, string): void)|null  $callback
     */
    private function dispatchCallback(?\Closure $callback): void
    {
        if (!$callback) {
            return;
        }
        $output = $this->extractNewData(self::StdOut);
        $error = $this->extractNewData(self::StdErr);
        if ($output !== '' || $error !== '') {
            $callback($output, $error);
        }
    }
    /**
     * Checks if the timeout has expired. If yes, terminates the process.
     */
    private function enforceTimeout(): void
    {
        if ($this->timeout !== null && microtime(\true) - $this->startTime >= $this->timeout) {
            $this->terminate();
            throw new ProcessTimeoutException('Process exceeded the time limit of ' . $this->timeout . ' seconds');
        }
    }
    /**
     * Reads any new data from the specified pipe and appends it to the buffer. Does nothing if the output
     * is not captured or the pipe is already closed (or handed over to another process).
     */
    private function readFromPipe(int $id): void
    {
        if (!isset($this->outputBuffers[$id]) || !is_resource($this->outputPipes[$id] ?? null)) {
            return;
        } elseif (Helpers::IsWindows) {
            fseek($this->outputPipes[$id], strlen($this->outputBuffers[$id]));
        } else {
            stream_set_blocking($this->outputPipes[$id], \false);
        }
        $this->outputBuffers[$id] .= stream_get_contents($this->outputPipes[$id]);
    }
    /**
     * Sends the initial input to the process: writes and closes a string or stream input,
     * or leaves STDIN open when input is null (until closeStdInput()) or another Process (fed by that process).
     * The input type was already validated by createInputDescriptor().
     *
     * Note: a string or stream input is written upfront, so if it is large and the process does not read it
     * while filling its own output, this can block; in that case pass null and feed STDIN via writeStdInput().
     * @param mixed $input
     */
    private function writeInitialInput($input): void
    {
        if ($input === null || $input instanceof self) {
            // STDIN stays open
        } elseif (is_string($input)) {
            $this->writeToPipe($input);
            $this->closeStdInput();
        } elseif (is_resource($input)) {
            stream_copy_to_stream($input, $this->inputPipe);
            $this->closeStdInput();
        }
    }
    /**
     * Validates the input and determines the STDIN descriptor based on its type.
     * @param mixed $input
     * @return mixed
     */
    private function createInputDescriptor($input)
    {
        if ($input === null || is_string($input) || is_resource($input)) {
            return ['pipe', 'r'];
        } elseif (!$input instanceof self) {
            throw new Nette\InvalidArgumentException('Input must be string, resource, Process or null, ' . get_debug_type($input) . ' given.');
        } elseif (Helpers::IsWindows) {
            throw new Nette\NotSupportedException('Process piping is not supported on Windows.');
        } elseif (!isset($input->outputBuffers[self::StdOut]) || !is_resource($input->outputPipes[self::StdOut] ?? null)) {
            throw new Nette\InvalidStateException('Cannot pipe from the given process: its STDOUT must be captured (it must not be redirected elsewhere).');
        }
        return $input->outputPipes[self::StdOut];
    }
    /**
     * Determines the descriptor for STDOUT or STDERR based on the specified output target.
     * @param mixed $output
     * @return mixed
     */
    private function createOutputDescriptor(int $id, $output)
    {
        if (is_resource($output)) {
            $this->callerOutputs[$id] = \true;
            return $output;
        } elseif (is_string($output)) {
            return FileSystem::open($output, 'w');
        } elseif ($output === \false) {
            return ['file', Helpers::IsWindows ? 'NUL' : '/dev/null', 'w'];
        } elseif ($output === null) {
            $this->outputBuffers[$id] = '';
            $this->outputBufferOffsets[$id] = 0;
            // On Windows anonymous pipes are blocking and cannot be polled without freezing the process,
            // so captured output is backed by a temporary file that can be read non-blockingly (needed for timeouts).
            return Helpers::IsWindows ? tmpfile() : ['pipe', 'w'];
        } else {
            throw new Nette\InvalidArgumentException('Output must be string, resource, bool or null, ' . get_debug_type($output) . ' given.');
        }
    }
    /**
     * Closes all pipes and the process resource.
     */
    private function close(): void
    {
        $this->drainPipes();
        $this->closeStdInput();
        $this->closeOutputPipes();
        proc_close($this->process);
    }
    /**
     * Closes the output pipes that this class opened; resources supplied by the caller are left untouched.
     * (The temporary file backing captured output on Windows is removed by fclose() itself.)
     */
    private function closeOutputPipes(): void
    {
        foreach ($this->outputPipes as $id => $pipe) {
            if (is_resource($pipe) && !isset($this->callerOutputs[$id])) {
                fclose($pipe);
            }
        }
    }
}
