Effigy.Tests/VmdlMaterialsTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Effigy;
using static Effigy.Tests.Report;

namespace Effigy.Tests;

/// <summary>
/// The MaterialGroupList node, which is the step that had been missing since face materials could
/// be assigned: the slots were right, the exporters named them, and the .vmdl bound none of them.
///
/// WHAT THESE CAN AND CANNOT CHECK. Whether the ENGINE honours a remap is a compile, and the keys
/// here were not guessed — they are the ones this project's lightswitch and first-person arms
/// models already ship. What a headless test can say is that the node names the slots the mesh
/// uses, that it never asks ModelDoc to replace everything with default, and that a part nobody
/// painted still writes a list so the compiler cannot fill one in.
/// </summary>
public static class VmdlMaterialsTests
{
	public static void Run()
	{
		Section( "vmdl materials: a bound slot reaches the node" );
		TestBoundSlot();

		Section( "vmdl materials: the whole part, which is slot 0" );
		TestBaseMaterial();

		Section( "vmdl materials: the node, and what it does with nothing" );
		TestAlwaysWritesTheList();
		TestDisplayNameIsNotARemap();

		Section( "vmdl materials: paint is a bound material, not a fallback" );
		TestPaintTakesNoFallback();

		Section( "vmdl materials: paint no longer votes on the fallback" );
		TestBlendDoesNotChooseTheFallback();

		Section( "vmdl materials: several slots, several spellings" );
		TestAliases();
		TestTwoSlots();
	}

	static void TestBoundSlot()
	{
		var studio = Painted( out var mesh, slot: 2, "materials/diner/diner_tile_floor.vmat" );
		var text = VmdlMaterials.GroupList( studio, mesh );

		Check( "it is a DefaultMaterialGroup inside a MaterialGroupList",
			CountOf( text, "_class = \"DefaultMaterialGroup\"" ) == 1
			&& CountOf( text, "_class = \"MaterialGroupList\"" ) == 1 );

		Check( "braces balance", CountOf( text, "{" ) == CountOf( text, "}" ),
			$"{CountOf( text, "{" )} open, {CountOf( text, "}" )} close" );
		Check( "and brackets balance", CountOf( text, "[" ) == CountOf( text, "]" ) );

		Check( "the node is a complete child entry, comma and all",
			text.TrimEnd( '\n' ).EndsWith( "}," ) );

		Check( "use_global_default is false — true is how every slot becomes default.vmat",
			text.Contains( "use_global_default = false" )
			&& !text.Contains( "use_global_default = true" ) );

		// THE FIELD, not the string anywhere in the node. default.vmat now appears legitimately as
		// the `to` of an unbound slot's own remap, which is the opposite of the failure this
		// guards: a GLOBAL default replaces every slot at once, a per-slot remap replaces one.
		Check( "and there is no global default material waiting to replace them",
			text.Contains( "global_default_material = \"\"" )
			&& !text.Contains( $"global_default_material = \"{VmdlMaterials.DefaultMaterial}\"" ) );

		Check( "the bound vmat is the remap target",
			text.Contains( "to = \"materials/diner/diner_tile_floor.vmat\"" ) );
	}

	static void TestBaseMaterial()
	{
		// Double-click in the Materials dock binds slot 0, which is every face nobody has painted.
		// A remap that only walked FaceMaterialFeature would miss it entirely, and the whole part
		// would compile as default — the original complaint, for the most ordinary assignment.
		var studio = new PartStudio();
		var box = studio.Add( new PrimitiveFeature() );
		box.SizeX.Value = box.SizeY.Value = box.SizeZ.Value = 2f;
		studio.MaterialNames[0] = "materials/wood/oak.vmat";
		studio.Rebuild();

		var mesh = studio.ToMesh();
		var text = VmdlMaterials.GroupList( studio, mesh );

		Check( "slot 0 is remapped when it carries a vmat",
			text.Contains( "to = \"materials/wood/oak.vmat\"" ) );

		Check( "and every face is still on slot 0, so the whole part is that material",
			mesh.Faces.All( f => f.Material == 0 ) );
	}

