Game/CharacterProgress.cs
namespace BlockParty;

/// <summary>Per-player character unlock progression backed by <see cref="GameSettings"/>.</summary>
public static class CharacterProgress
{
	/// <summary>Changes whenever unlock state is mutated, allowing UI components to rebuild.</summary>
	public static int Version { get; private set; }

	public static bool IsUnlocked( string characterId )
		=> Characters.TryGet( characterId, out _ )
			&& Settings.Current.UnlockedCharacterIds.Contains( characterId );

	public static bool IsNew( string characterId )
		=> characterId != Characters.OriginalId
			&& IsUnlocked( characterId )
			&& !Settings.Current.EverSelectedCharacterIds.Contains( characterId );

	public static int NewCharacterCount
		=> Characters.All.Count( character => IsNew( character.Id ) );

	public static bool HasSeenHelp( string characterId )
		=> Settings.Current.SeenCharacterHelpIds.Contains( characterId );

	/// <summary>Persist that automatic first-play help was shown, without changing unlock state.</summary>
	public static void MarkHelpSeen( string characterId )
	{
		if ( !Characters.TryGet( characterId, out var character ) || HasSeenHelp( character.Id ) )
			return;

		Settings.Current.SeenCharacterHelpIds.Add( character.Id );
		Settings.Save();
	}

	/// <summary>Whether the in-run "?" help button should pulse to teach the player it exists. Arms
	/// the first time an automatic first-play help overlay is shown — i.e. the first run PLAYING AS a
	/// character beyond Original, owned or not (forced-character levels count; Original's help is not
	/// auto-shown, so SeenCharacterHelpIds only ever gains non-Original ids). Retires permanently —
	/// across all future runs — the first time the button is actually clicked
	/// (<see cref="MarkHelpButtonUsed"/>); only a progress reset re-arms it.</summary>
	public static bool ShouldNudgeHelpButton
		=> !Settings.Current.CharacterHelpButtonUsed
			&& Settings.Current.SeenCharacterHelpIds.Count > 0;

	/// <summary>The player manually opened the character help; the nudge pulse is done teaching.
	/// Persists immediately so quitting right after the click doesn't resurrect the pulse.</summary>
	public static void MarkHelpButtonUsed()
	{
		if ( Settings.Current.CharacterHelpButtonUsed )
			return;

		Settings.Current.CharacterHelpButtonUsed = true;
		Settings.Save();
	}

	/// <summary>Remember that a character has been selected and persist immediately.</summary>
	public static void MarkSelected( string characterId )
	{
		if ( !Characters.TryGet( characterId, out var character )
			|| Settings.Current.EverSelectedCharacterIds.Contains( character.Id ) )
			return;

		Settings.Current.EverSelectedCharacterIds.Add( character.Id );
		Version++;
		Settings.Save();
	}

	/// <summary>Unlock a known character and persist. Returns true only when progression changed.
	/// A real unlock also queues the character's "YOU UNLOCKED" reveal — this is the single choke
	/// point for progression unlocks, so no unlock can slip through without the player being told.</summary>
	public static bool Unlock( string characterId )
	{
		if ( !Characters.TryGet( characterId, out var character ) || IsUnlocked( character.Id ) )
			return false;

		Settings.Current.UnlockedCharacterIds.Add( character.Id );
		if ( !Settings.Current.PendingUnlockRevealIds.Contains( character.Id ) )
			Settings.Current.PendingUnlockRevealIds.Add( character.Id );
		Version++;
		Settings.Save();
		Achievements.CheckProgress(); // this may have been the last character
		return true;
	}

	/// <summary>Quiet unlock for progress RECOVERY (see <see cref="CloudProgress"/>): the character was
	/// earned on another install, so no "YOU UNLOCKED" reveal is owed. Returns true when the roster
	/// changed.</summary>
	internal static bool Restore( string characterId )
	{
		if ( !Characters.TryGet( characterId, out var character ) || IsUnlocked( character.Id ) )
			return false;

		Settings.Current.UnlockedCharacterIds.Add( character.Id );
		Version++;
		Settings.Save();
		return true;
	}

	// --- owed "YOU UNLOCKED" reveals --------------------------------------------------------
	// A queue rather than a single slot: the reveal is delivered by the score tally on the way out
	// of the winning run, but a player who abandons a won run (or quits the game) before that never
	// sees it, so it waits — persisted — until the next menu screen can show it. See UnlockReveal.

	/// <summary>Whether any character is still owed a reveal that can actually be shown. Defined via
	/// <see cref="PeekPendingReveal"/> rather than the raw count so an id that no longer resolves (a
	/// character retired from the registry mid-session) doesn't read as permanently pending.</summary>
	public static bool HasPendingReveal => PeekPendingReveal() is not null;

