Editor/Data/ArchStorage.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using Sandbox;

namespace Sunless.Architecture;

public static class ArchStorage {
	public static string PlanDirectory => ArchDataRoot.For( "plans" );
	public static string KitDirectory => ArchDataRoot.For( "kits" );
	public static string ArchetypeDirectory => ArchDataRoot.For( "archetypes" );

	public const int CurrentPlanVersion = 4;

	// Rebuilt on assembly change — STJ freezes converters, hotload carries stale statics
	static JsonSerializerOptions indented;
	static JsonSerializerOptions compact;
	static string builtFor;

	static JsonSerializerOptions Options => Current( ref indented, true );

	static JsonSerializerOptions Compact => Current( ref compact, false );

	static JsonSerializerOptions Current( ref JsonSerializerOptions held, bool indent ) {
		var assembly = typeof( ArchUnit ).Assembly.FullName;

		if ( builtFor != assembly ) {
			builtFor = assembly;
			indented = null;
			compact = null;
		}

		return held ??= new JsonSerializerOptions {
			WriteIndented = indent,
			Converters = { new JsonStringEnumConverter() }
		};
	}

	// Scene.Source is null during hotload — remember last resolved path
	static readonly Dictionary<Guid, string> located = new();

	public static string PlanPathFor( Scene scene ) {
		if ( scene is null ) {
			return null;
		}

		var source = scene.Source?.ResourcePath;

		if ( string.IsNullOrWhiteSpace( source ) ) {
			return located.TryGetValue( scene.Id, out var remembered ) ? remembered : null;
		}

		var slug = source.Replace( '\\', '/' ).TrimStart( '/' ).Replace( ".scene", "" );
		var path = $"{PlanDirectory}/{slug}.archplan.json";

		located[scene.Id] = path;

		return path;
	}

	public static string KitPathFor( string name ) => $"{KitDirectory}/{name}.archkit.json";

	public static List<string> PlanPaths() {
		var folder = Absolute( PlanDirectory );

		try {
			if ( folder is null || !System.IO.Directory.Exists( folder ) ) {
				return new List<string>();
			}

			return System.IO.Directory.GetFiles( folder, "*.archplan.json", System.IO.SearchOption.AllDirectories )
				.Select( file => $"{PlanDirectory}/{file[folder.Length..].Replace( '\\', '/' ).TrimStart( '/' )}" )
				.OrderBy( path => path )
				.ToList();
		} catch ( Exception exception ) {
			Log.Warning( $"Architecture could not list {PlanDirectory}: {exception.Message}" );
			return new List<string>();
		}
	}

	public static ArchPlan LoadPlan( Scene scene ) {
		var path = PlanPathFor( scene );
		var plan = (path is null ? null : Read<ArchPlan>( path )) ?? new ArchPlan();
		Migrate( plan );
		plan.Normalize();

		return plan;
	}

	public static bool Supports( ArchPlan plan ) => plan is null || plan.Version <= CurrentPlanVersion;

	public static void Migrate( ArchPlan plan ) {
		if ( plan is null || plan.Version >= CurrentPlanVersion ) {
			return;
		}

		plan.Version = CurrentPlanVersion;
	}

	public static bool SavePlan( Scene scene, ArchPlan plan ) {
		if ( !Supports( plan ) ) {
			Log.Warning( $"Architecture: refused to save a plan at version {plan?.Version}, newer than this editor supports ({CurrentPlanVersion})." );
			return false;
		}

		var path = PlanPathFor( scene );

		if ( path is null ) {
			Log.Warning( "Architecture: this scene has no asset path yet, so its plan cannot be saved. Save the scene first." );
			return false;
		}

		if ( Unreadable<ArchPlan>( path ) ) {
			Log.Warning( $"Architecture: refused to save over {path} - it is on disk but does not read back, so saving would replace a layout this editor cannot see." );
			return false;
		}

		if ( plan is not null && !plan.HasContent && Read<ArchPlan>( path )?.HasContent == true ) {
			Log.Warning( $"Architecture: refused to save an empty plan over {path}, which has a layout in it. Reopen the scene to load it back." );
			return false;
		}

		return Write( path, plan );
	}

	public static string Snapshot( ArchPlan plan ) {
		return plan is null ? null : JsonSerializer.Serialize( plan, Options );
	}

	public static string Serialize<T>( T value ) => JsonSerializer.Serialize( value, Options );

	public static string Keyed<T>( T value ) => JsonSerializer.Serialize( value, Compact );

	public static T Deserialize<T>( string text ) where T : class {
		if ( string.IsNullOrWhiteSpace( text ) ) {
			return null;
		}

		try {
			return JsonSerializer.Deserialize<T>( text, Options );
		} catch ( Exception exception ) {
			Log.Warning( $"Architecture could not deserialize {typeof( T ).Name}: {exception.Message}" );
			return null;
		}
	}

