Game/Leaderboard.cs
namespace BlockParty;

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

/// <summary>
/// Thin wrapper around sbox's stats/leaderboard services for the single "high score"
/// board. We submit each completed run's score as a stat event; the board is queried with
/// <c>Max</c> aggregation + descending sort, so every player shows their personal best.
///
/// The <see cref="StatName"/> stat is created on first submit — no dashboard setup needed —
/// and submitting/querying works from the editor, so playing in-editor populates the board
/// with your own scores. (The first query of a brand-new stat can come back empty while the
/// backend warms up; the UI just shows "NO ENTRIES YET" until the next refresh.)
/// </summary>
public static class Leaderboard
{
	// All-time personal bests CONFIRMED by board reads this session. Missing means the backend result
	// is unknown, while NegativeInfinity means a successful query found no entry for the local player.
	// Submitted values are deliberately NOT merged: the engine drops a failed upload without retrying,
	// so caching a submit as the best would suppress every later run that beat the real board entry.
	// The cost is a redundant submit when a run beats the confirmed best but not this session's, which
	// Max aggregation absorbs.
	private static readonly Dictionary<string, double> _personalBests = new();
	private static readonly HashSet<string> _personalBestQueries = new();

	// The actual run behind each stat's best score submitted THIS SESSION, kept so the UI can splice
	// it into a board response that predates the submission. Separate from _personalBests, which only
	// board reads raise — those carry no payload, and a value we didn't submit ourselves is already on
	// the board by definition. Session-only is enough: it only has to cover the gap until the backend
	// stops serving a cached response, which is minutes at most.
	private static readonly Dictionary<string, LocalBestRun> _personalBestRuns = new();

	/// <summary>
	/// Bump this to start a fresh board — it's baked into <see cref="StatName"/>, so a new
	/// version queries/submits a brand-new stat and the old scores simply stop being shown. Also
	/// folded into the daily gen seed, so a bump re-rolls every day's layout. Sim behaviour changes
	/// bump <see cref="Sim.VERSION"/> instead — the two are independent (see that constant's doc).
	/// To wipe ONE level's boards, bump its entry in <see cref="LevelBoardRevisions"/> instead.
	/// </summary>
	public const int LEADERBOARD_VERSION = 40;

	/// <summary>Per-level board revisions. Bumping a level's entry wipes that level's boards alone
	/// (its main board and every character board, which derive from it) without rolling every board
	/// via LEADERBOARD_VERSION — for a sim bug only one level's runs hit, or a level edited after
	/// scores landed. Folded into <see cref="StatNameForLevel"/> as <c>score_vN_levelid_r2</c>; unlisted
	/// levels stay unsuffixed. Workshop levels are excluded (their hash suffix already rolls per
	/// revision). Removing an entry brings the old board back; clear the table on the next
	/// LEADERBOARD_VERSION bump. Keyed by registry id, e.g. <c>["lanes"] = 1</c>.</summary>
	private static readonly Dictionary<string, int> LevelBoardRevisions = new()
	{
		//["lanes"] = 1,
	};

	/// <summary>Base stat name; the live <see cref="StatName"/> appends the version suffix.</summary>
	private const string StatBaseName = "score";

	/// <summary>Versioned base stat name; every level's board is <c>score_vN_levelid</c> (see
	/// <see cref="StatNameForLevel"/>).</summary>
	public static string StatName => $"{StatBaseName}_v{LEADERBOARD_VERSION}";

