Game/CloudProgress.cs
using System.Collections.Generic;
using System.Linq;

namespace BlockParty;

/// <summary>
/// Cross-machine recovery mirror for map progression. The local <see cref="GameSettings.BeatenLevelIds"/>
/// file stays the source of truth (offline play works unchanged); every campaign victory ALSO stamps a
/// per-player backend stat, and on boot the account's stats are fetched — one request returns all of
/// them — and any mirrored levels are merged back into the local list. A second computer, a reinstall
/// or a lost data folder then restores the player's map instead of restarting it. Character unlocks
/// aren't mirrored separately: a restored forced-character level implies its character was earned, so
/// the merge re-derives those (daily-only unlocks are the one thing not recovered). Stat names carry
/// <see cref="LevelProgress.PROGRESS_VERSION"/>: player stats can never be deleted, so an unversioned
/// mirror would resurrect progress a version bump deliberately wiped — versioned names orphan the old
/// mirror together with the local wipe.
/// </summary>
public static class CloudProgress
{
	private static string Prefix => $"level_beaten_v{LevelProgress.PROGRESS_VERSION}_";

	/// <summary>Mirror a campaign victory to the player's backend stats, flushed immediately so
	/// quitting right after a win can't lose it. Called on EVERY victory, not just the
	/// unbeaten→beaten flip: the merge only reads a stat's existence, and re-submitting covers a
	/// first win whose submit was lost offline. Editor sessions never submit — the map's debug
	/// beaten-toggle and dev wins would pollute the account's mirror.</summary>
	public static void SubmitBeaten( string levelId )
	{
		if ( Game.IsEditor || string.IsNullOrEmpty( levelId ) )
			return;

		Sandbox.Services.Stats.Increment( Prefix + levelId, 1 );
		Sandbox.Services.Stats.Flush();
	}

	/// <summary>Fetch the account's stats and mark any mirrored level beaten locally. Best-effort:
	/// offline or failed fetches leave the local file as this session's only record (call sites retry
	/// on each level-select entry; the engine throttles the underlying fetch). When anything was
	/// restored, the unlock-reveal snapshot absorbs the healed map — recovered progress must not
	/// replay a node-by-node unlock ceremony — and an open level-select refreshes in place.</summary>
	public static async System.Threading.Tasks.Task MergeAsync()
	{
		// The editor runs under the #local package ident, a different stat namespace from the shipped
		// game — a merge here could never see real progress, and SubmitBeaten never writes to it.
		if ( Game.IsEditor )
			return;

		try
		{
			var stats = Sandbox.Services.Stats.LocalPlayer;
			if ( stats is null )
				return;
			await stats.Refresh();

			var restored = new List<string>();
			foreach ( var stat in stats )
			{
				// Increments accumulate into a stat's SUM, not Value (see RefreshBackendAttempts).
				if ( stat.Sum <= 0 || !stat.Name.StartsWith( Prefix, System.StringComparison.OrdinalIgnoreCase ) )
					continue;

				string id = stat.Name[Prefix.Length..];
				if ( Levels.Get( id ) is null || LevelProgress.IsBeaten( id ) )
					continue; // renamed/removed level, or already recorded locally

				LevelProgress.MarkBeaten( id );
				restored.Add( id );
			}

			if ( restored.Count == 0 )
				return;

			// Only the ids restored THIS merge: the victory path unlocks a forced level's character
			// before marking it beaten, so a cloud-beaten forced level always means an earned character.
			// Locally beaten ids are left alone so this can never override a debug re-lock.
			var restoredCharacters = new List<string>();
			foreach ( var id in restored )
			{
				string characterId = Levels.Get( id ).ForcedCharacterId;
				if ( CharacterProgress.Restore( characterId ) )
					restoredCharacters.Add( characterId );
			}

			if ( LevelMap.Nodes.Count > 0 ) // empty-map guard, mirroring PrepareUnlockQueue
				LevelProgress.CaptureNewlyUnlocked( LevelMap.Nodes.Where( n => n.Selectable ).Select( n => n.LevelId ) );

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

			Log.Info( $"[BlockParty] Restored {restored.Count} beaten level(s) from backend stats: {string.Join( ", ", restored )}." );
			if ( restoredCharacters.Count > 0 )
				Log.Info( $"[BlockParty] Restored {restoredCharacters.Count} character unlock(s) implied by them: {string.Join( ", ", restoredCharacters )}." );
		}
		catch ( System.Exception e )
		{
			Log.Info( $"[BlockParty] Beaten-level stat merge failed ({e.Message}); local progress gates this session." );
		}
	}
}