<?php

namespace GuzzleHttp;

use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Cookie\CookieJarInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Handler\CurlShareHandleState;
use GuzzleHttp\Promise as P;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;

/**
 * @final
 */
class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
{
    use ClientTrait;

    /**
     * @var array Default request options
     */
    private $config;

    /**
     * Clients accept an array of constructor parameters.
     *
     * Here's an example of creating a client using a base_uri and an array of
     * default request options to apply to each request:
     *
     *     $client = new Client([
     *         'base_uri'        => 'http://www.foo.com/1.0/',
     *         'timeout'         => 0,
     *         'allow_redirects' => false,
     *         'proxy'           => '192.168.16.1:10'
     *     ]);
     *
     * Client configuration settings include the following options:
     *
     * - handler: (callable) Function that transfers HTTP requests over the
     *   wire. The function is called with a Psr7\Http\Message\RequestInterface
     *   and array of transfer options, and must return a
     *   GuzzleHttp\Promise\PromiseInterface that is fulfilled with a
     *   Psr7\Http\Message\ResponseInterface on success.
     *   If no handler is provided, a default handler will be created
     *   that enables all of the request options below by attaching all of the
     *   default middleware to the handler.
     * - base_uri: (string|UriInterface) Base URI of the client that is merged
     *   into relative URIs. Can be a string or instance of UriInterface.
     * - transport_sharing: (string|null) Transport sharing mode for the
     *   default handler. Accepts TransportSharing::* or null. Defaults to null.
     * - **: any request option
     *
     * @param array $config Client configuration settings.
     *
     * @see RequestOptions for a list of available request options.
     */
    public function __construct(array $config = [])
    {
        $transportSharing = \array_key_exists('transport_sharing', $config) ? $config['transport_sharing'] : null;
        $transportSharingMode = CurlShareHandleState::normalizeMode($transportSharing, 'transport_sharing');
        unset($config['transport_sharing']);

        if (!isset($config['handler'])) {
            $config['handler'] = $transportSharingMode === TransportSharing::NONE
                ? HandlerStack::create()
                : HandlerStack::create(Utils::chooseHandler(['transport_sharing' => $transportSharingMode]));
        } elseif (!\is_callable($config['handler'])) {
            throw new InvalidArgumentException('handler must be a callable');
        } elseif ($transportSharingMode === TransportSharing::HANDLER_REQUIRE) {
            throw new InvalidArgumentException('The "transport_sharing" client option can only require sharing when Guzzle creates the default handler. Configure the "transport_sharing" option on CurlHandler or CurlMultiHandler when providing a custom cURL handler.');
        }

        // Convert the base_uri to a UriInterface
        if (isset($config['base_uri'])) {
            $config['base_uri'] = Psr7\Utils::uriFor($config['base_uri']);
        }

        $this->configureDefaults($config);
    }

    /**
     * @param string $method
     * @param array  $args
     *
     * @return PromiseInterface|ResponseInterface
     *
     * @deprecated Client::__call will be removed in guzzlehttp/guzzle:8.0.
     */
    public function __call($method, $args)
    {
        if (\count($args) < 1) {
            throw new InvalidArgumentException('Magic request methods require a URI and optional options array');
        }

        $uri = $args[0];
        $opts = $args[1] ?? [];

        $isAsync = \substr($method, -5) === 'Async';
        $method = $isAsync ? \substr($method, 0, -5) : $method;
        $method = \strtoupper($method);

        return $isAsync
            ? $this->requestAsync($method, $uri, $opts)
            : $this->request($method, $uri, $opts);
    }

    /**
     * Asynchronously send an HTTP request.
     *
     * @param array $options Request options to apply to the given
     *                       request and to the transfer. See {@see RequestOptions}.
     */
    public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface
    {
        // Merge the base URI into the request URI if needed.
        $options = $this->prepareDefaults($options);

        return $this->transfer(
            $request->withUri($this->buildUri($request->getUri(), $options), $request->hasHeader('Host')),
            $options
        );
    }

    /**
     * Send an HTTP request.
     *
     * @param array $options Request options to apply to the given
     *                       request and to the transfer. See {@see RequestOptions}.
     *
     * @throws GuzzleException
     */
    public function send(RequestInterface $request, array $options = []): ResponseInterface
    {
        $options[RequestOptions::SYNCHRONOUS] = true;

        return $this->sendAsync($request, $options)->wait();
    }

    /**
     * The HttpClient PSR (PSR-18) specify this method.
     *
     * {@inheritDoc}
     */
    public function sendRequest(RequestInterface $request): ResponseInterface
    {
        $options[RequestOptions::SYNCHRONOUS] = true;
        $options[RequestOptions::ALLOW_REDIRECTS] = false;
        $options[RequestOptions::HTTP_ERRORS] = false;

        return $this->sendAsync($request, $options)->wait();
    }

