Game/MedalStandings.cs
namespace BlockParty;

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

/// <summary>Session cache for the centrally computed map podiums. Never queries individual boards.</summary>
public static class MedalStandings
{
	private const string Endpoint = "https://blockparty-medals.ryly.workers.dev/v2/medals/";
	private sealed class CachedBoard
	{
		public MedalSnapshot Snapshot;
		public bool Loading;
		public bool RefreshFailed;
		public string Notice = "Loading standings...";
		public DateTimeOffset NextFetch;
	}
	private static readonly Dictionary<string, CachedBoard> Boards = new();
	private static CachedBoard Board( string characterId )
	{
		string key = characterId ?? "";
		if ( !Boards.TryGetValue( key, out var board ) ) Boards[key] = board = new();
		return board;
	}
	public static MedalSnapshot Current => GetSnapshot();
	public static string Notice => GetNotice();
	public static MedalSnapshot GetSnapshot( string characterId = null ) => Board( characterId ).Snapshot;
	public static string GetNotice( string characterId = null ) => Board( characterId ).Notice;
	public static int Revision { get; private set; }
	private static DateTimeOffset _nextCheck;
	private static string _collection;
	private static int _generation;
	private static bool _matches;
	private static bool _charactersMatch;

	/// <summary>Called while the panel is visible; revisiting the map reuses the session cache.</summary>
	public static void Tick( string characterId = null )
	{
		var now = DateTimeOffset.UtcNow;
		if ( now >= _nextCheck )
		{
			_nextCheck = now.AddSeconds( 1 );
			_matches = Leaderboard.LEADERBOARD_VERSION == MedalStandingsManifest.Version
				&& Levels.All.Count == MedalStandingsManifest.Stats.Length
				&& new HashSet<string>( Levels.All.Select( level => Leaderboard.StatNameForLevel( level.Id ) ) )
					.SetEquals( MedalStandingsManifest.Stats );
			_charactersMatch = new HashSet<string>( Characters.All.Select( c => c.Id ) ).SetEquals( MedalStandingsManifest.CharacterIds );
			string identity = _matches ? MedalStandingsManifest.CollectionId + "/" + (_charactersMatch ? MedalStandingsManifest.CharacterCollectionId : "") : null;
			if ( identity != _collection )
			{
				_collection = identity;
				_generation++;
				Boards.Clear();
				Revision++;
			}
		}
		var board = Board( characterId );
		// A hot-reloaded game may retain rows fetched under the previous policy.
		if ( board.Snapshot is not null && board.Snapshot.Ranking != AwardPoints.Ranking )
		{
			board.Snapshot = null;
			board.NextFetch = default;
			board.Notice = "Loading standings...";
			Revision++;
		}
		if ( !_matches || (characterId is not null && (!_charactersMatch || !MedalStandingsManifest.CharacterIds.Contains( characterId ))) )
		{
			board.Notice = "Standings are updating for this map.";
			return;
		}
		if ( board.Loading || now < board.NextFetch ) return;
		board.Loading = true;
		Revision++;
		string collection = characterId is null ? MedalStandingsManifest.CollectionId : MedalStandingsManifest.CharacterCollectionId;
		_ = Fetch( board, collection, characterId, _generation );
	}