	/// <summary>The oldest character still owed a reveal, or null. Stays queued until
	/// <see cref="ConsumePendingReveal"/> marks it seen.</summary>
	public static CharacterDef PeekPendingReveal()
	{
		foreach ( var id in Settings.Current.PendingUnlockRevealIds )
		{
			if ( Characters.TryGet( id, out var character ) )
				return character;
		}
		return null;
	}

	/// <summary>Mark a character's reveal as delivered and persist immediately, so quitting part-way
	/// through the reveal's fade doesn't show it again next launch.</summary>
	public static void ConsumePendingReveal( string characterId )
	{
		if ( Settings.Current.PendingUnlockRevealIds.Remove( characterId ) )
			Settings.Save();
	}

	/// <summary>Debug: owe the player a "YOU UNLOCKED" reveal for a character (unlocking it first if
	/// needed) so the overlay can be checked without winning that character's level. It appears on the
	/// next menu screen, or behind the score tally's next RESTART / CONTINUE.</summary>
	[ConCmd( "unlock_reveal" )]
	public static void QueueUnlockRevealCmd( string characterId )
	{
		if ( !Game.IsEditor ) return;
		if ( !Characters.TryGet( characterId, out var character ) )
		{
			Log.Info( $"[BlockParty] Unknown character '{characterId}'. Known: {string.Join( ", ", Characters.All.Select( c => c.Id ) )}" );
			return;
		}

		// Unlock() queues the reveal itself; an already-owned character just needs the queue entry.
		if ( !Unlock( character.Id ) && !Settings.Current.PendingUnlockRevealIds.Contains( character.Id ) )
		{
			Settings.Current.PendingUnlockRevealIds.Add( character.Id );
			Settings.Save();
		}

		Log.Info( $"[BlockParty] Queued unlock reveal for '{character.Id}'." );
	}

	/// <summary>Set a character's unlock state and persist. Locking the final unlocked character also
	/// unlocks the next registry entry so debug toggling can target any character without leaving the
	/// profile unable to start a voluntary run.</summary>
	public static bool SetUnlocked( string characterId, bool unlocked )
	{
		if ( !Characters.TryGet( characterId, out var character ) )
			return false;

		var unlockedIds = Settings.Current.UnlockedCharacterIds;
		bool currentlyUnlocked = unlockedIds.Contains( character.Id );
		if ( currentlyUnlocked == unlocked )
			return false;

		if ( unlocked )
			unlockedIds.Add( character.Id );
		else
		{
			if ( unlockedIds.Count <= 1 )
			{
				var fallback = Characters.All.First( candidate => candidate.Id != character.Id );
				unlockedIds.Add( fallback.Id );
			}
			unlockedIds.Remove( character.Id );
			// Debug re-lock: drop any reveal still owed for it, so the overlay can't announce a
			// character the profile no longer owns.
			Settings.Current.PendingUnlockRevealIds.Remove( character.Id );
		}

		RepairSelectedCharacter();
		Version++;
		Settings.Save();
		return true;
	}

	public static bool ToggleUnlocked( string characterId )
		=> SetUnlocked( characterId, !IsUnlocked( characterId ) );

	/// <summary>Restore the starter roster and selection. The caller owns persistence so this can be
	/// combined atomically with other profile-progression changes.</summary>
	internal static void ResetToOriginal()
	{
		var settings = Settings.Current;
		settings.UnlockedCharacterIds.Clear();
		settings.UnlockedCharacterIds.Add( Characters.OriginalId );
		settings.EverSelectedCharacterIds.Clear();
		settings.EverSelectedCharacterIds.Add( Characters.OriginalId );
		settings.PendingUnlockRevealIds.Clear();
		settings.SeenCharacterHelpIds.Clear();
		settings.CharacterHelpButtonUsed = false;
		settings.SelectedCharacterId = Characters.OriginalId;
		Version++;
	}

	/// <summary>Return the requested character when unlocked, otherwise Original or the first unlocked
	/// definition. Forced levels and replays intentionally do not call this helper.</summary>
	public static CharacterDef ResolveSelectable( string characterId )
	{
		if ( IsUnlocked( characterId ) )
			return Characters.Get( characterId );
		if ( IsUnlocked( Characters.OriginalId ) )
			return Characters.Original;
		return Characters.All.First( character => IsUnlocked( character.Id ) );
	}

	public static void RepairSelectedCharacter()
	{
		var selected = ResolveSelectable( Settings.Current.SelectedCharacterId );
		Settings.Current.SelectedCharacterId = selected.Id;
	}
}