    /**
     * Create and send an asynchronous HTTP request.
     *
     * Use an absolute path to override the base path of the client, or a
     * relative path to append to the base path of the client. The URL can
     * contain the query string as well. Use an array to provide a URL
     * template and additional variables to use in the URL template expansion.
     *
     * @param string              $method  HTTP method
     * @param string|UriInterface $uri     URI object or string.
     * @param array               $options Request options to apply. See {@see RequestOptions}.
     */
    public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface
    {
        $normalizedMethod = \strtoupper($method);
        if ($method !== $normalizedMethod) {
            \trigger_deprecation(
                'guzzlehttp/guzzle',
                '7.11',
                'Passing a non-uppercase HTTP method to Client::requestAsync() is deprecated; guzzlehttp/guzzle 8.0 will preserve HTTP method casing. Pass an uppercase method explicitly if uppercase is required.'
            );
            $method = $normalizedMethod;
        }

        $options = $this->prepareDefaults($options);
        // Remove request modifying parameter because it can be done up-front.
        $headers = $options['headers'] ?? [];
        $droppedHeaderNames = self::castDeprecatedHeaderOptionValues($headers);
        if ($droppedHeaderNames !== [] && isset($options['_conditional'])) {
            $options['_conditional'] = Psr7\Utils::caselessRemove($droppedHeaderNames, $options['_conditional']);
        }
        $body = $options['body'] ?? null;
        $version = self::normalizeProtocolVersion($options['version'] ?? '1.1');
        // Merge the URI into the base URI.
        $uri = $this->buildUri(Psr7\Utils::uriFor($uri), $options);
        if (\is_array($body)) {
            throw $this->invalidBody();
        }
        $body = self::createBodyStream($body);
        $request = new Psr7\Request($method, $uri, $headers, $body, $version);
        // Remove the option so that they are not doubly-applied.
        unset($options['headers'], $options['body'], $options['version']);

        return $this->transfer($request, $options);
    }

    /**
     * Create and send an HTTP request.
     *
     * Use an absolute path to override the base path of the client, or a
     * relative path to append to the base path of the client. The URL can
     * contain the query string as well.
     *
     * @param string              $method  HTTP method.
     * @param string|UriInterface $uri     URI object or string.
     * @param array               $options Request options to apply. See {@see RequestOptions}.
     *
     * @throws GuzzleException
     */
    public function request(string $method, $uri = '', array $options = []): ResponseInterface
    {
        $normalizedMethod = \strtoupper($method);
        if ($method !== $normalizedMethod) {
            \trigger_deprecation(
                'guzzlehttp/guzzle',
                '7.11',
                'Passing a non-uppercase HTTP method to Client::request() is deprecated; guzzlehttp/guzzle 8.0 will preserve HTTP method casing. Pass an uppercase method explicitly if uppercase is required.'
            );
            $method = $normalizedMethod;
        }

        $options[RequestOptions::SYNCHRONOUS] = true;

        return $this->requestAsync($method, $uri, $options)->wait();
    }

    /**
     * Get a client configuration option.
     *
     * These options include default request options of the client, a "handler"
     * (if utilized by the concrete client), and a "base_uri" if utilized by
     * the concrete client.
     *
     * @param string|null $option The config option to retrieve.
     *
     * @return mixed
     */
    public function getConfig(?string $option = null)
    {
        return $option === null
            ? $this->config
            : ($this->config[$option] ?? null);
    }

    private function buildUri(UriInterface $uri, array $config): UriInterface
    {
        if (isset($config['base_uri'])) {
            $uri = Psr7\UriResolver::resolve(Psr7\Utils::uriFor($config['base_uri']), $uri);
        }

        $idnOptions = Utils::normalizeIdnConversionOption($config['idn_conversion'] ?? null);
        if ($idnOptions !== null) {
            $uri = Utils::idnUriConvert($uri, $idnOptions);
        }

        if ($uri->getScheme() === '' && $uri->getHost() !== '') {
            $uri = $uri->withScheme('http');
        }

        return $uri;
    }

    /**
     * Configures the default options for a client.
     */
    private function configureDefaults(array $config): void
    {
        $defaults = [
            'allow_redirects' => RedirectMiddleware::$defaultSettings,
            'http_errors' => true,
            'decode_content' => true,
            'verify' => true,
            'cookies' => false,
            'idn_conversion' => false,
            'protocols' => ['http', 'https'],
        ];

        // Use the standard Linux HTTP_PROXY and HTTPS_PROXY if set.

        // We can only trust the HTTP_PROXY environment variable in a CLI
        // process due to the fact that PHP has no reliable mechanism to
        // get environment variables that start with "HTTP_".
        if (\PHP_SAPI === 'cli' && ($proxy = Utils::getenv('HTTP_PROXY'))) {
            $defaults['proxy']['http'] = $proxy;
        }

        if ($proxy = Utils::getenv('HTTPS_PROXY')) {
            $defaults['proxy']['https'] = $proxy;
        }

        if ($noProxy = Utils::getenv('NO_PROXY')) {
            $cleanedNoProxy = \str_replace(' ', '', $noProxy);
            $defaults['proxy']['no'] = \explode(',', $cleanedNoProxy);
        }

        $this->config = $config + $defaults;

        if (!empty($config['cookies']) && $config['cookies'] === true) {
            $this->config['cookies'] = new CookieJar();
        }

        // Add the default user-agent header.
        if (!isset($this->config['headers'])) {
            $this->config['headers'] = ['User-Agent' => Utils::defaultUserAgent()];
        } else {
            // Add the User-Agent header if one was not already set.
            $hasUserAgent = false;
            foreach (\array_keys($this->config['headers']) as $name) {
                if (\strtolower((string) $name) === 'user-agent') {
                    $hasUserAgent = true;
                    break;
                }
            }

            if (!$hasUserAgent) {
                $this->config['headers']['User-Agent'] = Utils::defaultUserAgent();
            }
        }

        if (\is_array($this->config['headers'])) {
            self::warnAboutInvalidHeaderOptionTypes($this->config['headers']);
            self::castDeprecatedHeaderOptionValues($this->config['headers']);
        }
    }

