using BomLocalService.Models; using BomLocalService.Services.Interfaces; using BomLocalService.Utilities; using System.Collections.Concurrent; using System.Text.Json; namespace BomLocalService.Services; public class CacheService : ICacheService { private readonly ILogger _logger; private readonly string _cacheDirectory; private readonly double _cacheExpirationMinutes; private readonly IConfiguration _configuration; private readonly ConcurrentDictionary _activeCacheFolders = new(); // locationKey -> cacheFolderPath // Progress tracking for cache updates private readonly ConcurrentDictionary _updateProgress = new(); private readonly ConcurrentQueue _recentTotalDurations = new(); // Overall durations in seconds private readonly ConcurrentDictionary> _phaseDurations = new(); // Phase -> durations private readonly ConcurrentDictionary> _stepDurations = new(); // Step name -> durations private readonly object _metricsLock = new(); private const int MaxSamples = 20; public CacheService(ILogger logger, IConfiguration configuration) { _logger = logger; _configuration = configuration; _cacheDirectory = FilePathHelper.GetCacheDirectory(configuration); var cacheExpirationMinutesConfig = configuration.GetValue("CacheExpirationMinutes"); if (!cacheExpirationMinutesConfig.HasValue) { throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable."); } _cacheExpirationMinutes = cacheExpirationMinutesConfig.Value; if (_cacheExpirationMinutes <= 0) { throw new ArgumentException("CacheExpirationMinutes must be greater than 0", nameof(configuration)); } // Ensure cache directory exists Directory.CreateDirectory(_cacheDirectory); } /// /// Gets the cached screenshot folder path and metadata for a location and data type /// public async Task<(string? cacheFolderPath, LastUpdatedInfo? metadata)> GetCachedScreenshotWithMetadataAsync( string suburb, string state, CachedDataType dataType, string? excludeFolder = null, CancellationToken cancellationToken = default) { var pattern = FilePathHelper.GetCacheFolderPattern(suburb, state); _logger.LogDebug("Looking for cached folders matching pattern: {Pattern} in directory: {Directory}", pattern, _cacheDirectory); var folders = Directory.GetDirectories(_cacheDirectory, pattern) .OrderByDescending(f => { // Try to extract timestamp from folder name var folderName = Path.GetFileName(f); var timestamp = LocationHelper.ParseTimestampFromFilename(folderName); if (timestamp.HasValue) { return timestamp.Value; } // Fallback to folder creation time return Directory.GetCreationTime(f); }) .ToList(); // Find the first complete cache folder for the specified data type foreach (var folder in folders) { if (!Directory.Exists(folder)) continue; // Skip the folder if it's being excluded (currently being written to) if (!string.IsNullOrEmpty(excludeFolder) && Path.GetFullPath(folder).Equals(Path.GetFullPath(excludeFolder), StringComparison.OrdinalIgnoreCase)) { _logger.LogDebug("Skipping excluded cache folder (being written to): {Folder}", folder); continue; } // Check if folder is complete for the specified data type if (!CacheHelper.IsCacheFolderCompleteForDataType(folder, dataType, _configuration)) { _logger.LogDebug("Skipping incomplete cache folder for {DataType}: {Folder}", dataType, folder); continue; // Skip incomplete folders (currently being written to) } // This folder is complete var metadata = await LoadMetadataAsync(folder, cancellationToken); return (folder, metadata); } // No complete cache folder found return (null, null); } /// /// Gets all cached frames for a location /// public async Task> GetCachedFramesAsync( string suburb, string state, CancellationToken cancellationToken = default) { // Exclude active cache folder (currently being written to) to avoid reading incomplete data var locationKey = LocationHelper.GetLocationKey(suburb, state); var excludeFolder = GetActiveCacheFolder(locationKey); var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, excludeFolder, cancellationToken); if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath)) { return new List(); } var cachedFrames = await GetFramesFromCacheFolderAsync(suburb, state, Path.GetFileName(cacheFolderPath), CachedDataType.Radar, cancellationToken); return cachedFrames.Cast().ToList(); } /// /// Gets a specific cached frame for a location /// public async Task GetCachedFrameAsync( string suburb, string state, int frameIndex, CancellationToken cancellationToken = default) { var frameCount = CacheHelper.GetFrameCountForDataType(_configuration, CachedDataType.Radar); if (frameIndex < 0 || frameIndex >= frameCount) { return null; } // Exclude active cache folder (currently being written to) to avoid reading incomplete data var locationKey = LocationHelper.GetLocationKey(suburb, state); var excludeFolder = GetActiveCacheFolder(locationKey); var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, excludeFolder, cancellationToken); if (string.IsNullOrEmpty(cacheFolderPath)) { return null; } var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, CachedDataType.Radar, frameIndex); if (!File.Exists(framePath)) { return null; } // Load frame metadata to get observation time var framesMetadata = await LoadFramesMetadataAsync(cacheFolderPath, CachedDataType.Radar, cancellationToken); var frameMetadata = framesMetadata.FirstOrDefault(f => f.FrameIndex == frameIndex); var observationTime = frameMetadata?.ObservationTime > DateTime.MinValue.AddYears(1) ? frameMetadata.ObservationTime : (DateTime?)null; return new RadarFrame { FrameIndex = frameIndex, ImagePath = framePath, AbsoluteObservationTime = observationTime }; } /// /// Saves metadata in a cache folder /// public async Task SaveMetadataAsync(string cacheFolderPath, LastUpdatedInfo metadata, CancellationToken cancellationToken = default) { try { var metadataPath = FilePathHelper.GetMetadataFilePath(cacheFolderPath); var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions { WriteIndented = true }); // Use CancellationToken.None for file writes to avoid cancellation issues // The frames are already saved, metadata save is best-effort await File.WriteAllTextAsync(metadataPath, json, CancellationToken.None); _logger.LogDebug("Saved metadata to: {Path}", metadataPath); } catch (Exception ex) { // Log but don't fail - frames are already saved _logger.LogWarning(ex, "Failed to save metadata for folder: {Path}", cacheFolderPath); } } /// /// Saves frame metadata to frames.json in the data type subfolder /// public async Task SaveFramesMetadataAsync(string cacheFolderPath, CachedDataType dataType, List frames, CancellationToken cancellationToken = default) { try { var framesMetadata = frames.Select(f => new FrameMetadata { FrameIndex = f.FrameIndex, ObservationTime = f.AbsoluteObservationTime ?? throw new InvalidOperationException($"Frame {f.FrameIndex} missing AbsoluteObservationTime") }).ToList(); var framesPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath, dataType); var framesDir = Path.GetDirectoryName(framesPath); if (!string.IsNullOrEmpty(framesDir) && !Directory.Exists(framesDir)) { Directory.CreateDirectory(framesDir); } var json = JsonSerializer.Serialize(framesMetadata, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(framesPath, json, CancellationToken.None); _logger.LogDebug("Saved frames metadata to: {Path}", framesPath); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to save frames metadata for folder: {Path}", cacheFolderPath); } } /// /// Loads frame metadata from frames.json in the data type subfolder /// private async Task> LoadFramesMetadataAsync(string cacheFolderPath, CachedDataType dataType, CancellationToken cancellationToken = default) { var framesPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath, dataType); if (!File.Exists(framesPath)) { return new List(); } try { var json = await File.ReadAllTextAsync(framesPath, cancellationToken); var framesMetadata = JsonSerializer.Deserialize>(json); return framesMetadata ?? new List(); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to load frames metadata from: {Path}", framesPath); return new List(); } } /// /// Loads metadata from a cache folder /// private async Task LoadMetadataAsync(string cacheFolderPath, CancellationToken cancellationToken = default) { var metadataPath = FilePathHelper.GetMetadataFilePath(cacheFolderPath); if (!File.Exists(metadataPath)) { return null; } try { var json = await File.ReadAllTextAsync(metadataPath, cancellationToken); return JsonSerializer.Deserialize(json); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to load metadata from: {Path}", metadataPath); return null; } } /// /// Checks if cache is still valid based on observation time /// public bool IsCacheValid(LastUpdatedInfo? metadata) { if (metadata == null) { return false; } // BOM updates observations at :00, :15, :30, :45 (every 15 minutes by default) // Cache expiration includes buffer (e.g., 15.5 minutes = 15 min + 30 sec buffer) var nextUpdateTime = metadata.ObservationTime.AddMinutes(_cacheExpirationMinutes); return DateTime.UtcNow < nextUpdateTime; } /// /// Gets the path to the cached screenshot for a location (returns first frame path for backward compatibility) /// public async Task GetCachedScreenshotPathAsync(string suburb, string state, CancellationToken cancellationToken = default) { var frames = await GetCachedFramesAsync(suburb, state, cancellationToken); return frames.FirstOrDefault()?.ImagePath ?? string.Empty; } /// /// Deletes all cached folders for a location /// public Task DeleteCachedLocationAsync(string suburb, string state, CancellationToken cancellationToken = default) { var pattern = FilePathHelper.GetCacheFolderPattern(suburb, state); var deleted = false; try { // Delete all folders matching the pattern var folders = Directory.GetDirectories(_cacheDirectory, pattern); foreach (var folder in folders) { try { Directory.Delete(folder, recursive: true); _logger.LogInformation("Deleted cached folder: {Folder}", folder); deleted = true; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete folder: {Folder}", folder); } } if (deleted) { _logger.LogInformation("Deleted all cached folders for location: {Suburb}, {State}", suburb, state); } else { _logger.LogDebug("No cached folders found to delete for location: {Suburb}, {State}", suburb, state); } } catch (Exception ex) { _logger.LogError(ex, "Error deleting cached location: {Suburb}, {State}", suburb, state); return Task.FromResult(false); } return Task.FromResult(deleted); } /// /// Gets all complete cache folders for a location, ordered by timestamp (oldest first) /// public async Task> GetAllCacheFoldersAsync( string suburb, string state, CancellationToken cancellationToken = default) { // Exclude active cache folder (currently being written to) to avoid reading incomplete data var locationKey = LocationHelper.GetLocationKey(suburb, state); var excludeFolder = GetActiveCacheFolder(locationKey); var pattern = FilePathHelper.GetCacheFolderPattern(suburb, state); var folders = Directory.GetDirectories(_cacheDirectory, pattern) .Select(f => { var folderName = Path.GetFileName(f); var timestamp = LocationHelper.ParseTimestampFromFilename(folderName); return new { Folder = f, FolderName = folderName, Timestamp = timestamp }; }) .Where(x => x.Timestamp.HasValue) .Select(x => new { x.Folder, x.FolderName, Timestamp = x.Timestamp!.Value }) .OrderBy(x => x.Timestamp) // Oldest first .ToList(); var result = new List(); foreach (var folder in folders) { if (!Directory.Exists(folder.Folder)) continue; // Skip the folder if it's being excluded (currently being written to) if (!string.IsNullOrEmpty(excludeFolder) && Path.GetFullPath(folder.Folder).Equals(Path.GetFullPath(excludeFolder), StringComparison.OrdinalIgnoreCase)) { _logger.LogDebug("Skipping excluded cache folder (being written to): {Folder}", folder.Folder); continue; } var metadata = await LoadMetadataAsync(folder.Folder, cancellationToken); if (metadata == null) continue; // Check which data types are available in this folder var availableDataTypes = new List(); if (CacheHelper.IsCacheFolderCompleteForDataType(folder.Folder, CachedDataType.Radar, _configuration)) { availableDataTypes.Add(CachedDataType.Radar); } if (availableDataTypes.Count > 0) { result.Add(new CacheFolder { FolderName = folder.FolderName, CacheTimestamp = folder.Timestamp, ObservationTime = metadata.ObservationTime, AvailableDataTypes = availableDataTypes, IsComplete = true }); } } return result; } /// /// Gets frames from a specific cache folder and data type /// public async Task> GetFramesFromCacheFolderAsync( string suburb, string state, string cacheFolderName, CachedDataType dataType, CancellationToken cancellationToken = default) { var folderPath = Path.Combine(_cacheDirectory, cacheFolderName); if (!Directory.Exists(folderPath)) { return new List(); } var frameCount = CacheHelper.GetFrameCountForDataType(_configuration, dataType); var frames = new List(); // Load frame metadata from frames.json if available var framesMetadata = await LoadFramesMetadataAsync(folderPath, dataType, cancellationToken); var metadataDict = framesMetadata.Where(f => f.ObservationTime > DateTime.MinValue.AddYears(1)) .ToDictionary(f => f.FrameIndex, f => f.ObservationTime); // Load frames from data type subfolder for (int i = 0; i < frameCount; i++) { var framePath = FilePathHelper.GetFrameFilePath(folderPath, dataType, i); if (File.Exists(framePath)) { if (dataType == CachedDataType.Radar) { var observationTime = metadataDict.ContainsKey(i) ? metadataDict[i] : (DateTime?)null; frames.Add(new RadarFrame { FrameIndex = i, ImagePath = framePath, AbsoluteObservationTime = observationTime }); } else { // For future data types, create base CachedFrame frames.Add(new CachedFrame { FrameIndex = i, ImagePath = framePath, DataType = dataType }); } } } return frames; } /// /// Gets the cache directory path /// public string GetCacheDirectory() => _cacheDirectory; /// /// Cleans up incomplete cache folders from previous crashes or restarts /// public int CleanupIncompleteCacheFolders() { if (!Directory.Exists(_cacheDirectory)) { return 0; } var deletedCount = 0; try { // Get all cache folders (they match the pattern LocationKey_Timestamp) var folders = Directory.GetDirectories(_cacheDirectory) .Where(f => !Path.GetFileName(f).StartsWith("debug", StringComparison.OrdinalIgnoreCase)) // Exclude debug folder .ToList(); foreach (var folder in folders) { try { // Check if folder is incomplete (check for radar data type) if (!CacheHelper.IsCacheFolderCompleteForDataType(folder, CachedDataType.Radar, _configuration)) { _logger.LogInformation("Found incomplete cache folder from previous session, deleting: {Folder}", Path.GetFileName(folder)); Directory.Delete(folder, recursive: true); deletedCount++; } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to check or delete incomplete cache folder: {Folder}", folder); } } if (deletedCount > 0) { _logger.LogInformation("Startup cleanup completed: deleted {Count} incomplete cache folder(s)", deletedCount); } else { _logger.LogDebug("Startup cleanup: no incomplete cache folders found"); } } catch (Exception ex) { _logger.LogError(ex, "Error during startup cleanup of incomplete cache folders"); } return deletedCount; } public bool IsLocationUpdating(string locationKey) { return _activeCacheFolders.ContainsKey(locationKey); } public string? GetActiveCacheFolder(string locationKey) { return _activeCacheFolders.TryGetValue(locationKey, out var folder) ? folder : null; } public void SetActiveCacheFolder(string locationKey, string cacheFolderPath) { _activeCacheFolders[locationKey] = cacheFolderPath; _updateProgress[locationKey] = (DateTime.UtcNow, CacheUpdatePhase.Initializing, null, null); _logger.LogDebug("Tracking active cache folder: {Folder} for location: {Location}", cacheFolderPath, locationKey); } public void ClearActiveCacheFolder(string locationKey) { if (_activeCacheFolders.TryRemove(locationKey, out var folder)) { RecordUpdateComplete(locationKey); _updateProgress.TryRemove(locationKey, out _); _logger.LogDebug("Cleared active cache folder tracking: {Folder} for location: {Location}", folder, locationKey); } } /// /// Records progress update for a cache update operation. /// public void RecordUpdateProgress(string locationKey, CacheUpdatePhase phase, int? currentFrame = null, int? totalFrames = null) { if (_updateProgress.TryGetValue(locationKey, out var existing)) { // Record duration for previous phase if it changed var previousPhase = existing.phase; if (previousPhase != phase) { var phaseDuration = (DateTime.UtcNow - existing.startTime).TotalSeconds; lock (_metricsLock) { var durations = _phaseDurations.GetOrAdd(previousPhase, _ => new ConcurrentQueue()); durations.Enqueue(phaseDuration); while (durations.Count > MaxSamples) { durations.TryDequeue(out _); } } } } // Update progress var startTime = _updateProgress.TryGetValue(locationKey, out var current) ? current.startTime : DateTime.UtcNow; _updateProgress[locationKey] = (startTime, phase, currentFrame, totalFrames); } /// /// Records completion of a cache update and stores metrics. /// private void RecordUpdateComplete(string locationKey) { if (_updateProgress.TryRemove(locationKey, out var progress)) { var totalDuration = (DateTime.UtcNow - progress.startTime).TotalSeconds; lock (_metricsLock) { _recentTotalDurations.Enqueue(totalDuration); while (_recentTotalDurations.Count > MaxSamples) { _recentTotalDurations.TryDequeue(out _); } } _logger.LogInformation("Cache update completed in {Duration:F1} seconds for {Location}", totalDuration, locationKey); } } /// /// Gets the estimated remaining seconds for an in-progress cache update. /// Returns 0 if not updating or no metrics available. /// public int GetEstimatedRemainingSeconds(string locationKey) { if (!_updateProgress.TryGetValue(locationKey, out var progress)) { return 0; // Not updating } var elapsed = (DateTime.UtcNow - progress.startTime).TotalSeconds; // If we have historical data, use it var avgTotal = GetAverageTotalDuration(); if (avgTotal > 0) { // Estimate based on phase and progress double estimatedRemaining = 0; switch (progress.phase) { case CacheUpdatePhase.Initializing: // Estimate: avg total - elapsed (with some buffer) estimatedRemaining = Math.Max(0, avgTotal * 1.1 - elapsed); break; case CacheUpdatePhase.CapturingFrames: if (progress.currentFrame.HasValue && progress.totalFrames.HasValue) { // We know exactly where we are: frame X of Y var framesRemaining = progress.totalFrames.Value - progress.currentFrame.Value - 1; var avgFrameDuration = GetAverageFrameDuration(); if (avgFrameDuration > 0) { // Time for remaining frames + saving phase estimatedRemaining = (framesRemaining * avgFrameDuration) + GetAveragePhaseDuration(CacheUpdatePhase.Saving); } else { // Fallback: estimate based on progress through total var progressFraction = (progress.currentFrame.Value + 1.0) / progress.totalFrames.Value; estimatedRemaining = Math.Max(0, (avgTotal / progressFraction) - elapsed); } } else { // No frame info - estimate based on elapsed time estimatedRemaining = Math.Max(0, avgTotal - elapsed); } break; case CacheUpdatePhase.Saving: // Almost done - just saving phase remaining estimatedRemaining = GetAveragePhaseDuration(CacheUpdatePhase.Saving); if (estimatedRemaining == 0) { estimatedRemaining = 5; // Default 5 seconds for saving } break; } return (int)Math.Ceiling(Math.Max(0, estimatedRemaining)); } // No historical data - return 0 to signal fallback return 0; } /// /// Gets the average total duration of cache updates from recent metrics. /// public double GetAverageTotalDuration() { lock (_metricsLock) { if (_recentTotalDurations.Count == 0) return 0; var durations = _recentTotalDurations.ToArray(); Array.Sort(durations); // Use median for robustness var median = durations.Length % 2 == 0 ? (durations[durations.Length / 2 - 1] + durations[durations.Length / 2]) / 2.0 : durations[durations.Length / 2]; return median; } } /// /// Gets the average duration per frame based on historical CapturingFrames phase data. /// private double GetAverageFrameDuration() { // Estimate frame duration from CapturingFrames phase duration / frame count var avgCapturingDuration = GetAveragePhaseDuration(CacheUpdatePhase.CapturingFrames); var frameCount = CacheHelper.GetFrameCountForDataType(_configuration, CachedDataType.Radar); return avgCapturingDuration > 0 && frameCount > 0 ? avgCapturingDuration / frameCount : 0; } /// /// Gets the average duration for a specific phase from historical data. /// private double GetAveragePhaseDuration(CacheUpdatePhase phase) { lock (_metricsLock) { if (!_phaseDurations.TryGetValue(phase, out var durations) || durations.Count == 0) { return 0; } var durationsArray = durations.ToArray(); return durationsArray.Average(); } } /// /// Records step completion timing for metrics tracking. /// public void RecordStepCompletion(string stepName, double durationSeconds) { lock (_metricsLock) { var durations = _stepDurations.GetOrAdd(stepName, _ => new ConcurrentQueue()); durations.Enqueue(durationSeconds); while (durations.Count > MaxSamples) { durations.TryDequeue(out _); } } } /// /// Gets the average duration for a specific step from historical data. /// public double GetAverageStepDuration(string stepName) { lock (_metricsLock) { if (!_stepDurations.TryGetValue(stepName, out var durations) || durations.Count == 0) { return 0; } var durationsArray = durations.ToArray(); return durationsArray.Average(); } } /// /// Gets step performance metrics for debugging/logging. /// public Dictionary GetStepMetrics() { lock (_metricsLock) { var metrics = new Dictionary(); foreach (var kvp in _stepDurations) { if (kvp.Value.Count > 0) { var durations = kvp.Value.ToArray(); metrics[kvp.Key] = durations.Average(); } } return metrics; } } /// /// Gets the locationKey from a cacheFolderPath by parsing the folder name. /// private string? GetLocationKeyFromCacheFolder(string cacheFolderPath) { var folderName = Path.GetFileName(cacheFolderPath); var location = LocationHelper.ParseLocationFromFilename(folderName); if (location.HasValue) { return LocationHelper.GetLocationKey(location.Value.suburb, location.Value.state); } return null; } /// /// Records progress update for a cache update operation using cacheFolderPath. /// This is useful when locationKey is not directly available (e.g., in ScrapingService). /// public void RecordUpdateProgressByFolder(string cacheFolderPath, CacheUpdatePhase phase, int? currentFrame = null, int? totalFrames = null) { var locationKey = GetLocationKeyFromCacheFolder(cacheFolderPath); if (locationKey != null) { RecordUpdateProgress(locationKey, phase, currentFrame, totalFrames); } } public string CreateCacheFolder(string suburb, string state, string timestamp) { var cacheFolderPath = FilePathHelper.GetCacheFolderPath(_cacheDirectory, suburb, state, timestamp); Directory.CreateDirectory(cacheFolderPath); // Create radar subfolder structure (for future: create other data type folders as needed) var radarFolder = FilePathHelper.GetDataTypeFolderPath(cacheFolderPath, CachedDataType.Radar); Directory.CreateDirectory(radarFolder); _logger.LogDebug("Created cache folder: {Folder}", cacheFolderPath); return cacheFolderPath; } public bool TryDeleteIncompleteCacheFolder(string cacheFolderPath) { if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath)) { return false; } try { // Check if folder is incomplete for radar data type if (!CacheHelper.IsCacheFolderCompleteForDataType(cacheFolderPath, CachedDataType.Radar, _configuration)) { Directory.Delete(cacheFolderPath, recursive: true); _logger.LogDebug("Deleted incomplete cache folder: {Folder}", cacheFolderPath); return true; } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete incomplete cache folder: {Folder}", cacheFolderPath); } return false; } public bool TryDeleteEmptyCacheFolder(string cacheFolderPath) { if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath)) { return false; } try { var files = Directory.GetFiles(cacheFolderPath, "*", SearchOption.AllDirectories); if (files.Length == 0) { Directory.Delete(cacheFolderPath, recursive: true); _logger.LogDebug("Deleted empty cache folder: {Folder}", cacheFolderPath); return true; } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete empty cache folder: {Folder}", cacheFolderPath); } return false; } public async Task GetCacheStatusAsync( string suburb, string state, CachedDataType dataType, int cacheExpirationMinutes, int cacheManagementCheckIntervalMinutes, CancellationToken cancellationToken = default) { var status = new CacheUpdateStatus(); var locationKey = LocationHelper.GetLocationKey(suburb, state); // Check if an update is already in progress var isUpdating = IsLocationUpdating(locationKey); var excludeFolder = GetActiveCacheFolder(locationKey); var (cacheFolderPath, cachedMetadata) = await GetCachedScreenshotWithMetadataAsync(suburb, state, dataType, excludeFolder, cancellationToken); status.CacheExists = !string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath); if (cachedMetadata != null) { status.CacheIsValid = IsCacheValid(cachedMetadata); status.CacheExpiresAt = cachedMetadata.ObservationTime.AddMinutes(cacheExpirationMinutes); } else { status.CacheIsValid = false; } // Set update status - if an update is in progress, UpdateTriggered would be false but Message indicates it // For GetCacheStatusAsync, we don't trigger updates, but we can indicate if one is in progress status.UpdateTriggered = false; // This method doesn't trigger updates if (isUpdating) { status.Message = "Cache update already in progress"; // Try metrics-based estimate first var remainingSeconds = GetEstimatedRemainingSeconds(locationKey); if (remainingSeconds > 0) { status.NextUpdateTime = DateTime.UtcNow.AddSeconds(remainingSeconds); } else { // Fallback to calculated estimate var estimatedDurationSeconds = CacheHelper.GetEstimatedUpdateDurationSeconds(_configuration, dataType); status.NextUpdateTime = DateTime.UtcNow.AddSeconds(estimatedDurationSeconds); } } else if (status.CacheIsValid && status.CacheExpiresAt.HasValue) { // Cache is valid - next update will be when cache expires status.Message = "Cache is valid, no update needed"; status.NextUpdateTime = status.CacheExpiresAt.Value; } else { // Cache is invalid/missing - next update would be after background service check status.Message = status.CacheExists ? "Cache is stale" : "No cache exists"; status.NextUpdateTime = ResponseBuilder.CalculateNextServiceCheck(cacheManagementCheckIntervalMinutes); } return status; } }