Effigy.Tests/MaterialDropTests.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace Effigy.Tests;

/// <summary>
/// Dragging a material out of a browser and letting go over a face.
///
/// The browser, the drag and the raycast are the editor's problem. What is testable is the decision
/// underneath, which is the whole reason this is not just FaceMaterialEdit with a name attached:
/// the drop names no slot, so something has to CHOOSE one, and choosing badly is silent. Take a
/// slot the face menu already painted with and faces elsewhere on the model change material for no
/// visible reason; take a fresh slot every time and thirty faces of one material become thirty
/// slots that export as thirty materials; take slot 0 and the drop paints the entire part.
/// </summary>
public static class MaterialDropTests
{
	public static void Run()
	{
		Report.Section( "material drop: a material nobody has used takes a fresh slot" );
		TestFirstDrop();

		Report.Section( "material drop: the same material again reuses its slot" );
		TestReuse();

		Report.Section( "material drop: a second material takes the next slot" );
		TestSecondMaterial();

		Report.Section( "material drop: slots already spoken for are left alone" );
		TestSkipsUsedSlots();

		Report.Section( "material drop: slot 0 is never allocated" );
		TestNeverAllocatesZero();

		Report.Section( "material drop: the same path spelled differently is the same material" );
		TestSpelling();

		Report.Section( "material drop: a compiled path is the same asset as its source" );
		TestCompiledPath();

		Report.Section( "material drop: a brush dab covers many faces at once" );
		TestBrushDab();
		TestBrushReleasesEmptiedSlots();

		Report.Section( "material drop: nothing to do, nothing changed" );
		TestNoChange();

		Report.Section( "material drop: no slots left" );
		TestExhausted();

		Report.Section( "material drop: which slot already carries a material" );
		TestSlotCarrying();

		Report.Section( "material drop: changing your mind frees the slot you left" );
		TestVacatedSlotIsFreed();

		Report.Section( "material drop: a slot somebody else is holding is not freed" );
		TestOccupiedSlotSurvives();
	}