    /**
     * Merges default options into the array.
     *
     * @param array $options Options to modify by reference
     */
    private function prepareDefaults(array $options): array
    {
        self::warnAboutRequestLevelHandler($options);

        $defaults = $this->config;

        if (!empty($defaults['headers'])) {
            // Default headers are only added if they are not present.
            $defaults['_conditional'] = $defaults['headers'];
            unset($defaults['headers']);
        }

        // Special handling for headers is required as they are added as
        // conditional headers and as headers passed to a request ctor.
        if (\array_key_exists('headers', $options)) {
            // Allows default headers to be unset.
            if ($options['headers'] === null) {
                $defaults['_conditional'] = [];
                unset($options['headers']);
            } elseif (!\is_array($options['headers'])) {
                throw new InvalidArgumentException('headers must be an array');
            }
        }

        // Shallow merge defaults underneath options.
        $result = $options + $defaults;

        // Remove null values.
        foreach ($result as $k => $v) {
            if ($v === null) {
                unset($result[$k]);
            }
        }

        self::warnAboutInvalidRequestOptionTypes($result);

        return self::normalizeDeprecatedRequestOptionValues($result);
    }

    /**
     * Normalize values that guzzlehttp/guzzle 8.0 rejects only after the
     * corresponding 7.x deprecation has already been emitted.
     *
     * @param array<string, mixed> $options
     *
     * @return array<string, mixed>
     */
    private static function normalizeDeprecatedRequestOptionValues(array $options): array
    {
        self::normalizeDeprecatedAuthOptionValues($options);
        self::normalizeDeprecatedTlsFileOptionValues($options, 'cert');
        self::normalizeDeprecatedTlsFileOptionValues($options, 'ssl_key');
        self::normalizeDeprecatedStringOptionValues($options);
        self::normalizeDeprecatedNumericOptionValues($options);
        self::normalizeDeprecatedIntegerOptionValues($options);

        return $options;
    }

    /**
     * @param mixed $value
     */
    private static function canStringifyDeprecatedValue($value): bool
    {
        return $value === null
            || \is_scalar($value)
            || (\is_object($value) && \method_exists($value, '__toString'));
    }

    /**
     * @param mixed $value
     */
    private static function stringifyDeprecatedValue($value): string
    {
        if (\is_float($value) && !\is_finite($value)) {
            return \is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF');
        }

        if ($value === null) {
            return '';
        }

        if (\is_scalar($value)) {
            return (string) $value;
        }

        if (\is_object($value) && \method_exists($value, '__toString')) {
            return $value->__toString();
        }

        throw new \LogicException('Value is not stringable.');
    }

    /**
     * @param array<string, mixed> $options
     */
    private static function normalizeDeprecatedAuthOptionValues(array &$options): void
    {
        if (!isset($options['auth']) || !\is_array($options['auth']) || $options['auth'] === []) {
            return;
        }

        foreach ([0, 1] as $index) {
            if (
                \array_key_exists($index, $options['auth'])
                && !\is_string($options['auth'][$index])
                && self::canStringifyDeprecatedValue($options['auth'][$index])
            ) {
                $options['auth'][$index] = self::stringifyDeprecatedValue($options['auth'][$index]);
            }
        }

        if (
            \array_key_exists(2, $options['auth'])
            && $options['auth'][2] !== null
            && !\is_string($options['auth'][2])
            && self::canStringifyDeprecatedValue($options['auth'][2])
        ) {
            $options['auth'][2] = self::stringifyDeprecatedValue($options['auth'][2]);
        }
    }

    /**
     * @param array<string, mixed> $options
     */
    private static function normalizeDeprecatedTlsFileOptionValues(array &$options, string $option): void
    {
        if (!isset($options[$option]) || !\is_array($options[$option])) {
            return;
        }

        foreach ([0, 1] as $index) {
            if (
                \array_key_exists($index, $options[$option])
                && $options[$option][$index] !== null
                && !\is_string($options[$option][$index])
                && self::canStringifyDeprecatedValue($options[$option][$index])
            ) {
                $options[$option][$index] = self::stringifyDeprecatedValue($options[$option][$index]);
            }
        }
    }

    /**
     * @param array<string, mixed> $options
     */
    private static function normalizeDeprecatedStringOptionValues(array &$options): void
    {
        foreach (['cert_type', 'force_ip_resolve', 'ssl_key_type'] as $option) {
            if (
                \array_key_exists($option, $options)
                && !\is_string($options[$option])
                && self::canStringifyDeprecatedValue($options[$option])
            ) {
                $options[$option] = self::stringifyDeprecatedValue($options[$option]);
            }
        }
    }

    /**
     * @param array<string, mixed> $options
     */
    private static function normalizeDeprecatedNumericOptionValues(array &$options): void
    {
        foreach (['connect_timeout', 'delay', 'read_timeout', 'timeout'] as $option) {
            if (
                \array_key_exists($option, $options)
                && \is_string($options[$option])
                && \is_numeric($options[$option])
            ) {
                $options[$option] = $options[$option] + 0;
            }
        }
    }

    /**
     * @param array<string, mixed> $options
     */
    private static function normalizeDeprecatedIntegerOptionValues(array &$options): void
    {
        foreach (['crypto_method', 'crypto_method_max', 'retries'] as $option) {
            if (!\array_key_exists($option, $options)) {
                continue;
            }

            if (\is_string($options[$option]) && \preg_match('/^-?\d+$/D', $options[$option]) === 1) {
                $options[$option] = (int) $options[$option];
            } elseif (
                \is_float($options[$option])
                && \is_finite($options[$option])
                && $options[$option] === (float) (int) $options[$option]
            ) {
                $options[$option] = (int) $options[$option];
            }
        }
    }

    private static function warnAboutRequestLevelHandler(array $options): void
    {
        if (!\array_key_exists('handler', $options)) {
            return;
        }

        \trigger_deprecation(
            'guzzlehttp/guzzle',
            '7.12',
            'Passing the "handler" request option is deprecated; guzzlehttp/guzzle 8.0 will ignore request-level handlers. Configure the handler when creating the Client, or use a separate Client instance for requests that need a different handler.'
        );
    }

