Game/GameSettings.cs
namespace BlockParty;

/// <summary>Whose scores the high-score board shows: everyone, or only the player's Steam friends.</summary>
public enum LeaderboardScope
{
	Global,
	Friends,
}

/// <summary>How the workshop browser orders results. Values follow the browser's cycle order
/// (Newest -> Top -> Trending) so the SORT button can advance with a modulo step.</summary>
public enum WorkshopSortMode
{
	Newest,
	TopVoted,
	Trending,
}

/// <summary>
/// Player-tunable, persisted settings. The volume sliders plus the high-score board's
/// last-used scope (so the leaderboard opens the way you left it). Values are kept in
/// slider-space (0..100) so the UI binds to them directly; the audio layer converts to a 0..1
/// factor when applying. Mirrors the approach in sbox-ss2's <c>GameSettingsSystem</c>, kept
/// minimal for BlockParty.
/// </summary>
public sealed class GameSettings
{
	/// <summary>Campaign-data version last written for this player. A mismatch with
	/// <see cref="LevelProgress.PROGRESS_VERSION"/> wipes campaign progression <em>and</em> character
	/// unlocks (beaten levels, unlocked/selected character, seen ability help) on load; volume,
	/// leaderboard, input and other preferences are preserved. See <see cref="Settings.Load"/>.</summary>
	public int ProgressVersion { get; set; }

	/// <summary>When true, show the elapsed run timer in live runs and on the score tally screen.
	/// Replays have their own transport clock, so this stays hidden there. Default off.</summary>
	public bool ShowTimer { get; set; } = false;

	public const float RestartButtonDelayStep = 0.05f;

	/// <summary>Seconds to hold Restart during a live run. Zero restarts instantly without an overlay.
	/// Clamped and snapped on assignment (including JSON load) to 0..1 in 0.05-second steps.</summary>
	public float RestartButtonDelay
	{
		get => _restartButtonDelay;
		set => _restartButtonDelay = float.IsFinite( value )
			? MathF.Round( Math.Clamp( value, 0f, 1f ) / RestartButtonDelayStep ) * RestartButtonDelayStep
			: 0.5f;
	}
	private float _restartButtonDelay = 0.5f;

	/// <summary>Camera-screenshake intensity multiplier. Slider-space 0..100 (the render layer converts
	/// to a 0..1 factor — see <see cref="GameStage"/>); scales every camera shake. 0 disables shake
	/// entirely. Default 100 (full shake).</summary>
	public float Screenshake { get; set; } = 50f;

	/// <summary>Overall volume; scales both SFX and music.</summary>
	public float MasterVolume { get; set; } = 60f;

	/// <summary>Sound-effects volume. Default 100.</summary>
	public float SfxVolume { get; set; } = 100f;

	/// <summary>Music volume. Default 60 (the audio layer scales this up by <see cref="Audio.MusicVolumeScale"/>).</summary>
	public float MusicVolume { get; set; } = 60f;

	/// <summary>Controller-vibration strength (0 = off). Slider-space 0..100; the haptics layer
	/// converts to a 0..1 scale — see <see cref="Haptics"/>. Default 75.</summary>
	public float VibrationStrength { get; set; } = 75f;

	/// <summary>Whether the normal high-score board is limited to friends. Default everyone. Applies
	/// only to the normal High Scores page, not the Daily Challenge board.</summary>
	public LeaderboardScope LeaderboardScope { get; set; } = LeaderboardScope.Global;

	/// <summary>Sort order last used in the workshop browser, so it reopens the way you left it.
	/// Default newest-first.</summary>
	public WorkshopSortMode WorkshopSort { get; set; } = WorkshopSortMode.Newest;

	/// <summary>Integer upscale of the 240px arena used by GIF export (1x = 240px, 3x = 720px), picked
	/// from the export overlay's SIZE dropdown. The setter clamps to 1..<see cref="GifExporter.MaxScale"/>
	/// (JSON load goes through it too), so readers never re-validate. Default 3x.</summary>
	public int GifScale
	{
		get => _gifScale;
		set => _gifScale = GifExporter.ClampScale( value );
	}
	private int _gifScale = GifExporter.DefaultScale;

	/// <summary>Which level's board the High Scores page shows (see <see cref="Levels"/>). Null =
	/// classic. Set from the page's LEVEL dropdown and by finishing a run on a level, so the board
	/// opens on the level just played.</summary>
	public string LeaderboardLevelId { get; set; }

	/// <summary>Optional character filter for the normal High Scores page. Null shows the overall
	/// per-level board. Kept separate from <see cref="SelectedCharacterId"/> so browsing scores never
	/// changes the character used by level select or daily challenges.</summary>
	public string LeaderboardCharacterId { get; set; }

