Stages/ScoreStage.cs
using System;
using System.Collections.Generic;

namespace BlockParty;

/// <summary>
/// Score-tally screen shown after a run (CONTINUE leads back to the map, or to the daily hub
/// for a daily run). Port of the original
/// <c>ScoreStage</c>: the five blocks are laid out and their points are counted out one
/// side at a time (with floating "+N" popups), then a time bonus if every block was maxed.
///
/// The original's grey→colour reveal used runtime palette swaps; our block art is baked into
/// PNGs, so we bake the four "tally" tint levels (grey → faint teal → stronger teal → the
/// block's own phase-2 colours, eyes shut throughout) and step a block up a level as its turn
/// begins and as each phase of side buttons is completed. Coroutines are replaced by a
/// precomputed list of timed steps driven from <see cref="Tick"/> (same pattern as the block
/// eye-cycle), and Confirm fast-forwards.
///
/// World-space block sprites are drawn under the stage Root; the running score, floating
/// popups, and the time-bonus banner are Razor (<see cref="ScoreScreen"/>), positioned by
/// projecting world coords into the 1080-reference UI space.
///
/// A run that unlocked a character (a first win with them) also owes the player a reveal: the
/// first RESTART / CONTINUE is queued behind the shared "YOU UNLOCKED" overlay instead of leaving
/// immediately — see <see cref="DeferForUnlock"/> and <see cref="UnlockReveal"/>.
/// </summary>
public sealed class ScoreStage : MenuStageBase
{
	// Original ScoreStage timing.
	private const float DELAY_BEFORE_BLOCKS = 0.10f;
	private const float DELAY_BETWEEN_SIDES = 0.10f;
	private const float DELAY_BEFORE_PHASES = 0.18f;
	private const float DELAY_AFTER_BLOCKS = 0.05f;
	private const float TIME_BONUS_TICK = 0.05f;
	// On a win, hold on the VICTORY! banner + block bounce before the TIME BONUS line drops in.
	private const float DELAY_VICTORY_HOLD = 0.45f;
	// One coin counted out per tick — slower than the time bonus so short coin runs still read.
	private const float COIN_TICK = 0.08f;

	private const float FLOATER_LIFE = 0.9f;
	private const float FLOATER_RISE = 22f;       // logical px / sec
	private const float FLOATER_DISTANCE = 30f;   // offset from block centre toward the side

	private static readonly Vector2 SCORE_WORLD_POS = new Vector2( Arena.WIDTH * 0.5f, Arena.HEIGHT * 0.88f );

	public sealed class Floater
	{
		public Vector2 World;
		public string Text;
		public Color Color;
		public float Age;
	}

	private readonly List<BlockResult> _blocks;
	private readonly List<BlockResult> _tallyBlocks;
	// Precise (un-rounded) elapsed game time in seconds; floored for the visible bonus, its
	// sub-second remainder feeds the leaderboard tiebreaker (see OnTallyComplete).
	private readonly float _gameTime;
	private readonly RunContext _runContext;
	private readonly bool _firstTutorialVictory;
	private readonly bool _isDebugRun;
	private readonly int _coins;
	private readonly bool _levelWasBeatenOnEntry;
	// This run's recorded payload (the local replay banked at submit time), so the footer's replay
	// button can watch the run that was just played. Null when the run wasn't recorded.
	private readonly RunData _runData;
	// Set when this tally is being REBUILT on the way back from the leaderboard / this run's replay:
	// the whole count-out is flushed silently in OnEnter so the player lands on the finished screen.
	private readonly bool _skipAnimation;

	// --- end-of-tally buttons (RESTART / CONTINUE) --------------------------------------
	// Once the tally finishes we show a row of text buttons, driven by the same "smiling cube"
	// selector the menus use. Most normal runs offer RESTART + CONTINUE; a first-time victory starts
	// on CONTINUE, while losses and victories on previously beaten levels start on RESTART. Daily runs
	// and the first Tutorial victory show CONTINUE only.
	public enum ScoreButton { Restart, Continue }
	private readonly List<ScoreButton> _buttons = new();
	private readonly MenuSelectorAnim _buttonSelector = new();
	private int _selectedButton;

	/// <summary>Buttons shown at the bottom once the tally finishes (read by the Razor screen).</summary>
	public IReadOnlyList<ScoreButton> Buttons => _buttons;
	/// <summary>Index of the currently selected button.</summary>
	public int SelectedButton => _selectedButton;
	/// <summary>Selector slide position in button-index units (read by the Razor screen).</summary>
	public float ButtonSelectorPos => _buttonSelector.Pos;
	/// <summary>Selector sprite-sheet frame (0..6), read by the Razor screen.</summary>
	public int ButtonSelectorFrame => _buttonSelector.Frame;

