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

namespace BlockParty;

/// <summary>
/// Level-select "map": an authored graph (see <see cref="LevelMap"/>) of a single upward spine of
/// major levels, each with an optional grid of side levels. The selector cube highlights one node;
/// arrows walk the grid, Confirm starts the level (if unlocked), Back returns to the title. A soft
/// "camera" follows the highlighted node, clamped to the central square so the bottom node sits near
/// the bottom rather than being centred over empty space. The highlighted node is remembered across
/// sessions (<see cref="LevelProgress"/>).
/// </summary>
public sealed class LevelSelectStage : MenuStageBase
{
	// --- Camera-follow tuning (1080-ref px) --------------------------------------------------------
	// The visible level-select area is the central 1080x1080 square (the game's fixed square fills the
	// screen height; overflow outside it is clipped). VIEW_HALF is half that square on each axis.
	private const float VIEW_HALF = 540f;
	// Padding kept between the outermost nodes and the square edges when the map is bottom/edge-anchored
	// (content smaller than the view, or scrolled to a limit). Bottom pad clears the leaderboard FOOTER
	// (see LevelSelectScreen .footer, ~340px) plus the selector cube; top pad clears the header bar.
	private const float PAD_BOTTOM = 440f;
	private const float PAD_TOP = 220f;
	private const float PAD_X = 160f;
	// How fast the camera + selector cube chase their targets (per second, exp-style lerp factor).
	private const float FOLLOW_LERP = 12f;
	private const float UNLOCK_REVEAL_TIME = 0.7f;
	private const float CAMERA_SETTLE_DISTANCE = 2f;
	private const float MAP_CLIP_INSET = 11f;
	private const float MAJOR_NODE_HALF_SIZE = 66f;
	private const float SIDE_NODE_HALF_SIZE = 42f;
	private const float BLOCKED_FEEDBACK_DURATION = 0.24f;
	private const float WHEEL_MOVE_SFX_MIN_INTERVAL = 0.09f;
	private const float WHEEL_ERROR_SFX_MIN_INTERVAL = 0.25f;

	// ================================ DEBUG ================================
	// Right-click a node to toggle its beaten flag, so map gating/unlocks can be tested without
	// actually clearing levels. Editor-only: standalone builds never get the toggle.
	private static readonly bool DEBUG_NODE_TOGGLE = Game.IsEditor;
	// =======================================================================

	private MapNode _selected;
	private readonly MenuSelectorAnim _selector = new();

	// Hold-to-repeat for map navigation (one per axis so vertical/horizontal repeat independently),
	// sharing the same cadence as every other menu (see NavRepeater). Tapping steps once; holding a
	// direction long enough starts auto-walking the grid.
	private readonly NavRepeater _navVertical = new();
	private readonly NavRepeater _navHorizontal = new();

	// Bumped whenever beaten-state changes on this screen (the DEBUG toggle) so the Razor panel
	// repaints node/line colours — BuildHash otherwise doesn't sample beaten state (it's constant for
	// a normal visit, only changing after a run when the stage is recreated).
	private int _revision;

	// Soft-followed camera centre (map space) and selector-cube position (map space), both lerped.
	private Vector2 _cam;
	private Vector2 _selectorPos;
	private MapNode _blockedFeedbackNode;
	private Vector2 _blockedFeedbackDirection;
	private float _blockedFeedbackTimer;
	private float _wheelSfxCooldown;

	private LevelSelectScreen _screen;
	private readonly List<MapNode> _unlockQueue = new();
	private readonly HashSet<string> _presentationLockedLevelIds = new();
	private MapNode _unlockingNode;
	private UnlockRevealPhase _unlockRevealPhase;
	private float _unlockRevealTimer;
	private Vector2? _unlockCameraTarget;
	private bool _unlockSequenceStarted;

	private enum UnlockRevealPhase
	{
		None,
		Focusing,
		Revealing,
		Restoring,
	}

