using System.Collections.Concurrent;
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
///
/// MCP-Tools für die WebUntis-Abgleiche (Nutzer-Nachtrag zum Untis-Hub, siehe TODO.md), zweigleisig
/// wie vom Nutzer entschieden:
///
/// - / (bevorzugter Weg): eine
/// rein technische row-id ordnet zurück, nie ein Schülername - nur Zeilen, die WebUntis
/// selbst eindeutig über die externe Schülernummer (ENr) einem Kursmitglied zuordnet, werden
/// überhaupt gelistet (unklare, nur namensbasiert auflösbare Fälle fließen bewusst NICHT hier
/// hinein, siehe ).
/// - (bewusste, eng begrenzte Ausnahme von der sonst in
/// geltenden Regel, dass personenbezogene Verhaltens-/Anwesenheitsdaten
/// nie mit einem Namen verknüpft nach außen gehen): exponiert Name UND Fehlzeiten gemeinsam, aber
/// nur für explizit angegebene Schüler-IDs und nur nach JEDES MAL gesonderter, prominenter
/// Bestätigung ohne Sitzungsfreigabe (allowSessionTrust: false).
///
/// ergänzt beide Wege um einen Überblick, welche Abgleiche laut
/// überhaupt fällig sind, ohne selbst WebUntis anzufragen.
///
public class UntisComparisonTools(
IGroupRepository groups, IStudentRepository students, IParticipationSessionRepository sessions,
IParticipationRepository participation, WebUntisIntegrationService untis, UntisHubService hub,
IMcpConfirmationService confirmation)
{
// Statuswerte, die ApplyUntisAbsenceStatus akzeptiert - dieselbe Einschränkung wie
// WebUntisLessonAbsenceRow.SelectableStatuses im interaktiven Dialog (nicht z.B. "Geschwänzt"
// oder "Suspendiert", die WebUntis hier nie meldet). Eigenständig gehalten statt der Row-Klasse
// referenziert, da Tool-Klassen unter Services/Mcp nicht von ViewModel-Klassen abhängen sollen
// (siehe GradeTools).
private static readonly AttendanceStatus[] SelectableStatuses =
[
AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused,
AttendanceStatus.Late, AttendanceStatus.LeftDuringClass, AttendanceStatus.Present,
];
// In-Memory, pro Prozesslaufzeit - eine row-id aus GetUntisAbsenceRows ist nur bis zum nächsten
// Neustart von LehrerApp gültig; danach muss der KI-Client die Liste erneut abrufen. Bewusst
// keine Ablauf-/Größenbegrenzung (siehe Nutzerdiskussion: geringe Nutzungsfrequenz, winzige
// Einträge) - ein v1-Kompromiss, kein Deployment-Risiko wie bei den ai-backend-Endpunkten.
private readonly ConcurrentDictionary _pendingRows = new();
private sealed record PendingAbsenceRow(Guid StudentId, Guid SessionId, Guid GroupId, DateOnly Date);
[Description("Listet die Fälligkeit der Untis-Hub-Abgleiche (Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-/Hausaufgabenabgleich) - reine Lesefunktion aus der lokalen Fälligkeits-Historie, kein eigener WebUntis-Zugriff.")]
public List GetUntisHubStatus() =>
hub.GetRows().Select(r => new UntisHubStatusRowDto(
r.Kind.ToString(), r.GroupName, r.DueState.ToString(), r.DueLabel, r.LastRunAt, r.LastResultSummary)).ToList();
[Description("""
Listet Fehlzeiten-Diskrepanzen einer Lerngruppe gegenüber WebUntis in einem Zeitraum, ANONYMISIERT:
enthält keinen Schülernamen, nur eine technische row-id je Zeile (für apply_untis_absence_status).
Enthält nur Zeilen, die WebUntis über die externe Schülernummer eindeutig einem Kursmitglied zuordnen
konnte - Zeilen, die nur über den Namen auflösbar wären, fehlen hier bewusst; für die braucht es
get_named_untis_absence_pattern (Namen exponierend, gesondert bestätigungspflichtig).
""")]
public async Task> GetUntisAbsenceRows(
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Startdatum, Format YYYY-MM-DD.")] DateOnly startDate,
[Description("Enddatum, Format YYYY-MM-DD.")] DateOnly endDate,
CancellationToken ct = default)
{
var group = groups.GetById(groupId);
if (group?.WebUntisLessonId is not { } lessonId) return [];
var courseStudents = students.GetByGroup(groupId);
var byExternKey = courseStudents
.Select(s => (Student: s, Key: UntisLessonAbsenceHelper.StudentExternKey(s)))
.Where(x => x.Key is not null)
.ToDictionary(x => x.Key!.Value, x => x.Student);
var localSessions = sessions.GetByGroup(groupId)
.Where(s => s.Date >= startDate && s.Date <= endDate)
.GroupBy(s => s.Date).ToDictionary(g => g.Key, g => g.First());
var absences = await untis.GetLessonAbsencesAsync(lessonId, startDate, endDate, ct);
var rows = new List();
foreach (var absence in absences)
{
if (absence.ExternKey is not { } key || !byExternKey.TryGetValue(key, out var student)) continue;
if (!TryParseDate(absence.Date, out var date) || !localSessions.TryGetValue(date, out var session)) continue;
var rowId = Guid.NewGuid().ToString("N");
_pendingRows[rowId] = new PendingAbsenceRow(student.Id, session.Id, groupId, date);
var entry = participation.GetBySessionAndStudent(session.Id, student.Id);
var guess = UntisLessonAbsenceHelper.MapStatus(absence);
rows.Add(new UntisAbsenceRowDto(
rowId, date, absence.Reason ?? "", absence.AbsentMinutes,
!string.IsNullOrWhiteSpace(absence.HandledOn),
absence.ExternKey is null ? null : absence.ExternKeyInParentheses,
LocalStatusLabel(entry?.Attendance), guess.ToString()));
}
return rows;
}
[Description("Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id in den lokalen Anwesenheitsstatus. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen; die Bestätigungsmeldung nennt bewusst KEINEN Schülernamen (nur Datum, Lerngruppe, Zielstatus).")]
public async Task ApplyUntisAbsenceStatus(
[Description("row-id aus get_untis_absence_rows.")] string rowId,
[Description("Zielstatus: Present, Late, LeftDuringClass, ExcusePending, Excused oder Unexcused.")] string status,
CancellationToken ct = default)
{
if (!Enum.TryParse(status, out var target) || !SelectableStatuses.Contains(target))
return new WriteResultDto(false, null, "Unbekannter oder nicht zulässiger Status.");
if (!_pendingRows.TryGetValue(rowId, out var row))
return new WriteResultDto(false, null, "Unbekannte oder abgelaufene row-id - zuerst get_untis_absence_rows erneut aufrufen.");
var groupName = groups.GetById(row.GroupId)?.Name ?? "?";
var message = $"Fehlzeile vom {row.Date:dd.MM.yyyy} in Lerngruppe „{groupName}“: " +
$"Anwesenheitsstatus auf „{LocalStatusLabel(target)}“ setzen?";
if (!await confirmation.ConfirmAsync("Fehlzeiten-Status übernehmen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var entry = participation.GetBySessionAndStudent(row.SessionId, row.StudentId)
?? new ParticipationEntry { SessionId = row.SessionId, StudentId = row.StudentId };
entry.Attendance = target;
entry.UpdatedAt = DateTime.UtcNow;
participation.Save(entry);
_pendingRows.TryRemove(rowId, out _);
return new WriteResultDto(true, entry.Id, "Status übernommen.");
}
[Description("""
Liefert Fehlzeiten für EXPLIZIT angegebene Schüler-IDs MIT Namen (z.B. für einen Bericht oder einen
Fehlmuster-Vergleich zwischen zwei Schülern) - bewusste, eng begrenzte Ausnahme von der sonst
geltenden Anonymisierung (siehe get_untis_absence_rows). So wenige studentIds wie für die Anfrage
nötig angeben, nicht den ganzen Kurs. Erfordert JEDES MAL eine gesonderte, prominente
Nutzerbestätigung ohne Sitzungsfreigabe - liefert bei Ablehnung granted:false und keine Zeilen.
""")]
public async Task GetNamedUntisAbsencePattern(
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Ids der Schüler, für die Name UND Fehlzeiten gemeinsam offengelegt werden sollen.")] List studentIds,
[Description("Startdatum, Format YYYY-MM-DD.")] DateOnly startDate,
[Description("Enddatum, Format YYYY-MM-DD.")] DateOnly endDate,
CancellationToken ct = default)
{
var group = groups.GetById(groupId);
if (group?.WebUntisLessonId is not { } lessonId)
return new NamedUntisAbsenceResultDto(false, "Für diese Lerngruppe ist keine WebUntis-Unterrichtsnummer hinterlegt.", null);
var resolvedStudents = studentIds.Distinct()
.Select(students.GetById).Where(s => s is not null).Cast().ToList();
if (resolvedStudents.Count == 0)
return new NamedUntisAbsenceResultDto(false, "Keine der angegebenen Schüler-IDs ist bekannt.", null);
var names = string.Join(", ", resolvedStudents.Select(s => s.FullName));
var message = $"Name UND Fehlzeiten gemeinsam an den KI-Assistenten weitergeben für:\n{names}\n\n" +
$"Zeitraum: {startDate:dd.MM.yyyy}–{endDate:dd.MM.yyyy}, Lerngruppe „{group.Name}“.";
if (!await confirmation.ConfirmAsync("Namentliche Fehlzeitenauskunft freigeben?", message, ct, allowSessionTrust: false))
return new NamedUntisAbsenceResultDto(false, "Vom Nutzer abgelehnt oder nicht bestätigt.", null);
var byExternKey = resolvedStudents
.Select(s => (Student: s, Key: UntisLessonAbsenceHelper.StudentExternKey(s)))
.Where(x => x.Key is not null)
.ToDictionary(x => x.Key!.Value, x => x.Student);
var byName = resolvedStudents
.SelectMany(s => new[]
{
NameKey($"{s.LastName} {s.FirstName}"), NameKey($"{s.FirstName} {s.LastName}"),
}.Select(key => (Key: key, Student: s)))
.GroupBy(x => x.Key).Where(g => g.Select(x => x.Student).Distinct().Count() == 1)
.ToDictionary(g => g.Key, g => g.First().Student);
var localSessions = sessions.GetByGroup(groupId)
.Where(s => s.Date >= startDate && s.Date <= endDate)
.GroupBy(s => s.Date).ToDictionary(g => g.Key, g => g.First());
var absences = await untis.GetLessonAbsencesAsync(lessonId, startDate, endDate, ct);
var rows = new List();
foreach (var absence in absences)
{
var match = absence.ExternKey is { } key && byExternKey.TryGetValue(key, out var byKeyStudent)
? byKeyStudent
: byName.GetValueOrDefault(NameKey(absence.StudentName));
if (match is null) continue; // nur die explizit freigegebenen Schüler, nie "geraten"
if (!TryParseDate(absence.Date, out var date)) continue;
var entry = localSessions.TryGetValue(date, out var session)
? participation.GetBySessionAndStudent(session.Id, match.Id) : null;
rows.Add(new NamedUntisAbsenceRowDto(
match.Id, match.FullName, date, absence.Reason ?? "",
LocalStatusLabel(entry?.Attendance), UntisLessonAbsenceHelper.MapStatus(absence).ToString()));
}
return new NamedUntisAbsenceResultDto(true, $"{rows.Count} Fehlzeile(n) für {resolvedStudents.Count} Schüler.", rows);
}
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
private static bool TryParseDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
// Eigene, schlanke Beschriftung statt AttendanceDisplay (Views-/ViewModel-Bezug) - Tool-Klassen
// unter Services/Mcp sollen nicht von ViewModel-Klassen abhängen (siehe GradeTools).
private static string LocalStatusLabel(AttendanceStatus? s) => s switch
{
null => "kein Status erfasst",
AttendanceStatus.Present => "Anwesend",
AttendanceStatus.ExcusePending => "Krank (Entschuldigung offen)",
AttendanceStatus.Excused => "Krank, entschuldigt",
AttendanceStatus.Unexcused => "Krank, unentschuldigt",
AttendanceStatus.Late => "Verspätet",
AttendanceStatus.LeftDuringClass => "Während des Unterrichts abgängig",
_ => s.ToString()!,
};
}