Save/SaveSystem.cs
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace Sandbox;

/// <summary>
/// A saving/loading system that captures the differences between the current scene state and the original scene.
/// </summary>
internal sealed class SaveSystem : GameObjectSystem<SaveSystem>, ISceneLoadingEvents
{
	private const int CurrentSaveVersion = 2;

	/// <summary>
	/// The current save format version. Saves with a different version are incompatible.
	/// </summary>
	public static int SaveVersion => CurrentSaveVersion;
	private Dictionary<string, string> _metadata = new();
	private readonly List<LoadedSceneEntry> _loadedScenes = new();
	private bool _suppressSystemScene;

	/// <summary>
	/// The path of the save file that was most recently loaded, if any.
	/// </summary>
	public string LoadedSavePath { get; private set; }

	/// <summary>
	/// Whether a save file is currently loaded.
	/// </summary>
	public bool HasLoadedSave => LoadedSavePath is not null;

	public SaveSystem( Scene scene ) : base( scene )
	{
	}

	/// <summary>
	/// Set metadata on the current session's save.
	/// This will be written on the next <see cref="Save"/> call.
	/// </summary>
	public void SetMetadata( string key, string value )
	{
		if ( string.IsNullOrWhiteSpace( key ) )
			throw new ArgumentException( "Metadata key cannot be null or empty.", nameof( key ) );

		_metadata[key] = value;
	}

	/// <summary>
	/// Get a metadata value from the current session, falling back to a default value.
	/// </summary>
	public string GetMetadata( string key, string defaultValue = null )
	{
		if ( key is null ) return defaultValue;

		return _metadata.TryGetValue( key, out var value ) ? value : defaultValue;
	}

	/// <summary>
	/// Get a copy of all metadata for the current session.
	/// </summary>
	public IReadOnlyDictionary<string, string> GetAllMetadata()
	{
		return new Dictionary<string, string>( _metadata );
	}