	static void TestAlwaysWritesTheList()
	{
		var studio = new PartStudio();
		var box = studio.Add( new PrimitiveFeature() );
		box.SizeX.Value = box.SizeY.Value = box.SizeZ.Value = 2f;
		studio.Rebuild();

		var text = VmdlMaterials.GroupList( studio, studio.ToMesh() );

		Check( "an unpainted part still writes a MaterialGroupList",
			text.Contains( "_class = \"MaterialGroupList\"" ) );

		// NOT "no remaps". An unpainted part used to write an empty list, which left the compiled
		// model asking for `material_0` - no asset answers to that, so it rendered in the bright
		// red missing-material shader. Every slot the mesh uses names a real asset now.
		var bare = VmdlMaterials.Remaps( studio.ToMesh(), studio.NameForSlot, studio.MaterialNames );

		Check( "its one slot is remapped rather than left dangling", bare.Count == 1,
			string.Join( ", ", bare.Select( r => $"{r.From}->{r.To}" ) ) );

		Check( "from the name the mesh writers emit", bare[0].From == "material_0", bare[0].From );

		Check( "to a material that exists, so it renders plain instead of red",
			bare[0].To == VmdlMaterials.DefaultMaterial, bare[0].To );

		Check( "and still refuses the global default, so ModelDoc cannot fill one in",
			text.Contains( "use_global_default = false" ) );
	}

	static void TestDisplayNameIsNotARemap()
	{
		// A name that is not a vmat path is what the mesh writers already emit. Pointing `to` at
		// it would be a remap to an asset that does not exist.
		var studio = Painted( out var mesh, slot: 3, "anodised" );
		var remaps = VmdlMaterials.Remaps( mesh, studio.NameForSlot, studio.MaterialNames );

		Check( "a hand-typed display name is never a remap TARGET",
			remaps.All( r => r.To != "anodised" ),
			string.Join( ", ", remaps.Select( r => $"{r.From}->{r.To}" ) ) );

		// It still has to go somewhere: the slot is on the mesh, so the compiled model will ask
		// for it by name, and "anodised" is no more of an asset than "material_3" is. The part
		// also carries an untouched slot 0, so this is every slot falling back, not just the one.
		Check( "it falls back to the default instead",
			remaps.Any( r => r.From == "anodised" )
			&& remaps.All( r => r.To == VmdlMaterials.DefaultMaterial ),
			string.Join( ", ", remaps.Select( r => $"{r.From}->{r.To}" ) ) );
	}

	/// <summary>
	/// A painted part reaches export with its slot already bound, so no fallback is involved.
	///
	/// THIS ASSERTED THE OPPOSITE UNTIL PAINT BECAME A TEXTURE. Paint was per-vertex colour, every
	/// ordinary material discarded the COLOR stream, and the fallback had to notice a coloured mesh
	/// and bind vertex_color.vmat or the paint compiled away to nothing. Now PaintMaterial writes the
	/// canvas out as a PNG in a .vmat and that .vmat goes into MaterialNames, so a painted slot is a
	/// BOUND slot like any other and the fallback never sees it.
	///
	/// The first two checks are the retirement itself: a mesh carrying vertex colours must now be
	/// treated exactly like one that is not. They fail if anything teaches the fallback to read the
	/// mesh again.
	/// </summary>
	static void TestPaintTakesNoFallback()
	{
		var studio = new PartStudio();
		var box = studio.Add( new PrimitiveFeature() );
		box.SizeX.Value = box.SizeY.Value = box.SizeZ.Value = 2f;
		studio.Rebuild();

		var mesh = studio.ToMesh();

		Check( "an unpainted mesh takes the plain default",
			VmdlMaterials.FallbackFor( studio ) == VmdlMaterials.DefaultMaterial );

		// Colours parallel to the positions is all HasVertexColors asks for. Nothing in the tool
		// produces them any more - this is the only way left to build such a mesh, and the point is
		// that it now changes nothing.
		mesh.VertexColors = new Vec4[mesh.Positions.Count];

		for ( var i = 0; i < mesh.VertexColors.Length; i++ )
			mesh.VertexColors[i] = new Vec4( 1f, 0f, 0f, 1f );

		var remaps = VmdlMaterials.Remaps( mesh, studio.NameForSlot, studio.MaterialNames,
			VmdlMaterials.FallbackFor( studio ) );

		Check( "a mesh carrying vertex colours takes the same default, not a colour material",
			remaps.Count > 0 && remaps.All( r => r.To == VmdlMaterials.DefaultMaterial ),
			string.Join( ", ", remaps.Select( r => $"{r.From}->{r.To}" ) ) );

		// What a painted body actually looks like to the exporter: the .vmat PaintMaterial wrote,
		// bound to the body's slot through MaterialNames exactly as a dropped material would be.
		var painted = Painted( out var paintedMesh, slot: 1, "models/effigy/box_paint.vmat" );
		var paintedRemaps = VmdlMaterials.Remaps( paintedMesh, painted.NameForSlot,
			painted.MaterialNames, VmdlMaterials.FallbackFor( painted ) );

		Check( "a painted body's own material is what its slot binds",
			paintedRemaps.Any( r => r.To == "models/effigy/box_paint.vmat" ),
			string.Join( ", ", paintedRemaps.Select( r => $"{r.From}->{r.To}" ) ) );

		Check( "and no slot on it falls back to a material that reads vertex colour",
			paintedRemaps.All( r => !r.To.Contains( "vertex_color" ) ),
			string.Join( ", ", paintedRemaps.Select( r => $"{r.From}->{r.To}" ) ) );
	}