	private readonly List<TallyDisplay> _displays = new();
	private readonly List<Vector2> _positions = new();

	private struct Step { public float Wait; public Action Act; }
	private readonly List<Step> _steps = new();
	private int _cursor;
	private float _stepTimer;
	private bool _fast;

	// Fast-forward (pressing Confirm) flushes every remaining step in a single frame, which
	// would otherwise fire all the queued tally sounds at once into a deafening pile. Cap how
	// many tally sfx may play once fast-forwarding so the skip gives a single blip of feedback.
	private const int FAST_SFX_BUDGET = 1;
	private int _fastSfxBudget = FAST_SFX_BUDGET;

	// State the Razor screen reads.
	public int Score { get; private set; }
	public bool Finished { get; private set; }
	public string Banner { get; private set; }
	/// <summary>The coin count-out line ("COINS: +n"), shown under <see cref="Banner"/> once the
	/// coin group starts; empty for coin-less runs.</summary>
	public string CoinBanner { get; private set; }
	/// <summary>Elapsed scored run time in seconds. Stops when the run entered its game-over beat.</summary>
	public float GameTime => _gameTime;
	/// <summary>Whether this run completed every block.</summary>
	public bool Victory { get; }
	// True once a winning tally reveals the VICTORY! banner; the screen draws it one line above
	// where the TIME BONUS banner appears.
	public bool ShowVictory { get; private set; }
	// Loss counterpart: true once a failed tally reveals the YOU DIED banner in the same slot.
	public bool ShowDeath { get; private set; }
	public IReadOnlyList<Floater> Floaters => _floaters;
	public Vector2 ScoreWorldPos => SCORE_WORLD_POS;
	/// <summary>The character this run was played as (null/unknown → Original), for the icon the
	/// screen shows above the score.</summary>
	public CharacterDef RunCharacter => Characters.Get( _runContext.CharacterId );

	private readonly List<Floater> _floaters = new();
	private ScoreScreen _screen;

	public ScoreStage( GameManager manager, List<BlockResult> blocks, float gameTime, RunContext runContext = default,
		bool firstTutorialVictory = false,
		bool levelWasBeatenOnEntry = false, bool isDebugRun = false, int coinsCollected = 0,
		RunData runData = null, bool skipAnimation = false ) : base( manager )
	{
		_blocks = blocks;
		_tallyBlocks = blocks.FindAll( block => block.ScoreStartPhase < Block.NUM_PHASES - 1 );
		_gameTime = gameTime;
		_runContext = runContext == default ? RunContext.Normal : runContext;
		_firstTutorialVictory = firstTutorialVictory;
		_isDebugRun = isDebugRun;
		_coins = coinsCollected;
		_levelWasBeatenOnEntry = levelWasBeatenOnEntry;
		_runData = runData;
		_skipAnimation = skipAnimation;

		// The first Tutorial victory omits restart. A daily run offers it only while the day is still
		// today AND has attempts remaining (the restart
		// consumes one; a DEBUG daily restarts freely) — DoRestart re-checks both live, so a score
		// screen held across UTC midnight can't relaunch onto the closed day's board.
		bool dailyCanRestart = _runContext.IsDaily && (_isDebugRun || DailyRestartAllowed( _runContext ));
		if ( (!_runContext.IsDaily || dailyCanRestart) && !_firstTutorialVictory )
			_buttons.Add( ScoreButton.Restart );
		_buttons.Add( ScoreButton.Continue );

		Victory = ScoreCalc.AllMaxPhase( _blocks );
		if ( Victory && !levelWasBeatenOnEntry )
			_selectedButton = _buttons.IndexOf( ScoreButton.Continue );
	}

	/// <summary>The tally sits on the dark transition fill grown over the game on game-over, so the
	/// wipe square appears to persist as the background rather than revealing the playfield.</summary>
	public override bool CoveredBackground => true;

	protected override void OnEnter()
	{
		// The run's score + replay were already submitted by GameStage the moment the run was decided
		// (the game-over beat ending, or an earlier restart/home bail after a death) — see
		// GameStage.SubmitRunOnce. This stage is now purely the visual tally; it never submits.

		var go = CreateUiRoot();
		_screen = go.Components.Create<ScoreScreen>();
		_screen.Stage = this;

		BuildLayout();
		BuildSteps();

		_stepTimer = _steps.Count > 0 ? _steps[0].Wait : 0f;
		if ( _steps.Count == 0 )
			OnTallyComplete();

		// Coming back from the leaderboard / this run's replay: play the whole tally out instantly and
		// silently (no sfx budget, no leftover floaters) so we land exactly where we left — finished,
		// with RESTART / CONTINUE waiting.
		if ( _skipAnimation )
		{
			_fast = true;
			_fastSfxBudget = 0;
			AdvanceTally( 0f );
			_floaters.Clear();
			return;
		}

		Audio.PlaySfx( SfxType.EnterGame, Arena.Center );
	}

