using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; namespace LehrerApp.Desktop.Services; /// Ergebnis eines einzelnen Verarbeitungsdurchlaufs (Abruf oder Test-Text) — für /// Statusanzeige/Logging. public sealed record UntisPollResult(int EventCount, int SubstitutionCount); /// Für den Zuordnungs-Review-Dialog: das Rohergebnis der Musteranalyse (Stufe 1) für /// einen konkreten Abruf, ohne dass dabei schon etwas gespeichert wird. public sealed record UntisMatchPreview(int EventCount, UntisMatchResult Matches); /// /// Orchestriert den periodischen WebUntis-iCal-Abgleich (siehe TODO.md/Planungsdokument): Abruf /// per HTTP, Parsen (IcsParser), Musterabgleich (UntisMatchingService) sowie laufender /// Schnappschuss-Abgleich (UntisDiffService), dessen Ergebnis über die Repositories geschrieben /// wird. Gleiches Timer/Gate/Dispose-Muster wie LehrerApp.Sync.SyncEngine. /// /// Der reine Verarbeitungskern () ist bewusst ohne HTTP-Zugriff /// gehalten (public statt internal, da diese Codebasis kein InternalsVisibleTo nutzt), damit er /// direkt mit vorgefertigtem ICS-Text getestet werden kann, ohne einen echten Abruf zu brauchen. /// public class UntisSyncService : IDisposable { private const int PollIntervalMinutes = 60; private readonly HttpClient _http; private readonly WebUntisSettingsService _settings; private readonly IUntisSnapshotRepository _snapshots; private readonly IUntisSlotMappingRepository _mappings; private readonly ISubstitutionEntryRepository _substitutions; private readonly IGroupRepository _groups; private readonly ITimetableSlotRepository _timetableSlots; private readonly ISupervisionDutyRepository _supervisionDuties; private readonly ISchoolHolidayRepository _schoolHolidays; private readonly PublicHolidayService _publicHolidays; private readonly SchoolCalendarSettingsService _calendarSettings; private readonly PeriodScheduleService _periodSchedule; private readonly UntisMatchingService _matchingService; private readonly UntisDiffService _diffService; private readonly AppLogger? _logger; private readonly SemaphoreSlim _gate = new(1, 1); private readonly Timer _timer; /// Feuert, wenn ein Poll tatsächlich Vertretungen geschrieben hat — Timetable/Dashboard können /// dann bei Bedarf neu laden (EventApplier-Muster: direktes Schreiben an ViewModels vorbei). public event Action? DataChanged; public UntisSyncService(HttpClient http, WebUntisSettingsService settings, IUntisSnapshotRepository snapshots, IUntisSlotMappingRepository mappings, ISubstitutionEntryRepository substitutions, IGroupRepository groups, ITimetableSlotRepository timetableSlots, ISupervisionDutyRepository supervisionDuties, ISchoolHolidayRepository schoolHolidays, PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, UntisMatchingService matchingService, UntisDiffService diffService, AppLogger? logger = null) { _http = http; _settings = settings; _snapshots = snapshots; _mappings = mappings; _substitutions = substitutions; _groups = groups; _timetableSlots = timetableSlots; _supervisionDuties = supervisionDuties; _schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings; _periodSchedule = periodSchedule; _matchingService = matchingService; _diffService = diffService; _logger = logger; _timer = new Timer(async _ => await PollAsync(), null, TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes)); } public async Task PollAsync() { if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return; try { var url = _settings.GetIcalUrl(); if (string.IsNullOrEmpty(url)) return; string icsText; try { icsText = await _http.GetStringAsync(url).ConfigureAwait(false); } catch (Exception ex) { _logger?.Error("WebUntis-Abgleich: Abruf fehlgeschlagen", ex); _settings.SetLastSync(DateTime.UtcNow, $"Fehler beim Abruf: {ex.Message}"); return; } UntisPollResult result; try { result = await Task.Run(() => ProcessIcsText(icsText)).ConfigureAwait(false); } catch (Exception ex) { // Darf NICHT aus PollAsync herausfallen: der Timer-Callback fängt keine // Ausnahmen ab, eine unbehandelte Exception hier würde den gesamten Prozess // beenden (gleiche Begründung wie EventApplier in LehrerApp.Sync). _logger?.Error("WebUntis-Abgleich: Verarbeitung fehlgeschlagen", ex); _settings.SetLastSync(DateTime.UtcNow, $"Fehler bei der Verarbeitung: {ex.Message}"); return; } _settings.SetLastSync(DateTime.UtcNow, $"{result.SubstitutionCount} Änderung(en) erkannt ({result.EventCount} Termine geprüft)."); _logger?.Info($"WebUntis-Abgleich: {result.SubstitutionCount} Änderung(en) aus {result.EventCount} Terminen."); if (result.SubstitutionCount > 0) DataChanged?.Invoke(); } finally { _gate.Release(); } } // Reiner Kern ohne HTTP - direkt mit vorgefertigtem ICS-Text testbar (kein InternalsVisibleTo // in dieser Codebasis üblich, siehe CLAUDE.md - deshalb public statt internal). public UntisPollResult ProcessIcsText(string icsText) { var events = IcsParser.Parse(icsText); var previousSnapshot = _snapshots.GetAll(); var confirmedMappings = _mappings.GetAll(); var today = DateOnly.FromDateTime(DateTime.Today); var diffResult = _diffService.Diff(events, previousSnapshot, confirmedMappings, today, existingSupervisionDuties: _supervisionDuties.GetAll(), freeDates: BuildFreeDates(today)); var savedSubstitutionCount = 0; foreach (var candidate in diffResult.SubstitutionsToSave) { var existing = candidate.ExternalId is not null ? _substitutions.GetByExternalId(candidate.ExternalId) : null; // Der Diff liefert auch weiterhin bestehende WebUntis-Abweichungen als Kandidaten. // Ein unverändertes Upsert würde über Repository.OnChange bei jedem Poll erneut ein // Sync-Ereignis erzeugen und bei vielen aktiven Abweichungen das Protokoll fluten. if (existing is not null) { candidate.Id = existing.Id; if (SubstitutionContentEquals(existing, candidate)) continue; } _substitutions.Save(candidate); savedSubstitutionCount++; } // Zuvor automatisch erzeugte Einträge, die jetzt nicht (mehr) gebraucht werden (siehe // UntisDiffResult.SubstitutionExternalIdsToDelete) - existiert keiner mit dieser // ExternalId, ist das ein no-op. foreach (var externalId in diffResult.SubstitutionExternalIdsToDelete) { var stale = _substitutions.GetByExternalId(externalId); if (stale is not null) _substitutions.Delete(stale.Id); } foreach (var snapshot in diffResult.SnapshotToSave) _snapshots.Save(snapshot); foreach (var id in diffResult.SnapshotIdsToDelete) _snapshots.Delete(id); return new UntisPollResult(events.Count, savedSubstitutionCount); } private static bool SubstitutionContentEquals(SubstitutionEntry left, SubstitutionEntry right) => left.Date == right.Date && left.Kind == right.Kind && left.PeriodNumber == right.PeriodNumber && left.AfterPeriod == right.AfterPeriod && left.FromPeriod == right.FromPeriod && left.ToPeriod == right.ToPeriod && left.IsAllDay == right.IsAllDay && left.GroupId == right.GroupId && left.GroupLabel == right.GroupLabel && left.Description == right.Description && left.Notes == right.Notes && left.ExternalId == right.ExternalId; // Ferien-/Feiertagstage im relevanten Zeitfenster (deutlich über das Lookahead-Fenster // hinaus, kostet bei kleinen Ferienlisten nichts) - verhindert, dass die aktive // "fehlt komplett im Feed"-Prüfung in UntisDiffService Ferientage fälschlich als Ausfall // meldet, an denen WebUntis ohnehin keine Termine führt. Gleiche Logik wie // TimetableViewModel.IsFreeDay, hier separat gehalten statt geteilt, da UntisDiffService // (LehrerApp.Core) bewusst framework-frei bleibt und keine Desktop-ViewModels referenziert. private HashSet BuildFreeDates(DateOnly today) { var horizonEnd = today.AddDays(90); var freeDates = new HashSet(); foreach (var year in new[] { today.Year, today.Year + 1 }) foreach (var h in _publicHolidays.GetHolidays(year, _calendarSettings.State)) freeDates.Add(h.Date); var schoolHolidays = _schoolHolidays.GetAll(); for (var date = today; date <= horizonEnd; date = date.AddDays(1)) if (schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate)) freeDates.Add(date); return freeDates; } /// Für den Zuordnungs-Review-Dialog (Stufe 1, ohne etwas zu speichern). public UntisMatchPreview BuildMatchPreview(string icsText) { var events = IcsParser.Parse(icsText); var groups = _groups.GetAll(); var timetableSlots = _timetableSlots.GetAll(); var matches = _matchingService.BuildMatches(events, groups, timetableSlots, _periodSchedule); return new UntisMatchPreview(events.Count, matches); } public async Task FetchAndBuildMatchPreviewAsync() { var url = _settings.GetIcalUrl(); if (string.IsNullOrEmpty(url)) return null; var icsText = await _http.GetStringAsync(url); return BuildMatchPreview(icsText); } public void ConfirmMappings(IEnumerable confirmed) { foreach (var mapping in confirmed) { mapping.Confirmed = true; _mappings.Save(mapping); } } public void Dispose() { _timer.Dispose(); _gate.Dispose(); } }