using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

namespace StellaOps.Auth.Client;

/// <summary>
/// Default implementation of <see cref="IStellaOpsTokenClient"/>.
/// </summary>
public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
{
    private static readonly MediaTypeHeaderValue JsonMediaType = new("application/json");

    private readonly HttpClient httpClient;
    private readonly StellaOpsDiscoveryCache discoveryCache;
    private readonly StellaOpsJwksCache jwksCache;
    private readonly IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor;
    private readonly IStellaOpsTokenCache tokenCache;
    private readonly TimeProvider timeProvider;
    private readonly ILogger<StellaOpsTokenClient>? logger;
    private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);

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

    public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(username);
        ArgumentException.ThrowIfNullOrWhiteSpace(password);

        var options = optionsMonitor.CurrentValue;

        var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["grant_type"] = "password",
            ["username"] = username,
            ["password"] = password,
            ["client_id"] = options.ClientId
        };

        if (!string.IsNullOrEmpty(options.ClientSecret))
        {
            parameters["client_secret"] = options.ClientSecret;
        }

        AppendScope(parameters, scope, options);

        return RequestTokenAsync(parameters, cancellationToken);
    }

    public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, CancellationToken cancellationToken = default)
    {
        var options = optionsMonitor.CurrentValue;
        if (string.IsNullOrWhiteSpace(options.ClientId))
        {
            throw new InvalidOperationException("Client credentials flow requires ClientId to be configured.");
        }

        var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["grant_type"] = "client_credentials",
            ["client_id"] = options.ClientId
        };

        if (!string.IsNullOrEmpty(options.ClientSecret))
        {
            parameters["client_secret"] = options.ClientSecret;
        }

        AppendScope(parameters, scope, options);

        return RequestTokenAsync(parameters, cancellationToken);
    }

    public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
        => jwksCache.GetAsync(cancellationToken);

    public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
        => tokenCache.GetAsync(key, cancellationToken);

    public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
        => tokenCache.SetAsync(key, entry, cancellationToken);

    public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
        => tokenCache.RemoveAsync(key, cancellationToken);

    private async Task<StellaOpsTokenResult> RequestTokenAsync(Dictionary<string, string> parameters, CancellationToken cancellationToken)
    {
        var options = optionsMonitor.CurrentValue;
        var configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false);

        using var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint)
        {
            Content = new FormUrlEncodedContent(parameters)
        };
        request.Headers.Accept.TryParseAdd(JsonMediaType.ToString());

        using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);

        var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);

        if (!response.IsSuccessStatusCode)
        {
            logger?.LogWarning("Token request failed with status {StatusCode}: {Payload}", response.StatusCode, payload);
            throw new InvalidOperationException($"Token request failed with status {(int)response.StatusCode}.");
        }

        var document = JsonSerializer.Deserialize<TokenResponseDocument>(payload, serializerOptions);
        if (document is null || string.IsNullOrWhiteSpace(document.AccessToken))
        {
            throw new InvalidOperationException("Token response did not contain an access_token.");
        }

        var expiresIn = document.ExpiresIn ?? 3600;
        var expiresAt = timeProvider.GetUtcNow() + TimeSpan.FromSeconds(expiresIn);
        var normalizedScopes = ParseScopes(document.Scope ?? parameters.GetValueOrDefault("scope"));

        var result = new StellaOpsTokenResult(
            document.AccessToken,
            document.TokenType ?? "Bearer",
            expiresAt,
            normalizedScopes,
            document.RefreshToken,
            document.IdToken,
            payload);

        logger?.LogDebug("Token issued; expires at {ExpiresAt}.", expiresAt);

        return result;
    }

    private static void AppendScope(IDictionary<string, string> parameters, string? scope, StellaOpsAuthClientOptions options)
    {
        var resolvedScope = scope;
        if (string.IsNullOrWhiteSpace(resolvedScope) && options.NormalizedScopes.Count > 0)
        {
            resolvedScope = string.Join(' ', options.NormalizedScopes);
        }

        if (!string.IsNullOrWhiteSpace(resolvedScope))
        {
            parameters["scope"] = resolvedScope;
        }
    }

    private static string[] ParseScopes(string? scope)
    {
        if (string.IsNullOrWhiteSpace(scope))
        {
            return Array.Empty<string>();
        }

        var parts = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        if (parts.Length == 0)
        {
            return Array.Empty<string>();
        }

        var unique = new HashSet<string>(parts.Length, StringComparer.Ordinal);
        foreach (var part in parts)
        {
            unique.Add(part);
        }

        var result = new string[unique.Count];
        unique.CopyTo(result);
        Array.Sort(result, StringComparer.Ordinal);
        return result;
    }

    private sealed record TokenResponseDocument(
        [property: JsonPropertyName("access_token")] string? AccessToken,
        [property: JsonPropertyName("refresh_token")] string? RefreshToken,
        [property: JsonPropertyName("id_token")] string? IdToken,
        [property: JsonPropertyName("token_type")] string? TokenType,
        [property: JsonPropertyName("expires_in")] int? ExpiresIn,
        [property: JsonPropertyName("scope")] string? Scope,
        [property: JsonPropertyName("error")] string? Error,
        [property: JsonPropertyName("error_description")] string? ErrorDescription);
}