    private static function warnAboutInvalidRequestOptionTypes(array $options): void
    {
        if (isset($options['handler']) && !\is_callable($options['handler'])) {
            self::warnInvalidRequestOptionType('handler', 'callable', $options['handler']);
        }

        if (isset($options['allow_redirects']) && \is_array($options['allow_redirects'])) {
            self::warnAboutInvalidAllowRedirectsOptionTypes($options['allow_redirects']);
        } elseif (isset($options['allow_redirects']) && !\is_bool($options['allow_redirects'])) {
            self::warnInvalidRequestOptionType('allow_redirects', 'bool|array', $options['allow_redirects'], '7.13');
        }

        if (isset($options['auth'])) {
            self::warnAboutInvalidAuthOptionTypes($options['auth']);
        }

        if (isset($options['body']) && \is_array($options['body'])) {
            self::warnInvalidRequestOptionType('body', 'resource|string|null|int|float|bool|StreamInterface|(callable&object)|\Iterator|\Stringable', $options['body']);
        }

        self::warnAboutInvalidTlsFileOptionTypes($options, 'cert');
        self::warnIfPresentAndNotString($options, 'cert_type');
        self::warnIfPresentAndNotNumber($options, 'connect_timeout');
        self::warnIfPresentAndNotInt($options, 'crypto_method');
        self::warnIfPresentAndNotInt($options, 'crypto_method_max', null, '7.13');
        self::warnIfPresentAndNotBoolOrResource($options, 'debug');
        self::warnIfPresentAndNotBoolOrString($options, 'decode_content');
        self::warnIfPresentAndNotNumber($options, 'delay');
        if (isset($options['delay']) && \is_numeric($options['delay'])) {
            $delay = (float) $options['delay'];
            if (!\is_finite($delay) || $delay < 0.0) {
                self::warnInvalidRequestOptionType('delay', 'finite int|float greater than or equal to 0', $options['delay'], '7.13');
            }
        }
        self::warnIfPresentAndNotBoolOrInt($options, 'expect');

        if (isset($options['form_params'])) {
            self::warnAboutInvalidFormParamTypes($options['form_params']);
        }

        if (isset($options['force_ip_resolve']) && !\is_string($options['force_ip_resolve'])) {
            self::warnInvalidRequestOptionType('force_ip_resolve', 'string', $options['force_ip_resolve']);
        }

        if (
            isset($options['force_ip_resolve'])
            && \is_string($options['force_ip_resolve'])
            && $options['force_ip_resolve'] !== 'v4'
            && $options['force_ip_resolve'] !== 'v6'
        ) {
            self::warnInvalidRequestOptionType('force_ip_resolve', '"v4"|"v6"', $options['force_ip_resolve'], '7.13');
        }

        if (isset($options['headers'])) {
            self::warnAboutInvalidHeaderOptionTypes($options['headers']);
        }

        self::warnIfPresentAndNotBool($options, 'http_errors');

        if (isset($options['multipart'])) {
            self::warnAboutInvalidMultipartOptionTypes($options['multipart']);
        }

        self::warnIfPresentAndNotCallable($options, 'on_headers');
        self::warnIfPresentAndNotCallable($options, 'on_stats');
        self::warnIfPresentAndNotCallable($options, 'progress');
        self::warnIfPresentAndNotStringArray($options, 'protocols', true);
        self::warnAboutInvalidProtocolValues($options, 'protocols');
        self::warnAboutInvalidProxyOptionTypes($options);

        self::warnIfPresentAndNotNumber($options, 'read_timeout');
        self::warnIfPresentAndNotInt($options, 'retries');

        if (isset($options['sink']) && !\is_bool($options['sink']) && !\is_resource($options['sink']) && !\is_string($options['sink']) && !$options['sink'] instanceof StreamInterface) {
            self::warnInvalidRequestOptionType('sink', 'resource|string|StreamInterface', $options['sink']);
        }

        self::warnAboutInvalidTlsFileOptionTypes($options, 'ssl_key');
        self::warnIfPresentAndNotString($options, 'ssl_key_type');
        self::warnIfPresentAndNotBool($options, 'stream');
        self::warnIfPresentAndNotArray($options, 'stream_context', 'array<array-key, mixed>');
        self::warnIfPresentAndNotBool($options, 'synchronous');
        self::warnIfPresentAndNotNumber($options, 'timeout');
        self::warnIfPresentAndNotBoolOrString($options, 'verify');
        self::warnIfPresentAndNotStringOrNumber($options, 'version');
        self::warnIfPresentAndNotArray($options, 'curl', 'array<int|string, mixed>');

        if (isset($options['cookies']) && $options['cookies'] === true) {
            self::warnInvalidRequestOptionType('cookies', 'false|CookieJarInterface', $options['cookies']);
        }

        if (
            isset($options['cookies'])
            && $options['cookies'] !== false
            && $options['cookies'] !== true
            && !($options['cookies'] instanceof CookieJarInterface)
        ) {
            self::warnInvalidRequestOptionType('cookies', 'false|CookieJarInterface', $options['cookies'], '7.13');
        }
    }

    private static function warnAboutInvalidAllowRedirectsOptionTypes(array $allowRedirects): void
    {
        self::warnIfPresentAndNotInt($allowRedirects, 'max', 'allow_redirects.max');
        self::warnIfPresentAndNotBool($allowRedirects, 'strict', 'allow_redirects.strict');
        self::warnIfPresentAndNotBool($allowRedirects, 'referer', 'allow_redirects.referer');
        self::warnIfPresentAndNotStringArray($allowRedirects, 'protocols', true, 'allow_redirects.protocols');
        self::warnAboutInvalidProtocolValues($allowRedirects, 'protocols', 'allow_redirects.protocols');
        self::warnIfPresentAndNotCallable($allowRedirects, 'on_redirect', 'allow_redirects.on_redirect');
        self::warnIfPresentAndNotBool($allowRedirects, 'track_redirects', 'allow_redirects.track_redirects');
    }