	// --- layout -------------------------------------------------------------------------
	// The row shapes are driven by the TALLIED count (_tallyBlocks, not the level's block count — a
	// block a level authored as already complete scores nothing and gets no row), so e.g. a six-block
	// level whose four pins start maxed lays out as two. One or two blocks form a compact upper row,
	// three blocks arc around the banner, and 4+ split over two rows. Five-block rows use the full
	// arena width so their 40-unit sprites cannot overlap. The score always occupies the top lane.
	private void BuildLayout()
	{
		float w = Arena.WIDTH, h = Arena.HEIGHT;
		int count = _tallyBlocks.Count;

		void AddRow( int rowCount, float y )
		{
			float edge = rowCount >= 5 ? 0.10f : 0.20f;
			for ( int i = 0; i < rowCount; i++ )
			{
				float t = rowCount == 1 ? 0.5f : MathX.Lerp( edge, 1f - edge, i / (float)(rowCount - 1) );
				_positions.Add( new Vector2( w * t, y ) );
			}
		}

		if ( count == 1 )
		{
			_positions.Add( new Vector2( w * 0.50f, h * 0.68f ) );
		}
		else if ( count == 3 )
		{
			_positions.Add( new Vector2( w * 0.20f, h * 0.50f ) );
			_positions.Add( new Vector2( w * 0.50f, h * 0.70f ) );
			_positions.Add( new Vector2( w * 0.80f, h * 0.50f ) );
		}
		else if ( count == 2 )
		{
			_positions.Add( new Vector2( w * 0.38f, h * 0.68f ) );
			_positions.Add( new Vector2( w * 0.62f, h * 0.68f ) );
		}
		else
		{
			AddRow( count / 2, h * 0.30f );
			AddRow( count - count / 2, h * 0.70f );
		}

		for ( int i = 0; i < _tallyBlocks.Count; i++ )
		{
			// Blocks stay in their faded grey, eyes-closed look for the whole tally; only the
			// side buttons being counted light up (handled by PressSide / SetSidesOut).
			_displays.Add( new TallyDisplay( this, _tallyBlocks[i], _positions[i] ) );
		}
	}

	// --- tally sequence -----------------------------------------------------------------
	private void AddStep( float wait, Action act ) => _steps.Add( new Step { Wait = wait, Act = act } );

