using Core.Entities; namespace Core.YearTransition; /// /// Inputs for building a year-transition plan. /// public sealed class YearTransitionRequest { public required IReadOnlyList Students { get; init; } public required IReadOnlySet ReturningStudentIds { get; init; } public IReadOnlyDictionary OfficerAssignments { get; init; } = new Dictionary(); public required int GraduatingGrade { get; init; } public required string TargetCompetitionYear { get; init; } public IReadOnlyList PastedNames { get; init; } = []; } /// /// Preview of a year transition before it is applied. /// public sealed class YearTransitionPlan { public required string TargetCompetitionYear { get; init; } public required int GraduatingGrade { get; init; } public required IReadOnlyList Promotions { get; init; } public required IReadOnlyList StudentsToRemove { get; init; } public required IReadOnlyList OfficerChanges { get; init; } public required IReadOnlyList UnmatchedPastedNames { get; init; } public required IReadOnlyList AmbiguousPastedNames { get; init; } public required IReadOnlyList Warnings { get; init; } public int ReturningCount => Promotions.Count; public int RemovalCount => StudentsToRemove.Count; } public sealed class StudentPromotion { public required Student Student { get; init; } public required int PreviousGrade { get; init; } public required int NewGrade { get; init; } public required int PreviousTsaYear { get; init; } public required int NewTsaYear { get; init; } public OfficerRole? PreviousOfficerRole { get; init; } public OfficerRole? NewOfficerRole { get; init; } } public sealed class OfficerAssignmentChange { public required OfficerRole Role { get; init; } public Student? NewOfficer { get; init; } public Student? PreviousOfficer { get; init; } } /// /// Pure planner for year-end student promotion, graduation, and officer assignment. /// public static class YearTransitionPlanner { private const int AbsoluteMaxGrade = 12; /// /// Students at or above the graduating grade are suggested as non-returning. /// public static bool SuggestReturning(Student student, int graduatingGrade) => student.Grade < graduatingGrade; /// /// Parses pasted name lines and matches them to students. /// public static NameMatchResult MatchPastedNames( IReadOnlyList students, IEnumerable pastedLines) { var matchedIds = new HashSet(); var unmatched = new List(); var ambiguous = new List(); foreach (var rawLine in pastedLines) { var line = rawLine.Trim(); if (string.IsNullOrWhiteSpace(line)) continue; var matches = FindNameMatches(students, line).ToList(); if (matches.Count == 0) { unmatched.Add(line); } else if (matches.Count > 1) { ambiguous.Add(line); foreach (var match in matches) matchedIds.Add(match.Id); } else { matchedIds.Add(matches[0].Id); } } return new NameMatchResult(matchedIds, unmatched, ambiguous); } public static YearTransitionPlan Build(YearTransitionRequest request) { ArgumentNullException.ThrowIfNull(request); if (request.GraduatingGrade is < 5 or > AbsoluteMaxGrade) throw new ArgumentOutOfRangeException(nameof(request), "Graduating grade must be between 5 and 12."); var studentsById = request.Students.ToDictionary(s => s.Id); var warnings = new List(); var promotions = new List(); var toRemove = new List(); var nameMatch = MatchPastedNames(request.Students, request.PastedNames); warnings.AddRange(nameMatch.AmbiguousNames.Select(n => $"Pasted name '{n}' matched more than one student.")); // Officer role -> student id from request (null = vacant) var newOfficerByRole = Enum.GetValues() .ToDictionary( role => role, role => request.OfficerAssignments.TryGetValue(role, out var id) ? id : null); // Detect same student assigned to multiple offices var assignedStudentIds = newOfficerByRole.Values .Where(id => id.HasValue) .Select(id => id!.Value) .ToList(); var duplicateOfficerStudents = assignedStudentIds .GroupBy(id => id) .Where(g => g.Count() > 1) .Select(g => g.Key) .ToHashSet(); foreach (var studentId in duplicateOfficerStudents) { if (studentsById.TryGetValue(studentId, out var student)) { warnings.Add($"{student.LastNameFirstName} is assigned to more than one officer role."); } } // Build new officer role lookup for returning students var newRoleByStudentId = new Dictionary(); foreach (var (role, studentId) in newOfficerByRole) { if (!studentId.HasValue) continue; if (!request.ReturningStudentIds.Contains(studentId.Value)) { var name = studentsById.TryGetValue(studentId.Value, out var s) ? s.LastNameFirstName : $"Id {studentId.Value}"; warnings.Add($"{role} is assigned to {name}, who is not marked returning."); continue; } if (duplicateOfficerStudents.Contains(studentId.Value)) continue; newRoleByStudentId[studentId.Value] = role; } foreach (var student in request.Students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName)) { if (request.ReturningStudentIds.Contains(student.Id)) { var newGrade = Math.Min(AbsoluteMaxGrade, Math.Min(request.GraduatingGrade, student.Grade + 1)); var newTsaYear = student.TsaYear + 1; if (student.Grade >= request.GraduatingGrade) { warnings.Add( $"{student.LastNameFirstName} is at or above graduating grade {request.GraduatingGrade} but marked returning; grade stays at {newGrade}."); } OfficerRole? assignedRole = newRoleByStudentId.TryGetValue(student.Id, out var role) ? role : null; promotions.Add(new StudentPromotion { Student = student, PreviousGrade = student.Grade, NewGrade = newGrade, PreviousTsaYear = student.TsaYear, NewTsaYear = newTsaYear, PreviousOfficerRole = student.OfficerRole, NewOfficerRole = assignedRole }); } else { toRemove.Add(student); } } var officerChanges = Enum.GetValues() .Select(role => { var previous = request.Students.FirstOrDefault(s => s.OfficerRole == role); Student? next = null; if (newOfficerByRole.TryGetValue(role, out var nextId) && nextId.HasValue && studentsById.TryGetValue(nextId.Value, out var nextStudent) && request.ReturningStudentIds.Contains(nextId.Value) && !duplicateOfficerStudents.Contains(nextId.Value)) { next = nextStudent; } return new OfficerAssignmentChange { Role = role, PreviousOfficer = previous, NewOfficer = next }; }) .ToList(); return new YearTransitionPlan { TargetCompetitionYear = request.TargetCompetitionYear, GraduatingGrade = request.GraduatingGrade, Promotions = promotions, StudentsToRemove = toRemove, OfficerChanges = officerChanges, UnmatchedPastedNames = nameMatch.UnmatchedNames, AmbiguousPastedNames = nameMatch.AmbiguousNames, Warnings = warnings }; } private static IEnumerable FindNameMatches(IReadOnlyList students, string line) { var comparer = StringComparer.OrdinalIgnoreCase; // Exact full-name matches first var fullMatches = students.Where(s => comparer.Equals(s.FirstNameLastName, line) || comparer.Equals(s.LastNameFirstName, line)).ToList(); if (fullMatches.Count > 0) return fullMatches; var (first, last) = ParseName(line); if (string.IsNullOrWhiteSpace(first) && string.IsNullOrWhiteSpace(last)) return []; return students.Where(s => comparer.Equals(s.FirstName.Trim(), first) && comparer.Equals(s.LastName.Trim(), last)); } /// /// Parses a name line into (first, last), using for /// "Last, First" and a last-space split for "First Last". /// private static (string First, string Last) ParseName(string fullName) { var trimmed = fullName.Trim(); if (trimmed.Contains(',')) { var parts = Student.ParseNameParts(trimmed); return (parts.Item1.Trim(), parts.Item2.Trim()); } var lastSpace = trimmed.LastIndexOf(' '); if (lastSpace <= 0) return (trimmed, string.Empty); return (trimmed[..lastSpace].Trim(), trimmed[(lastSpace + 1)..].Trim()); } } public sealed class NameMatchResult { public NameMatchResult( IReadOnlySet matchedStudentIds, IReadOnlyList unmatchedNames, IReadOnlyList ambiguousNames) { MatchedStudentIds = matchedStudentIds; UnmatchedNames = unmatchedNames; AmbiguousNames = ambiguousNames; } public IReadOnlySet MatchedStudentIds { get; } public IReadOnlyList UnmatchedNames { get; } public IReadOnlyList AmbiguousNames { get; } }