using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; namespace LehrerApp.Desktop.Services; public sealed record AnnualPlanPollResult(int EventCount, int Added, int Updated, int Deleted) { public int ChangeCount => Added + Updated + Deleted; } /// /// Periodischer Vollabgleich des ClassyPlan-Jahresplans. Dieser Dienst hat bewusst keinerlei /// Abhängigkeit zu Stundenplan-, Vertretungs- oder Lerngruppen-Repositories. /// public sealed class AnnualPlanSyncService : IDisposable { private static readonly TimeSpan PollInterval = TimeSpan.FromHours(6); private readonly HttpClient _http; private readonly AnnualPlanSettingsService _settings; private readonly IAnnualPlanEventRepository _events; private readonly AppLogger? _logger; private readonly SemaphoreSlim _gate = new(1, 1); private readonly Timer _timer; public event Action? DataChanged; public AnnualPlanSyncService(HttpClient http, AnnualPlanSettingsService settings, IAnnualPlanEventRepository events, AppLogger? logger = null) { _http = http; _settings = settings; _events = events; _logger = logger; _timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval); } public async Task PollAsync(CancellationToken cancellationToken = default) { if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return; try { var url = _settings.GetIcalUrl(); if (string.IsNullOrWhiteSpace(url)) return; string icsText; try { icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _logger?.Error("Jahresplan: Abruf fehlgeschlagen", ex); _settings.SetLastSync(DateTime.UtcNow, $"Fehler beim Abruf: {ex.Message}"); return; } AnnualPlanPollResult result; try { result = await Task.Run(() => ProcessIcsText(icsText)).ConfigureAwait(false); } catch (Exception ex) { _logger?.Error("Jahresplan: Verarbeitung fehlgeschlagen", ex); _settings.SetLastSync(DateTime.UtcNow, $"Fehler bei der Verarbeitung: {ex.Message}"); return; } _settings.SetLastSync(DateTime.UtcNow, $"{result.EventCount} Termine — {result.Added} neu, {result.Updated} geändert, {result.Deleted} entfernt."); _logger?.Info($"Jahresplan: {result.EventCount} Termine, {result.ChangeCount} Änderung(en)."); if (result.ChangeCount > 0) DataChanged?.Invoke(); } finally { _gate.Release(); } } /// HTTP-freier, vollständig testbarer Snapshot-Abgleich. public AnnualPlanPollResult ProcessIcsText(string icsText) { // Parse muss vollständig erfolgreich sein, bevor das Repository verändert wird. Der // strikte Parser verhindert so Teilimporte und fälschliches Löschen des Restbestands. var imported = AnnualPlanIcsParser.Parse(icsText); var existing = _events.GetAll(); var existingByExternalId = existing.ToDictionary(e => e.ExternalId, StringComparer.Ordinal); var importedIds = imported.Select(e => e.ExternalId).ToHashSet(StringComparer.Ordinal); var added = 0; var updated = 0; foreach (var entry in imported) { if (!existingByExternalId.TryGetValue(entry.ExternalId, out var previous)) { _events.Save(entry); added++; continue; } entry.Id = previous.Id; if (SameContent(previous, entry)) continue; _events.Save(entry); updated++; } var stale = existing.Where(e => !importedIds.Contains(e.ExternalId)).ToList(); foreach (var entry in stale) _events.Delete(entry.Id); return new AnnualPlanPollResult(imported.Count, added, updated, stale.Count); } private static bool SameContent(AnnualPlanEvent left, AnnualPlanEvent right) => left.ExternalId == right.ExternalId && left.Title == right.Title && left.Description == right.Description && left.Location == right.Location && left.StartDate == right.StartDate && left.EndDate == right.EndDate && left.StartTime == right.StartTime && left.EndTime == right.EndTime && left.IsAllDay == right.IsAllDay && left.CalendarGroup == right.CalendarGroup && left.Color == right.Color && left.Status == right.Status && left.Sequence == right.Sequence && SameInstant(left.SourceLastModifiedUtc, right.SourceLastModifiedUtc); private static bool SameInstant(DateTime? left, DateTime? right) => left is null && right is null || left is not null && right is not null && left.Value.ToUniversalTime() == right.Value.ToUniversalTime(); public void Clear() { var existing = _events.GetAll(); foreach (var entry in existing) _events.Delete(entry.Id); if (existing.Count > 0) DataChanged?.Invoke(); } public void Dispose() { _timer.Dispose(); _gate.Dispose(); } }