Game/CompletionStandings.cs
namespace BlockParty;

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

/// <summary>Reads the existing achievement total; no per-level queries or new stat writes.</summary>
public static class CompletionStandings
{
	private const int PageSize = 100;
	private const string Endpoint = "https://public.facepunch.com/sbox/package/facepunch.blockparty/leaderboard/";
	public static IReadOnlyList<CompletionEntry> Players { get; private set; } = Array.Empty<CompletionEntry>();
	public static CompletionEntry Personal { get; private set; }
	public static long TotalPlayers { get; private set; }
	public static DateTimeOffset UpdatedAt { get; private set; }
	public static bool Loading { get; private set; }
	public static string Notice { get; private set; }
	public static int Revision { get; private set; }
	public static bool HasMore => Players.Count < TotalPlayers;
	public static bool IsComplete( double value ) => LevelMap.Nodes.Count > 0 && value == LevelMap.Nodes.Count;
	private static DateTimeOffset _nextFetch;
	private static int _rowLimit = PageSize;

	public static void Tick()
	{
		if ( !Loading && DateTimeOffset.UtcNow >= _nextFetch ) _ = Fetch( false );
	}

	public static void LoadMore()
	{
		if ( !Loading && HasMore ) _ = Fetch( true );
	}

	private static async Task Fetch( bool more )
	{
		Loading = true;
		Revision++;
		try
		{
			// Re-read the whole visible range in one response. Separate cached pages can
			// disagree when players join or move across the boundary between requests.
			int count = Math.Max( _rowLimit, Players.Count ) + (more ? PageSize : 0);
			using var timeout = new CancellationTokenSource( TimeSpan.FromSeconds( 15 ) );
			var board = await ReadBoard( count, timeout.Token );
			// Break ties within the loaded range only; Load More can reveal earlier submissions.
			var players = board.Entries.Where( p => p.Rank <= count )
				.OrderByDescending( p => p.Value ).ThenBy( p => p.Timestamp )
				.ThenBy( p => p.SteamId ).ToArray();
			for ( int i = 0; i < players.Length; i++ ) players[i].Rank = i + 1;
			Players = players;
			// Outside the loaded range, keep the personal row's backend rank.
			Personal = board.Entries.FirstOrDefault( p => p.SteamId == (long)Game.SteamId );
			TotalPlayers = board.TotalEntries;
			UpdatedAt = DateTimeOffset.UtcNow;
			_rowLimit = count;
			Notice = null;
			_nextFetch = DateTimeOffset.UtcNow.AddMinutes( 5 );
		}
		catch ( Exception e )
		{
			Notice = UpdatedAt == default ? "Completions unavailable. Retrying shortly." : "Showing saved completions. Retrying shortly.";
			_nextFetch = DateTimeOffset.UtcNow.AddMinutes( 1 );
			if ( Game.IsEditor ) Log.Info( $"BlockParty: completion standings unavailable ({e.Message})." );
		}
		finally
		{
			Loading = false;
			Revision++;
		}
	}

	private static async Task<CompletionBoard> ReadBoard( int count, CancellationToken cancellation )
	{
		string url = $"{Endpoint}{Achievements.MapLevelsBeatenStat}/?aggregation=max&sort=desc&datefilter=none&count={count}&offset=0&include={Game.SteamId}";
		var board = await Http.RequestJsonAsync<CompletionBoard>( url, cancellationToken: cancellation );
		if ( board is null || board.Stat != Achievements.MapLevelsBeatenStat || board.TotalEntries < 0
			|| board.Entries is null || board.Entries.Count > count + 1 )
			throw new InvalidOperationException( "Unexpected completion leaderboard." );
		var seen = new HashSet<long>();
		foreach ( var entry in board.Entries )
		{
			if ( entry is null || entry.SteamId <= 0 || !seen.Add( entry.SteamId )
				|| entry.Rank < 1 || entry.Rank > board.TotalEntries
				|| (entry.Rank > count && entry.SteamId != (long)Game.SteamId)
				|| !double.IsFinite( entry.Value ) || entry.Value < 1 || entry.Value != Math.Truncate( entry.Value )
				|| entry.Timestamp == default
				|| string.IsNullOrWhiteSpace( entry.DisplayName ) || entry.DisplayName.Length > 256 )
				throw new InvalidOperationException( "Invalid completion entry." );
		}
		var players = board.Entries.Where( p => p.Rank <= count ).OrderBy( p => p.Rank ).ToArray();
		if ( players.Length != Math.Min( count, board.TotalEntries )
			|| players.Where( (p, i) => p.Rank != i + 1 ).Any()
			|| players.Skip( 1 ).Where( (p, i) => p.Value > players[i].Value ).Any() )
			throw new InvalidOperationException( "Incomplete or unordered completion standings." );
		return board;
	}
}

public sealed class CompletionBoard
{
	public string Stat { get; set; }
	public long TotalEntries { get; set; }
	public List<CompletionEntry> Entries { get; set; }
}

public sealed class CompletionEntry
{
	public long SteamId { get; set; }
	public string DisplayName { get; set; }
	public long Rank { get; set; }
	public double Value { get; set; }
	public DateTimeOffset Timestamp { get; set; }
}