	/// <summary>Per-level stat name (<c>score_vN_levelid</c>). Null/empty maps to classic. Falls back
	/// to the raw id if the level registry hasn't loaded yet (early boot), so it never NREs. Workshop
	/// levels append their content hash (<c>score_vN_ws123-hash8</c>), so an author republish starts a
	/// fresh board — and an UNREGISTERED workshop id keeps its own name rather than falling back to
	/// classic, which would silently query/poison classic's board.</summary>
	public static string StatNameForLevel( string levelId )
	{
		var resolved = Levels.Get( levelId );
		if ( resolved is null && WorkshopLevels.IsWorkshopId( levelId ) )
		{
			Log.Warning( $"BlockParty: stat requested for unregistered workshop level '{levelId}'." );
			return $"{StatName}_{levelId}";
		}

		resolved ??= Levels.Classic;
		var id = resolved?.Id ?? (string.IsNullOrEmpty( levelId ) ? Levels.ClassicId : levelId);
		if ( resolved?.IsWorkshop == true && !string.IsNullOrEmpty( resolved.WorkshopContentHash ) )
			return $"{StatName}_{id}-{resolved.WorkshopContentHash}";

		return LevelBoardRevisions.TryGetValue( id, out int rev ) && rev > 0
			? $"{StatName}_{id}_r{rev}"
			: $"{StatName}_{id}";
	}

	/// <summary>Per-level, per-character stat name. Character ids are resolved through the registry so
	/// null, unknown, or retired ids consistently share the Original character's board.</summary>
	public static string StatNameForLevelAndCharacter( string levelId, string characterId ) =>
		$"{StatNameForLevel( levelId )}_character_{Characters.Get( characterId ).Id}";

	/// <summary>Per-day stat name for daily challenges. One stat per UTC date lets old daily boards be
	/// browsed forever without mixing them into the normal high-score table.</summary>
	public static string DailyStatName( string dailyId ) => DailyChallenge.StatNameFor( dailyId );

	/// <summary>How many entries the highscore screen requests.</summary>
	public const int MaxEntries = 200;

	/// <summary>Start loading the local player's relevant all-time bests. Failed or unfinished queries
	/// deliberately leave a stat unknown so a finished run submits to that board as a safe fallback.</summary>
	public static void PrimePersonalBests( RunContext context )
	{
		if ( context == default )
			context = RunContext.Normal;

		if ( context.IsDaily )
		{
			PrimePersonalBest( DailyStatName( context.DailyId ) );
			return;
		}

		PrimePersonalBest( StatNameForLevel( context.LevelId ) );
		PrimePersonalBest( StatNameForLevelAndCharacter( context.LevelId, context.CharacterId ) );
	}

	private static void PrimePersonalBest( string statName )
	{
		if ( _personalBests.ContainsKey( statName ) || !_personalBestQueries.Add( statName ) )
			return;

		_ = FetchPersonalBest( statName );
	}

	private static async Task FetchPersonalBest( string statName )
	{
		try
		{
			var board = GetBoard( statName );
			await board.Refresh();
			var localEntry = board.Entries?.FirstOrDefault( entry => entry.SteamId == (long)Game.SteamId );
			RecordPersonalBest( statName, localEntry?.Value );
		}
		catch ( System.Exception e )
		{
			Log.Info( $"BlockParty: personal-best query unavailable ({e.Message}) [stat '{statName}']; submission will use the safe fallback." );
		}
		finally
		{
			_personalBestQueries.Remove( statName );
		}
	}

	/// <summary>A run we submitted this session: the full packed <paramref name="Score"/> (including its
	/// fractional time tiebreaker, so it sorts against board entries exactly), the payload itself, and
	/// when we submitted it. See <see cref="TryGetLocalBestRun"/>.</summary>
	public readonly record struct LocalBestRun( double Score, RunData Data, System.DateTimeOffset SubmittedAt );

	/// <summary>The best run this session submitted to <paramref name="statName"/>, if any. The UI uses
	/// it to show your new score immediately on a board response that was cached before you submitted.</summary>
	public static bool TryGetLocalBestRun( string statName, out LocalBestRun run )
	{
		run = default;
		return !string.IsNullOrEmpty( statName ) && _personalBestRuns.TryGetValue( statName, out run );
	}