    /**
     * @param mixed $auth
     */
    private static function warnAboutInvalidAuthOptionTypes($auth): void
    {
        if ($auth === false || \is_string($auth) || $auth === []) {
            return;
        }

        if (!\is_array($auth)) {
            self::warnInvalidRequestOptionType('auth', 'array{0: string, 1: string, 2?: string|null}|string|false|null', $auth);

            return;
        }

        if (!\array_key_exists(0, $auth) || !\is_string($auth[0])) {
            self::warnInvalidRequestOptionType('auth.0', 'string', $auth[0] ?? null);
        }

        if (!\array_key_exists(1, $auth) || !\is_string($auth[1])) {
            self::warnInvalidRequestOptionType('auth.1', 'string', $auth[1] ?? null);
        }

        if (\array_key_exists(2, $auth) && $auth[2] !== null && !\is_string($auth[2])) {
            self::warnInvalidRequestOptionType('auth.2', 'string|null', $auth[2]);
        }
    }

    /**
     * @param mixed $value
     */
    private static function warnAboutInvalidFormParamTypes($value): void
    {
        if (!\is_array($value)) {
            self::warnInvalidRequestOptionType('form_params', 'array<array-key, string|int|float|bool|null|array>', $value);

            return;
        }

        self::warnAboutInvalidFormParamArray($value, 'form_params');
    }

    private static function warnAboutInvalidFormParamArray(array $values, string $path): bool
    {
        foreach ($values as $key => $item) {
            $itemPath = $path.'.'.(string) $key;
            if (\is_array($item)) {
                if (!self::warnAboutInvalidFormParamArray($item, $itemPath)) {
                    return false;
                }

                continue;
            }

            if ($item !== null && !\is_scalar($item)) {
                self::warnInvalidRequestOptionType($itemPath, 'string|int|float|bool|null|array', $item);

                return false;
            }
        }

        return true;
    }

    /**
     * @param mixed $headers
     */
    private static function warnAboutInvalidHeaderOptionTypes($headers): void
    {
        if (!\is_array($headers)) {
            self::warnInvalidRequestOptionType('headers', 'array<array-key, string|non-empty-array<array-key, string>>|null', $headers);

            return;
        }

        foreach ($headers as $name => $value) {
            $path = 'headers.'.(string) $name;
            if (\is_array($value)) {
                if ($value === []) {
                    self::warnInvalidRequestOptionType($path, 'string|non-empty-array<array-key, string>', $value);

                    break;
                }

                foreach ($value as $index => $item) {
                    if (!\is_string($item)) {
                        self::warnInvalidRequestOptionType($path.'.'.(string) $index, 'string', $item);

                        break 2;
                    }
                }
            } elseif (!\is_string($value)) {
                self::warnInvalidRequestOptionType($path, 'string|non-empty-array<array-key, string>', $value);

                break;
            }
        }
    }

    /**
     * @param mixed $multipart
     */
    private static function warnAboutInvalidMultipartOptionTypes($multipart): void
    {
        if (!\is_array($multipart)) {
            self::warnInvalidRequestOptionType('multipart', 'array<array-key, array{name: string|int, contents: mixed, headers?: array<array-key, string>, filename?: string}>', $multipart);

            return;
        }

        foreach ($multipart as $index => $part) {
            $path = 'multipart.'.(string) $index;
            if (!\is_array($part)) {
                self::warnInvalidRequestOptionType($path, 'array{name: string|int, contents: mixed, headers?: array<array-key, string>, filename?: string}', $part);

                return;
            }

            if (!\array_key_exists('name', $part) || (!\is_string($part['name']) && !\is_int($part['name']))) {
                self::warnInvalidRequestOptionType($path.'.name', 'string|int', $part['name'] ?? null);
            }

            if (!\array_key_exists('contents', $part)) {
                self::warnInvalidRequestOptionType($path, 'array{name: string|int, contents: mixed, headers?: array<array-key, string>, filename?: string}', $part);
            }

            if (\array_key_exists('headers', $part)) {
                if (!\is_array($part['headers'])) {
                    self::warnInvalidRequestOptionType($path.'.headers', 'array<array-key, string>', $part['headers']);
                } else {
                    foreach ($part['headers'] as $name => $value) {
                        if (!\is_string($value)) {
                            self::warnInvalidRequestOptionType($path.'.headers.'.(string) $name, 'string', $value);

                            break 2;
                        }
                    }
                }
            }

            if (\array_key_exists('filename', $part) && !\is_string($part['filename'])) {
                self::warnInvalidRequestOptionType($path.'.filename', 'string', $part['filename']);
            }
        }
    }

    private static function warnAboutInvalidProxyOptionTypes(array $options): void
    {
        if (!isset($options['proxy'])) {
            return;
        }

        if (!\is_string($options['proxy']) && !\is_array($options['proxy'])) {
            self::warnInvalidRequestOptionType('proxy', 'string|array{http?: string|null, https?: string|null, no?: string|array<array-key, string>|null}', $options['proxy']);

            return;
        }

        if (!\is_array($options['proxy'])) {
            return;
        }

        foreach (['http', 'https'] as $scheme) {
            if (\array_key_exists($scheme, $options['proxy']) && $options['proxy'][$scheme] !== null && !\is_string($options['proxy'][$scheme])) {
                self::warnInvalidRequestOptionType('proxy.'.$scheme, 'string|null', $options['proxy'][$scheme]);
            }
        }

        if (!\array_key_exists('no', $options['proxy']) || $options['proxy']['no'] === null) {
            return;
        }

        if (\is_string($options['proxy']['no'])) {
            return;
        }

        if (!\is_array($options['proxy']['no'])) {
            self::warnInvalidRequestOptionType('proxy.no', 'string|array<array-key, string>|null', $options['proxy']['no']);

            return;
        }

        foreach ($options['proxy']['no'] as $index => $noProxy) {
            if (!\is_string($noProxy)) {
                self::warnInvalidRequestOptionType('proxy.no.'.(string) $index, 'string', $noProxy);

                return;
            }
        }
    }

