using System.Collections.Concurrent;
using CalledIt.DataAccess;
using CalledIt.DataAccess.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace CalledIt.Domain.Services;

public class TokenRevocationService : ITokenRevocationService
{
    public const string NotificationChannel = "revoked_tokens";

    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ConcurrentDictionary<string, DateTime> _revoked = new();

    public TokenRevocationService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    // Used by the cross-replica listener to push revocations seen by another
    // replica into this replica's cache without re-writing them to the DB.
    public void AddToCache(string jti, DateTime expiresAtUtc)
    {
        if (expiresAtUtc <= DateTime.UtcNow)
        {
            return;
        }

        _revoked[jti] = expiresAtUtc;
    }

    public async Task LoadFromDatabaseAsync(CancellationToken cancellationToken)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<CalledItDbContext>();

        var now = DateTime.UtcNow;
        var entries = await db.RevokedTokens
            .AsNoTracking()
            .Where(r => r.ExpiresAtUtc >= now)
            .Select(r => new { r.Jti, r.ExpiresAtUtc })
            .ToListAsync(cancellationToken);

        foreach (var e in entries)
        {
            _revoked[e.Jti] = e.ExpiresAtUtc;
        }
    }

    public async Task RevokeAsync(string jti, DateTime expiresAtUtc)
    {
        _revoked[jti] = expiresAtUtc;

        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<CalledItDbContext>();

        db.RevokedTokens.Add(new RevokedTokenEntity
        {
            Jti = jti,
            ExpiresAtUtc = expiresAtUtc
        });

        var now = DateTime.UtcNow;
        await db.RevokedTokens
            .Where(r => r.ExpiresAtUtc < now)
            .ExecuteDeleteAsync();

        await db.SaveChangesAsync();

        // Tell every other replica's listener about this revocation so their
        // in-memory caches stay current without waiting for token expiry or restart.
        var unixExpiry = new DateTimeOffset(
            DateTime.SpecifyKind(expiresAtUtc, DateTimeKind.Utc),
            TimeSpan.Zero).ToUnixTimeSeconds();
        var payload = $"{jti}:{unixExpiry}";
        await db.Database.ExecuteSqlInterpolatedAsync(
            $"SELECT pg_notify({NotificationChannel}, {payload})");

        foreach (var kvp in _revoked)
        {
            if (kvp.Value < now)
            {
                _revoked.TryRemove(kvp.Key, out _);
            }
        }
    }

    public Task<bool> IsRevokedAsync(string jti)
    {
        if (!_revoked.TryGetValue(jti, out var expiresAtUtc))
        {
            return Task.FromResult(false);
        }

        if (expiresAtUtc < DateTime.UtcNow)
        {
            _revoked.TryRemove(jti, out _);
            return Task.FromResult(false);
        }

        return Task.FromResult(true);
    }
}