	/// <summary>
	/// Read metadata from a save file on disk without loading the full save.
	/// Returns null if the file doesn't exist or is invalid.
	/// </summary>
	public static IReadOnlyDictionary<string, string> GetFileMetadata( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) )
			return null;

		if ( !FileSystem.Data.FileExists( path ) )
			return null;

		try
		{
			var text = FileSystem.Data.ReadAllText( path );
			using var doc = JsonDocument.Parse( text );

			if ( doc.RootElement.TryGetProperty( "Metadata", out var metaElement ) )
			{
				return JsonSerializer.Deserialize<Dictionary<string, string>>( metaElement.GetRawText() );
			}

			return new Dictionary<string, string>();
		}
		catch ( Exception e )
		{
			Log.Warning( $"SaveSystem: Failed to read metadata from '{path}': {e.Message}" );
			return null;
		}
	}

	/// <summary>
	/// Read the save format version from a save file without loading the full save.
	/// Returns 0 if the file doesn't exist, is invalid, or has no version field.
	/// </summary>
	public static int GetFileSaveVersion( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) )
			return 0;

		if ( !FileSystem.Data.FileExists( path ) )
			return 0;

		try
		{
			var text = FileSystem.Data.ReadAllText( path );
			using var doc = JsonDocument.Parse( text );

			if ( doc.RootElement.TryGetProperty( "Version", out var versionElement ) )
				return versionElement.GetInt32();

			return 0;
		}
		catch
		{
			return 0;
		}
	}

	/// This means any changes made to the those original scenes are preserved when loading an older save.
	/// </summary>
	/// <returns>True if the save was successful.</returns>
	public bool Save( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) )
		{
			Log.Warning( "SaveSystem: Cannot save — path is null or empty." );
			return false;
		}

		if ( !Scene.IsValid() )
		{
			Log.Warning( "SaveSystem: Cannot save — no valid scene." );
			return false;
		}

		if ( _loadedScenes.Count == 0 )
		{
			Log.Warning( "SaveSystem: Cannot save — no tracked scene sources. The scene must be loaded from a SceneFile." );
			return false;
		}

		Scene.RunEvent<Global.ISaveEvents>( x => x.BeforeSave( path ) );

		var baseline = BuildCompositeBaseline();
		if ( baseline is null )
		{
			Log.Warning( "SaveSystem: Failed to build baseline from loaded scene sources." );
			return false;
		}

		var current = BuildCurrentSceneJson( Scene );
		if ( current is null )
		{
			Log.Warning( "SaveSystem: Failed to serialize current scene state." );
			return false;
		}

		// Calculate the diff between baseline and current state
		var patch = Json.CalculateDifferences( baseline, current, GameObject.DiffObjectDefinitions );
		var sceneSources = new JsonArray();
		foreach ( var entry in _loadedScenes )
		{
			sceneSources.Add( JsonValue.Create( entry.ResourcePath ) );
		}

		var primarySceneFile = GetPrimarySceneFile();
		var networkOwnership = CollectNetworkOwnership( Scene );
		var syncState = CollectSyncState( Scene );
		var requiredPackages = CollectRequiredPackages( _loadedScenes, current );
		var saveData = new JsonObject
		{
			["Version"] = CurrentSaveVersion,
			["SceneId"] = Scene.Id.ToString(),
			["SceneSources"] = sceneSources,
			["SceneProperties"] = primarySceneFile is not null ? SerializeScenePropertyDiffs( Scene, primarySceneFile ) : null,
			["Metadata"] = JsonSerializer.SerializeToNode( _metadata ),
			["Patch"] = Json.ToNode( patch ),
			["NetworkOwnership"] = networkOwnership,
			["SyncState"] = syncState,
			["RequiredPackages"] = requiredPackages,
		};

		try
		{
			// Make sure any parent directories exist first
			var dir = Path.GetDirectoryName( path );
			if ( !string.IsNullOrEmpty( dir ) )
				FileSystem.Data.CreateDirectory( dir );

			FileSystem.Data.WriteAllText( path, saveData.ToJsonString() );
			LoadedSavePath = path;
		}
		catch ( Exception e )
		{
			Log.Warning( $"SaveSystem: Failed to write save file '{path}': {e.Message}" );
			return false;
		}

		Scene.RunEvent<Global.ISaveEvents>( x => x.AfterSave( path ) );
		return true;
	}

	/// <summary>
	/// Load a previously saved game state from a file, applying the differences to the original scene file(s) to reconstruct the saved scene.
	/// </summary>
	/// <returns>True if the load was successful.</returns>
	public async Task<bool> Load( string path )
	{
		//
		// Host only
		//
		if ( !Networking.IsHost ) return false;

		if ( string.IsNullOrWhiteSpace( path ) )
		{
			Log.Warning( "SaveSystem: Cannot load — path is null or empty." );
			return false;
		}

		if ( !Scene.IsValid() )
		{
			Log.Warning( "SaveSystem: Cannot load — no valid scene." );
			return false;
		}

		if ( !FileSystem.Data.FileExists( path ) )
		{
			Log.Warning( $"SaveSystem: Save file '{path}' does not exist." );
			return false;
		}

		JsonObject saveRoot;

		try
		{
			var text = FileSystem.Data.ReadAllText( path );
			saveRoot = JsonNode.Parse( text )?.AsObject();
		}
		catch ( Exception e )
		{
			Log.Warning( $"SaveSystem: Failed to read save file '{path}': {e.Message}" );
			return false;
		}

		if ( saveRoot is null )
		{
			Log.Warning( $"SaveSystem: Save file '{path}' is empty or invalid." );
			return false;
		}

		// Validate that the save format version is compatible
		var saveVersion = saveRoot["Version"]?.GetValue<int>() ?? 0;
		if ( saveVersion != CurrentSaveVersion )
		{
			Log.Warning( $"SaveSystem: Save file '{path}' uses version {saveVersion}, but this build requires version {CurrentSaveVersion}. The save is incompatible." );
			return false;
		}

		var sceneSources = new List<string>();
		if ( saveRoot["SceneSources"] is JsonArray sourcesArray )
		{
			foreach ( var source in sourcesArray )
			{
				var val = source?.GetValue<string>();
				if ( !string.IsNullOrEmpty( val ) )
					sceneSources.Add( val );
			}
		}

		if ( sceneSources.Count == 0 )
		{
			Log.Warning( $"SaveSystem: Save file '{path}' has no scene sources." );
			return false;
		}

		// Resolve all scene files from disk, the first being the primary scene
		var sceneFiles = new List<SceneFile>();
		foreach ( var source in sceneSources )
		{
			var sf = ResourceLibrary.Get<SceneFile>( source );
			if ( sf is null )
			{
				Log.Warning( $"SaveSystem: Scene source '{source}' not found. Skipping." );
				continue;
			}
			sceneFiles.Add( sf );
		}

		if ( sceneFiles.Count == 0 )
		{
			Log.Warning( $"SaveSystem: None of the scene sources from save '{path}' could be found." );
			return false;
		}

		// Mount any cloud packages the save references before loading the scene
		if ( saveRoot["RequiredPackages"] is JsonArray pkgArray )
		{
			await MountRequiredPackages( pkgArray );
		}

		Scene.RunEvent<Global.ISaveEvents>( x => x.BeforeLoad( path ) );

		Json.Patch savedPatch = null;
		if ( saveRoot["Patch"] is JsonObject patchNode )
		{
			savedPatch = Json.FromNode<Json.Patch>( patchNode );
		}

		savedPatch ??= new Json.Patch();

		// Use the saved scene ID for the baseline root so the patch applies correctly
		var savedSceneId = Guid.TryParse( saveRoot["SceneId"]?.GetValue<string>(), out var parsedId )
			? parsedId
			: sceneFiles[0].Id;

		var baseline = BuildCompositeBaselineFromFiles( sceneFiles, savedSceneId );
		if ( baseline is null )
		{
			Log.Warning( "SaveSystem: Failed to build baseline from scene sources." );
			return false;
		}

		// Create a new SceneFile by applying the saved patch to the baseline, then load that
		var patched = Json.ApplyPatch( baseline, savedPatch, GameObject.DiffObjectDefinitions );
		var primarySceneFile = sceneFiles[0];
		var savedSceneProperties = saveRoot["SceneProperties"];
		var patchedSceneFile = BuildPatchedSceneFile( primarySceneFile, patched, savedSceneProperties );

		// Show loading screen first
		BroadcastShowLoadingScreen();
		await Task.Delay( 50 );

		_suppressSystemScene = true; // Make sure we don't load two system scenes.....

		CleanupSystem.PreserveBaselineForSaveLoad();

		var options = new SceneLoadOptions();
		options.SetScene( patchedSceneFile );
		Game.ChangeScene( options );

		var newSystem = SaveSystem.Current;
		if ( newSystem is null )
		{
			Log.Warning( "SaveSystem: Could not find new SaveSystem instance after ChangeScene." );
			return false;
		}

		// Keep track of the original scene sources so any subsequent re-save diffs correctly.
		newSystem._loadedScenes.Clear();
		foreach ( var sf in sceneFiles )
		{
			if ( string.IsNullOrEmpty( sf.ResourcePath ) ) continue;
			newSystem._loadedScenes.Add( new LoadedSceneEntry
			{
				ResourcePath = sf.ResourcePath,
				SceneFileId = sf.Id
			} );
		}

		// Restore metadata onto the new instance so AfterLoad event handlers can read it.
		newSystem._metadata = saveRoot["Metadata"] is JsonObject metaNode
			? JsonSerializer.Deserialize<Dictionary<string, string>>( metaNode ) ?? new Dictionary<string, string>()
			: new Dictionary<string, string>();
		newSystem.LoadedSavePath = path;

		// Restore [Sync] property values before network ownership so everything is populated BEFORE any ownership-change callbacks fire.
		if ( saveRoot["SyncState"] is JsonObject syncNode )
		{
			RestoreSyncState( newSystem.Scene, syncNode );
		}

		if ( saveRoot["NetworkOwnership"] is JsonObject ownershipNode )
		{
			RestoreNetworkOwnership( newSystem.Scene, ownershipNode );
		}

		newSystem.Scene.RunEvent<Global.ISaveEvents>( x => x.AfterLoad( path ) );
		return true;
	}

	// Before we load any scene, we keep track of the source scene file so we can diff against it later when saving
	void ISceneLoadingEvents.BeforeLoad( Scene scene, SceneLoadOptions options )
	{
		var sceneFile = options.GetSceneFile();
		if ( sceneFile is null ) return;

		if ( !options.IsAdditive )
		{
			_loadedScenes.Clear();
			_metadata.Clear();
			LoadedSavePath = null;
		}

		var resourcePath = sceneFile.ResourcePath;

		// The scene file might be in-memory (e.g. editor Play creates one via
		// CreateSceneFile). Try to resolve the original on-disk scene file by
		// checking Scene.Source, then by matching the scene file's Id against
		// all loaded SceneFile resources.
		if ( string.IsNullOrEmpty( resourcePath ) && scene.Source is SceneFile sourceFile )
		{
			resourcePath = sourceFile.ResourcePath;
		}

		if ( string.IsNullOrEmpty( resourcePath ) && sceneFile.Id != Guid.Empty )
		{
			var match = ResourceLibrary.GetAll<SceneFile>()
				.FirstOrDefault( x => x.Id == sceneFile.Id && !string.IsNullOrEmpty( x.ResourcePath ) );

			if ( match is not null )
				resourcePath = match.ResourcePath;
		}

		if ( string.IsNullOrEmpty( resourcePath ) )
			return;
		if ( _loadedScenes.Any( e => e.ResourcePath == resourcePath ) )
			return;

		_loadedScenes.Add( new LoadedSceneEntry
		{
			ResourcePath = resourcePath,
			SceneFileId = sceneFile.Id
		} );
	}

	// When loading a scene, we hook into it and make sure WantsSystemScene is false when we need to prevent duplicates
	Task ISceneLoadingEvents.OnLoad( Scene scene, SceneLoadOptions options, LoadingContext context )
	{
		if ( _suppressSystemScene )
		{
			scene.WantsSystemScene = false;
			_suppressSystemScene = false;
		}

		return Task.CompletedTask;
	}

	private sealed class LoadedSceneEntry
	{
		public string ResourcePath { get; init; }
		public Guid SceneFileId { get; init; }
	}

	private SceneFile GetPrimarySceneFile()
	{
		if ( _loadedScenes.Count == 0 ) return null;
		return ResourceLibrary.Get<SceneFile>( _loadedScenes[0].ResourcePath );
	}

	private static JsonArray CollectRequiredPackages( List<LoadedSceneEntry> loadedScenes, JsonObject currentSceneJson )
	{
		var packages = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		foreach ( var entry in loadedScenes )
		{
			var sf = ResourceLibrary.Get<SceneFile>( entry.ResourcePath );
			if ( sf is null ) continue;

			foreach ( var pkg in sf.GetReferencedPackages() )
			{
				if ( !string.IsNullOrEmpty( pkg ) )
					packages.Add( pkg );
			}
		}

		if ( currentSceneJson is not null )
		{
			foreach ( var pkg in Cloud.ResolvePrimaryAssetsFromJson( currentSceneJson ) )
			{
				if ( !string.IsNullOrEmpty( pkg.FullIdent ) )
					packages.Add( pkg.FullIdent );
			}
		}

		var result = new JsonArray();
		foreach ( var ident in packages )
		{
			result.Add( JsonValue.Create( ident ) );
		}

		return result;
	}

	private static async Task MountRequiredPackages( JsonArray packageArray )
	{
		foreach ( var node in packageArray )
		{
			var ident = node?.GetValue<string>();
			if ( string.IsNullOrEmpty( ident ) ) continue;

			// Skip packages that are already mounted
			if ( Package.TryGetCached( ident, out var _ ) )
				continue;

			await Package.MountAsync( ident, false );
		}
	}

	/// <summary>
	/// This builds a baseline JSON from all the loaded scene files that we've tracked in BeforeLoad.
	/// The final scene gets diffed against this.
	/// </summary>
	private JsonObject BuildCompositeBaseline()
	{
		var sceneFiles = new List<SceneFile>();
		foreach ( var entry in _loadedScenes )
		{
			var sf = ResourceLibrary.Get<SceneFile>( entry.ResourcePath );
			if ( sf is null )
			{
				Log.Warning( $"SaveSystem: Tracked scene '{entry.ResourcePath}' could not be found." );
				continue;
			}
			sceneFiles.Add( sf );
		}

		return BuildCompositeBaselineFromFiles( sceneFiles, Scene.Id );
	}

	/// <summary>
	/// Builds a composite baseline JSON from a list of scene files by merging
	/// all their GameObjects into a single root node.
	/// </summary>
	/// <param name="sceneFiles">The scene files to merge.</param>
	/// <param name="rootId">The GUID to use for the root node. Match the scene's root ID to make sure diffs dont get fucked.</param>
	private static JsonObject BuildCompositeBaselineFromFiles( List<SceneFile> sceneFiles, Guid rootId )
	{
		if ( sceneFiles.Count == 0 ) return null;

		var children = new JsonArray();

		foreach ( var sceneFile in sceneFiles )
		{
			if ( sceneFile?.GameObjects is null ) continue;

			foreach ( var go in sceneFile.GameObjects )
			{
				if ( go is null ) continue;
				children.Add( go.DeepClone() );
			}
		}

		// The root needs to look like a valid GameObject for the diff to actually work
		var root = new JsonObject
		{
			["__guid"] = rootId.ToString(),
			["Flags"] = 0,
			["Components"] = new JsonArray(),
			["Children"] = children,
		};

		return root;
	}

	/// <summary>
	/// Serializes the current live scene as a JSON object in the same format as <see cref="BuildCompositeBaseline"/> so the two can be diffed.
	/// </summary>
	private static JsonObject BuildCurrentSceneJson( Scene scene )
	{
		using var sceneScope = scene.Push();

		var children = new JsonArray();

		foreach ( var child in scene.Children )
		{
			// Skip DontDestroyOnLoad objects — they persist across scene loads and probably shouldn't be part of the save diff (?)
			if ( child.Flags.Contains( GameObjectFlags.DontDestroyOnLoad ) )
				continue;

			var jso = child.Serialize();
			if ( jso is null ) continue;

			children.Add( jso );
		}

		// Needs to look like a GameObject for diffs to work
		var root = new JsonObject
		{
			["__guid"] = scene.Id.ToString(),
			["Flags"] = 0,
			["Components"] = new JsonArray(),
			["Children"] = children,
		};

		return root;
	}

	/// <summary>
	/// Get any changes made to scene-level properties (like gravity, nav mesh settings, etc)
	/// </summary>
	private static JsonNode SerializeScenePropertyDiffs( Scene scene, SceneFile sceneFile )
	{
		var currentProps = scene.SerializeProperties();
		var baseProps = sceneFile.SceneProperties;

		if ( baseProps is null )
			return currentProps?.DeepClone();

		// Only store properties that ACTUALLY differ from the baseline
		var diffs = new JsonObject();
		var hasChanges = false;

		foreach ( var prop in currentProps )
		{
			if ( baseProps.TryGetPropertyValue( prop.Key, out var baseValue ) )
			{
				if ( !JsonNode.DeepEquals( baseValue, prop.Value ) )
				{
					diffs[prop.Key] = prop.Value?.DeepClone();
					hasChanges = true;
				}
			}
			else
			{
				// New property not in baseline
				diffs[prop.Key] = prop.Value?.DeepClone();
				hasChanges = true;
			}
		}

		return hasChanges ? diffs : null;
	}

	/// <summary>
	/// Snapshots network ownership for all owned GameObjects in the scene, storing the owner's SteamID.
	/// </summary>
	private static JsonObject CollectNetworkOwnership( Scene scene )
	{
		var result = new JsonObject();

		foreach ( var go in scene.GetAllObjects( true ) )
		{
			if ( !go.Network.Active ) continue;

			var owner = go.Network.Owner;
			if ( owner is null ) continue;

			result[go.Id.ToString()] = owner.SteamId.Value;
		}

		return result;
	}

	/// <summary>
	/// Restores network ownership by matching saved SteamIDs to connected players.
	/// </summary>
	private static void RestoreNetworkOwnership( Scene scene, JsonObject ownershipData )
	{
		var steamIdToConnection = new Dictionary<long, Connection>();
		foreach ( var conn in Connection.All )
		{
			steamIdToConnection.TryAdd( conn.SteamId.Value, conn );
		}

		// Anything we spawn here, let's batch it
		using var _ = scene.BatchGroup();

		foreach ( var (goGuidStr, node) in ownershipData )
		{
			if ( !Guid.TryParse( goGuidStr, out var goGuid ) ) continue;

			var go = scene.Directory.FindByGuid( goGuid ) as GameObject;
			if ( go is null || !go.IsValid() ) continue;

			Connection target = null;

			if ( node?.GetValue<long>() is long steamIdValue && steamIdValue != 0 )
			{
				steamIdToConnection.TryGetValue( steamIdValue, out target );
			}

			if ( target is null ) continue;

			// Only the host can reassign ownership, and the object must be networked
			if ( !go.Network.Active )
			{
				go.NetworkSpawn( target );
			}
			else
			{
				go.Network.AssignOwnership( target );
			}
		}
	}

	/// <summary>
	/// Collects all [Sync]-only property values from every component in the scene, so the networked state can be restored
	/// </summary>
	private static JsonObject CollectSyncState( Scene scene )
	{
		var result = new JsonObject();

		foreach ( var go in scene.GetAllObjects( true ) )
		{
			if ( go.Flags.Contains( GameObjectFlags.DontDestroyOnLoad ) )
				continue;

			foreach ( var component in go.Components.GetAll() )
			{
				var typeDesc = TypeLibrary.GetType( component.GetType() );
				if ( typeDesc is null ) continue;

				var syncProps = typeDesc.Properties.Where( p => p.HasAttribute<SyncAttribute>() );
				JsonObject componentData = null;

				foreach ( var syncProp in syncProps )
				{
					// Skip properties that also have [Property] since those are already captured by the main diff
					if ( syncProp.HasAttribute<PropertyAttribute>() )
						continue;

					try
					{
						var value = syncProp.GetValue( component );

						// Try JSON first, then fallback to bytepack (mostly for the types that don't serialize well)
						JsonNode node;
						try
						{
							node = Json.ToNode( value, syncProp.PropertyType );
						}
						catch
						{
							var bs = ByteStream.Create( 256 );
							try
							{
								Game.TypeLibrary.ToBytes( value, ref bs );
								var base64 = Convert.ToBase64String( bs.ToArray() );
								node = new JsonObject { ["__bytepack"] = base64 };
							}
							finally
							{
								bs.Dispose();
							}
						}

						componentData ??= new JsonObject();
						componentData[syncProp.Name] = node;
					}
					catch ( Exception e )
					{
						Log.Warning( $"SaveSystem: Failed to serialize [Sync] property {component.GetType().Name}.{syncProp.Name}: {e.Message}" );
					}
				}

				if ( componentData is not null )
				{
					result[component.Id.ToString()] = componentData;
				}
			}
		}

		return result;
	}

	/// <summary>
	/// Restore the [Sync]-only properties
	/// </summary>
	private static void RestoreSyncState( Scene scene, JsonObject syncData )
	{
		foreach ( var (compGuidStr, node) in syncData )
		{
			if ( !Guid.TryParse( compGuidStr, out var compGuid ) ) continue;
			if ( node is not JsonObject propData ) continue;

			var target = scene.Directory.FindComponentByGuid( compGuid );
			if ( target is null ) continue;

			var typeDesc = TypeLibrary.GetType( target.GetType() );
			if ( typeDesc is null ) continue;

			var syncProps = typeDesc.Properties.Where( p => p.HasAttribute<SyncAttribute>() );

			foreach ( var syncProp in syncProps )
			{
				if ( syncProp.HasAttribute<PropertyAttribute>() )
					continue;

				if ( !propData.ContainsKey( syncProp.Name ) )
					continue;

				try
				{
					var jsonValue = propData[syncProp.Name];

					object value;

					// Check if this was serialized via BytePack fallback
					if ( jsonValue is JsonObject wrapper && wrapper.ContainsKey( "__bytepack" ) )
					{
						var base64 = wrapper["__bytepack"]!.GetValue<string>();
						var bytes = Convert.FromBase64String( base64 );
						var reader = ByteStream.CreateReader( bytes );
						try
						{
							value = Game.TypeLibrary.FromBytes<object>( ref reader );
						}
						finally
						{
							reader.Dispose();
						}
					}
					else
					{
						// JSON deserialize as normal
						value = Json.FromNode( jsonValue, syncProp.PropertyType );
					}

					syncProp.SetValue( target, value );
				}
				catch ( Exception e )
				{
					Log.Warning( $"SaveSystem: Failed to restore [Sync] property {target.GetType().Name}.{syncProp.Name}: {e.Message}" );
				}
			}
		}
	}

	/// <summary>
	/// Tells all clients to show the loading screen immediately, before Scene.Load() fires.
	/// </summary>
	[Rpc.Broadcast( NetFlags.HostOnly )]
	private static void BroadcastShowLoadingScreen()
	{
		LoadingScreen.Title = "Loading Save...";
		LoadingScreen.IsVisible = true;
	}

	/// <summary>
	/// Constructs a <see cref="SceneFile"/> from the patched JSON data so we can load it just like any other scene
	/// </summary>
	private static SceneFile BuildPatchedSceneFile( SceneFile original, JsonObject patchedRoot, JsonNode savedSceneProperties )
	{
		var patchedSceneFile = new SceneFile
		{
			Id = original.Id,
		};

		if ( patchedRoot["Children"] is JsonArray goArray )
		{
			patchedSceneFile.GameObjects = goArray
				.Where( x => x is JsonObject )
				.Select( x => x.DeepClone().AsObject() )
				.ToArray();
		}

		// Start with original scene properties, then apply any saved overrides
		var sceneProperties = original.SceneProperties?.DeepClone()?.AsObject() ?? new JsonObject();

		if ( savedSceneProperties is JsonObject overrides )
		{
			foreach ( var prop in overrides )
			{
				sceneProperties[prop.Key] = prop.Value?.DeepClone();
			}
		}

		patchedSceneFile.SceneProperties = sceneProperties;

		return patchedSceneFile;
	}
}