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