	private void BuildSteps()
	{
		for ( int i = 0; i < _displays.Count; i++ )
		{
			int bi = i;
			var br = _tallyBlocks[i];

			// Begin this block's tally with a blip and a quick scale bounce so it's clear which block
			// the sound refers to — otherwise a block with no sides pressed gives no visual cue at all.
			// The body stays in its dim grey "asleep" look the whole time; progression is shown by the
			// lit side buttons and, once a phase is completed, the block's real face (SetPhaseFace) —
			// not by recolouring the body.
			AddStep( DELAY_BEFORE_BLOCKS, () =>
			{
				TallySfx( SfxType.MenuBlip, _positions[bi] );
				_displays[bi].StartBounce();
			} );

			for ( int phase = 0; phase <= br.Phase && phase < Block.NUM_PHASES; phase++ )
			{
				int ph = phase;

				// Sides scored this phase: all four if the phase was fully completed, else the
				// sides actually pressed when the run ended (shared with the submitted score via
				// ScoreCalc.ScoredSides so the tally and the leaderboard number can't diverge).
				var sides = ScoreCalc.ScoredSides( br, phase );

				AddStep( DELAY_BEFORE_PHASES, () =>
				{
					// Pop the buttons back out for the new phase only if something was pressed in it.
					// If the block reached phase 1 but pressed nothing, the four buttons that took it
					// up stay depressed (so the progress reads instead of the buttons looking reset).
					if ( ph == 1 && br.ScoreStartPhase < 1 && sides.Count > 0 ) _displays[bi].SetSidesOut( 1 );
				} );

				if ( phase == 1 )
					AddStep( DELAY_BETWEEN_SIDES, null );

				for ( int j = 0; j < sides.Count; j++ )
				{
					int jj = j;
					var dir = sides[j];
					bool completes = ScoreCalc.CompletesPhase( br, ph, jj, sides.Count );

					AddStep( DELAY_BETWEEN_SIDES, () =>
					{
						if ( ph < Block.NUM_PHASES - 1 )
							_displays[bi].PressSide( dir, ph );

						int amount = ScoreCalc.SideAmount( ph, completes );
						Score += amount;
						AddFloater( _positions[bi], dir, amount, ph, completes );
						TallySfx( CompleteSfx( ph, completes ), _positions[bi] );

						// Completing a phase reveals the block's real face for the phase it just
						// reached: phase-0 done -> phase-1 face; phase-1 done -> phase-2 face + max sides.
						if ( completes )
						{
							if ( ph == 0 )
								_displays[bi].SetPhaseFace( 1 );
							else if ( ph == 1 )
							{
								_displays[bi].SetPhaseFace( 2 );
								_displays[bi].SetSidesOut( 2 ); // all sides go to "max" yellow
							}
						}
					} );
				}
			}

			if ( br.Phase >= Block.NUM_PHASES - 1 )
				AddStep( 0f, () => _displays[bi].SetSidesOut( 2 ) );

			AddStep( DELAY_AFTER_BLOCKS, null );
		}

		// Time bonus only on a full win.
		if ( Victory )
		{
			int bonus = ScoreCalc.TimeBonus( _gameTime );

			// Win moment: once every block's sides are tallied, reveal VICTORY! (one line above
			// where TIME BONUS will land), flip every block to its death face — the expression they
			// wear in the live all-max win throes — and give them a quick celebratory scale bounce.
			AddStep( DELAY_BEFORE_PHASES, () =>
			{
				ShowVictory = true;
				TallySfx( SfxType.BlockPhaseReachMax, Arena.Center );
				foreach ( var d in _displays )
				{
					d.SetDeathFace();
					d.StartBounce();
				}
			} );

			// Hold on the bounce, then drop in the TIME BONUS line under the VICTORY! banner.
			AddStep( DELAY_VICTORY_HOLD, () => Banner = $"TIME BONUS: +{bonus}" );

			int remaining = bonus;
			while ( remaining > 0 )
			{
				int inc = Math.Max( (int)MathF.Floor( remaining * 0.25f ), 1 );
				remaining -= inc;
				int add = inc;
				AddStep( TIME_BONUS_TICK, () =>
				{
					Score += add;
					TallySfx( SfxType.AddScoreTimeBonus, Arena.Center );
				} );
			}
		}
		else
		{
			// Loss moment: same beat as the VICTORY! reveal, but a quiet YOU DIED in its place.
			AddStep( DELAY_BEFORE_PHASES, () => ShowDeath = true );
		}

		// Coins, counted out one per step so the count AND the contribution visibly rise with the
		// total. Appended last on purpose: after the time bonus on a victory, straight after the
		// block sides otherwise. The final Score must equal ScoreCalc.Compute(blocks, time, coins) —
		// the submitted value — which is why the per-coin add is the same COIN_SCORE constant.
		if ( _coins > 0 )
		{
			AddStep( DELAY_VICTORY_HOLD, () =>
			{
				CoinBanner = "COINS: +0";
				TallySfx( SfxType.MenuBlip, ScoreWorldPos );
			} );

			for ( int i = 1; i <= _coins; i++ )
			{
				int ci = i;
				AddStep( COIN_TICK, () =>
				{
					Score += ScoreCalc.COIN_SCORE;
					CoinBanner = $"COINS: +{ci}";
					TallySfx( SfxType.CoinTally, ScoreWorldPos );
				} );
			}
		}
	}

	private static SfxType CompleteSfx( int phase, bool completes )
	{
		if ( phase == 0 ) return completes ? SfxType.AddScorePhase0 : SfxType.AddScoreFinishPhase0;
		return completes ? SfxType.AddScorePhase1 : SfxType.AddScoreFinishPhase1;
	}

	// Plays a tally sound, but once the player has fast-forwarded the remaining steps all run
	// in one frame; allow only FAST_SFX_BUDGET of them through so they don't stack into noise.
	private void TallySfx( SfxType type, Vector2 pos )
	{
		if ( _fast )
		{
			if ( _fastSfxBudget <= 0 ) return;
			_fastSfxBudget--;
		}
		Audio.PlaySfx( type, pos );
	}

	private void AddFloater( Vector2 blockPos, Direction dir, int amount, int phase, bool completes )
	{
		_floaters.Add( new Floater
		{
			World = blockPos + Globals.GetVectorForDirection( dir ) * FLOATER_DISTANCE,
			Text = $"+{amount}",
			Color = FloaterColor( phase, completes ),
			Age = 0f,
		} );
	}