	/// <summary>Merge a successful board query into the session cache. A null score means the query
	/// completed but the local player had no entry; a known value is never lowered.</summary>
	public static void RecordPersonalBest( string statName, double? score )
	{
		double known = score ?? double.NegativeInfinity;
		if ( !_personalBests.TryGetValue( statName, out double current ) || known > current )
			_personalBests[statName] = known;
	}

	/// <summary>
	/// Submit a finished run's score, then flush so it's queryable promptly. The value is a
	/// <c>double</c>: callers may pack a sub-integer remainder into it as a leaderboard tiebreaker
	/// (e.g. precise finish time on a win) — the board sorts by the full value, while the UI shows
	/// only the integer part, so the fraction is invisible but still breaks ties. The run's elapsed
	/// <paramref name="timeSeconds"/>, <paramref name="victory"/> flag, and the recorded input needed
	/// to replay the run all ride along as a JSON data payload (read back via <c>Entry.DataUrl</c>).
	/// The final block states (<paramref name="blocks"/>) ride along too so the row can draw a graphic
	/// of which sides were pressed (mirrors the score-tally screen).
	/// </summary>
	public static RunData Submit( double score, float timeSeconds, bool victory, int finalScore, IReadOnlyList<BlockResult> blocks, RunContext context = default )
	{
		if ( context == default )
			context = RunContext.Normal;

		var data = BuildRunData( timeSeconds, victory, finalScore, blocks, context );

		string levelStat = StatNameForLevel( context.LevelId );
		string characterStat = context.IsDaily ? null : StatNameForLevelAndCharacter( context.LevelId, context.CharacterId );

		// Keep a local copy so the player can rewatch their own runs offline (My Replays screen),
		// including when every known leaderboard already has a better score.
		LocalReplays.Add( data );

		// Named 'data:' arg binds the (name, amount, string context = null, object data = null)
		// overload — passing the object positionally would fail to bind (it converts to neither
		// string nor Dictionary). The engine serializes it to JSON and exposes it on Entry.DataUrl.
		//
		// Normal runs count toward the level's overall board and the board for their selected character.
		// Daily runs stay isolated on their per-day board because each day plays its own generated layout.
		// All boards use Max aggregation. Unknown stats submit as a safe fallback; known stats only
		// submit when this run improves the CONFIRMED value, including its hidden time tiebreaker.
		var stats = context.IsDaily
			? new List<string> { DailyStatName( context.DailyId ) }
			: new List<string> { levelStat, characterStat };
		var submittedStats = new List<string>( stats.Count );
		foreach ( string statName in stats )
		{
			if ( _personalBests.TryGetValue( statName, out double best ) && score <= best )
				continue;

			Sandbox.Services.Stats.SetValue( statName, score, data: data );
			// Hold onto the run itself: the board can keep serving a pre-submit response for a few
			// minutes, and the UI splices this in so you see your new score right away. Only the
			// session's best: a later run can still submit below it (see _personalBests).
			if ( !_personalBestRuns.TryGetValue( statName, out var bestRun ) || score > bestRun.Score )
				_personalBestRuns[statName] = new LocalBestRun( score, data, System.DateTimeOffset.Now );
			submittedStats.Add( statName );
		}

		if ( submittedStats.Count > 0 )
		{
			Sandbox.Services.Stats.Flush();
			Log.Info( $"BlockParty: submitted score {score} (time {timeSeconds:0.00}s, victory {victory}, {RunRecorder.StepCount} steps, seed {RunRecorder.Seed}) to {string.Join( ", ", submittedStats )}." );
		}
		else
		{
			Log.Info( $"BlockParty: saved score {score} locally; confirmed personal bests already meet or exceed it on every relevant leaderboard." );
		}

		return data;
	}