	/// <summary>
	/// Paint's Blend choice decides what an unbound slot compiles to.
	///
	/// ON ANY MESH. This used to carry a caveat - a mesh carrying vertex colours overrode Blend
	/// entirely, because only one material read those colours - and that override is gone with the
	/// vertex-colour fallback it belonged to. Blend is now the only thing an unbound slot consults.
	/// </summary>
	static void TestBlendDoesNotChooseTheFallback()
	{
		var studio = new PartStudio();
		var box = studio.Add( new PrimitiveFeature() );
		box.SizeX.Value = box.SizeY.Value = box.SizeZ.Value = 2f;

		var paint = studio.Add( new PaintFeature() );
		studio.Rebuild();

		Check( "Blend is still on the feature so old documents load",
			paint.Blend.Value == "Tint", paint.Blend.Value );

		Check( "and it is not a parameter the dialog offers",
			!paint.Parameters.Contains( paint.Blend ) );

		paint.Blend.Index = Array.IndexOf( paint.Blend.Options, "Replace" );

		Check( "Replace no longer turns unbound slots white — paint is a bound texture now",
			VmdlMaterials.FallbackFor( studio ) == VmdlMaterials.DefaultMaterial,
			VmdlMaterials.FallbackFor( studio ) );

		var remaps = VmdlMaterials.Remaps( studio.ToMesh(), studio.NameForSlot, studio.MaterialNames,
			VmdlMaterials.FallbackFor( studio ) );

		Check( "and that is what reaches the remap list",
			remaps.Count == 1 && remaps[0].To == VmdlMaterials.DefaultMaterial,
			string.Join( ", ", remaps.Select( r => $"{r.From}->{r.To}" ) ) );

		// A BOUND SLOT IS NOT TOUCHED. Dropping a material on a face is a deliberate choice, and
		// covering it is not what Replace is for - it moves the slots that had nothing.
		var bound = Painted( out var mesh, slot: 1, "materials/diner/diner_tile_floor.vmat" );
		var boundRemaps = VmdlMaterials.Remaps( mesh, bound.NameForSlot, bound.MaterialNames,
			VmdlMaterials.ReplaceMaterial );

		Check( "a slot with a material dropped on it still points at that material",
			boundRemaps.Any( r => r.To == "materials/diner/diner_tile_floor.vmat" ),
			string.Join( ", ", boundRemaps.Select( r => $"{r.From}->{r.To}" ) ) );

		// A layer that produced nothing does not get a vote.
		paint.Suppressed = true;

		Check( "a suppressed paint layer stops asking for it",
			VmdlMaterials.FallbackFor( studio ) == VmdlMaterials.DefaultMaterial );
	}

