using CalledIt.Domain.Services;
using Npgsql;

namespace CalledIt.Api.Authentication;

// Subscribes to Postgres NOTIFY messages on the revoked-tokens channel so a
// logout on one backend replica propagates into every other replica's
// in-memory revocation cache. Without this, a JWT revoked on backend-1 would
// remain usable on backend-2 until backend-2 restarts or the token expires.
public sealed class TokenRevocationListener : BackgroundService
{
    private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(5);

    private readonly IConfiguration _configuration;
    private readonly TokenRevocationService _service;
    private readonly ILogger<TokenRevocationListener> _logger;

    public TokenRevocationListener(
        IConfiguration configuration,
        TokenRevocationService service,
        ILogger<TokenRevocationListener> logger)
    {
        _configuration = configuration;
        _service = service;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await ListenLoopAsync(stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                return;
            }
#pragma warning disable CA1031 // we want to retry on any failure mode
            catch (Exception ex)
#pragma warning restore CA1031
            {
                _logger.LogWarning(
                    ex,
                    "Revocation listener disconnected; reconnecting in {Delay}.",
                    ReconnectDelay);
                try
                {
                    await Task.Delay(ReconnectDelay, stoppingToken);
                }
                catch (OperationCanceledException)
                {
                    return;
                }
            }
        }
    }

    private async Task ListenLoopAsync(CancellationToken stoppingToken)
    {
        var connectionString = _configuration.GetConnectionString("DefaultConnection")
            ?? throw new InvalidOperationException(
                "ConnectionStrings:DefaultConnection is not configured.");

        await using var connection = new NpgsqlConnection(connectionString);
        await connection.OpenAsync(stoppingToken);

        connection.Notification += OnNotification;

        await using (var cmd = new NpgsqlCommand(
            $"LISTEN {TokenRevocationService.NotificationChannel}", connection))
        {
            await cmd.ExecuteNonQueryAsync(stoppingToken);
        }

        // Re-hydrate the cache after LISTEN is attached: this catches any
        // revocations committed between the synchronous startup load (run by
        // TokenRevocationCacheLoader) and the moment we begin receiving NOTIFYs.
        await _service.LoadFromDatabaseAsync(stoppingToken);

        _logger.LogInformation(
            "Listening on Postgres channel '{Channel}' for token revocations.",
            TokenRevocationService.NotificationChannel);

        while (!stoppingToken.IsCancellationRequested)
        {
            await connection.WaitAsync(stoppingToken);
        }
    }

    private void OnNotification(object? sender, NpgsqlNotificationEventArgs args)
    {
        if (!string.Equals(
                args.Channel,
                TokenRevocationService.NotificationChannel,
                StringComparison.Ordinal))
        {
            return;
        }

        var payload = args.Payload;
        var separator = payload.IndexOf(':');
        if (separator <= 0 || separator == payload.Length - 1)
        {
            _logger.LogWarning(
                "Ignoring malformed revocation notification payload '{Payload}'.",
                payload);
            return;
        }

        var jti = payload[..separator];
        if (!long.TryParse(payload[(separator + 1)..], out var unixSeconds))
        {
            _logger.LogWarning(
                "Ignoring revocation notification with non-numeric expiry '{Payload}'.",
                payload);
            return;
        }

        var expiresAtUtc = DateTimeOffset.FromUnixTimeSeconds(unixSeconds).UtcDateTime;
        _service.AddToCache(jti, expiresAtUtc);
    }
}
