using System.Globalization; using System.Threading; using MareSynchronos.API.Dto.Group; using MareSynchronos.MareConfiguration.Models; using MareSynchronos.PlayerData.Pairs; using MareSynchronos.Services.Mediator; using MareSynchronos.WebAPI; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace MareSynchronos.Services; public sealed class TemporarySyncshellNotificationService : MediatorSubscriberBase, IHostedService { private static readonly int[] NotificationThresholdMinutes = [30, 15, 5, 1]; private readonly ApiController _apiController; private readonly PairManager _pairManager; private readonly Lock _stateLock = new(); private readonly Dictionary _trackedGroups = new(StringComparer.Ordinal); private CancellationTokenSource? _loopCts; private Task? _loopTask; public TemporarySyncshellNotificationService(ILogger logger, MareMediator mediator, PairManager pairManager, ApiController apiController) : base(logger, mediator) { _pairManager = pairManager; _apiController = apiController; } public Task StartAsync(CancellationToken cancellationToken) { _loopCts = new CancellationTokenSource(); Mediator.Subscribe(this, _ => ResetTrackedGroups()); Mediator.Subscribe(this, _ => ResetTrackedGroups()); _loopTask = Task.Run(() => MonitorLoopAsync(_loopCts.Token), _loopCts.Token); return Task.CompletedTask; } public async Task StopAsync(CancellationToken cancellationToken) { Mediator.UnsubscribeAll(this); if (_loopCts == null) { return; } try { _loopCts.Cancel(); if (_loopTask != null) { await _loopTask.ConfigureAwait(false); } } catch (OperationCanceledException) { } finally { _loopTask = null; _loopCts.Dispose(); _loopCts = null; } } private async Task MonitorLoopAsync(CancellationToken ct) { var delay = TimeSpan.FromSeconds(30); while (!ct.IsCancellationRequested) { try { CheckGroups(); } catch (Exception ex) { Logger.LogDebug(ex, "Failed to check temporary syncshell expirations"); } try { await Task.Delay(delay, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } } } private void CheckGroups() { var nowUtc = DateTime.UtcNow; var groupsSnapshot = _pairManager.Groups.Values.ToList(); var notifications = new List(); var expiredGroups = new List(); var seenTemporaryGids = new HashSet(StringComparer.Ordinal); using (var guard = _stateLock.EnterScope()) { foreach (var group in groupsSnapshot) { if (!group.IsTemporary || group.ExpiresAt == null) { continue; } if (string.IsNullOrEmpty(_apiController.UID) || !string.Equals(group.OwnerUID, _apiController.UID, StringComparison.Ordinal)) { continue; } var gid = group.Group.GID; seenTemporaryGids.Add(gid); var expiresAtUtc = NormalizeToUtc(group.ExpiresAt.Value); var remaining = expiresAtUtc - nowUtc; if (!_trackedGroups.TryGetValue(gid, out var state)) { state = new TrackedGroup(expiresAtUtc); _trackedGroups[gid] = state; } else if (state.ExpiresAtUtc != expiresAtUtc) { state.UpdateExpiresAt(expiresAtUtc); } if (remaining <= TimeSpan.Zero) { _trackedGroups.Remove(gid); expiredGroups.Add(group); continue; } if (!state.LastRemaining.HasValue) { state.UpdateRemaining(remaining); continue; } var previousRemaining = state.LastRemaining.Value; foreach (var thresholdMinutes in NotificationThresholdMinutes) { var threshold = TimeSpan.FromMinutes(thresholdMinutes); if (previousRemaining > threshold && remaining <= threshold) { notifications.Add(new NotificationPayload(group, thresholdMinutes, expiresAtUtc)); } } state.UpdateRemaining(remaining); } var toRemove = _trackedGroups.Keys.Where(k => !seenTemporaryGids.Contains(k)).ToList(); foreach (var gid in toRemove) { _trackedGroups.Remove(gid); } } foreach (var expiredGroup in expiredGroups) { Logger.LogInformation("Temporary syncshell {gid} expired locally; removing", expiredGroup.Group.GID); _pairManager.RemoveGroup(expiredGroup.Group); } foreach (var notification in notifications) { PublishNotification(notification.Group, notification.ThresholdMinutes, notification.ExpiresAtUtc); } } private void PublishNotification(GroupFullInfoDto group, int thresholdMinutes, DateTime expiresAtUtc) { string displayName = string.IsNullOrWhiteSpace(group.GroupAlias) ? group.Group.GID : group.GroupAlias!; string threshold = thresholdMinutes == 1 ? "1 minute" : $"{thresholdMinutes} minutes"; string expiresLocal = expiresAtUtc.ToLocalTime().ToString("t", CultureInfo.CurrentCulture); string message = $"La Syncshell temporaire \"{displayName}\" sera supprimee dans {threshold} (a {expiresLocal})."; Mediator.Publish(new NotificationMessage("Syncshell temporaire", message, NotificationType.Warning, TimeSpan.FromSeconds(6))); } private static DateTime NormalizeToUtc(DateTime expiresAt) { return expiresAt.Kind switch { DateTimeKind.Utc => expiresAt, DateTimeKind.Local => expiresAt.ToUniversalTime(), _ => DateTime.SpecifyKind(expiresAt, DateTimeKind.Utc) }; } private void ResetTrackedGroups() { using (var guard = _stateLock.EnterScope()) { _trackedGroups.Clear(); } } private sealed class TrackedGroup { public TrackedGroup(DateTime expiresAtUtc) { ExpiresAtUtc = expiresAtUtc; } public DateTime ExpiresAtUtc { get; private set; } public TimeSpan? LastRemaining { get; private set; } public void UpdateExpiresAt(DateTime expiresAtUtc) { ExpiresAtUtc = expiresAtUtc; LastRemaining = null; } public void UpdateRemaining(TimeSpan remaining) { LastRemaining = remaining; } } private sealed record NotificationPayload(GroupFullInfoDto Group, int ThresholdMinutes, DateTime ExpiresAtUtc); }