	private static Color FloaterColor( int phase, bool completes )
	{
		// Mirrors the original TallyScore swatches.
		if ( phase == 0 )
			return completes ? Rgb( 155, 165, 255 ) : Rgb( 115, 120, 208 ); // lighter blue / blue
		// Phase-2 sides: the +20s take the reddish tint (matching the red buttons) and the final
		// +40 takes the yellowish tint (matching the final max side button).
		return completes ? Rgb( 221, 221, 153 ) : Rgb( 200, 90, 85 );        // yellow / red
	}

	private static Color Rgb( int r, int g, int b ) => new Color( r / 255f, g / 255f, b / 255f );

	// --- update -------------------------------------------------------------------------
	public override void Tick( float dt )
	{
		base.Tick( dt );

		// The unlock reveal owns all input while it's up (GameManager eats the frame's menu edges for
		// it, but the Restart key below is read straight from the engine), and the exit it was handed
		// fires when it finishes fading. Keep the tally's cosmetics moving behind it.
		if ( Manager.UnlockReveal.IsActive )
		{
			AgeFloaters( dt );
			foreach ( var d in _displays )
				d.TickBounce( dt );
			return;
		}

		// The Restart key re-rolls a fresh run at any point during the score stage, matching the
		// in-run R: dailies accept it while restartable (today + attempts remaining; debug freely).
		// Results which intentionally offer Continue only ignore it.
		if ( !IsFadingOut && !_firstTutorialVictory
			&& (!_runContext.IsDaily || _isDebugRun || DailyRestartAllowed( _runContext ))
			&& Input.Pressed( "Restart" ) )
		{
			DoRestart();
			return;
		}

		if ( InputState.ConfirmJust )
		{
			Confirm();
			if ( Finished ) return;
		}

		// Once the tally finishes, Up/Down move the selector between the bottom buttons.
		if ( Finished && !IsFadingOut )
		{
			if ( InputState.NavDownJust ) NavigateButton( 1 );
			if ( InputState.NavUpJust ) NavigateButton( -1 );
		}

		AdvanceTally( dt );
		AgeFloaters( dt );

		foreach ( var d in _displays )
			d.TickBounce( dt );

		_buttonSelector.Tick( dt, _selectedButton );
	}

	// --- unlock reveal --------------------------------------------------------------------
	/// <summary>Pay off any "YOU UNLOCKED" reveal this run owes before leaving. Returns true when
	/// <paramref name="exit"/> was handed to the reveal (the caller must then do nothing) — it runs
	/// once the player has dismissed the overlay and it has faded out. False when nothing is owed and
	/// the caller should leave as normal. The queue is persisted, so a reveal skipped here (the player
	/// bailed out of the won run before the tally) is still delivered on the next menu screen.</summary>
	private bool DeferForUnlock( Action exit )
		// The IsActive arm is belt-and-braces: a reveal already on screen has its own queued exit, so
		// swallow this one rather than leaving the tally out from under a live overlay.
		=> Manager.UnlockReveal.IsActive || Manager.UnlockReveal.ShowPending( exit );

	/// <summary>Fast-forward the tally, or activate the selected button once it has finished.</summary>
	public void Confirm()
	{
		// A click landing while the reveal is up belongs to the reveal. Its own overlay swallows the
		// click before this ever runs; this is the backstop.
		if ( Manager.UnlockReveal.IsActive )
		{
			Manager.UnlockReveal.Dismiss();
			return;
		}

		if ( Finished )
		{
			ActivateSelectedButton();
			return;
		}
		if ( !_fast )
			// One crisp "skip" blip as the player confirms, pitched up so it reads as a
			// fast-forward rather than another tally tick. The flush's own sfx are throttled.
			Audio.PlaySfx( SfxType.MenuBlip, Arena.Center, pitch: 1.4f );
		_fast = true;
	}

	// --- end-of-tally button navigation -------------------------------------------------
	/// <summary>Keyboard/pad navigation between the bottom buttons: move if possible, else wobble.</summary>
	private void NavigateButton( int delta )
	{
		_selectedButton = MenuNav.Step( _selectedButton, delta, _buttons.Count, _buttonSelector );
	}

	/// <summary>Called by the screen on hover to sync the selected button (mirrors the menu screens).</summary>
	public void SelectButton( int index )
	{
		int next = Math.Clamp( index, 0, _buttons.Count - 1 );
		if ( next == _selectedButton )
			return;

		_selectedButton = next;
		_buttonSelector.PlayMove();
		Audio.PlaySfx( SfxType.MenuBlip );
	}

