using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using LaDOSE.Business.Interface; using LaDOSE.Entity; using LaDOSE.Entity.Context; namespace LaDOSE.Business.Service { /// /// Match statistics over the persisted Set rows. /// /// Two things to know about the data: /// - There is no Set -> Player relationship in the EF model (the navigation properties /// and their configuration are commented out), so player names are resolved with a /// separate query plus an in-memory dictionary. Nothing here Includes a player. /// - There is no winner column. The winner is inferred from the scores, and start.gg /// encodes a DQ as -1, so games are clamped at 0. /// /// The database work and the aggregation are deliberately separated: /// issues one query per table and hands the loaded lists to the pure static /// , which is unit-testable without a database. /// public class StatisticsService : IStatisticsService { protected LaDOSEDbContext _context; public StatisticsService(LaDOSEDbContext context) { _context = context; } public Task GetMatchStats(List eventIds) { var requested = (eventIds ?? new List()).Distinct().ToList(); if (requested.Count == 0) { // Well-formed and empty, never null and never a 500. return Task.FromResult(new MatchStats()); } // One query per table. No query per set and no query per player. var events = _context.Event .Where(e => requested.Contains(e.Id)) .ToList(); var existingEventIds = events.Select(e => e.Id).ToList(); var tournaments = existingEventIds.Count == 0 ? new List() : _context.Tournament .Where(t => existingEventIds.Contains(t.EventId)) .ToList(); var tournamentIds = tournaments.Select(t => t.Id).Distinct().ToList(); // _context.Set is the DbSet property, not DbContext.Set(). var sets = tournamentIds.Count == 0 ? new List() : _context.Set .Where(s => tournamentIds.Contains(s.TournamentId)) .ToList(); var playerIds = sets.Select(s => s.Player1Id) .Concat(sets.Select(s => s.Player2Id)) .Where(id => id != 0) .Distinct() .ToList(); var players = playerIds.Count == 0 ? new List() : _context.Player .Where(p => playerIds.Contains(p.Id)) .ToList(); return Task.FromResult(Aggregate(requested, events, tournaments, sets, players)); } /// /// Pure aggregation over already-loaded rows. No database, no I/O, deterministic. /// /// The event ids the caller asked for. /// Events that exist (used for the coverage count). /// Candidate tournaments; filtered here on EventId. /// Candidate sets; filtered here on TournamentId. /// Players used to resolve display names; may be incomplete. public static MatchStats Aggregate( IEnumerable requestedEventIds, IEnumerable events, IEnumerable tournaments, IEnumerable sets, IEnumerable players) { var result = new MatchStats(); var requested = new HashSet((requestedEventIds ?? Enumerable.Empty())); if (requested.Count == 0) { return result; } // Coverage: events ------------------------------------------------------- var existingEventIds = new HashSet((events ?? Enumerable.Empty()) .Where(e => e != null && requested.Contains(e.Id)) .Select(e => e.Id)); result.Coverage.Events = existingEventIds.Count; // Coverage: brackets ---------------------------------------------------- var scopedTournamentIds = new HashSet((tournaments ?? Enumerable.Empty()) .Where(t => t != null && existingEventIds.Contains(t.EventId)) .Select(t => t.Id)); result.Coverage.Brackets = scopedTournamentIds.Count; var scopedSets = (sets ?? Enumerable.Empty()) .Where(s => s != null && scopedTournamentIds.Contains(s.TournamentId)) .ToList(); result.Coverage.Sets = scopedSets.Count; result.Coverage.BracketsWithSets = scopedSets.Select(s => s.TournamentId).Distinct().Count(); // Name resolution ------------------------------------------------------- var nameById = new Dictionary(); foreach (var player in (players ?? Enumerable.Empty()).Where(p => p != null)) { nameById[player.Id] = DisplayName(player); } var stats = new Dictionary(); var h2h = new Dictionary<(int, int), HeadToHead>(); foreach (var set in scopedSets) { // Unusable rows still count in Coverage.Sets but nowhere else. if (set.Player1Id == 0 || set.Player2Id == 0 || set.Player1Id == set.Player2Id) { continue; } // No winner column: equal scores (including 0-0 and -1 / -1) are undecided. if (set.Player1Score == set.Player2Score) { continue; } result.Coverage.DecidedSets++; var p1 = set.Player1Id; var p2 = set.Player2Id; // A DQ is stored as -1. Clamp so it never produces negative games. var games1 = Math.Max(0, set.Player1Score); var games2 = Math.Max(0, set.Player2Score); var stat1 = GetOrAdd(stats, p1, nameById); var stat2 = GetOrAdd(stats, p2, nameById); stat1.GamesWon += games1; stat1.GamesLost += games2; stat2.GamesWon += games2; stat2.GamesLost += games1; var winnerId = set.Player1Score > set.Player2Score ? p1 : p2; if (winnerId == p1) { stat1.Wins++; stat2.Losses++; } else { stat2.Wins++; stat1.Losses++; } // Lower PlayerId is always A, so WinsA / WinsB are unambiguous. var aId = Math.Min(p1, p2); var bId = Math.Max(p1, p2); var key = (aId, bId); if (!h2h.TryGetValue(key, out var pair)) { pair = new HeadToHead { PlayerAId = aId, PlayerA = ResolveName(aId, nameById), PlayerBId = bId, PlayerB = ResolveName(bId, nameById) }; h2h[key] = pair; } if (winnerId == aId) { pair.WinsA++; } else { pair.WinsB++; } } // Sets is derived, so Sets == Wins + Losses cannot drift. foreach (var stat in stats.Values) { stat.Sets = stat.Wins + stat.Losses; Debug.Assert(stat.Sets == stat.Wins + stat.Losses, "Sets must equal Wins + Losses"); } result.Players = stats.Values .OrderByDescending(p => p.Wins) .ThenByDescending(p => p.Sets) .ThenBy(p => p.Player, StringComparer.Ordinal) .ThenBy(p => p.PlayerId) .ToList(); // Not capped: bounded by the number of sets in scope. result.HeadToHead = h2h.Values .OrderByDescending(p => p.WinsA + p.WinsB) .ThenBy(p => p.PlayerA, StringComparer.Ordinal) .ThenBy(p => p.PlayerB, StringComparer.Ordinal) .ThenBy(p => p.PlayerAId) .ThenBy(p => p.PlayerBId) .ToList(); return result; } private static PlayerMatchStats GetOrAdd(Dictionary stats, int playerId, Dictionary nameById) { if (!stats.TryGetValue(playerId, out var stat)) { stat = new PlayerMatchStats { PlayerId = playerId, Player = ResolveName(playerId, nameById) }; stats[playerId] = stat; } return stat; } /// /// Gamertag, falling back to Name, else "#<id>" (a set can reference a player row /// that no longer exists, and Gamertag is nullable in the database). /// private static string ResolveName(int playerId, Dictionary nameById) { return nameById.TryGetValue(playerId, out var name) ? name : $"#{playerId}"; } private static string DisplayName(Player player) { if (!string.IsNullOrWhiteSpace(player.Gamertag)) { return player.Gamertag; } if (!string.IsNullOrWhiteSpace(player.Name)) { return player.Name; } return $"#{player.Id}"; } } }