Effigy.Tests/VmdlPhysicsTests.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.IO;
using System.Threading;
using Effigy;
using static Effigy.Tests.Report;

namespace Effigy.Tests;

/// <summary>
/// The PhysicsShapeList node, which is the step that had been missing since CollisionBuilder was
/// written: the shapes were right and the .vmdl carried none of them.
///
/// WHAT THESE CAN AND CANNOT CHECK. Whether the ENGINE reads a key is not decidable here, and it was
/// not guessed either — every key was probed by writing a .vmdl, compiling it, and reading the
/// compiled model's physics bounds back (VmdlPhysics' own comment records what each probe answered).
/// What is left for a headless test is everything downstream of that: the right key for the right
/// shape, the right NUMBER against it, and text a KV3 parser can actually read.
///
/// The number is not a formality. `dimensions` is a full size and CollisionShape.Size is a
/// half-extent, so the one line that doubles it is the difference between a part's collision and
/// half of it — and a wrong one compiles, loads, and is only ever noticed by someone walking
/// through a wall.
/// </summary>
public static class VmdlPhysicsTests
{
	/// <summary>
	/// A studio whose collision has a known, easily-measured answer: a 4-cube at the origin and a
	/// 2-cube out at x = 10, which together span 13 x 4 x 4.
	///
	/// Shared by the headless checks and by the sample file, so the number the compiler is asked to
	/// agree with is the same number the tests assert.
	/// </summary>
	internal static PartStudio TwoBoxes()
	{
		var studio = new PartStudio();

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

		var b = studio.Add( new PrimitiveFeature() );
		b.SizeX.Value = 2f;
		b.SizeY.Value = 2f;
		b.SizeZ.Value = 2f;
		b.Position.Value = new Vec3( 10, 0, 0 );

		studio.Rebuild();
		return studio;
	}

	public static void Run()
	{
		Section( "vmdl physics: each shape's own keys and numbers" );
		TestBox();
		TestSphere();
		TestCylinder();
		TestHull();

		Section( "vmdl physics: the bone a shape rides on" );
		TestBoneOnAHull();
		TestNoBoneIsTheRoot();
		TestBonedPrimitiveBecomesAHull();

		Section( "vmdl physics: the node, and what it does with nothing" );
		TestNodeShape();
		TestNothingIsNothing();

		Section( "vmdl physics: numbers a KV3 parser can read" );
		TestNumberFormatting();

		Section( "vmdl physics: a studio's collision, end to end" );
		TestFromAStudio();
	}

	static void TestBox()
	{
		var text = VmdlPhysics.ShapeList( new[]
		{
			new CollisionShape
			{
				Kind = CollisionKind.Box,
				Position = new Vec3( 1, 2, 3 ),
				Size = new Vec3( 2, 3, 4 ),
			}
		} );

		Check( "a box is a PhysicsShapeBox", text.Contains( "_class = \"PhysicsShapeBox\"" ) );

		// THE DOUBLING. Size is half-extents, dimensions is the full size. Halved collision compiles
		// and loads and is invisible until something falls through it.
		Check( "dimensions is the FULL size, not the half-extents",
			text.Contains( "dimensions = [ 4.0, 6.0, 8.0 ]" ), Line( text, "dimensions" ) );

		// `origin`, and not the three keys that compile and are ignored.
		Check( "and it is placed by origin", text.Contains( "origin = [ 1.0, 2.0, 3.0 ]" ),
			Line( text, "origin" ) );
		Check( "not by center, translation or position, which the engine ignores on a box",
			!text.Contains( "center =" ) && !text.Contains( "translation =" ) && !text.Contains( "position =" ) );
	}

	static void TestSphere()
	{
		var text = VmdlPhysics.ShapeList( new[]
		{
			new CollisionShape
			{
				Kind = CollisionKind.Sphere,
				Position = new Vec3( 0, 0, 5 ),
				Size = new Vec3( 1.5f, 0, 0 ),
			}
		} );

		Check( "a sphere is a PhysicsShapeSphere", text.Contains( "_class = \"PhysicsShapeSphere\"" ) );
		Check( "with its radius", text.Contains( "radius = 1.5" ), Line( text, "radius" ) );

		// The one shape whose placement key is NOT the box's. Probed both ways round.
		Check( "and placed by center, which is the sphere's key and not the box's",
			text.Contains( "center = [ 0.0, 0.0, 5.0 ]" ), Line( text, "center" ) );
		Check( "and not by origin", !text.Contains( "origin =" ) );
	}

