using Microsoft.Extensions.Logging; using System.Text.Json; namespace Blueberry.Redmine { class RedmineCache { private List Items { get; set; } = []; public DateTime LastUpdated { get; set; } = DateTime.MinValue; private readonly ILogger _logger; private readonly TimeSpan _cacheDuration; private readonly object _lock = new(); private int _maxCapacity = int.MaxValue; private bool _useSlidingExpiration = false; private DateTime _lastAccessed = DateTime.MinValue; private string? _cacheFilePath; private Func>>? _refreshCallback; private class CacheData { public List Items { get; set; } = []; public DateTime LastUpdated { get; set; } public DateTime LastAccessed { get; set; } } /// /// Initializes a new instance of the RedmineCache class. /// /// The time span for which the cache remains valid. After this duration, the cache is considered expired. /// The logger instance for logging cache operations. /// The maximum number of items to store in the cache. Defaults to int.MaxValue (unlimited). /// If true, the cache expiration resets on each access (sliding expiration). If false, uses absolute expiration from the last update. Defaults to false. /// Optional file path for persisting the cache to disk. If provided, the cache loads from and saves to this file. Defaults to null (no persistence). /// Optional asynchronous callback function to refresh the cache when it expires. Called automatically in GetItemsAsync if the cache is invalid. Defaults to null. public RedmineCache(TimeSpan cacheDuration, ILogger> logger, int maxCapacity = int.MaxValue, bool useSlidingExpiration = false, string? cacheFilePath = null, Func>>? refreshCallback = null) { if (logger == null) throw new ArgumentNullException(nameof(logger)); if (cacheDuration <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(cacheDuration)); _logger = logger; _cacheDuration = cacheDuration; _maxCapacity = maxCapacity; _useSlidingExpiration = useSlidingExpiration; _cacheFilePath = cacheFilePath; _refreshCallback = refreshCallback; LoadFromFile(); } /// /// Initializes a new instance of the RedmineCache class with cache duration in seconds. /// /// The cache duration in seconds. Converted to a TimeSpan internally. /// The logger instance for logging cache operations. /// The maximum number of items to store in the cache. Defaults to int.MaxValue (unlimited). /// If true, the cache expiration resets on each access (sliding expiration). If false, uses absolute expiration from the last update. Defaults to false. /// Optional file path for persisting the cache to disk. If provided, the cache loads from and saves to this file. Defaults to null (no persistence). /// Optional asynchronous callback function to refresh the cache when it expires. Called automatically in GetItemsAsync if the cache is invalid. Defaults to null. public RedmineCache(int cacheDurationSec, ILogger> logger, int maxCapacity = int.MaxValue, bool useSlidingExpiration = false, string? cacheFilePath = null, Func>>? refreshCallback = null) : this(new TimeSpan(0, 0, cacheDurationSec), logger, maxCapacity, useSlidingExpiration, cacheFilePath, refreshCallback) { } private void LoadFromFile() { if (_cacheFilePath == null || !File.Exists(_cacheFilePath)) return; try { var json = File.ReadAllText(_cacheFilePath); var data = JsonSerializer.Deserialize(json); if (data != null) { Items = data.Items ?? []; LastUpdated = data.LastUpdated; _lastAccessed = data.LastAccessed; } _logger.LogDebug("Loaded cache from {path}", _cacheFilePath); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to load cache from {path}", _cacheFilePath); } } private void SaveToFile() { if (_cacheFilePath == null) return; try { var data = new CacheData { Items = Items, LastUpdated = LastUpdated, LastAccessed = _lastAccessed }; var json = JsonSerializer.Serialize(data); var dir = Path.GetDirectoryName(_cacheFilePath) ?? throw new NullReferenceException(); Directory.CreateDirectory(dir); File.WriteAllText(_cacheFilePath, json); _logger.LogDebug("Saved cache to {path}", _cacheFilePath); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to save cache to {path}", _cacheFilePath); } } private void RefreshCache(List newItems) { _logger.LogDebug("Refreshing cache with {count} items", newItems.Count); lock (_lock) { Items = newItems.Count > _maxCapacity ? newItems.Take(_maxCapacity).ToList() : newItems; LastUpdated = DateTime.UtcNow; _lastAccessed = DateTime.UtcNow; SaveToFile(); _logger.LogDebug("Cache refreshed"); } } public void InvalidateCache() { _logger.LogDebug("Invalidating cache"); lock (_lock) { LastUpdated = DateTime.MinValue; _lastAccessed = DateTime.MinValue; Items.Clear(); SaveToFile(); _logger.LogDebug("Cache invalidated"); } } public bool IsCacheValid() { lock (_lock) { bool valid = !_useSlidingExpiration ? DateTime.UtcNow - LastUpdated <= _cacheDuration : DateTime.UtcNow - _lastAccessed <= _cacheDuration; _logger.LogDebug("Cache valid: {valid}", valid); return valid; } } private IReadOnlyList GetItems() { lock (_lock) { _lastAccessed = DateTime.UtcNow; _logger.LogDebug("Returning {count} cached items", Items.Count); return Items.AsReadOnly(); } } public Task RefreshCacheAsync(List newItems) => Task.Run(() => RefreshCache(newItems)); public async Task> GetItemsAsync() { bool needsRefresh = false; lock (_lock) { _lastAccessed = DateTime.UtcNow; if (!IsCacheValid() && _refreshCallback != null) { needsRefresh = true; } } if (needsRefresh) { await TryRefreshAsync(); } lock (_lock) { _logger.LogDebug("Returning {count} cached items", Items.Count); return Items.AsReadOnly(); } } public bool IsEmpty() { lock (_lock) { return Items.Count == 0; } } public int GetCount() { lock (_lock) { return Items.Count; } } public async Task TryRefreshAsync() { if (_refreshCallback != null) { try { var newItems = await _refreshCallback(); RefreshCache(newItems); } catch (Exception ex) { _logger.LogError(ex, "Failed to refresh cache via callback"); } } } } }