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();
}
}