	static void TestCylinder()
	{
		var text = VmdlPhysics.ShapeList( new[]
		{
			new CollisionShape
			{
				Kind = CollisionKind.Cylinder,
				Position = new Vec3( 0, 0, 10 ),
				Size = new Vec3( 2f, 0f, 4f ),
			}
		} );

		Check( "a cylinder is a PhysicsShapeCylinder", text.Contains( "_class = \"PhysicsShapeCylinder\"" ) );
		Check( "with its radius", text.Contains( "radius = 2.0" ), Line( text, "radius" ) );

		// Size.z is the HALF height, so the two points sit one half-height either side of the centre.
		// Using the whole height for each would double the cylinder without changing anything a
		// compile could complain about.
		Check( "and its ends one half-height either side of the centre",
			text.Contains( "point0 = [ 0.0, 0.0, 6.0 ]" ) && text.Contains( "point1 = [ 0.0, 0.0, 14.0 ]" ),
			$"{Line( text, "point0" )} / {Line( text, "point1" )}" );
	}

	static void TestHull()
	{
		var cube = new List<Vec3>();

		foreach ( var x in new[] { -1f, 1f } )
		foreach ( var y in new[] { -1f, 1f } )
		foreach ( var z in new[] { -1f, 1f } )
			cube.Add( new Vec3( x, y, z ) );

		var text = VmdlPhysics.ShapeList( new[]
		{
			new CollisionShape { Kind = CollisionKind.Hull, Points = cube }
		} );

		Check( "a hull is a PhysicsShapeHull", text.Contains( "_class = \"PhysicsShapeHull\"" ) );
		Check( "carrying every one of its points", CountOf( text, "[ " ) >= 8,
			$"{CountOf( text, "[ " )} vectors" );
		Check( "under hull_vertices", text.Contains( "hull_vertices = " ) );

		// The points go in as they are. The probe that settled this wrote a cube offset along x and
		// measured 20 across, so nothing is being re-centred underneath.
		Check( "in model space, not re-centred", text.Contains( "[ -1.0, -1.0, -1.0 ]" )
			&& text.Contains( "[ 1.0, 1.0, 1.0 ]" ) );

		// A "hull" of three points is a triangle, which is not a solid. Better nothing than a
		// degenerate shape the physics engine has to decide what to do with.
		var flat = VmdlPhysics.ShapeList( new[]
		{
			new CollisionShape
			{
				Kind = CollisionKind.Hull,
				Points = new List<Vec3> { Vec3.Zero, new( 1, 0, 0 ), new( 0, 1, 0 ) },
			}
		} );

		Check( "and a hull of fewer than four points is dropped rather than written",
			flat.Length == 0, flat );
	}

	/// <summary>
	/// A hull's points do NOT move when it is given a bone.
	///
	/// This is the whole reason the bone-parented path emits hulls and nothing else. The probe
	/// VmdlPhysics records found that hull_vertices stay MODEL space while a primitive's origin is
	/// read in the bone's own space — so a hull can name a bone without its numbers changing, and
	/// this pins that: same points, bone or no bone.
	/// </summary>
	static void TestBoneOnAHull()
	{
		var points = new List<Vec3>
		{
			new( 0, 0, 0 ), new( 4, 0, 0 ), new( 0, 4, 0 ), new( 0, 0, 4 ),
		};

		var free = VmdlPhysics.ShapeList( new[]
		{
			new CollisionShape { Kind = CollisionKind.Hull, Points = points }
		} );

		var boned = VmdlPhysics.ShapeList( new[]
		{
			new CollisionShape { Kind = CollisionKind.Hull, Points = points, Bone = "upper_arm_L" }
		} );

		Check( "a hull's bone reaches parent_bone",
			boned.Contains( "parent_bone = \"upper_arm_L\"" ), Line( boned, "parent_bone" ) );

		Check( "and its vertices are the same ones it had without a bone - model space either way",
			free.Replace( "parent_bone = \"\"", "parent_bone = \"upper_arm_L\"" ) == boned );
	}

	/// <summary>
	/// No bone still means the root, which is what a static part wants and what every earlier test
	/// in this file was written against.
	/// </summary>
	static void TestNoBoneIsTheRoot()
	{
		var text = VmdlPhysics.ShapeList( new[]
		{
			new CollisionShape { Kind = CollisionKind.Box, Size = new Vec3( 1, 1, 1 ) }
		} );

		Check( "a shape with no bone is parented to the root",
			text.Contains( "parent_bone = \"\"" ), Line( text, "parent_bone" ) );
	}

	/// <summary>
	/// A primitive that is given a bone comes out as a hull of its corners.
	///
	/// NOT PEDANTRY. A PhysicsShapeBox's `origin` is read in the parent bone's LOCAL space, rotation
	/// included, and a CollisionShape has no rotation to describe that with — so a box written
	/// against a bone lands wherever that bone's bind pose happens to put it. The corners are points
	/// and points are model space, so the hull says the same thing in a space both families agree on.
	/// </summary>
	static void TestBonedPrimitiveBecomesAHull()
	{
		var text = VmdlPhysics.ShapeList( new[]
		{
			new CollisionShape
			{
				Kind = CollisionKind.Box,
				Position = new Vec3( 10, 0, 0 ),
				Size = new Vec3( 2, 3, 4 ),
				Bone = "spine",
			}
		} );

		Check( "a bone-parented box is written as a hull, not a PhysicsShapeBox",
			text.Contains( "_class = \"PhysicsShapeHull\"" ) && !text.Contains( "PhysicsShapeBox" ) );

		Check( "still on its bone", text.Contains( "parent_bone = \"spine\"" ) );

		// The eight corners of 10±2, 0±3, 0±4 - the same solid, said with points.
		Check( "and the corners are the box's own, in model space",
			text.Contains( "[ 8.0, -3.0, -4.0 ]" ) && text.Contains( "[ 12.0, 3.0, 4.0 ]" ), text );
	}

	static void TestNodeShape()
	{
		var text = VmdlPhysics.ShapeList( new[]
		{
			new CollisionShape { Kind = CollisionKind.Box, Size = new Vec3( 1, 1, 1 ) },
			new CollisionShape { Kind = CollisionKind.Box, Position = new Vec3( 4, 0, 0 ), Size = new Vec3( 1, 1, 1 ) },
		} );

		Check( "one PhysicsShapeList holds them all", CountOf( text, "PhysicsShapeList" ) == 1,
			$"{CountOf( text, "PhysicsShapeList" )}" );
		Check( "with a child per shape", CountOf( text, "_class = \"PhysicsShapeBox\"" ) == 2 );

		// KV3 IS BRACE-SENSITIVE AND THE COMPILER'S MESSAGE FOR AN UNBALANCED ONE IS NOT KIND. This
		// is the same reason DmxGrammarTests parses its output rather than searching it.
		Check( "braces balance", CountOf( text, "{" ) == CountOf( text, "}" ),
			$"{CountOf( text, "{" )} open, {CountOf( text, "}" )} close" );
		Check( "and brackets balance", CountOf( text, "[" ) == CountOf( text, "]" ),
			$"{CountOf( text, "[" )} open, {CountOf( text, "]" )} close" );

		// It is spliced between a RootNode's other children, so it has to end the way they do.
		Check( "the node is a complete child entry, comma and all",
			text.TrimEnd( '\n' ).EndsWith( "}," ), text[^8..] );

		Check( "every shape names a surface and a collision tag",
			CountOf( text, "surface_prop" ) == 2 && CountOf( text, "collision_tags" ) == 2 );
	}

	static void TestNothingIsNothing()
	{
		// EMPTY, NOT AN EMPTY LIST. A PhysicsShapeList with no children is a model that says it has
		// collision and has none, which reads as a physics bug rather than as a missing step.
		Check( "no shapes writes no node", VmdlPhysics.ShapeList( new CollisionShape[0] ).Length == 0 );
		Check( "and neither does null", VmdlPhysics.ShapeList( null ).Length == 0 );

		var fallback = VmdlPhysics.MeshFromRender();

		Check( "the fallback node is the one this project already ships",
			fallback.Contains( "_class = \"PhysicsMeshFromRender\"" )
			&& fallback.Contains( "_class = \"PhysicsShapeList\"" ) );
	}

	/// <summary>
	/// The two ways a number can be written that KV3 will not read: with a comma for a decimal
	/// point, and in exponent form. Both are what .NET does by default under the right conditions,
	/// and neither fails loudly.
	/// </summary>
	static void TestNumberFormatting()
	{
		var text = VmdlPhysics.ShapeList( new[]
		{
			new CollisionShape
			{
				Kind = CollisionKind.Box,
				Position = new Vec3( 0.0000015f, -0f, 12345.75f ),
				Size = new Vec3( 0.5f, 1f, 2f ),
			}
		} );

		Check( "no exponent notation anywhere", !text.Contains( "E" ) && !text.Contains( "e+" ),
			Line( text, "origin" ) );
		Check( "every number has a decimal point", text.Contains( "12345.75" ) && text.Contains( "1.0" ) );
		Check( "and negative zero is written as zero", !text.Contains( "-0.0" ), Line( text, "origin" ) );

		// A machine with a comma decimal separator writes [ 1,5, 0,0, 0,0 ], which a KV3 parser reads
		// as six integers. Nothing about that fails at the point it happens.
		var culture = Thread.CurrentThread.CurrentCulture;

		try
		{
			Thread.CurrentThread.CurrentCulture = new CultureInfo( "de-DE" );

			var german = VmdlPhysics.ShapeList( new[]
			{
				new CollisionShape { Kind = CollisionKind.Box, Size = new Vec3( 0.75f, 1f, 1f ) }
			} );

			Check( "and a comma-decimal machine still writes a dot",
				german.Contains( "1.5" ) && !german.Contains( "1,5" ), Line( german, "dimensions" ) );
		}
		finally
		{
			Thread.CurrentThread.CurrentCulture = culture;
		}
	}

	/// <summary>
	/// The whole path: a studio, its collision, its node. Two boxes drawn apart come out as two
	/// PhysicsShapeBoxes in the right places and at the right size — which is the claim
	/// CollisionBuilder has been making since it was written and could not previously deliver on.
	/// </summary>
	static void TestFromAStudio()
	{
		var studio = TwoBoxes();
		var report = CollisionBuilder.Build( studio );

		Check( "the studio's collision is read from the history", report.FromHistory, report.Reason );

		var text = VmdlPhysics.ShapeList( report.Shapes );

		Check( "and comes out as two boxes", CountOf( text, "_class = \"PhysicsShapeBox\"" ) == 2,
			$"{CountOf( text, "_class = \"PhysicsShapeBox\"" )}" );
		Check( "the first at the origin, four across",
			text.Contains( "dimensions = [ 4.0, 4.0, 4.0 ]" ) && text.Contains( "origin = [ 0.0, 0.0, 0.0 ]" ) );
		Check( "the second out at x = 10, two across",
			text.Contains( "dimensions = [ 2.0, 2.0, 2.0 ]" ) && text.Contains( "origin = [ 10.0, 0.0, 0.0 ]" ),
			Line( text, "origin" ) );

		// And the fallback path still produces something writable: a subdivide spoils the history,
		// so this becomes hulls, and hulls still have to make a node.
		studio.Add( new SubdivideFeature() ).Levels.Value = 1;
		studio.Rebuild();

		var hulled = CollisionBuilder.Build( studio );
		var hullText = VmdlPhysics.ShapeList( hulled.Shapes );

		Check( "a spoiled history still writes shapes, as hulls", !hulled.FromHistory
			&& hullText.Contains( "_class = \"PhysicsShapeHull\"" ), hulled.ToString() );
	}

