< Summary

Information
Class: EHonda.KicktippAi.Core.HistoryCsvUtility
Assembly: EHonda.KicktippAi.Core
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Core/HistoryCsvUtility.cs
Line coverage
95%
Covered lines: 384
Uncovered lines: 19
Coverable lines: 403
Total lines: 798
Line coverage: 95.2%
Branch coverage
97%
Covered branches: 97
Total branches: 100
Branch coverage: 97%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Core/HistoryCsvUtility.cs

#LineLine coverage
 1using System.Globalization;
 2using CsvHelper;
 3
 4namespace EHonda.KicktippAi.Core;
 5
 6public sealed record HistoryDateMapEntry(
 7    string DocumentName,
 8    string Competition,
 9    string HomeTeam,
 10    string AwayTeam,
 11    string Score,
 12    string Annotation,
 13    string PlayedAt,
 14    string SourceName,
 15    string SourceUrl,
 16    string VerifiedAt,
 17    string Notes);
 18
 19public sealed record HistoryDateMapApplyResult
 20{
 21    public HistoryDateMapApplyResult(
 22        string Content,
 23        int RowCount,
 24        int UpdatedRowCount,
 25        IReadOnlyList<HistoryDateMapEntry> MissingEntries,
 26        int PreservedRowCount = 0,
 27        int SkippedRowCount = 0,
 28        IReadOnlyList<HistoryDateMapEntry>? MissingPredictionEntries = null)
 29    {
 30        this.Content = Content;
 31        this.RowCount = RowCount;
 32        this.UpdatedRowCount = UpdatedRowCount;
 33        this.MissingEntries = MissingEntries;
 34        this.PreservedRowCount = PreservedRowCount;
 35        this.SkippedRowCount = SkippedRowCount;
 36        this.MissingPredictionEntries = MissingPredictionEntries ?? Array.Empty<HistoryDateMapEntry>();
 37    }
 38
 39    public string Content { get; init; }
 40    public int RowCount { get; init; }
 41    public int UpdatedRowCount { get; init; }
 42    public IReadOnlyList<HistoryDateMapEntry> MissingEntries { get; init; }
 43    public int PreservedRowCount { get; init; }
 44    public int SkippedRowCount { get; init; }
 45    public IReadOnlyList<HistoryDateMapEntry> MissingPredictionEntries { get; init; }
 46}
 47
 48public sealed record HistoryDateMapApplyOptions(
 49    bool ApplyKnownOnly = false,
 50    DateOnly? PreserveCollectedOnOrAfter = null,
 51    IReadOnlyList<HistoryDateMapEntry>? PredictionDateEntries = null)
 52{
 53    public static HistoryDateMapApplyOptions Strict { get; } = new();
 54}
 55
 56/// <summary>
 57/// Utility class for handling date columns in history CSV documents.
 58/// </summary>
 59public static class HistoryCsvUtility
 60{
 61    public const string DataCollectedAtColumnName = "Data_Collected_At";
 62    public const string PlayedAtColumnName = "Played_At";
 63
 164    private static readonly string[] DateMapHeaders =
 165    [
 166        "DocumentName",
 167        "Competition",
 168        "Home_Team",
 169        "Away_Team",
 170        "Score",
 171        "Annotation",
 172        PlayedAtColumnName,
 173        "Source_Name",
 174        "Source_Url",
 175        "Verified_At",
 176        "Notes"
 177    ];
 78
 179    private static readonly string[] PlayedAtTimestampFormats =
 180    [
 181        "yyyy-MM-dd'T'HH:mm:sszzz",
 182        "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFzzz"
 183    ];
 84
 85    private enum ExistingDateTreatment
 86    {
 87        None,
 88        ReplaceFromPrediction,
 89        PreserveExistingTimestamp
 90    }
 91
 92    /// <summary>
 93    /// Adds or updates the Data_Collected_At column in a history CSV document.
 94    /// </summary>
 95    /// <param name="csvContent">The original CSV content.</param>
 96    /// <param name="previousCsvContent">The previous version of the CSV content (null if this is the first version).</p
 97    /// <param name="collectedDate">The date when the data was collected (e.g., "2025-08-30").</param>
 98    /// <returns>The updated CSV content with Data_Collected_At column.</returns>
 99    public static string AddDataCollectedAtColumn(string csvContent, string? previousCsvContent, string collectedDate)
 100    {
 101        // Check if the CSV already has a history date column.
 1102        if (HasHistoryDateColumn(csvContent))
 103        {
 1104            return csvContent; // Already has the column
 105        }
 106
 107        // Extract matches from previous version to get their collection dates
 1108        var previousMatches = previousCsvContent != null
 1109            ? ExtractMatchesWithCollectionDates(previousCsvContent)
 1110            : new Dictionary<string, string>();
 111
 112        // Extract current matches
 1113        var currentMatches = ExtractMatches(csvContent);
 114
 115        // Build the new CSV with Data_Collected_At column
 1116        var updatedCsvContent = BuildCsvWithDataCollectedAt(csvContent, currentMatches, previousMatches, collectedDate);
 117
 118        // When the previous version already used Played_At and the rebuilt rows plus dates
 119        // are otherwise identical, keep the previous payload unchanged to avoid transient
 120        // version churn in WM26 collect-context -> date-map workflows.
 1121        if (HasPlayedAtColumn(previousCsvContent))
 122        {
 1123            var playedAtVariant = ReplaceDataCollectedAtHeaderWithPlayedAt(updatedCsvContent);
 1124            if (string.Equals(
 1125                    NormalizeLineEndings(playedAtVariant),
 1126                    NormalizeLineEndings(previousCsvContent!),
 1127                    StringComparison.Ordinal))
 128            {
 1129                return previousCsvContent;
 130            }
 131        }
 132
 1133        return updatedCsvContent;
 134    }
 135
 136    public static IReadOnlyList<HistoryDateMapEntry> ReadDateMapEntries(string csvContent)
 137    {
 1138        var entries = new List<HistoryDateMapEntry>();
 1139        if (string.IsNullOrWhiteSpace(csvContent))
 140        {
 0141            return entries.AsReadOnly();
 142        }
 143
 1144        using var reader = new StringReader(csvContent);
 1145        using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
 146
 147        try
 148        {
 1149            csv.Read();
 1150            csv.ReadHeader();
 151
 1152            while (csv.Read())
 153            {
 1154                entries.Add(new HistoryDateMapEntry(
 1155                    GetOptionalField(csv, "DocumentName"),
 1156                    GetOptionalField(csv, "Competition"),
 1157                    GetOptionalField(csv, "Home_Team"),
 1158                    GetOptionalField(csv, "Away_Team"),
 1159                    GetOptionalField(csv, "Score"),
 1160                    GetOptionalField(csv, "Annotation"),
 1161                    GetOptionalField(csv, PlayedAtColumnName),
 1162                    GetOptionalField(csv, "Source_Name"),
 1163                    GetOptionalField(csv, "Source_Url"),
 1164                    GetOptionalField(csv, "Verified_At"),
 1165                    GetOptionalField(csv, "Notes")));
 166            }
 1167        }
 0168        catch (Exception)
 169        {
 0170            return Array.Empty<HistoryDateMapEntry>();
 171        }
 172
 1173        return entries.AsReadOnly();
 1174    }
 175
 176    public static string WriteDateMapEntries(IEnumerable<HistoryDateMapEntry> entries)
 177    {
 1178        using var writer = new StringWriter();
 1179        using var csvWriter = new CsvWriter(writer, CultureInfo.InvariantCulture);
 180
 1181        foreach (var header in DateMapHeaders)
 182        {
 1183            csvWriter.WriteField(header);
 184        }
 185
 1186        csvWriter.NextRecord();
 187
 1188        foreach (var entry in entries)
 189        {
 1190            csvWriter.WriteField(entry.DocumentName);
 1191            csvWriter.WriteField(entry.Competition);
 1192            csvWriter.WriteField(entry.HomeTeam);
 1193            csvWriter.WriteField(entry.AwayTeam);
 1194            csvWriter.WriteField(entry.Score);
 1195            csvWriter.WriteField(entry.Annotation);
 1196            csvWriter.WriteField(entry.PlayedAt);
 1197            csvWriter.WriteField(entry.SourceName);
 1198            csvWriter.WriteField(entry.SourceUrl);
 1199            csvWriter.WriteField(entry.VerifiedAt);
 1200            csvWriter.WriteField(entry.Notes);
 1201            csvWriter.NextRecord();
 202        }
 203
 1204        return writer.ToString();
 1205    }
 206
 207    public static IReadOnlyList<HistoryDateMapEntry> ExtractDateMapEntries(
 208        string documentName,
 209        string csvContent,
 210        bool includeExistingDataCollectedAt = false)
 211    {
 1212        var entries = new List<HistoryDateMapEntry>();
 1213        if (string.IsNullOrWhiteSpace(csvContent))
 214        {
 0215            return entries.AsReadOnly();
 216        }
 217
 1218        using var reader = new StringReader(csvContent);
 1219        using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
 220
 221        try
 222        {
 1223            csv.Read();
 1224            csv.ReadHeader();
 225
 1226            while (csv.Read())
 227            {
 1228                var playedAt = includeExistingDataCollectedAt
 1229                    ? GetHistoryDateField(csv)
 1230                    : "";
 231
 1232                entries.Add(new HistoryDateMapEntry(
 1233                    documentName,
 1234                    GetOptionalField(csv, "Competition"),
 1235                    GetOptionalField(csv, "Home_Team"),
 1236                    GetOptionalField(csv, "Away_Team"),
 1237                    GetOptionalField(csv, "Score"),
 1238                    GetOptionalField(csv, "Annotation"),
 1239                    playedAt,
 1240                    SourceName: "",
 1241                    SourceUrl: "",
 1242                    VerifiedAt: "",
 1243                    Notes: ""));
 244            }
 1245        }
 0246        catch (Exception)
 247        {
 0248            return Array.Empty<HistoryDateMapEntry>();
 249        }
 250
 1251        return entries.AsReadOnly();
 1252    }
 253
 254    public static IReadOnlyList<HistoryDateMapEntry> ExtractRowsRequiringPredictionPlayedAt(
 255        string documentName,
 256        string csvContent,
 257        DateOnly preserveCollectedOnOrAfter)
 258    {
 1259        var entries = new List<HistoryDateMapEntry>();
 1260        if (string.IsNullOrWhiteSpace(csvContent))
 261        {
 0262            return entries.AsReadOnly();
 263        }
 264
 1265        using var reader = new StringReader(csvContent);
 1266        using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
 267
 268        try
 269        {
 1270            csv.Read();
 1271            csv.ReadHeader();
 272
 1273            while (csv.Read())
 274            {
 1275                var row = new HistoryDateMapEntry(
 1276                    documentName,
 1277                    GetOptionalField(csv, "Competition"),
 1278                    GetOptionalField(csv, "Home_Team"),
 1279                    GetOptionalField(csv, "Away_Team"),
 1280                    GetOptionalField(csv, "Score"),
 1281                    GetOptionalField(csv, "Annotation"),
 1282                    PlayedAt: GetHistoryDateField(csv),
 1283                    SourceName: "",
 1284                    SourceUrl: "",
 1285                    VerifiedAt: "",
 1286                    Notes: "");
 287
 1288                if (IsWorldCupTournamentRow(row) &&
 1289                    GetExistingDateTreatment(row.PlayedAt, preserveCollectedOnOrAfter) == ExistingDateTreatment.ReplaceF
 290                {
 1291                    entries.Add(row);
 292                }
 293            }
 1294        }
 0295        catch (Exception)
 296        {
 0297            return Array.Empty<HistoryDateMapEntry>();
 298        }
 299
 1300        return entries.AsReadOnly();
 1301    }
 302
 303    public static HistoryDateMapApplyResult ApplyDateMap(
 304        string documentName,
 305        string csvContent,
 306        IReadOnlyList<HistoryDateMapEntry> dateMapEntries,
 307        HistoryDateMapApplyOptions? options = null)
 308    {
 1309        options ??= HistoryDateMapApplyOptions.Strict;
 310
 1311        var dateMap = dateMapEntries
 1312            .Where(entry => string.Equals(entry.DocumentName, documentName, StringComparison.OrdinalIgnoreCase))
 1313            .GroupBy(CreateDateMapKey, StringComparer.OrdinalIgnoreCase)
 1314            .ToDictionary(
 1315                group => group.Key,
 1316                group => new Queue<HistoryDateMapEntry>(group),
 1317                StringComparer.OrdinalIgnoreCase);
 1318        var predictionDateMap = (options.PredictionDateEntries ?? Array.Empty<HistoryDateMapEntry>())
 1319            .Where(entry => string.Equals(entry.DocumentName, documentName, StringComparison.OrdinalIgnoreCase))
 1320            .GroupBy(CreateDateMapKey, StringComparer.OrdinalIgnoreCase)
 1321            .ToDictionary(
 1322                group => group.Key,
 1323                group => group.Last().PlayedAt.Trim(),
 1324                StringComparer.OrdinalIgnoreCase);
 325
 1326        var missingEntries = new List<HistoryDateMapEntry>();
 1327        var missingPredictionEntries = new List<HistoryDateMapEntry>();
 1328        var rows = new List<HistoryDateMapEntry>();
 1329        var updatedRowCount = 0;
 1330        var preservedRowCount = 0;
 1331        var skippedRowCount = 0;
 332
 1333        if (string.IsNullOrWhiteSpace(csvContent))
 334        {
 0335            return new HistoryDateMapApplyResult(csvContent, RowCount: 0, UpdatedRowCount: 0, missingEntries);
 336        }
 337
 1338        using var reader = new StringReader(csvContent);
 1339        using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
 340
 341        try
 342        {
 1343            csv.Read();
 1344            csv.ReadHeader();
 345
 1346            while (csv.Read())
 347            {
 1348                var row = new HistoryDateMapEntry(
 1349                    documentName,
 1350                    GetOptionalField(csv, "Competition"),
 1351                    GetOptionalField(csv, "Home_Team"),
 1352                    GetOptionalField(csv, "Away_Team"),
 1353                    GetOptionalField(csv, "Score"),
 1354                    GetOptionalField(csv, "Annotation"),
 1355                    PlayedAt: GetHistoryDateField(csv),
 1356                    SourceName: "",
 1357                    SourceUrl: "",
 1358                    VerifiedAt: "",
 1359                    Notes: "");
 360
 1361                if (options.PreserveCollectedOnOrAfter.HasValue)
 362                {
 1363                    var existingDateTreatment = GetExistingDateTreatment(
 1364                        row.PlayedAt,
 1365                        options.PreserveCollectedOnOrAfter.Value);
 366
 1367                    if (existingDateTreatment == ExistingDateTreatment.PreserveExistingTimestamp)
 368                    {
 1369                        preservedRowCount++;
 1370                        rows.Add(row);
 1371                        continue;
 372                    }
 373
 1374                    if (IsWorldCupTournamentRow(row) &&
 1375                        existingDateTreatment == ExistingDateTreatment.ReplaceFromPrediction)
 376                    {
 1377                        if (predictionDateMap.TryGetValue(CreateDateMapKey(row), out var predictedPlayedAt) &&
 1378                            IsExactTimestamp(predictedPlayedAt))
 379                        {
 1380                            rows.Add(row with { PlayedAt = predictedPlayedAt });
 1381                            if (!string.Equals(row.PlayedAt, predictedPlayedAt, StringComparison.Ordinal))
 382                            {
 1383                                updatedRowCount++;
 384                            }
 385
 1386                            continue;
 387                        }
 388
 1389                        if (dateMap.TryGetValue(CreateDateMapKey(row), out var predictionFallbackEntries) &&
 1390                            predictionFallbackEntries.Count > 0 &&
 1391                            IsExactDate(predictionFallbackEntries.Peek().PlayedAt))
 392                        {
 1393                            var fallbackDateMapEntry = predictionFallbackEntries.Dequeue();
 1394                            var fallbackPlayedAt = fallbackDateMapEntry.PlayedAt.Trim();
 1395                            rows.Add(row with { PlayedAt = fallbackPlayedAt });
 1396                            if (!string.Equals(row.PlayedAt, fallbackPlayedAt, StringComparison.Ordinal))
 397                            {
 1398                                updatedRowCount++;
 399                            }
 400
 1401                            continue;
 402                        }
 403
 1404                        missingPredictionEntries.Add(row);
 1405                        rows.Add(row);
 1406                        continue;
 407                    }
 408                }
 409
 1410                if (!dateMap.TryGetValue(CreateDateMapKey(row), out var dateMapEntriesForRow) ||
 1411                    dateMapEntriesForRow.Count == 0)
 412                {
 1413                    if (!options.ApplyKnownOnly)
 414                    {
 0415                        missingEntries.Add(row);
 416                    }
 417                    else
 418                    {
 1419                        skippedRowCount++;
 420                    }
 421
 1422                    rows.Add(row);
 1423                    continue;
 424                }
 425
 1426                if (!IsExactDate(dateMapEntriesForRow.Peek().PlayedAt))
 427                {
 1428                    var skippedDateMapEntry = dateMapEntriesForRow.Dequeue();
 1429                    if (!options.ApplyKnownOnly)
 430                    {
 1431                        missingEntries.Add(skippedDateMapEntry);
 432                    }
 433                    else
 434                    {
 1435                        skippedRowCount++;
 436                    }
 437
 1438                    rows.Add(row);
 1439                    continue;
 440                }
 441
 1442                var dateMapEntry = dateMapEntriesForRow.Dequeue();
 1443                var playedAt = dateMapEntry.PlayedAt.Trim();
 1444                rows.Add(row with { PlayedAt = playedAt });
 1445                if (!string.Equals(row.PlayedAt, playedAt, StringComparison.Ordinal))
 446                {
 1447                    updatedRowCount++;
 448                }
 449            }
 1450        }
 0451        catch (Exception)
 452        {
 0453            return new HistoryDateMapApplyResult(csvContent, RowCount: 0, UpdatedRowCount: 0, missingEntries);
 454        }
 455
 1456        if (missingEntries.Count > 0 || missingPredictionEntries.Count > 0)
 457        {
 1458            return new HistoryDateMapApplyResult(
 1459                csvContent,
 1460                rows.Count,
 1461                UpdatedRowCount: 0,
 1462                missingEntries,
 1463                MissingPredictionEntries: missingPredictionEntries);
 464        }
 465
 1466        using var writer = new StringWriter();
 1467        using var csvWriter = new CsvWriter(writer, CultureInfo.InvariantCulture);
 468
 1469        csvWriter.WriteField("Competition");
 1470        csvWriter.WriteField(PlayedAtColumnName);
 1471        csvWriter.WriteField("Home_Team");
 1472        csvWriter.WriteField("Away_Team");
 1473        csvWriter.WriteField("Score");
 1474        csvWriter.WriteField("Annotation");
 1475        csvWriter.NextRecord();
 476
 1477        foreach (var row in rows)
 478        {
 1479            csvWriter.WriteField(row.Competition);
 1480            csvWriter.WriteField(row.PlayedAt);
 1481            csvWriter.WriteField(row.HomeTeam);
 1482            csvWriter.WriteField(row.AwayTeam);
 1483            csvWriter.WriteField(row.Score);
 1484            csvWriter.WriteField(row.Annotation);
 1485            csvWriter.NextRecord();
 486        }
 487
 1488        return new HistoryDateMapApplyResult(
 1489            writer.ToString(),
 1490            rows.Count,
 1491            updatedRowCount,
 1492            missingEntries,
 1493            preservedRowCount,
 1494            skippedRowCount,
 1495            missingPredictionEntries);
 1496    }
 497
 498    /// <summary>
 499    /// Checks if the CSV content already has a recognized history date column.
 500    /// </summary>
 501    private static bool HasHistoryDateColumn(string csvContent)
 502    {
 1503        var lines = csvContent.Split('\n', StringSplitOptions.RemoveEmptyEntries);
 1504        if (lines.Length == 0)
 505        {
 1506            return false;
 507        }
 508
 1509        var header = lines[0];
 1510        return header.Contains(DataCollectedAtColumnName, StringComparison.OrdinalIgnoreCase)
 1511               || header.Contains(PlayedAtColumnName, StringComparison.OrdinalIgnoreCase);
 512    }
 513
 514    private static bool HasPlayedAtColumn(string? csvContent)
 515    {
 1516        if (string.IsNullOrWhiteSpace(csvContent))
 517        {
 1518            return false;
 519        }
 520
 1521        var lines = csvContent.Split('\n', StringSplitOptions.RemoveEmptyEntries);
 1522        if (lines.Length == 0)
 523        {
 0524            return false;
 525        }
 526
 1527        return lines[0].Contains(PlayedAtColumnName, StringComparison.OrdinalIgnoreCase);
 528    }
 529
 530    private static string ReplaceDataCollectedAtHeaderWithPlayedAt(string csvContent)
 531    {
 1532        return csvContent.Replace(
 1533            DataCollectedAtColumnName,
 1534            PlayedAtColumnName,
 1535            StringComparison.Ordinal);
 536    }
 537
 538    private static string NormalizeLineEndings(string value)
 539    {
 1540        return value
 1541            .Replace("\r\n", "\n", StringComparison.Ordinal)
 1542            .Replace("\r", "\n", StringComparison.Ordinal);
 543    }
 544
 545    /// <summary>
 546    /// Extracts matches from CSV content without a history date column.
 547    /// </summary>
 548    private static HashSet<string> ExtractMatches(string csvContent)
 549    {
 1550        var matches = new HashSet<string>();
 551
 1552        using var reader = new StringReader(csvContent);
 1553        using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
 554
 555        try
 556        {
 1557            csv.Read();
 1558            csv.ReadHeader();
 559
 1560            while (csv.Read())
 561            {
 1562                var competition = csv.GetField("Competition") ?? "";
 1563                var homeTeam = csv.GetField("Home_Team") ?? "";
 1564                var awayTeam = csv.GetField("Away_Team") ?? "";
 1565                var score = csv.GetField("Score") ?? "";
 1566                var annotation = (csv.TryGetField<string>("Annotation", out var ann) ? ann : null) ?? "";
 567
 1568                var matchKey = CreateMatchKey(competition, homeTeam, awayTeam, score, annotation);
 1569                matches.Add(matchKey);
 570            }
 1571        }
 1572        catch (Exception)
 573        {
 574            // If CSV parsing fails, return empty set
 1575        }
 576
 1577        return matches;
 1578    }
 579
 580    /// <summary>
 581    /// Extracts matches with their collection dates from CSV content that has a history date column.
 582    /// </summary>
 583    private static Dictionary<string, string> ExtractMatchesWithCollectionDates(string csvContent)
 584    {
 1585        var matches = new Dictionary<string, string>();
 586
 1587        if (!HasHistoryDateColumn(csvContent))
 588        {
 1589            return matches;
 590        }
 591
 1592        using var reader = new StringReader(csvContent);
 1593        using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
 594
 595        try
 596        {
 1597            csv.Read();
 1598            csv.ReadHeader();
 599
 1600            while (csv.Read())
 601            {
 1602                var competition = csv.GetField("Competition") ?? "";
 1603                var historyDate = GetHistoryDateField(csv);
 1604                var homeTeam = csv.GetField("Home_Team") ?? "";
 1605                var awayTeam = csv.GetField("Away_Team") ?? "";
 1606                var score = csv.GetField("Score") ?? "";
 1607                var annotation = (csv.TryGetField<string>("Annotation", out var ann) ? ann : null) ?? "";
 608
 1609                var matchKey = CreateMatchKey(competition, homeTeam, awayTeam, score, annotation);
 1610                matches[matchKey] = historyDate;
 611            }
 1612        }
 0613        catch (Exception)
 614        {
 615            // If CSV parsing fails, return empty dictionary
 0616        }
 617
 1618        return matches;
 1619    }
 620
 621    /// <summary>
 622    /// Builds a new CSV with the Data_Collected_At column.
 623    /// </summary>
 624    private static string BuildCsvWithDataCollectedAt(
 625        string originalCsvContent,
 626        HashSet<string> currentMatches,
 627        Dictionary<string, string> previousMatches,
 628        string collectedDate)
 629    {
 1630        using var reader = new StringReader(originalCsvContent);
 1631        using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
 632
 1633        using var writer = new StringWriter();
 1634        using var csvWriter = new CsvWriter(writer, CultureInfo.InvariantCulture);
 635
 636        try
 637        {
 1638            csv.Read();
 1639            csv.ReadHeader();
 640
 641            // Write new header with Data_Collected_At after Competition
 1642            csvWriter.WriteField("Competition");
 1643            csvWriter.WriteField(DataCollectedAtColumnName);
 1644            csvWriter.WriteField("Home_Team");
 1645            csvWriter.WriteField("Away_Team");
 1646            csvWriter.WriteField("Score");
 1647            csvWriter.WriteField("Annotation");
 1648            csvWriter.NextRecord();
 649
 1650            while (csv.Read())
 651            {
 1652                var competition = csv.GetField("Competition") ?? "";
 1653                var homeTeam = csv.GetField("Home_Team") ?? "";
 1654                var awayTeam = csv.GetField("Away_Team") ?? "";
 1655                var score = csv.GetField("Score") ?? "";
 1656                var annotation = (csv.TryGetField<string>("Annotation", out var ann) ? ann : null) ?? "";
 657
 1658                var matchKey = CreateMatchKey(competition, homeTeam, awayTeam, score, annotation);
 659
 660                // Determine the collection date for this match
 661                string dataCollectedAt;
 1662                if (previousMatches.TryGetValue(matchKey, out var existingDate))
 663                {
 664                    // Match existed in previous version, use its existing date
 1665                    dataCollectedAt = existingDate;
 666                }
 667                else
 668                {
 669                    // New match, use current collection date
 1670                    dataCollectedAt = collectedDate;
 671                }
 672
 1673                csvWriter.WriteField(competition);
 1674                csvWriter.WriteField(dataCollectedAt);
 1675                csvWriter.WriteField(homeTeam);
 1676                csvWriter.WriteField(awayTeam);
 1677                csvWriter.WriteField(score);
 1678                csvWriter.WriteField(annotation);
 1679                csvWriter.NextRecord();
 680            }
 1681        }
 1682        catch (Exception)
 683        {
 684            // If parsing fails, return original content
 1685            return originalCsvContent;
 686        }
 687
 1688        return writer.ToString();
 1689    }
 690
 691    /// <summary>
 692    /// Creates a unique key for a match.
 693    /// </summary>
 694    private static string CreateMatchKey(string competition, string homeTeam, string awayTeam, string score, string anno
 695    {
 1696        return $"{competition}|{homeTeam}|{awayTeam}|{score}|{annotation}";
 697    }
 698
 699    private static string CreateDateMapKey(HistoryDateMapEntry entry)
 700    {
 1701        return CreateMatchKey(
 1702            NormalizeKeyPart(entry.Competition),
 1703            NormalizeKeyPart(entry.HomeTeam),
 1704            NormalizeKeyPart(entry.AwayTeam),
 1705            NormalizeKeyPart(entry.Score),
 1706            NormalizeKeyPart(entry.Annotation));
 707    }
 708
 709    private static bool IsWorldCupTournamentRow(HistoryDateMapEntry entry)
 710    {
 1711        return string.Equals(
 1712            NormalizeKeyPart(entry.Competition),
 1713            "WM",
 1714            StringComparison.OrdinalIgnoreCase);
 715    }
 716
 717    private static string NormalizeKeyPart(string? value)
 718    {
 1719        return value?.Trim() ?? "";
 720    }
 721
 722    private static bool IsExactDate(string value)
 723    {
 1724        return DateOnly.TryParseExact(
 1725            value.Trim(),
 1726            "yyyy-MM-dd",
 1727            CultureInfo.InvariantCulture,
 1728            DateTimeStyles.None,
 1729            out _);
 730    }
 731
 732    private static bool IsExactTimestamp(string value)
 733    {
 1734        return DateTimeOffset.TryParseExact(
 1735            value.Trim(),
 1736            PlayedAtTimestampFormats,
 1737            CultureInfo.InvariantCulture,
 1738            DateTimeStyles.None,
 1739            out _);
 740    }
 741
 742    private static ExistingDateTreatment GetExistingDateTreatment(string value, DateOnly preserveCollectedOnOrAfter)
 743    {
 1744        if (TryParseExactDate(value, out var collectedAt))
 745        {
 1746            return collectedAt >= preserveCollectedOnOrAfter
 1747                ? ExistingDateTreatment.ReplaceFromPrediction
 1748                : ExistingDateTreatment.None;
 749        }
 750
 1751        if (TryParseExactTimestampDate(value, out var playedAtDate) && playedAtDate >= preserveCollectedOnOrAfter)
 752        {
 1753            return ExistingDateTreatment.PreserveExistingTimestamp;
 754        }
 755
 0756        return ExistingDateTreatment.None;
 757    }
 758
 759    private static bool TryParseExactDate(string value, out DateOnly date)
 760    {
 1761        return DateOnly.TryParseExact(
 1762            value.Trim(),
 1763            "yyyy-MM-dd",
 1764            CultureInfo.InvariantCulture,
 1765            DateTimeStyles.None,
 1766            out date);
 767    }
 768
 769    private static bool TryParseExactTimestampDate(string value, out DateOnly date)
 770    {
 1771        if (DateTimeOffset.TryParseExact(
 1772                value.Trim(),
 1773                PlayedAtTimestampFormats,
 1774                CultureInfo.InvariantCulture,
 1775                DateTimeStyles.None,
 1776                out var timestamp))
 777        {
 1778            date = DateOnly.FromDateTime(timestamp.DateTime);
 1779            return true;
 780        }
 781
 0782        date = default;
 0783        return false;
 784    }
 785
 786    private static string GetHistoryDateField(CsvReader csv)
 787    {
 1788        var playedAt = GetOptionalField(csv, PlayedAtColumnName);
 1789        return string.IsNullOrWhiteSpace(playedAt)
 1790            ? GetOptionalField(csv, DataCollectedAtColumnName)
 1791            : playedAt;
 792    }
 793
 794    private static string GetOptionalField(CsvReader csv, string fieldName)
 795    {
 1796        return (csv.TryGetField<string>(fieldName, out var value) ? value : null) ?? "";
 797    }
 798}