Game/DailyTrophyStandings.cs
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace BlockParty;

/// <summary>Session cache for finalized daily trophies. Only the visible daily hub polls this service.</summary>
public static class DailyTrophyStandings
{
	private const string Endpoint = "https://blockparty-trophies.ryly.workers.dev/v2/trophies";
	public static DailyTrophySnapshot Current { get; private set; }
	// Loading text belongs to the empty view, not the cached error state.
	public static string Notice => _errorNotice;
	private static string _errorNotice;
	public static int Revision { get; private set; }
	private static bool _loading;
	private static bool _refreshFailed;
	private static DateTimeOffset _nextFetch;

	public static void Tick()
	{
		// Hot reload can preserve a snapshot from the previous ranking policy.
		// Its new Points fields default to zero; don't wait for the hourly refresh.
		if ( Current is not null && Current.Ranking != AwardPoints.Ranking )
		{
			Current = null;
			_nextFetch = default;
			_errorNotice = null;
			Revision++;
		}
		if ( _loading || DateTimeOffset.UtcNow < _nextFetch ) return;
		_loading = true;
		_ = Fetch();
	}

	private static async Task Fetch()
	{
		try
		{
			using var timeout = new CancellationTokenSource( TimeSpan.FromSeconds( 15 ) );
			var snapshot = await Http.RequestJsonAsync<DailyTrophySnapshot>( Endpoint, cancellationToken: timeout.Token );
			if ( !IsValid( snapshot ) ) throw new InvalidOperationException( "Unexpected daily trophy snapshot." );
			if ( Current is not null && (string.CompareOrdinal( snapshot.ThroughDay, Current.ThroughDay ) < 0
				|| snapshot.DaysCounted < Current.DaysCounted || snapshot.UpdatedAt < Current.UpdatedAt) )
				throw new InvalidOperationException( "Daily trophy snapshot went backwards." );
			Current = snapshot;
			_errorNotice = null;
			_nextFetch = NextFetchAt( DateTimeOffset.UtcNow, snapshot );
			if ( _refreshFailed )
				Log.Info( $"BlockParty: daily trophy refresh recovered. URL: {Endpoint}." );
			_refreshFailed = false;
		}
		catch ( Exception error )
		{
			_errorNotice = Current is null ? "Trophies unavailable. Retrying shortly." : "Showing the last available data.";
			_nextFetch = DateTimeOffset.UtcNow.AddMinutes( 1 );
			// Keep the first failure for bug reports; repeated failures only need editor diagnostics.
			if ( !_refreshFailed || Game.IsEditor )
				Log.Warning( $"BlockParty: daily trophy refresh failed. URL: {Endpoint}. Saved through: {Current?.ThroughDay ?? "none"}. Error: {(_refreshFailed ? error.Message : error.ToString())}" );
			_refreshFailed = true;
		}
		finally { _loading = false; Revision++; }
	}

	internal static DateTimeOffset NextFetchAt( DateTimeOffset now, DailyTrophySnapshot snapshot )
	{
		var due = new DateTimeOffset( now.UtcDateTime.Date, TimeSpan.Zero ).AddMinutes( 16 );
		if ( due <= now ) due = due.AddDays( 1 );
		var expectedDay = DailyChallenge.IdFromDate( now.AddMinutes( -15 ).AddDays( -1 ) );
		var next = now.AddMinutes( snapshot.DaysPending > 0 || string.CompareOrdinal( snapshot.ThroughDay, expectedDay ) < 0 ? 5 : 60 );
		return due < next ? due : next;
	}

	private static bool IsValid( DailyTrophySnapshot snapshot )
	{
		if ( snapshot is null || snapshot.SchemaVersion is not (1 or 2) || snapshot.Ranking != AwardPoints.Ranking
			|| !DailyChallenge.TryParseId( snapshot.FirstDay, out var first ) || !DailyChallenge.TryParseId( snapshot.ThroughDay, out var last )
			|| first > last || last >= DateTime.UtcNow.Date || snapshot.DaysCounted < 1 || snapshot.DaysPending < 0
			|| (snapshot.SchemaVersion == 1 && snapshot.DaysPending != 0)
			|| (long)snapshot.DaysCounted + snapshot.DaysPending != (last - first).Days + 1
			|| snapshot.UpdatedAt == default || snapshot.UpdatedAt > DateTimeOffset.UtcNow.AddMinutes( 5 )
			|| snapshot.DaysWithEntries < 0 || snapshot.DaysWithEntries > snapshot.DaysCounted
			|| snapshot.Players is null || snapshot.Players.Count > (long)snapshot.DaysCounted * 3 ) return false;
		var seen = new HashSet<long>();
		DailyTrophyPlayer previous = null;
		int rank = 0;
		long gold = 0, silver = 0, bronze = 0;
		for ( int i = 0; i < snapshot.Players.Count; i++ )
		{
			var player = snapshot.Players[i];
			if ( player is null || player.SteamId?.Length != 17 || player.AccountId <= 0 || !seen.Add( player.AccountId )
				|| string.IsNullOrWhiteSpace( player.Name ) || player.Name.Length > 256
				|| player.Gold < 0 || player.Silver < 0 || player.Bronze < 0
				|| (long)player.Gold + player.Silver + player.Bronze < 1
				|| (long)player.Gold + player.Silver + player.Bronze > snapshot.DaysCounted
				|| 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;
			gold += player.Gold; silver += player.Silver; bronze += player.Bronze;
			previous = player;
		}
		return gold == snapshot.DaysWithEntries && silver <= gold && bronze <= silver;
	}
}

public sealed class DailyTrophySnapshot
{
	public int SchemaVersion { get; set; }
	public string Ranking { get; set; }
	public string FirstDay { get; set; }
	public string ThroughDay { get; set; }
	public int DaysCounted { get; set; }
	public int DaysPending { get; set; }
	public int DaysWithEntries { get; set; }
	public DateTimeOffset UpdatedAt { get; set; }
	public List<DailyTrophyPlayer> Players { get; set; }
}

public sealed class DailyTrophyPlayer
{
	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;
}