Game/RunData.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Sandbox;

namespace BlockParty;

/// <summary>
/// Decoded per-block tally state for the leaderboard / replay row graphic: the block's type, its
/// final phase, and which of its four sides were pressed in that phase. Enough to reproduce the
/// score-tally end-state look (faded block, sides lit per phase, full colour + yellow max sides).
/// </summary>
public readonly record struct BlockTallyState( BlockType Type, int Phase, bool Left, bool Right, bool Up, bool Down );

/// <summary>
/// Small JSON payload attached to each leaderboard run via
/// <c>Stats.SetValue( name, score, data: ... )</c> and read back from <c>Board2.Entry.DataUrl</c>.
/// Carries the run's elapsed time so the high-score screen can show how long each run took (mm:ss).
/// Strongly typed so it round-trips through System.Text.Json. (Same approach SS2 uses for its richer
/// run payload — the score stat itself stays a plain number; anything extra rides along as data.)
/// </summary>
public class RunData
{
	/// <summary>Schema version of this payload, so the reader can reject a shape it doesn't
	/// understand. Older versions are simply not replayable — no legacy decoding is kept.</summary>
	public const int CURRENT_VERSION = 8;

	public int Version { get; set; } = CURRENT_VERSION;

	/// <summary>Elapsed gameplay time in seconds for this run (precise; format on display).</summary>
	public float TimeSeconds { get; set; }

	/// <summary>True if the run was a full win (every block reached max phase).</summary>
	public bool Victory { get; set; }

	// --- replay (v2+) -------------------------------------------------------------------
	// The run is deterministic at a fixed step with all randomness seeded, so it can be re-simulated
	// from (Seed + the input consumed each step). SimVersion guards against replaying a run recorded
	// by a gameplay build this sim no longer honours (which would desync); outside the supported
	// range the run just isn't replayable.

	/// <summary>Simulation seed the run was recorded with (reused verbatim on replay).</summary>
	public int Seed { get; set; }

	/// <summary>Simulation version the run was recorded on (<see cref="Sim.VERSION"/>); a replay only
	/// reproduces on a build whose sim honours that version (post-release, older versions stay
	/// playable through per-fix version gates — see the Sim.VERSION doc).</summary>
	public int SimVersion { get; set; }

	/// <summary>Number of fixed sim steps in the run (the decoded replay length).</summary>
	public int StepCount { get; set; }

	/// <summary>Base64 of the delta-encoded per-step input (see <see cref="RunInputCodec"/>).</summary>
	public string InputDeltas { get; set; }

	/// <summary>True if the run hit the recording cap (too long to replay).</summary>
	public bool Truncated { get; set; }

	/// <summary>Final integer score of the run, for an optional desync check at the end of playback.</summary>
	public int FinalScore { get; set; }

	// --- block tally state (v3+) --------------------------------------------------------
	// Tiny per-block summary so the leaderboard / My Replays rows can draw a graphic of which
	// block sides were pressed during the run (mirrors the score-tally screen). Blocks authored to
	// start fully completed are omitted. Each int packs
	// one block: (typeIndex << 6) | (phase << 4) | (L<<3 | R<<2 | U<<1 | D). Absent on v2 payloads,
	// in which case the row simply omits the graphic.

	/// <summary>Packed per-block tally state (see <see cref="PackBlocks"/> / <see cref="DecodeBlocks"/>).
	/// Null/absent on pre-v3 payloads.</summary>
	public List<int> Blocks { get; set; }

	// --- daily challenge identity (v4+) ---------------------------------------------------
	// DailyId is the only stored daily field. IsDaily is derived from it (below), and DailyId itself
	// is omitted from the serialized payload unless set — so the common non-daily run carries no daily
	// fields at all, keeping the leaderboard payload small.

