Editor/Effigy/VmdlMaterials.cs
using System;
using System.Collections.Generic;
using System.Text;

namespace Effigy;

/// <summary>
/// The MaterialGroupList node a .vmdl needs so faces keep the materials they were given.
///
/// WHAT THIS CLOSES. Faces carry a slot number and PartStudio.MaterialNames binds that number to a
/// vmat. Every exporter writes the name — OBJ as <c>usemtl</c>, DMX as <c>mtlName</c>, SMD as the
/// triangle's material line — and that was as far as it went. The .vmdl Compile writes had no
/// MaterialGroupList at all. ModelDoc fills a missing one in with
/// <c>use_global_default = true</c> and <c>materials/default.vmat</c>, which is why a part that
/// rendered in brushed steel in the viewport compiled to a blank grey prop.
///
/// COPIED, NOT GUESSED. The remap shape is the one this project's own lightswitch and first-person
/// arms models already ship:
///
/// <code>
/// from = "sw_plate.vmat"
/// to   = "materials/lightswitch/sw_plate.vmat"
/// </code>
///
/// <c>from</c> is the name the mesh file carries. <c>to</c> is the asset the compiler should bind.
/// <c>use_global_default</c> stays false: true is the switch that replaces every slot with default,
/// which is the failure this exists to stop.
///
/// WHY SEVERAL <c>from</c> SPELLINGS. The mesh writers emit whatever NameForSlot returns, which for
/// a bound slot is the full vmat path. ModelDoc's importer is documented as dropping everything
/// after a period in a material name (the Blender <c>.001</c> rule), and an OBJ <c>usemtl</c> with
/// slashes is sometimes taken as the last segment only. The lightswitch files remap from the
/// filename. Emitting the path, the filename, and both with <c>.vmat</c> stripped means whichever
/// spelling the importer keeps still hits a remap.
///
/// WHY THE KERNEL AND NOT THE EDITOR. Same reason as VmdlPhysics: it is text, it has no engine
/// types, and a headless test can say whether the node names the slots the mesh actually uses.
/// </summary>
public static class VmdlMaterials
{
	/// <summary>
	/// The MaterialGroupList node, indented to sit among a RootNode's children.
	///
	/// ALWAYS A NODE, even when nothing is bound. An omitted list is what ModelDoc replaces with
	/// the global default; an empty remap list with <c>use_global_default = false</c> leaves the
	/// mesh names in place, which is the honest answer for a part nobody has painted.
	/// </summary>
	public static string GroupList( PartStudio studio, PolyMesh mesh )
	{
		if ( studio is null )
			return GroupList( mesh, null, null );

		return GroupList( mesh, studio.NameForSlot, studio.MaterialNames );
	}

	/// <summary>
	/// The same node, from the two facts export already has: what the mesh writers will call each
	/// slot, and which slots have a vmat bound.
	/// </summary>
	public static string GroupList( PolyMesh mesh, Func<int, string> nameForSlot,
		IReadOnlyDictionary<int, string> materialNames )
	{
		var remaps = Remaps( mesh, nameForSlot, materialNames );
		var sb = new StringBuilder();

		sb.Append( "\t\t\t{\n" );
		sb.Append( "\t\t\t\t_class = \"MaterialGroupList\"\n" );
		sb.Append( "\t\t\t\tchildren = \n" );
		sb.Append( "\t\t\t\t[\n" );
		sb.Append( "\t\t\t\t\t{\n" );
		sb.Append( "\t\t\t\t\t\t_class = \"DefaultMaterialGroup\"\n" );
		sb.Append( "\t\t\t\t\t\tremaps = \n" );
		sb.Append( "\t\t\t\t\t\t[\n" );

		foreach ( var (from, to) in remaps )
		{
			sb.Append( "\t\t\t\t\t\t\t{\n" );
			sb.Append( $"\t\t\t\t\t\t\t\tfrom = {Quote( from )}\n" );
			sb.Append( $"\t\t\t\t\t\t\t\tto = {Quote( to )}\n" );
			sb.Append( "\t\t\t\t\t\t\t},\n" );
		}

		sb.Append( "\t\t\t\t\t\t]\n" );
		sb.Append( "\t\t\t\t\t\tuse_global_default = false\n" );
		sb.Append( "\t\t\t\t\t\tglobal_default_material = \"\"\n" );
		sb.Append( "\t\t\t\t\t},\n" );
		sb.Append( "\t\t\t\t]\n" );
		sb.Append( "\t\t\t},\n" );

		return sb.ToString();
	}

