Transposer/Scenes/MainMenuScene.cs
namespace Sandbox.Transposer;

/// <summary>
/// Main menu scene. Shows the "transposer" title bouncing around, score text,
/// and replays the last game's grid swaps in the background progressively faster.
/// After the player has died at least once, streams leaderboard entries upward
/// from the bottom of the screen in a continuous loop.
/// </summary>
public class MainMenuScene : TransposerScene
{
	private ScoreText _instructionsText;
	private bool _hasStartedSwitching;
	private bool _replayingSwaps;
	private int _replayIndex;
	private float _replayWaitTime;
	private float _replayTimer;
	private bool _replayStarted;
	private float _replayDelayTimer;

	// ── Leaderboard scroll state ──────────────────────────────────────────
	private List<string> _lbLines = new();
	private int _lbIndex;
	private float _lbSpawnTimer;
	private float _lbWaitTimer;
	private bool _lbReady;   // true once lines are populated and ready to spawn
	private bool _lbActive;  // true while this scene instance is alive; guards late async returns
	private bool _lbWaiting;

	private const float SPAWN_INTERVAL = 0.5f;
	private const float LOOP_WAIT      = 3f;

	public override void Activate()
	{
		base.Activate();

		_hasStartedSwitching = false;

		AddEntity( new Background( 0, 0, this ) );

		// Title text at 2× scale, centred.
		string titleStr = "transposer";
		int titleWidth = titleStr.Length * 14; // (6+1)*2 = 14 per char at 2x
		TitleText title = new( titleStr, this,
			Screen.PixelWidth / 2 - (int)MathF.Round( titleWidth * 0.5f ),
			Screen.PixelHeight / 2 - 9, "font", 2 );
		title.Depth -= 1;
		AddEntity( title );

		if ( !Globals.HasPlayedOnce )
		{
			_instructionsText = new( "press space to start", this,
				Screen.PixelWidth / 2 - 70, 60, "font" );
			AddEntity( _instructionsText );
		}
		else
		{
			_lbLines.Clear();
			_lbActive  = true;
			_lbReady   = false;
			_lbWaiting = false;
			_lbIndex   = 0;
			FetchLeaderboard();
		}

		// Set up swap replay (starts after 3s delay).
		_replayingSwaps = Globals.LastNumSwaps > 0;
		_replayIndex = 0;
		_replayWaitTime = 1f;
		_replayTimer = 0f;
		_replayStarted = false;
		_replayDelayTimer = 3f;

		FadeIn( 0.25f );
		AudioManager.MusicPitch = 1f;
		AudioManager.FadeInMusic( 1f );
	}

	public override void Deactivate()
	{
		base.Deactivate();
		_replayingSwaps = false;
		_lbActive = false; // prevent a late-arriving fetch result from going live
	}

	public override void UpdateScene( float deltaTime )
	{
		Screen.Clear( new Color32( 0, 0, 0, 255 ) );

		base.UpdateScene( deltaTime );

		// Swap prompt text between controller and keyboard hint.
		if ( _instructionsText is not null )
		{
			if ( Input.UsingController )
			{
				_instructionsText.Text = "press a to start";
				_instructionsText.PixelX = Screen.PixelWidth / 2 - 56; // 16 chars × 7px / 2
			}
			else
			{
				_instructionsText.Text = "press space to start";
				_instructionsText.PixelX = Screen.PixelWidth / 2 - 70; // 20 chars × 7px / 2
			}
		}

		// Toggle mute with M.
		if ( Input.Pressed( "Mute" ) )
			AudioManager.ToggleMute();

		if ( AudioManager.IsMuted )
			DrawMuteIcon();

		// Space/Z → start game, but not while the ESC screen is covering the menu.
		if ( !_switchingScene && !_hasStartedSwitching
			&& Input.Pressed( "Start" ) )
		{
			SetFadeToBlackCallback( () => SwitchScene( SceneType.Game ) );
			FadeToBlack( 0.25f );
			_hasStartedSwitching = true;
		}

		// Leaderboard scroll tick.
		if ( _lbReady )
		{
			if ( _lbWaiting )
			{
				_lbWaitTimer -= deltaTime;
				if ( _lbWaitTimer <= 0f )
				{
					_lbWaiting = false;
					_lbIndex = 0;
					_lbSpawnTimer = 0f;
				}
			}
			else
			{
				_lbSpawnTimer -= deltaTime;
				if ( _lbSpawnTimer <= 0f )
				{
					SpawnNextLeaderboardEntry();
					_lbSpawnTimer = SPAWN_INTERVAL;
				}
			}
		}

		// Replay swaps from the last game.
		if ( _replayingSwaps )
			HandleSwapReplay( deltaTime );

		HandleTransposing( deltaTime );
		HandleFading( deltaTime );
	}

