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

<root style="opacity: @(Stage?.FadeAlpha ?? 0f)">
	@{ if ( Stage is null ) return; }
	<PixelTooltip @ref="Tooltip"></PixelTooltip>
	@* Quiet strip above the title (absolute, so appearing/expiring never shifts the layout). *@
	@if ( DayCountdownText is string countdown )
	{
		<label class="countdown">@countdown</label>
	}
	<label class="header">DAILY CHALLENGE</label>

	@* Day paging is driven by A/D (see the stage) and mouse clicks — the arrows aren't part of the
	   W/S selector cycle. *@
	<div class="daybar">
		<button class="square-btn nav i-arrow-left @(Stage.CanGoPrevious ? "" : "disabled")" onclick=@(() => Stage.PreviousDay())><div class="img"></div></button>
		<div class="daytext">
			<label class="date">@Stage.DisplayDate</label>
			@if ( AttemptsText is string attempts )
			{
				<label class="attempts">@attempts</label>
			}
		</div>
		<button class="square-btn nav i-arrow-right @(CanGoNext ? "" : "disabled")" onclick=@(() => Stage.NextDay())><div class="img"></div></button>
	</div>

	<div class="status">
		@if ( DebugCanPlay )
		{
			@* daily_debug_nav: any non-today day plays its generated level — no submit/attempts,
			   local replay only (see GameStage.IsDebugRun). Today keeps the normal PLAY flow. *@
			<div class="selcube @(StatusFocused ? "" : "hidden")" style="background-position: @(-SelectorFrame * 100)% 0%;"></div>
			<button class="play debug @(StatusFocused ? "selected" : "")" onmouseover=@HoverStatus onclick=@(() => Stage.DebugPlay())>DEBUG PLAY</button>
		}
		else if ( Stage.IsFuture )
		{
			<label class="state">NOT AVAILABLE</label>
		}
		else if ( CanPlay )
		{
			<div class="selcube @(StatusFocused ? "" : "hidden")" style="background-position: @(-SelectorFrame * 100)% 0%;"></div>
			<button class="play @(StatusFocused ? "selected" : "")" onmouseover=@HoverStatus onclick=@(() => Stage.Play())>PLAY</button>
		}
		else if ( HasReplay )
		{
			<div class="selcube @(StatusFocused ? "" : "hidden")" style="background-position: @(-SelectorFrame * 100)% 0%;"></div>
			<button class="watch @(StatusFocused ? "selected" : "")" onmouseover=@HoverStatus onclick=@WatchMyRun>WATCH MY RUN</button>
		}
		else if ( Played )
		{
			<label class="state">PLAYED</label>
		}
		else if ( _loading )
		{
			<label class="state">CHECKING...</label>
		}
		else
		{
			<label class="state">NO RUN</label>
		}
	</div>

	<div class="list">
		@{
			// Visible window + row placement come from VirtualListPanel.
			int first = FirstVisible;
			int last = LastVisible;
		}
		@if ( _loading )
		{
			<label class="info">LOADING...</label>
		}
		else if ( _rows.Count == 0 )
		{
			@* Past days' boards are closed — no "YET" about it. *@
			<label class="info">@(Stage.IsToday || Stage.IsFuture ? "NO ENTRIES YET" : "NO ENTRIES")</label>
		}
		else
		{
			@for ( int i = first; i < last; i++ )
			{
				var row = _rows[i];
				var rank = i + 1;
				var idx = i;
				var top = RowTop( i );
				@* The row content is the shared LeaderboardRow; this outer div positions the row and
				   carries the zebra/selected background + hover-to-select. A launched replay returns
				   to this daily screen (ReturnDailyId). Debug rows pass their synthetic time inline. *@
				<div class="row @(i % 2 == 1 ? "even" : "") @(RowFocused && i == Selected ? "selected" : "")" style="top: @(top)px;"
					onmouseover=@(() => HoverRow( idx )) onclick=@(() => HoverRow( idx ))>
					<LeaderboardRow Rank=@rank ShowRankTrophy=@true [email protected] [email protected] [email protected]
						[email protected] [email protected] [email protected]
						[email protected] DimNonVictoryScore=@true [email protected]
						AwardTip=@StaleTipFactory WatchTip=@Tip( "Watch replay" )
						StaleTip=@StaleTipFactory
						ProfileTip=@Tip( "Steam profile" )></LeaderboardRow>
				</div>
			}

			@if ( ShowScrollbar )
			{
				<div class="scrollbar" style="top: @(ThumbTop)px; height: @(ThumbHeight)px;"></div>
			}

			@* Hidden unless a leaderboard row is the focused zone; the other zones show their own cube. *@
			@if ( RowFocused )
			{
				<div class="selector" style="top: @(SelectorTop)px; background-position: @(-SelectorFrame * 100)% 0%;"></div>
			}
		}
	</div>

	<div class="footer">
		@* MAIN MENU is the bottom of the selector cycle: up-off the day-nav wraps here, down-off the
		   last row lands here. Its cube keeps a fixed lane so the button doesn't shift. *@
		<div class="selcube @(MainMenuFocused ? "" : "hidden")" style="background-position: @(-SelectorFrame * 100)% 0%;"></div>
		<button class="back @(MainMenuFocused ? "selected" : "")" onmouseover=@HoverMainMenu onclick=@GoToMainMenu>MAIN MENU</button>
	</div>

	@* Forced days override only the trigger preview and lock it; the player's persisted selection is
	   untouched and returns when browsing a non-forced day. The run itself reads ForcedCharacterId. *@
	<div class="character-slot">
		<CharacterPicker CenterOverlayOnScreen=@true
			DisplayCharacterId=@(Stage.Daily?.Level.ForcedCharacterId)
			TriggerLocked=@(Stage.Daily?.Level.ForcedCharacterId is not null)
			LockedVisualsVisible=@_lockedPickerVisualsVisible
			[email protected]></CharacterPicker>
	</div>

	@* Schematic of the day's generated level in the UI square's upper-left corner (mirrors the
	   character slot). Shown for today and every past day; future days only leak their layout with
	   daily_debug_nav on (generator evaluation). *@
	@if ( ShowLevelPreview )
	{
		<div class="level-slot">
			<LevelNodePreview [email protected]></LevelNodePreview>
		</div>
	}
	<DailyTrophyStandingsPanel @ref="Trophies"></DailyTrophyStandingsPanel>