	/// <summary>Build the deterministic replay payload for the run that just finished (seed + per-step
	/// input read from <see cref="RunRecorder"/>, plus the block tally and run context). Shared by
	/// <see cref="Submit"/> (leaderboard runs) and the editor test-play path, which saves a LOCAL
	/// replay without submitting. The recorder holds the finished run until the next run begins.</summary>
	public static RunData BuildRunData( float timeSeconds, bool victory, int finalScore, IReadOnlyList<BlockResult> blocks, RunContext context )
	{
		if ( context == default )
			context = RunContext.Normal;

		return new RunData
		{
			TimeSeconds = timeSeconds,
			Victory = victory,
			Seed = RunRecorder.Seed,
			SimVersion = Sim.VERSION,
			StepCount = RunRecorder.StepCount,
			InputDeltas = RunRecorder.Encode(),
			Truncated = RunRecorder.Truncated,
			FinalScore = finalScore,
			Blocks = RunData.PackBlocks( blocks ),
			DailyId = context.IsDaily ? context.DailyId : null,
			LevelId = context.Level.Id,
			// Every run records which level REVISION it was played on: replays re-sim the level they
			// resolve at watch time (registry / regenerated daily / installed workshop copy) and are
			// only watchable while its content hash still matches (see RunData.CanReplay).
			LevelHash = context.Level.ContentHash,
			CharacterId = context.Character.Id,
		};
	}

	/// <summary>Build the all-time high-score board for the player's last-viewed level, applying the
	/// last-used Global/Friends scope. Highest score first.</summary>
	public static Sandbox.Services.Leaderboards.Board2 GetBoard() =>
		GetBoard( StatNameForLevel( Settings.Current.LeaderboardLevelId ),
			Settings.Current.LeaderboardScope );

	/// <summary>Build an all-time board centered on the local player's best score for a specific
	/// stat, optionally limited to friends.</summary>
	public static Sandbox.Services.Leaderboards.Board2 GetBoard( string statName,
		LeaderboardScope scope = LeaderboardScope.Global, int maxEntries = MaxEntries )
	{
		var board = Sandbox.Services.Leaderboards.GetFromStat( statName );
		board.SetAggregationMax();
		board.SetSortDescending();
		board.FilterByNone();
		board.SetFriendsOnly( scope == LeaderboardScope.Friends );
		board.CenterOnMe();
		board.MaxEntries = maxEntries;
		return board;
	}

	/// <summary>Build a compact all-time board of the TOP entries for a stat (highest first), applying
	/// the same scope filter as <see cref="GetBoard(string, LeaderboardScope, int)"/> but WITHOUT center-on-me.
	/// <paramref name="maxEntries"/> keeps the query small.</summary>
	public static Sandbox.Services.Leaderboards.Board2 GetTopBoard( string statName, int maxEntries,
		LeaderboardScope scope = LeaderboardScope.Global )
	{
		var board = Sandbox.Services.Leaderboards.GetFromStat( statName );
		board.SetAggregationMax();
		board.SetSortDescending();
		board.FilterByNone();
		board.SetFriendsOnly( scope == LeaderboardScope.Friends );
		board.MaxEntries = maxEntries;
		return board;
	}

	/// <summary>Build the leaderboard for a daily challenge day. The current sbox Board2 API exposes
	/// Sum/Avg/Min/Max/Last aggregation but not First, so repeat-submission protection still relies on
	/// local locking plus checking whether this player already appears on the day board. If First is added
	/// later, this is the single place to switch daily aggregation from Max to First.
	/// <para>
	/// The board is DAY-FILTERED server-side: the backend buckets every stat write by the UTC date it
	/// was INGESTED (PlayerStatDaily), and FilterByDay ranks only the challenge day's bucket. So a
	/// client patched to play a past day and submit to its stat lands in the wrong bucket and simply
	/// never appears here — enforced by the backend's clock, which no client edit can reach. Honest
	/// runs always land in-bucket because PLAY is only offered on the day itself. Known edge: a run
	/// submitted just after UTC midnight (started ~23:59, or delayed by the engine's ~30s stat
	/// batching) lands in the next day's bucket and misses its board.
	/// </para></summary>
	public static Sandbox.Services.Leaderboards.Board2 GetDailyBoard( string dailyId )
	{
		var board = GetTopBoard( DailyStatName( dailyId ), MaxEntries );
		if ( DailyChallenge.TryParseId( dailyId, out var day ) )
		{
			board.FilterByDay();
			board.SetDatePeriod( day );
		}
		return board;
	}

