Code/FileIO/FileController.cs
using Sandbox;
using Sandbox.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace SandbankDatabase;

internal static class FileController
{
	/// <summary>
	/// Only let one thread write/read a collection at a time using this lock.
	/// </summary>
	private static Dictionary<string, object> _collectionWriteLocks = new();
	private static IFileIOProvider IOProvider;  
	
	public static void Initialise()
	{
		// Don't re-create if it already exists. Otherwise in the unit tests we
		// lose all of the files after initialisation.
		if ( IOProvider == null )
		{
			Logging.Log( "recreating file IO provider..." );

			if ( TestHelpers.IsUnitTests )
				IOProvider = new MockFileIOProvider();
			else
				IOProvider = new FileIOProvider();
		}
	}

	public static void CreateCollectionLock( string collection )
	{
		Logging.Log( $"creating collection write lock for collection \"{collection}\"" );

		_collectionWriteLocks[collection] = new();
	}

	/// <summary>
	/// Returns null on success, or the error message on failure.
	/// </summary>
	public static string DeleteDocument( string collection, string documentID )
	{
		try
		{
			lock ( _collectionWriteLocks[collection] )
			{
				IOProvider.DeleteFile( $"{ConfigController.DATABASE_NAME}/{collection}/{documentID}" );
			}

			return null;
		}
		catch ( Exception e )
		{
			return Logging.ExtractExceptionString( e );
		}
	}

	public static void WriteFile( string path, string contents )
	{
		IOProvider.WriteAllText( path, contents );
	}

	public static void DeleteFile( string path )
	{
		IOProvider.DeleteFile( path );
	}

	public static bool FileExists( string fileName, string folder )
	{
		return IOProvider.FindFile( folder ).Any(x => x == fileName);
	}

	/// <summary>
	/// Save the document to file. We use a JSON merge strategy, so that if the current file has
	/// data that this new document doesn't recognise, it is not lost (the JSON is merged).
	/// This stops data from being wiped when doing things like renaming fields.
	/// 
	/// Returns null on success, or the error message on failure.
	/// </summary>
	public static string SaveDocument( Document document )
	{
		try
		{
			string finalJSONData = "";

			// Load document currently stored on disk, if there is one.
			string data = ConfigController.MERGE_JSON ?
				IOProvider.ReadAllText( $"{ConfigController.DATABASE_NAME}/{document.CollectionName}/{document.UID}" )
				: null;

			if ( data != null && data[0] == 'O' )
				data = Obfuscation.UnobfuscateFileText( data );

			if ( ConfigController.MERGE_JSON && data != null )
			{
				var currentDocument = JsonDocument.Parse( data );

				// Get data from the new document we want to save.
				var saveableProperties = PropertyDescriptionsCache.GetPropertyDescriptionsForType( 
					document.Data.GetType().ToString(), document.Data 
				);
				var propertyValuesMap = new Dictionary<string, PropertyDescription>();

				foreach ( var property in saveableProperties )
					propertyValuesMap.Add( property.Name, property );

				// Construct a new JSON object.
				var jsonObject = new JsonObject();
				
				// Add data by iterating over fields of old version.
				foreach ( var oldDocumentProperty in currentDocument.RootElement.EnumerateObject() )
				{
					if ( propertyValuesMap.ContainsKey( oldDocumentProperty.Name ) )
					{
						// Prefer values from the newer document.
						var value = propertyValuesMap[oldDocumentProperty.Name].GetValue( document.Data );
						var type = propertyValuesMap[oldDocumentProperty.Name].PropertyType;

						jsonObject.Add( oldDocumentProperty.Name, JsonSerializer.SerializeToNode( value, type ) );
					}
					else
					{
						// If newer document doesn't have this field, use the value from old document.
						jsonObject.Add( oldDocumentProperty.Name, JsonNode.Parse( oldDocumentProperty.Value.GetRawText() ) );
					}
				}

				// Also add any new fields the old version might not have.
				foreach ( var property in propertyValuesMap )
				{
					if ( !jsonObject.ContainsKey( property.Key ) )
					{
						var value = propertyValuesMap[property.Key].GetValue( document.Data );
						var type = propertyValuesMap[property.Key].PropertyType;

						jsonObject.Add( property.Key, JsonSerializer.SerializeToNode( value, type ) );
					}
				}

				// Serialize the object we just created.
				finalJSONData = Serialisation.SerialiseJSONObject( jsonObject );
			}
			else
			{
				// If no file exists for this record then we can just serialise the class directly.
				finalJSONData = Serialisation.SerialiseClass( document.Data, document.DocumentType );
			}

			if ( ConfigController.OBFUSCATE_FILES )
				finalJSONData = Obfuscation.ObfuscateFileText( finalJSONData );

			lock ( _collectionWriteLocks[document.CollectionName] )
			{
				IOProvider.WriteAllText( $"{ConfigController.DATABASE_NAME}/{document.CollectionName}/{document.UID}", finalJSONData );
			}

			return null;
		}
		catch ( Exception e )
		{
			return Logging.ExtractExceptionString( e );
		}
	}

