using System.Globalization; using System.Text; using Marathon.Application.Abstractions; using Marathon.Application.Export; using Marathon.Application.Storage; using Marathon.Domain.ValueObjects; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using DomainEventId = Marathon.Domain.ValueObjects.EventId; namespace Marathon.Application.UseCases; /// /// Exports the bet journal and the paper-trading (forward-test) ledger to CSV files in /// the configured export directory, returning each file's path (or null when there is /// nothing to export). Mirrors 's write-and-return-path /// contract; CSV needs no third-party library so it stays in the Application layer. /// public sealed class ExportToCsvUseCase { // BOM so Excel opens UTF-8 (Cyrillic team names) correctly. private static readonly Encoding Utf8Bom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); private readonly IPlacedBetRepository _bets; private readonly IPaperBetRepository _paperBets; private readonly IEventRepository _events; private readonly IOptions _storage; private readonly ILogger _logger; public ExportToCsvUseCase( IPlacedBetRepository bets, IPaperBetRepository paperBets, IEventRepository events, IOptions storage, ILogger logger) { _bets = bets ?? throw new ArgumentNullException(nameof(bets)); _paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets)); _events = events ?? throw new ArgumentNullException(nameof(events)); _storage = storage ?? throw new ArgumentNullException(nameof(storage)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// Writes the bet journal to CSV; returns the path, or null when empty. public async Task ExportJournalAsync(CancellationToken ct = default) { var bets = await _bets.ListAsync(ct).ConfigureAwait(false); if (bets.Count == 0) return null; var titles = await TitlesAsync(bets.Select(b => b.EventId), ct).ConfigureAwait(false); var header = new[] { "PlacedAt", "Event", "EventId", "Type", "Side", "Value", "Rate", "Stake", "Outcome", "Profit", "Notes", }; var rows = bets .OrderByDescending(b => b.PlacedAt) .Select(b => (IReadOnlyList)new[] { b.PlacedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture), Title(titles, b.EventId), Csv.NeutralizeFormula(b.EventId.Value), b.Selection.Type.ToString(), b.Selection.Side.ToString(), b.Selection.Value?.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty, b.Selection.Rate.Value.ToString(CultureInfo.InvariantCulture), b.Stake.ToString(CultureInfo.InvariantCulture), b.Outcome.ToString(), b.NetProfit?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, Csv.NeutralizeFormula(b.Notes), }); return await WriteAsync("journal", Csv.Document(header, rows), ct).ConfigureAwait(false); } /// Writes the paper-trading ledger to CSV; returns the path, or null when empty. public async Task ExportPaperLedgerAsync(CancellationToken ct = default) { var bets = await _paperBets.ListAsync(ct).ConfigureAwait(false); if (bets.Count == 0) return null; var titles = await TitlesAsync(bets.Select(b => b.EventId), ct).ConfigureAwait(false); var header = new[] { "OpenedAt", "Event", "EventId", "PickedSide", "Rate", "Stake", "Outcome", "Payout", "SettledAt", }; var rows = bets .OrderByDescending(b => b.OpenedAt) .Select(b => (IReadOnlyList)new[] { b.OpenedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture), Title(titles, b.EventId), Csv.NeutralizeFormula(b.EventId.Value), b.PickedSide.ToString(), b.Rate.ToString(CultureInfo.InvariantCulture), b.Stake.ToString(CultureInfo.InvariantCulture), b.Outcome.ToString(), b.Payout?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, b.SettledAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? string.Empty, }); return await WriteAsync("forward-test", Csv.Document(header, rows), ct).ConfigureAwait(false); } private async Task WriteAsync(string label, string content, CancellationToken ct) { var dir = _storage.Value.ExportDirectory; Directory.CreateDirectory(dir); var fileName = $"Marathon_{label}_{MoscowTime.Now:yyyy-MM-dd_HHmmss}.csv"; var path = Path.Combine(dir, fileName); await File.WriteAllTextAsync(path, content, Utf8Bom, ct).ConfigureAwait(false); _logger.LogInformation("ExportToCsvUseCase: wrote {Label} CSV → {Path}", label, path); return path; } private async Task> TitlesAsync( IEnumerable ids, CancellationToken ct) { var distinct = ids.Distinct().ToList(); var events = await _events.GetManyAsync(distinct, ct).ConfigureAwait(false); var titles = new Dictionary(events.Count); foreach (var (id, ev) in events) titles[id] = ev.Title; return titles; } // Titles are scraped ("Side1 vs Side2") so they're treated as untrusted text and // neutralized against CSV/formula injection. private static string Title(IReadOnlyDictionary titles, DomainEventId id) => Csv.NeutralizeFormula(titles.TryGetValue(id, out var t) ? t : id.Value); }