Stages/DailyChallengeStage.cs
namespace BlockParty;

/// <summary>Daily challenge hub: play today's fixed-seed run once, browse previous UTC days, and watch
/// leaderboard replays for the selected day.</summary>
public sealed class DailyChallengeStage : MenuStageBase
{
	/// <summary>DEBUG: unlock paging into future (and pre-earliest) days to preview the generator
	/// from the hub. Toggled by the daily_debug_nav ConCmd; never ships enabled.</summary>
	public static bool DebugNavigation { get; set; }

	public string DailyId { get; }
	public bool LockedPickerAlreadyVisible { get; }
	private DailyChallengeScreen _screen;
	private string _pendingDayId;
	private float _pendingDayTimer;
	private const float LockedPickerExitTime = 0.14f;

	public DailyChallengeStage( GameManager manager, string dailyId = null, bool lockedPickerAlreadyVisible = false ) : base( manager )
	{
		// With debug navigation on, any well-formed day id sticks (a ScoreStage return from a debug
		// future-day run would otherwise silently snap to today).
		DailyId = DebugNavigation && DailyChallenge.TryParseId( dailyId ?? "", out _ )
			? dailyId
			: DailyChallenge.ClampId( dailyId );
		LockedPickerAlreadyVisible = lockedPickerAlreadyVisible;
	}

	public override bool CoveredBackground => true;
	public bool IsToday => DailyId == DailyChallenge.TodayId;
	public bool IsFuture => DailyChallenge.IsFuture( DailyId );
	public bool CanGoPrevious => DebugNavigation || !DailyChallenge.IsBeforeEarliest( DailyChallenge.AddDays( DailyId, -1 ) );
	public string DisplayDate => DailyChallenge.DisplayDate( DailyId );
	public int Seed => DailyChallenge.SeedFor( DailyId );

	/// <summary>The day's generated challenge.</summary>
	public DailyDef Daily => DailyLevels.Get( DailyId );

	protected override void OnEnter()
	{
		// Preview the displayed day's song (paging days re-enters the stage, so this covers both).
		// Starting the run plays the same track, and Audio.PlayMusic keeps a playing song going when
		// the path matches — so PLAY doesn't restart it. TitleStage restores the menu song on BACK.
		Audio.PlayLevelMusic( Daily?.Level );

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

		if ( Game.IsEditor && Daily is DailyDef daily )
			Log.Info( $"[BlockParty] daily_show {DailyId}: {DailyDebugCommands.Describe( daily )}" );
	}

	public override void Tick( float dt )
	{
		base.Tick( dt );
		if ( _screen?.TrophyStandingsOpen == true )
		{
			if ( InputState.BackJust ) _screen.CloseTrophyStandings();
			return;
		}
		if ( _pendingDayId is not null )
		{
			_pendingDayTimer -= dt;
			if ( _pendingDayTimer <= 0f )
			{
				var next = _pendingDayId;
				_pendingDayId = null;
				FadeToStage( new DailyChallengeStage( Manager, next ) );
			}
			return;
		}

		// A/D always page the day; Confirm activates the focused zone (PLAY/WATCH, a row, or MAIN MENU
		// — the screen owns that). W/S selection lives in the screen.
		if ( InputState.LeftJust ) PreviousDay();
		if ( InputState.RightJust ) NextDay();
		if ( InputState.ConfirmJust ) _screen?.ActivateFocused();
		if ( InputState.BackJust ) GoBack();
	}

	// Pitched-down selector-move blip played when A/D (or an arrow click) actually pages the day.
	private const float DayNavBlipPitch = 0.8f;

	public void PreviousDay()
	{
		if ( _pendingDayId is not null ) return;

		var prev = DailyChallenge.AddDays( DailyId, -1 );
		if ( !DebugNavigation && DailyChallenge.IsBeforeEarliest( prev ) )
		{
			Audio.PlaySfx( SfxType.Error, pitch: 0.7f );   // already at the earliest day
			return;
		}

		Audio.PlaySfx( SfxType.MenuBlip, 1f, DayNavBlipPitch );
		Haptics.MenuBlip();
		ChangeDay( prev );
	}

	public void NextDay()
	{
		if ( _pendingDayId is not null ) return;

		var next = DailyChallenge.AddDays( DailyId, 1 );
		if ( !DebugNavigation && DailyChallenge.IsFuture( next ) )
		{
			Audio.PlaySfx( SfxType.Error, pitch: 0.75f );   // already at today (can't page into the future)
			return;
		}

		Audio.PlaySfx( SfxType.MenuBlip, 1f, DayNavBlipPitch );
		Haptics.MenuBlip();
		ChangeDay( next );
	}

	private void ChangeDay( string nextId )
	{
		bool currentLocked = Daily?.Level.ForcedCharacterId is not null;
		bool nextLocked = DailyLevels.Get( nextId )?.Level.ForcedCharacterId is not null;
		if ( currentLocked && !nextLocked )
		{
			_screen?.BeginLockedPickerExit();
			_pendingDayId = nextId;
			_pendingDayTimer = LockedPickerExitTime;
			return;
		}

		FadeToStage( new DailyChallengeStage( Manager, nextId,
			lockedPickerAlreadyVisible: currentLocked && nextLocked ) );
	}

	public void Play()
	{
		if ( !IsToday || IsFuture )
			return;

		// Local attempt gate (offline / fast path): the day's generated limit vs the local counter.
		var daily = Daily;
		if ( daily is null || !DailyChallengeProgress.HasAttemptsRemaining( DailyId, daily.MaxAttempts ) )
			return;

		// On a SINGLE-attempt day the daily board stays the authoritative lock (it survives the
		// player deleting the local file). On multi/unlimited-attempt days board membership just
		// means "has a score", so it can't gate here.
		if ( daily.MaxAttempts == 1 && _screen?.LocalPlayerOnBoard == true )
			return;

		Manager.StartDailyChallenge( DailyId );
	}

	/// <summary>DEBUG: play this day's generated level regardless of the date — never submits or
	/// counts attempts, but banks a local replay (see <see cref="GameStage.IsDebugRun"/>). Only
	/// reachable while <see cref="DebugNavigation"/> is on (daily_debug_nav).</summary>
	public void DebugPlay()
	{
		if ( !DebugNavigation )
			return;

		Manager.StartDailyChallengeDebug( DailyId );
	}

	public void GoBack() => FadeToStage( new TitleStage( Manager ) );
}