	// --- Razor-facing accessors --------------------------------------------------------------------
	public System.Collections.Generic.IReadOnlyList<MapNode> Nodes => LevelMap.Nodes;
	public System.Collections.Generic.IReadOnlyList<MapConnection> Connections => LevelMap.Connections;
	public MapNode Selected => _selected;
	public float CamX => _cam.x;
	public float CamY => _cam.y;
	public float SelectorX => _selectorPos.x;
	public float SelectorY => _selectorPos.y;
	public int SelectorFrame => _selector.Frame;
	public bool BlockedFeedbackActive => _blockedFeedbackTimer > 0f;
	public MapNode BlockedFeedbackNode => BlockedFeedbackActive ? _blockedFeedbackNode : null;
	public Vector2 BlockedFeedbackDirection => BlockedFeedbackActive ? _blockedFeedbackDirection : Vector2.Zero;
	public float BlockedFeedbackProgress => Math.Clamp( 1f - _blockedFeedbackTimer / BLOCKED_FEEDBACK_DURATION, 0f, 1f );
	/// <summary>True while the map still owes or is playing its own newly-unlocked-node presentation
	/// (camera focus → reveal → restore). It plays itself out on a timer, so anything that would cover
	/// the map — e.g. the "YOU UNLOCKED" character overlay — waits for it
	/// (see <c>GameManager.TickUnlockReveal</c>). The not-yet-started window counts: the sequence waits
	/// for the entry wipe to finish, and the overlay is polled BEFORE this stage ticks, so it would
	/// otherwise claim that very frame and hide the whole presentation.</summary>
	public bool UnlockPresentationActive
		=> !_unlockSequenceStarted || _unlockRevealPhase != UnlockRevealPhase.None;

	/// <summary>Repaint token for the Razor panel (see <see cref="_revision"/>).</summary>
	public int Revision => _revision;

	/// <summary>How long the map's music takes to fade up to full when the screen opens.</summary>
	private const float MusicFadeInSeconds = 0.5f;

	// Off for now: every other screen/song change cuts in hard, so fading only here reads inconsistent.
	// Flip to true to re-enable (the fade plumbing lives in Audio.FadeInMusic / TickMusicFade).
	private const bool MusicFadeInEnabled = false;

	// Song changes wait for the highlight to settle: scrolling fast through nodes shouldn't kick off a
	// song change per step, only the node we actually stop on.
	private const float MusicSwitchDelaySeconds = 0.1f;
	private LevelDef _pendingMusicLevel;
	private bool _musicSwitchPending;
	private float _musicSwitchTimer;

	/// <summary>Fade the map's music up from silence on entry. Set only when arriving from the main
	/// menu — returning from a run/tally keeps the music at full volume.</summary>
	public bool FadeInMusic { get; init; }

	public LevelSelectStage( GameManager manager ) : base( manager ) { }

	protected override void OnEnter()
	{
		SpawnBackgroundBlocks();

		// Retry the cloud progress merge in case the boot-time fetch was offline (the engine throttles
		// the underlying request, so repeat map visits are cheap). If it restores anything it calls
		// RefreshProgress on this stage itself once the fetch lands.
		_ = CloudProgress.MergeAsync();

		// Re-check progression achievements with the map guaranteed built. The boot check can run
		// before the asset mount is ready (empty map), so this is what covers a profile that already
		// owns everything and has no further win to trigger on.
		Achievements.CheckProgress();

		_selected = ResolveSavedSelection();
		Audio.PlayLevelMusic( _selected?.Level );
		// Coming from the main menu, fade the map's music up. It's a global multiplier, so picking a
		// different node mid-fade swaps the song without restarting (or cutting) the fade.
		if ( FadeInMusic && MusicFadeInEnabled )
			Audio.FadeInMusic( MusicFadeInSeconds );
		PrepareUnlockQueue();

		// Snap the camera + selector to the initial node so the screen opens already focused.
		_cam = ClampCamera( _selected?.Pos ?? Vector2.Zero );
		_selectorPos = _selected?.Pos ?? Vector2.Zero;

		var go = CreateUiRoot();
		_screen = go.Components.Create<LevelSelectScreen>();
		_screen.Stage = this;
		_screen.ShowBoardFor( _selected?.LevelId );
	}

