using System;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace StellaOps.Auth.Client;

/// <summary>
/// Caches Authority discovery metadata.
/// </summary>
public sealed class StellaOpsDiscoveryCache
{
    private readonly HttpClient httpClient;
    private readonly IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor;
    private readonly TimeProvider timeProvider;
    private readonly ILogger<StellaOpsDiscoveryCache>? logger;
    private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);

    private OpenIdConfiguration? cachedConfiguration;
    private DateTimeOffset cacheExpiresAt;
    private DateTimeOffset offlineExpiresAt;

    public StellaOpsDiscoveryCache(HttpClient httpClient, IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor, TimeProvider? timeProvider = null, ILogger<StellaOpsDiscoveryCache>? logger = null)
    {
        this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
        this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
        this.timeProvider = timeProvider ?? TimeProvider.System;
        this.logger = logger;
    }

    public async Task<OpenIdConfiguration> GetAsync(CancellationToken cancellationToken)
    {
        var now = timeProvider.GetUtcNow();

        if (cachedConfiguration is not null && now < cacheExpiresAt)
        {
            return cachedConfiguration;
        }

        var options = optionsMonitor.CurrentValue;
        var discoveryUri = new Uri(options.AuthorityUri, ".well-known/openid-configuration");

        logger?.LogDebug("Fetching StellaOps discovery document from {DiscoveryUri}.", discoveryUri);

        try
        {
            using var request = new HttpRequestMessage(HttpMethod.Get, discoveryUri);
            using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
            response.EnsureSuccessStatusCode();

            await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
            var document = await JsonSerializer.DeserializeAsync<DiscoveryDocument>(stream, serializerOptions, cancellationToken).ConfigureAwait(false);

            if (document is null)
            {
                throw new InvalidOperationException("Authority discovery document is empty.");
            }

            if (string.IsNullOrWhiteSpace(document.TokenEndpoint))
            {
                throw new InvalidOperationException("Authority discovery document does not expose token_endpoint.");
            }

            if (string.IsNullOrWhiteSpace(document.JwksUri))
            {
                throw new InvalidOperationException("Authority discovery document does not expose jwks_uri.");
            }

            var configuration = new OpenIdConfiguration(
                new Uri(document.TokenEndpoint, UriKind.Absolute),
                new Uri(document.JwksUri, UriKind.Absolute));

            cachedConfiguration = configuration;
            cacheExpiresAt = now + options.DiscoveryCacheLifetime;
            offlineExpiresAt = cacheExpiresAt + options.OfflineCacheTolerance;

            return configuration;
        }
        catch (Exception exception) when (IsOfflineCandidate(exception, cancellationToken) && TryUseOfflineFallback(options, now, exception))
        {
            return cachedConfiguration!;
        }
    }

    private sealed record DiscoveryDocument(
        [property: System.Text.Json.Serialization.JsonPropertyName("token_endpoint")] string? TokenEndpoint,
        [property: System.Text.Json.Serialization.JsonPropertyName("jwks_uri")] string? JwksUri);

    private static bool IsOfflineCandidate(Exception exception, CancellationToken cancellationToken)
    {
        if (exception is HttpRequestException)
        {
            return true;
        }

        if (exception is TaskCanceledException && !cancellationToken.IsCancellationRequested)
        {
            return true;
        }

        if (exception is TimeoutException)
        {
            return true;
        }

        return false;
    }

    private bool TryUseOfflineFallback(StellaOpsAuthClientOptions options, DateTimeOffset now, Exception exception)
    {
        if (!options.AllowOfflineCacheFallback || cachedConfiguration is null)
        {
            return false;
        }

        if (options.OfflineCacheTolerance <= TimeSpan.Zero)
        {
            return false;
        }

        if (offlineExpiresAt == DateTimeOffset.MinValue)
        {
            return false;
        }

        if (now >= offlineExpiresAt)
        {
            return false;
        }

        logger?.LogWarning(exception, "Discovery document fetch failed; reusing cached configuration until {FallbackExpiresAt}.", offlineExpiresAt);
        return true;
    }
}

/// <summary>
/// Minimal OpenID Connect configuration representation.
/// </summary>
public sealed record OpenIdConfiguration(Uri TokenEndpoint, Uri JwksEndpoint);
