using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;

namespace ZB.MOM.WW.ScadaBridge.NotificationService;

/// <summary>
/// OAuth2 Client Credentials token lifecycle — fetch, cache, refresh on expiry.
/// Used for Microsoft 365 SMTP authentication.
/// Tokens are cached per credential identity (tenant/client/secret), so a
/// second SMTP configuration with different credentials never receives the first
/// configuration's token.
/// </summary>
public class OAuth2TokenService
{
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly ILogger<OAuth2TokenService> _logger;

    // Cache keyed by a hash of the credential string. Each distinct
    // tenant/client/secret triple gets its own cached token and its own lock.
    private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();

    /// <summary>Initializes the service with an HTTP client factory and logger.</summary>
    /// <param name="httpClientFactory">Factory used to create HTTP clients for token endpoint requests.</param>
    /// <param name="logger">Logger instance.</param>
    public OAuth2TokenService(
        IHttpClientFactory httpClientFactory,
        ILogger<OAuth2TokenService> logger)
    {
        _httpClientFactory = httpClientFactory;
        _logger = logger;
    }

    /// <summary>
    /// Gets a valid access token, refreshing if expired.
    /// Credentials format: "tenantId:clientId:clientSecret"
    /// </summary>
    /// <param name="credentials">Colon-separated string in the form <c>tenantId:clientId:clientSecret</c>.</param>
    /// <param name="authority">
    /// Optional token-endpoint authority template (may contain a <c>{tenant}</c> placeholder);
    /// null preserves the Microsoft 365 default endpoint.
    /// </param>
    /// <param name="scope">Optional OAuth2 scope; null preserves the Microsoft 365 default scope.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>A valid OAuth2 access token string.</returns>
    public async Task<string> GetTokenAsync(
        string credentials,
        string? authority = null,
        string? scope = null,
        CancellationToken cancellationToken = default)
    {
        // Cache key incorporates authority + scope so two configurations with the same
        // credentials but different endpoints/scopes never share a token.
        var key = CredentialKey($"{credentials}|{authority}|{scope}");
        var entry = _cache.GetOrAdd(key, _ => new CacheEntry());

        // Read the token/expiry pair through a single volatile reference so a lock-free
        // reader can never observe a torn token/expiry combination (arch-review S8).
        var current = entry.Current;
        if (current != null && DateTimeOffset.UtcNow < current.Expiry)
        {
            return current.Token;
        }

        await entry.Lock.WaitAsync(cancellationToken);
        try
        {
            // Double-check after acquiring the per-credential lock.
            current = entry.Current;
            if (current != null && DateTimeOffset.UtcNow < current.Expiry)
            {
                return current.Token;
            }

            var parts = credentials.Split(':', 3);
            if (parts.Length < 3)
            {
                throw new InvalidOperationException("OAuth2 credentials must be 'tenantId:clientId:clientSecret'");
            }

            var tenantId = parts[0];
            var clientId = parts[1];
            var clientSecret = parts[2];

            var client = _httpClientFactory.CreateClient("OAuth2");

            // Authority: null → today's hardcoded Microsoft 365 endpoint; otherwise the stored
            // template with an optional {tenant} placeholder resolved from the credential.
            var tokenUrl = authority == null
                ? $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"
                : authority.Replace("{tenant}", tenantId);

            // Scope: null → the Microsoft 365 default.
            var tokenScope = scope ?? "https://outlook.office365.com/.default";

            var form = new FormUrlEncodedContent(new Dictionary<string, string>
            {
                ["grant_type"] = "client_credentials",
                ["client_id"] = clientId,
                ["client_secret"] = clientSecret,
                ["scope"] = tokenScope
            });

            var response = await client.PostAsync(tokenUrl, form, cancellationToken);
            response.EnsureSuccessStatusCode();

            var json = await response.Content.ReadAsStringAsync(cancellationToken);
            using var doc = JsonDocument.Parse(json);

            var token = doc.RootElement.GetProperty("access_token").GetString()
                ?? throw new InvalidOperationException("No access_token in OAuth2 response");

            var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32();
            // Publish the token/expiry pair as a single atomic reference assignment
            // (Refresh 60s before expiry).
            entry.Current = new TokenValue(
                token, DateTimeOffset.UtcNow.AddSeconds(expiresIn - 60));

            // The token endpoint identity is logged by tenant only — never
            // the client secret or the access token itself.
            _logger.LogInformation(
                "OAuth2 token refreshed for tenant {Tenant}, expires in {ExpiresIn}s", tenantId, expiresIn);
            return token;
        }
        finally
        {
            entry.Lock.Release();
        }
    }

    /// <summary>
    /// A stable, non-reversible key for the credential string so the cache
    /// is partitioned by credential identity without holding the secret as a key.
    /// </summary>
    private static string CredentialKey(string credentials)
    {
        var hash = SHA256.HashData(Encoding.UTF8.GetBytes(credentials));
        return Convert.ToHexString(hash);
    }

    /// <summary>An atomically-published token/expiry pair — one reference, never torn.</summary>
    private sealed record TokenValue(string Token, DateTimeOffset Expiry);

    private sealed class CacheEntry
    {
        // Written only under Lock; read lock-free via the volatile reference so a reader
        // always sees a self-consistent token/expiry pair (or null for "no token yet").
        public volatile TokenValue? Current;
        public readonly SemaphoreSlim Lock = new(1, 1);
    }
}