	/// <summary>Which character the player has chosen to play as (see <see cref="Characters"/>).
	/// Null = Original. Set from the title-screen character picker and used when a fresh
	/// run is launched. Persisted so the choice survives across sessions.</summary>
	public string SelectedCharacterId { get; set; }

	/// <summary>Character ids the player has unlocked. Null (missing/corrupt file) loads as the
	/// starter roster; see <see cref="Settings.Load"/>.</summary>
	public System.Collections.Generic.List<string> UnlockedCharacterIds { get; set; }

	/// <summary>Character ids the player has selected at least once. Original starts selected.</summary>
	public System.Collections.Generic.List<string> EverSelectedCharacterIds { get; set; }
		= new() { Characters.OriginalId };

	/// <summary>Characters that have been unlocked but not yet shown to the player as a "YOU UNLOCKED"
	/// reveal, oldest first. Persisted so a reveal still owed when the game is closed (or when a won run
	/// was abandoned before its score tally opened) is delivered on the next menu screen instead of
	/// being lost. Managed via <see cref="CharacterProgress"/>; presented by <see cref="UnlockReveal"/>.</summary>
	public System.Collections.Generic.List<string> PendingUnlockRevealIds { get; set; } = new();

	/// <summary>Characters whose automatic first-play ability help has already been shown. This is
	/// independent of unlock state because authored levels can force a character the player does not own.</summary>
	public System.Collections.Generic.List<string> SeenCharacterHelpIds { get; set; } = new();

	/// <summary>The player has manually opened the in-run "?" character help at least once. Once true,
	/// the help button stops its "this exists" nudge pulse forever (until a progress reset). Managed via
	/// <see cref="CharacterProgress"/>.</summary>
	public bool CharacterHelpButtonUsed { get; set; }

	/// <summary>Ids (see <see cref="LevelDef.Id"/>) of levels the player has beaten by reaching max
	/// phase on every block, driving map progression/unlocks on the level-select screen. Managed via
	/// <see cref="LevelProgress"/>.</summary>
	public System.Collections.Generic.List<string> BeatenLevelIds { get; set; } = new();

	/// <summary>The map node the player last had highlighted on the level-select screen (a
	/// <see cref="LevelDef.Id"/>). The map reopens focused on this node. Null = start on the first
	/// major level. Managed via <see cref="LevelProgress"/>.</summary>
	public string LastSelectedLevelId { get; set; }

	/// <summary>Level ids that were selectable the last time the level-select stage was entered. Null
	/// identifies a profile written before unlock-reveal tracking existed.</summary>
	public System.Collections.Generic.List<string> LastSeenUnlockedLevelIds { get; set; }

	/// <summary>Characters that have beaten the capstone level (<see cref="Achievements.CapstoneLevelId"/>),
	/// driving the "beat it with every character" achievement. Named for the ROLE rather than the level
	/// so re-pointing the achievement at a different level doesn't leave a misnamed field; the stored
	/// ids then simply no longer apply. Ids that leave <see cref="Characters.All"/> are ignored on read
	/// rather than pruned, so a character retired and restored keeps its win.</summary>
	public System.Collections.Generic.List<string> CapstoneWinCharacterIds { get; set; } = new();

	/// <summary>Names of <see cref="BlockType"/>s the player has brought to max phase (enum NAMES, not
	/// indices, so reordering the enum can't corrupt the record — a renamed type simply orphans its
	/// entry). Drives the block-type collection achievement; counted against the CURRENT enum on read,
	/// so orphans can't inflate the numerator. Managed via <see cref="Achievements.RecordMaxPhasedTypes"/>.</summary>
	public System.Collections.Generic.List<string> MaxPhasedBlockTypes { get; set; } = new();

	/// <summary>Identity keys (see <see cref="GameManager.CurrentReplayKey"/>) of replays already
	/// counted toward the "GIF of N different replays" achievement, so re-exporting the same run can't
	/// count twice. Deliberately NOT cleared by <see cref="Settings.ResetPlayerProgress"/> — it's
	/// achievement bookkeeping rather than progression, and clearing it would let a wipe inflate the
	/// stat. Trimmed to <see cref="Achievements.MaxTrackedGifReplays"/> most-recent entries so a
	/// prolific exporter can't grow the settings file without bound.</summary>
	public System.Collections.Generic.List<string> GifExportedReplayKeys { get; set; } = new();

	/// <summary>Lifetime totals behind the counting stat achievements, keyed by stat name. The DURABLE
	/// source of truth: each goes out whole via SetValue under HIGHEST aggregation (see
	/// <c>Achievements.SubmitTally</c>), so a submission lost to a network drop heals on the next
	/// submit or boot instead of vanishing. Achievement bookkeeping like the GIF keys: never cleared
	/// by a progress reset.</summary>
	public System.Collections.Generic.Dictionary<string, long> AchievementTallies { get; set; } = new();

