<?php

declare (strict_types=1);
namespace Kazcode\WpStorage\Vendor\GuzzleHttp;

use Kazcode\WpStorage\Vendor\GuzzleHttp\Auth\DigestAuth;
use Kazcode\WpStorage\Vendor\GuzzleHttp\Auth\DigestChallenge;
use Kazcode\WpStorage\Vendor\GuzzleHttp\Exception\InvalidArgumentException;
use Kazcode\WpStorage\Vendor\GuzzleHttp\Exception\ResponseException;
use Kazcode\WpStorage\Vendor\GuzzleHttp\Promise as P;
use Kazcode\WpStorage\Vendor\GuzzleHttp\Promise\PromiseInterface;
use Kazcode\WpStorage\Vendor\GuzzleHttp\Psr7\HttpFactory;
use Kazcode\WpStorage\Vendor\GuzzleHttp\Psr7\LazyOpenStream;
use Kazcode\WpStorage\Vendor\Psr\Http\Message\RequestInterface;
use Kazcode\WpStorage\Vendor\Psr\Http\Message\ResponseInterface;
use Kazcode\WpStorage\Vendor\Psr\Http\Message\StreamFactoryInterface;
use Kazcode\WpStorage\Vendor\Psr\Http\Message\StreamInterface;
use Kazcode\WpStorage\Vendor\Psr\Http\Message\UriInterface;
/**
 * Applies built-in Basic authentication and handles Digest authentication
 * challenges.
 */
