<?php

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

use Kazcode\WpStorage\Vendor\GuzzleHttp\Exception\InvalidArgumentException;
use Kazcode\WpStorage\Vendor\GuzzleHttp\HostIdentity;
use Kazcode\WpStorage\Vendor\GuzzleHttp\NonSerializableTrait;
use Kazcode\WpStorage\Vendor\GuzzleHttp\Psr7;
use Kazcode\WpStorage\Vendor\Openssl\Session;
/**
 * Per-handler in-memory TLS session resumption cache for the stream handler.
 *
 * Backed by the OpenSSL TLS session API added in PHP 8.6. A session is only
 * ever replayed for an identical TLS identity: the non-secret identity is
 * encoded in the lookup key and secret material is matched in constant time.
 * TLS resumption reuses the original certificate-chain verification result
 * instead of building and verifying the chain again. Guzzle includes ambient
 * trust configuration paths in the cache identity, but cannot detect
 * trust-store contents rewritten at an unchanged path. Recreate the handler
 * or disable transport sharing when trust changes must take effect
 * immediately.
 *
 * New sessions are held temporarily and committed only after the HTTPS stream
 * opens successfully. If PHP rejects peer verification or another stream-open
 * step fails, any session reported during that attempt is discarded.
 *
 * @internal
 */