	[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingDefault )]
	public string DailyId { get; set; }

	/// <summary>True when this run came from a daily challenge. Derived from <see cref="DailyId"/>, so
	/// it needn't be stored or serialized separately.</summary>
	[JsonIgnore]
	public bool IsDaily => !string.IsNullOrEmpty( DailyId );

	// --- level identity (v5+) -------------------------------------------------------------
	// Which level the run was played on, so a replay re-enters the same level (the layout is
	// re-simulated, not stored — an edited level definition silently desyncs its old replays).

	public string LevelId { get; set; }

	// --- level revision (v8; workshop-only originally, ALL runs since the sim/board version split) --
	// The 8-hex content hash of the exact level revision the run was played on (LevelDef.ContentHash).
	// Replays re-sim whatever level they resolve at watch time — registry JSON, regenerated daily,
	// installed workshop copy — so they're gated on this matching: an edited level, a republished
	// workshop item, or a re-rolled daily greys its old runs out instead of silently desyncing.
	// Null only on payloads recorded before the stamp existed (tolerated: unverifiable, not invalid).

	[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingDefault )]
	public string LevelHash { get; set; }

	// --- embedded level layout (LOCAL replays only) ---------------------------------------
	// Editor test-play runs ALWAYS play a transient snapshot of the in-memory editor model (never the
	// disk file), so their level can't be reliably resolved by LevelId after the test ends — even a
	// pristine, saved-to-disk level is played transiently, and could be edited on disk afterwards. So
	// every test-play run embeds its exact level definition here and the replay re-simulates that
	// identical layout. Absent (null) on every leaderboard run — those resolve their layout by
	// LevelId — so the leaderboard payload shape is unchanged and no CURRENT_VERSION bump is needed
	// (the field appears only in the local file and, base64-packed, in a transient run's share code —
	// see ReplayCodeCodec).
	// STORED as the canonical LevelJson wire form, never as a raw LevelDef: RectF doesn't JSON
	// round-trip (only its computed getters serialize), so a raw def came back from disk with every
	// obstacle / alt-rect zeroed and the next Save made that permanent. LevelData is decoded from the
	// snapshot through the same FromJson/ToLevelDef path a share-code import takes, so a replay sims
	// identical geometry whether watched in the recording session, after a restart or from a pasted
	// code — and the regression key / ContentHash derived from it is stable across all three.

	/// <summary>Canonical wire form of the embedded level (see the note above); null when the run
	/// resolves its level by id. Set through <see cref="LevelData"/>.</summary>
	[JsonIgnore( Condition = JsonIgnoreCondition.WhenWritingDefault )]
	public LevelJson LevelSnapshot
	{
		get => _levelSnapshot;
		set { _levelSnapshot = value; _levelData = null; _levelDecoded = false; }
	}

	[JsonIgnore]
	private LevelJson _levelSnapshot;
	[JsonIgnore]
	private LevelDef _levelData;
	[JsonIgnore]
	private bool _levelDecoded;

	/// <summary>The embedded level definition decoded from <see cref="LevelSnapshot"/>, or null when
	/// there is none or it doesn't decode (a newer schema, or a hand-mangled file — the lenient DTO
	/// parser throws on e.g. a null spike entry, and CanReplay is evaluated while rendering every
	/// My Replays row, so a throw here would take the whole list down). Decoded once and cached
	/// either way. Assigning stores the def's canonical wire form; the def read back is that form
	/// decoded, not the instance set.</summary>
	[JsonIgnore]
	public LevelDef LevelData
	{
		get
		{
			if ( !_levelDecoded )
			{
				_levelDecoded = true;
				try
				{
					_levelData = _levelSnapshot is null ? null : EditorLevel.FromVersionedJson( _levelSnapshot, out _ )?.ToLevelDef();
				}
				catch
				{
					_levelData = null;
				}
			}
			return _levelData;
		}
		set
		{
			_levelSnapshot = value is null ? null : EditorLevel.FromLevelDef( value ).ToJson();
			_levelData = null;
			_levelDecoded = false;
		}
	}

	/// <summary>The level this run plays: a daily run regenerates its level from <see cref="DailyId"/>
	/// (deterministic per id within a leaderboard version); otherwise the embedded
	/// <see cref="LevelData"/> when present (test-play local replays), otherwise resolved from
	/// <see cref="LevelId"/> — null when that id names a level that no longer exists (such a run
	/// can't be replayed).</summary>
	[JsonIgnore]
	public LevelDef Level => IsDaily ? DailyLevels.Get( DailyId )?.Level : LevelData ?? Levels.Get( LevelId );

	// --- character identity (v6+) ---------------------------------------------------------
	// Which character the run was played as. Purely visual today (all characters share movement),
	// but recorded so a replay renders the right character — and so it stays correct once future
	// characters have differing movement. An unknown/removed character falls back to the default,
	// so it never blocks replay (unlike a missing level).

	public string CharacterId { get; set; }

	/// <summary>The character this run plays, resolved from <see cref="CharacterId"/> (null/unknown
	/// → Original).</summary>
	[JsonIgnore]
	public CharacterDef Character => Characters.Get( CharacterId );

	// Per-block packing layout (one int per block):
	//   bits 0-3  : sides (Left=8, Right=4, Up=2, Down=1)
	//   bits 4-5  : phase (0-3)
	//   bits 6-10 : BlockType (0-31)  -- 5-bit field (widened from 4 in v7 when Siren became the 17th type)
	// BlockType must stay within this field. Widening past 32 values needs a re-layout AND a
	// CURRENT_VERSION bump (the guard in the static ctor enforces the ceiling).
	private const int BLOCK_TYPE_SHIFT = 6;
	private const int BLOCK_TYPE_MASK = 0x1F; // 5 bits -> up to 32 block types

	static RunData()
	{
		// Ties BlockType's cardinality to the block-tally field width so adding a 9th+ type can't
		// silently wrap (type 8 -> Dragon, etc.) in leaderboard graphics / replay-code round-trips.
		if ( Enum.GetValues<BlockType>().Length > BLOCK_TYPE_MASK + 1 )
			throw new InvalidOperationException(
				$"BlockType has more than {BLOCK_TYPE_MASK + 1} values; widen the block-tally packing " +
				"field in RunData and bump RunData.CURRENT_VERSION." );
	}

	/// <summary>Pack the final block results into the compact <see cref="Blocks"/> form, omitting blocks
	/// authored to start fully completed and ordering the rest by block type so the row graphic always
	/// shows them in the same order (independent of the order the player happened to press them).</summary>
	public static List<int> PackBlocks( IReadOnlyList<BlockResult> blocks )
	{
		if ( blocks is null )
			return null;

		var list = new List<int>( blocks.Count );
		foreach ( var b in blocks.OrderBy( b => (int)b.Type ) )
		{
			if ( b.ScoreStartPhase >= Block.NUM_PHASES - 1 )
				continue;

			int sides = (b.Left ? 8 : 0) | (b.Right ? 4 : 0) | (b.Up ? 2 : 0) | (b.Down ? 1 : 0);
			list.Add( (((int)b.Type & BLOCK_TYPE_MASK) << BLOCK_TYPE_SHIFT) | (b.Phase << 4) | sides );
		}
		return list;
	}

	/// <summary>Decode <see cref="Blocks"/> back into render-ready states, ordered by block type.
	/// Returns an empty list when there's no block data (pre-v3 payloads).</summary>
	public List<BlockTallyState> DecodeBlocks()
	{
		var result = new List<BlockTallyState>();
		if ( Blocks is null )
			return result;

		foreach ( var packed in Blocks )
		{
			var type = (BlockType)((packed >> BLOCK_TYPE_SHIFT) & BLOCK_TYPE_MASK);
			int phase = (packed >> 4) & 0x3;
			result.Add( new BlockTallyState(
				type, phase,
				(packed & 8) != 0, (packed & 4) != 0, (packed & 2) != 0, (packed & 1) != 0 ) );
		}
		return result.OrderBy( b => (int)b.Type ).ToList();
	}

	// Cached result of validating InputDeltas (it never mutates after deserialize/build). Every
	// RunPlayback construction is gated on CanReplay, so this is what keeps a corrupt payload — a
	// mangled board DataUrl response, a hand-edited local-replays file — from throwing out of
	// RunInputCodec.Decode mid-launch instead of just greying the replay button out.
	[JsonIgnore]
	private bool? _inputDecodes;

	/// <summary>Whether this entry carries a replayable run on the current build: the payload is
	/// structurally intact (<see cref="ReplayIntact"/>) AND was recorded on the revision this build
	/// would re-simulate (<see cref="ReplayCurrent"/>).</summary>
	public bool CanReplay => ReplayIntact && ReplayCurrent;

	/// <summary>An intact run that this build can no longer reproduce — recorded on an older sim
	/// version or an edited level. Boards keep such scores; the row shows a greyed-out, unclickable
	/// replay button rather than none so the score isn't mistaken for an unrecorded run.</summary>
	public bool ReplayOutdated => ReplayIntact && !ReplayCurrent;

	/// <summary>Why <see cref="ReplayOutdated"/> holds, for the greyed replay button's tooltip: the sim
	/// version side (older than the oldest replayable, or newer than this build) or the level-revision
	/// side (hash mismatch). Mirrors ReplayCurrent's clauses in order. The editor gets the numbers so a
	/// stale row explains itself; a player build gets a short plain phrase. The generic text is also
	/// the fallback for a row that isn't outdated at all.</summary>
	public string ReplayOutdatedReason
	{
		get
		{
			bool editor = Game.IsEditor;
			if ( SimVersion < Sim.MIN_REPLAY_VERSION )
				return editor ? $"Replay out of date: recorded on sim v{SimVersion}, before v{Sim.MIN_REPLAY_VERSION}" : "Replay out of date";
			if ( SimVersion > Sim.VERSION )
				return editor ? $"Replay out of date: recorded on sim v{SimVersion}, this build is v{Sim.VERSION}" : "Recorded on newer version";
			if ( LevelHash is not null && LevelData is null && Level is { } level && !level.MatchesContentHash( LevelHash ) )
				return editor ? $"Replay out of date: level changed (run {LevelHash}, now {level.ContentHash})" : "Level has changed";
			return "Replay out of date";
		}
	}

	/// <summary>Why <see cref="CanReplay"/> is false, coarsely, for the regression harness's skip
	/// tally. <see cref="ReplayUnavailableReason.None"/> when the run IS replayable.</summary>
	public ReplayUnavailableReason UnavailableReason
	{
		get
		{
			if ( !ReplayIntact ) return ReplayUnavailableReason.Unusable;
			if ( SimVersion < Sim.MIN_REPLAY_VERSION ) return ReplayUnavailableReason.TooOld;
			if ( SimVersion > Sim.VERSION ) return ReplayUnavailableReason.TooNew;
			if ( !ReplayCurrent ) return ReplayUnavailableReason.LevelChanged;
			return ReplayUnavailableReason.None;
		}
	}

	/// <summary>The payload itself is usable: a known level (a run recorded on a since-removed level
	/// has no layout to re-simulate) and an input stream that actually decodes. StepCount is bounded
	/// BEFORE the decode check (same cap the share-code path enforces): it's untrusted, and
	/// Validate/Decode allocate a StepCount-sized buffer — a fabricated payload with a huge value would
	/// otherwise stall the client with a giant synchronous allocation just from rendering a leaderboard
	/// row.</summary>
	private bool ReplayIntact =>
		Version == CURRENT_VERSION
		&& !Truncated
		&& StepCount > 0
		&& StepCount <= RunRecorder.MAX_STEPS
		// Empty is the recorder's valid encoding for an all-idle run; null means no recording.
		&& InputDeltas is not null
		&& Level is not null
		// A run that CARRIES a snapshot must decode it: falling back to the id-resolved level would
		// sim whatever that id names today, not the recorded geometry. (Entries with no snapshot at
		// all — every leaderboard run — resolve by id as normal.)
		&& (_levelSnapshot is null || LevelData is not null)
		&& (_inputDecodes ??= RunInputCodec.Validate( InputDeltas, StepCount ));

	/// <summary>The run was recorded on what this build re-simulates. Only meaningful once
	/// <see cref="ReplayIntact"/> holds (it dereferences Level).</summary>
	private bool ReplayCurrent =>
		// Any version this build's sim still honours: older versions replay through their version
		// gates (the sim runs at the run's recorded version) — see Sim.VERSION.
		SimVersion >= Sim.MIN_REPLAY_VERSION && SimVersion <= Sim.VERSION
		// The run must re-sim the SAME level revision it was recorded on: the level resolved at
		// watch time is content-hashed against the recorded hash, so a changed level greys out
		// instead of desyncing. Runs carrying an embedded snapshot skip the check — the exact
		// recorded level rides with the run, and there's nothing external to verify it against.
		&& (LevelHash is null || LevelData is not null || Level.MatchesContentHash( LevelHash ));
}