	/// <summary>
	/// The second return value is null on success, and contains the error message
	/// on failure.
	/// </summary>
	public static (List<string>, string) ListCollectionNames()
	{
		try
		{
			return (IOProvider.FindDirectory( ConfigController.DATABASE_NAME ).ToList(), null);
		}
		catch ( Exception e )
		{
			return (null, Logging.ExtractExceptionString( e ));
		}
	}

	/// <summary>
	/// The second return value contains the error message (or null if successful).
	/// </summary>
	public static (Collection, string) LoadCollectionDefinition( string collectionName )
	{
		try
		{
			string data;

			if ( !_collectionWriteLocks.ContainsKey( collectionName ) )
				CreateCollectionLock( collectionName );

			lock ( _collectionWriteLocks[collectionName] )
			{
				data = IOProvider.ReadAllText( $"{ConfigController.DATABASE_NAME}/{collectionName}/definition.txt" );
			}

			if ( data == null )
				return (null, $"no definition.txt for collection \"{collectionName}\" found - see RepairGuide.txt");

			Collection collection;

			try
			{
				collection = Serialisation.DeserialiseClass<Collection>( data );
			}
			catch ( Exception e )
			{
				return (null, $"error thrown when deserialising definition.txt for \"{collectionName}\": " + Logging.ExtractExceptionString( e ));
			}

			if ( collection.CollectionName != collectionName )
				return (null, $"failed to load definition.txt for collection \"{collectionName}\" - the CollectionName in the definition.txt differed from the name of the directory ({collectionName} vs {collection.CollectionName}) - see RepairGuide.txt");

			try
			{
				collection.DocumentClassType = GlobalGameNamespace.TypeLibrary
					.GetType( collection.DocumentClassTypeSerialized )
					.TargetType;
			}
			catch ( Exception e )
			{
				return (null, $"couldn't load the type described by the definition.txt for collection \"{collectionName}\" - most probably you renamed or removed your data type - see RepairGuide.txt: " + Logging.ExtractExceptionString( e ));
			}

			return (collection, null);
		}
		catch ( Exception e )
		{
			return (null, Logging.ExtractExceptionString( e ));
		}
	}

	/// <summary>
	/// The second return value contains the error message (or null if successful).
	/// </summary>
	public static (List<Document>, string) LoadAllCollectionsDocuments( Collection collection )
	{
		try
		{
			List<Document> output = new();

			lock ( _collectionWriteLocks[collection.CollectionName] )
			{
				var files = IOProvider.FindFile( $"{ConfigController.DATABASE_NAME}/{collection.CollectionName}/" )
					.Where( x => x != "definition.txt" )
					.ToList();

				foreach ( var file in files )
				{
					var contents = IOProvider.ReadAllText( $"{ConfigController.DATABASE_NAME}/{collection.CollectionName}/{file}" );

					if ( contents[0] == 'O' )
						contents = Obfuscation.UnobfuscateFileText( contents );

					try
					{
						var document = new Document( Serialisation.DeserialiseClass( contents, collection.DocumentClassType ), 
							collection.DocumentClassType, 
							false,
							collection.CollectionName );

						if ( file != document.UID )
							return (null, $"failed loading document \"{file}\": the filename does not match the UID ({file} vs {document.UID}) - see RepairGuide.txt");

						output.Add( document );
					}
					catch ( Exception e )
					{
						return (null, $"failed loading document \"{file}\" - your JSON is probably invalid: " + Logging.ExtractExceptionString( e ) );
					}
				}
			}

			return (output, null);
		}
		catch ( Exception e )
		{
			return (null, Logging.ExtractExceptionString( e ));
		}
	}