	// ── Leaderboard ───────────────────────────────────────────────────────

	private async void FetchLeaderboard()
	{
		//Log.Info( $"[Transposer] FetchLeaderboard: fetching '{Globals.LEADERBOARD_STAT}'" );

		var lb = Sandbox.Services.Leaderboards.GetFromStat( Globals.LEADERBOARD_STAT );
		lb.SetAggregationMax();
		lb.SetSortDescending();
		lb.MaxEntries = 100;
		await lb.Refresh();

		//Log.Info( $"[Transposer] Leaderboard refresh done. Entry count: {lb.Entries.Count()}" );

		_lbLines.Clear();

		int rank = 1;
		foreach ( var entry in lb.Entries )
			_lbLines.Add( FormatLine( rank++, entry.DisplayName, (int)entry.Value ) );

		//Log.Info( $"[Transposer] {_lbLines.Count} lines ready." );

		// Deactivate() sets _lbReady=false; if the scene was torn down while we
		// were awaiting, discard the results — UpdateScene is no longer running.
		if ( !_lbActive )
		{
			_lbLines.Clear();
			return;
		}

		_lbReady = true;
		_lbIndex = 0;
		_lbSpawnTimer = 0f;
	}

	private static string SanitizeName( string name )
	{
		name = name.Trim();
		var sb = new System.Text.StringBuilder( name.Length );
		foreach ( char c in name )
		{
			if ( c >= 'a' && c <= 'z' || c >= '0' && c <= '9' )
				sb.Append( c );
			else if ( c >= 'A' && c <= 'Z' )
				sb.Append( (char)(c + 32) );
			else if ( c == ' ' )
				sb.Append( ' ' );
			else
				sb.Append( '?' );
		}
		return sb.ToString();
	}

	private static string FormatLine( int rank, string name, int score )
	{
		name = SanitizeName( name );
		if ( name.Length > 10 ) name = name[..10];
		return rank + ": " + name + ": " + score;
	}

	private void SpawnNextLeaderboardEntry()
	{
		if ( _lbIndex >= _lbLines.Count )
		{
			_lbWaiting = true;
			_lbWaitTimer = LOOP_WAIT;
			return;
		}

		string line = _lbLines[_lbIndex];
		int lineW = (line.Length * 7) - 1;
		int x = Screen.PixelWidth / 2 - lineW / 2;
		int y = -_lbLetterHeight;

		AddEntity( new LeaderboardScrollText( line, this, x, y, "font", _lbIndex + 1 ) );
		_lbIndex++;
	}

	// Letter height of the "font" sprite at scale 1 — used for spawn-below-screen offset.
	// Matches TextDisplay._letterHeight for "font" (which is 9px).
	private const int _lbLetterHeight = 9;

	// ── Swap replay ───────────────────────────────────────────────────────

	/// <summary>
	/// Progressively replay the last game's grid swaps with decreasing delay,
	/// starting 3 seconds after scene activation.
	/// </summary>
	private void HandleSwapReplay( float deltaTime )
	{
		if ( !_replayStarted )
		{
			_replayDelayTimer -= deltaTime;
			if ( _replayDelayTimer > 0f )
				return;
			_replayStarted = true;
		}

		if ( _replayIndex >= Globals.LastNumSwaps )
		{
			_replayingSwaps = false;
			return;
		}

		_replayTimer -= deltaTime;
		if ( _replayTimer <= 0f )
		{
			SwapGridSquares();
			_replayIndex++;

			// Decrease the wait time between each replayed swap so the replay
			// progressively accelerates, bottoming out at 10ms between swaps.
			_replayWaitTime -= 0.025f;
			if ( _replayWaitTime < 0.01f )
				_replayWaitTime = 0.01f;

			_replayTimer = _replayWaitTime;
		}
	}
}