/// <summary>Coarse reason a run can't be replayed on this build — see <see cref="RunData.UnavailableReason"/>.</summary>
public enum ReplayUnavailableReason
{
	None,
	/// <summary>Corrupt payload, or its level no longer exists (fails ReplayIntact).</summary>
	Unusable,
	/// <summary>Recorded before <see cref="Sim.MIN_REPLAY_VERSION"/> — no gate can revive it.</summary>
	TooOld,
	/// <summary>Recorded on a newer sim than this build.</summary>
	TooNew,
	/// <summary>Level revision changed since recording (content-hash mismatch).</summary>
	LevelChanged,
}

/// <summary>
/// Text-only replay share/import format. The input stream stays in the existing compact
/// <see cref="RunInputCodec"/> base64 form; the surrounding fields are the deterministic context
/// needed to re-simulate it without any backend lookup.
/// </summary>
public static class ReplayCodeCodec
{
	private const string Prefix = "BP6";
	// The only accepted layout: BP6 prefix + 18 pipe-delimited fields (the current version).
	// Blocks are bounded by the largest level. IsDaily is NOT serialized — it's derived from DailyId,
	// so storing it separately was redundant (and let the flag disagree with the id); dropped in BP2.
	// BP3 added the trailing embedded-level field so transient test-play runs get codes too: empty on
	// every normal run (registry/daily levels resolve by id, keeping those codes short), otherwise
	// base64 of the level's canonical LevelJson wire form — the code decodes to the exact recorded
	// snapshot instead of desyncing against the on-disk layout.
	// BP4 appended a checksum (FNV-1a over everything before it) so a hand-edited code — swapped
	// character/level name, poked seed — reads as invalid instead of playing back as a silently wrong
	// "replay" that is only (maybe) flagged desynced at the very end. Tamper-EVIDENT, not tamper-proof:
	// the hash is recomputable by anyone reading this code, but it turns a casual notepad edit into a
	// deliberate reverse-engineering exercise, which is the right bar for a share code.
	// BP5 added the runner's SteamId so an imported code is attributed to the player who did the run,
	// not whoever pasted it. It sits BEFORE the checksum, so swapping it in to claim someone else's
	// run breaks the code just like any other edit.
	// BP6 added the level-revision hash (RunData.LevelHash), so a code imported on a build that
	// resolves a DIFFERENT revision of the run's level (an edited registry level, a re-rolled daily)
	// reads as invalid instead of silently desyncing.
	private const int FieldCount = 18;
	// Registry max plus headroom for generated DAILY levels, which aren't in the registry and can
	// exceed it (rolled count + template-pinned blocks). Purely an anti-garbage upper bound on
	// pasted codes, so being generous is harmless.
	private static int MaxBlocks => Levels.MaxBlockCount + 8;

