using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Templating;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// Erzeugt zu einer Klausur einen Satz individueller Deckblätter als ein Sammel-PDF.
/// Zur Auswahl stehen nur Vorlagen mit report-type=exam-cover, egal in welcher der beiden
/// Vorlagenbibliotheken sie importiert wurden. Mit trägt dieselbe
/// Vorlage Punkte und Note ein oder bleibt als Blanko-Deckblatt zum Ausfüllen von Hand.
public partial class ExamCoverSheetDialogViewModel : ObservableObject
{
private readonly Exam _exam;
private readonly LearningGroup _group;
private readonly string _subjectName;
private readonly GradingService _grading;
private readonly TemplateStore _loader;
private readonly ITemplateBatchRenderer _renderer;
[ObservableProperty] private LetterTemplateChoice? _selectedTemplate;
[ObservableProperty] private bool _fillResults;
[ObservableProperty] private string _statusMessage = "";
/// Vorbelegt aus den Einstellungen, hier nur für diesen Druck änderbar (z. B. zwei
/// Kürzel bei einem gemeinsam unterrichteten Kurs) — die Einstellung bleibt unberührt.
[ObservableProperty] private string _teacherAbbreviation;
/// Vorbelegt mit dem internen Klausurtitel, hier nur für diesen Druck änderbar — der
/// Titel der Klausur bleibt unberührt. Leer gelassen gilt wieder der interne Titel.
[ObservableProperty] private string _coverTitle;
[ObservableProperty, NotifyPropertyChangedFor(nameof(CanGenerate))] private bool _isGenerating;
[ObservableProperty] private int _progressValue;
[ObservableProperty] private int _progressMaximum = 1;
/// Alle Deckblätter sind gesetzt, das Sammel-PDF entsteht — dieser Schritt meldet
/// keinen Zwischenstand, der Balken läuft deshalb unbestimmt.
[ObservableProperty] private bool _isMerging;
[ObservableProperty] private string _progressText = "";
public string ExamTitle => _exam.Title;
public string ExamLabel => $"{_exam.Title} · {_group.Name} · {_exam.Date:dd.MM.yyyy}";
public ObservableCollection Templates { get; } = [];
public ObservableCollection Students { get; } = [];
public bool HasNoTemplates => Templates.Count == 0;
public bool CanGenerate => !HasNoTemplates && !IsGenerating;
public string SuggestedFileName => SanitizeFileName($"Deckblätter_{_group.Name}_{_exam.Title}.pdf");
public ExamCoverSheetDialogViewModel(Exam exam, LearningGroup group, string subjectName,
IStudentRepository students, IGroupMembershipRepository memberships, IExamResultRepository results,
GradingService grading, IReadOnlyList templateStores, ITemplateBatchRenderer renderer,
string teacherAbbreviation = "")
{
_teacherAbbreviation = teacherAbbreviation;
_coverTitle = exam.Title;
_exam = exam; _group = group; _subjectName = subjectName; _grading = grading; _renderer = renderer;
_loader = templateStores[0];
foreach (var template in templateStores.SelectMany(x => x.GetTemplates()).Where(x => x.IsExamCover)
.DistinctBy(x => x.Id).OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
Templates.Add(new(template));
SelectedTemplate = Templates.FirstOrDefault();
var membershipList = memberships.GetByGroup(group.Id);
var existing = results.GetByExam(exam.Id).ToDictionary(r => r.StudentId);
var wroteOriginal = ExamParticipantService.WroteOriginal(exam, results.GetByExam);
foreach (var student in students.GetByGroup(group.Id).OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
var membership = membershipList.FirstOrDefault(m => m.StudentId == student.Id);
existing.TryGetValue(student.Id, out var result);
if (!ExamParticipantService.Includes(exam, membership, result is not null, wroteOriginal.Contains(student.Id)))
continue;
Students.Add(new(student, result, membership?.Niveau));
}
// Vor der Korrektur ist das Blanko-Deckblatt der Normalfall, danach das ausgefüllte.
FillResults = Students.Any(s => s.IsCorrected);
}
/// Schreibt das Sammel-PDF und meldet den Erfolg. Gesetzt wird im Hintergrund, damit
/// der Dialog bei einem Klassensatz bedienbar bleibt und den Fortschritt zeigen kann.
public async Task GenerateAsync(string outputPath)
{
StatusMessage = "";
if (SelectedTemplate is null || IsGenerating) return false;
var included = Students.Where(s => s.IsIncluded).ToList();
if (included.Count == 0) { StatusMessage = "Kein Schüler ausgewählt."; return false; }
try
{
var loaded = _loader.Load(SelectedTemplate.Model);
var today = DateOnly.FromDateTime(DateTime.Now);
var data = included.Select(s => (ITemplateDataProvider)new LetterDataProvider(
ExamCoverPlaceholderBuilder.Build(_exam, _group, _subjectName, s.Model, s.Result, FillResults,
_grading, loaded, today, s.Niveau, TeacherAbbreviation, CoverTitle))).ToList();
ReportProgress(0, included.Count);
IsGenerating = true;
var progress = new Progress(done => ReportProgress(done, included.Count));
var result = await Task.Run(() =>
{
var rendered = _renderer.RenderBatchToPdf(loaded, data, progress);
File.WriteAllBytes(outputPath, rendered.Pdf);
return rendered;
});
var message = $"{included.Count} Deckblätter erzeugt.";
var blank = FillResults ? included.Count(s => !s.IsCorrected) : 0;
if (blank > 0) message += $" {blank} davon ohne Punkte und Note (noch nicht korrigiert).";
var overflowing = result.PageCounts.Count(pages => pages > 1);
if (overflowing > 0)
message += $" Achtung: {overflowing} Deckblätter passen nicht auf eine Seite — die Punktetabelle braucht mehr Platz, als die Vorlage lässt.";
StatusMessage = message;
return true;
}
catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException)
{ StatusMessage = $"Deckblätter konnten nicht erzeugt werden: {ex.Message}"; return false; }
finally { IsGenerating = false; }
}
private void ReportProgress(int done, int total)
{
ProgressMaximum = total;
ProgressValue = done;
IsMerging = done >= total && total > 1;
ProgressText = IsMerging
? "Sammel-PDF wird zusammengefügt …"
: $"{done} von {total} Deckblättern gesetzt …";
}
private static string SanitizeFileName(string value)
{ foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; }
}
public partial class ExamCoverStudentChoice : ObservableObject
{
public Student Model { get; }
public ExamResult? Result { get; }
public Niveau? Niveau { get; }
public string FullName => Model.FullName;
public bool IsCorrected => ExamCoverSheetService.IsCorrected(Result);
public string Hint => Result is { Absent: true } ? "abwesend" : "";
[ObservableProperty] private bool _isIncluded;
public ExamCoverStudentChoice(Student model, ExamResult? result, Niveau? niveau = null)
{
Model = model; Result = result; Niveau = niveau;
// Wer gefehlt hat, bekommt kein Deckblatt — lässt sich im Dialog wieder anhaken.
_isIncluded = result is not { Absent: true };
}
}