	protected override void OnExit()
	{
		// Don't leave the next screen's music stuck mid-fade if the map is left early.
		Audio.ClearMusicFade();
	}

	/// <summary>Queue the highlighted level's song, played once the highlight has rested on it for
	/// <see cref="MusicSwitchDelaySeconds"/> (see <see cref="TickMusicSwitch"/>).</summary>
	private void RequestLevelMusic( LevelDef level )
	{
		_pendingMusicLevel = level;
		_musicSwitchPending = true;
		_musicSwitchTimer = MusicSwitchDelaySeconds;
	}

	/// <summary>Switch songs immediately, dropping any queued switch.</summary>
	private void PlayLevelMusicNow( LevelDef level )
	{
		_pendingMusicLevel = null;
		_musicSwitchPending = false;
		Audio.PlayLevelMusic( level );
	}

	// Run out the settle delay on a queued song change. Leaving the map drops it — the next stage picks
	// its own song, so a switch mid-wipe would just be a wasted restart.
	private void TickMusicSwitch( float dt )
	{
		if ( !_musicSwitchPending ) return;

		if ( IsFadingOut )
		{
			_musicSwitchPending = false;
			_pendingMusicLevel = null;
			return;
		}

		_musicSwitchTimer -= dt;
		if ( _musicSwitchTimer > 0f ) return;

		_musicSwitchPending = false;
		Audio.PlayLevelMusic( _pendingMusicLevel );
		_pendingMusicLevel = null;
	}

	/// <summary>The node the map should open focused on. Falls back to the first level when the saved
	/// selection is missing from the map, points at a locked node, or sits beyond a locked node (e.g.
	/// progress was reset or edited out from under the save, leaving a locked gap the highlight could
	/// never cross to reach earlier levels), repairing the persisted value so it doesn't recur.</summary>
	private MapNode ResolveSavedSelection()
	{
		MapNode node = LevelMap.Find( LevelProgress.LastSelected );
		if ( node is null || !HasClearPathToFirst( node ) )
		{
			node = LevelMap.First;
			if ( node is not null )
				LevelProgress.SetLastSelected( node.LevelId );
		}
		return node;
	}

	/// <summary>Whether the highlight could walk from <paramref name="node"/> back to the first level.
	/// The route back to the start is exactly the predecessor chain (Down along the spine, inward along
	/// a side chain), and each step requires the target node to be unlocked — so the path is clear only
	/// if the node and every ancestor are selectable.</summary>
	private static bool HasClearPathToFirst( MapNode node )
	{
		for ( ; node is not null; node = node.Predecessor )
			if ( !node.Selectable )
				return false;
		return true;
	}

	/// <summary>Re-focus and repaint the map after progression is changed by a console command.</summary>
	public void RefreshProgress()
	{
		_selected = ResolveSavedSelection();
		PlayLevelMusicNow( _selected?.Level );
		_cam = ClampCamera( _selected?.Pos ?? Vector2.Zero );
		_selectorPos = _selected?.Pos ?? Vector2.Zero;
		_unlockQueue.Clear();
		_presentationLockedLevelIds.Clear();
		_unlockingNode = null;
		_unlockRevealPhase = UnlockRevealPhase.None;
		_unlockCameraTarget = null;
		_unlockSequenceStarted = true;
		_revision++;
		_screen?.ShowBoardFor( _selected?.LevelId );
	}

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

		Audio.TickMusicFade( dt );
		TickMusicSwitch( dt );

		if ( !_unlockSequenceStarted && !IsFadingOut )
		{
			_unlockSequenceStarted = true;
			BeginNextUnlockReveal();
		}

		TickUnlockReveal( dt );
		_wheelSfxCooldown = MathF.Max( 0f, _wheelSfxCooldown - dt );
		if ( _blockedFeedbackTimer > 0f )
		{
			_blockedFeedbackTimer = MathF.Max( 0f, _blockedFeedbackTimer - dt );
			if ( _blockedFeedbackTimer == 0f )
			{
				_blockedFeedbackNode = null;
				_blockedFeedbackDirection = Vector2.Zero;
			}
		}

