using System.Collections.Concurrent;
using System.Text.Json.Nodes;
using StackExchange.Redis;

namespace Eis.Infrastructure.Caching;

public interface IApplicationCache
{
    bool Enabled { get; }
    string Status { get; }
    Task<JsonObject> RememberJsonAsync(
        string cacheNamespace,
        string key,
        Func<Task<JsonObject>> loader,
        int? ttlSeconds = null);
    Task<bool> InvalidateAsync(string cacheNamespace);
}

internal sealed class ApplicationCache : IApplicationCache, IDisposable
{
    private readonly ConcurrentDictionary<string, LocalEntry> _values = new(StringComparer.Ordinal);
    private readonly ConcurrentDictionary<string, PendingEntry> _pending = new(StringComparer.Ordinal);
    private readonly ConcurrentDictionary<string, long> _generations = new(StringComparer.Ordinal);
    private readonly object _pruneLock = new();
    private readonly int _defaultTtlSeconds;
    private readonly int _maxEntries;
    private readonly string _prefix;
    private readonly IConnectionMultiplexer? _connection;
    private readonly IDatabase? _database;
    private volatile bool _unavailable;

    public ApplicationCache() : this(
        Environment.GetEnvironmentVariable("REDIS_URL")?.Trim(),
        PositiveInteger("REDIS_CACHE_TTL_SECONDS", 60, 86400),
        PositiveInteger("LOCAL_CACHE_MAX_ENTRIES", 200, 5000),
        SanitizePrefix(Environment.GetEnvironmentVariable("REDIS_CACHE_PREFIX")))
    {
    }

    internal ApplicationCache(string? redisUrl, int defaultTtlSeconds = 60, int maxEntries = 200, string prefix = "exam-information")
    {
        _defaultTtlSeconds = Math.Clamp(defaultTtlSeconds, 1, 86400);
        _maxEntries = Math.Clamp(maxEntries, 1, 5000);
        _prefix = SanitizePrefix(prefix);
        var url = redisUrl?.Trim();
        if (string.IsNullOrEmpty(url)) return;
        try
        {
            var configuration = ConfigurationOptions.Parse(url);
            configuration.AbortOnConnectFail = false;
            configuration.ConnectTimeout = PositiveInteger("REDIS_CONNECT_TIMEOUT_MS", 1500, 30000);
            configuration.ConnectRetry = 1;
            _connection = ConnectionMultiplexer.Connect(configuration);
            _database = _connection.GetDatabase();
            _unavailable = !_connection.IsConnected;
        }
        catch (RedisException)
        {
            _unavailable = true;
        }
        catch (ArgumentException)
        {
            _unavailable = true;
        }
    }

    public bool Enabled => _database is not null && _connection?.IsConnected == true;
    public string Status => _database is null ? (_unavailable ? "unavailable" : "disabled") :
        Enabled ? "ready" : "unavailable";

    public async Task<JsonObject> RememberJsonAsync(
        string cacheNamespace,
        string key,
        Func<Task<JsonObject>> loader,
        int? ttlSeconds = null)
    {
        var lifetime = Math.Clamp(ttlSeconds ?? _defaultTtlSeconds, 1, 86400);
        if (Enabled)
        {
            try
            {
                var version = await NamespaceVersionAsync(cacheNamespace);
                var redisKey = $"{_prefix}:{cacheNamespace}:{version}:{key}";
                var cached = await _database!.StringGetAsync(redisKey);
                if (cached.HasValue && JsonNode.Parse(cached.ToString()) is JsonObject parsed)
                    return parsed;
                var loaded = await CoalescedLoadAsync(
                    $"redis:{redisKey}",
                    cacheNamespace,
                    loader,
                    lifetime,
                    storeLocal: false);
                await _database.StringSetAsync(redisKey, loaded.ToJsonString(), TimeSpan.FromSeconds(lifetime));
                return loaded;
            }
            catch (RedisException)
            {
                _unavailable = true;
            }
        }
        return await RememberLocalAsync(cacheNamespace, key, loader, lifetime);
    }