	/// <summary>
	/// A complete .vmdl carrying this kernel's own collision output, written for the same reason the
	/// sample DMX and the sample normal map are: the verdict that matters is somewhere else.
	///
	/// The checks above prove the text says what it should. Only the engine can say whether the
	/// engine agrees, and the way to ask is:
	///
	///     copy out/sample_physics.{obj,vmdl} into Assets/models/effigy_probe/
	///     register_external_assets, asset_compile, then kit_validate that folder
	///
	/// TwoBoxes spans 13 x 4 x 4 — a 4-cube at the origin and a 2-cube whose far face is at x = 11 —
	/// so the answer is one number and it is not a matter of opinion. BOTH bounds have to read it:
	/// PhysicsBounds alone says the shapes are right, and Bounds alone says the mesh is where it was
	/// drawn, and the pair of them agreeing is the only thing that says the collision is on the
	/// model rather than beside it. Run on 2026-08-31 it gave 13 x 4 x 4 for both.
	///
	/// The failures each look like something specific. Halved boxes read 11 x 4 x 4 (a `dimensions`
	/// taken as half-extents). Shapes stacked at the origin read 4 x 4 x 4 (a placement key the
	/// engine ignores). And 13 x 13 x 4 is the mesh and the shapes at ninety degrees to each other,
	/// which is what the import_rotation below is there to stop.
	/// </summary>
	internal static void WriteSample( string outDir )
	{
		var studio = TwoBoxes();
		var report = CollisionBuilder.Build( studio );
		var physics = VmdlPhysics.ShapeList( report.Shapes );

		ObjWriter.WriteFile( studio.ToMesh(), Path.Combine( outDir, "sample_physics.obj" ), "sample_physics" );

		var vmdl =
			"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc29:version{3cec427c-1b0e-4d48-a90a-0436f33a6041} -->\n"
			+ "{\n\trootNode = \n\t{\n\t\t_class = \"RootNode\"\n\t\tchildren = \n\t\t[\n"
			+ "\t\t\t{\n\t\t\t\t_class = \"RenderMeshList\"\n\t\t\t\tchildren = \n\t\t\t\t[\n"
			+ "\t\t\t\t\t{\n\t\t\t\t\t\t_class = \"RenderMeshFile\"\n\t\t\t\t\t\tname = \"Body_LOD0\"\n"
			+ "\t\t\t\t\t\tchildren = \n\t\t\t\t\t\t[\n\t\t\t\t\t\t]\n"
			+ "\t\t\t\t\t\tfilename = \"models/effigy_probe/sample_physics.obj\"\n"
			+ "\t\t\t\t\t\timport_translation = [ 0.0, 0.0, 0.0 ]\n"
			// The -90 yaw the editor's own BuildVmdl writes, and for the reason given there:
			// ModelDoc's OBJ importer turns the mesh a quarter turn, and the physics shapes are in
			// the file's own coordinates. Without it this sample compiles with its collision at
			// ninety degrees to its mesh, and the bounds read 13 x 13 x 4 instead of 13 x 4 x 4.
			+ "\t\t\t\t\t\timport_rotation = [ 0.0, -90.0, 0.0 ]\n"
			+ "\t\t\t\t\t\timport_scale = 1.0\n"
			+ "\t\t\t\t\t\talign_origin_x_type = \"None\"\n"
			+ "\t\t\t\t\t\talign_origin_y_type = \"None\"\n"
			+ "\t\t\t\t\t\talign_origin_z_type = \"None\"\n"
			+ "\t\t\t\t\t\tparent_bone = \"\"\n\t\t\t\t\t},\n\t\t\t\t]\n\t\t\t},\n"
			+ physics
			+ "\t\t]\n\t\tmodel_archetype = \"\"\n\t\tprimary_associated_entity = \"\"\n"
			+ "\t\tanim_graph_name = \"\"\n\t\tbase_model_name = \"\"\n\t}\n}\n";

		File.WriteAllText( Path.Combine( outDir, "sample_physics.vmdl" ), vmdl );

		Check( $"wrote {outDir}/sample_physics.vmdl - compile it and its physics bounds should read 13 x 4 x 4",
			physics.Length > 0 && report.FromHistory, report.ToString() );
	}

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

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

	/// <summary>The first line naming a key, so a failure prints what was actually written.</summary>
	static string Line( string text, string key )
	{
		foreach ( var line in text.Split( '\n' ) )
		{
			if ( line.Contains( key + " =", StringComparison.Ordinal ) )
				return line.Trim();
		}

		return $"no '{key}' line";
	}
}