	public static ArchPlan Restore( string snapshot ) {
		if ( string.IsNullOrWhiteSpace( snapshot ) ) {
			return null;
		}

		try {
			var plan = JsonSerializer.Deserialize<ArchPlan>( snapshot, Options );
			Migrate( plan );
			plan?.Normalize();

			return plan;
		} catch ( Exception exception ) {
			Log.Warning( $"Architecture could not restore a plan snapshot: {exception.Message}" );
			return null;
		}
	}

	public static ArchKit LoadKit( string name ) {
		if ( string.IsNullOrWhiteSpace( name ) ) {
			return new ArchKit();
		}

		var kit = Read<ArchKit>( KitPathFor( name ) ) ?? new ArchKit { Name = name };

		Stock( kit );

		return kit;
	}

	static void Stock( ArchKit kit ) {
		var shelves = ArchCatalogs.Load();

		shelves.Stock( ArchShelf.Openings, kit.Openings );
		shelves.Stock( ArchShelf.Walls, kit.Walls );
		shelves.Stock( ArchShelf.Profiles, kit.Profiles );
		shelves.Stock( ArchShelf.Cornices, kit.Cornices );
		shelves.Stock( ArchShelf.Cabinets, kit.Cabinets );
		shelves.Stock( ArchShelf.RoadLines, kit.RoadLines );

		kit.Cabinets?.RemoveAll( type => type.Hidden );
	}

	public static bool SaveKit( ArchKit kit ) {
		return Write( KitPathFor( kit.Name ), kit );
	}

	public static string ArchetypePathFor( string name ) => $"{ArchetypeDirectory}/{name}.archetype.json";

	public static List<ArchArchetype> LoadArchetypes() => Listed( ArchCatalogs.Load() );

	public static List<ArchArchetype> Listed( ArchCatalogs shelves ) {
		var listed = new List<ArchArchetype>();

		shelves.Stock( ArchShelf.Types, listed );

		return listed.Where( archetype => !archetype.Hidden ).OrderBy( archetype => archetype.Title ).ToList();
	}

	public static bool SaveArchetype( ArchArchetype archetype ) {
		return ArchShelved.Save( ArchShelf.Types, archetype );
	}

	// Authored = delete file; shipped = write Hidden flag
	public static bool RemoveArchetype( string name ) {
		if ( string.IsNullOrWhiteSpace( name ) ) {
			return false;
		}

		var shipped = ArchShelved.Ships<ArchArchetype>( ArchShelf.Types )
			.FirstOrDefault( archetype => Named( archetype, name ) );

		if ( shipped is null ) {
			return Delete( ArchetypePathFor( name ) );
		}

		var held = Read<ArchArchetype>( ArchetypePathFor( name ) ) ?? shipped;
		held.Hidden = true;

		return Write( ArchetypePathFor( name ), held );
	}

	public static int RestoreArchetypes() {
		var restored = 0;

		foreach ( var file in Files( ArchetypeDirectory, ".archetype.json" ) ) {
			var path = $"{ArchetypeDirectory}/{file}";

			if ( Read<ArchArchetype>( path ) is not { Hidden: true } held ) {
				continue;
			}

			held.Hidden = false;

			if ( Write( path, held ) ) {
				restored++;
			}
		}

		return restored;
	}

	public static string CabinetPathFor( string name ) => $"{ArchCabinetCatalog.Directory}/{name}.cabinet.json";

	public static bool RemoveCabinetType( string name ) {
		if ( string.IsNullOrWhiteSpace( name ) ) {
			return false;
		}

		var shipped = ArchCabinetTypes.Shipped()
			.FirstOrDefault( type => string.Equals( type.Name, name, StringComparison.OrdinalIgnoreCase ) );

		if ( shipped is null ) {
			return Delete( CabinetPathFor( name ) );
		}

		var held = Read<ArchCabinetType>( CabinetPathFor( name ) ) ?? shipped;
		held.Hidden = true;

		return Write( CabinetPathFor( name ), held );
	}

	public static int RestoreCabinetTypes() {
		var restored = 0;

		foreach ( var file in Files( ArchCabinetCatalog.Directory, ".cabinet.json" ) ) {
			var path = $"{ArchCabinetCatalog.Directory}/{file}";

			if ( Read<ArchCabinetType>( path ) is not { Hidden: true } held ) {
				continue;
			}

			held.Hidden = false;

			if ( Write( path, held ) ) {
				restored++;
			}
		}

		return restored;
	}

	public static List<string> HiddenCabinetTypes() {
		return ArchShelved.Authors<ArchCabinetType>( ArchShelf.Cabinets )
			.Where( type => type.Hidden )
			.Select( type => type.Name )
			.OrderBy( name => name )
			.ToList();
	}

	public static List<string> HiddenArchetypes() {
		return ArchShelved.Authors<ArchArchetype>( ArchShelf.Types )
			.Where( archetype => archetype.Hidden )
			.Select( archetype => archetype.Title )
			.OrderBy( title => title )
			.ToList();
	}