		bool medalsOpen = _screen?.MedalStandingsOpen == true;
		if ( medalsOpen && InputState.BackJust )
			_screen.CloseMedalStandings();

		if ( !medalsOpen && !IsFadingOut && !UnlockPresentationActive && _selected is not null )
		{
			// All four directions walk connected neighbours — nodes a line is drawn to (the spine and
			// side grids alike; adjacent-but-unconnected grid cells are dead ends). All four hold-to-repeat
			// (a tap steps once; holding long enough auto-walks) via the shared NavRepeater cadence,
			// reading the same held A/D + arrow menu inputs the rest of the UI uses. After each held
			// step we suppress further repeats the moment the (possibly newly-selected) node has no
			// reachable neighbour left in that direction — so arriving on a dead end stops immediately
			// instead of playing blocked-move feedback one more time.
			int vStep = _navVertical.Tick( dt, InputState.NavUp, InputState.NavDown );
			if ( vStep < 0 ) { Navigate( _selected.Up, new Vector2( 0f, 1f ) ); if ( !CanReach( _selected.Up ) ) _navVertical.SuppressRepeat(); }
			else if ( vStep > 0 ) { Navigate( _selected.Down, new Vector2( 0f, -1f ) ); if ( !CanReach( _selected.Down ) ) _navVertical.SuppressRepeat(); }

			int hStep = _navHorizontal.Tick( dt, InputState.Left, InputState.Right );
			if ( hStep < 0 ) { Navigate( _selected.Left, new Vector2( -1f, 0f ) ); if ( !CanReach( _selected.Left ) ) _navHorizontal.SuppressRepeat(); }
			else if ( hStep > 0 ) { Navigate( _selected.Right, new Vector2( 1f, 0f ) ); if ( !CanReach( _selected.Right ) ) _navHorizontal.SuppressRepeat(); }

			if ( InputState.ConfirmJust ) Activate();
			if ( InputState.BackJust ) GoBack();
		}

		// Chase the highlighted node (camera clamped to keep the map framed in the square).
		float k = MathF.Min( 1f, FOLLOW_LERP * dt );
		if ( _selected is not null )
		{
			Vector2 cameraTarget = _unlockRevealPhase != UnlockRevealPhase.Restoring
				&& _unlockCameraTarget is Vector2 unlockTarget
				? unlockTarget
				: ClampCamera( _selected.Pos );
			_cam = Vector2.Lerp( _cam, cameraTarget, k );
			_selectorPos = Vector2.Lerp( _selectorPos, _selected.Pos, k );

			if ( _unlockRevealPhase == UnlockRevealPhase.Focusing && CameraHasSettled( cameraTarget ) )
				BeginCurrentUnlockReveal();
			else if ( _unlockRevealPhase == UnlockRevealPhase.Restoring && CameraHasSettled( cameraTarget ) )
			{
				_unlockRevealPhase = UnlockRevealPhase.None;
				_revision++;
			}
		}