final class StreamTlsSessionCache
{
    use NonSerializableTrait;
    /**
     * Sessions retained per peer. libcurl uses 2 to buffer single-use TLS 1.3
     * tickets; the synchronous stream handler does not need more.
     */
    public const MAX_SESSIONS_PER_KEY = 2;
    /**
     * TLS 1.3 tickets should not live longer than RFC 8446 allows. All
     * pre-TLS-1.3 sessions use libcurl's tighter one-day cap.
     */
    private const MAX_TLS13_LIFETIME = 604800;
    private const MAX_PRE_TLS13_LIFETIME = 86400;
    private const USER_MANAGED_SESSION_OPTIONS = ['session_cache' => \true, 'session_cache_size' => \true, 'session_data' => \true, 'session_get_cb' => \true, 'session_id_context' => \true, 'session_new_cb' => \true, 'session_remove_cb' => \true, 'session_stream' => \true, 'session_timeout' => \true];
    private const USER_MANAGED_PSK_OPTIONS = ['psk_client_cb' => \true, 'psk_server_cb' => \true];
    /**
     * TLS 1.3 early data (0-RTT) options are deliberately never shared: an
     * injected cached session would let PHP send the replayable early data
     * payload ahead of the HTTP request.
     */
    private const USER_MANAGED_EARLY_DATA_OPTIONS = ['early_data' => \true, 'early_data_cb' => \true, 'max_early_data' => \true];
    /**
     * File/path-bearing SSL context options are deliberately not shared: their
     * contents can change outside this handler.
     */
    private const PATH_OPTIONS = ['SNI_server_certs' => \true, 'cafile' => \true, 'capath' => \true, 'dh_param' => \true, 'local_cert' => \true, 'local_pk' => \true];
    private const CERT_CAPTURE_OPTIONS = ['capture_peer_cert' => \true, 'capture_peer_cert_chain' => \true];
    /**
     * Scalar custom SSL context options that are known to be self-contained
     * and safe to include in the TLS session cache identity.
     */
    private const CUSTOM_SCALAR_KEYABLE_OPTIONS = ['SNI_enabled' => \true, 'allow_self_signed' => \true, 'alpn_protocols' => \true, 'ciphers' => \true, 'crypto_method' => \true, 'disable_compression' => \true, 'max_proto_version' => \true, 'min_proto_version' => \true, 'peer_name' => \true, 'security_level' => \true, 'verify_depth' => \true, 'verify_peer' => \true, 'verify_peer_name' => \true];
    private const INVALID_PEER_FINGERPRINT_REASON = 'the SSL context option "peer_fingerprint" must be a string or a non-empty, flat array with string algorithm names and string fingerprints.';
    private int $maxKeys;
    /**
     * @var array<string, list<array{session: Session, credentials: string, expiresAt: int, singleUse: bool}>>
     */
    private array $sessions = [];
    public function __construct(int $maxKeys)
    {
        if ($maxKeys < 1) {
            throw new InvalidArgumentException('maxKeys must be a positive integer.');
        }
        $this->maxKeys = $maxKeys;
    }
    public static function isSupported(): bool
    {
        // Any PHP 8.6 build qualifies, including pre-release and nightly
        // builds; the class check keeps unstable builds that do not carry
        // the final API fail-closed.
        return \PHP_VERSION_ID >= 80600 && \class_exists(Session::class, \false);
    }
    /**
     * @param array $ssl       The assembled 'ssl' stream context array.
     * @param array $customSsl The user-supplied stream_context['ssl'] array.
     */
    public static function unsupportedContextReason(array $ssl, array $customSsl = []): ?string
    {
        foreach ($customSsl as $key => $value) {
            if (!isset(self::CUSTOM_SCALAR_KEYABLE_OPTIONS[$key]) && !isset(self::PATH_OPTIONS[$key]) && !isset(self::USER_MANAGED_SESSION_OPTIONS[$key]) && !isset(self::USER_MANAGED_PSK_OPTIONS[$key]) && !isset(self::USER_MANAGED_EARLY_DATA_OPTIONS[$key]) && !isset(self::CERT_CAPTURE_OPTIONS[$key]) && $key !== 'no_ticket' && $key !== 'peer_fingerprint') {
                return \sprintf('the custom SSL context option "%s" is not known to be safe for TLS session sharing.', Psr7\DiagnosticValue::escape((string) $key));
            }
        }
        foreach ($ssl as $key => $value) {
            if (isset(self::PATH_OPTIONS[$key])) {
                return \sprintf('the SSL context option "%s" uses file or path state that cannot be safely shared.', Psr7\DiagnosticValue::escape((string) $key));
            }
            if (isset(self::USER_MANAGED_SESSION_OPTIONS[$key])) {
                return \sprintf('the SSL context option "%s" is user-managed TLS session state.', Psr7\DiagnosticValue::escape((string) $key));
            }
            if (isset(self::USER_MANAGED_PSK_OPTIONS[$key])) {
                return \sprintf('the SSL context option "%s" is user-managed TLS PSK state.', Psr7\DiagnosticValue::escape((string) $key));
            }
            if (isset(self::USER_MANAGED_EARLY_DATA_OPTIONS[$key])) {
                return \sprintf('the SSL context option "%s" is user-managed TLS early data state.', Psr7\DiagnosticValue::escape((string) $key));
            }
            if (isset(self::CERT_CAPTURE_OPTIONS[$key]) && $value) {
                return \sprintf('the SSL context option "%s" requires a fresh peer certificate handshake.', Psr7\DiagnosticValue::escape((string) $key));
            }
            if ($key === 'no_ticket' && $value) {
                return 'the SSL context option "no_ticket" disables TLS ticket sharing.';
            }
            if ($key === 'peer_fingerprint') {
                if (!self::isPeerFingerprint($value)) {
                    return self::INVALID_PEER_FINGERPRINT_REASON;
                }
                continue;
            }
            if (!self::isCanonicalScalar($value)) {
                return \sprintf('the SSL context option "%s" cannot be safely included in the TLS session cache identity.', Psr7\DiagnosticValue::escape((string) $key));
            }
        }
        return null;
    }
    /**
     * Builds the non-secret lookup key: host/port plus every TLS parameter that
     * defines the verification/identity context. Keyed on the connection host
     * a transport reads, with numeric IPv4 spellings folded to one dotted
     * quad; names are never resolved. Secret material is excluded.
     *
     * @param array $ssl The assembled 'ssl' stream context array.
     */
    public static function peerKey(
        string $host,
        ?int $port,
        #[\SensitiveParameter]
        array $ssl
    ): string
    {
        $identity = ['schema' => 'guzzle-stream-tls-session-v1', 'runtime' => self::runtimeIdentity(), 'peer' => ['host' => self::canonicalHost($host), 'port' => $port], 'ssl' => self::canonicalPeerSslContext($ssl)];
        return \hash('sha256', \serialize($identity));
    }
    /**
     * Builds the secret-aware credential fingerprint matched in constant time,
     * keeping the passphrase out of the loggable peer key.
     *
     * Every credential-bearing context is currently rejected by
     * unsupportedContextReason() before sharing, so fingerprints can only
     * diverge if the allow-lists are ever widened; the constant-time match is
     * retained as defense in depth for that case.
     *
     * @param array $ssl The assembled 'ssl' stream context array.
     */
    public static function credentialFingerprint(array $ssl): string
    {
        $material = ['local_cert' => isset($ssl['local_cert']) && \is_string($ssl['local_cert']) ? self::pathIdentity($ssl['local_cert']) : null, 'local_pk' => isset($ssl['local_pk']) && \is_string($ssl['local_pk']) ? self::pathIdentity($ssl['local_pk']) : null, 'passphrase' => isset($ssl['passphrase']) && \is_string($ssl['passphrase']) ? $ssl['passphrase'] : null];
        return \hash('sha256', \serialize($material));
    }
    public function find(string $key, string $credentials): ?Session
    {
        if (!isset($this->sessions[$key])) {
            return null;
        }
        $now = \time();
        $entries = $this->sessions[$key];
        $found = null;
        foreach ($entries as $i => $entry) {
            if ($entry['expiresAt'] <= $now) {
                unset($entries[$i]);
                continue;
            }
            if (!\hash_equals($entry['credentials'], $credentials)) {
                continue;
            }
            if (!$entry['session']->isResumable()) {
                unset($entries[$i]);
                continue;
            }
            $found = $entry['session'];
            // TLS 1.3 tickets are single-use; consume on take.
            if ($entry['singleUse']) {
                unset($entries[$i]);
            }
            break;
        }
        if ($entries === []) {
            unset($this->sessions[$key]);
        } else {
            $this->sessions[$key] = \array_values($entries);
            if ($found !== null) {
                $this->touch($key);
            }
        }
        return $found;
    }
    public function store(
        string $key,
        string $credentials,
        #[\SensitiveParameter]
        Session $session
    ): void
    {
        if (!$session->isResumable()) {
            return;
        }
        $expiresAt = self::expiry($session);
        if ($expiresAt === null) {
            return;
        }
        $list = $this->sessions[$key] ?? [];
        $list[] = ['session' => $session, 'credentials' => $credentials, 'expiresAt' => $expiresAt, 'singleUse' => self::isTls13($session)];
        if (\count($list) > self::MAX_SESSIONS_PER_KEY) {
            $list = \array_slice($list, -self::MAX_SESSIONS_PER_KEY);
        }
        $this->sessions[$key] = $list;
        $this->touch($key);
        $this->evictExcessKeys();
    }
    private function touch(string $key): void
    {
        if (!isset($this->sessions[$key])) {
            return;
        }
        $value = $this->sessions[$key];
        unset($this->sessions[$key]);
        $this->sessions[$key] = $value;
    }
    private function evictExcessKeys(): void
    {
        while (\count($this->sessions) > $this->maxKeys) {
            \array_shift($this->sessions);
        }
    }
    private static function isTls13(
        #[\SensitiveParameter]
        Session $session
    ): bool
    {
        $protocol = $session->getProtocol();
        return $protocol !== null && Psr7\Utils::caselessContains($protocol, '1.3');
    }
    /**
     * Returns the absolute expiry timestamp, or null when the session must
     * not be cached.
     */
    private static function expiry(
        #[\SensitiveParameter]
        Session $session
    ): ?int
    {
        return self::expiryFromLifetimes(self::isTls13($session), $session->hasTicket(), $session->getTicketLifetimeHint(), $session->getTimeout(), $session->getCreatedAt(), \time());
    }
    /**
     * Decides the absolute expiry timestamp from a session's scalar lifetime
     * attributes, or null when the session must not be cached.
     *
     * A non-positive session timeout is never cached. TLS 1.3 resumption
     * requires a New Session Ticket, so a TLS 1.3 session without a ticket
     * or without a positive RFC 8446 ticket lifetime is not cached, and the
     * ticket lifetime bounds the expiry together with the session timeout
     * and the seven-day cap. Pre-TLS-1.3 tickets treat a zero or missing
     * lifetime hint as unspecified per RFC 5077, so only a positive hint
     * tightens the session timeout and the one-day cap. An expiry that has
     * already passed is not cached.
     */
    private static function expiryFromLifetimes(bool $isTls13, bool $hasTicket, ?int $ticketLifetimeHint, int $timeout, int $createdAt, int $now): ?int
    {
        if ($timeout <= 0) {
            return null;
        }
        $lifetime = \min($timeout, $isTls13 ? self::MAX_TLS13_LIFETIME : self::MAX_PRE_TLS13_LIFETIME);
        if ($isTls13) {
            if (!$hasTicket || $ticketLifetimeHint === null || $ticketLifetimeHint <= 0) {
                return null;
            }
            $lifetime = \min($lifetime, $ticketLifetimeHint);
        } elseif ($hasTicket && $ticketLifetimeHint !== null && $ticketLifetimeHint > 0) {
            $lifetime = \min($lifetime, $ticketLifetimeHint);
        }
        $expiresAt = $createdAt + $lifetime;
        return $expiresAt > $now ? $expiresAt : null;
    }
    private static function runtimeIdentity(): array
    {
        return ['php' => \PHP_VERSION_ID, 'openssl' => \defined('OPENSSL_VERSION_NUMBER') ? \OPENSSL_VERSION_NUMBER : null, 'trust' => ['openssl.cafile' => (string) \ini_get('openssl.cafile'), 'openssl.capath' => (string) \ini_get('openssl.capath'), 'SSL_CERT_FILE' => (string) \getenv('SSL_CERT_FILE'), 'SSL_CERT_DIR' => (string) \getenv('SSL_CERT_DIR'), 'cwd' => \getcwd()]];
    }
    /**
     * Returns the host identity hashed into the cache key. Valid bracketed
     * IPv6 literals are canonicalized to their RFC 5952 form so equivalent
     * spellings of one address share a single session entry; other valid
     * hosts, such as reg-names and IPvFuture literals, fall back to ASCII
     * case folding. Text that is not a valid RFC 3986 host, such as
     * zone-bearing or malformed bracketed literals, must never become a
     * usable cache key, so it is rejected.
     */
    private static function canonicalHost(string $host): string
    {
        if (!Psr7\Rfc3986::isValidHost($host)) {
            throw new InvalidArgumentException('Hosts used in the TLS session cache identity must be valid RFC 3986 hosts.');
        }
        return HostIdentity::canonicalHost($host);
    }
    private static function canonicalPeerSslContext(
        #[\SensitiveParameter]
        array $ssl
    ): array
    {
        $context = [];
        foreach ($ssl as $key => $value) {
            if (isset(self::USER_MANAGED_SESSION_OPTIONS[$key]) || isset(self::USER_MANAGED_PSK_OPTIONS[$key]) || isset(self::USER_MANAGED_EARLY_DATA_OPTIONS[$key]) || $key === 'passphrase') {
                continue;
            }
            if ($key === 'peer_name' && \is_string($value)) {
                $context[$key] = ['string', self::canonicalHost($value)];
                continue;
            }
            if ($key === 'peer_fingerprint') {
                $context[$key] = self::canonicalPeerFingerprint($value);
                continue;
            }
            if (\in_array($key, ['cafile', 'capath', 'local_cert', 'local_pk'], \true) && \is_string($value)) {
                $context[$key] = self::pathIdentity($value);
                continue;
            }
            $context[$key] = self::canonicalScalar($value);
        }
        \ksort($context);
        return $context;
    }
    private static function pathIdentity(string $path): array
    {
        $realPath = \realpath($path);
        return ['path', $realPath !== \false ? $realPath : $path];
    }
    /**
     * @param mixed $value
     */
    private static function isCanonicalScalar($value): bool
    {
        return $value === null || \is_bool($value) || \is_int($value) || \is_float($value) || \is_string($value);
    }
    /**
     * @param mixed $value
     */
    private static function isPeerFingerprint($value): bool
    {
        if (\is_string($value)) {
            return \true;
        }
        if (!\is_array($value) || $value === []) {
            return \false;
        }
        foreach ($value as $algorithm => $fingerprint) {
            if (!\is_string($algorithm) || !\is_string($fingerprint)) {
                return \false;
            }
        }
        return \true;
    }
    /**
     * @param mixed $value
     *
     * @return array{0: string, 1: mixed}
     */
    private static function canonicalScalar($value): array
    {
        if ($value === null) {
            return ['null', null];
        }
        if (\is_bool($value)) {
            return ['bool', $value];
        }
        if (\is_int($value)) {
            return ['int', $value];
        }
        if (\is_float($value)) {
            return ['float', \bin2hex(\pack('E', $value))];
        }
        if (\is_string($value)) {
            return ['string', $value];
        }
        throw new InvalidArgumentException('SSL context values used in the TLS session cache identity must be scalar or null.');
    }
    /**
     * @param mixed $value
     *
     * @return array{0: string, 1: mixed}
     */
    private static function canonicalPeerFingerprint($value): array
    {
        if (!self::isPeerFingerprint($value)) {
            throw new InvalidArgumentException(self::INVALID_PEER_FINGERPRINT_REASON);
        }
        if (\is_string($value)) {
            return ['string', $value];
        }
        if (!\is_array($value)) {
            throw new InvalidArgumentException(self::INVALID_PEER_FINGERPRINT_REASON);
        }
        $items = [];
        foreach ($value as $algorithm => $fingerprint) {
            $items[$algorithm] = ['string', $fingerprint];
        }
        \ksort($items, \SORT_STRING);
        return ['map', $items];
    }
}