	/// <summary>Called by the screen on click: select the clicked button, then activate it.</summary>
	public void ActivateButton( int index )
	{
		SelectButton( index );
		ActivateSelectedButton();
	}

	private void ActivateSelectedButton()
	{
		if ( IsFadingOut )
			return;

		_buttonSelector.PlayWobble();

		if ( _buttons[_selectedButton] == ScoreButton.Restart )
			DoRestart();
		else
			Continue();
	}

	/// <summary>Whether a non-debug daily run may relaunch: the day must still be TODAY (live check —
	/// see <see cref="GameManager.StartDailyChallenge"/>'s matching backstop) with attempts remaining.</summary>
	private static bool DailyRestartAllowed( RunContext context )
		=> context.DailyId == DailyChallenge.TodayId
			&& DailyChallengeProgress.HasAttemptsRemaining( context.DailyId, DailyLevels.Get( context.DailyId )?.MaxAttempts );

	/// <summary>Attempts line shown on a daily run's tally ("2/5 ATTEMPTS LEFT", or "UNLIMITED
	/// ATTEMPTS" on a day with no limit); null on non-daily runs. With daily_debug_nav on this reads
	/// the display-only debug counter instead of real progress (see
	/// <see cref="DailyChallengeProgress.DisplayedAttemptsUsed"/>).</summary>
	public string DailyAttemptsText
	{
		get
		{
			if ( !_runContext.IsDaily )
				return null;
			if ( DailyLevels.Get( _runContext.DailyId )?.MaxAttempts is not int max )
				return "UNLIMITED ATTEMPTS";

			int left = Math.Max( 0, max - DailyChallengeProgress.DisplayedAttemptsUsed( _runContext.DailyId ) );
			return $"{left}/{max} ATTEMPTS LEFT";
		}
	}

	/// <summary>Re-roll a fresh run on the same level. The run's score was already banked by
	/// GameStage, so this just starts a new game. A daily restart re-guards and consumes one of the
	/// day's attempts (see <see cref="GameManager.StartDailyChallenge"/>); a DEBUG daily restarts freely.</summary>
	private void DoRestart()
	{
		if ( _firstTutorialVictory )
			return;

		if ( _runContext.IsDaily )
		{
			if ( !_isDebugRun && !DailyRestartAllowed( _runContext ) )
				return;

			// Show the unlock first; this same method re-runs (guards and all) after the fade.
			if ( DeferForUnlock( DoRestart ) )
				return;

			Audio.PlaySfx( SfxType.MenuStart, Arena.Center );
			if ( _isDebugRun )
				Manager.StartDailyChallengeDebug( _runContext.DailyId );
			else
				Manager.StartDailyChallenge( _runContext.DailyId );
			return;
		}

		if ( DeferForUnlock( DoRestart ) )
			return;

		Audio.PlaySfx( SfxType.MenuStart, Arena.Center );
		// Carry the run's character forward so a per-run override (e.g. a repro ConCmd's forced
		// character) survives a score-screen restart, matching the in-run R restart path.
		Manager.StartLevel( _runContext.LevelId, _runContext.CharacterId );
	}

	/// <summary>Leave the tally: a daily run returns to the daily hub; a workshop run returns to the
	/// workshop browser; a normal run returns straight to the level-select map it was launched from
	/// (win or loss — the map's footer trophy button reaches the full leaderboard).</summary>
	private void Continue()
	{
		if ( DeferForUnlock( Continue ) )
			return;

		Audio.PlaySfx( SfxType.MenuStart, Arena.Center );
		FadeToStage( _runContext.IsDaily
			? new DailyChallengeStage( Manager, _runContext.DailyId )
			: _runContext.Level?.IsWorkshop == true
				? new WorkshopStage( Manager )
				: new LevelSelectStage( Manager ) );
	}

	// --- footer shortcuts (leaderboard / this run's replay) -------------------------------
	/// <summary>Snapshot of everything this tally was built from, so a screen opened from it can
	/// rebuild it (already counted out) when the player backs out.</summary>
	public ScoreReturn Snapshot() => new ScoreReturn
	{
		Blocks = _blocks,
		GameTime = _gameTime,
		Context = _runContext,
		FirstTutorialVictory = _firstTutorialVictory,
		LevelWasBeatenOnEntry = _levelWasBeatenOnEntry,
		IsDebugRun = _isDebugRun,
		Coins = _coins,
		RunData = _runData,
	};