	private static async Task Fetch( CachedBoard board, string collection, string characterId, int generation )
	{
		string url = Endpoint + collection + (characterId is null ? "" : $"/characters/{characterId}");
		try
		{
			using var timeout = new CancellationTokenSource( TimeSpan.FromSeconds( 15 ) );
			var snapshot = await Http.RequestJsonAsync<MedalSnapshot>( url, cancellationToken: timeout.Token );
			if ( generation != _generation ) return;
			if ( !IsValid( snapshot, collection, characterId ) ) throw new InvalidOperationException( "Unexpected medal snapshot." );
			board.Snapshot = snapshot;
			board.Notice = null;
			board.NextFetch = DateTimeOffset.UtcNow.AddMinutes( 5 );
			if ( board.RefreshFailed )
				Log.Info( $"BlockParty: medal refresh recovered. URL: {url}." );
			board.RefreshFailed = false;
		}
		catch ( Exception e )
		{
			if ( generation != _generation ) return;
			board.Notice = board.Snapshot is null ? "Medals unavailable. Retrying shortly." : "Showing the last available data.";
			board.NextFetch = DateTimeOffset.UtcNow.AddMinutes( 1 );
			// Keep the first failure for bug reports; repeated failures only need editor diagnostics.
			if ( !board.RefreshFailed || Game.IsEditor )
				Log.Warning( $"BlockParty: medal refresh failed. URL: {url}. "
					+ $"Saved standings updated at: {(board.Snapshot is null ? "none" : board.Snapshot.UpdatedAt.ToString( "O" ))}. "
					+ $"Retrying in 1 minute. Error: {(board.RefreshFailed ? e.Message : e.ToString())}" );
			board.RefreshFailed = true;
		}
		finally
		{
			if ( generation == _generation )
			{
				board.Loading = false;
				Revision++;
			}
		}
	}

	private static bool IsValid( MedalSnapshot snapshot, string collection, string characterId )
	{
		int count = MedalStandingsManifest.Stats.Length;
		if ( snapshot is null || snapshot.SchemaVersion != 1 || snapshot.Ranking != AwardPoints.Ranking || snapshot.CollectionId != collection || snapshot.CharacterId != characterId
			|| snapshot.LeaderboardVersion != Leaderboard.LEADERBOARD_VERSION || snapshot.LevelsCounted != count
			|| snapshot.LevelsWithEntries < 0 || snapshot.LevelsWithEntries > count
			|| snapshot.UpdatedAt == default || snapshot.UpdatedAt > DateTimeOffset.UtcNow.AddMinutes( 5 )
			|| snapshot.Players is null || snapshot.Players.Count > count * 3 ) return false;
		var seen = new HashSet<long>();
		MedalPlayer previous = null;
		int rank = 0;
		for ( int i = 0; i < snapshot.Players.Count; i++ )
		{
			var player = snapshot.Players[i];
			if ( player is null || player.AccountId <= 0 || !seen.Add( player.AccountId )
				|| string.IsNullOrWhiteSpace( player.Name ) || player.Name.Length > 256
				|| player.Gold < 0 || player.Gold > count || player.Silver < 0 || player.Silver > count
				|| player.Bronze < 0 || player.Bronze > count || player.Gold + player.Silver + player.Bronze == 0
				|| player.Points != AwardPoints.Total( player.Gold, player.Silver, player.Bronze ) ) return false;
			if ( previous is not null && AwardPoints.Compare( previous.Gold, previous.Silver, previous.Bronze, player.Gold, player.Silver, player.Bronze ) > 0 ) return false;
			if ( previous is null || player.Gold != previous.Gold || player.Silver != previous.Silver || player.Bronze != previous.Bronze ) rank = i + 1;
			if ( player.Rank != rank ) return false;
			previous = player;
		}
		return snapshot.Players.Sum( player => player.Gold ) == snapshot.LevelsWithEntries
			&& snapshot.Players.Sum( player => player.Silver ) <= snapshot.LevelsWithEntries
			&& snapshot.Players.Sum( player => player.Bronze ) <= snapshot.LevelsWithEntries;
	}
}

public sealed class MedalSnapshot
{
	public string Ranking { get; set; }
	public int SchemaVersion { get; set; }
	public string CollectionId { get; set; }
	public string CharacterId { get; set; }
	public int LeaderboardVersion { get; set; }
	public DateTimeOffset UpdatedAt { get; set; }
	public int LevelsCounted { get; set; }
	public int LevelsWithEntries { get; set; }
	public List<MedalPlayer> Players { get; set; }
}

public sealed class MedalPlayer
{
	public long Points { get; set; }
	public string SteamId { get; set; }
	public string Name { get; set; }
	public int Rank { get; set; }
	public int Gold { get; set; }
	public int Silver { get; set; }
	public int Bronze { get; set; }
	public long AccountId => long.TryParse( SteamId, out var id ) ? id : 0;
}