using Core.Entities;
using Core.Models;
using Core.Notes;
namespace Core.Parsers;
///
/// Parses leftover CSV columns (not roster or ranking) into student note field merges.
///
public class StudentNotesFieldParser : CsvParserBase
{
private static readonly HashSet ReservedHeaders = new(StringComparer.OrdinalIgnoreCase)
{
"Student Name",
"Grade",
"TSA year",
"State ID",
"Regional ID",
"National ID",
"Officer",
"TOTAL # OF EVENTS"
};
public StudentNotesFieldParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines)
{
}
public StudentNotesFieldParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines)
{
}
///
/// Roster and ranking columns that must not become imported note fields.
///
public static bool IsReservedHeader(string? header)
{
if (string.IsNullOrWhiteSpace(header))
return true;
var trimmed = header.Trim();
if (ReservedHeaders.Contains(trimmed))
return true;
return int.TryParse(trimmed, out var rank)
&& rank >= 1
&& rank <= StudentEventRanking.MaxRank;
}
///
/// Leftover field names from a header row after reserved columns are removed.
///
public static List GetLeftoverFieldNames(IEnumerable headers) =>
headers
.Where(h => !IsReservedHeader(h))
.Select(h => h!.Trim())
.ToList();
///
/// Reads the header row and returns leftover field names without processing data rows.
///
public List PeekLeftoverFieldNames()
{
CsvReader.Read();
CsvReader.ReadHeader();
return GetLeftoverFieldNames(CsvReader.HeaderRecord ?? []);
}
public StudentNotesImportResult Parse(
ICollection students,
IReadOnlyDictionary existingNotesByStudentId)
{
var result = new StudentNotesImportResult();
CsvReader.Read();
CsvReader.ReadHeader();
if (CsvReader.HeaderRecord is null ||
!CsvReader.HeaderRecord.Contains("Student Name", StringComparer.OrdinalIgnoreCase))
{
result.Errors.Add("CSV must include a 'Student Name' column.");
return result;
}
var fieldNames = GetLeftoverFieldNames(CsvReader.HeaderRecord);
result.FieldNames = fieldNames;
if (fieldNames.Count == 0)
result.Warnings.Add("No leftover field columns were found besides roster and ranking columns.");
Dictionary pendingByStudentId = [];
while (CsvReader.Read())
{
var rowNumber = CsvReader.Context.Parser?.Row ?? 0;
var name = CsvReader.GetField("Student Name")?.Trim();
if (string.IsNullOrEmpty(name))
continue;
var studentMatch = FuzzyStudentMatcher.Find(students, name);
if (studentMatch is null)
{
result.Issues.Add(new StudentNotesImportIssue
{
RowNumber = rowNumber,
RawStudentName = name,
Message = $"No student matched '{name}'."
});
continue;
}
var (student, score) = studentMatch.Value;
if (!pendingByStudentId.TryGetValue(student.Id, out var pending))
{
pending = new PendingStudentFields(student);
pendingByStudentId[student.Id] = pending;
}
pending.RawStudentName = name;
pending.RowNumber = rowNumber;
pending.StudentScore = score;
foreach (var fieldName in fieldNames)
{
var raw = CsvReader.GetField(fieldName);
pending.Fields[fieldName.Trim()] = raw ?? string.Empty;
}
}
foreach (var pending in pendingByStudentId.Values)
{
List incoming = [.. pending.Fields.Select(pair => new ImportedField(pair.Key, pair.Value))];
existingNotesByStudentId.TryGetValue(pending.Student.Id, out var existingMarkdown);
var merge = ImportedFieldsTable.Merge(existingMarkdown, incoming);
result.Matches.Add(new StudentNotesImportMatch
{
Student = pending.Student,
RawStudentName = pending.RawStudentName,
RowNumber = pending.RowNumber,
StudentScore = pending.StudentScore,
IncomingFields = incoming,
Merge = merge
});
}
if (result.Matches.Count == 0 && result.Errors.Count == 0)
result.Warnings.Add("No students were matched from the CSV.");
return result;
}
private sealed class PendingStudentFields(Student student)
{
public Student Student { get; } = student;
public string RawStudentName { get; set; } = string.Empty;
public int RowNumber { get; set; }
public int StudentScore { get; set; }
public Dictionary Fields { get; } = new(StringComparer.OrdinalIgnoreCase);
}
}