Editor/Data/ArchStorage.cs

Editor utility for reading, writing and managing serialized architecture documents (plans, kits, archetypes) under project assets. It resolves absolute paths, serializes with System.Text.Json using build-specific options, guards against overwriting unreadable files, migrates plan versions, lists files, and exposes helpers to snapshot/restore and manage hidden archetypes.

File Access
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 const string PlanDirectory = "arch/by-scene";
	public const string KitDirectory = "arch/kits";
	public const string ArchetypeDirectory = "arch/archetypes";

	// Layer metadata joined the schema with version 2; nothing older may be written back as 2. Version 4 replaced
	// the polymorphic "$kind" discriminator with an ordinary "Kind" field, so an older editor must refuse it.
	public const int CurrentPlanVersion = 4;

	// Rebuilt whenever the editor assembly changes, and never cached across one. System.Text.Json freezes a type's
	// resolved converters onto the options instance the first time it serializes through it, and hotload carries a
	// static instance over - so a plan saved after any code edit went to disk through the metadata the editor had
	// BEFORE the edit. That is not a stale read; it is a stale write, and it silently drops whatever the edit added.
	static JsonSerializerOptions indented;
	static JsonSerializerOptions compact;
	static string builtFor;

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

	// For a cache key, never for disk: indentation is bytes nobody reads and this runs per layer per build.
	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 reads null during a hotload; remember the last resolved path, else don't persist at all.
	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";

	// Every plan authored in this project, at whatever depth - the by-scene tree mirrors the scene tree.
	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;
	}

	// A plan from a future editor must never be silently downgraded and written back.
	public static bool Supports( ArchPlan plan ) => plan is null || plan.Version <= CurrentPlanVersion;

	// v1 → v2: the layer records and links joined the schema, and their empty defaults already are
	// the v2 shape - so the migration is the version stamp itself.
	// v2 → v3: the two top-level lists became one. The lift is ArchPlan.Adopt, run from Normalize, because a
	// plan built in memory has to fold the same way one read off disk does.
	public static void Migrate( ArchPlan plan )
	{
		if ( plan is null || plan.Version >= CurrentPlanVersion )
		{
			return;
		}

		plan.Version = CurrentPlanVersion;
	}

	// An empty plan never overwrites one on disk that has content; a newer plan is never overwritten at all.
	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;
		}

		// Before the empty-plan guard, because that guard re-reads through the same path that failed: an
		// unreadable file reads as null, null has no content, and the check it was meant to make cannot fire.
		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 );
	}

	// Undo stores the plan only; geometry is regenerated, so the two can't diverge.
	public static string Snapshot( ArchPlan plan )
	{
		return plan is null ? null : JsonSerializer.Serialize( plan, Options );
	}

	// Reusable assets serialize through the same options as the plan.
	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;
	}

	// The kit carries every module's catalog, so a kit written by an editor with roads installed still reads on
	// one without - the shelf simply has nobody to top it up and the tuned entries stand as they were saved.
	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.RoadLines, kit.RoadLines );
	}

	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() );

	// Shipped stands under authored and a type saying it is hidden stands nowhere. The folder used to BE the
	// catalog, so a built-in was written out on first use and deleting one only bought a load's worth of quiet.
	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 );
	}

	// One gesture, two outcomes: an authored type IS its file, so removing it deletes it, while a type core ships
	// has no file to delete - that one is written out carrying the flag that hides it, which is also its way back.
	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 ) );
		}

		// Whatever is on disk, not a stub over it - a tuned type must come back tuned when it is restored.
		var held = Read<ArchArchetype>( ArchetypePathFor( name ) ) ?? shipped;
		held.Hidden = true;

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

	// Every hidden type at once, because a grid emptied by removals has no card left to reach one through.
	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;
	}

	// Titled rather than counted, so the way back can say what it will bring.
	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 );
	}

	// Never through Editor.FileSystem.Content - it is a Zio aggregate, read-only, and every write throws.
	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 );

	// A file that is there and does not parse is authored content this editor cannot read - most often a plan
	// naming a kind this build has no manifest for. Absent is not unreadable.
	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;
		}
	}

	// A baked scene, not one of our documents - same System.IO path as Absolute.
	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 ) );

			// Never replace a good file with one that cannot be read back - write to a sibling, verify,
			// then swap. The previous file stays intact until the swap succeeds.
			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 );

	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;
		}
	}
}