	/// <summary>MANUAL achievement idents earned on this machine, re-sent by
	/// <c>Achievements.CheckProgress</c> every boot — unlocking is idempotent and the backend drops
	/// repeats, so an unlock request lost offline heals once a later boot gets through. Achievement
	/// bookkeeping: never cleared by a progress reset.</summary>
	public System.Collections.Generic.List<string> EarnedManualAchievements { get; set; } = new();
}

/// <summary>
/// Static accessor + JSON persistence for <see cref="GameSettings"/>. Stored in the game's
/// per-user data folder via <see cref="FileSystem.Data"/>; loaded lazily on first access and
/// re-saved (debounced) by the options UI whenever a value changes.
/// </summary>
public static class Settings
{
	public const string FilePath = "blockparty_settings.json";

	private static GameSettings _current;

	public static GameSettings Current
	{
		get
		{
			if ( _current is null ) Load();
			return _current;
		}
	}

	/// <summary>Load settings from disk, falling back to defaults if the file is missing/invalid.</summary>
	public static void Load()
	{
		_current = FileSystem.Data.ReadJsonOrDefault<GameSettings>( FilePath, new GameSettings() );
		_current ??= new GameSettings();

		// Sanity-filter the unlocked roster: drop ids that don't resolve to a character, and make
		// sure at least the starter is present (also covers a missing/corrupt file).
		_current.UnlockedCharacterIds = ( _current.UnlockedCharacterIds ?? new() { Characters.OriginalId } )
			.Where( id => Characters.TryGet( id, out _ ) )
			.Distinct()
			.ToList();
		if ( _current.UnlockedCharacterIds.Count == 0 )
			_current.UnlockedCharacterIds.Add( Characters.OriginalId );

		_current.EverSelectedCharacterIds = ( _current.EverSelectedCharacterIds
				?? new() { Characters.OriginalId } )
			.Where( id => Characters.TryGet( id, out _ ) )
			.Distinct()
			.ToList();
		if ( !_current.EverSelectedCharacterIds.Contains( Characters.OriginalId ) )
			_current.EverSelectedCharacterIds.Add( Characters.OriginalId );

		// Owed reveals must still name a real, still-unlocked character — a renamed/removed character
		// or a debug re-lock would otherwise leave an undeliverable entry popping up on every launch.
		_current.PendingUnlockRevealIds = ( _current.PendingUnlockRevealIds ?? new() )
			.Where( id => Characters.TryGet( id, out _ ) && _current.UnlockedCharacterIds.Contains( id ) )
			.Distinct()
			.ToList();

		_current.SeenCharacterHelpIds = ( _current.SeenCharacterHelpIds ?? new() )
			.Where( id => Characters.TryGet( id, out _ ) )
			.Distinct()
			.ToList();

		// A null here (hand-edited or truncated file) would throw out of the progress-version reset
		// below — and since the new version is only stamped by that reset, the throw would repeat on
		// every launch and make the profile unloadable.
		_current.BeatenLevelIds ??= new();

		// Same hand-edited/truncated-file hardening for the achievement bookkeeping lists — a null here
		// would throw out of Achievements.CheckProgress on boot (capstone) or the next GIF export.
		_current.CapstoneWinCharacterIds ??= new();
		_current.GifExportedReplayKeys ??= new();
		_current.MaxPhasedBlockTypes ??= new();
		_current.AchievementTallies ??= new();
		_current.EarnedManualAchievements ??= new();

		if ( _current.ProgressVersion != LevelProgress.PROGRESS_VERSION )
		{
			ResetPlayerProgress();
			Save();
		}

		CharacterProgress.RepairSelectedCharacter();
	}

	public static void Save()
	{
		FileSystem.Data.WriteJson( FilePath, Current );
	}

	/// <summary>Wipe local campaign progression while preserving player preferences, replays and daily
	/// challenge history. Restores the starter character and focuses the map on Tutorial.</summary>
	[ConCmd( "reset_player_progress" )]
	public static void ResetPlayerProgressCmd()
	{
		if ( !Game.IsEditor ) return;
		ResetPlayerProgress();
		Save();

		if ( GameManager.Instance?.Stage is LevelSelectStage levelSelect )
			levelSelect.RefreshProgress();

		Log.Info( "[BlockParty] Reset local player progress: Original and Tutorial are unlocked." );
	}

	private static void ResetPlayerProgress()
	{
		Current.ProgressVersion = LevelProgress.PROGRESS_VERSION;
		Current.BeatenLevelIds.Clear();
		Current.LastSelectedLevelId = Levels.TutorialId;
		Current.LastSeenUnlockedLevelIds = new() { Levels.TutorialId };
		CharacterProgress.ResetToOriginal();
	}
}