	/// <summary>Whether the footer's trophy button is offered: this run has a real board behind it
	/// (daily runs read their board on the daily page, debug runs never submit).</summary>
	public bool CanViewLeaderboard => Finished && !_runContext.IsDaily && !_isDebugRun;

	/// <summary>Whether the footer's replay button is offered — the run we just played was recorded.</summary>
	public bool CanWatchReplay => Finished && _runData?.CanReplay == true;

	/// <summary>The Steam Workshop page of the level just played (footer thumbs-up link); null unless
	/// this was a workshop level and the tally has finished.</summary>
	public string WorkshopItemUrl => Finished ? WorkshopLevels.ItemPageUrlFor( _runContext.Level?.Id ) : null;

	/// <summary>Trophy button: open this level's full board. BACK there returns to this tally.</summary>
	public void ViewLeaderboard()
	{
		if ( IsFadingOut || !CanViewLeaderboard )
			return;
		if ( DeferForUnlock( ViewLeaderboard ) )
			return;

		Audio.PlaySfx( SfxType.MenuStart, Arena.Center );
		FadeToStage( new HighscoreStage( Manager, levelId: _runContext.Level?.Id, returnScore: Snapshot() ) );
	}

	/// <summary>Replay button: watch the run that was just played (its recorded payload, attributed to
	/// us). Exiting the replay returns to this tally.</summary>
	public void WatchReplay()
	{
		if ( IsFadingOut || !CanWatchReplay )
			return;
		if ( DeferForUnlock( WatchReplay ) )
			return;

		Audio.PlaySfx( SfxType.MenuStart, Arena.Center );
		long steamId = (long)Game.SteamId;
		Manager.StartReplay( _runData, new ReplaySubmitter( steamId, new Friend( steamId ).Name, "", DateTimeOffset.UtcNow ),
			returnScore: Snapshot(), returnToScore: true );
	}

	private void AdvanceTally( float dt )
	{
		if ( _cursor >= _steps.Count ) return;

		_stepTimer -= _fast ? 1000f : dt;
		while ( _cursor < _steps.Count && _stepTimer <= 0f )
		{
			_steps[_cursor].Act?.Invoke();
			_cursor++;
			_stepTimer += _cursor < _steps.Count ? _steps[_cursor].Wait : 0f;
		}

		if ( _cursor >= _steps.Count && !Finished )
			OnTallyComplete();
	}

	private void OnTallyComplete()
	{
		Finished = true;
	}

	private void AgeFloaters( float dt )
	{
		for ( int i = _floaters.Count - 1; i >= 0; i-- )
		{
			var f = _floaters[i];
			f.Age += dt;
			f.World = new Vector2( f.World.x, f.World.y + FLOATER_RISE * dt );
			if ( f.Age >= FLOATER_LIFE )
				_floaters.RemoveAt( i );
		}
	}

	// --- one block's display sprites ----------------------------------------------------
	public sealed class TallyDisplay
	{
		private readonly GameObject _go;
		private readonly SpriteRenderer _face, _mouth, _eyes, _eyebrows;
		private readonly Dictionary<Direction, SpriteRenderer> _sides = new();
		private static readonly Vector2 SIZE = new Vector2( 40, 40 );

		// Quick scale "punch" played on the victory reveal: snap up to BOUNCE_PEAK over BOUNCE_UP,
		// then ease back to 1x over BOUNCE_DOWN. _bounceTime < 0 means no bounce is running.
		private const float BOUNCE_PEAK = 1.15f;
		private const float BOUNCE_UP = 0.05f;
		private const float BOUNCE_DOWN = 0.10f;
		private float _bounceTime = -1f;

		public TallyDisplay( ScoreStage stage, BlockResult br, Vector2 pos )
		{
			var go = stage.CreateChild( $"Tally_{br.Type}" );
			_go = go;
			go.WorldPosition = new Vector3( MathF.Round( pos.x ), MathF.Round( pos.y ), Globals.DepthToZ( Globals.DEPTH_BLOCK ) );

			// Start in the faded "asleep" look: flat, dim greyscale phase-0 shapes with closed eyes
			// (baked tally_* anims). The fade is baked into the grey art and fully opaque, so the
			// lit/coloured pressed buttons read against it without any background bleeding through.
			_face = SpriteLayer.Add( go, br.SpritePath, SIZE, "tally_face", childOrder: 0 );
			_mouth = SpriteLayer.Add( go, br.SpritePath, SIZE, "tally_mouth", childOrder: 2 );
			_eyes = SpriteLayer.Add( go, br.SpritePath, SIZE, "tally_eyes", childOrder: 3 );
			_eyebrows = SpriteLayer.Add( go, br.SpritePath, SIZE, "tally_eyebrows", childOrder: 4 );

			foreach ( var d in Globals.GetAllDirections() )
				_sides[d] = SpriteLayer.Add( go, "sprites/sides.sprite", SIZE, $"out0dark_{Globals.GetStringForDirection( d )}", childOrder: 1 );

			// Authored starting progress the run began with: open on the phase it was authored at with
			// its pre-pressed buttons already down, so the tally only counts out what the player earned.
			// A block authored as already complete gets no display at all (see ScoreStage._tallyBlocks),
			// so ScoreStartPhase here is always below max phase.
			if ( br.ScoreStartPhase > 0 )
			{
				SetPhaseFace( br.ScoreStartPhase );
				SetSidesOut( br.ScoreStartPhase );
			}
			foreach ( var d in Globals.GetAllDirections() )
				if ( br.WasPressedAtScoreStart( d ) ) PressSide( d, br.ScoreStartPhase );
		}

