Editor/EffigyEditor/EffigyAssetFolder.cs
using Editor;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

namespace Marionette.EditorTools;

/// <summary>
/// Where Effigy writes its exports, and how it makes the editor notice them.
///
/// WHY THIS IS HERE RATHER THAN CALLED. Both halves used to live in this repo's private Blender
/// pipeline (Editor/Pipeline/KitConfig + ExternalAssetTools), which the library cannot reference:
/// a library is compiled on its own in whatever project mounts it, and the pipeline is personal
/// content that never ships. The two pieces Effigy actually needs are small and generic, so they
/// are restated here and the tool stands up in any project.
/// </summary>
internal static class EffigyAssetFolder
{
	// Only what Effigy itself writes. Deliberately not "every file": an Assets tree also holds
	// .py, .json and .md, and handing those to the asset system is noise at best.
	private static readonly string[] Extensions =
	{
		".vmdl", ".vmat", ".obj", ".dmx", ".smd", ".fbx", ".png", ".tga",
	};

	/// <summary>The active project's Assets folder, or null if there isn't one.</summary>
	public static string AssetsRoot()
	{
		var project = Sandbox.Project.Current;
		if ( project is null ) return null;

		var root = project.GetAssetsPath();
		return string.IsNullOrWhiteSpace( root ) || !Directory.Exists( root )
			? null
			: Path.GetFullPath( root );
	}

	/// <summary>
	/// Resolve a project-relative folder ("models/effigy") to an absolute path, refusing anything
	/// that escapes the Assets tree.
	/// </summary>
	public static string ResolveAssetFolder( string folder )
	{
		var root = AssetsRoot()
			?? throw new Exception( "Could not resolve the active project's Assets folder." );

		var target = string.IsNullOrWhiteSpace( folder )
			? root
			: Path.GetFullPath( Path.Combine( root, folder.Replace( '/', Path.DirectorySeparatorChar ) ) );

		if ( !target.StartsWith( root, StringComparison.OrdinalIgnoreCase ) )
			throw new Exception( $"'{folder}' resolves outside the Assets folder." );

		return target;
	}

	/// <summary>
	/// Register every asset file under a folder, so a file written with File.WriteAllText becomes
	/// visible to the asset system. Without this, asset_compile fails with "No asset at ..." and
	/// there is no console command that forces a rescan. Safe to re-run: an already-known file is
	/// counted and skipped.
	/// </summary>
	public static RegisterResult Register( string absoluteFolder, bool recursive = true )
	{
		var root = AssetsRoot();
		var option = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;

		var registered = new List<string>();
		var alreadyKnown = 0;
		var failed = new List<string>();

		foreach ( var file in Directory.EnumerateFiles( absoluteFolder, "*.*", option ) )
		{
			var ext = Path.GetExtension( file );
			if ( !Extensions.Contains( ext, StringComparer.OrdinalIgnoreCase ) )
				continue;

			// Compiled output is not a source asset.
			if ( file.Contains( ".generated.", StringComparison.OrdinalIgnoreCase ) ) continue;
			if ( file.EndsWith( "_c", StringComparison.OrdinalIgnoreCase ) ) continue;

			try
			{
				var relative = Path.GetRelativePath( root, file ).Replace( '\\', '/' );
				if ( AssetSystem.FindByPath( relative ) is not null )
				{
					alreadyKnown++;
					continue;
				}

				var asset = AssetSystem.RegisterFile( file );
				if ( asset is not null )
					registered.Add( asset.Path );
				else
					failed.Add( relative );
			}
			catch ( Exception e )
			{
				failed.Add( $"{Path.GetFileName( file )}: {e.Message}" );
			}
		}

		return new RegisterResult
		{
			Success = failed.Count == 0,
			Folder = absoluteFolder,
			Registered = registered.Count,
			AlreadyKnown = alreadyKnown,
			Failed = failed.Count,
			Errors = failed.Take( 10 ).ToArray(),
		};
	}

	/// <summary>What a registration pass found.</summary>
	public class RegisterResult
	{
		public bool Success { get; set; }
		public string Folder { get; set; }
		public int Registered { get; set; }
		public int AlreadyKnown { get; set; }
		public int Failed { get; set; }
		public string[] Errors { get; set; }
	}
}