	static bool Named( ArchArchetype archetype, string name ) {
		return string.Equals( archetype.Name, name, StringComparison.OrdinalIgnoreCase );
	}

	// FileSystem.Content is read-only — must go through System.IO
	static string Absolute( string path ) {
		var assets = Project.Current?.GetAssetsPath();

		return string.IsNullOrWhiteSpace( assets )
			? null
			: System.IO.Path.Combine( assets, path.Replace( '/', System.IO.Path.DirectorySeparatorChar ) );
	}

	static bool Exists( string path ) {
		var file = Absolute( path );

		return file is not null && System.IO.File.Exists( file );
	}

	public static bool DocumentExists( string path ) => Exists( path );

	static List<string> Files( string directory, string suffix ) {
		var folder = Absolute( directory );

		try {
			if ( folder is null || !System.IO.Directory.Exists( folder ) ) {
				return new List<string>();
			}

			return System.IO.Directory.GetFiles( folder, $"*{suffix}" )
				.Select( System.IO.Path.GetFileName )
				.ToList();
		} catch ( Exception exception ) {
			Log.Warning( $"Architecture could not list {directory}: {exception.Message}" );
			return new List<string>();
		}
	}

	public static List<string> DocumentFiles( string directory, string suffix ) => Files( directory, suffix );

	static T Read<T>( string path ) where T : class {
		var file = Absolute( path );

		try {
			if ( file is null || !System.IO.File.Exists( file ) ) {
				return null;
			}

			return JsonSerializer.Deserialize<T>( System.IO.File.ReadAllText( file ), Options );
		} catch ( Exception exception ) {
			Log.Warning( $"Architecture could not read {path}: {exception.Message}" );
			return null;
		}
	}

	public static T ReadDocument<T>( string path ) where T : class => Read<T>( path );

	static bool Unreadable<T>( string path ) where T : class {
		var file = Absolute( path );

		if ( file is null || !System.IO.File.Exists( file ) ) {
			return false;
		}

		try {
			return JsonSerializer.Deserialize<T>( System.IO.File.ReadAllText( file ), Options ) is null;
		} catch {
			return true;
		}
	}

	public static bool WriteAsset( string path, string text ) {
		var file = Absolute( path );

		if ( file is null ) {
			Log.Warning( $"Architecture cannot write {path}: no project is open." );
			return false;
		}

		try {
			System.IO.Directory.CreateDirectory( System.IO.Path.GetDirectoryName( file ) );
			System.IO.File.WriteAllText( file, text );

			return true;
		} catch ( Exception exception ) {
			Log.Warning( $"Architecture could not write {path}: {exception.Message}" );
			return false;
		}
	}

	public static string ReadAssetText( string path ) {
		var file = Absolute( path );

		try {
			return file is not null && System.IO.File.Exists( file ) ? System.IO.File.ReadAllText( file ) : null;
		} catch ( Exception exception ) {
			Log.Warning( $"Architecture could not read {path}: {exception.Message}" );
			return null;
		}
	}

	static bool Write<T>( string path, T value ) {
		var file = Absolute( path );

		if ( file is null ) {
			Log.Warning( $"Architecture cannot save {path}: no project is open, so the plan only exists in this tool instance and a hotload will lose it." );
			return false;
		}

		try {
			System.IO.Directory.CreateDirectory( System.IO.Path.GetDirectoryName( file ) );

			// Write-verify-swap so a bad serialize never replaces a good file
			var temporary = file + ".tmp";
			var text = JsonSerializer.Serialize( value, Options );

			System.IO.File.WriteAllText( temporary, text );

			if ( JsonSerializer.Deserialize<T>( System.IO.File.ReadAllText( temporary ), Options ) is null ) {
				System.IO.File.Delete( temporary );
				Log.Warning( $"Architecture could not save {path}: the serialized document does not read back, so nothing was replaced." );
				return false;
			}

			if ( System.IO.File.Exists( file ) ) {
				System.IO.File.Replace( temporary, file, file + ".bak" );
				System.IO.File.Delete( file + ".bak" );
			} else {
				System.IO.File.Move( temporary, file );
			}

			return true;
		} catch ( Exception exception ) {
			Log.Warning( $"Architecture could not write {path}: {exception.Message} - the plan only exists in this tool instance and a hotload will lose it." );
			return false;
		}
	}

	public static bool WriteDocument<T>( string path, T value ) => Write( path, value );

	public static bool DeleteDocument( string path ) => Delete( path );

	static bool Delete( string path ) {
		var file = Absolute( path );

		try {
			if ( file is null || !System.IO.File.Exists( file ) ) {
				return false;
			}

			System.IO.File.Delete( file );

			return true;
		} catch ( Exception exception ) {
			Log.Warning( $"Architecture could not delete {path}: {exception.Message}" );
			return false;
		}
	}
}