using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.AiPlanning;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar (statt stillschweigend
/// weggefiltert zu werden) - kann manuell per Auswahlliste gesetzt
/// werden, wenn weder `ENr` noch der Name eindeutig auf ein Kursmitglied passen.
public partial class WebUntisLessonAbsenceRow : ObservableObject
{
public required string UntisStudentName { get; init; }
public required DateOnly Date { get; init; }
public required string TimeLabel { get; init; }
public required string UntisStatus { get; init; }
public required AttendanceStatus TargetStatus { get; init; }
public required IReadOnlyList Candidates { get; init; }
internal Action? OnAssignmentChanged { get; init; }
public string? Reason { get; init; }
public string DateLabel => Date.ToString("dd.MM.yyyy");
public bool CanApply => SessionId is not null;
// Nur für den optionalen KI-Statusvorschlag mitgeführt (siehe
// WebUntisLessonAbsenceComparisonViewModel.SuggestStatusWithAi) - dieselben Rohsignale, aus
// denen MapStatus den TargetStatus berechnet, bewusst OHNE Name/Klasse/Datum, damit die Anfrage
// an das KI-Backend personenbezogen leer bleibt.
internal int AbsentMinutes { get; init; }
internal bool HandledOn { get; init; }
internal bool? ExternKeyInParentheses { get; init; }
// Statusübernahme ist per ComboBox anpassbar (Nutzer-Feedback: der aus WebUntis abgeleitete
// TargetStatus war über den reinen Anzeigetext oft nicht eindeutig nachvollziehbar; Ablehnen der
// ganzen Zeile und der Status manuell nachtragen war die einzige Korrekturmöglichkeit) - die
// ComboBox ist mit TargetStatus vorbelegt, aber vor "Übernehmen" frei änderbar. Bindet wie bei
// GradeCategoryDisplay über einen String-Wrapper statt direkt ans Enum (sonst ToString() auf
// Englisch). Nur die Stati, die MapStatus tatsächlich liefert bzw. die als Korrektur plausibel
// sind (nicht z.B. "Geschwänzt" oder "Suspendiert", die WebUntis hier nie meldet). Internal statt
// private, damit SuggestStatusWithAi eine von der KI zurückgegebene Statusangabe dagegen validieren
// kann, statt jeden von der KI genannten Enum-Namen blind zu übernehmen.
internal static readonly AttendanceStatus[] SelectableStatuses =
[
AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused,
AttendanceStatus.Late, AttendanceStatus.LeftDuringClass, AttendanceStatus.Present,
];
public static string[] StatusOptions { get; } = SelectableStatuses.Select(s => AttendanceDisplay.Label(s)).ToArray();
[ObservableProperty] private Student? _assignedStudent;
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
[ObservableProperty] private Guid? _sessionId;
[ObservableProperty] private bool _selected;
[ObservableProperty] private string _selectedStatusName = "";
public AttendanceStatus SelectedStatus =>
SelectableStatuses.FirstOrDefault(s => AttendanceDisplay.Label(s) == SelectedStatusName, TargetStatus);
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
}
/// Fehlzeitenabgleich über den "Fehlzeiten pro Unterricht"-Bericht ().
/// Der früher genutzte, pro Schüler abgerufene Fehlzeiten-Report (getTimetableWithAbsences) brauchte
/// weitergehende WebUntis-Rechte als dieser Lerngruppen-Bericht und wurde deshalb entfernt.
public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
{
private readonly LearningGroup _group;
private readonly WebUntisIntegrationService _untis;
private readonly IStudentRepository _students;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation;
private readonly AiPlanningService _ai;
private readonly AiSettingsService _aiSettings;
private IReadOnlyList _loadedStudents = [];
private IReadOnlyDictionary _loadedSessions =
new Dictionary();
public ObservableCollection Rows { get; } = [];
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddMonths(-2);
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
[ObservableProperty] private bool _busy;
[ObservableProperty] private bool _aiSuggestBusy;
[ObservableProperty] private bool _markUnknownAsPresent;
public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
IStudentRepository students, IParticipationSessionRepository sessions,
IParticipationRepository participation, AiPlanningService ai, AiSettingsService aiSettings)
{
_group = group; _untis = untis; _students = students; _sessions = sessions;
_participation = participation; _ai = ai; _aiSettings = aiSettings;
}
[RelayCommand]
private async Task Load()
{
var lessonId = _group.WebUntisLessonId;
if (lessonId is null)
{
Status = "Für diese Lerngruppe ist noch keine WebUntis-Unterrichtsnummer hinterlegt " +
"(Gruppe bearbeiten).";
return;
}
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
Busy = true; Rows.Clear();
try
{
var courseStudents = _students.GetByGroup(_group.Id);
var localSessions = _sessions.GetByGroup(_group.Id)
.Where(x => x.Date >= start && x.Date <= end).GroupBy(x => x.Date)
.ToDictionary(x => x.Key, x => x.First());
_loadedStudents = courseStudents;
_loadedSessions = localSessions;
// Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt
// per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb
// der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten.
var byKey = courseStudents.Select(student => (Student: student, Key: UntisLessonAbsenceHelper.StudentExternKey(student)))
.Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
var byName = courseStudents
.SelectMany(student => new[]
{
NameKey($"{student.LastName} {student.FirstName}"),
NameKey($"{student.FirstName} {student.LastName}"),
}.Select(key => (Key: key, Student: student)))
.GroupBy(x => x.Key)
.Where(group => group.Select(x => x.Student).Distinct().Count() == 1)
.ToDictionary(group => group.Key, group => group.First().Student);
var absences = await _untis.GetLessonAbsencesAsync(lessonId.Value, start, end);
var ordered = absences
.Select(absence => (Absence: absence, Date: TryDate(absence.Date, out var date) ? date : (DateOnly?)null))
.Where(x => x.Date is not null)
.OrderBy(x => x.Date).ThenBy(x => x.Absence.StudentName);
void ResolveLocalMatch(WebUntisLessonAbsenceRow row)
{
if (row.AssignedStudent is not { } student)
{
row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false;
return;
}
localSessions.TryGetValue(row.Date, out var session);
var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id);
row.SessionId = session?.Id;
row.LocalStatus = session is null ? "keine lokale Stunde" : AttendanceDisplay.Label(entry?.Attendance);
row.Selected = session is not null && entry?.Attendance != row.TargetStatus;
}
foreach (var (absence, date) in ordered)
{
var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent)
? byKeyStudent
: byName.GetValueOrDefault(NameKey(absence.StudentName));
var targetStatus = MapStatus(absence);
var row = new WebUntisLessonAbsenceRow
{
UntisStudentName = absence.StudentName, Date = date!.Value,
TimeLabel = TimeLabel(absence.StartTime, absence.EndTime),
UntisStatus = DisplayUntisStatus(absence),
TargetStatus = targetStatus, Reason = absence.Reason,
AbsentMinutes = absence.AbsentMinutes,
HandledOn = !string.IsNullOrWhiteSpace(absence.HandledOn),
ExternKeyInParentheses = absence.ExternKey is null ? null : absence.ExternKeyInParentheses,
Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch,
SelectedStatusName = AttendanceDisplay.Label(targetStatus),
};
Rows.Add(row);
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
}
var unresolved = Rows.Count(x => x.AssignedStudent is null);
Status = $"{absences.Count} Fehlzeiten von WebUntis erhalten, {Rows.Count - unresolved} automatisch zugeordnet" +
(unresolved > 0 ? $", {unresolved} bitte manuell zuordnen" : "") +
$". {Rows.Count(x => x.CanApply)} sind einer lokalen Kursstunde zuordenbar.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
///
/// Fragt für alle geladenen, einer lokalen Kursstunde zuordenbaren Zeilen in einer gebündelten
/// Anfrage (Kosten/Latenz, siehe AiPlanningService.RequestUntisStatusSuggestionsAsync) einen
/// KI-Statusvorschlag ab und setzt ihn nur in der "Übernahme als"-ComboBox vor - Namen, Klasse
/// und Datum verlassen dafür nie die App (siehe AiUntisStatusRow), nur die je Zeile rein
/// technische Positions-Id sowie die bereits lokal bekannten Rohsignale. Ersetzt nie
/// eigenständig einen bestehenden Übernahme-Status ohne Zutun der Lehrkraft - "Markierte
/// übernehmen" bleibt der einzige schreibende Schritt.
///
[RelayCommand]
private async Task SuggestStatusWithAi()
{
var token = _aiSettings.GetToken();
if (token is null)
{
Status = "Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden.";
return;
}
var candidates = Rows.Where(x => x.CanApply).ToList();
if (candidates.Count == 0)
{
Status = "Keine Zeilen mit lokaler Kursstunde geladen.";
return;
}
AiSuggestBusy = true;
try
{
var requestRows = candidates.Select((row, i) => new AiUntisStatusRow
{
Id = i.ToString(), ReasonText = row.Reason ?? "", AbsentMinutes = row.AbsentMinutes,
HandledOn = row.HandledOn, ExternKeyInParentheses = row.ExternKeyInParentheses,
CurrentGuess = row.TargetStatus.ToString(),
}).ToList();
var suggestions = await _ai.RequestUntisStatusSuggestionsAsync(requestRows, token);
var applied = 0;
for (var i = 0; i < candidates.Count; i++)
{
if (!suggestions.TryGetValue(i.ToString(), out var statusName)) continue;
if (!Enum.TryParse(statusName, out var status)) continue;
if (!WebUntisLessonAbsenceRow.SelectableStatuses.Contains(status)) continue;
candidates[i].SelectedStatusName = AttendanceDisplay.Label(status);
applied++;
}
Status = suggestions.Count == 0
? "Die KI hat keinen verwertbaren Vorschlag geliefert, die bisherige Vorbelegung bleibt unverändert."
: $"KI-Vorschlag für {applied} von {candidates.Count} Zeilen in \"Übernahme als\" vorbelegt - bitte prüfen.";
}
catch (AiBackendException ex) { Status = ex.Message; }
finally { AiSuggestBusy = false; }
}
[RelayCommand]
private void Apply()
{
var selected = Rows.Where(x => x.Selected && x.SessionId is not null && x.AssignedStudent is not null).ToList();
foreach (var row in selected)
{
var studentId = row.AssignedStudent!.Id;
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
entry.Attendance = row.SelectedStatus;
entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry);
}
var presentCount = MarkUnknownAsPresent ? FillUnknownAsPresent() : 0;
Status = $"{selected.Count} Anwesenheitsstatus übernommen." +
(MarkUnknownAsPresent ? $" {presentCount} unbekannte Status auf anwesend gesetzt." : "");
foreach (var row in selected) row.Selected = false;
}
// "Identifiziert" heißt hier: WebUntis hat für diesen Schüler an diesem Tag überhaupt eine Zeile
// gemeldet - unabhängig davon, ob die Zeile markiert/übernommen wurde. Nur wer für den geladenen
// Zeitraum weder von WebUntis gemeldet noch lokal schon kontrolliert wurde, gilt als "unbekannt"
// und wird auf anwesend gesetzt; bereits erfasste Einträge (auch ohne Anwesenheitsstatus, z.B. nur
// mit Notiz) werden nicht überschrieben, wenn ihr Anwesenheitsstatus schon gesetzt ist.
private int FillUnknownAsPresent()
{
var identified = Rows.Where(x => x.AssignedStudent is not null)
.Select(x => (x.Date, StudentId: x.AssignedStudent!.Id))
.ToHashSet();
var filled = 0;
foreach (var session in _loadedSessions.Values)
foreach (var student in _loadedStudents)
{
if (identified.Contains((session.Date, student.Id))) continue;
var entry = _participation.GetBySessionAndStudent(session.Id, student.Id);
if (entry?.Attendance is not null) continue;
entry ??= new ParticipationEntry { SessionId = session.Id, StudentId = student.Id };
entry.Attendance = AttendanceStatus.Present;
entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry);
filled++;
}
return filled;
}
// Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht
// dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen
// werden beim Aufbau von `byName` registriert; hier wird nur normalisiert.
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
// MapStatus/StudentKey leben jetzt in UntisLessonAbsenceHelper (framework-frei), damit
// UntisComparisonTools (MCP) exakt dieselbe Regel verwendet statt eines eigenen Duplikats, das
// aus dem Tritt geraten könnte.
private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence) =>
UntisLessonAbsenceHelper.MapStatus(absence);
// Nutzt dieselben deutschen Bezeichnungen wie die reguläre Mitarbeitserfassung
// (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden.
private static string DisplayUntisStatus(UntisLessonAbsenceDto absence) =>
string.Join(" · ", new[] { AttendanceDisplay.Label(MapStatus(absence)), absence.Reason }
.Where(x => !string.IsNullOrWhiteSpace(x)));
private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
private static string TimeLabel(int? start, int? end) =>
start is null || end is null ? "" : $"{Time(start.Value)}–{Time(end.Value)}";
private static string Time(int value) => $"{value / 100:00}:{value % 100:00}";
}