Editor/Templates/PaletteTemplateStore.cs
using System;
using System.Collections.Generic;
using System.IO;
using Editor;
using Sandbox;

namespace Grains.RazorDesigner.Templates;

public sealed class PaletteTemplateStore
{
	private const string LogPrefix = "[Grains.RazorDesigner]";

	// Per-user (writable) — saved templates land here in the user's currently-loaded project.
	private const string UserTemplatesSubdir = "RazorDesigner/Templates";

	private static readonly (string Ident, string Subpath)[] BundledTemplateRoots = new[]
	{
		( "grains_razordesigner", "Assets/Templates/Included" ),
		( "grains_razordesigner", "Libraries/xaz.razordesigner/Assets/Templates/Included" ),
		( "razordesigner",        "Assets/Templates/Included" ),
	};

	private readonly List<PaletteTemplate> _all = new();

	public IReadOnlyList<PaletteTemplate> All => _all;
	public event Action Changed;

	public string TemplatesDirectoryPath
	{
		get
		{
			var assetsRoot = Project.Current?.GetAssetsPath();
			if ( string.IsNullOrEmpty( assetsRoot ) ) return null;
			return Path.Combine( assetsRoot, UserTemplatesSubdir );
		}
	}

	public static string BundledTemplatesDirectoryPath()
	{
		foreach ( var (ident, subpath) in BundledTemplateRoots )
		{
			var root = System.Linq.Enumerable
				.FirstOrDefault( EditorUtility.Projects.GetAll(), p => string.Equals( p.Config?.Ident, ident, StringComparison.OrdinalIgnoreCase ) )
				?.GetRootPath();
			if ( string.IsNullOrEmpty( root ) ) continue;
			var dir = Path.Combine( root, subpath );
			if ( Directory.Exists( dir ) ) return dir;
		}
		return null;
	}

	public static string ResolveBundledTemplatesWriteDir()
	{
		foreach ( var (ident, subpath) in BundledTemplateRoots )
		{
			var root = System.Linq.Enumerable
				.FirstOrDefault( EditorUtility.Projects.GetAll(),
					p => string.Equals( p.Config?.Ident, ident, StringComparison.OrdinalIgnoreCase ) )
				?.GetRootPath();
			if ( string.IsNullOrEmpty( root ) ) continue;
			return Path.Combine( root, subpath );
		}
		Log.Warning( $"{LogPrefix} ResolveBundledTemplatesWriteDir: no loaded project matches a BundledTemplateRoots Ident " +
			$"({string.Join( ", ", System.Linq.Enumerable.Select( BundledTemplateRoots, r => r.Ident ) )})" );
		return null;
	}

	public void Scan()
	{
		_all.Clear();

		var bundledDir = BundledTemplatesDirectoryPath();
		var bundledCount = ScanDirectory( bundledDir );

		var userDir = TemplatesDirectoryPath;
		if ( string.IsNullOrEmpty( userDir ) )
			Log.Warning( $"{LogPrefix} TemplateStore.Scan: no project assets path available; user templates skipped" );
		var userCount = ScanDirectory( userDir );

		Log.Info( $"{LogPrefix} TemplateStore.Scan: {bundledCount} bundled + {userCount} user = {_all.Count} total" );
		Changed?.Invoke();
	}

	private int ScanDirectory( string dir )
	{
		if ( string.IsNullOrEmpty( dir ) || !Directory.Exists( dir ) ) return 0;

		var added = 0;
		var files = Directory.GetFiles( dir, "*.json", SearchOption.TopDirectoryOnly );
		foreach ( var path in files )
		{
			PaletteTemplate t;
			try
			{
				var json = File.ReadAllText( path );
				t = PaletteTemplateSerializer.Deserialize( json, path );
			}
			catch ( PaletteTemplateException ex )
			{
				Log.Warning( $"{LogPrefix} TemplateStore.Scan: skipping {Path.GetFileName( path )} ({ex.Message})" );
				continue;
			}
			catch ( IOException ex )
			{
				Log.Warning( $"{LogPrefix} TemplateStore.Scan: cannot read {Path.GetFileName( path )} ({ex.Message})" );
				continue;
			}
			_all.Add( t );
			added++;
		}
		return added;
	}

	public void Save( PaletteTemplate template )
	{
		if ( template is null ) throw new ArgumentNullException( nameof( template ) );

		var dir = TemplatesDirectoryPath;
		if ( string.IsNullOrEmpty( dir ) )
		{
			Log.Warning( $"{LogPrefix} TemplateStore.Save: no project assets path; cannot save \"{template.Name}\"" );
			return;
		}

		Directory.CreateDirectory( dir );
		var fileName = SanitiseFilename( template.Name ) + ".json";
		var fullPath = Path.Combine( dir, fileName );

		// Persist with the actual file path baked in (deserialize uses it as identity).
		var stamped = template with { FilePath = fullPath };
		var json = PaletteTemplateSerializer.Serialize( stamped );

		try
		{
			File.WriteAllText( fullPath, json );
			Log.Info( $"{LogPrefix} TemplateStore.Save: \"{template.Name}\" -> {fullPath}" );
		}
		catch ( IOException ex )
		{
			Log.Warning( $"{LogPrefix} TemplateStore.Save: write failed for {fullPath} ({ex.Message})" );
			return;
		}

		Scan(); // refreshes _all and fires Changed
	}

	public void Delete( PaletteTemplate template )
	{
		if ( template is null ) throw new ArgumentNullException( nameof( template ) );
		if ( string.IsNullOrEmpty( template.FilePath ) ) return;

		try
		{
			if ( File.Exists( template.FilePath ) )
				File.Delete( template.FilePath );
			Log.Info( $"{LogPrefix} TemplateStore.Delete: \"{template.Name}\" removed ({template.FilePath})" );
		}
		catch ( IOException ex )
		{
			Log.Warning( $"{LogPrefix} TemplateStore.Delete: failed for {template.FilePath} ({ex.Message})" );
			return;
		}

		Scan();
	}

	public bool NameExists( string name )
	{
		if ( string.IsNullOrWhiteSpace( name ) ) return false;
		foreach ( var t in _all )
		{
			if ( string.Equals( t.Name, name, StringComparison.OrdinalIgnoreCase ) )
				return true;
		}
		return false;
	}

	private static string SanitiseFilename( string name )
	{
		var trimmed = (name ?? "").Trim();
		if ( string.IsNullOrEmpty( trimmed ) ) return "Untitled";

		var invalid = Path.GetInvalidFileNameChars();
		var chars = trimmed.ToCharArray();
		for ( int i = 0; i < chars.Length; i++ )
		{
			foreach ( var c in invalid )
				if ( chars[i] == c ) chars[i] = '_';
		}
		var result = new string( chars ).Trim().TrimEnd( '.' );
		return string.IsNullOrEmpty( result ) ? "Untitled" : result;
	}
}