using System.Globalization; using ClosedXML.Excel; using Marathon.Application.Abstractions; using Marathon.Application.Storage; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Microsoft.EntityFrameworkCore; using Marathon.Infrastructure.Persistence; namespace Marathon.Infrastructure.Export; /// /// Exports odds snapshots to an Excel file matching the customer's wide-column specification. /// internal sealed class ExcelExporter : IExcelExporter { private readonly MarathonDbContext _db; public ExcelExporter(MarathonDbContext db) => _db = db; /// public async Task ExportAsync( DateRange range, ExportKind kind, string outputPath, CancellationToken ct = default) { // Load all snapshots in the date range with their bets eagerly var fromStr = range.From.ToString("O"); var toStr = range.To.ToString("O"); var snapshotEntities = await _db.Snapshots.AsNoTracking() .Include(s => s.Bets) .Include(s => s.Event) .Where(s => string.Compare(s.CapturedAt, fromStr, StringComparison.Ordinal) >= 0 && string.Compare(s.CapturedAt, toStr, StringComparison.Ordinal) <= 0) .ToListAsync(ct); // Convert to domain objects for processing var allSnapshots = snapshotEntities .Select(e => ( Snapshot: Mapping.ToDomain(e), Event: Mapping.ToDomain(e.Event))) .ToList(); // Determine max periods across all relevant snapshots var relevantBetLists = allSnapshots .Where(x => IsRelevant(x.Snapshot.Source, kind)) .Select(x => x.Snapshot.Bets) .ToList(); var maxPeriods = BetRowDenormalizer.MaxPeriods(relevantBetLists); // Build filename var fileName = string.Format( CultureInfo.InvariantCulture, "Marathon_{0:yyyy-MM-dd}_to_{1:yyyy-MM-dd}.xlsx", range.From, range.To); Directory.CreateDirectory(outputPath); var fullPath = Path.Combine(outputPath, fileName); using var workbook = new XLWorkbook(); if (kind == ExportKind.PreMatch || kind == ExportKind.Combined) { var preMatchData = allSnapshots .Where(x => x.Snapshot.Source == OddsSource.PreMatch) .ToList(); WriteSheet(workbook, "PreMatch", preMatchData, "Bet_", maxPeriods); } if (kind == ExportKind.Live || kind == ExportKind.Combined) { var liveData = allSnapshots .Where(x => x.Snapshot.Source == OddsSource.Live) .ToList(); WriteSheet(workbook, "Live", liveData, "Live_", maxPeriods); } workbook.SaveAs(fullPath); return fullPath; } private static bool IsRelevant(OddsSource source, ExportKind kind) => kind switch { ExportKind.PreMatch => source == OddsSource.PreMatch, ExportKind.Live => source == OddsSource.Live, ExportKind.Combined => true, _ => false, }; private static void WriteSheet( IXLWorkbook workbook, string sheetName, IReadOnlyList<(OddsSnapshot Snapshot, Event Event)> rows, string prefix, int maxPeriods) { var sheet = workbook.Worksheets.Add(sheetName); // Build header columns in canonical order var headers = BuildHeaders(prefix, maxPeriods); // Write header row for (var col = 0; col < headers.Count; col++) sheet.Cell(1, col + 1).Value = headers[col]; // Write data rows for (var i = 0; i < rows.Count; i++) { var (snapshot, evt) = rows[i]; var rowNum = i + 2; // 1-indexed, row 1 is header var scheduledAt = evt.ScheduledAt; var betDict = BetRowDenormalizer.Denormalize(snapshot.Bets, prefix, maxPeriods); // Compute WinnerSide: 1 if Win_1 rate < Win_2 rate, else 2, else blank object? winnerSide = ComputeWinnerSide(betDict, prefix); // Write metadata columns sheet.Cell(rowNum, 1).Value = i + 1; // RowNum (1-based) sheet.Cell(rowNum, 2).Value = evt.Sport.Value; sheet.Cell(rowNum, 3).Value = string.Empty; // Sport name — not available without lookup table join sheet.Cell(rowNum, 4).Value = evt.CountryCode; sheet.Cell(rowNum, 5).Value = evt.LeagueId; sheet.Cell(rowNum, 6).Value = evt.Category; sheet.Cell(rowNum, 7).Value = scheduledAt.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture); sheet.Cell(rowNum, 8).Value = scheduledAt.Day; sheet.Cell(rowNum, 9).Value = scheduledAt.Month; sheet.Cell(rowNum, 10).Value = scheduledAt.Year; sheet.Cell(rowNum, 11).Value = scheduledAt.ToString("HH:mm", CultureInfo.InvariantCulture); sheet.Cell(rowNum, 12).Value = evt.Id.Value; // Write bet columns in the order they appear in headers (starting at col 13) for (var col = MetadataColumnCount; col < headers.Count - 1; col++) { var key = headers[col]; if (betDict.TryGetValue(key, out var cellValue) && cellValue is not null) SetCellValue(sheet.Cell(rowNum, col + 1), cellValue); } // WinnerSide — last column if (winnerSide is not null) SetCellValue(sheet.Cell(rowNum, headers.Count), winnerSide); } } private const int MetadataColumnCount = 12; // RowNum, SportCode, Sport, Country, League, Category, DateFull, Day, Month, Year, Time, EventId private static List BuildHeaders(string prefix, int maxPeriods) { var headers = new List { "RowNum", "SportCode", "Sport", "Country", "League", "Category", "DateFull", "Day", "Month", "Year", "Time", "EventId", // Match-level bet columns $"{prefix}Match_Win_1", $"{prefix}Match_Draw", $"{prefix}Match_Win_2", $"{prefix}Match_Win_Fora_1_Value", $"{prefix}Match_Win_Fora_1_Rate", $"{prefix}Match_Win_Fora_2_Value", $"{prefix}Match_Win_Fora_2_Rate", $"{prefix}Match_Total_Less_Value", $"{prefix}Match_Total_Less_Rate", $"{prefix}Match_Total_More_Value", $"{prefix}Match_Total_More_Rate", }; for (var n = 1; n <= maxPeriods; n++) { var p = $"Period-{n}"; headers.Add($"{prefix}{p}_Win_1"); headers.Add($"{prefix}{p}_Draw"); headers.Add($"{prefix}{p}_Win_2"); headers.Add($"{prefix}{p}_Win_Fora_1_Value"); headers.Add($"{prefix}{p}_Win_Fora_1_Rate"); headers.Add($"{prefix}{p}_Win_Fora_2_Value"); headers.Add($"{prefix}{p}_Win_Fora_2_Rate"); headers.Add($"{prefix}{p}_Total_Less_Value"); headers.Add($"{prefix}{p}_Total_Less_Rate"); headers.Add($"{prefix}{p}_Total_More_Value"); headers.Add($"{prefix}{p}_Total_More_Rate"); } headers.Add("WinnerSide"); return headers; } /// /// Sets a cell's value from a boxed primitive. Handles decimal, int, and string. /// Empty cell on null (caller already guards). /// private static void SetCellValue(IXLCell cell, object value) { switch (value) { case decimal d: cell.Value = (double)d; break; case int i: cell.Value = i; break; case long l: cell.Value = (double)l; break; case string s: cell.Value = s; break; default: cell.Value = value.ToString() ?? string.Empty; break; } } private static object? ComputeWinnerSide(Dictionary betDict, string prefix) { var win1Key = $"{prefix}Match_Win_1"; var win2Key = $"{prefix}Match_Win_2"; if (!betDict.TryGetValue(win1Key, out var win1Raw) || win1Raw is null) return null; if (!betDict.TryGetValue(win2Key, out var win2Raw) || win2Raw is null) return null; var win1 = Convert.ToDecimal(win1Raw, CultureInfo.InvariantCulture); var win2 = Convert.ToDecimal(win2Raw, CultureInfo.InvariantCulture); if (win1 == win2) return null; // Lower rate = bookmaker's favourite return win1 < win2 ? (object?)1 : 2; } }