using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;

namespace PowderCoating.Application.Services;

/// <summary>
/// Defines a thin wrapper around <see cref="IMemoryCache"/> that adds thread-safe
/// get-or-create semantics and prefix-based bulk invalidation.
/// </summary>
public interface ICachingService
{
    /// <summary>
    /// Returns the cached value for <paramref name="key"/> if present; otherwise
    /// invokes <paramref name="factory"/>, stores the result, and returns it.
    /// </summary>
    /// <typeparam name="T">Type of the cached value.</typeparam>
    /// <param name="key">Unique cache key.</param>
    /// <param name="factory">Async delegate used to produce the value on a cache miss.</param>
    /// <param name="absoluteExpiration">
    /// How long the entry lives before being evicted regardless of access.
    /// Defaults to 15 minutes when <c>null</c>.
    /// </param>
    Task<T> GetOrCreateAsync<T>(string key, Func<Task<T>> factory, TimeSpan? absoluteExpiration = null);

    /// <summary>Removes a single cache entry by its exact key.</summary>
    /// <param name="key">The key to remove.</param>
    void Remove(string key);

    /// <summary>
    /// Removes all cache entries whose keys begin with <paramref name="prefix"/>.
    /// This enables coarse-grained invalidation (e.g. all keys for a given company)
    /// without knowing every individual key in advance.
    /// </summary>
    /// <param name="prefix">The key prefix to match.</param>
    void RemoveByPrefix(string prefix);
}

/// <summary>
/// In-process memory cache wrapper that enforces a double-checked locking pattern
/// to prevent cache stampedes under concurrent load.
/// <para>
/// Design notes:
/// <list type="bullet">
///   <item>
///     A static <see cref="HashSet{T}"/> tracks every key written so that
///     <see cref="RemoveByPrefix"/> can iterate them — <see cref="IMemoryCache"/>
///     does not expose its key set natively.
///   </item>
///   <item>
///     A static <see cref="SemaphoreSlim"/> (capacity 1) serialises factory
///     invocations for the same key, preventing multiple simultaneous DB calls
///     for an identical cache miss.
///   </item>
///   <item>
///     Both statics are process-wide intentionally: this service is registered as
///     Scoped but the underlying <see cref="IMemoryCache"/> is Singleton, so the
///     key registry must outlive individual request scopes.
///   </item>
/// </list>
/// </para>
/// </summary>
public class CachingService : ICachingService
{
    private readonly IMemoryCache _cache;
    private readonly ILogger<CachingService> _logger;

    /// <summary>
    /// Process-wide set of all keys written to the cache.
    /// Required because <see cref="IMemoryCache"/> has no built-in key enumeration.
    /// Static so the registry survives across DI scope lifetimes.
    /// </summary>
    private static readonly HashSet<string> _cacheKeys = new();

    /// <summary>
    /// Process-wide semaphore that serialises factory invocations to prevent
    /// cache stampedes when multiple requests miss the same key simultaneously.
    /// </summary>
    private static readonly SemaphoreSlim _semaphore = new(1, 1);

    /// <summary>
    /// Initialises a new <see cref="CachingService"/> with the required dependencies.
    /// </summary>
    /// <param name="cache">The underlying ASP.NET Core memory cache.</param>
    /// <param name="logger">Logger for debug-level cache hit/miss diagnostics.</param>
    public CachingService(IMemoryCache cache, ILogger<CachingService> logger)
    {
        _cache = cache;
        _logger = logger;
    }

    /// <summary>
    /// Returns the cached value for <paramref name="key"/>; on a miss, acquires the
    /// semaphore and double-checks before invoking <paramref name="factory"/> to
    /// prevent multiple simultaneous DB hits for the same key.
    /// Combined absolute + sliding expiration means infrequently-accessed entries
    /// are evicted early while hot entries live up to the absolute limit.
    /// </summary>
    /// <typeparam name="T">Type of the cached value.</typeparam>
    /// <param name="key">Unique cache key.</param>
    /// <param name="factory">Async delegate that loads the value from the source of truth.</param>
    /// <param name="absoluteExpiration">
    /// Hard upper bound on entry lifetime; defaults to 15 minutes.
    /// </param>
    /// <returns>The cached or freshly loaded value.</returns>
    public async Task<T> GetOrCreateAsync<T>(string key, Func<Task<T>> factory, TimeSpan? absoluteExpiration = null)
    {
        if (_cache.TryGetValue(key, out T cachedValue))
        {
            _logger.LogDebug("Cache hit for key: {Key}", key);
            return cachedValue;
        }

        await _semaphore.WaitAsync();
        try
        {
            // Double-check after acquiring lock
            if (_cache.TryGetValue(key, out cachedValue))
            {
                return cachedValue;
            }

            _logger.LogDebug("Cache miss for key: {Key}. Loading from source...", key);
            var value = await factory();

            var cacheEntryOptions = new MemoryCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = absoluteExpiration ?? TimeSpan.FromMinutes(15),
                SlidingExpiration = TimeSpan.FromMinutes(5)
            };

            _cache.Set(key, value, cacheEntryOptions);
            _cacheKeys.Add(key);

            _logger.LogDebug("Cached value for key: {Key}", key);
            return value;
        }
        finally
        {
            _semaphore.Release();
        }
    }

    /// <summary>
    /// Removes the cache entry with the given <paramref name="key"/> from both the
    /// underlying <see cref="IMemoryCache"/> and the internal key registry.
    /// </summary>
    /// <param name="key">The exact key to remove.</param>
    public void Remove(string key)
    {
        _cache.Remove(key);
        _cacheKeys.Remove(key);
        _logger.LogDebug("Removed cache key: {Key}", key);
    }

    /// <summary>
    /// Removes all cache entries whose keys start with <paramref name="prefix"/>.
    /// Used for coarse-grained invalidation — for example, wiping all lookup data
    /// for a single company by passing <c>"JobStatus_42"</c> prefix patterns.
    /// Snapshot the matching keys first to avoid mutating the set while iterating.
    /// </summary>
    /// <param name="prefix">Key prefix to match.</param>
    public void RemoveByPrefix(string prefix)
    {
        var keysToRemove = _cacheKeys.Where(k => k.StartsWith(prefix)).ToList();
        foreach (var key in keysToRemove)
        {
            Remove(key);
        }
        _logger.LogDebug("Removed {Count} cache keys with prefix: {Prefix}", keysToRemove.Count, prefix);
    }
}