    public async Task<bool> InvalidateAsync(string cacheNamespace)
    {
        _generations.AddOrUpdate(cacheNamespace, 1, (_, value) => value + 1);
        var prefix = $"{cacheNamespace}:";
        foreach (var key in _values.Keys.Where(key => key.StartsWith(prefix, StringComparison.Ordinal)))
            _values.TryRemove(key, out _);
        if (!Enabled) return false;
        try
        {
            await _database!.StringIncrementAsync(VersionKey(cacheNamespace));
            return true;
        }
        catch (RedisException)
        {
            _unavailable = true;
            return false;
        }
    }

    private async Task<JsonObject> RememberLocalAsync(
        string cacheNamespace,
        string key,
        Func<Task<JsonObject>> loader,
        int ttlSeconds)
    {
        var fullKey = $"{cacheNamespace}:{key}";
        if (_values.TryGetValue(fullKey, out var cached))
        {
            if (cached.ExpiresAt > DateTimeOffset.UtcNow) return Parse(cached.Json);
            _values.TryRemove(fullKey, out _);
        }
        return await CoalescedLoadAsync(fullKey, cacheNamespace, loader, ttlSeconds, storeLocal: true);
    }

    private async Task<JsonObject> CoalescedLoadAsync(
        string fullKey,
        string cacheNamespace,
        Func<Task<JsonObject>> loader,
        int ttlSeconds,
        bool storeLocal)
    {
        var generation = Generation(cacheNamespace);
        while (_pending.TryGetValue(fullKey, out var existing))
        {
            if (existing.Generation == generation)
                return Parse(await existing.Loading);
            _pending.TryRemove(new KeyValuePair<string, PendingEntry>(fullKey, existing));
        }
        var loading = LoadJsonAsync(loader);
        var pending = new PendingEntry(generation, loading);
        if (!_pending.TryAdd(fullKey, pending))
        {
            var active = _pending[fullKey];
            return Parse(await active.Loading);
        }
        try
        {
            var json = await loading;
            if (storeLocal && Generation(cacheNamespace) == generation)
            {
                _values[fullKey] = new(json, DateTimeOffset.UtcNow.AddSeconds(ttlSeconds));
                Prune();
            }
            return Parse(json);
        }
        finally
        {
            _pending.TryRemove(new KeyValuePair<string, PendingEntry>(fullKey, pending));
        }
    }

    private async Task<string> NamespaceVersionAsync(string cacheNamespace)
    {
        var key = VersionKey(cacheNamespace);
        var current = await _database!.StringGetAsync(key);
        if (current.HasValue) return current.ToString();
        await _database.StringSetAsync(key, "1", when: When.NotExists);
        current = await _database.StringGetAsync(key);
        return current.HasValue ? current.ToString() : "1";
    }

    private void Prune()
    {
        lock (_pruneLock)
        {
            var now = DateTimeOffset.UtcNow;
            foreach (var item in _values.Where(item => item.Value.ExpiresAt <= now))
                _values.TryRemove(item.Key, out _);
            foreach (var key in _values.OrderBy(item => item.Value.ExpiresAt).Select(item => item.Key)
                         .Take(Math.Max(0, _values.Count - _maxEntries)))
                _values.TryRemove(key, out _);
        }
    }

    private long Generation(string cacheNamespace) => _generations.GetValueOrDefault(cacheNamespace);
    private string VersionKey(string cacheNamespace) => $"{_prefix}:namespace:{cacheNamespace}";
    private static async Task<string> LoadJsonAsync(Func<Task<JsonObject>> loader) =>
        (await loader()).ToJsonString();
    private static JsonObject Parse(string json) => JsonNode.Parse(json)?.AsObject() ?? new JsonObject();
    private static int PositiveInteger(string name, int fallback, int maximum) =>
        int.TryParse(Environment.GetEnvironmentVariable(name), out var value) && value > 0
            ? Math.Min(value, maximum)
            : fallback;
    private static string SanitizePrefix(string? value)
    {
        var source = string.IsNullOrWhiteSpace(value) ? "exam-information" : value.Trim();
        var chars = source.Select(character =>
            char.IsAsciiLetterOrDigit(character) || character is ':' or '_' or '-' ? character : '-').ToArray();
        var result = new string(chars);
        return result.Length == 0 ? "exam-information" : result;
    }

    public void Dispose() => _connection?.Dispose();

    private sealed record LocalEntry(string Json, DateTimeOffset ExpiresAt);
    private sealed record PendingEntry(long Generation, Task<string> Loading);
}