		/// <summary>Pop every side out for the given phase (0/1) or max (2) — plays the pop-out
		/// animation so the replay mirrors the live block's buttons.</summary>
		public void SetSidesOut( int phase )
		{
			string add = phase >= 2 ? "maxadd" : phase == 1 ? "out1darkadd" : "out0darkadd";
			foreach ( var d in Globals.GetAllDirections() )
				_sides[d].PlayAnimation( $"{add}_{Globals.GetStringForDirection( d )}" );
		}

		public void PressSide( Direction dir, int phase )
		{
			string state = phase == 0 ? "pressed0" : "pressed1";
			_sides[dir].PlayAnimation( $"{state}_{Globals.GetStringForDirection( dir )}" );
		}

		/// <summary>Reveal the block's real, full-colour face for a phase it has reached (replacing
		/// the faded grey tally art). The eyes use a fixed forward ("down") gaze so the look matches
		/// the static row graphic (sprites/ui/blocks/{type}_p{phase}.png).</summary>
		public void SetPhaseFace( int phase )
		{
			_face.PlayAnimation( $"face_{phase}" );
			_mouth.PlayAnimation( $"mouth_{phase}" );
			_eyes.PlayAnimation( $"eyes_down_{phase}" );
			_eyebrows.PlayAnimation( $"eyebrows_{phase}" );
		}

		/// <summary>Switch to the death face shown once a winning tally completes: the maxed
		/// (phase-2) face with the dead eyes/mouth/brows (mirrors <see cref="Block.Die"/>).</summary>
		public void SetDeathFace()
		{
			_face.PlayAnimation( "face_2" );
			_eyes.PlayAnimation( "eyes_dead" );
			_mouth.PlayAnimation( "mouth_dead" );
			_eyebrows.PlayAnimation( "eyebrows_dead" );
		}

		/// <summary>Kick off the quick victory scale punch (see BOUNCE_* constants).</summary>
		public void StartBounce() => _bounceTime = 0f;

		/// <summary>Advance the scale punch; a no-op once the bounce has finished (or never started).</summary>
		public void TickBounce( float dt )
		{
			if ( _bounceTime < 0f ) return;

			_bounceTime += dt;

			float scale;
			if ( _bounceTime < BOUNCE_UP )
				scale = MathX.Lerp( 1f, BOUNCE_PEAK, _bounceTime / BOUNCE_UP );
			else if ( _bounceTime < BOUNCE_UP + BOUNCE_DOWN )
				scale = MathX.Lerp( BOUNCE_PEAK, 1f, (_bounceTime - BOUNCE_UP) / BOUNCE_DOWN );
			else
			{
				scale = 1f;
				_bounceTime = -1f; // done
			}

			_go.LocalScale = scale;
		}
	}
}

/// <summary>
/// Everything a finished <see cref="ScoreStage"/> was built from. Carried by the screens the tally's
/// footer buttons open (the full leaderboard, and this run's replay) so their BACK rebuilds the tally
/// with its count-out already played out — the player lands back on RESTART / CONTINUE.
/// </summary>
public sealed class ScoreReturn
{
	public List<BlockResult> Blocks;
	public float GameTime;
	public RunContext Context;
	public bool FirstTutorialVictory;
	public bool LevelWasBeatenOnEntry;
	public bool IsDebugRun;
	public int Coins;
	public RunData RunData;

	public ScoreStage Create( GameManager manager ) => new ScoreStage( manager, Blocks, GameTime, Context,
		FirstTutorialVictory, LevelWasBeatenOnEntry, IsDebugRun, Coins, RunData, skipAnimation: true );
}