final class AuthMiddleware
{
    use NonSerializableTrait;
    private const DIGEST_MAX_RETRIES = 2;
    private const CHALLENGE_CACHE_LIMIT = 32;
    /**
     * Request option carrying nonce counts already spent during a Digest
     * handshake, so stale retries advance instead of repeating a count.
     */
    private const DIGEST_NONCE_COUNTS_OPTION = '__guzzle_digest_nonce_counts';
    /**
     * Headers that describe the request payload. A probe with an empty body
     * must not carry them.
     *
     * @var list<string>
     */
    private const DIGEST_PROBE_PAYLOAD_HEADERS = ['Transfer-Encoding', 'Expect', 'Trailer', 'Content-Range', 'Content-Encoding', 'Content-MD5', 'Digest', 'Content-Digest', 'Repr-Digest'];
    /**
     * @var callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>
     */
    private $nextHandler;
    /**
     * @var callable(): string
     */
    private $cnonceGenerator;
    private bool $reuseChallenges;
    private ?string $credentialHashSecret = null;
    /**
     * @var array<string, array{challenge: DigestChallenge, credentials: string, nextNc: int}>
     */
    private array $digestChallenges = [];
    /**
     * @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $nextHandler
     * @param (callable(): string)|null                                                                       $cnonceGenerator
     */
    public function __construct(callable $nextHandler, ?callable $cnonceGenerator = null, bool $reuseChallenges = \true)
    {
        $this->nextHandler = $nextHandler;
        $this->cnonceGenerator = $cnonceGenerator ?? static function (): string {
            return \bin2hex(\random_bytes(16));
        };
        $this->reuseChallenges = $reuseChallenges;
    }
    /**
     * @return PromiseInterface<ResponseInterface, mixed>
     */
    public function __invoke(
        #[\SensitiveParameter]
        RequestInterface $request,
        #[\SensitiveParameter]
        array $options
    ): PromiseInterface
    {
        $auth = $options['auth'] ?? null;
        if ($auth === null || $auth === \false || $auth === [] || \is_string($auth)) {
            return ($this->nextHandler)($request, $options);
        }
        $normalizedAuth = self::normalizeAuth($auth);
        if ($normalizedAuth === null) {
            return ($this->nextHandler)($request, $options);
        }
        [$username, $password, $type] = $normalizedAuth;
        if ($type === 'basic') {
            return $this->sendBasic($request, $options, $username, $password);
        }
        return $this->sendDigest($request, $options, $username, $password);
    }
    /**
     * @param array<array-key, mixed> $auth
     *
     * @return array{0: string, 1: string, 2: 'basic'|'digest'}|null
     */
    private static function normalizeAuth(
        #[\SensitiveParameter]
        array $auth
    ): ?array
    {
        $type = 'basic';
        if (\array_key_exists(2, $auth) && $auth[2] !== null) {
            if (!\is_string($auth[2])) {
                throw new InvalidArgumentException('auth type must be a string');
            }
            $type = Psr7\Utils::asciiToLower($auth[2]);
        }
        if (!\in_array($type, ['basic', 'digest'], \true)) {
            return null;
        }
        if (!\array_key_exists(0, $auth) || !\array_key_exists(1, $auth)) {
            throw new InvalidArgumentException('auth must contain username and password strings');
        }
        if (!\is_string($auth[0]) || !\is_string($auth[1])) {
            throw new InvalidArgumentException('auth must contain username and password strings');
        }
        return [$auth[0], $auth[1], $type];
    }
    /**
     * @return PromiseInterface<ResponseInterface, mixed>
     */
    private function sendBasic(
        #[\SensitiveParameter]
        RequestInterface $request,
        #[\SensitiveParameter]
        array $options,
        string $username,
        #[\SensitiveParameter]
        string $password
    ): PromiseInterface
    {
        if (\strpos($username, ':') !== \false) {
            throw new InvalidArgumentException('Basic authentication username must not contain a colon');
        }
        if (\preg_match('/[\x00-\x1F\x7F]/', $username . $password) !== 0) {
            throw new InvalidArgumentException('Basic authentication credentials must not contain ASCII control characters');
        }
        unset($options['auth']);
        return ($this->nextHandler)($request->withHeader('Authorization', 'Basic ' . \base64_encode($username . ':' . $password)), $options);
    }
    /**
     * @return PromiseInterface<ResponseInterface, mixed>
     */
    private function sendDigest(
        #[\SensitiveParameter]
        RequestInterface $request,
        #[\SensitiveParameter]
        array $options,
        string $username,
        #[\SensitiveParameter]
        string $password
    ): PromiseInterface
    {
        $preemptive = $this->reuseChallenges ? $this->preemptiveDigestRequest($request, $username, $password) : null;
        if ($preemptive !== null) {
            $preemptiveOptions = self::withTemporarySink($options);
            unset($preemptiveOptions['auth']);
            return ($this->nextHandler)($preemptive, $preemptiveOptions)->then(function (
                #[\SensitiveParameter]
                ResponseInterface $response
            ) use ($request, $options, $preemptiveOptions, $username, $password) {
                // Clear on 4xx/5xx other than 401 so non-conformant stale-nonce
                // errors cannot poison the cache indefinitely.
                $status = $response->getStatusCode();
                if ($status >= 400 && $status !== 401) {
                    $this->clearDigestChallenge($request);
                }
                return $this->handleDigestResponse($request, $options, $preemptiveOptions, $response, $username, $password, \false);
            }, function (
                #[\SensitiveParameter]
                $reason
            ) use ($request, $preemptiveOptions) {
                return $this->handleDigestRejection($request, $preemptiveOptions, $reason, \true);
            });
        }
        $probeOptions = self::withTemporarySink($options);
        unset($probeOptions['auth']);
        [$probeRequest, $bodyWithheld] = self::probeRequest($request, $options);
        if ($bodyWithheld) {
            // The probe has no payload, so prepare_body must not add Expect
            // if a custom empty stream has unknown size.
            $probeOptions[RequestOptions::EXPECT] = \false;
        }
        return ($this->nextHandler)($probeRequest, $probeOptions)->then(function (
            #[\SensitiveParameter]
            ResponseInterface $response
        ) use ($request, $options, $probeOptions, $username, $password, $bodyWithheld) {
            return $this->handleDigestResponse($request, $options, $probeOptions, $response, $username, $password, $bodyWithheld);
        }, function (
            #[\SensitiveParameter]
            $reason
        ) use ($request, $probeOptions) {
            return $this->handleDigestRejection($request, $probeOptions, $reason, \false);
        });
    }
    private function preemptiveDigestRequest(
        #[\SensitiveParameter]
        RequestInterface $request,
        string $username,
        #[\SensitiveParameter]
        string $password
    ): ?RequestInterface
    {
        try {
            if ($request->getBody()->getSize() !== 0) {
                return null;
            }
        } catch (\Exception $e) {
            return null;
        }
        $key = self::digestCacheKey($request);
        if ($key === null) {
            return null;
        }
        $entry = $this->digestChallenges[$key] ?? null;
        if ($entry === null || $entry['credentials'] !== $this->digestCredentialKey($username, $password) || !self::challengeCoversRequest($entry['challenge'], $request)) {
            return null;
        }
        $authorization = DigestAuth::authorizationHeader($request, $entry['challenge'], $username, $password, ($this->cnonceGenerator)(), \sprintf('%08x', $entry['nextNc']));
        if ($authorization === null) {
            return null;
        }
        ++$this->digestChallenges[$key]['nextNc'];
        return $request->withHeader('Authorization', $authorization);
    }
    /**
     * Builds the unauthenticated probe request for a Digest handshake.
     *
     * @return array{0: RequestInterface, 1: bool}
     */
    private static function probeRequest(
        #[\SensitiveParameter]
        RequestInterface $request,
        #[\SensitiveParameter]
        array $options
    ): array
    {
        $probe = $request->withoutHeader('Authorization');
        try {
            $size = $probe->getBody()->getSize();
        } catch (\Exception $e) {
            $size = null;
        }
        if ($size === 0) {
            return [$probe, \false];
        }
        $streamFactory = self::requireStreamFactory($options[RequestOptions::STREAM_FACTORY] ?? new HttpFactory());
        $probe = $probe->withBody($streamFactory->createStream(''))->withHeader('Content-Length', '0');
        foreach (self::DIGEST_PROBE_PAYLOAD_HEADERS as $header) {
            $probe = $probe->withoutHeader($header);
        }
        return [$probe, \true];
    }
    /**
     * @return ResponseInterface|PromiseInterface<ResponseInterface, mixed>
     */
    private function handleDigestResponse(
        #[\SensitiveParameter]
        RequestInterface $request,
        #[\SensitiveParameter]
        array $options,
        #[\SensitiveParameter]
        array $probeOptions,
        #[\SensitiveParameter]
        ResponseInterface $response,
        string $username,
        #[\SensitiveParameter]
        string $password,
        bool $bodyWithheld
    )
    {
        $status = $response->getStatusCode();
        $previousEntry = null;
        if ($this->reuseChallenges && $status === 401) {
            $key = self::digestCacheKey($request);
            $previousEntry = $key !== null ? $this->digestChallenges[$key] ?? null : null;
            $this->clearDigestChallenge($request);
        }
        if ($status !== 401) {
            if ($this->reuseChallenges && $response->hasHeader('Authentication-Info')) {
                $this->clearDigestChallenge($request);
            }
            $response = self::restoreOriginalSink($request, $response, $probeOptions);
            if ($bodyWithheld && $status !== 407 && !self::isRedirectLikeResponse($response)) {
                throw new ResponseException('Digest authentication failed because the server did not issue a challenge; the request was probed without its body', $request, $response);
            }
            return $response;
        }
        $challenge = DigestAuth::selectChallenge($response);
        if ($challenge === null) {
            return self::restoreOriginalSink($request, $response, $probeOptions);
        }
        $retries = $options['__guzzle_digest_retries'] ?? 0;
        if (!\is_int($retries)) {
            $retries = 0;
        }
        if ($retries > 0 && !$challenge->stale || $retries >= self::DIGEST_MAX_RETRIES) {
            return self::restoreOriginalSink($request, $response, $probeOptions);
        }
        try {
            self::rewindBodyForRetry($request, $bodyWithheld);
        } catch (\Exception $e) {
            $response = self::restoreOriginalSink($request, $response, $probeOptions);
            throw new ResponseException('Digest authentication failed because the request body could not be rewound', $request, $response, $e);
        }
        $nonceCounts = self::digestNonceCountsFromOptions($options);
        if ($previousEntry !== null && $previousEntry['credentials'] === $this->digestCredentialKey($username, $password)) {
            self::rememberDigestNonceCount($nonceCounts, $previousEntry['challenge'], $previousEntry['nextNc']);
        }
        $nc = $challenge->qop === null ? 1 : $nonceCounts[self::digestNonceCountKey($challenge)] ?? 1;
        $authorization = DigestAuth::authorizationHeader($request, $challenge, $username, $password, ($this->cnonceGenerator)(), \sprintf('%08x', $nc));
        if ($authorization === null) {
            return self::restoreOriginalSink($request, $response, $probeOptions);
        }
        $response->getBody()->close();
        self::rememberDigestNonceCount($nonceCounts, $challenge, $nc + 1);
        $retryOptions = $options;
        $retryOptions['__guzzle_digest_retries'] = $retries + 1;
        $retryOptions[self::DIGEST_NONCE_COUNTS_OPTION] = $nonceCounts;
        $downstreamOptions = $retryOptions;
        // The nonce-count map only carries handshake state into the retry
        // recursion; downstream handlers must not observe it.
        unset($downstreamOptions[self::DIGEST_NONCE_COUNTS_OPTION]);
        // The caller's delay applies once, before the first Digest leg, not
        // before each handshake retry.
        unset($downstreamOptions[RequestOptions::DELAY]);
        $downstreamOptions = self::withTemporarySink($downstreamOptions);
        unset($downstreamOptions['auth']);
        $retryRequest = $request->withHeader('Authorization', $authorization);
        return ($this->nextHandler)($retryRequest, $downstreamOptions)->then(function (
            #[\SensitiveParameter]
            ResponseInterface $retryResponse
        ) use ($retryRequest, $retryOptions, $downstreamOptions, $username, $password, $challenge, $nc) {
            if ($this->reuseChallenges && self::isCacheableDigestSuccess($retryResponse)) {
                $this->storeDigestChallenge($retryRequest, $challenge, $username, $password, $nc + 1);
            }
            return $this->handleDigestResponse($retryRequest, $retryOptions, $downstreamOptions, $retryResponse, $username, $password, \false);
        }, function (
            #[\SensitiveParameter]
            $reason
        ) use ($retryRequest, $downstreamOptions) {
            return $this->handleDigestRejection($retryRequest, $downstreamOptions, $reason, \false);
        });
    }
    private static function isCacheableDigestSuccess(ResponseInterface $response): bool
    {
        $status = $response->getStatusCode();
        return $status >= 200 && $status < 400 && !$response->hasHeader('Authentication-Info');
    }
    private function storeDigestChallenge(
        #[\SensitiveParameter]
        RequestInterface $request,
        DigestChallenge $challenge,
        string $username,
        #[\SensitiveParameter]
        string $password,
        int $nextNc
    ): void
    {
        $key = self::digestCacheKey($request);
        if ($key === null || $challenge->qop === null) {
            return;
        }
        unset($this->digestChallenges[$key]);
        if (\count($this->digestChallenges) >= self::CHALLENGE_CACHE_LIMIT) {
            \array_shift($this->digestChallenges);
        }
        $this->digestChallenges[$key] = ['challenge' => $challenge, 'credentials' => $this->digestCredentialKey($username, $password), 'nextNc' => $nextNc];
    }
    private function clearDigestChallenge(RequestInterface $request): void
    {
        $key = self::digestCacheKey($request);
        if ($key !== null) {
            unset($this->digestChallenges[$key]);
        }
    }
    /**
     * @param array<array-key, mixed> $options
     *
     * @return array<string, int>
     */
    private static function digestNonceCountsFromOptions(array $options): array
    {
        $counts = $options[self::DIGEST_NONCE_COUNTS_OPTION] ?? [];
        if (!\is_array($counts)) {
            return [];
        }
        $normalized = [];
        foreach ($counts as $key => $nextNc) {
            if (\is_string($key) && \is_int($nextNc) && $nextNc > 0 && $nextNc < \PHP_INT_MAX) {
                $normalized[$key] = $nextNc;
            }
        }
        return $normalized;
    }
    private static function digestNonceCountKey(DigestChallenge $challenge): string
    {
        return \implode("\x00", [$challenge->realm, $challenge->nonce, $challenge->qop ?? '', $challenge->algorithm['header']]);
    }
    /**
     * @param array<string, int> $counts
     */
    private static function rememberDigestNonceCount(array &$counts, DigestChallenge $challenge, int $nextNc): void
    {
        if ($challenge->qop === null) {
            return;
        }
        $key = self::digestNonceCountKey($challenge);
        $counts[$key] = \max($counts[$key] ?? 1, $nextNc);
    }
    /**
     * @param mixed $reason
     *
     * @return PromiseInterface<ResponseInterface, mixed>
     */
    private function handleDigestRejection(
        #[\SensitiveParameter]
        RequestInterface $request,
        #[\SensitiveParameter]
        array $options,
        #[\SensitiveParameter]
        $reason,
        bool $preemptive
    ): PromiseInterface
    {
        if ($this->reuseChallenges && $reason instanceof ResponseException) {
            $response = $reason->getResponse();
            if ($response->getStatusCode() === 401 || $response->hasHeader('Authentication-Info') || $preemptive && $response->getStatusCode() >= 400) {
                $this->clearDigestChallenge($request);
            }
        }
        return self::restoreOriginalSinkOnRejection($options, $reason);
    }
    private static function digestCacheKey(RequestInterface $request): ?string
    {
        $uri = $request->getUri();
        $scheme = Psr7\Utils::asciiToLower($uri->getScheme());
        $host = HostIdentity::canonicalHost($uri->getHost());
        if ($scheme !== 'http' && $scheme !== 'https' || $host === '') {
            return null;
        }
        $port = $uri->getPort() ?? ($scheme === 'https' ? 443 : 80);
        return $scheme . '://' . $host . ':' . $port . '|' . HostIdentity::canonicalHostHeader($request->getHeaderLine('Host'));
    }
    private function digestCredentialKey(
        string $username,
        #[\SensitiveParameter]
        string $password
    ): string
    {
        if ($this->credentialHashSecret === null) {
            $this->credentialHashSecret = \random_bytes(32);
        }
        return \hash_hmac('sha256', $username . "\x00" . $password, $this->credentialHashSecret);
    }
    private static function challengeCoversRequest(DigestChallenge $challenge, RequestInterface $request): bool
    {
        if ($challenge->domain === []) {
            return \true;
        }
        $target = $request->getRequestTarget();
        foreach ($challenge->domain as $space) {
            $prefix = self::protectionSpacePath($space, $request);
            if ($prefix !== null && \str_starts_with($target, $prefix)) {
                return \true;
            }
        }
        return \false;
    }
    private static function protectionSpacePath(string $space, RequestInterface $request): ?string
    {
        if ($space === '') {
            return null;
        }
        if ($space[0] === '/') {
            return $space;
        }
        try {
            $uri = new Psr7\Uri($space);
        } catch (\InvalidArgumentException $e) {
            return null;
        }
        if (Psr7\UriComparator::isCrossOrigin($uri, self::effectiveRequestUri($request))) {
            return null;
        }
        $path = $uri->getPath();
        $prefix = $path === '' ? '/' : $path;
        if ($uri->getQuery() !== '') {
            $prefix .= '?' . $uri->getQuery();
        }
        return $prefix;
    }
    private static function effectiveRequestUri(RequestInterface $request): UriInterface
    {
        $uri = $request->getUri();
        $host = $request->getHeaderLine('Host');
        if ($host === '' || Psr7\Utils::caselessEquals($host, $uri->getHost())) {
            return $uri;
        }
        try {
            $authority = new Psr7\Uri('//' . $host);
            if ($authority->getHost() === '') {
                return $uri;
            }
            return $uri->withHost($authority->getHost())->withPort($authority->getPort());
        } catch (\InvalidArgumentException $e) {
            return $uri;
        }
    }
    private static function isRedirectLikeResponse(ResponseInterface $response): bool
    {
        return $response->hasHeader('Location') && \in_array($response->getStatusCode(), [301, 302, 303, 307, 308], \true);
    }
    private static function rewindBodyForRetry(
        #[\SensitiveParameter]
        RequestInterface $request,
        bool $bodyWithheld
    ): void
    {
        $body = $request->getBody();
        if (!$bodyWithheld) {
            try {
                if ($body->getSize() === 0) {
                    return;
                }
            } catch (\Exception $e) {
                // Fall through to the standard rewind path.
            }
            Psr7\Message::rewindBody($request);
            return;
        }
        if ($body->isSeekable()) {
            $body->rewind();
        }
    }
    private static function withTemporarySink(
        #[\SensitiveParameter]
        array $options
    ): array
    {
        // The 'stream' option is deliberately not checked here: the cURL
        // handlers do not support streaming and write each leg to the sink,
        // so a configured sink always needs challenge-body protection.
        if (!isset($options[RequestOptions::SINK])) {
            return $options;
        }
        $streamFactory = self::requireStreamFactory($options[RequestOptions::STREAM_FACTORY] ?? new HttpFactory());
        $options['__guzzle_auth_original_sink'] = $options[RequestOptions::SINK];
        $options[RequestOptions::SINK] = $streamFactory->createStreamFromResource(Psr7\Utils::tryFopen('php://temp', 'w+'));
        return $options;
    }
    private static function restoreOriginalSink(
        #[\SensitiveParameter]
        RequestInterface $request,
        #[\SensitiveParameter]
        ResponseInterface $response,
        #[\SensitiveParameter]
        array $options
    ): ResponseInterface
    {
        if (!\array_key_exists('__guzzle_auth_original_sink', $options)) {
            return $response;
        }
        try {
            $source = $response->getBody();
            if ($source->isSeekable()) {
                $source->rewind();
            }
            $target = self::streamForOriginalSink($options['__guzzle_auth_original_sink']);
            Psr7\Utils::copyToStream($source, $target);
            if ($target->isSeekable()) {
                $target->rewind();
            }
            return $response->withBody($target);
        } catch (\Exception $e) {
            throw new ResponseException($e->getMessage() !== '' ? $e->getMessage() : 'Failed to write the response body', $request, $response, $e);
        }
    }
    /**
     * @param mixed $reason
     *
     * @return PromiseInterface<ResponseInterface, mixed>
     */
    private static function restoreOriginalSinkOnRejection(
        #[\SensitiveParameter]
        array $options,
        #[\SensitiveParameter]
        $reason
    ): PromiseInterface
    {
        if (!$reason instanceof ResponseException || !\array_key_exists('__guzzle_auth_original_sink', $options)) {
            /** @var PromiseInterface<ResponseInterface, mixed> */
            return P\Create::rejectionFor($reason);
        }
        try {
            $response = self::restoreOriginalSink($reason->getRequest(), $reason->getResponse(), $options);
            /** @var PromiseInterface<ResponseInterface, mixed> */
            return P\Create::rejectionFor($reason->withResponse($response));
        } catch (\Throwable $e) {
            /** @var PromiseInterface<ResponseInterface, mixed> */
            return P\Create::rejectionFor($e);
        }
    }
    /**
     * @param mixed $sink
     */
    private static function streamForOriginalSink($sink): StreamInterface
    {
        if (\is_string($sink)) {
            return new LazyOpenStream($sink, 'w+');
        }
        if (\is_resource($sink)) {
            return self::streamForResourceSink(Psr7\Utils::streamFor($sink));
        }
        if (!$sink instanceof StreamInterface) {
            throw new InvalidArgumentException(\sprintf('sink must be a resource, string, or %s', StreamInterface::class));
        }
        return Psr7\Utils::streamFor($sink);
    }
    /**
     * Decorates a caller-owned sink stream so that closing the response body
     * detaches Guzzle's wrapper without closing the original PHP resource.
     */
    private static function streamForResourceSink(StreamInterface $stream): StreamInterface
    {
        return Psr7\FnStream::decorate($stream, ['close' => static function () use ($stream): void {
            $stream->detach();
        }]);
    }
    /**
     * @param mixed $factory
     */
    private static function requireStreamFactory($factory): StreamFactoryInterface
    {
        if (!$factory instanceof StreamFactoryInterface) {
            throw new InvalidArgumentException(\sprintf('%s must be an instance of %s', RequestOptions::STREAM_FACTORY, StreamFactoryInterface::class));
        }
        return $factory;
    }
}