	/// <summary>Encode a run as a share code. <paramref name="steamId"/> is the player who DID the
	/// run (the watched replay's submitter, not necessarily the local player); 0 means the local
	/// player. Importers attribute the run to this id, and the trailing checksum covers it.</summary>
	public static string Encode( RunData data, long steamId )
	{
		if ( data is null )
			return "";

		if ( steamId == 0 )
			steamId = (long)Game.SteamId;

		string blocks = data.Blocks is null ? "" : string.Join( ",", data.Blocks );
		// A transient run (an editor test-play) rides its exact recorded level snapshot along in the
		// code — its LevelId can't be trusted to resolve the same layout later (unsaved / since-edited).
		// WORKSHOP runs ride a snapshot for the same reason from the other side: their id only resolves
		// for players who installed the item, and the author can republish new content under it — the
		// snapshot makes the code decodable anywhere and replay the exact recorded revision. Safe to
		// resolve here: codes are only encoded mid-watch (CurrentReplayCode), and a watchable workshop
		// run's installed level already passed CanReplay's content-hash gate, so the resolved def IS the
		// recorded revision. Serialized via the canonical LevelJson wire form (the same round-trip the
		// disk files and the regression hash use) because a raw LevelDef doesn't JSON-round-trip its
		// RectF fields; base64 keeps the JSON safe inside the pipe-delimited layout. Empty on every
		// normal (shipped-level) run.
		var snapshot = data.LevelData;
		if ( snapshot is null && WorkshopLevels.IsWorkshopId( data.LevelId ) )
			snapshot = data.Level;
		string levelData = snapshot is null
			? ""
			: Convert.ToBase64String( System.Text.Encoding.UTF8.GetBytes(
				EditorLevel.FromLevelDef( snapshot ).ToJsonString() ) );
		string payload = string.Join( "|",
			Prefix,
			data.Version,
			data.SimVersion,
			data.Seed,
			data.StepCount,
			BitConverter.SingleToInt32Bits( data.TimeSeconds ),
			data.Victory ? 1 : 0,
			data.Truncated ? 1 : 0,
			data.FinalScore,
			data.InputDeltas ?? "",
			blocks,
			data.DailyId ?? "",
			data.LevelId ?? "",
			data.CharacterId ?? "",
			levelData,
			data.LevelHash ?? "",
			steamId );
		return payload + "|" + Checksum( payload ).ToString( "x8", System.Globalization.CultureInfo.InvariantCulture );
	}