	// Shared core of the entry↔payload sanity checks below: the submitted stat value is exactly
	// FinalScore + TimeTiebreaker(TimeSeconds) (see GameStage.SubmitRunOnce — same float in, same code),
	// and the pipeline is double end-to-end, so for an honest entry this reproduces bit-for-bit. The
	// tolerance is far below the tiebreaker's resolution (~2.8e-6 per second of run time), so it only
	// absorbs float↔JSON round-trip noise, not a forged time.
	private static bool ValueMatchesPayload( RunData data, double value )
		=> System.Math.Abs( value - (data.FinalScore + ScoreCalc.TimeTiebreaker( data.TimeSeconds )) ) < 1e-9;

	/// <summary>Cheap display-side sanity check between a daily-board entry and its run payload; rows
	/// failing it are hidden (see the daily screen's pruning). Catches lazily fabricated writes — an
	/// arbitrary Stats.SetValue with a missing, borrowed, or mismatched payload: the payload must claim
	/// this exact day, carry the day's deterministic seed, and the entry's submitted value must equal
	/// the payload's own score + time tiebreaker (the exact value <c>GameStage</c> submits). It does NOT
	/// re-simulate the input stream — a payload that passes here but was never really played still
	/// desyncs visibly when watched.</summary>
	public static bool IsDailyEntryConsistent( RunData data, string dailyId, double value )
		=> data is not null
		&& data.DailyId == dailyId
		&& data.Seed == DailyChallenge.SeedFor( dailyId )
		&& ValueMatchesPayload( data, value );

	/// <summary>The same cheap sanity check for the NORMAL boards (per-level, and per-level-per-character
	/// when <paramref name="characterId"/> is non-null): the payload must be a non-daily run on the
	/// board's level (resolved exactly like <see cref="StatNameForLevel"/> resolves the stat name, so the
	/// expectation always matches the stat actually being browsed), played as the board's character on a
	/// character board, and the entry's value must equal the payload's score + tiebreaker. Level boards
	/// have no daily-style server-side window, so this is their only tamper check — and a forged entry
	/// here would otherwise sit on an all-time board forever. No seed check: normal runs draw a random
	/// seed per run.</summary>
	public static bool IsLevelEntryConsistent( RunData data, string levelId, string characterId, double value )
	{
		if ( data is null || data.IsDaily )
			return false;

		// Workshop boards: never resolve through the classic fallback (an uninstalled ws id would
		// otherwise prune EVERY row as "not classic"), and bind each entry to the board's level
		// revision — same spirit as the daily board's seed check.
		if ( WorkshopLevels.IsWorkshopId( levelId ) )
		{
			var ws = Levels.Get( levelId );
			return ws is not null
				&& data.LevelId == ws.Id
				&& data.LevelHash == ws.WorkshopContentHash
				&& (characterId is null || Characters.Get( data.CharacterId ).Id == Characters.Get( characterId ).Id)
				&& ValueMatchesPayload( data, value );
		}

		var resolved = Levels.Get( levelId ) ?? Levels.Classic;
		var expectedLevelId = resolved?.Id ?? (string.IsNullOrEmpty( levelId ) ? Levels.ClassicId : levelId);
		if ( data.LevelId != expectedLevelId )
			return false;

		// Character boards resolve both sides through the registry, mirroring
		// StatNameForLevelAndCharacter (null/unknown → Original).
		if ( characterId is not null && Characters.Get( data.CharacterId ).Id != Characters.Get( characterId ).Id )
			return false;

		return ValueMatchesPayload( data, value );
	}
}