    private static function warnAboutInvalidTlsFileOptionTypes(array $options, string $option): void
    {
        if (!isset($options[$option])) {
            return;
        }

        if (\is_string($options[$option])) {
            return;
        }

        if (!\is_array($options[$option])) {
            self::warnInvalidRequestOptionType($option, 'string|array{0: string, 1?: string}', $options[$option]);

            return;
        }

        if (!\array_key_exists(0, $options[$option]) || !\is_string($options[$option][0])) {
            self::warnInvalidRequestOptionType($option.'.0', 'string', $options[$option][0] ?? null);
        }

        if (\array_key_exists(1, $options[$option]) && $options[$option][1] !== null && !\is_string($options[$option][1])) {
            self::warnInvalidRequestOptionType($option.'.1', 'string|null', $options[$option][1]);
        }
    }

    private static function warnIfPresentAndNotArray(array $options, string $option, string $expected): void
    {
        if (\array_key_exists($option, $options) && !\is_array($options[$option])) {
            self::warnInvalidRequestOptionType($option, $expected, $options[$option]);
        }
    }

    private static function warnIfPresentAndNotBool(array $options, string $option, ?string $path = null): void
    {
        if (\array_key_exists($option, $options) && !\is_bool($options[$option])) {
            self::warnInvalidRequestOptionType($path ?? $option, 'bool', $options[$option]);
        }
    }

    private static function warnIfPresentAndNotBoolOrInt(array $options, string $option): void
    {
        if (\array_key_exists($option, $options) && !\is_bool($options[$option]) && !\is_int($options[$option])) {
            self::warnInvalidRequestOptionType($option, 'bool|int', $options[$option]);
        }
    }

    private static function warnIfPresentAndNotBoolOrResource(array $options, string $option): void
    {
        if (\array_key_exists($option, $options) && !\is_bool($options[$option]) && !\is_resource($options[$option])) {
            self::warnInvalidRequestOptionType($option, 'bool|resource', $options[$option]);
        }
    }

    private static function warnIfPresentAndNotBoolOrString(array $options, string $option): void
    {
        if (\array_key_exists($option, $options) && !\is_bool($options[$option]) && !\is_string($options[$option])) {
            self::warnInvalidRequestOptionType($option, 'bool|string', $options[$option]);
        }
    }

    private static function warnIfPresentAndNotCallable(array $options, string $option, ?string $path = null): void
    {
        if (\array_key_exists($option, $options) && !\is_callable($options[$option])) {
            self::warnInvalidRequestOptionType($path ?? $option, 'callable', $options[$option]);
        }
    }

    private static function warnIfPresentAndNotInt(
        array $options,
        string $option,
        ?string $path = null,
        string $since = '7.11'
    ): void {
        if (\array_key_exists($option, $options) && !\is_int($options[$option])) {
            self::warnInvalidRequestOptionType($path ?? $option, 'int', $options[$option], $since);
        }
    }

    private static function warnIfPresentAndNotNumber(array $options, string $option): void
    {
        if (\array_key_exists($option, $options) && !\is_int($options[$option]) && !\is_float($options[$option])) {
            self::warnInvalidRequestOptionType($option, 'int|float', $options[$option]);
        }
    }

    private static function warnIfPresentAndNotString(array $options, string $option): void
    {
        if (\array_key_exists($option, $options) && !\is_string($options[$option])) {
            self::warnInvalidRequestOptionType($option, 'string', $options[$option]);
        }
    }

    private static function warnIfPresentAndNotStringArray(array $options, string $option, bool $nonEmpty, ?string $path = null): void
    {
        if (!\array_key_exists($option, $options)) {
            return;
        }

        $path = $path ?? $option;
        $expected = ($nonEmpty ? 'non-empty-' : '').'array<array-key, string>';

        if (!\is_array($options[$option]) || ($nonEmpty && $options[$option] === [])) {
            self::warnInvalidRequestOptionType($path, $expected, $options[$option]);

            return;
        }

        foreach ($options[$option] as $index => $item) {
            if (!\is_string($item)) {
                self::warnInvalidRequestOptionType($path.'.'.(string) $index, 'string', $item);

                return;
            }
        }
    }

    /**
     * @param array<array-key, mixed> $options
     */
    private static function warnAboutInvalidProtocolValues(array $options, string $option, ?string $path = null): void
    {
        if (!isset($options[$option]) || !\is_array($options[$option])) {
            return;
        }

        $path = $path ?? $option;

        foreach ($options[$option] as $index => $protocol) {
            if (\is_string($protocol) && $protocol !== 'http' && $protocol !== 'https') {
                self::warnInvalidRequestOptionType($path.'.'.(string) $index, '"http"|"https"', $protocol, '7.13');
            }
        }
    }

    private static function warnIfPresentAndNotStringOrNumber(array $options, string $option): void
    {
        if (
            \array_key_exists($option, $options)
            && !\is_string($options[$option])
            && !\is_int($options[$option])
            && !\is_float($options[$option])
        ) {
            self::warnInvalidRequestOptionType($option, 'string|int|float', $options[$option]);
        }
    }

