UI/LevelSelectScreen.razor
@using Sandbox
@using Sandbox.UI
@using System
@using System.Linq
@using System.Collections.Generic
@using System.Diagnostics
@using System.Threading
@using System.Threading.Tasks
@inherits PanelComponent
@namespace BlockParty
@attribute [StyleSheet( "LevelSelectScreen.razor.scss" )]

<root style="opacity: @(Stage?.FadeAlpha ?? 0f)">
	@{ if ( Stage is null ) return; }
	<PixelTooltip @ref="Tooltip"></PixelTooltip>

	@* The map lives inside the fixed 1080x1080 square (matches the game's play square); anything
	   scrolled past its edges is clipped. *@
	<div class="square">
		@* Map content (lines / nodes / selector) is clipped to the VISIBLE play square: the .clip wrapper
		   is inset by the border ring (the visible teal square sits ~9px inside the 1080 layout square,
		   same inset the header/footer use), and .map-content is shifted back so node coordinates stay in
		   full 1080-square space. Without this the map leaked into the dark border ring. *@
		<div class="clip"><div class="map-content">
		@* Connecting lines first, so the opaque node squares draw on top of them. *@
		@foreach ( var conn in Stage.Connections )
		{
			<div class="line @(Stage.IsConnectionTraversable( conn ) ? "on" : "") @(Stage.IsNodeUnlocking( conn.To ) ? "unlocking" : "")" style="@LineStyle( conn )"></div>
		}

		@foreach ( var node in Stage.Nodes )
		{
			var n = node;
			var nodeClass = NodeClass( n );
			@* onrightclick is a DEBUG toggle of the node's beaten flag (see LevelSelectStage). *@
			<div class="node-anchor @nodeClass" style="@NodeStyle( n )">
			<div class="node @nodeClass"
				 onclick=@(() => Stage.ClickNode( n ))
				 onrightclick=@(() => Stage.DebugToggleBeaten( n ))>
				@* The node's face is a miniature of the level's arena (colours + obstacle layout +
				   spikes) so each node reads as the level it launches. *@
				@if ( ShouldRenderPreview( n ) )
				{
					<LevelNodePreview [email protected]></LevelNodePreview>
				}
				@* Open-but-unbeaten previews get a light veil so beaten levels are the only
				   full-brightness thumbnails — cleared vs to-do reads from fill brightness at a glance,
				   not just the thin frame colour. *@
				@if ( !n.Beaten && Stage.IsNodeSelectable( n ) )
				{
					<div class="dim-veil"></div>
				}
				@* Locked nodes are dimmed and marked directly, so their state reads without header text.
				   A locked FORCED-CHARACTER level shows that character's silhouette instead of the padlock
				   (same black semi-transparent treatment), so it advertises who it unlocks. *@
				@if ( !Stage.IsNodeSelectable( n ) )
				{
					<div class="lock-veil"></div>
					@if ( Characters.TryGet( n.Level?.ForcedCharacterId, out var lockedCharacter ) )
					{
						<div class="lock-char" style="@LockedCharacterStyle( lockedCharacter, 0 )"></div>
						@if ( lockedCharacter.Partner is not null )
						{
							<div class="lock-char" style="@LockedCharacterStyle( lockedCharacter, 1 )"></div>
						}
					}
					else
					{
						<div class="lock-icon"></div>
					}
				}
			</div>
			</div>
		}

		@* Soft glow behind the character marker (same baked oval as the picker / leaderboard rows).
		   It does NOT ride the moving selector: each glow is anchored to a NODE, fading in on the node
		   the highlight arrives at and out on the one it left (see TickNodeGlows). Shown for every
		   selected node, forced character or not. *@
		@foreach ( var glow in _nodeGlows )
		{
			<div class="node-glow" style="@NodeGlowStyle( glow.Key, glow.Value )"></div>
		}

		@* Forced-character markers sit centred on their node — the selector's settled position. The
		   moving selector is drawn afterward, so it perfectly covers the marker when it arrives at that
		   node. Locked nodes show their silhouette inside the node markup above instead. *@
		@foreach ( var node in Stage.Nodes )
		{
			if ( Stage.IsNodeSelectable( node ) && Characters.TryGet( node.Level?.ForcedCharacterId, out var forcedCharacter ) )
			{
				<div class="forced-character" style="@ForcedCharacterStyle( node, forcedCharacter, 0 )"></div>
				@if ( forcedCharacter.Partner is not null )
				{
					<div class="forced-character" style="@ForcedCharacterStyle( node, forcedCharacter, 1 )"></div>
				}
			}
		}

		@* Highlighted-node marker: a sprite of the currently-selected character instead of the old cube.
		   It sits centred on the selected node, shrinking/growing (lerped) as the highlight moves
		   between major and side nodes. *@
		@if ( Stage.Selected is not null )
		{
			<div class="selector" style="@SelectorStyle( 0 )"></div>
			@if ( SelectedCharacter.Partner is not null )
			{
				<div class="selector" style="@SelectorStyle( 1 )"></div>
			}
		}
		</div></div>

		@* Header bar: the highlighted level's tag + name. *@
		<div class="header">
			<div class="current">
				@if ( !string.IsNullOrEmpty( Stage.Selected?.Tag ) )
				{
					<label class="tag">@Stage.Selected.Tag</label>
				}
				<label class="name">@(Stage.Selected?.Name ?? "")</label>
			</div>
		</div>
	</div>

	<div class="footer">
		<div class="foot-controls">
			<div class="foot-control">
				<PixelDropdown Options=@_scopeOptions Selected=@((int)Settings.Current.LeaderboardScope) OnSelected=@OnScopeSelected></PixelDropdown>
			</div>
			@* Trophy shortcut to the full High Scores screen, opened on this level's board; BACK there
			   returns to the map. Tooltip rides the screen's shared PixelTooltip layer (see Tip). *@
			<button class="square-btn i-trophy board-button" onclick=@(() => Stage.ClickLeaderboards())
				onmouseover=@Tip( "View full leaderboard" )>
				<div class="img"></div>
			</button>
		</div>

		<div class="foot-list">
			@* Slice bounds declared at markup top-level so they're in scope inside the else body
			   (a @{ } block inside an @if/else body would trip the razor parser). *@
			@{
				int footFirst = FootFirstVisible;
				int footLast = FootLastVisible;
				// Adjacent rows sharing an mm:ss reveal their milliseconds, so a tie can be told apart (same
				// helper the High Scores page uses). Off-screen rows only count if their payload is
				// already cached — see ResolveFootTime.
				var footMsRows = LeaderboardRows.ComputeMsRows( _scoreRows, ResolveFootTime );
			}
			@if ( _boardLoading )
			{
				<label class="foot-info">LOADING...</label>
			}
			else if ( _scoreRows.Count == 0 )
			{
				<label class="foot-info">NO ENTRIES YET</label>
			}
			else
			{
				@for ( int i = footFirst; i < footLast; i++ )
				{
					var r = _scoreRows[i];
					bool showMs = footMsRows.Contains( i );
					<div class="foot-row @(i % 2 == 1 ? "even" : "")" style="top:@Px( FootRowTop( i ) )">
						<LeaderboardRow Rank=@(i + 1) ShowRankMedal=@(Settings.Current.LeaderboardScope == LeaderboardScope.Global) [email protected] [email protected] [email protected]
							[email protected] [email protected] [email protected]
							[email protected] ShowMs=@showMs DimNonVictoryScore=@true ReturnToLevelSelect=@(true)
							AwardTip=@StaleTipFactory WatchTip=@Tip( "Watch replay" )
							StaleTip=@StaleTipFactory
							ProfileTip=@Tip( "Steam profile" )></LeaderboardRow>
					</div>
				}

				@if ( FootShowScrollbar )
				{
					<div class="foot-scrollbar" style="top:@Px( FootThumbTop );height:@Px( FootThumbHeight )"></div>
				}
			}
		</div>
	</div>

	@* Editor-only shortcut in the bottom-left letterbox, matching the title screen. *@
	@if ( Game.IsEditor )
	{
		<button class="square-btn i-edit editor-button" onclick=@(() => Stage.ClickEditor())>
			<div class="img"></div>
		</button>
	}

	@* Home button in the bottom-right letterbox (outside the square) — returns to the title screen. *@
	<button class="square-btn i-home home-button" onclick=@(() => Stage.ClickHome())>
		<div class="img"></div>
	</button>

	@* Character picker inside the play square's upper-right corner. *@
	<div class="character-slot">
		<CharacterPicker CenterOverlayOnScreen=@true></CharacterPicker>
	</div>
	<MedalStandingsPanel @ref="Medals"></MedalStandingsPanel>

</root>

@code
{
	public LevelSelectStage Stage { get; set; }
	private MedalStandingsPanel Medals { get; set; }
	public bool MedalStandingsOpen => Medals?.Expanded == true;
	public void CloseMedalStandings() => Medals?.Close();

	// Node/line sizing in 1080-ref px. Sizes live here (applied inline) so the stage's camera padding
	// and these visuals stay in one place; the SCSS only styles colour/appearance. Node positions (and
	// thus spacing) are fixed by the map layout, so enlarging these just shortens the visible gap
	// between nodes (the connecting lines run centre-to-centre, unchanged). Side nodes stay smaller
	// than major ones.
	const float MAJOR_SIZE = 160f;
	const float SIDE_SIZE = 120f;
	const float LINE_THICKNESS = 10f;
	// Character marker sizing (1080-ref px): the sprite box, per node tier — side nodes are smaller,
	// so their icons shrink to stay inside the node face.
	const float CHAR_SIZE = 86f;
	const float CHAR_SIZE_SIDE = 72f;
	const float LOCKED_NODE_SHAKE_DISTANCE = 10f;
	const float BLOCKED_PLAYER_SHAKE_DISTANCE = 18f;
	// The square is 1080x1080; its centre is the camera focus point.
	const float SQUARE_HALF = 540f;

	// Selector frame (0..6) -> scale, preserving the old cube's squash(1-3)/stretch(4-6) bounce so the
	// marker pops when the highlight moves and settles back to 1 at rest (frame 0).
	static readonly float[] FRAME_SCALE = { 1f, 0.92f, 0.84f, 0.8f, 1.08f, 1.16f, 1.2f };

	// The selector marker's live size (base, before PreviewScale), in 1080-ref px. Its TARGET is
	// discrete (CHAR_SIZE on a major node, CHAR_SIZE_SIDE on a side node), but we LERP the live value
	// toward it (in OnUpdate) so the marker shrinks/grows smoothly when the highlight crosses between
	// tiers rather than snapping. Seeded to the target on the first frame so the screen opens sized.
	private float _selectorSize;
	private bool _selectorSizeInit;
	// How fast the marker size chases its target (per second, exp-style lerp factor).
	const float MARKER_LERP = 14f;

	// Per-node glow fade factors (0..1). The selected node's entry chases 1, every other entry chases
	// 0 and is dropped once invisible — so a selection change CROSSFADES the glow from the old node to
	// the new one rather than dragging it along under the gliding selector. Seeded to full on the
	// first frame so the screen opens already lit.
	private readonly Dictionary<MapNode, float> _nodeGlows = new();
	private bool _nodeGlowsInit;
	// How fast a glow fades (per second, exp-style lerp factors). Arrival eases in noticeably slower
	// than the departed node's glow snuffs out, so the light settles onto the new node rather than
	// popping there.
	const float GLOW_LERP_IN = 5f;
	const float GLOW_LERP_OUT = 10f;
	// Resting brightness — matches .icon-glow in the character picker / leaderboard rows.
	const float GLOW_OPACITY = 0.5f;
	// Glow box proportions relative to the character box
	const float GLOW_SCALE_X = 2.52f;
	const float GLOW_SCALE_Y = 2.51f;
	private int _lockedNodeShakeStep = -1;
	private Vector2 _lockedNodeShake;

	// Map space (+Y up) -> square-local px (top-left origin, +Y down), offset by the followed camera.
	float LocalX( float mapX ) => SQUARE_HALF + ( mapX - Stage.CamX );
	float LocalY( float mapY ) => SQUARE_HALF - ( mapY - Stage.CamY );

	// Previews contain many geometry panels and animated background blocks. Only create them near
	// the camera; clipping alone still builds and ticks every preview on each map visit. Keep the
	// cheap node anchors intact so navigation, click targets and unlock animations stay unchanged.
	// A full major-row buffer prepares previews before they enter view, including during reveals.
	bool ShouldRenderPreview( MapNode node )
	{
		float halfSize = ( node.IsMajor ? MAJOR_SIZE : SIDE_SIZE ) / 2f;
		float reach = SQUARE_HALF + halfSize + LevelMap.MAJOR_SPACING_Y;
		return MathF.Abs( node.Pos.x - Stage.CamX ) <= reach
			&& MathF.Abs( node.Pos.y - Stage.CamY ) <= reach;
	}

	string NodeClass( MapNode n )
	{
		string size = n.IsMajor ? "major" : "side";
		string state = n.Beaten ? "beaten" : ( Stage.IsNodeSelectable( n ) ? "open" : "locked" );
		string unlocking = Stage.IsNodeUnlocking( n ) ? " unlocking" : "";
		// The highlighted node gets a distinct bright frame so it's obvious which one is selected
		// (independent of the beaten/open/locked tier and of hover).
		string selected = n == Stage.Selected ? " selected" : "";
		return $"{size} {state}{selected}{unlocking}";
	}

	string NodeStyle( MapNode n )
	{
		float size = n.IsMajor ? MAJOR_SIZE : SIDE_SIZE;
		var shake = LockedNodeShakeOffset( n );
		float left = LocalX( n.Pos.x ) - size / 2f + shake.x;
		float top = LocalY( n.Pos.y ) - size / 2f + shake.y;
		return $"left:{Px( left )};top:{Px( top )};width:{Px( size )};height:{Px( size )};";
	}

	Vector2 BlockedDirection()
	{
		if ( !Stage.BlockedFeedbackActive )
			return Vector2.Zero;

		var direction = Stage.BlockedFeedbackDirection;
		return new Vector2( direction.x, -direction.y );
	}

	Vector2 LockedNodeShakeOffset( MapNode n )
	{
		if ( n != Stage.BlockedFeedbackNode )
			return Vector2.Zero;

		float progress = Stage.BlockedFeedbackProgress;
		int step = (int)(progress * 12f);
		if ( step != _lockedNodeShakeStep )
		{
			_lockedNodeShakeStep = step;
			_lockedNodeShake = new Vector2(
				Rng.CosmeticFloat( -1f, 1f ),
				Rng.CosmeticFloat( -1f, 1f ) );
		}

		return _lockedNodeShake * (LOCKED_NODE_SHAKE_DISTANCE * (1f - progress));
	}

	Vector2 BlockedPlayerShakeOffset()
	{
		if ( !Stage.BlockedFeedbackActive )
			return Vector2.Zero;

		float progress = Stage.BlockedFeedbackProgress;
		float pulse = MathF.Abs( MathF.Sin( progress * MathF.PI * 4f ) ) * (1f - progress);
		return BlockedDirection() * (BLOCKED_PLAYER_SHAKE_DISTANCE * pulse);
	}

	// A forced level switches the live marker to its character as soon as it is selected. Otherwise the
	// marker follows the player's live picker choice (folded into BuildHash below).
	CharacterDef SelectedCharacter
		=> Characters.TryGet( Stage.Selected?.Level?.ForcedCharacterId, out var forced )
			? forced
			: Characters.Get( Settings.Current.SelectedCharacterId );

	// The character-marker base size for a node: side nodes are smaller than majors, so their icons
	// shrink with them. Null (no selection yet) falls back to the major size.
	float CharSize( MapNode n ) => n is null || n.IsMajor ? CHAR_SIZE : CHAR_SIZE_SIDE;

	// One body of a character marker: a single character is one box centred on (cx, cy); a paired
	// character (the twins) shrinks both bodies and sets them SIDE BY SIDE — not the picker's overlap,
	// because the forced marker is semi-transparent and overlapped sprites would show through each
	// other. The boxes overlap a little (centres PAIR_SPACING apart, in body sizes); the art's own
	// margins keep the sprites clear of each other. Slot 0 is the character itself (left), slot 1 its
	// partner (right). Units are whatever cx/cy/baseSize are in (px or %), so the locked silhouette
	// shares it.
	const float PAIR_SCALE = 0.78f;
	const float PAIR_SPACING = 0.72f;
	(float left, float top, float size, string image) MarkerLayer( float cx, float cy, float baseSize, CharacterDef character, int slot )
	{
		var def = slot == 0 ? character : character.Partner;
		float size = baseSize * def.PreviewScale;
		if ( character.Partner is not null )
		{
			size *= PAIR_SCALE;
			cx += ( slot == 0 ? -0.5f : 0.5f ) * PAIR_SPACING * size;
		}
		return ( cx - size / 2f, cy - size / 2f, size, def.PreviewImage );
	}

	string ForcedCharacterStyle( MapNode n, CharacterDef character, int slot )
	{
		var (left, top, size, image) = MarkerLayer( LocalX( n.Pos.x ), LocalY( n.Pos.y ), CharSize( n ), character, slot );
		return $"left:{Px( left )};top:{Px( top )};width:{Px( size )};height:{Px( size )};"
			+ $"background-image:url( {image} );";
	}

	// A node's glow: the oval centred on the node, sized off that node's character box (so side-node
	// glows shrink with their icons) by the GLOW_SCALE proportions, dimmed by its live fade factor.
	string NodeGlowStyle( MapNode n, float fade )
	{
		float charSize = CharSize( n );
		float w = charSize * GLOW_SCALE_X;
		float h = charSize * GLOW_SCALE_Y;
		float left = LocalX( n.Pos.x ) - w / 2f;
		float top = LocalY( n.Pos.y ) - h / 2f;
		return $"left:{Px( left )};top:{Px( top )};width:{Px( w )};height:{Px( h )};"
			+ $"opacity:{Num( GLOW_OPACITY * fade )};";
	}

	// The locked-node silhouette occupies the same centred region the padlock does (percent-based, so
	// it scales with the node's major/side size), adjusted by the character's preview proportion.
	// Paired characters split into two side-by-side silhouettes via the shared MarkerLayer.
	string LockedCharacterStyle( CharacterDef character, int slot )
	{
		var (left, top, size, image) = MarkerLayer( 50f, 50f, 60f, character, slot );
		return $"left:{Num( left )}%;top:{Num( top )}%;width:{Num( size )}%;height:{Num( size )}%;"
			+ $"background-image:url( {image} );";
	}

	string SelectorStyle( int slot )
	{
		// The node centre is followed via the lerped SelectorX/Y; the size is lerped separately
		// (OnUpdate) so a major<->side move both glides across AND resizes smoothly. A paired character
		// renders one selector div per body (same MarkerLayer geometry as the forced marker, so the
		// selector still settles exactly onto it); the bounce scale is per-div, each body squashing
		// around its own centre.
		float cx = LocalX( Stage.SelectorX );
		float cy = LocalY( Stage.SelectorY );
		var shake = BlockedPlayerShakeOffset();
		cx += shake.x;
		cy += shake.y;
		float baseSize = _selectorSizeInit ? _selectorSize : CharSize( Stage.Selected );
		var (left, top, size, image) = MarkerLayer( cx, cy, baseSize, SelectedCharacter, slot );

		float scale = !Stage.BlockedFeedbackActive
			? FRAME_SCALE[Math.Clamp( Stage.SelectorFrame, 0, FRAME_SCALE.Length - 1 )]
			: 1f;
		return $"left:{Px( left )};top:{Px( top )};width:{Px( size )};height:{Px( size )};"
			+ $"background-image:url( {image} );"
			+ $"transform:scale({Num( scale )}, {Num( scale )});";
	}

	// A predecessor connection is always axis-aligned (side chains are horizontal, the spine vertical),
	// so a line is a thin rect spanning centre-to-centre; nodes drawn on top hide the overlap.
	string LineStyle( MapConnection conn )
	{
		float ax = LocalX( conn.From.Pos.x ), ay = LocalY( conn.From.Pos.y );
		float bx = LocalX( conn.To.Pos.x ), by = LocalY( conn.To.Pos.y );

		if ( conn.From.Pos.x == conn.To.Pos.x )
		{
			// Vertical (spine).
			float top = MathF.Min( ay, by ), height = MathF.Abs( ay - by );
			return $"left:{Px( ax - LINE_THICKNESS / 2f )};top:{Px( top )};width:{Px( LINE_THICKNESS )};height:{Px( height )};";
		}

		// Horizontal (side chain).
		float leftH = MathF.Min( ax, bx ), width = MathF.Abs( ax - bx );
		return $"left:{Px( leftH )};top:{Px( ay - LINE_THICKNESS / 2f )};width:{Px( width )};height:{Px( LINE_THICKNESS )};";
	}

	static string Px( float value ) => value.ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture ) + "px";

	// Plain (unit-less) invariant number for transform values (scale takes bare numbers, not px).
	static string Num( float value ) => value.ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture );

	// ---- Footer leaderboard (top scores for the highlighted level) --------------------------------
	// Max rows fetched for a level's board (the list virtual-scrolls, so this can be large).
	const int FOOTER_MAX_ROWS = 100;

	// DEBUG toggle: when true the footer shows synthetic rows (below) instead of querying the live
	// board, so the leaderboard layout can be exercised without a published backend board. Mirrors
	// HighscoreScreen.DEBUG_ROWS. MUST be static readonly (not const) so the guarded branch stays
	// reachable and doesn't trip CS0162.
	private static readonly bool DEBUG_ROWS = false;
	// Number of synthetic rows built when DEBUG_ROWS is on. Set high (e.g. 150) to see how the small
	// footer copes with a long, overflowing list.
	private const int DEBUG_ROW_COUNT = 150;
	private List<LeaderboardRowData> _scoreRows = new();
	private bool _boardLoading;
	// The level id the current rows are for.
	private string _boardLevelId;
	// Bumped on every footer state change so BuildHash repaints when the board loads.
	private int _boardVersion;
	// Cancel superseded requests waiting in the engine's shared leaderboard queue. Keep the
	// generation guard too: Refresh cannot cancel an HTTP query that has already started.
	private int _boardGeneration;
	private CancellationTokenSource _boardCancellation;
	private Stopwatch _boardLoadTimer;
	private string _boardRequestContext;
	private bool _boardSlowLogged;
	private const int BOARD_SLOW_SECONDS = 5;

	private void LogSlowBoardRequest()
	{
		if ( !Game.IsEditor || !_boardLoading || _boardLoadTimer is null || _boardSlowLogged )
			return;

		int seconds = (int)_boardLoadTimer.Elapsed.TotalSeconds;
		if ( seconds >= BOARD_SLOW_SECONDS )
		{
			_boardSlowLogged = true;
			Log.Warning( $"BlockParty: level-select board still pending after {seconds}s (includes queue wait; transport progress is unavailable). {_boardRequestContext}" );
		}
	}

	// HttpClient timeouts usually arrive wrapped in a cancellation exception. A cancellation
	// without a TimeoutException in its chain is not enough evidence to call it a timeout.
	private static bool IsBoardTimeout( Exception exception )
	{
		for ( var cause = exception; cause is not null; cause = cause.InnerException )
			if ( cause is TimeoutException )
				return true;

		return false;
	}

	protected override void OnDestroy()
	{
		++_boardGeneration;
		CancelBoardRequest();
		base.OnDestroy();
	}

	private void CancelBoardRequest()
	{
		_boardCancellation?.Cancel();
		_boardCancellation?.Dispose();
		_boardCancellation = null;
	}

	// Scope dropdown labels follow LeaderboardScope enum order and share the High Scores setting.
	private readonly List<string> _scopeOptions = new() { "GLOBAL", "FRIENDS" };

	/// <summary>Called by the stage on enter and whenever the highlight moves: load that level's top
	/// scores into the footer. A no-op if we're already showing/loading that level.</summary>
	public void ShowBoardFor( string levelId )
	{
		if ( _boardLevelId == levelId )
			return;

		_boardLevelId = levelId;
		ReloadBoard();
	}

	// (Re)query the current level's board — also after a filter change (which keeps the same level, so
	// it bypasses ShowBoardFor's same-level guard).
	private void ReloadBoard()
	{
		_boardVersion++;
		int gen = ++_boardGeneration;
		CancelBoardRequest();
		ResetFootScroll();
		_boardLoadTimer = null;
		_boardSlowLogged = false;

		// DEBUG: skip the live board and show synthetic rows immediately.
		if ( DEBUG_ROWS )
		{
			_boardLoading = false;
			_scoreRows = BuildDebugRows( _boardLevelId );
			StateHasChanged();
			return;
		}

		_boardLoading = true;
		_scoreRows = new();
		StateHasChanged();
		_boardCancellation = new CancellationTokenSource();
		_ = FetchBoard( _boardLevelId, Settings.Current.LeaderboardScope, gen, _boardCancellation.Token );
	}

	// DEBUG: DEBUG_ROW_COUNT synthetic entries (descending scores, varied times) to exercise the footer
	// leaderboard layout without a published board. Character/block-tally columns stay empty (those
	// come from a real data payload, which fake rows don't have); rank/avatar/name/time/score render.
	// Rows come in pairs sharing a whole second but differing in ms, so the tie-breaker ms also show.
	private static List<LeaderboardRowData> BuildDebugRows( string levelId )
	{
		int seed = string.IsNullOrEmpty( levelId ) ? 0 : levelId.GetHashCode();
		var rows = new List<LeaderboardRowData>();
		for ( int i = 0; i < DEBUG_ROW_COUNT; i++ )
		{
			rows.Add( new LeaderboardRowData
			{
				SteamId = 76561197960265728L + (uint)( seed & 0xffff ) + i,
				Name = $"PLAYER {i + 1}",
				Score = 1000 - i * 5,
				Value = 1000 - i * 5,
				DataUrl = null,
				CountryCode = null,
				Timestamp = System.DateTimeOffset.UtcNow.AddDays( -i ),
				TimeSeconds = 75f + (i / 2) * 2f + (i % 2 == 0 ? 0.142f : 0.787f),
			} );
		}
		return rows;
	}

	private void OnScopeSelected( int index )
	{
		Settings.Current.LeaderboardScope = (LeaderboardScope)index;
		Settings.Save();
		ReloadBoard();
	}

	private async Task FetchBoard( string levelId, LeaderboardScope scope, int gen, CancellationToken cancellation )
	{
		string statName = Leaderboard.StatNameForLevel( levelId );
		string context = $"[stat '{statName}', scope {scope}, request {gen}]";
		var timer = Stopwatch.StartNew();
		_boardLoadTimer = timer;
		_boardRequestContext = context;
		bool refreshStarted = false;
		double? localScore = null;
		try
		{
			// Cancel both the navigation debounce and the wait for the engine's shared mutex;
			// otherwise every briefly visited level stays ahead of the current level in its queue.
			await Task.Delay( 200, cancellation );
			if ( !this.IsValid() || gen != _boardGeneration )
				return;

			// Rows are numbered from 1 and the first three show medals, so this must be the
			// actual head of the board, not a window centered on the local player's position.
			var board = Leaderboard.GetTopBoard( statName, FOOTER_MAX_ROWS, scope );
			refreshStarted = true;
			if ( Game.IsEditor )
				Log.Info( $"BlockParty: level-select board refresh requested (queue wait + query). {context}" );
			await board.Refresh( cancellation );
			timer.Stop();
			// Board2 swallows ApiException (including HTTP errors). A completed Refresh with zero
			// entries is not proof of a successful empty response; do not log it as HTTP success.
			if ( Game.IsEditor )
				Log.Info( $"BlockParty: level-select board refresh finished after {timer.Elapsed.TotalSeconds:F2}s with {board.Entries?.Length ?? 0} entries{(gen != _boardGeneration ? " (superseded)" : "")}. {context}" );
			if ( !this.IsValid() || gen != _boardGeneration )
				return; // torn down, or a newer load superseded this one while we waited

			var entries = await LeaderboardOrder.CompletePodiumTiesAsync( board.Entries?.ToList() ?? new(),
				board.TotalEntries, () => Leaderboard.GetTopBoard( statName, FOOTER_MAX_ROWS, scope ), cancellation );
			if ( !this.IsValid() || gen != _boardGeneration ) return;
			localScore = entries
				.Where( entry => entry.SteamId == (long)Game.SteamId )
				.Select( entry => (double?)entry.Value )
				.FirstOrDefault();
			if ( localScore.HasValue )
				Leaderboard.RecordPersonalBest( statName, localScore.Value );
			entries = entries.Take( FOOTER_MAX_ROWS ).ToList();
			_scoreRows = entries
				.Select( e => new LeaderboardRowData
				{
					SteamId = e.SteamId,
					Name = e.DisplayName,
					Score = (int)e.Value,
					Value = e.Value,
					DataUrl = e.DataUrl,
					CountryCode = e.CountryCode,
					Timestamp = e.Timestamp,
					TimeSeconds = float.NaN,   // resolved lazily from the data payload on render
				} )
				.ToList();

			// Every legitimate submission attaches its run payload (Leaderboard.Submit always passes
			// data:), so an entry with no DataUrl is a bare forged Stats.SetValue — hide it. Entries
			// whose payload exists but disagrees are pruned once it loads, in PruneFootRows.
			int noData = _scoreRows.RemoveAll( r => string.IsNullOrEmpty( r.DataUrl ) );
			if ( Game.IsEditor && noData > 0 )
				Log.Info( $"BlockParty: board '{statName}' hid {noData} payload-less entries." );
		}
		catch ( OperationCanceledException e ) when ( cancellation.IsCancellationRequested && !IsBoardTimeout( e ) )
		{
			timer.Stop();
			if ( Game.IsEditor && refreshStarted )
				Log.Info( $"BlockParty: level-select board wait cancelled after {timer.Elapsed.TotalSeconds:F2}s (selection changed or screen closed). {context}" );
			return;
		}
		catch ( System.Exception e )
		{
			timer.Stop();
			if ( Game.IsEditor )
			{
				string outcome = IsBoardTimeout( e ) ? "timed out"
					: e is OperationCanceledException ? "was cancelled outside navigation" : "failed";
				// Log even a superseded query's failure: it may explain why the new level was waiting.
				Log.Warning( $"BlockParty: level-select board {outcome} after {timer.Elapsed.TotalSeconds:F2}s{(gen != _boardGeneration ? " (superseded)" : "")}. {context} {e}" );
			}

			// Stale-guard BEFORE clearing: a superseded fetch failing late must not blank the newer
			// highlight's board (it flashed "NO ENTRIES YET" over level B when level A's fetch died).
			if ( !this.IsValid() || gen != _boardGeneration )
				return;

			// The backend omits Entries entirely for a stat with zero entries, so the engine's
			// Board2.From() throws — treat that as an empty board (same handling as HighscoreScreen).
			_scoreRows = new();
		}

		if ( !this.IsValid() || gen != _boardGeneration )
			return;

		// Outside the try so it also covers the empty/failed board: a run we submitted this session
		// is worth showing even when the query came back with nothing.
		LeaderboardRows.SpliceLocalBest( _scoreRows, statName, FOOTER_MAX_ROWS, localScore );

		_boardLoading = false;
		_boardVersion++;
		StateHasChanged();
	}

	protected override int BuildHash() => System.HashCode.Combine(
		Stage?.Selected?.LevelId,
		(int)(Stage?.CamX ?? 0f),
		(int)(Stage?.CamY ?? 0f),
		(int)(Stage?.SelectorX ?? 0f),
		(int)(Stage?.SelectorY ?? 0f),
		Stage?.SelectorFrame ?? 0,
		(int)((Stage?.FadeAlpha ?? 0f) * 20f),
		System.HashCode.Combine( Stage?.Revision ?? 0, _boardVersion, (int)_footScroll,
			Settings.Current.SelectedCharacterId, Stage?.BlockedFeedbackNode?.LevelId,
			(int)((Stage?.BlockedFeedbackProgress ?? 0f) * 100f), Stage?.BlockedFeedbackActive ?? false ) );

	// ---- Footer virtual scrolling -----------------------------------------------------------------
	// The footer list is a fixed-row-height virtualized list (same idea as VirtualListPanel, but this
	// footer is a secondary, mouse-only panel: no keyboard selection / selector cube — the map owns
	// W/S — so it's a small self-contained implementation instead of that full base class).
	// Row height in 1080-ref px; MUST match .foot-row height in the stylesheet.
	const float FOOT_ROW_HEIGHT = 60f;
	// Assumed viewport height until the .foot-list panel has laid out and we can measure it.
	const float FOOT_VIEWPORT_FALLBACK = 240f;

	private float _footScroll;         // scroll offset, ref px
	private float _footScrollVel;      // inertial velocity, ref px/sec
	private float _footViewport = -1f; // measured .foot-list inner height, ref px (-1 = not yet)
	private bool _footDraggingBar;
	private float _footDragGrab;       // ref px from the thumb top to the grab point

	private float FootViewport => _footViewport >= 0f ? _footViewport : FOOT_VIEWPORT_FALLBACK;
	private float FootTotalHeight => _scoreRows.Count * FOOT_ROW_HEIGHT;
	private float FootMaxScroll => MathF.Max( 0f, FootTotalHeight - FootViewport );

	// Render only the rows covering the viewport (+2 overscan so a fast scroll doesn't flash gaps).
	private int FootFirstVisible => Math.Clamp( (int)MathF.Floor( _footScroll / FOOT_ROW_HEIGHT ), 0, Math.Max( 0, _scoreRows.Count - 1 ) );
	private int FootLastVisible => Math.Min( _scoreRows.Count, FootFirstVisible + (int)MathF.Ceiling( FootViewport / FOOT_ROW_HEIGHT ) + 2 );
	private float FootRowTop( int index ) => index * FOOT_ROW_HEIGHT - _footScroll;

	// Proportional scrollbar thumb: tall list -> short thumb, positioned by scroll fraction.
	private bool FootShowScrollbar => FootTotalHeight > FootViewport;
	private float FootThumbHeight => MathF.Max( 40f, (FootViewport / MathF.Max( 1f, FootTotalHeight )) * FootViewport );
	private float FootThumbTravel => MathF.Max( 0f, FootViewport - FootThumbHeight );
	private float FootScrollRange => MathF.Max( 1f, FootTotalHeight - FootViewport );
	private float FootThumbTop => Math.Clamp( _footScroll / FootScrollRange, 0f, 1f ) * FootThumbTravel;

	// Reset scroll to the top whenever the board is replaced (new level / filter / reload).
	private void ResetFootScroll()
	{
		_footScroll = 0f;
		_footScrollVel = 0f;
	}

	// Wheel over the leaderboard footer scrolls the score list (inertial); anywhere else on the screen
	// it walks the level-select spine one node per notch (value.y > 0 = wheel down = step down).
	// Declared 'protected' (not 'protected internal'): the engine member is in another assembly, so
	// only its protected accessibility is visible to override here.
	protected override void OnMouseWheel( Vector2 value )
	{
		if ( value.y == 0f || MedalStandingsOpen )
			return;
		// (wheel over the medals overlay is swallowed by MedalStandingsPanel and never reaches here)

		// Over the (scrollable) footer: scroll the leaderboard as before, don't move the spine.
		// Hit-tested against the cursor rather than footer.HasHovered: the engine leaves :hover set on
		// a panel's ancestors when the hovered child is deleted mid-hover, and the footer's rows are
		// rebuilt constantly (virtual slice, board reload), so HasHovered can latch true and send the
		// map's wheel to the leaderboard for the rest of the visit.
		var footer = FindDescendant( Panel, "footer" );
		if ( FootShowScrollbar && CursorOver( footer ) )
		{
			_footScrollVel = Math.Clamp( _footScrollVel + value.y * 1400f, -6000f, 6000f );
			return;
		}

		// Elsewhere: move the highlighted node up/down the spine.
		Stage?.WheelStep( value.y > 0f ? 1 : -1 );
	}

	// ── hover tooltip for the footer rows' WATCH buttons ─────────────────────────────────────────────
	// The layer belongs to this SCREEN, not to LeaderboardRow: a per-row layer would be clipped by the
	// footer's scrolling container. Rows get the handler through their WatchTip parameter. Mechanism (and
	// why the poll validates the stored panel rather than using mouseout) documented in LevelEditorHud.
	private PixelTooltip Tooltip { get; set; }
	private Panel _tipPanel;
	private string _tipText;

	private Action<PanelEvent> Tip( string text )
		=> e => { _tipPanel = e.This; _tipText = text; };

	// Explicitly typed: the engine's razor generator can't infer a lambda's delegate type inside an attribute.
	private Func<string, Action<PanelEvent>> StaleTipFactory => reason => Tip( reason );

	protected override void OnUpdate()
	{
		LogSlowBoardRequest();
		if ( _tipPanel.IsValid() && _tipPanel.HasHovered && !string.IsNullOrEmpty( _tipText ) ) Tooltip?.Show( _tipText );
		else Tooltip?.Hide();

		MeasureFootViewport();
		TickSelectorSize();
		TickNodeGlows();

		float dt = Time.Delta;
		if ( MathF.Abs( _footScrollVel ) > 1f )
		{
			_footScroll = Math.Clamp( _footScroll + _footScrollVel * dt, 0f, FootMaxScroll );
			_footScrollVel *= MathF.Exp( -12f * dt );   // exponential decay -> same ease at any framerate
			if ( _footScroll <= 0f || _footScroll >= FootMaxScroll )
				_footScrollVel = 0f;   // don't stick against an end with leftover velocity
			StateHasChanged();
		}
		else if ( _footScroll > FootMaxScroll )
		{
			_footScroll = FootMaxScroll;   // list shrank (board reloaded to fewer rows) — re-clamp
			StateHasChanged();
		}

		PruneFootRows();
	}

	// A footer row's run time for the ms-dedup: inline for debug rows, otherwise from the run payload.
	// Only rows in the visible window are allowed to FETCH a missing payload (which they're loading
	// anyway to draw their character / block tally); off-screen rows just peek at the cache, so
	// browsing the map doesn't pull a hundred payloads per level highlighted. A tie involving an
	// unloaded off-screen row therefore reveals itself once that row is scrolled into view.
	private float? ResolveFootTime( int index )
	{
		var row = _scoreRows[index];
		if ( !float.IsNaN( row.TimeSeconds ) )
			return row.TimeSeconds;

		bool visible = index >= FootFirstVisible && index < FootLastVisible;
		var data = visible ? RunDataStore.Get( row.DataUrl, OnFootPayloadLoaded ) : RunDataStore.Peek( row.DataUrl );
		return data?.TimeSeconds;
	}

	// A footer row's payload landed — repaint, so a newly-resolved time can join (or break) a tie.
	// The rows repaint themselves for their own content; this is for the ms set, which the footer owns.
	private void OnFootPayloadLoaded()
	{
		if ( !this.IsValid() )
			return;

		_boardVersion++;
		StateHasChanged();
	}

	// --- footer payload pruning (anti-tamper, display side) -----------------------------------------
	// Same check as HighscoreScreen.PruneInconsistentRows, over the footer's visible window: hide rows
	// whose (lazily fetched) payload contradicts the entry — wrong level / a daily run / a value that
	// doesn't match the payload's own score + tiebreaker. The footer board is level-only, so no
	// character expectation. Rows are validated against _boardLevelId (the level these rows were
	// FETCHED for), not the live highlight — a debounced fetch can lag the selection. Null payloads
	// (loading / fetch failed) are kept; payload-less entries were already dropped in FetchBoard. The
	// scroll re-clamp above handles the list shrinking.
	private void PruneFootRows()
	{
		if ( DEBUG_ROWS || _boardLoading || _scoreRows.Count == 0 )
			return;

		int first = FootFirstVisible;
		bool removed = false;
		for ( int i = Math.Min( FootLastVisible, _scoreRows.Count ) - 1; i >= first; i-- )
		{
			var row = _scoreRows[i];
			var data = RunDataStore.Get( row.DataUrl );
			if ( data is null || Leaderboard.IsLevelEntryConsistent( data, _boardLevelId, null, row.Value ) )
				continue;

			if ( Game.IsEditor )
				Log.Info( $"BlockParty: level-select board '{_boardLevelId}' hid row {i + 1} ({row.Name}, {row.SteamId}) — entry doesn't match its run payload." );
			_scoreRows.RemoveAt( i );
			removed = true;
		}

		if ( removed )
		{
			_boardVersion++;
			StateHasChanged();
		}
	}

	// Glide the character marker's size toward its target each frame (see _selectorSize). Seeds
	// instantly on the first frame so the screen opens already sized; thereafter lerps so a major<->side
	// move resizes smoothly rather than snapping. Repaints while still in motion (the panel's BuildHash
	// doesn't sample the size, so drive it explicitly here).
	private void TickSelectorSize()
	{
		if ( Stage?.Selected is null )
			return;

		float target = CharSize( Stage.Selected );

		if ( !_selectorSizeInit )
		{
			_selectorSize = target;
			_selectorSizeInit = true;
			return;
		}

		if ( MathF.Abs( _selectorSize - target ) <= 0.1f )
		{
			if ( _selectorSize != target )
			{
				_selectorSize = target;   // settle exactly, one final repaint
				StateHasChanged();
			}
			return;
		}

		_selectorSize += ( target - _selectorSize ) * MathF.Min( 1f, MARKER_LERP * Time.Delta );
		StateHasChanged();
	}

	// Advance every node glow toward its target each frame (see _nodeGlows): 1 for the selected node,
	// 0 for the rest; entries that finish fading out are dropped. Repaints while any fade is moving
	// (BuildHash doesn't sample the fades, so drive it explicitly here, like the size lerp).
	private void TickNodeGlows()
	{
		bool changed = false;
		var selected = Stage?.Selected;
		// Don't consume the first-frame full seed while there's nothing to seed yet (no selection):
		// _nodeGlowsInit only latches once a node actually got its entry. A fresh entry is itself a
		// repaint (the div doesn't exist in the tree yet), even the seeded-at-full one whose value
		// then sits exactly on target.
		if ( selected is not null )
		{
			if ( !_nodeGlows.ContainsKey( selected ) )
			{
				_nodeGlows[selected] = _nodeGlowsInit ? 0f : 1f;
				changed = true;
			}
			_nodeGlowsInit = true;
		}

		if ( _nodeGlows.Count == 0 )
			return;
		foreach ( var node in _nodeGlows.Keys.ToList() )
		{
			float target = node == selected ? 1f : 0f;
			float value = _nodeGlows[node];
			if ( value == target )
				continue;

			float rate = target > value ? GLOW_LERP_IN : GLOW_LERP_OUT;
			value += ( target - value ) * MathF.Min( 1f, rate * Time.Delta );
			if ( MathF.Abs( value - target ) <= 0.01f )
				value = target;   // settle exactly, one final repaint

			if ( value <= 0f )
				_nodeGlows.Remove( node );
			else
				_nodeGlows[node] = value;
			changed = true;
		}

		if ( changed )
			StateHasChanged();
	}

	// Measure the .foot-list panel's inner height (screen px) back into ref px for the virtualization.
	private void MeasureFootViewport()
	{
		var list = FindDescendant( Panel, "foot-list" );
		if ( list is null )
			return;

		float h = list.Box.RectInner.Height * list.ScaleFromScreen;
		if ( h > 1f )
			_footViewport = h;
	}

	// Stateless "is the cursor inside this panel" test (screen space, same as Box.Top / Mouse.Position
	// elsewhere here). Use in place of HasHovered wherever a stale reading would stick.
	private static bool CursorOver( Panel panel )
		=> panel.IsValid() && panel.Box.Rect.Width > 0f && panel.IsInside( Mouse.Position );

	// The .foot-list isn't a direct child of the root (root > play-area > footer > foot-list), so walk.
	private static Panel FindDescendant( Panel root, string cls )
	{
		if ( root is null )
			return null;

		foreach ( var child in root.Children )
		{
			if ( child.HasClass( cls ) )
				return child;

			var found = FindDescendant( child, cls );
			if ( found is not null )
				return found;
		}
		return null;
	}

	// --- scrollbar drag: click + drag the thumb. Only starts when the thumb is the event target, so
	// per-row content never hijacks it. ---
	protected override void OnMouseDown( MousePanelEvent e )
	{
		if ( e.MouseButton != MouseButtons.Left || e.Target is null || !e.Target.HasClass( "foot-scrollbar" ) )
			return;

		_footDragGrab = Math.Clamp( FootListLocalY( Mouse.Position ) - FootThumbTop, 0f, FootThumbHeight );
		_footDraggingBar = true;
		e.StopPropagation();
	}

	protected override void OnMouseMove( MousePanelEvent e )
	{
		if ( !_footDraggingBar )
			return;

		if ( FootThumbTravel > 0f )
		{
			float thumbTop = Math.Clamp( FootListLocalY( Mouse.Position ) - _footDragGrab, 0f, FootThumbTravel );
			_footScroll = Math.Clamp( (thumbTop / FootThumbTravel) * FootScrollRange, 0f, FootScrollRange );
			_footScrollVel = 0f;   // a drag overrides any inertial glide
			StateHasChanged();
		}

		e.StopPropagation();
	}

	protected override void OnMouseUp( MousePanelEvent e )
	{
		_footDraggingBar = false;
	}

	// Screen-space mouse Y -> reference-space Y within the .foot-list panel.
	private float FootListLocalY( Vector2 mouse )
	{
		var list = FindDescendant( Panel, "foot-list" );
		return list is null ? 0f : (mouse.y - list.Box.Top) * list.ScaleFromScreen;
	}
}