using System.Text.Json; using Marathon.Application.Abstractions; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; using DomainEventId = Marathon.Domain.ValueObjects.EventId; namespace Marathon.UI.Services; /// /// Repository-backed anomaly browsing service. Loads anomalies + their /// originating events in a single pass, parses EvidenceJson, and shapes /// / records for /// the UI to consume directly. /// public sealed class AnomalyBrowsingService : IAnomalyBrowsingService { private readonly IAnomalyRepository _anomalies; private readonly IEventRepository _events; public AnomalyBrowsingService(IAnomalyRepository anomalies, IEventRepository events) { _anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies)); _events = events ?? throw new ArgumentNullException(nameof(events)); } public async Task> ListAsync(AnomalyFilter filter, CancellationToken ct) { ArgumentNullException.ThrowIfNull(filter); // Date filter pushed to SQL; severity needs the parsed score and sport needs // the event join, so those two stay in memory over the smaller returned set. var all = await _anomalies.ListByDateRangeAsync(filter.From, filter.To, ct).ConfigureAwait(false); if (all.Count == 0) return Array.Empty(); // Resolve event metadata in one batched pass — distinct EventIds only. var eventLookup = await BuildEventLookupAsync(all, ct).ConfigureAwait(false); var items = new List(all.Count); foreach (var anomaly in all) { ct.ThrowIfCancellationRequested(); if (TryProject(anomaly, eventLookup, out var item)) { items.Add(item); } } // Remaining filters in-memory (page-sized set). IEnumerable filtered = items; if (filter.MinSeverity is { } minSeverity) { filtered = filtered.Where(i => AnomalySeverityRules.MeetsThreshold(i.Severity, minSeverity)); } if (filter.SportCodes is { Count: > 0 } sports) { filtered = filtered.Where(i => sports.Contains(i.Sport.Value)); } if (filter.Kinds is { Count: > 0 } kinds) { filtered = filtered.Where(i => kinds.Contains(i.Kind)); } // DetectedAt is the tiebreak for the score/gap sorts so order stays stable. var sorted = filter.Sort switch { AnomalySort.HighestScore => filtered.OrderByDescending(i => i.Score).ThenByDescending(i => i.DetectedAt), AnomalySort.LongestGap => filtered.OrderByDescending(i => i.SuspensionGapSeconds).ThenByDescending(i => i.DetectedAt), _ => filtered.OrderByDescending(i => i.DetectedAt), }; return sorted.ToList(); } public async Task GetByIdAsync(Guid id, CancellationToken ct) { var anomaly = await _anomalies.GetAsync(id, ct).ConfigureAwait(false); if (anomaly is null) return null; var eventLookup = await BuildEventLookupAsync(new[] { anomaly }, ct).ConfigureAwait(false); if (!TryProject(anomaly, eventLookup, out var item)) return null; if (!TryParseEvidence(anomaly.EvidenceJson, out var dto)) return null; var pre = ToSnapshot(dto.PreSuspension); var post = ToSnapshot(dto.PostSuspension); return new AnomalyDetailVm(item, pre, post); } public Task GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct) // Server-side COUNT(*) — no longer materialises the table to count. => _anomalies.CountSinceAsync(since, ct); public async Task> ListKnownSportCodesAsync(CancellationToken ct) { var all = await _anomalies.ListAsync(ct).ConfigureAwait(false); if (all.Count == 0) return Array.Empty(); var lookup = await BuildEventLookupAsync(all, ct).ConfigureAwait(false); return all .Select(a => lookup.TryGetValue(a.EventId, out var ev) ? ev.Sport.Value : (int?)null) .Where(static x => x.HasValue) .Select(static x => x!.Value) .Distinct() .OrderBy(static x => x) .ToList(); } // ---------------- internals ---------------- private async Task> BuildEventLookupAsync( IReadOnlyCollection anomalies, CancellationToken ct) { var distinct = anomalies .Select(a => a.EventId) .Distinct() .ToList(); // Single batched query instead of one GetAsync per distinct event (N+1). return await _events.GetManyAsync(distinct, ct).ConfigureAwait(false); } private static bool TryProject( Anomaly anomaly, IReadOnlyDictionary events, out AnomalyListItem item) { item = default!; if (!TryParseEvidence(anomaly.EvidenceJson, out var dto)) return false; var severity = AnomalySeverityRules.FromScore(anomaly.Score); // Skip orphan anomalies whose event has been pruned — degrade gracefully rather // than throwing on a sentinel SportCode(0). Anomalies have an FK to events so this // is defensive; the feed already drops rows whose evidence won't parse. if (!events.TryGetValue(anomaly.EventId, out var ev) || ev is null) return false; var sport = ev.Sport; var country = ev.CountryCode; var league = ev.LeagueId; var title = ev.Title; var preSnap = ToSnapshot(dto.PreSuspension); var postSnap = ToSnapshot(dto.PostSuspension); var twoWay = dto.PreSuspension.PDraw is null && dto.PostSuspension.PDraw is null; item = new AnomalyListItem( anomaly.Id, anomaly.EventId, title, sport, country, league, anomaly.DetectedAt, anomaly.Score, severity, anomaly.Kind, dto.SuspensionGapSeconds, preSnap.Rate1, preSnap.RateDraw, preSnap.Rate2, postSnap.Rate1, postSnap.RateDraw, postSnap.Rate2, preSnap.Favourite, postSnap.Favourite, twoWay); return true; } private static bool TryParseEvidence(string evidenceJson, out EvidenceDto dto) { dto = default!; if (string.IsNullOrWhiteSpace(evidenceJson)) return false; try { var parsed = JsonSerializer.Deserialize(evidenceJson, JsonOptions); if (parsed?.PreSuspension is null || parsed.PostSuspension is null) return false; dto = parsed; return true; } catch (JsonException) { return false; } } private static AnomalyEvidenceSnapshot ToSnapshot(EvidenceSnapshotDto dto) { var fav = ResolveFavourite(dto.P1, dto.PDraw, dto.P2); return new AnomalyEvidenceSnapshot( dto.CapturedAt, dto.Rate1, dto.RateDraw, dto.Rate2, dto.P1, dto.PDraw, dto.P2, fav); } private static AnomalyFavourite ResolveFavourite(decimal? p1, decimal? pDraw, decimal? p2) { // Favourite = side with the highest implied probability (lowest odds). var best = AnomalyFavourite.None; decimal bestValue = decimal.MinValue; if (p1 is { } v1 && v1 > bestValue) { bestValue = v1; best = AnomalyFavourite.Side1; } if (pDraw is { } vd && vd > bestValue) { bestValue = vd; best = AnomalyFavourite.Draw; } if (p2 is { } v2 && v2 > bestValue) { best = AnomalyFavourite.Side2; } return best; } private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, }; private sealed class EvidenceDto { public int SuspensionGapSeconds { get; set; } public EvidenceSnapshotDto PreSuspension { get; set; } = default!; public EvidenceSnapshotDto PostSuspension { get; set; } = default!; } private sealed class EvidenceSnapshotDto { public DateTimeOffset CapturedAt { get; set; } public decimal? P1 { get; set; } public decimal? PDraw { get; set; } public decimal? P2 { get; set; } public decimal? Rate1 { get; set; } public decimal? RateDraw { get; set; } public decimal? Rate2 { get; set; } } }