	/// <summary>
	/// Every <c>from → to</c> pair the node will write, in the order they appear.
	///
	/// Public so a test can count remaps without scraping KV3, and so the editor can log how many
	/// slots actually went out.
	/// </summary>
	public static List<(string From, string To)> Remaps( PolyMesh mesh, Func<int, string> nameForSlot,
		IReadOnlyDictionary<int, string> materialNames )
	{
		var remaps = new List<(string, string)>();
		var seenFrom = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		foreach ( var slot in SlotsOn( mesh ) )
		{
			if ( !TryBoundVmat( slot, materialNames, out var target ) )
				continue;

			var written = nameForSlot is not null ? nameForSlot( slot ) : ObjWriter.DefaultMaterialName( slot );

			foreach ( var from in FromAliases( written, target ) )
			{
				if ( !seenFrom.Add( from ) )
					continue;

				remaps.Add( (from, target) );
			}
		}

		return remaps;
	}

	/// <summary>Slots the mesh actually paints. A name sitting on a slot no face wears does not
	/// reach the compiled model, so it is not a remap.</summary>
	static SortedSet<int> SlotsOn( PolyMesh mesh )
	{
		var slots = new SortedSet<int>();

		if ( mesh?.Faces is null )
			return slots;

		foreach ( var face in mesh.Faces )
			slots.Add( face.Material );

		return slots;
	}

	/// <summary>
	/// The vmat a slot should compile to, or nothing.
	///
	/// A hand-typed display name — "anodised", "brushed steel" — is what the mesh writers already
	/// emit, and there is no asset to point <c>to</c> at. Only a path that looks like a material
	/// asset is remappable.
	/// </summary>
	static bool TryBoundVmat( int slot, IReadOnlyDictionary<int, string> materialNames, out string path )
	{
		path = null;

		if ( materialNames is null || !materialNames.TryGetValue( slot, out var name )
			|| string.IsNullOrWhiteSpace( name ) )
			return false;

		var n = name.Trim().Replace( '\\', '/' );

		if ( !n.EndsWith( ".vmat", StringComparison.OrdinalIgnoreCase )
			&& !n.StartsWith( "materials/", StringComparison.OrdinalIgnoreCase ) )
			return false;

		path = n;
		return true;
	}

	/// <summary>
	/// Every spelling of <paramref name="written"/> the importer might keep, plus the filename of
	/// <paramref name="target"/> — see the class comment for why there is more than one.
	/// </summary>
	static List<string> FromAliases( string written, string target )
	{
		var names = new List<string>();
		var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		void Add( string value )
		{
			if ( string.IsNullOrWhiteSpace( value ) )
				return;

			var n = value.Trim().Replace( '\\', '/' );

			if ( seen.Add( n ) )
				names.Add( n );
		}

		Add( written );
		Add( target );
		Add( FileName( target ) );
		Add( StripVmat( FileName( target ) ) );
		Add( StripVmat( target ) );

		return names;
	}

	static string FileName( string path )
	{
		if ( string.IsNullOrEmpty( path ) )
			return path;

		var n = path.Replace( '\\', '/' );
		var cut = n.LastIndexOf( '/' );

		return cut >= 0 && cut < n.Length - 1 ? n[(cut + 1)..] : n;
	}

	static string StripVmat( string name )
	{
		if ( string.IsNullOrEmpty( name ) )
			return name;

		return name.EndsWith( ".vmat", StringComparison.OrdinalIgnoreCase )
			? name[..^5]
			: name;
	}

	static string Quote( string value )
	{
		if ( value is null )
			return "\"\"";

		return "\"" + value.Replace( "\\", "\\\\" ).Replace( "\"", "\\\"" ) + "\"";
	}
}