	/// <summary>FNV-1a 32 over the code's payload (both bytes of every char, so nothing rides above
	/// the hash). See the BP4 note on <see cref="FieldCount"/> for what this does and doesn't defend.</summary>
	private static uint Checksum( string payload )
	{
		uint h = 2166136261u;
		foreach ( char c in payload )
		{
			h = (h ^ (byte)c) * 16777619u;
			h = (h ^ (byte)(c >> 8)) * 16777619u;
		}
		return h;
	}

	/// <summary><paramref name="steamId"/> is the player who did the run, for attribution on import
	/// (see <see cref="Encode"/>).</summary>
	public static bool TryDecode( string code, out RunData data, out long steamId )
	{
		data = null;
		steamId = 0;
		if ( string.IsNullOrWhiteSpace( code ) )
			return false;

		var parts = code.Trim().Split( '|' );
		if ( parts[0] != Prefix || parts.Length != FieldCount )
			return false;

		// parts[16] = checksum over every field before it. Verified up front so ANY hand-edit —
		// including to fields that would otherwise decode fine, like the character id — reads as an
		// invalid code rather than a subtly wrong replay.
		if ( !uint.TryParse( parts[FieldCount - 1], System.Globalization.NumberStyles.HexNumber,
				System.Globalization.CultureInfo.InvariantCulture, out uint sum )
			|| sum != Checksum( string.Join( "|", parts[..(FieldCount - 1)] ) ) )
			return false;

		if ( !TryInt( parts[1], out int version )
			|| !TryInt( parts[2], out int simVersion )
			|| !TryInt( parts[3], out int seed )
			|| !TryInt( parts[4], out int stepCount )
			|| !TryInt( parts[5], out int timeBits )
			|| !TryBoolInt( parts[6], out bool victory )
			|| !TryBoolInt( parts[7], out bool truncated )
			|| !TryInt( parts[8], out int finalScore ) )
			return false;

		if ( version != RunData.CURRENT_VERSION || stepCount <= 0 || stepCount > RunRecorder.MAX_STEPS )
			return false;

		string inputDeltas = parts[9];

		// parts[14] = embedded level snapshot, present on transient test-play runs AND workshop runs
		// (see Encode). Decoded before the block tally so the tally bound can come from the snapshot
		// itself — neither kind of level is covered by the registry-derived MaxBlocks (test-play levels
		// aren't registered; workshop levels are excluded from the bound and needn't be installed here).
		LevelDef levelData = null;
		if ( !string.IsNullOrWhiteSpace( parts[14] ) )
		{
			try
			{
				string json = System.Text.Encoding.UTF8.GetString( Convert.FromBase64String( parts[14] ) );
				levelData = EditorLevel.FromJsonString( json, out _ )?.ToLevelDef();
			}
			catch
			{
				return false;
			}
			if ( levelData is null )
				return false;
		}

		int maxBlocks = levelData is not null ? levelData.Count + 8 : MaxBlocks;
		List<int> blocks = null;
		if ( !string.IsNullOrWhiteSpace( parts[10] ) )
		{
			blocks = new List<int>();
			foreach ( var raw in parts[10].Split( ',', StringSplitOptions.RemoveEmptyEntries ) )
			{
				if ( !TryInt( raw, out int packed ) )
					return false;
				if ( blocks.Count >= maxBlocks )
					return false;
				blocks.Add( packed );
			}
		}

		// parts[11] = daily id. Validated whenever present (IsDaily is derived from it, so a non-empty
		// value MUST be a well-formed id); empty means a non-daily run. A daily run regenerates its
		// level from the id, so a code claiming both daily AND an embedded snapshot is garbage.
		string dailyId = string.IsNullOrWhiteSpace( parts[11] ) ? null : parts[11];
		if ( dailyId is not null && (levelData is not null || !DailyChallenge.TryParseId( dailyId, out _ )) )
			return false;

		// parts[12] = level id. Unknown ids are rejected here rather than left to CanReplay, so a
		// bad code reads as invalid instead of silently unreplayable. A daily run's level isn't in
		// the registry — its id must match the (validated) daily id it regenerates from. A transient
		// run's level isn't either (unsaved / since-edited) — its id must match the embedded snapshot,
		// which the replay registers under that id (see Levels.RegisterTransient).
		string levelId = string.IsNullOrWhiteSpace( parts[12] ) ? null : parts[12];
		if ( dailyId is not null )
		{
			if ( levelId != DailyLevels.LevelIdFor( dailyId ) )
				return false;
		}
		else if ( levelData is not null )
		{
			if ( !string.Equals( levelData.Id, levelId, StringComparison.Ordinal ) )
				return false;
		}
		else if ( Levels.Get( levelId ) is null )
			return false;

		// parts[13] = character id. Any value is accepted (unknown → Original), so a character
		// being removed later never makes an otherwise-valid run undecodable.
		string characterId = string.IsNullOrWhiteSpace( parts[13] ) ? null : parts[13];

		// parts[15] = level-revision hash the run was recorded on. Empty only on a payload predating
		// the stamp; CanReplay (below) compares it against the level the code resolves here, so a
		// stale code reads as invalid rather than desyncing.
		string levelHash = string.IsNullOrWhiteSpace( parts[15] ) ? null : parts[15];

		// parts[16] = SteamId of the player who did the run. Checksum-covered like everything else,
		// so it can't be swapped to pass the run off as someone else's. 0 (a code encoded with no
		// Steam session) attributes to whoever imports it, matching LocalReplays.Add's convention.
		if ( !long.TryParse( parts[16], System.Globalization.NumberStyles.Integer,
				System.Globalization.CultureInfo.InvariantCulture, out steamId )
			|| steamId < 0 )
			return false;

		// Validate the base64/varint payload before accepting the code. The dense buffer is discarded;
		// actual playback decodes it again inside RunPlayback.
		if ( !RunInputCodec.Validate( inputDeltas, stepCount ) )
			return false;

		data = new RunData
		{
			Version = version,
			SimVersion = simVersion,
			Seed = seed,
			StepCount = stepCount,
			TimeSeconds = BitConverter.Int32BitsToSingle( timeBits ),
			Victory = victory,
			Truncated = truncated,
			FinalScore = finalScore,
			InputDeltas = inputDeltas,
			Blocks = blocks,
			DailyId = dailyId,
			LevelId = levelId,
			LevelHash = levelHash,
			CharacterId = characterId,
			LevelData = levelData
		};

		return data.CanReplay;
	}