	static void TestAliases()
	{
		var studio = Painted( out var mesh, slot: 1, "materials/halo/characters/elite/halo_3.vmat" );
		var remaps = VmdlMaterials.Remaps( mesh, studio.NameForSlot, studio.MaterialNames );
		var froms = remaps.Select( r => r.From ).ToList();

		Check( "the full path the exporters write is a from",
			froms.Contains( "materials/halo/characters/elite/halo_3.vmat" ) );

		Check( "so is the filename, which is what the lightswitch files remap from",
			froms.Contains( "halo_3.vmat" ) );

		Check( "and both with .vmat stripped, because ModelDoc drops everything after a period",
			froms.Contains( "halo_3" ) && froms.Contains( "materials/halo/characters/elite/halo_3" ) );

		// Excluding the fallback: the part's other slot is unbound and remaps to the default, which
		// is not an alias of this vmat and never was.
		Check( "every alias points at the same vmat",
			remaps.Where( r => r.To != VmdlMaterials.DefaultMaterial )
				.All( r => r.To == "materials/halo/characters/elite/halo_3.vmat" ),
			string.Join( ", ", remaps.Select( r => $"{r.From}->{r.To}" ) ) );
	}

	static void TestTwoSlots()
	{
		var studio = new PartStudio();
		var box = studio.Add( new PrimitiveFeature() );
		box.SizeX.Value = 4f;
		box.SizeY.Value = 3f;
		box.SizeZ.Value = 2f;
		studio.Rebuild();

		var body = studio.Bodies.Single();
		var top = FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) );
		var side = FaceIndexFacing( body.Mesh, new Vec3( 1, 0, 0 ) );

		var paintTop = studio.Add( new FaceMaterialFeature() );
		paintTop.Material.Value = 1;
		paintTop.Faces.Add( FacePlane.Capture( body, top, body.Mesh.FaceCentroid( body.Mesh.Faces[top] ) ) );

		var paintSide = studio.Add( new FaceMaterialFeature() );
		paintSide.Material.Value = 2;
		paintSide.Faces.Add( FacePlane.Capture( body, side, body.Mesh.FaceCentroid( body.Mesh.Faces[side] ) ) );

		studio.MaterialNames[0] = "materials/wood/oak.vmat";
		studio.MaterialNames[1] = "materials/metal/brushed.vmat";
		studio.MaterialNames[2] = "materials/rubber/grip.vmat";
		studio.Rebuild();

		var mesh = studio.ToMesh();
		var remaps = VmdlMaterials.Remaps( mesh, studio.NameForSlot, studio.MaterialNames );
		var targets = remaps.Select( r => r.To ).Distinct().OrderBy( t => t ).ToList();

		Check( "three bound slots make three remap targets",
			targets.Count == 3,
			string.Join( ", ", targets ) );

		Check( "oak, brushed and grip are all there",
			targets.Contains( "materials/wood/oak.vmat" )
			&& targets.Contains( "materials/metal/brushed.vmat" )
			&& targets.Contains( "materials/rubber/grip.vmat" ) );

		// A name sitting on a slot no face wears must not appear. Slot 7 is named and unused.
		studio.MaterialNames[7] = "materials/unused/spare.vmat";
		var after = VmdlMaterials.Remaps( mesh, studio.NameForSlot, studio.MaterialNames );

		Check( "a named slot with no faces is not remapped",
			after.All( r => r.To != "materials/unused/spare.vmat" ) );
	}

	// --- helpers ----------------------------------------------------------------------------------

	static PartStudio Painted( out PolyMesh mesh, int slot, string name )
	{
		var studio = new PartStudio();

		var box = studio.Add( new PrimitiveFeature() );
		box.SizeX.Value = 4f;
		box.SizeY.Value = 3f;
		box.SizeZ.Value = 2f;
		studio.Rebuild();

		var body = studio.Bodies.Single();
		var top = FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) );

		var paint = studio.Add( new FaceMaterialFeature() );
		paint.Material.Value = slot;
		paint.Faces.Add( FacePlane.Capture( body, top, body.Mesh.FaceCentroid( body.Mesh.Faces[top] ) ) );
		studio.MaterialNames[slot] = name;
		studio.Rebuild();

		mesh = studio.ToMesh();
		return studio;
	}

	static int FaceIndexFacing( PolyMesh mesh, Vec3 direction )
	{
		for ( var i = 0; i < mesh.Faces.Count; i++ )
		{
			if ( Vec3.Dot( mesh.FaceNormal( mesh.Faces[i] ), direction.Normal ) > 0.99f )
				return i;
		}

		return -1;
	}

	static int CountOf( string text, string needle )
	{
		var count = 0;
		var at = 0;

		while ( (at = text.IndexOf( needle, at, StringComparison.Ordinal )) >= 0 )
		{
			count++;
			at += needle.Length;
		}

		return count;
	}
}