		_selector.Tick( dt, 0 ); // 1D slide unused on the map; only drive the squash frame.
	}

	/// <summary>Whether a node should currently render and behave as unlocked.</summary>
	public bool IsNodeSelectable( MapNode node )
		=> node is not null
			&& !_presentationLockedLevelIds.Contains( node.LevelId )
			&& node.Selectable;

	public bool IsConnectionTraversable( MapConnection connection ) => IsNodeSelectable( connection.To );

	public bool IsNodeUnlocking( MapNode node )
		=> node is not null
			&& _unlockRevealPhase == UnlockRevealPhase.Revealing && node == _unlockingNode;

	/// <summary>Capture this arrival's unlock snapshot and queue only levels which were locked on the
	/// previous visit. Major spine levels reveal before side levels; order within each group follows
	/// the authored map.</summary>
	private void PrepareUnlockQueue()
	{
		// An empty map means the manifest failed to load (or the filesystem isn't mounted yet), not
		// that everything locked — capturing it would clobber the snapshot and make EVERY unlocked
		// level replay its reveal ceremony once the map builds. Leave the snapshot untouched.
		if ( Nodes.Count == 0 )
			return;

		var currentlyUnlocked = new List<string>();
		foreach ( var node in Nodes )
			if ( node.Selectable )
				currentlyUnlocked.Add( node.LevelId );

		var newlyUnlocked = new HashSet<string>( LevelProgress.CaptureNewlyUnlocked( currentlyUnlocked ) );
		for ( int majorPass = 1; majorPass >= 0; majorPass-- )
		{
			bool major = majorPass == 1;
			foreach ( var node in Nodes )
			{
				if ( node.IsMajor != major || !newlyUnlocked.Contains( node.LevelId ) )
					continue;
				_unlockQueue.Add( node );
				_presentationLockedLevelIds.Add( node.LevelId );
			}
		}
	}

	private void TickUnlockReveal( float dt )
	{
		if ( _unlockRevealPhase != UnlockRevealPhase.Revealing )
			return;

		_unlockRevealTimer -= dt;
		if ( _unlockRevealTimer > 0f )
			return;

		_unlockingNode = null;
		_revision++;
		BeginNextUnlockReveal();
	}

	private void BeginNextUnlockReveal()
	{
		if ( _unlockQueue.Count == 0 )
		{
			_unlockingNode = null;
			_unlockCameraTarget = null;
			Vector2 selectedTarget = ClampCamera( _selected?.Pos ?? Vector2.Zero );
			_unlockRevealPhase = CameraHasSettled( selectedTarget )
				? UnlockRevealPhase.None
				: UnlockRevealPhase.Restoring;
			_revision++;
			return;
		}

		_unlockingNode = _unlockQueue[0];
		_unlockQueue.RemoveAt( 0 );
		if ( IsNodeFullyVisible( _unlockingNode, _cam ) )
		{
			BeginCurrentUnlockReveal();
			return;
		}

		_unlockCameraTarget = ClampCamera( _unlockingNode.Pos );
		_unlockRevealPhase = UnlockRevealPhase.Focusing;
		_revision++;
	}

	private void BeginCurrentUnlockReveal()
	{
		if ( _unlockingNode is null )
			return;

		_presentationLockedLevelIds.Remove( _unlockingNode.LevelId );
		_unlockRevealPhase = UnlockRevealPhase.Revealing;
		_unlockRevealTimer = UNLOCK_REVEAL_TIME;
		_revision++;
		Audio.PlaySfx( SfxType.TurnVisible, volume: 0.75f, pitch: 1.15f );
	}

	private bool CameraHasSettled( Vector2 target ) => (_cam - target).Length <= CAMERA_SETTLE_DISTANCE;

	private static bool IsNodeFullyVisible( MapNode node, Vector2 camera )
	{
		float nodeHalf = node.IsMajor ? MAJOR_NODE_HALF_SIZE : SIDE_NODE_HALF_SIZE;
		float visibleHalf = VIEW_HALF - MAP_CLIP_INSET;
		return MathF.Abs( node.Pos.x - camera.x ) + nodeHalf <= visibleHalf
			&& MathF.Abs( node.Pos.y - camera.y ) + nodeHalf <= visibleHalf;
	}

	/// <summary>Move the highlight to a grid neighbour. A dead end shakes the player marker in the
	/// attempted direction; a locked neighbour also shakes its node. Locked neighbours are
	/// refused with an error cue (you must beat the level leading to it first). <paramref name="silent"/>
	/// suppresses the move/error SFX so wheel scrolling can provide its own rate-limited cues.</summary>
	private void Navigate( MapNode next, Vector2 attemptedDirection, bool silent = false )
	{
		if ( next is null )
		{
			PlayBlockedFeedback( null, attemptedDirection, silent, pitch: 0.95f );
			return;
		}
		if ( !IsNodeSelectable( next ) )
		{
			PlayBlockedFeedback( next, next.Pos - _selected.Pos, silent );
			return;
		}
		SelectNode( next, silent );
	}

	/// <summary>Whether the highlight could step onto <paramref name="node"/> (a non-null, unlocked
	/// neighbour). Used to stop hold-to-repeat the instant no further move exists in that direction.</summary>
	private bool CanReach( MapNode node ) => IsNodeSelectable( node );

	/// <summary>Mouse-wheel navigation: walk one node along the spine per notch — wheel up.
	/// A negative <paramref name="dir"/> moves up toward later levels; a positive one moves down.
	/// Successful steps use a quieter click than keyboard navigation, rate-limited for high-resolution
	/// wheels. Locked levels and dead ends use a separately rate-limited error cue.</summary>
	public void WheelStep( int dir )
	{
		if ( IsFadingOut || UnlockPresentationActive || _selected is null || dir == 0 )
			return;

		MapNode next = dir < 0 ? _selected.Up : _selected.Down;
		MapNode previous = _selected;
		Navigate( next,
			dir < 0 ? new Vector2( 0f, 1f ) : new Vector2( 0f, -1f ), silent: true );

		if ( _wheelSfxCooldown > 0f )
			return;

		if ( _selected != previous )
		{
			Audio.PlaySfx( SfxType.BlockSidePressed0, volume: 0.45f, pitch: 1.35f );
			_wheelSfxCooldown = WHEEL_MOVE_SFX_MIN_INTERVAL;
		}
		else
		{
			Audio.PlaySfx( SfxType.Error, pitch: next is null ? 0.95f : 0.85f );
			_wheelSfxCooldown = WHEEL_ERROR_SFX_MIN_INTERVAL;
		}
	}

	/// <summary>Highlight a node (keyboard move or click). Persists the choice so the map reopens here
	/// next time. Never called for locked nodes — callers refuse those first.</summary>
	public void SelectNode( MapNode node, bool silent = false )
	{
		if ( node is null || node == _selected )
			return;

		_blockedFeedbackNode = null;
		_blockedFeedbackDirection = Vector2.Zero;
		_blockedFeedbackTimer = 0f;
		_selected = node;
		RequestLevelMusic( node.Level );
		_selector.PlayMove();
		if ( !silent ) Audio.PlaySfx( SfxType.MenuBlip );
		Haptics.MenuBlip();
		LevelProgress.SetLastSelected( node.LevelId );
		_screen?.ShowBoardFor( node.LevelId );
	}

	/// <summary>Enter the highlighted level. The highlight only ever rests on unlocked nodes, so the
	/// locked guard here is a safety net.</summary>
	public void Activate()
	{
		if ( IsFadingOut || UnlockPresentationActive || _selected is null )
			return;

		if ( !IsNodeSelectable( _selected ) )
		{
			PlayBlockedFeedback( _selected, Vector2.Zero );
			return;
		}

		_selector.PlayWobble();
		Audio.PlaySfx( SfxType.MenuStart );
		Haptics.MenuConfirm();
		Manager.StartLevel( _selected.LevelId );
	}

	/// <summary>Click a node: a locked node is refused with an error cue. Clicking a node that isn't
	/// already highlighted just selects it; clicking the already-highlighted node enters the level.
	/// Hovering only highlights (via CSS) — it never changes the selection.</summary>
	public void ClickNode( MapNode node )
	{
		if ( node is null || IsFadingOut || UnlockPresentationActive )
			return;

		if ( !IsNodeSelectable( node ) )
		{
			PlayBlockedFeedback( node, node.Pos - _selected.Pos );
			return;
		}

		// First click selects; a second click on the already-highlighted node enters it.
		if ( node != _selected )
		{
			SelectNode( node );
			return;
		}

		Activate();
	}

	/// <summary>DEBUG: right-click a node to flip its beaten flag (persisted), for testing map gating
	/// without playing the level. No-op unless <see cref="DEBUG_NODE_TOGGLE"/> is on.</summary>
	public void DebugToggleBeaten( MapNode node )
	{
		if ( !DEBUG_NODE_TOGGLE || node is null )
			return;

		//LevelProgress.ToggleBeaten( node.LevelId );
		//_revision++;
		//Audio.PlaySfx( SfxType.MenuBlip );
		//Log.Info( $"[DEBUG] Level '{node.LevelId}' beaten = {node.Beaten}." );
	}

	/// <summary>Feedback for a blocked move. The UI nudges the player marker in the attempted direction
	/// and, when <paramref name="node"/> is non-null, also shakes that locked target.
	/// <paramref name="silent"/> skips the error SFX (wheel scrolling).</summary>
	private void PlayBlockedFeedback( MapNode node, Vector2 direction, bool silent = false, float pitch = 0.85f )
	{
		_blockedFeedbackNode = node;
		_blockedFeedbackDirection = direction.Length > 0.001f ? direction / direction.Length : Vector2.Zero;
		_blockedFeedbackTimer = BLOCKED_FEEDBACK_DURATION;
		if ( !silent ) Audio.PlaySfx( SfxType.Error, pitch: pitch );
		Haptics.MenuBlip();
	}

	private void GoBack()
	{
		Audio.PlaySfx( SfxType.MenuStart );
		FadeToStage( new TitleStage( Manager ) );
	}

	/// <summary>Editor button (bottom-left letterbox, editor-only): open the level editor. Locked
	/// during the exit wipe / unlock presentation like every other input path.</summary>
	public void ClickEditor()
	{
		if ( IsFadingOut || UnlockPresentationActive )
			return;

		Audio.PlaySfx( SfxType.MenuStart, 0.7f );
		LevelEditorStage.OpenLevelEditor( "" );
	}

	/// <summary>Home button (bottom-right letterbox): return to the title screen, same as Back.</summary>
	public void ClickHome()
	{
		if ( IsFadingOut || UnlockPresentationActive )
			return;
		GoBack();
	}

	/// <summary>Footer trophy button: open the full High Scores screen on the highlighted level's
	/// board. BACK there returns to this map (not the title) — see <see cref="HighscoreStage.GoBack"/>.</summary>
	public void ClickLeaderboards()
	{
		if ( IsFadingOut || UnlockPresentationActive || _selected is null )
			return;

		Audio.PlaySfx( SfxType.MenuStart );
		FadeToStage( new HighscoreStage( Manager, levelId: _selected.LevelId, backToLevelSelect: true ) );
	}

	/// <summary>Clamp a desired camera centre so the map stays framed in the square. Horizontally the
	/// camera centres on the selected node (even if that leaves the map lopsided — a major with only a
	/// right side-chain still sits dead-centre when selected). Vertically it bottom-anchors so the first
	/// level rests near the bottom rather than floating in the middle. Either axis scrolls (clamped)
	/// once the content is taller/wider than the view.</summary>
	private static Vector2 ClampCamera( Vector2 target )
	{
		// Horizontally, ALWAYS centre on the selected node (no content-edge clamp): a major on the spine
		// stays dead-centre even when a side chain is very long, and stepping into a chain pans the
		// camera along it. The old bounds-clamp shoved a selected major off-centre toward a long branch.
		float camX = target.x;
		float camY = ClampAxis( target.y, LevelMap.MinY, LevelMap.MaxY, PAD_BOTTOM, PAD_TOP, FitAnchor.Low );
		return new Vector2( camX, camY );
	}

	/// <summary>When the content (plus padding) is smaller than the view it can't scroll to frame both
	/// edges: <see cref="Low"/> pins it to the low edge (used vertically so the first node sits near the
	/// bottom); <see cref="Follow"/> centres the target node (used horizontally).</summary>
	private enum FitAnchor { Low, Follow }

	/// <summary>Clamp one camera axis. <paramref name="padLow"/>/<paramref name="padHigh"/> are the gaps
	/// kept at the low/high content edges when scrolled to a limit.</summary>
	private static float ClampAxis( float target, float min, float max, float padLow, float padHigh, FitAnchor fit )
	{
		float lo = min + VIEW_HALF - padLow;   // low edge of content near low edge of view
		float hi = max - VIEW_HALF + padHigh;  // high edge of content near high edge of view
		if ( hi >= lo )
			return Math.Clamp( target, lo, hi );
		return fit == FitAnchor.Low ? lo : target;
	}
}