	public static string Describe( RunData data )
	{
		if ( data is null )
			return "no run data";

		var daily = data.IsDaily ? $", daily {data.DailyId}" : "";
		var level = string.IsNullOrEmpty( data.LevelId ) ? "" : $", level {data.LevelId}";
		var embedded = data.LevelData is not null ? " (embedded level)" : "";
		return $"score {data.FinalScore}, steps {data.StepCount}, seed {data.Seed}, sim v{data.SimVersion}{daily}{level}{embedded}";
	}

	private static bool TryInt( string value, out int result )
	{
		return int.TryParse( value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out result );
	}

	private static bool TryBoolInt( string value, out bool result )
	{
		result = false;
		if ( !TryInt( value, out int raw ) || (raw != 0 && raw != 1) )
			return false;
		result = raw == 1;
		return true;
	}
}

/// <summary>
/// Lazy, cached fetcher for run payloads. Leaderboard entries expose a <c>DataUrl</c> (the JSON file
/// the payload was submitted with); this fetches/caches it per URL so the score list only loads each
/// row once and a repaint is cheap. Failures cache as null so we don't spam retries.
/// (Mirrors SS2's <c>RunResultStore</c>.)
/// </summary>
public static class RunDataStore
{
	private static readonly Dictionary<string, RunData> _cache = new();
	private static readonly HashSet<string> _inFlight = new();
	// Every consumer waiting on an in-flight url (a row component + the host's ms-dedup can both want
	// the same payload). All are notified when it lands — a single-callback store would leave the
	// later callers (e.g. the child row component) un-repainted.
	private static readonly Dictionary<string, List<Action>> _pending = new();