    /**
     * @param mixed $value
     */
    private static function warnInvalidRequestOptionType(string $option, string $expected, $value, string $since = '7.11'): void
    {
        \trigger_deprecation(
            'guzzlehttp/guzzle',
            $since,
            'Passing %s to request option "%s" is deprecated; guzzlehttp/guzzle 8.0 requires %s.',
            \get_debug_type($value),
            $option,
            $expected
        );
    }

    /**
     * Transfers the given request and applies request options.
     *
     * The URI of the request is not modified and the request options are used
     * as-is without merging in default options.
     *
     * @param array $options See {@see RequestOptions}.
     */
    private function transfer(RequestInterface $request, array $options): PromiseInterface
    {
        $request = $this->applyOptions($request, $options);

        $protocolVersion = $request->getProtocolVersion();

        if ('' === $protocolVersion) {
            \trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Sending a request with an empty protocol version is deprecated; guzzlehttp/guzzle 8.0 will reject empty protocol versions.');

            $request = Psr7\Utils::modifyRequest($request, ['version' => '1.1']);
        } elseif (!self::isProtocolVersionValid($protocolVersion)) {
            \trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Sending a request with a malformed protocol version is deprecated; guzzlehttp/guzzle 8.0 will reject malformed protocol versions.');
        }

        /** @var HandlerStack $handler */
        $handler = $options['handler'];

        try {
            return P\Create::promiseFor($handler($request, $options));
        } catch (\Exception $e) {
            return P\Create::rejectionFor($e);
        }
    }

    /**
     * Applies the array of request options to a request.
     */
    private function applyOptions(RequestInterface $request, array &$options): RequestInterface
    {
        $modify = [
            'set_headers' => [],
        ];

        if (isset($options['headers'])) {
            if (array_keys($options['headers']) === range(0, count($options['headers']) - 1)) {
                throw new InvalidArgumentException('The headers array must have header name as keys.');
            }
            $headers = $options['headers'];
            $droppedHeaderNames = self::castDeprecatedHeaderOptionValues($headers);
            if ($droppedHeaderNames !== [] && isset($options['_conditional'])) {
                $options['_conditional'] = Psr7\Utils::caselessRemove($droppedHeaderNames, $options['_conditional']);
            }
            $modify['set_headers'] = $headers;
            unset($options['headers']);
        }

        if (isset($options['form_params'])) {
            if (isset($options['multipart'])) {
                throw new InvalidArgumentException('You cannot use '
                    .'form_params and multipart at the same time. Use the '
                    .'form_params option if you want to send application/'
                    .'x-www-form-urlencoded requests, and the multipart '
                    .'option to send multipart/form-data requests.');
            }
            $options['body'] = \http_build_query(self::normalizeNonFiniteFloats($options['form_params'], 'form_params'), '', '&');
            unset($options['form_params']);
            // Ensure that we don't have the header in different case and set the new value.
            $options['_conditional'] = Psr7\Utils::caselessRemove(['Content-Type'], $options['_conditional']);
            $options['_conditional']['Content-Type'] = 'application/x-www-form-urlencoded';
        }

        if (isset($options['multipart'])) {
            $options['body'] = new Psr7\MultipartStream($options['multipart']);
            unset($options['multipart']);
        }

        if (isset($options['json'])) {
            $options['body'] = Utils::jsonEncode($options['json']);
            unset($options['json']);
            // Ensure that we don't have the header in different case and set the new value.
            $options['_conditional'] = Psr7\Utils::caselessRemove(['Content-Type'], $options['_conditional']);
            $options['_conditional']['Content-Type'] = 'application/json';
        }

        if (isset($options['decode_content']) && \is_string($options['decode_content'])) {
            // Ensure that we don't have the header in different case and set the new value.
            $options['_conditional'] = Psr7\Utils::caselessRemove(['Accept-Encoding'], $options['_conditional']);
            $modify['set_headers']['Accept-Encoding'] = (string) $options['decode_content'];
        }

        if (isset($options['body'])) {
            if (\is_array($options['body'])) {
                throw $this->invalidBody();
            }
            $modify['body'] = self::createBodyStream($options['body']);
            unset($options['body']);
        }

        if (!empty($options['auth']) && \is_array($options['auth'])) {
            $value = $options['auth'];
            $type = isset($value[2]) ? \strtolower($value[2]) : 'basic';
            switch ($type) {
                case 'basic':
                    // Ensure that we don't have the header in different case and set the new value.
                    $modify['set_headers'] = Psr7\Utils::caselessRemove(['Authorization'], $modify['set_headers']);
                    $modify['set_headers']['Authorization'] = 'Basic '
                        .\base64_encode("$value[0]:$value[1]");
                    break;
                case 'digest':
                    // @todo: Do not rely on curl
                    $options['curl'][\CURLOPT_HTTPAUTH] = \CURLAUTH_DIGEST;
                    $options['curl'][\CURLOPT_USERPWD] = "$value[0]:$value[1]";
                    break;
                case 'ntlm':
                    \trigger_deprecation(
                        'guzzlehttp/guzzle',
                        '7.12',
                        'Passing "ntlm" as the built-in auth type is deprecated; guzzlehttp/guzzle 8.0 will no longer apply NTLM through the "auth" request option. Configure NTLM with cURL HTTP authentication options instead.'
                    );
                    $options['curl'][\CURLOPT_HTTPAUTH] = \CURLAUTH_NTLM;
                    $options['curl'][\CURLOPT_USERPWD] = "$value[0]:$value[1]";
                    break;
            }
        }

        if (isset($options['query'])) {
            $value = $options['query'];
            if (\is_array($value)) {
                $value = \http_build_query(self::normalizeNonFiniteFloats($value, 'query'), '', '&', \PHP_QUERY_RFC3986);
            }
            if (!\is_string($value)) {
                throw new InvalidArgumentException('query must be a string or array');
            }
            $modify['query'] = $value;
            unset($options['query']);
        }

        // Ensure that sink is not an invalid value.
        if (isset($options['sink'])) {
            // TODO: Add more sink validation?
            if (\is_bool($options['sink'])) {
                throw new InvalidArgumentException('sink must not be a boolean');
            }
        }

        if (isset($options['version'])) {
            $modify['version'] = self::normalizeProtocolVersion($options['version']);
        }

        $request = Psr7\Utils::modifyRequest($request, $modify);
        if ($request->getBody() instanceof Psr7\MultipartStream) {
            // Use a multipart/form-data POST if a Content-Type is not set.
            // Ensure that we don't have the header in different case and set the new value.
            $options['_conditional'] = Psr7\Utils::caselessRemove(['Content-Type'], $options['_conditional']);
            $options['_conditional']['Content-Type'] = 'multipart/form-data; boundary='
                .$request->getBody()->getBoundary();
        }

        // Merge in conditional headers if they are not present.
        if (isset($options['_conditional'])) {
            // Build up the changes so it's in a single clone of the message.
            $modify = [];
            foreach ($options['_conditional'] as $k => $v) {
                $name = (string) $k;
                if (!$request->hasHeader($name)) {
                    $modify['set_headers'][$name] = $v;
                }
            }
            $request = Psr7\Utils::modifyRequest($request, $modify);
            // Don't pass this internal value along to middleware/handlers.
            unset($options['_conditional']);
        }

        return $request;
    }