</root>

@code
{
	public DailyChallengeStage Stage { get; set; }
	private DailyTrophyStandingsPanel Trophies { get; set; }
	private bool _wasToday;
	private int _refreshGeneration;
	public bool TrophyStandingsOpen => Trophies?.Expanded == true;
	public void CloseTrophyStandings() => Trophies?.Close();
	protected override void OnMouseWheel( Vector2 value )
	{
		if ( !TrophyStandingsOpen ) base.OnMouseWheel( value );
	}

	// Toggle: when true the board is filled with randomized synthetic rows instead of the live daily
	// leaderboard, so the row layout (rank / name / time / block tally / score) can be eyeballed
	// without a published board. Set false for the real board. (static readonly, not const, so
	// flipping it never makes the other branch unreachable — a const bool would trip CS0162.)
	private static readonly bool DEBUG_ROWS = false;

	// ── hover tooltip for the rows' WATCH buttons ────────────────────────────────────────────────────
	// The layer belongs to this SCREEN, not to LeaderboardRow: a per-row layer would be clipped by the
	// list'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()
	{
		if ( TrophyStandingsOpen ) { Tooltip?.Hide(); return; }
		base.OnUpdate();
		if ( Stage is not null && _wasToday != Stage.IsToday )
		{
			_wasToday = Stage.IsToday;
			// A hub held open across midnight must discard provisional local places too.
			_ = Refresh();
		}
		if ( _tipPanel.IsValid() && _tipPanel.HasHovered && !string.IsNullOrEmpty( _tipText ) ) Tooltip?.Show( _tipText );
		else Tooltip?.Hide();
		PruneInconsistentRows();
	}

	// --- daily-board payload pruning (anti-tamper, display side) ------------------------------------
	// Board writes are client-authoritative, so a patched client can put any value on today's board.
	// The board query itself is day-filtered server-side (past-day submissions land in the wrong
	// ingestion-date bucket and never come back — see Leaderboard.GetDailyBoard); this prunes what that
	// can't: forged values submitted ON the day. Checks the visible window only — exactly the rows whose
	// payloads the shared LeaderboardRow already fetches for its time column — so it costs no extra
	// requests; an off-screen bad row is pruned when scrolled into view. A null payload (still loading,
	// or fetch failed) is kept: it's indistinguishable from a network failure, and honest rows must not
	// vanish offline. Payload-LESS entries (no DataUrl at all) are dropped in Refresh instead.
	private void PruneInconsistentRows()
	{
		if ( DEBUG_ROWS || _loading || _rows.Count == 0 || Stage is null )
			return;

		int first = FirstVisible;
		bool removed = false;
		for ( int i = Math.Min( LastVisible, _rows.Count ) - 1; i >= first; i-- )
		{
			var row = _rows[i];
			var data = RunDataStore.Get( row.DataUrl, OnPayloadLoaded );
			if ( data is null || Leaderboard.IsDailyEntryConsistent( data, Stage.DailyId, row.Value ) )
				continue;

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

		if ( removed )
		{
			ClampSelectionAfterRemoval();
			StateHasChanged();
		}
	}

	private List<LeaderboardRowData> _rows = new();
	private bool _loading = true;
	private bool _attemptedByBoard;
	private long _localSteamId;
	private int _dataVersion;
	private bool _lockedPickerVisualsVisible = true;

	public void BeginLockedPickerExit()
	{
		_lockedPickerVisualsVisible = false;
		StateHasChanged();
	}

	/// <summary>True once the daily board has been fetched and the local player already has an entry
	/// for this day. Exposed so <see cref="DailyChallengeStage.Play"/> can honour the same server-side
	/// check the UI uses, rather than relying only on the local tamper-able attempt file.</summary>
	public bool LocalPlayerOnBoard => _attemptedByBoard;

	// --- selector focus zones -----------------------------------------------------------------
	// The cube travels a single vertical cycle down the screen:
	//   [ PLAY/WATCH ] → [ leaderboard rows ] → [ MAIN MENU ] → (wraps)
	// The PLAY/WATCH slot only joins the cycle when it's an actual button (StatusSelectable). Rows
	// reuse the base's Selected/scroll; the button zones render their own cube (see the .selcube
	// elements) off the shared SelectorFrame. Day paging is A/D-only and not part of this cycle.
	private enum Zone { Status, Row, MainMenu }
	private Zone _zone = Zone.MainMenu;
	private readonly NavRepeater _navRepeat = new();

	// PLAY/WATCH is a selectable button (vs. a plain status label) only in these states.
	private bool StatusSelectable => CanPlay || HasReplay || DebugCanPlay;

	// daily_debug_nav: non-today days offer DEBUG PLAY (no submit/attempts, local replay only).
	private bool DebugCanPlay => DailyChallengeStage.DebugNavigation && Stage?.IsToday == false;

	private bool StatusFocused => _zone == Zone.Status;
	private bool RowFocused => _zone == Zone.Row;
	private bool MainMenuFocused => _zone == Zone.MainMenu;

	// Row count for the virtual-list base (scroll/selection/selector all live in VirtualListPanel).
	protected override int ItemCount => _rows.Count;

	// This board's list panel is shorter than the full-screen leaderboards; seed the pre-layout
	// viewport to match so the first frame's row count is unchanged from before the refactor.
	protected override float InitialViewportHeight => 520f;

	private bool CanGoNext => Stage is not null
		&& (DailyChallengeStage.DebugNavigation || !DailyChallenge.IsFuture( DailyChallenge.AddDays( Stage.DailyId, 1 ) ));
	private bool LocalAttempted => Stage is not null && DailyChallengeProgress.IsAttempted( Stage.DailyId );
	// Display-only ("has a score / has played") — no longer a lockout by itself on multi-attempt days.
	private bool Played => LocalAttempted || _attemptedByBoard;
	// The local attempt counter gates every day; the board additionally locks SINGLE-attempt days
	// (authoritative — survives deleting the local file). See DailyChallengeStage.Play.
	private bool AttemptsRemain => Stage?.Daily is DailyDef daily
		&& DailyChallengeProgress.HasAttemptsRemaining( Stage.DailyId, daily.MaxAttempts )
		&& !(daily.MaxAttempts == 1 && Played);
	private bool CanPlay => Stage?.IsToday == true && !_loading && AttemptsRemain;

	// --- day-end countdown ---------------------------------------------------------------------
	// "DAY ENDS IN M:SS" under the date as TODAY approaches UTC midnight: a run started this close
	// to the rollover may finish (or flush) after the day's board window closes and miss the board
	// (see Leaderboard.GetDailyBoard). Display only — PLAY is never disabled by this.

	// TUNING: show the countdown all day instead of only inside the final window, to compare both
	// presentations. (static readonly, not const, for the same CS0162 reason as DEBUG_ROWS.)
	private static readonly bool ALWAYS_SHOW_DAY_COUNTDOWN = true;

	// How close to UTC midnight the countdown appears (ignored when ALWAYS_SHOW_DAY_COUNTDOWN).
	private const double CountdownWindowMinutes = 10;

	private string DayCountdownText
	{
		get
		{
			if ( Stage?.IsToday != true )
				return null;

			var left = DailyChallenge.TimeUntilDayEnd;
			if ( !ALWAYS_SHOW_DAY_COUNTDOWN && left.TotalMinutes > CountdownWindowMinutes )
				return null;

			return left.TotalHours >= 1
				? $"DAY ENDS IN {(int)left.TotalHours}:{left.Minutes:00}:{left.Seconds:00}"
				: $"DAY ENDS IN {left.Minutes}:{left.Seconds:00}";
		}
	}

	// Per-day attempt info under the date. Today counts down what's left; past days show what was
	// spent (or just the day's allowance if it was never played); no-limit days say UNLIMITED.
	// Uses DisplayedAttemptsUsed so daily_debug_nav runs exercise this label without real progress.
	private string AttemptsText
	{
		get
		{
			if ( Stage?.Daily is not DailyDef daily ) return null;
			// Future days only leak their generated limit with daily_debug_nav on (like the preview).
			if ( Stage.IsFuture && !DailyChallengeStage.DebugNavigation ) return null;
			if ( daily.MaxAttempts is not int max ) return "UNLIMITED ATTEMPTS";

			int used = Math.Clamp( DailyChallengeProgress.DisplayedAttemptsUsed( Stage.DailyId ), 0, max );

			if ( Stage.IsToday )
				return used >= max ? $"{max}/{max} ATTEMPTS USED"
					: $"{max - used}/{max} ATTEMPTS LEFT";

			if ( used > 0 ) return $"{used}/{max} ATTEMPTS USED";
			return max == 1 ? "1 ATTEMPT" : $"{max} ATTEMPTS";
		}
	}
	private bool ShowLevelPreview => Stage is not null
		&& (!Stage.IsFuture || DailyChallengeStage.DebugNavigation)
		&& Stage.Daily?.Level is not null;

	private RunData LocalReplay => Stage is null ? null : DailyChallengeProgress.GetReplay( Stage.DailyId );
	private LeaderboardRowData? MyBoardRow
	{
		get
		{
			int index = _rows.FindIndex( r => r.SteamId == _localSteamId );
			return index >= 0 ? _rows[index] : null;
		}
	}
	private RunData BoardReplay => MyBoardRow is LeaderboardRowData row && !string.IsNullOrEmpty( row.DataUrl ) ? RunDataStore.Get( row.DataUrl, OnPayloadLoaded ) : null;
	private bool HasReplay => LocalReplay?.CanReplay == true || BoardReplay?.CanReplay == true;

	protected override void OnStart()
	{
		base.OnStart();
		_localSteamId = (long)Game.SteamId;
		_wasToday = Stage?.IsToday == true;
		_ = Refresh();
	}

	private async Task Refresh()
	{
		int generation = ++_refreshGeneration;
		_loading = true;
		_attemptedByBoard = false;
		StateHasChanged();

		// DEBUG: skip the live board and show randomized synthetic rows for layout checking.
		if ( DEBUG_ROWS )
		{
			_rows = BuildDebugRows( _localSteamId );
			_attemptedByBoard = _rows.Any( r => r.SteamId == _localSteamId );
			_loading = false;
			SelectDefault();
			StateHasChanged();
			return;
		}

		// Limited-attempt day: pull the backend attempt counter before enabling PLAY, so a deleted
		// local progress file can't re-open the day (best-effort; offline falls back to the file).
		if ( Stage.IsToday && Stage.Daily?.MaxAttempts is not null )
			await DailyChallengeProgress.RefreshBackendAttempts( Stage.DailyId );
		if ( !this.IsValid() || generation != _refreshGeneration ) return;

		string statName = Leaderboard.DailyStatName( Stage.DailyId );
		double? localScore = null;
		try
		{
			var board = Leaderboard.GetDailyBoard( Stage.DailyId );
			board.IncludeSteamIds( _localSteamId );
			await board.Refresh();
			if ( !this.IsValid() || generation != _refreshGeneration ) return;
			var entries = board.Entries?.ToList() ?? new();
			localScore = entries
				.Where( entry => entry.SteamId == _localSteamId )
				.Select( entry => (double?)entry.Value )
				.FirstOrDefault();
			Leaderboard.RecordPersonalBest( statName, localScore );

			// Read straight off the backend's own entries, NOT off _rows. _rows later gains a locally
			// spliced row (see below) and this flag gates the single-attempt lockout in
			// DailyChallengeStage.Play — it has to mean "the server says I'm on this day's board" and
			// nothing else, whatever the display list ends up containing.
			_attemptedByBoard = entries.Any( e => e.SteamId == _localSteamId );

			// IncludeSteamIds keeps attempt detection authoritative for players below the top 100;
			// only ranked head entries belong in the displayed leaderboard.
			entries = await LeaderboardOrder.CompletePodiumTiesAsync(
				entries.Where( e => e.Rank <= Leaderboard.MaxEntries ).ToList(), board.TotalEntries,
				() => Leaderboard.GetDailyBoard( Stage.DailyId ),
				isCurrent: () => this.IsValid() && generation == _refreshGeneration );
			if ( !this.IsValid() || generation != _refreshGeneration ) return;
			_rows = entries.Take( Leaderboard.MaxEntries ).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();
			if ( Game.IsEditor )
				Log.Info( $"BlockParty: daily leaderboard {Stage.DailyId} → {_rows.Count} entries [querying ident '{Sandbox.Game.Ident}' stat '{statName}']." );

			// Every legitimate daily 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
			// PruneInconsistentRows. Deliberately AFTER _attemptedByBoard is read: a forged write under
			// your own SteamId still counts as "on the board" for the 1-attempt lockout.
			int noData = _rows.RemoveAll( r => string.IsNullOrEmpty( r.DataUrl ) );
			if ( Game.IsEditor && noData > 0 )
				Log.Info( $"BlockParty: daily board {Stage.DailyId} hid {noData} payload-less entries." );
		}
		catch ( Exception e )
		{
			if ( !this.IsValid() || generation != _refreshGeneration ) return;
			if ( Game.IsEditor )
				Log.Info( $"BlockParty: daily leaderboard {Stage.DailyId} empty/not-ready ({e.Message}) [querying ident '{Sandbox.Game.Ident}' stat '{statName}']." );
		}

		if ( !this.IsValid() || generation != _refreshGeneration )
			return;

		// Show today's run straight away even while the board still serves a pre-submit cached
		// response. Display only — _attemptedByBoard above is deliberately left out of this.
		LeaderboardRows.SpliceLocalBest( _rows, statName, Leaderboard.MaxEntries, localScore, allowLocalPending: Stage.IsToday );

		_loading = false;
		SelectDefault();
		StateHasChanged();
	}

	private void OnPayloadLoaded()
	{
		if ( !this.IsValid() ) return;
		_dataVersion++;
		StateHasChanged();
	}

	// --- zone navigation --------------------------------------------------------------------
	// W/S vertical movement (with hold-to-repeat) drives the whole-screen focus cycle, replacing the
	// base's row-only navigation.
	protected override void HandleNavigation( float dt )
	{
		EnsureValidZone();

		int step = _navRepeat.Tick( dt,
			Input.Down( "Up" ) || Input.Down( "UpArrow" ),
			Input.Down( "Down" ) || Input.Down( "DownArrow" ) );
		if ( step != 0 )
			MoveFocus( step );
	}

	// The zones flattened to a vertical index: PLAY/WATCH (if selectable) first, then the rows, then
	// MAIN MENU last. Header count is the number of slots above the rows.
	private int HeaderCount => StatusSelectable ? 1 : 0;

	private int CurrentLinear() => _zone switch
	{
		Zone.Status => 0,
		Zone.Row => HeaderCount + Math.Clamp( Selected, 0, Math.Max( 0, ItemCount - 1 ) ),
		_ => HeaderCount + ItemCount,   // MainMenu
	};

	private void MoveFocus( int dir )
	{
		int total = HeaderCount + ItemCount + 1;   // (status) + rows + main menu
		int cur = CurrentLinear();
		int next = ((cur + dir) % total + total) % total;

		// Only MAIN MENU is present (empty, no PLAY/WATCH) — nowhere to move, so don't fake a move blip.
		if ( next == cur )
			return;

		if ( StatusSelectable && next == 0 )
			SetButtonZone( Zone.Status );
		else if ( next < HeaderCount + ItemCount )
			SetRowZone( next - HeaderCount );
		else
			SetButtonZone( Zone.MainMenu );
	}

	private void SetButtonZone( Zone zone )
	{
		_zone = zone;
		PlaySelectorMove();
		Audio.PlaySfx( SfxType.MenuBlip );
		StateHasChanged();
	}

	private void SetRowZone( int index )
	{
		_zone = Zone.Row;
		FocusRow( index );
		Audio.PlaySfx( SfxType.MenuBlip, 1f, RowBlipPitch( index ) );
		StateHasChanged();
	}

	// Focus can't sit on a zone that isn't currently shown (the PLAY/WATCH button can come and go as
	// the board/replay data resolves; the list can be empty). Fall back to MAIN MENU if so.
	private void EnsureValidZone()
	{
		if ( _zone == Zone.Status && !StatusSelectable )
			_zone = Zone.MainMenu;
		else if ( _zone == Zone.Row && ItemCount == 0 )
			_zone = Zone.MainMenu;
	}

	// Confirm/Space activates whatever's focused. Routed here from the stage's Confirm edge.
	public void ActivateFocused()
	{
		if ( TrophyStandingsOpen ) return;
		switch ( _zone )
		{
			case Zone.Status:
				if ( DebugCanPlay ) Stage.DebugPlay();
				else if ( CanPlay ) Stage.Play();
				else if ( HasReplay ) WatchMyRun();
				else WobbleSelector();
				break;

			case Zone.Row:
				LaunchSelected();
				break;

			default:   // MainMenu
				GoToMainMenu();
				break;
		}
	}

	private void GoToMainMenu()
	{
		Audio.PlaySfx( SfxType.MenuStart );
		Stage.GoBack();
	}

	// --- mouse focus (hover mirrors the keyboard focus) -------------------------------------
	private void HoverRow( int index )
	{
		_zone = Zone.Row;
		SelectRow( index );
		StateHasChanged();
	}

	private void HoverStatus()
	{
		if ( _zone == Zone.Status ) return;
		_zone = Zone.Status;
		PlaySelectorMove();
		StateHasChanged();
	}

	private void HoverMainMenu()
	{
		if ( _zone == Zone.MainMenu ) return;
		_zone = Zone.MainMenu;
		PlaySelectorMove();
		StateHasChanged();
	}

	private void WatchMyRun()
	{
		var run = LocalReplay;
		if ( run?.CanReplay == true )
		{
			var attempt = DailyChallengeProgress.Get( Stage.DailyId );
			var when = attempt?.CompletedAt ?? attempt?.StartedAt ?? DateTimeOffset.Now;
			var friend = new Friend( _localSteamId );
			GameManager.Instance?.StartReplay( run, new ReplaySubmitter( _localSteamId, friend.Name, "", when ), returnDailyId: Stage.DailyId );
			return;
		}

		if ( MyBoardRow is LeaderboardRowData row )
		{
			var payload = string.IsNullOrEmpty( row.DataUrl ) ? null : RunDataStore.Get( row.DataUrl, OnPayloadLoaded );
			if ( payload?.CanReplay == true )
				StartReplay( row, payload );
		}
	}

	private void StartReplay( LeaderboardRowData row, RunData data )
	{
		GameManager.Instance?.StartReplay( data, new ReplaySubmitter( row.SteamId, row.Name, row.CountryCode, row.Timestamp ), returnDailyId: Stage?.DailyId );
	}

	public void LaunchSelected()
	{
		if ( Selected < 0 || Selected >= _rows.Count ) return;
		var row = _rows[Selected];
		var payload = string.IsNullOrEmpty( row.DataUrl ) ? null : RunDataStore.Get( row.DataUrl, OnPayloadLoaded );
		if ( payload?.CanReplay == true ) StartReplay( row, payload );
		else WobbleSelector();
	}

	// Open focused on the primary action — PLAY/WATCH when it's an actual button — else on MAIN MENU
	// (a safe default when there's nothing to play/watch). Keep the base selection parked on the local
	// player's row so entering the list lands there.
	private void SelectDefault()
	{
		int me = _rows.FindIndex( r => r.SteamId == _localSteamId );
		SelectInitial( me >= 0 ? me : 0 );
		_zone = StatusSelectable ? Zone.Status : Zone.MainMenu;
	}

	// DEBUG (see DEBUG_ROWS): a board of randomized synthetic rows so the row layout — rank, avatar,
	// name (varied widths/scripts), time, block tally, score, and the "me" highlight — can be
	// eyeballed without a live daily board. Each row's synthetic payload (time + packed block states)
	// is primed into RunDataStore under a fake url, so the shared LeaderboardRow resolves it through
	// exactly the same path as a real row. The payloads carry no recorded input, so CanReplay stays
	// false and no ▶ button appears. Sorted by score descending like the live board.
	private static List<LeaderboardRowData> BuildDebugRows( long meId )
	{
		var rng = new System.Random();
		string[] names =
		{
			"PLAYER", "xX_LongDragonSlayer_ProGamer_Xx", "日本語のなまえ", "مرحبا باللعبة",
			"Игрок Кириллица", "🎮🔥 GamerXX 🐉", "shortie", "THE_NAME_THAT_KEEPS_GOING_ON_FOREVER",
		};

		int count = rng.Next( 25, 60 );
		var rows = new List<LeaderboardRowData>( count );
		for ( int i = 0; i < count; i++ )
		{
			int score = rng.Next( 100, 2000 );
			float seconds = rng.Next( 20, 900 ) + (float)rng.NextDouble();

			string dataUrl = $"debug-run://daily/{i}";
			RunDataStore.Prime( dataUrl, new RunData { TimeSeconds = seconds, FinalScore = score, Blocks = RandomBlocks( rng ) } );

			rows.Add( new LeaderboardRowData
			{
				SteamId = 76561197960265728L + i,
				Name = $"{names[rng.Next( names.Length )]} {i:00}",
				Score = score,
				Value = score,
				DataUrl = dataUrl,
				TimeSeconds = seconds,
			} );
		}

		rows = rows.OrderByDescending( r => r.Score ).ToList();

		// Mark one row as the local player to preview the "me" highlight / PLAYED state.
		if ( rows.Count > 3 )
		{
			var me = rows[3];
			me.SteamId = meId;
			me.Name = "YOU (DEBUG)";
			rows[3] = me;
		}

		return rows;
	}

	// DEBUG: one packed tally int per block type, each with a random phase and random pressed sides,
	// in the same (type << 6) | (phase << 4) | sides layout RunData.PackBlocks/DecodeBlocks use.
	private static List<int> RandomBlocks( System.Random rng )
	{
		var blocks = new List<int>();
		foreach ( var type in Enum.GetValues<BlockType>() )
		{
			int phase = rng.Next( 0, Block.NUM_PHASES );   // 0..2
			int sides = rng.Next( 0, 16 );                 // 4 bits: L R U D
			blocks.Add( ((int)type << 6) | (phase << 4) | sides );
		}
		return blocks;
	}

	protected override int BuildHash() => HashCode.Combine(
		_loading,
		_attemptedByBoard,
		LocalAttempted,
		_rows.Count,
		_dataVersion,
		Selected,
		HashCode.Combine( (int)ScrollPos, (int)(SelectorPos * 8f), SelectorFrame, Stage?.DailyId ?? "" ),
		HashCode.Combine( (int)_zone, DailyChallengeProgress.DisplayedAttemptsUsed( Stage?.DailyId ), DailyChallengeStage.DebugNavigation,
			// Whole seconds to the UTC rollover, so the day-end countdown ticks (and appears/expires).
			(int)DailyChallenge.TimeUntilDayEnd.TotalSeconds ) );
}