using System.Globalization; using Marathon.Application.Abstractions; using Marathon.Application.Storage; using Marathon.Application.UseCases; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; using DomainEventId = Marathon.Domain.ValueObjects.EventId; namespace Marathon.UI.Services; /// /// Page-facing implementation of . Composes the /// four bet-journal use cases and joins event titles for the table rows. /// public sealed class BetJournalService : IBetJournalService { private readonly BuildBetJournalReportUseCase _build; private readonly RecordPlacedBetUseCase _record; private readonly ResolvePendingBetsUseCase _resolve; private readonly DeletePlacedBetUseCase _delete; private readonly IEventRepository _events; public BetJournalService( BuildBetJournalReportUseCase build, RecordPlacedBetUseCase record, ResolvePendingBetsUseCase resolve, DeletePlacedBetUseCase delete, IEventRepository events) { _build = build ?? throw new ArgumentNullException(nameof(build)); _record = record ?? throw new ArgumentNullException(nameof(record)); _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve)); _delete = delete ?? throw new ArgumentNullException(nameof(delete)); _events = events ?? throw new ArgumentNullException(nameof(events)); } public async Task GetReportAsync(CancellationToken ct) { var report = await _build.ExecuteAsync(ct).ConfigureAwait(false); if (report.Bets.Count == 0) return new BetJournalVm(report.Stats, Array.Empty()); // Resolve event titles in one batched query — distinct ids only (was N+1). // Missing events (pruned by snapshot retention) fall back to the raw id. var distinctIds = report.Bets.Select(r => r.Bet.EventId).Distinct().ToList(); var events = await _events.GetManyAsync(distinctIds, ct).ConfigureAwait(false); var titles = new Dictionary(distinctIds.Count); foreach (var id in distinctIds) { titles[id] = events.TryGetValue(id, out var ev) ? ev.Title : id.Value; } var rows = report.Bets .Select(r => new BetJournalRowVm( Id: r.Bet.Id, EventTitle: titles.TryGetValue(r.Bet.EventId, out var t) ? t : r.Bet.EventId.Value, Bet: r.Bet, ClvProbabilityDelta: r.ClvProbabilityDelta)) .ToList(); return new BetJournalVm(report.Stats, rows); } public async Task AddAsync(AddBetForm form, CancellationToken ct) { ArgumentNullException.ThrowIfNull(form); if (!form.IsValid(out var error)) throw new ArgumentException(error ?? "Invalid form.", nameof(form)); var selection = new Bet( scope: MatchScope.Instance, type: form.Type, side: form.Side, value: form.Value is { } v ? new OddsValue(v) : null, rate: new OddsRate(form.Rate)); var bet = new PlacedBet( Id: Guid.NewGuid(), EventId: new DomainEventId(form.EventId.Trim()), Selection: selection, Stake: form.Stake, PlacedAt: MoscowTime.Now, Outcome: BetOutcome.Pending, Notes: form.Notes); var stored = await _record.ExecuteAsync(bet, ct).ConfigureAwait(false); return stored.Id; } public Task DeleteAsync(Guid betId, CancellationToken ct) => _delete.ExecuteAsync(betId, ct); public Task ResolvePendingAsync(CancellationToken ct) => _resolve.ExecuteAsync(ct); public async Task> GetUpcomingEventOptionsAsync(CancellationToken ct) { // Generous betting window: recently started through a month out. Loaded once // by the page; the autocomplete filters this list in memory per keystroke. var now = MoscowTime.Now; var range = new DateRange(now.AddDays(-7), now.AddDays(30)); var events = await _events.ListByDateRangeAsync(range, ct).ConfigureAwait(false); return events .OrderBy(e => e.ScheduledAt) .Select(e => new EventOption( e.Id.Value, string.Concat(e.Title, " · ", e.ScheduledAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)), e.ScheduledAt)) .ToList(); } }