<?php
declare(strict_types=1);

/**
 * Fetches and caches OIDC discovery documents and JWKS keys.
 *
 * Cache strategy: settings table per provider, JSON-encoded with a 'fetched_at'
 * timestamp. Discovery document caches for 24 h, JWKS caches for 1 h. A
 * signature-verify failure should call `forceRefreshJwks()` once to handle key
 * rotations.
 */
class OidcDiscovery
{
    private const DISCOVERY_TTL = 86400;   // 24 h
    private const JWKS_TTL      = 3600;    // 1 h
    private const HTTP_TIMEOUT  = 8;

    /** Returns the cached discovery document or fetches it fresh. */
    public static function get(string $providerId, string $discoveryUrl): ?array
    {
        $key = 'oidc_discovery_' . $providerId;
        $row = Helper::getSetting($key, '');
        if ($row !== '') {
            $cached = json_decode((string)$row, true);
            if (is_array($cached)
                && isset($cached['fetched_at'], $cached['doc'])
                && (time() - (int)$cached['fetched_at']) < self::DISCOVERY_TTL
            ) {
                return $cached['doc'];
            }
        }
        $doc = self::fetchJson($discoveryUrl);
        if ($doc === null) {
            // Fall back to stale cache if available — still better than failing
            // mid-login when the provider is briefly slow.
            if (!empty($cached['doc']) && is_array($cached['doc'])) return $cached['doc'];
            return null;
        }
        Helper::setSetting($key, json_encode(['fetched_at' => time(), 'doc' => $doc]));
        return $doc;
    }

    /** Returns the cached JWKS or fetches fresh. */
    public static function jwks(string $providerId, string $jwksUrl): ?array
    {
        $key = 'oidc_jwks_' . $providerId;
        $row = Helper::getSetting($key, '');
        if ($row !== '') {
            $cached = json_decode((string)$row, true);
            if (is_array($cached)
                && isset($cached['fetched_at'], $cached['jwks'])
                && (time() - (int)$cached['fetched_at']) < self::JWKS_TTL
            ) {
                return $cached['jwks'];
            }
        }
        $jwks = self::fetchJson($jwksUrl);
        if ($jwks === null) {
            if (!empty($cached['jwks']) && is_array($cached['jwks'])) return $cached['jwks'];
            return null;
        }
        Helper::setSetting($key, json_encode(['fetched_at' => time(), 'jwks' => $jwks]));
        return $jwks;
    }

    /**
     * Drop the JWKS cache for a provider. Called after a signature failure so
     * the next verify pulls fresh keys (handles key-rotation events).
     */
    public static function forceRefreshJwks(string $providerId): void
    {
        Helper::setSetting('oidc_jwks_' . $providerId, '');
    }

    /** Wipe both caches for a provider (e.g. after settings change). */
    public static function clearCache(string $providerId): void
    {
        Helper::setSetting('oidc_discovery_' . $providerId, '');
        Helper::setSetting('oidc_jwks_'      . $providerId, '');
    }

    /**
     * Defensive HTTPS fetch. cURL preferred; falls back to stream context.
     * No SSRF guard here because discovery URLs are admin-controlled (not
     * user-supplied), and operators legitimately point at internal Keycloak
     * instances on private networks.
     */
    private static function fetchJson(string $url): ?array
    {
        // Same HTTPS-or-loopback policy as the token endpoint — discovery
        // and JWKS URLs traverse the same threat model.
        if (!OidcAuthAdapter::isHttpsOrLoopback($url)) {
            error_log('[Plenvo OIDC] refused non-HTTPS endpoint: ' . $url);
            return null;
        }
        $body = null;

        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_HTTPHEADER     => ['Accept: application/json'],
                CURLOPT_USERAGENT      => 'Plenvo-OIDC/1.0',
            ]);
            $body = curl_exec($ch);
            $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);
            if ($body === false || $code !== 200) $body = null;
        } else {
            $ctx  = stream_context_create(['http' => [
                'method'        => 'GET',
                'timeout'       => self::HTTP_TIMEOUT,
                'header'        => "Accept: application/json\r\nUser-Agent: Plenvo-OIDC/1.0\r\n",
                'ignore_errors' => true,
            ]]);
            $body = @file_get_contents($url, false, $ctx);
            if ($body === false) $body = null;
        }

        if ($body === null) return null;
        $data = json_decode($body, true);
        return is_array($data) ? $data : null;
    }
}
