<?php
declare(strict_types=1);

/**
 * Exchanges an authorization code for tokens at the provider's token endpoint
 * and validates the resulting ID token. The validation performs the seven
 * checks RFC 6749 + OIDC Core mandate:
 *   1. Cryptographic signature against the provider's JWKS (RS256 only).
 *   2. iss matches the discovery document's issuer.
 *   3. aud contains our client_id.
 *   4. exp is in the future.
 *   5. iat is recent (≤ 10 minutes old to defeat replay of stolen tokens).
 *   6. nonce equals the one we generated at /auth/oidc/start.
 *   7. azp (authorized party), if present in multi-aud tokens, is our client_id.
 */
class OidcAuthAdapter
{
    private const HTTP_TIMEOUT = 10;
    private const IAT_MAX_AGE  = 600;   // 10 minutes

    /**
     * Exchange code → ID token claims (validated). Returns null on any failure.
     * Reasons are written to error_log for the operator and the calling
     * controller writes a SecurityLog event with masked email.
     */
    public static function exchangeAndVerify(
        array $provider,
        string $code,
        string $codeVerifier,
        string $expectedNonce
    ): ?array {
        $disc = OidcDiscovery::get($provider['id'], $provider['discovery_url']);
        if ($disc === null) {
            error_log('[Plenvo OIDC] discovery fetch failed: ' . $provider['discovery_url']);
            return null;
        }
        $tokenUrl = (string)($disc['token_endpoint'] ?? '');
        $jwksUrl  = (string)($disc['jwks_uri']       ?? '');
        $issuer   = (string)($disc['issuer']         ?? '');
        if ($tokenUrl === '' || $jwksUrl === '' || $issuer === '') {
            error_log('[Plenvo OIDC] discovery doc incomplete for ' . $provider['id']);
            return null;
        }

        $token = self::tokenRequest($tokenUrl, [
            'grant_type'    => 'authorization_code',
            'code'          => $code,
            'redirect_uri'  => OidcConfig::redirectUri(),
            'client_id'     => (string)($provider['client_id'] ?? ''),
            'client_secret' => OidcConfig::clientSecret($provider),
            'code_verifier' => $codeVerifier,
        ]);
        if ($token === null) return null;

        $idToken = (string)($token['id_token'] ?? '');
        if ($idToken === '') return null;

        $jwks = OidcDiscovery::jwks($provider['id'], $jwksUrl);
        if ($jwks === null) return null;

        $claims = OidcJwt::verify($idToken, $jwks);
        if ($claims === null) {
            // One retry with a fresh JWKS in case the keys rotated since the
            // last cache fill. Belt-and-braces: if it still fails, give up.
            OidcDiscovery::forceRefreshJwks($provider['id']);
            $jwks   = OidcDiscovery::jwks($provider['id'], $jwksUrl);
            $claims = $jwks ? OidcJwt::verify($idToken, $jwks) : null;
            if ($claims === null) {
                error_log('[Plenvo OIDC] id-token signature verify failed for ' . $provider['id']);
                return null;
            }
        }

        if (!self::validateClaims($claims, $issuer, (string)($provider['client_id'] ?? ''), $expectedNonce)) {
            return null;
        }
        return $claims;
    }

    private static function tokenRequest(string $url, array $params): ?array
    {
        // Token endpoint must be HTTPS; refuse anything else outright. The
        // discovery document is operator-trusted but a compromised provider
        // (or a manipulated discovery doc) could otherwise downgrade us to
        // cleartext and exfiltrate client secret + ID token.
        if (!self::isHttpsOrLoopback($url)) {
            error_log('[Plenvo OIDC] refused non-HTTPS token endpoint: ' . $url);
            return null;
        }
        $body = http_build_query($params);

        if (function_exists('curl_init')) {
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_FOLLOWLOCATION => false,
                CURLOPT_PROTOCOLS      => CURLPROTO_HTTPS | CURLPROTO_HTTP,
                CURLOPT_TIMEOUT        => self::HTTP_TIMEOUT,
                CURLOPT_CONNECTTIMEOUT => 5,
                CURLOPT_POST           => true,
                CURLOPT_POSTFIELDS     => $body,
                CURLOPT_HTTPHEADER     => [
                    'Accept: application/json',
                    'Content-Type: application/x-www-form-urlencoded',
                ],
                CURLOPT_USERAGENT      => 'Plenvo-OIDC/1.0',
            ]);
            $resp = curl_exec($ch);
            $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);
            if ($resp === false || $code < 200 || $code >= 300) return null;
        } else {
            $ctx  = stream_context_create(['http' => [
                'method'        => 'POST',
                'timeout'       => self::HTTP_TIMEOUT,
                'header'        => "Content-Type: application/x-www-form-urlencoded\r\nAccept: application/json\r\nUser-Agent: Plenvo-OIDC/1.0\r\n",
                'content'       => $body,
                'ignore_errors' => true,
            ]]);
            $resp = @file_get_contents($url, false, $ctx);
            if ($resp === false) return null;
        }
        $data = json_decode($resp, true);
        return is_array($data) ? $data : null;
    }

    /**
     * Allow HTTPS to anywhere, plain HTTP only against the loopback names —
     * that lets local Keycloak setups in dev work without disabling TLS
     * verification globally.
     */
    public static function isHttpsOrLoopback(string $url): bool
    {
        $scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME));
        if ($scheme === 'https') return true;
        if ($scheme !== 'http')  return false;
        $host = strtolower((string)parse_url($url, PHP_URL_HOST));
        return in_array($host, ['localhost', '127.0.0.1', '::1'], true);
    }

    private static function validateClaims(array $claims, string $issuer, string $clientId, string $expectedNonce): bool
    {
        // iss
        if (($claims['iss'] ?? '') !== $issuer) {
            error_log('[Plenvo OIDC] iss mismatch: got ' . ($claims['iss'] ?? '') . ' expected ' . $issuer);
            return false;
        }
        // aud
        $aud = $claims['aud'] ?? '';
        $audOk = is_string($aud) ? ($aud === $clientId) : (is_array($aud) && in_array($clientId, $aud, true));
        if (!$audOk) {
            error_log('[Plenvo OIDC] aud does not contain client_id');
            return false;
        }
        // azp (RFC 7519 Section 4.1.6 / OIDC Core §2): when multiple audiences,
        // the authorized party must be the client.
        if (is_array($aud) && count($aud) > 1 && ($claims['azp'] ?? '') !== $clientId) {
            error_log('[Plenvo OIDC] multi-aud token without correct azp');
            return false;
        }
        // exp / iat / nbf
        $now = time();
        if (!isset($claims['exp']) || $claims['exp'] < $now) {
            error_log('[Plenvo OIDC] id-token expired');
            return false;
        }
        if (isset($claims['iat']) && ($now - (int)$claims['iat']) > self::IAT_MAX_AGE) {
            error_log('[Plenvo OIDC] id-token iat too old');
            return false;
        }
        // nbf is optional in OIDC, but if present we honour it. 30s clock-skew tolerance.
        if (isset($claims['nbf']) && (int)$claims['nbf'] > ($now + 30)) {
            error_log('[Plenvo OIDC] id-token nbf in future');
            return false;
        }
        // nonce
        if (($claims['nonce'] ?? '') !== $expectedNonce) {
            error_log('[Plenvo OIDC] nonce mismatch');
            return false;
        }
        return true;
    }
}