	/// <summary>
	/// Returns the cached payload for this DataUrl, or null while it loads (or if it has no data /
	/// failed to load). Kicks off a one-time fetch on the first miss; <paramref name="onLoaded"/>
	/// fires when it lands (pass a repaint callback so the row updates once the time arrives). Multiple
	/// distinct callbacks for the same url are all invoked; the same callback isn't queued twice.
	/// </summary>
	public static RunData Get( string dataUrl, Action onLoaded = null )
	{
		if ( string.IsNullOrEmpty( dataUrl ) )
			return null;

		if ( _cache.TryGetValue( dataUrl, out var cached ) )
			return cached;

		if ( onLoaded is not null )
		{
			if ( !_pending.TryGetValue( dataUrl, out var list ) )
			{
				list = new List<Action>();
				_pending[dataUrl] = list;
			}
			if ( !list.Contains( onLoaded ) )
				list.Add( onLoaded );
		}

		if ( !_inFlight.Contains( dataUrl ) )
		{
			_inFlight.Add( dataUrl );
			_ = Fetch( dataUrl );
		}

		return null;
	}

	/// <summary>
	/// Cache-only lookup: the payload if it has already landed, otherwise null — unlike
	/// <see cref="Get"/> this never starts a fetch. For callers that will happily use a payload that
	/// happens to be cached but must not pull one in themselves (the level-select footer's ms-dedup
	/// over its off-screen rows, which would otherwise fetch a whole board per highlighted level).
	/// </summary>
	public static RunData Peek( string dataUrl ) =>
		!string.IsNullOrEmpty( dataUrl ) && _cache.TryGetValue( dataUrl, out var cached ) ? cached : null;