	/// <summary>
	/// Returns null on success, or the error message on failure.
	/// </summary>
	public static string SaveCollectionDefinition( Collection collection )
	{
		try
		{
			var data = Serialisation.SerialiseClass( collection );

			lock ( _collectionWriteLocks[collection.CollectionName] )
			{
				if ( !IOProvider.DirectoryExists( $"{ConfigController.DATABASE_NAME}/{collection.CollectionName}" ) )
					IOProvider.CreateDirectory( $"{ConfigController.DATABASE_NAME}/{collection.CollectionName}" );

				IOProvider.WriteAllText( $"{ConfigController.DATABASE_NAME}/{collection.CollectionName}/definition.txt", data );
			}

			return null;
		}
		catch ( Exception e )
		{
			return Logging.ExtractExceptionString( e );
		}
	}

	/// <summary>
	/// Returns null on success, or the error message on failure.
	/// </summary>
	private static string DeleteCollection( string name )
	{
		try
		{
			lock ( _collectionWriteLocks[name] )
			{
				IOProvider.DeleteDirectory( $"{ConfigController.DATABASE_NAME}/{name}" );
			}

			return null;
		}
		catch ( Exception e )
		{
			return Logging.ExtractExceptionString( e );
		}
	}

	/// <summary>
	/// Wipes all sandbank files. Returns null on success and the error message on failure.
	/// </summary>
	public static string WipeFilesystem()
	{
		try
		{
			var (collections, error) = ListCollectionNames();

			if ( error != null )
				return $"failed to wipe filesystem: {error}";

			// Don't delete collection folders when we are half-way through writing to them.
			lock ( Cache.WriteInProgressLock )
			{
				foreach ( var collection in collections )
				{
					error = DeleteCollection( collection );

					if ( error != null )
						return $"failed to wipe filesystem: {error}";
				}
			}

			return null;
		}
		catch ( Exception e )
		{
			return Logging.ExtractExceptionString( e );
		}
	}

	/// <summary>
	/// Wipes all backups.
	/// </summary>
	public static void WipeBackups()
	{
		var backupFolders = ListBackupFolders();

		lock ( Backups.BackupLock )
		{
			foreach ( var folder in backupFolders )
			{
				DeleteBackup( folder );
			}
		}
	}

	/// <summary>
	/// Creates the directories needed for the database. Returns null on success, or the error message
	/// on failure.
	/// </summary>
	/// 
	public static string EnsureFileSystemSetup()
	{
		var attempt = 0;
		string error = "";

		while ( true )
		{
			try
			{
				if ( attempt++ >= 10 )
					return "failed to ensure filesystem is setup after 10 tries: " + error;

				// Create main directory.
				if ( !IOProvider.DirectoryExists( ConfigController.DATABASE_NAME ) )
					IOProvider.CreateDirectory( ConfigController.DATABASE_NAME );

				// Create backups directory.
				if ( !IOProvider.DirectoryExists( $"{ConfigController.DATABASE_NAME}_backups" ) )
					IOProvider.CreateDirectory( $"{ConfigController.DATABASE_NAME}_backups" );

				return null;
			}
			catch ( Exception e )
			{
				error = Logging.ExtractExceptionString( e );
			}
		}
	}

	public static void SaveBackupCollectionDefinition( string backupFolderName, Collection collection )
	{
		var data = Serialisation.SerialiseClass( collection );
		var path = $"{ConfigController.DATABASE_NAME}_backups/{backupFolderName}/{collection.CollectionName}/definition.txt";

		IOProvider.WriteAllText( path, data );
	}

	public static void SaveBackupDocument( string backupFolderName, Collection collection, Document document )
	{
		string jsonData = Serialisation.SerialiseClass( document.Data, document.DocumentType );

		if ( ConfigController.OBFUSCATE_FILES )
			jsonData = Obfuscation.ObfuscateFileText( jsonData );

		var path = $"{ConfigController.DATABASE_NAME}_backups/{backupFolderName}/{collection.CollectionName}/{document.UID}";

		IOProvider.WriteAllText( path, jsonData );
	}

	public static List<string> ListFiles( string path )
	{
		return IOProvider.FindFile( path ).ToList();
	}

	public static string ReadFile( string path )
	{
		return IOProvider.ReadAllText( path );
	}

	/// <summary>
	/// List backup folders.
	/// </summary>
	public static List<string> ListBackupFolders()
	{
		return IOProvider.FindDirectory( $"{ConfigController.DATABASE_NAME}_backups" ).ToList();
	}

	public static void CreateBackupCollectionFolder( string backupFolderName, Collection collection )
	{
		IOProvider.CreateDirectory( $"{ConfigController.DATABASE_NAME}_backups/{backupFolderName}/{collection.CollectionName}" );
	}

	public static void DeleteBackup( string backupFolderName )
	{
		IOProvider.DeleteDirectory( $"{ConfigController.DATABASE_NAME}_backups/{backupFolderName}" );
	}
}