    /**
     * @param array<array-key, mixed> $headers
     *
     * @return list<string>
     */
    private static function castDeprecatedHeaderOptionValues(array &$headers): array
    {
        $droppedHeaderNames = [];

        foreach ($headers as $name => $value) {
            if (\is_array($value)) {
                if ($value === []) {
                    $droppedHeaderNames[] = (string) $name;
                    unset($headers[$name]);

                    continue;
                }

                foreach ($value as $index => $item) {
                    if ($item === null || (!\is_string($item) && \is_scalar($item))) {
                        if (\is_float($item) && !\is_finite($item)) {
                            $item = \is_nan($item) ? 'NAN' : ($item > 0 ? 'INF' : '-INF');
                        }
                        $value[$index] = (string) $item;
                    }
                }

                $headers[$name] = $value;

                continue;
            }

            if ($value === null || (!\is_string($value) && \is_scalar($value))) {
                if (\is_float($value) && !\is_finite($value)) {
                    $value = \is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF');
                }
                $headers[$name] = (string) $value;
            }
        }

        return $droppedHeaderNames;
    }

    /**
     * @param mixed $body
     */
    private static function createBodyStream($body): StreamInterface
    {
        if ($body instanceof StreamInterface) {
            return $body;
        }

        if (\is_resource($body) || $body === null || \is_string($body) || $body instanceof \Iterator) {
            return Psr7\Utils::streamFor($body);
        }

        if (\is_scalar($body)) {
            \trigger_deprecation('guzzlehttp/guzzle', '7.12', 'Passing a non-string scalar to the "body" request option is deprecated; guzzlehttp/guzzle 8.0 will reject non-string scalar bodies.');

            return Psr7\Utils::streamFor(self::stringifyScalar($body));
        }

        if (\is_object($body) && \method_exists($body, '__toString')) {
            return Psr7\Utils::streamFor((string) $body);
        }

        if (\is_callable($body)) {
            return Psr7\Utils::streamFor($body);
        }

        throw new InvalidArgumentException(\sprintf(
            'Passing %s to request option "body" is invalid; expected resource|string|null|int|float|bool|StreamInterface|callable&object|Iterator|Stringable.',
            \get_debug_type($body)
        ));
    }

    /**
     * @param bool|float|int|string $value
     */
    private static function stringifyScalar($value): string
    {
        // Normalize non-finite floats to dodge PHP 8.5's (string) NAN
        // coercion warning while the value is still accepted.
        if (\is_float($value) && !\is_finite($value)) {
            $value = \is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF');
        }

        return (string) $value;
    }

    /**
     * Converts non-finite floats in the array to the strings PHP coerces
     * them to, as implicit coercion of NAN emits a warning on PHP 8.5.
     */
    private static function normalizeNonFiniteFloats(array $values, string $option): array
    {
        foreach ($values as $key => $value) {
            if (\is_array($value)) {
                $values[$key] = self::normalizeNonFiniteFloats($value, $option);
            } elseif (\is_float($value) && !\is_finite($value)) {
                \trigger_deprecation('guzzlehttp/guzzle', '7.12', 'Passing a non-finite float in the "%s" request option is deprecated; guzzlehttp/guzzle 8.0 will reject non-finite floats.', $option);

                $values[$key] = \is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF');
            }
        }

        return $values;
    }

    /**
     * @param string|int|float $version
     */
    private static function normalizeProtocolVersion($version): string
    {
        if ('' === $version) {
            \trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Passing an empty "version" request option is deprecated; guzzlehttp/guzzle 8.0 will reject empty protocol versions.');

            return '1.1';
        }

        return \is_float($version) ? \number_format($version, 1, '.', '') : (string) $version;
    }

    private static function isProtocolVersionValid(string $version): bool
    {
        return 1 === \preg_match('/^\d+(?:\.\d+)?$/D', $version);
    }

    /**
     * Return an InvalidArgumentException with pre-set message.
     */
    private function invalidBody(): InvalidArgumentException
    {
        return new InvalidArgumentException('Passing in the "body" request '
            .'option as an array to send a request is not supported. '
            .'Please use the "form_params" request option to send a '
            .'application/x-www-form-urlencoded request, or the "multipart" '
            .'request option to send a multipart/form-data request.');
    }
}