	/// <summary>
	/// Seed the store with a payload we already hold in memory, keyed by a synthetic url that a row
	/// then carries as its DataUrl. Used for a locally-spliced row — a run we submitted that the
	/// board hasn't served back to us yet (see <see cref="LeaderboardRows.SpliceLocalBest"/>) — so it
	/// resolves its time / character / block tally and offers replay exactly like a backend row, with
	/// no fetch. Overwrites, so a later better run under the same key replaces the earlier one.
	/// </summary>
	public static void Prime( string dataUrl, RunData data )
	{
		if ( string.IsNullOrEmpty( dataUrl ) || data is null )
			return;

		_cache[dataUrl] = data;

		// Belt-and-braces: if anything already asked for this key before we primed it, retire the
		// fetch and repaint those waiters rather than leaving them hanging on a url that can't load.
		_inFlight.Remove( dataUrl );
		if ( _pending.Remove( dataUrl, out var waiting ) )
			foreach ( var cb in waiting )
				cb?.Invoke();
	}

	private static async Task Fetch( string dataUrl )
	{
		try
		{
			_cache[dataUrl] = await Http.RequestJsonAsync<RunData>( dataUrl );
		}
		catch ( Exception e )
		{
			Log.Warning( $"BlockParty: failed to fetch run data from {dataUrl}: {e.Message}" );
			_cache[dataUrl] = null; // cache the failure so we don't retry it every frame
		}
		finally
		{
			_inFlight.Remove( dataUrl );
			if ( _pending.Remove( dataUrl, out var list ) )
				foreach ( var cb in list )
					cb?.Invoke();
		}
	}
}