UI/LeaderboardRows.cs
using System;
using System.Collections.Generic;
using Sandbox;

namespace BlockParty;

/// <summary>
/// One renderable leaderboard line, shared by the High Scores page and the level-select footer so
/// the two feed identical data into the shared <c>LeaderboardRow</c> component. Debug rows set
/// <see cref="TimeSeconds"/> directly; real rows leave it NaN and carry a <see cref="DataUrl"/> whose
/// payload (time / character / block tally / replay input) is fetched and cached lazily on render by
/// <see cref="RunDataStore"/>.
/// </summary>
public struct LeaderboardRowData
{
	public long SteamId;
	public string Name;

	/// <summary>The score as displayed — the whole-number part of <see cref="Value"/>.</summary>
	public int Score;

	/// <summary>The board entry's full value. Its fractional part is the hidden finish-time tiebreaker
	/// (see <see cref="Leaderboard.Submit"/>), so this — not <see cref="Score"/> — is what the board
	/// actually sorts on, and what a spliced row has to be ordered against.</summary>
	public double Value;

	public string DataUrl;
	public string CountryCode;
	public DateTimeOffset Timestamp;

	/// <summary>Debug/inline rows carry their time here directly. NaN means "resolve from the payload",
	/// which every backend row wants — and it is NOT the default, so a row built from a board entry has
	/// to set it explicitly or it renders a bogus 00:00 instead of waiting for its DataUrl.</summary>
	public float TimeSeconds;
}

/// <summary>
/// Helpers for post-processing a fetched board before it's displayed.
/// </summary>
public static class LeaderboardRows
{
	/// <summary>
	/// Order rows by value and submission date, then show the local player's just-submitted run
	/// even when the board response predates it.
	///
	/// Submitting is immediate, but the leaderboard query is an HTTP GET whose response is cached
	/// upstream per-URL, so for a few minutes after a run the board can still hand back your OLD
	/// score — while a differently-shaped query (a different scope, or the center-on-me page) returns
	/// the new one. That's what produces the confusing state where two screens disagree.
	///
	/// So if we submitted something better this session than what came back, we patch it in from
	/// <see cref="Leaderboard.TryGetLocalBestRun"/>. The payload is primed into <see cref="RunDataStore"/>
	/// under a synthetic url, which means the spliced row is indistinguishable from a real one: it
	/// renders its time, character and block tally, and its replay button works.
	///
	/// <paramref name="maxEntries"/> is the size of the window the caller asked the backend for.
	/// <paramref name="confirmedLocalScore"/> comes from this response BEFORE trimming the display
	/// window. A confirmed run outside that window must not be reinserted using a local timestamp.
	/// <paramref name="allowLocalPending"/> is false for closed daily boards: only server-confirmed rows count.
	/// </summary>
	public static void SpliceLocalBest( List<LeaderboardRowData> rows, string statName, int maxEntries,
		double? confirmedLocalScore = null, bool allowLocalPending = true )
	{
		if ( rows is null ) return;
		if ( !allowLocalPending ) rows.RemoveAll( r => r.DataUrl?.StartsWith( "local-run://", StringComparison.Ordinal ) == true );
		rows.Sort( Compare );
		if ( !allowLocalPending ) return;
		if ( !Leaderboard.TryGetLocalBestRun( statName, out var best ) || best.Data is null )
			return;
		if ( confirmedLocalScore.HasValue && confirmedLocalScore.Value >= best.Score )
			return;

		long me = (long)Game.SteamId;
		int existing = rows.FindIndex( r => r.SteamId == me );

		// The window we're allowed to fill. Normally maxEntries, but a caller can legitimately hold a
		// LONGER list than it asked the backend for — a debug fan-out, or a backend that returned more
		// than requested — and silently truncating that would be a regression, not a fix. Captured
		// before any mutation so the patch path (remove + re-insert) can't shrink the list either.
		int limit = Math.Max( maxEntries, rows.Count );

		// The response already reflects this run (or something better we didn't submit here) — the
		// cache has caught up, so keep its authoritative timestamp and payload.
		if ( existing >= 0 && rows[existing].Value >= best.Score )
			return;

		string dataUrl = LocalDataUrl( statName, me );
		RunDataStore.Prime( dataUrl, best.Data );

		LeaderboardRowData row;
		if ( existing >= 0 )
		{
			// We're listed, just with a stale run. Keep the backend's own metadata for us: the name it
			// has on file, and the country code, which the row doesn't draw but does hand to
			// ReplaySubmitter for the replay HUD. Only the run itself is out of date.
			row = rows[existing];
			rows.RemoveAt( existing );
		}
		else
		{
			row = new LeaderboardRowData
			{
				SteamId = me,
				Name = new Friend( Game.SteamId ).Name,
				CountryCode = null,   // not known locally; only affects the replay HUD, not the row
			};
		}

		row.Value = best.Score;
		row.Score = best.Data.FinalScore;
		row.DataUrl = dataUrl;
		row.Timestamp = best.SubmittedAt;
		row.TimeSeconds = float.NaN;   // resolved from the primed payload, exactly like a real row

		// Use the same earlier-submission tiebreaker as fetched rows.
		int index = rows.FindIndex( r => Compare( row, r ) < 0 );
		if ( index < 0 )
			index = rows.Count;

		// Falling outside a full window is a legitimate outcome, not staleness: this is either a
		// top-N page (the level-select footer) or one centred somewhere else, and we don't make the
		// cut. Only applies to a row we're adding — one that was already listed always goes back,
		// and can only have moved UP, since its score just improved.
		if ( existing < 0 && index >= limit )
			return;

		rows.Insert( index, row );

		// Only ever trim the overflow this insert caused. The patch path is already back to its
		// original length here, so it never reaches this.
		if ( rows.Count > limit )
			rows.RemoveRange( limit, rows.Count - limit );
	}

	private static int Compare( LeaderboardRowData a, LeaderboardRowData b ) =>
		LeaderboardOrder.Compare( a.Value, a.Timestamp, a.SteamId, b.Value, b.Timestamp, b.SteamId );

	/// <summary>
	/// Indices of rows whose whole-second mm:ss matches the row immediately above or below. Those rows
	/// reveal their milliseconds so tied times can be told apart. Shared by the High Scores page and
	/// the level-select footer so both agree on when ms show.
	///
	/// Computed over the whole list, not just the visible window, so neighbors across a window boundary
	/// can still match. <paramref name="resolveTime"/>
	/// hands back a row's time by index — null while its payload is still loading — which leaves the
	/// caller in charge of which rows it's willing to fetch a payload for.
	/// </summary>
	public static HashSet<int> ComputeMsRows( List<LeaderboardRowData> rows, Func<int, float?> resolveTime )
	{
		var dupes = new HashSet<int>();
		string previousKey = null;

		for ( int i = 0; i < rows.Count; i++ )
		{
			var time = resolveTime( i );
			var key = time is null ? null : LeaderboardRow.FormatTime( time.Value );
			if ( key != null && key == previousKey )
			{
				dupes.Add( i - 1 );
				dupes.Add( i );
			}

			previousKey = key;
		}

		return dupes;
	}

	/// <summary>Synthetic <c>DataUrl</c> for a spliced row. Never fetched — <see cref="RunDataStore.Prime"/>
	/// puts the payload in the cache under this key first. Stable per (stat, player) so re-entering a
	/// screen reuses the same cache slot instead of growing a new one each time.</summary>
	private static string LocalDataUrl( string statName, long steamId ) => $"local-run://{statName}/{steamId}";
}