Persistence/PersistenceManager.cs
using HC3.Terrain;
using System;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Text.Json.Nodes;
using System.Threading.Tasks;

namespace HC3.Persistence;

/// <summary>
/// Handles saving and loading the game.
/// </summary>
public sealed class PersistenceManager : Component,
	ISaveDataProperty<PersistenceManager.SaveMetadata>,
	Component.ISceneStage
{
	public const string SaveDirectoryName = "Saves";

	/// <summary>
	/// Increment this whenever a save-breaking change is made. Saves with a different version are automatically deleted.
	/// </summary>
	public const int CurrentSaveVersion = 2;

	/// <summary>
	/// Singleton
	/// </summary>
	public static PersistenceManager? Instance { get; private set; }

	[ConVar( "hc3.current_save", ConVarFlags.Saved | ConVarFlags.Replicated )]
	public static string CurrentSaveName { get; set; } = "Default";

	[Property]
	public int AutoSavePeriodMinutes { get; set; } = 2;

	public DateTime? CreatedTimeUtc { get; private set; }

	private RealTimeSince _lastAutoSave;

	public bool IsLoading { get; private set; }

	protected override void OnAwake()
	{
		CreatedTimeUtc = DateTime.UtcNow;
	}

	protected override void OnStart()
	{
		Instance = this;

		// Only the host should load the game
		if ( !Networking.IsHost )
			return;

		if ( !Load() )
		{
			Scene.GetAllComponents<TerrainGenerator>()
				.FirstOrDefault()?
				.Generate();
		}
	}

	protected override void OnFixedUpdate()
	{
		// Only the host should save the game
		if ( !Networking.IsHost )
			return;

		if ( AutoSavePeriodMinutes > 0 && _lastAutoSave > AutoSavePeriodMinutes * 60 )
		{
			AutoSave();
		}
	}

	private string GetSavePath( string saveName = null ) =>
		System.IO.Path.Combine( SaveDirectoryName, $"{saveName ?? CurrentSaveName}.json" );

	public bool NewGame( string name )
	{
		if ( FileSystem.Data.FileExists( GetSavePath( name ) ) )
		{
			return false;
		}

		CurrentSaveName = name;
		Scene.Load( Scene.Source );

		return true;
	}

	public bool LoadGame( string name )
	{
		if ( !FileSystem.Data.FileExists( GetSavePath( name ) ) )
		{
			return false;
		}

		CurrentSaveName = name;
		Scene.Load( Scene.Source );

		return true;
	}

	[Button( "Save Game", "save" )]
	private void SaveButton() => Save();

	public void Save( string? savePath = null )
	{
		savePath ??= GetSavePath( CurrentSaveName );

		var data = new JsonObject();

		var timer = Stopwatch.StartNew();

		data.Add( "Version", CurrentSaveVersion );

		foreach ( var property in FindProperties() )
		{
			try
			{
				data.Add( property.PropertyName, property.WriteValue( Scene ) );
			}
			catch ( Exception ex )
			{
				Log.Error( ex, $"Exception while saving property \"{property.PropertyName}\"" );
			}
		}

		NotificationPanel.Instance?.Broadcast( $"Saved \"{CurrentSaveName}\" in {timer.Elapsed.TotalMilliseconds:F2}ms", "save" );

		if ( !FileSystem.Data.DirectoryExists( SaveDirectoryName ) )
		{
			FileSystem.Data.CreateDirectory( SaveDirectoryName );
		}

		FileSystem.Data.WriteJson( savePath, data );

		_lastAutoSave = 0;
	}

	private void AutoSave()
	{
		// TODO: save to different file?

		Save();
		_lastAutoSave = 0;
	}

	[Button( "Delete Save", "delete" )]
	public void DeleteCurrentSave()
	{
		if ( Game.IsPlaying ) return;

		FileSystem.Data.DeleteFile( GetSavePath( CurrentSaveName ) );
	}

	[Button( "Load Save", "refresh" )]
	private void LoadButton() => Load();

	public bool Load( string? savePath = null )
	{
		savePath ??= GetSavePath( CurrentSaveName );

		if ( !FileSystem.Data.DirectoryExists( SaveDirectoryName ) ) return false;
		if ( !FileSystem.Data.FileExists( savePath ) ) return false;
		if ( FileSystem.Data.ReadJson<JsonObject>( savePath ) is not { } data ) return false;

		// Version check — delete incompatible saves and start fresh
		var savedVersion = data.TryGetPropertyValue( "Version", out var versionNode )
			? versionNode?.GetValue<int>() ?? 0
			: 0;

		if ( savedVersion != CurrentSaveVersion )
		{
			Log.Warning( $"[PersistenceManager] Save version mismatch (save={savedVersion}, current={CurrentSaveVersion}). Deleting incompatible save." );
			FileSystem.Data.DeleteFile( savePath );
			return false;
		}

		IsLoading = true;

		var timer = Stopwatch.StartNew();

		foreach ( var property in FindProperties() )
		{
			if ( !data.TryGetPropertyValue( property.PropertyName, out var value ) || value is null )
			{
				continue;
			}

			try
			{
				property.ReadValue( Scene, value );
			}
			catch ( Exception ex )
			{
				Log.Error( ex, $"Exception while loading property \"{property.PropertyName}\"" );
			}
		}

		Log.Info( $"Loaded game in {timer.Elapsed.TotalMilliseconds:F2}ms" );
		_lastAutoSave = 0;
		IsLoading = false;

		GridManager.Instance.DirtyRegions();
		return true;
	}

	public IEnumerable<string> GetAllSaveNames()
	{
		if ( !FileSystem.Data.DirectoryExists( SaveDirectoryName ) )
			return Enumerable.Empty<string>();
		var files = FileSystem.Data.FindFile( SaveDirectoryName, "*.json" );
		return files
			.Select( x => System.IO.Path.GetFileNameWithoutExtension( x ) )
			.Where( x => x != null )
			.ToArray();
	}

	/// <summary>
	/// Save properties can come from <see cref="Component"/>s, <see cref="GameObjectSystem"/>s,
	/// or little helper classes with public parameterless constructors.
	/// </summary>
	private ImmutableArray<ISaveDataProperty> FindProperties()
	{
		var found = new List<ISaveDataProperty>();

		// Find (singleton) Components or GameObjectSystems implementing ISaveDataProperty

		foreach ( var component in Scene.GetAll<ISaveDataProperty>() )
		{
			found.Add( component );
		}

		// Find types implementing ISaveDataProperty that aren't components or systems

		foreach ( var type in TypeLibrary.GetTypes<ISaveDataProperty>() )
		{
			if ( type.IsAbstract ) continue;
			if ( type.TargetType.IsAssignableTo( typeof( Component ) ) ) continue;
			if ( type.TargetType.IsAssignableTo( typeof( GameObjectSystem ) ) ) continue;

			// Try to create an instance, fail silently

			try
			{
				found.Add( type.Create<ISaveDataProperty>() );
			}
			catch
			{
				//
			}
		}

		return found
			.DistinctBy( x => x.PropertyName )
			.OrderBy( x => x.PropertyOrder )
			.ThenBy( x => x.PropertyName )
			.ToImmutableArray();
	}

	private record SaveMetadata( string Name, DateTime CreatedTimeUtc, DateTime ModifiedTimeUtc );

	string ISaveDataProperty.PropertyName => "Metadata";
	int ISaveDataProperty.PropertyOrder => int.MinValue;

	SaveMetadata ISaveDataProperty<SaveMetadata>.WriteValue( Scene scene ) =>
		new( CurrentSaveName, CreatedTimeUtc ?? DateTime.UtcNow, DateTime.UtcNow );

	void ISaveDataProperty<SaveMetadata>.ReadValue( Scene scene, SaveMetadata model )
	{
		CurrentSaveName = model.Name;
		CreatedTimeUtc = model.CreatedTimeUtc;
	}

	internal static async Task WaitForLoad()
	{
		while ( Instance.IsLoading )
		{
			await Instance.Task.Delay( 100 );
		}
	}
}