	/// <summary>
	/// THE ONE THIS WAS ADDED FOR. Dropping four materials onto one face used to leave four bound
	/// slots — the face wears the last, and the other three are named, wear nothing, and get written
	/// by every exporter. Nothing on screen counts them except the Materials panel's footer, which
	/// is where it was noticed: "9 bound" on a part with three painted faces.
	/// </summary>
	static void TestVacatedSlotIsFreed()
	{
		var studio = Boxed( out var body );
		var top = FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) );

		Drop( studio, body, top, "materials/a.vmat", out var first );

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

		var changed = Drop( studio, body, top, "materials/b.vmat", out var second, out var released );

		Report.Check( "the second material still gets its own slot", first == 1 && second == 2,
			$"{first} then {second}" );

		Report.Check( "the drop reports the slot it retired", changed && released == first,
			$"released {released}" );

		Report.Check( "and only the material actually worn is left bound",
			studio.MaterialNames.Count == 1
			&& studio.MaterialNames[second] == "materials/b.vmat",
			string.Join( ", ", studio.MaterialNames.Select( kv => $"{kv.Key}={kv.Value}" ) ) );

		var report = studio.Rebuild();

		Report.Check( "and it builds", !report.HasErrors, report.ToString() );

		var mesh = studio.Bodies.Single().Mesh;

		Report.Check( "with the face on the second slot and nothing on the first",
			mesh.Faces.Count( f => f.Material == second ) == 1
			&& mesh.Faces.Count( f => f.Material == first ) == 0 );

		// Third and fourth changes of mind, to show the count does not creep. Slot 1 is free again
		// after the release above, so this walks 2 -> 1 -> 2 rather than climbing.
		studio.Rebuild();
		body = studio.Bodies.Single();
		top = FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) );
		Drop( studio, body, top, "materials/c.vmat", out _ );

		studio.Rebuild();
		body = studio.Bodies.Single();
		top = FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) );
		Drop( studio, body, top, "materials/d.vmat", out _ );

		Report.Check( "four materials onto one face still leaves one bound slot",
			studio.MaterialNames.Count == 1,
			string.Join( ", ", studio.MaterialNames.Select( kv => $"{kv.Key}={kv.Value}" ) ) );
	}

	/// <summary>
	/// The other half, and the reason the release is not just "drop any slot with no faces on it".
	/// A slot two faces share must survive one of them leaving, and a slot somebody named in the
	/// Materials panel before painting anything must survive full stop — it has no faces by
	/// definition, and reserving one is a thing you are allowed to do.
	/// </summary>
	static void TestOccupiedSlotSurvives()
	{
		var studio = Boxed( out var body );

		Drop( studio, body, FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) ), "materials/a.vmat", out var shared );

		studio.Rebuild();
		body = studio.Bodies.Single();

		Drop( studio, body, FaceIndexFacing( body.Mesh, new Vec3( 0, 0, -1 ) ), "materials/a.vmat", out _ );

		// A slot named and never painted, exactly as the Materials panel leaves one.
		studio.MaterialNames[9] = "materials/reserved.vmat";

		studio.Rebuild();
		body = studio.Bodies.Single();

		// One of the two faces moves away. The other is still on the slot.
		var top = FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) );
		Drop( studio, body, top, "materials/b.vmat", out var moved, out var released );

		Report.Check( "nothing was retired", released == -1, $"released {released}" );

		Report.Check( "the shared slot keeps its material",
			studio.MaterialNames.TryGetValue( shared, out var name ) && name == "materials/a.vmat" );

		Report.Check( "the reserved slot is untouched",
			studio.MaterialNames.TryGetValue( 9, out var reserved ) && reserved == "materials/reserved.vmat" );

		var report = studio.Rebuild();

		Report.Check( "and it builds", !report.HasErrors, report.ToString() );

		var mesh = studio.Bodies.Single().Mesh;

		Report.Check( "one face left behind on the shared slot, one on the new one",
			mesh.Faces.Count( f => f.Material == shared ) == 1
			&& mesh.Faces.Count( f => f.Material == moved ) == 1 );
	}

	/// <summary>
	/// The question the browser's slot badge asks, and the reason it is not SlotFor.
	///
	/// SlotFor answers "where would this go", which for a material the part does not use is a FREE
	/// slot — a perfectly good answer to a different question, and a badge drawn from it would put
	/// the same number on every unused material in the project.
	/// </summary>
	static void TestSlotCarrying()
	{
		var studio = Boxed( out var body );

		Report.Check( "nothing carries a material in an empty document",
			MaterialDrop.SlotCarrying( studio, "materials/a.vmat" ) == -1 );

		Drop( studio, body, FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) ), "materials/a.vmat", out var slot );

		Report.Check( "the slot it landed on is the slot carrying it",
			MaterialDrop.SlotCarrying( studio, "materials/a.vmat" ) == slot, $"slot {slot}" );

		Report.Check( "a material the part does not use carries nowhere, rather than reporting the free slot",
			MaterialDrop.SlotCarrying( studio, "materials/b.vmat" ) == -1
			&& MaterialDrop.SlotFor( studio, "materials/b.vmat" ) > 0 );

		Report.Check( "spelling does not hide it",
			MaterialDrop.SlotCarrying( studio, "MATERIALS\\A.VMAT" ) == slot );

		studio.MaterialNames[0] = "materials/base.vmat";

		Report.Check( "slot 0 is reported like any other", MaterialDrop.SlotCarrying( studio, "materials/base.vmat" ) == 0 );
	}

	static void TestFirstDrop()
	{
		var studio = Boxed( out var body );
		var top = FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) );

		var changed = Drop( studio, body, top, "materials/dev/reflectivity_30.vmat", out var slot );

		Report.Check( "the drop reports that it did something", changed );
		Report.Check( "it went to slot 1", slot == 1, $"slot {slot}" );

		Report.Check( "the slot carries the material",
			studio.MaterialNames.TryGetValue( 1, out var name )
			&& name == "materials/dev/reflectivity_30.vmat",
			studio.MaterialNames.TryGetValue( 1, out var shown ) ? shown : "nothing" );

		var report = studio.Rebuild();

		Report.Check( "and it builds", !report.HasErrors, report.ToString() );

		var mesh = studio.Bodies.Single().Mesh;

		Report.Check( "exactly the face that was dropped on is on the slot",
			mesh.Faces.Count( f => f.Material == 1 ) == 1
			&& mesh.FaceNormal( mesh.Faces.First( f => f.Material == 1 ) ).z > 0.99f );
	}

	static void TestReuse()
	{
		var studio = Boxed( out var body );

		Drop( studio, body, FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) ), "materials/a.vmat", out var first );

		studio.Rebuild();
		body = studio.Bodies.Single();

		Drop( studio, body, FaceIndexFacing( body.Mesh, new Vec3( 0, 0, -1 ) ), "materials/a.vmat", out var second );

		Report.Check( "the second drop lands on the same slot", first == second, $"{first} then {second}" );

		Report.Check( "and named exactly one slot", studio.MaterialNames.Count == 1 );

		Report.Check( "on one assignment feature, not two",
			studio.Features.OfType<FaceMaterialFeature>().Count() == 1 );

		var report = studio.Rebuild();

		Report.Check( "and it builds", !report.HasErrors, report.ToString() );

		var mesh = studio.Bodies.Single().Mesh;

		Report.Check( "both faces carry it", mesh.Faces.Count( f => f.Material == first ) == 2 );
	}

	static void TestSecondMaterial()
	{
		var studio = Boxed( out var body );

		Drop( studio, body, FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) ), "materials/a.vmat", out var first );

		studio.Rebuild();
		body = studio.Bodies.Single();

		Drop( studio, body, FaceIndexFacing( body.Mesh, new Vec3( 1, 0, 0 ) ), "materials/b.vmat", out var second );

		Report.Check( "a different material gets its own slot", first == 1 && second == 2, $"{first} then {second}" );

		var report = studio.Rebuild();

		Report.Check( "and it builds", !report.HasErrors, report.ToString() );

		var mesh = studio.Bodies.Single().Mesh;

		Report.Check( "one face each", mesh.Faces.Count( f => f.Material == 1 ) == 1
			&& mesh.Faces.Count( f => f.Material == 2 ) == 1 );
	}

	/// <summary>
	/// The one that matters. A slot the face menu has painted with carries no name, so a drop that
	/// only avoided NAMED slots would take it — and every face somebody had put on slot 1 by hand
	/// would change material without being touched.
	/// </summary>
	static void TestSkipsUsedSlots()
	{
		var studio = Boxed( out var body );

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

		studio.Rebuild();
		body = studio.Bodies.Single();

		Drop( studio, body, FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) ), "materials/a.vmat", out var slot );

		Report.Check( "the painted slot is not taken", slot == 2, $"slot {slot}" );

		var report = studio.Rebuild();

		Report.Check( "and it builds", !report.HasErrors, report.ToString() );

		var mesh = studio.Bodies.Single().Mesh;

		Report.Check( "the hand-painted face is still on its own slot",
			mesh.Faces.Count( f => f.Material == 1 ) == 1
			&& MathF.Abs( mesh.FaceNormal( mesh.Faces.First( f => f.Material == 1 ) ).x - 1f ) < 0.01f );
	}

	static void TestNeverAllocatesZero()
	{
		var studio = Boxed( out _ );

		// Every slot from 1 up named, so slot 0 is the only free NUMBER in the range — and still
		// must not be chosen, because it is what the untouched rest of the part is on.
		for ( var i = 1; i <= MaterialDrop.HighestSlot; i++ )
			studio.MaterialNames[i] = $"materials/filler_{i}.vmat";

		Report.Check( "an unused material finds nowhere to go rather than falling back to 0",
			MaterialDrop.SlotFor( studio, "materials/new.vmat" ) == -1 );

		// Named deliberately from the Materials panel, which is allowed: rule 1 finds it, and the
		// drop then means "this face goes back to the part's base material", which is a real thing
		// to want.
		studio.MaterialNames[0] = "materials/base.vmat";

		Report.Check( "but a material somebody put on slot 0 is still found there",
			MaterialDrop.SlotFor( studio, "materials/base.vmat" ) == 0 );
	}

	static void TestSpelling()
	{
		var studio = Boxed( out var body );

		Drop( studio, body, FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) ), "materials/Dev/Grid.vmat", out var first );

		studio.Rebuild();
		body = studio.Bodies.Single();

		Drop( studio, body, FaceIndexFacing( body.Mesh, new Vec3( 0, 0, -1 ) ), "materials\\dev\\grid.vmat", out var second );

		Report.Check( "backslashes and case do not open a second slot", first == second, $"{first} then {second}" );

		Report.Check( "and the name written first is the one kept",
			studio.MaterialNames[first] == "materials/Dev/Grid.vmat", studio.MaterialNames[first] );
	}

	/// <summary>
	/// A dab is many faces and ONE slot — the case the brush exists for, and the one where looping
	/// the single-face drop by hand would be easiest to get wrong.
	/// </summary>
	/// <summary>
	/// `.vmat_c` is what the asset browser calls anything that ships compiled, which is most of the
	/// engine's own content. Binding that string is what put the missing-material shader on a face
	/// somebody had just painted: nothing resolves it, so the model asks for a material that is not
	/// there. Two separate jobs, and both were wrong.
	/// </summary>
	static void TestCompiledPath()
	{
		Report.Check( "the source path is what a document stores",
			MaterialDrop.AsSourcePath( "materials/concrete/wall/concrete_rough_a.vmat_c" )
				== "materials/concrete/wall/concrete_rough_a.vmat" );

		Report.Check( "a source path is left alone",
			MaterialDrop.AsSourcePath( "materials/wood/oak.vmat" ) == "materials/wood/oak.vmat" );

		Report.Check( "and case is not touched, because this is written to disk and shown to people",
			MaterialDrop.AsSourcePath( "Materials/Wood/Oak.vmat_c" ) == "Materials/Wood/Oak.vmat" );

		Report.Check( "the two spellings compare equal, so the browser badge finds a bound material",
			MaterialDrop.Normalise( "materials/wood/oak.vmat_c" )
				== MaterialDrop.Normalise( "materials/wood/oak.vmat" ) );

		// The consequence that actually bit: a second slot for a material the part already wore.
		var studio = Boxed( out var body );
		var top = FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) );

		Drop( studio, body, top, "materials/wood/oak.vmat", out var first );

		Report.Check( "the compiled spelling finds the slot the source one took",
			MaterialDrop.SlotCarrying( studio, "materials/wood/oak.vmat_c" ) == first,
			$"{MaterialDrop.SlotCarrying( studio, "materials/wood/oak.vmat_c" )} vs {first}" );

		Report.Check( "so it does not allocate a second slot for the same material",
			MaterialDrop.SlotFor( studio, "materials/wood/oak.vmat_c" ) == first );

		// THE UPGRADE. A document bound before the suffix was understood holds the broken spelling,
		// and Normalise now calls it equal - so without an explicit upgrade the name that does not
		// resolve stays put and re-dropping the material appears to do nothing.
		var stale = Boxed( out var staleBody );
		var face = FaceIndexFacing( staleBody.Mesh, new Vec3( 0, 0, 1 ) );

		stale.MaterialNames[1] = "materials/concrete/wall/concrete_rough_a.vmat_c";

        MaterialDrop.Drop( stale, staleBody.Id,
			face, FacePlane.Capture( staleBody, face, staleBody.Mesh.FaceCentroid( staleBody.Mesh.Faces[face] ) ),
			"materials/concrete/wall/concrete_rough_a.vmat_c", out _ );

		Report.Check( "a slot already holding a compiled path is rewritten to the source one",
			stale.MaterialNames[1] == "materials/concrete/wall/concrete_rough_a.vmat",
			stale.MaterialNames[1] );
	}

	static void TestBrushDab()
	{
		var studio = Boxed( out var body );
		var faces = Enumerable.Range( 0, body.Mesh.Faces.Count ).ToList();

		var changed = MaterialDrop.Brush( studio, body, faces,
			"materials/dev/reflectivity_30.vmat", out var slot, out var released );

		Report.Check( "every face of the box was moved", changed == faces.Count,
			$"{changed} of {faces.Count}" );

		Report.Check( "onto one slot, not one each", slot == 1, $"slot {slot}" );

		Report.Check( "and only that one slot is named", studio.MaterialNames.Count == 1
			&& studio.MaterialNames[1] == "materials/dev/reflectivity_30.vmat" );

		Report.Check( "nothing was emptied, because nothing was painted before",
			released.Count == 0, string.Join( ", ", released ) );

		var report = studio.Rebuild();

		Report.Check( "and it builds", !report.HasErrors, report.ToString() );

		var mesh = studio.Bodies.Single().Mesh;

		Report.Check( "every face wears it after the rebuild",
			mesh.Faces.All( f => f.Material == 1 ) );

		// The second dab over the same faces is the ordinary thing a held brush does, and it must
		// not report an edit — that would put a do-nothing step on the undo stack per mouse-move.
		var again = MaterialDrop.Brush( studio, studio.Bodies.Single(),
			Enumerable.Range( 0, mesh.Faces.Count ), "materials/dev/reflectivity_30.vmat",
			out _, out _ );

		Report.Check( "dragging back over the same faces changes nothing", again == 0, $"{again}" );
	}

	/// <summary>
	/// Sweeping one material off the last face holding another retires the slot it left, the same
	/// rule the single drop follows — a stroke can empty several at once.
	/// </summary>
	static void TestBrushReleasesEmptiedSlots()
	{
		var studio = Boxed( out var body );
		var top = FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) );

		Drop( studio, body, top, "materials/dev/reflectivity_30.vmat", out var first );
		studio.Rebuild();

		Report.Check( "the first material has a slot", first == 1, $"slot {first}" );

		var live = studio.Bodies.Single();

		var changed = MaterialDrop.Brush( studio, live,
			Enumerable.Range( 0, live.Mesh.Faces.Count ), "materials/wood/oak.vmat",
			out var slot, out var released );

		Report.Check( "the second material sweeps every face", changed > 0, $"{changed}" );

		Report.Check( "and the slot the first one had is retired, not left named on nothing",
			released.Contains( first ), string.Join( ", ", released ) );

		Report.Check( "so only the new material is named",
			!studio.MaterialNames.ContainsKey( first ) || studio.MaterialNames[first] == "materials/wood/oak.vmat",
			string.Join( ", ", studio.MaterialNames.Select( kv => $"{kv.Key}={kv.Value}" ) ) );

		Report.Check( "which is on the slot the brush reported", slot >= 0
			&& studio.MaterialNames.TryGetValue( slot, out var n ) && n == "materials/wood/oak.vmat" );
	}

	static void TestNoChange()
	{
		var studio = Boxed( out var body );
		var top = FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) );

		Drop( studio, body, top, "materials/a.vmat", out _ );

		studio.Rebuild();
		body = studio.Bodies.Single();

		top = FaceIndexFacing( body.Mesh, new Vec3( 0, 0, 1 ) );

		var again = Drop( studio, body, top, "materials/a.vmat", out _ );

		Report.Check( "dropping the same material on the same face reports no change", !again );

		var reference = FacePlane.Capture( body, top, body.Mesh.FaceCentroid( body.Mesh.Faces[top] ) );

		Report.Check( "an empty material is refused",
			!MaterialDrop.Drop( studio, body.Id, top, reference, "  ", out var slot ) && slot == -1 );
	}

	static void TestExhausted()
	{
		var studio = Boxed( out var body );

		for ( var i = 1; i <= MaterialDrop.HighestSlot; i++ )
			studio.MaterialNames[i] = $"materials/filler_{i}.vmat";

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

		var changed = Drop( studio, body, top, "materials/one_too_many.vmat", out var slot );

		Report.Check( "the drop is refused rather than overwriting somebody's slot", !changed && slot == -1 );

		Report.Check( "and nothing was named",
			!studio.MaterialNames.Values.Contains( "materials/one_too_many.vmat" ) );
	}

	// --- helpers, the same ones FaceMenuTests uses ----------------------------------------------

	static bool Drop( PartStudio studio, Body body, int faceIndex, string material, out int slot ) =>
		Drop( studio, body, faceIndex, material, out slot, out _ );

	static bool Drop( PartStudio studio, Body body, int faceIndex, string material, out int slot,
		out int released )
	{
		var reference = FacePlane.Capture( body, faceIndex, body.Mesh.FaceCentroid( body.Mesh.Faces[faceIndex] ) );

		return MaterialDrop.Drop( studio, body.Id, faceIndex, reference, material, out slot, out released );
	}

	static bool Assign( PartStudio studio, Body body, int faceIndex, int slot )
	{
		var reference = FacePlane.Capture( body, faceIndex, body.Mesh.FaceCentroid( body.Mesh.Faces[faceIndex] ) );

		return FaceMaterialEdit.Assign( studio, body.Id, faceIndex, reference, slot );
	}

	static PartStudio Boxed( out Body body )
	{
		var studio = new PartStudio();

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

		studio.Rebuild();
		body = studio.Bodies.Single();

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