Effigy.Tests/DiagnosticTests.cs
using System;
using System.Linq;
using Effigy;
using static Effigy.Tests.Report;

namespace Effigy.Tests;

/// <summary>
/// A feature that cannot do what was asked says what it was asked, what stopped it, with this
/// model's numbers, and what would work instead. A feature that did nothing is never a success.
/// </summary>
public static class DiagnosticTests
{
	public static void Run()
	{
		Section( "diagnostics: the mechanism" );
		TestPlainExceptionBecomesADiagnostic();
		TestFeatureExceptionKeepsItsStructure();

		Section( "diagnostics: signed volume lives on the mesh" );
		TestSignedVolume();

		Section( "diagnostics: an oversized fillet is an error, not a silent invert" );
		TestOversizedFillet();
		TestFilletsPastTheHalfWidthAllRefuse();
		TestFilletJustUnderTheHalfWidthStillBuilds();
		TestSmallFilletStillBuilds();
		TestGenerousFilletWarnsItTookHalf();

		Section( "diagnostics: a no-op is an error" );
		TestUnreachableThresholdIsAnError();

		Section( "diagnostics: empty-studio refusals have a cause and a remedy" );
		TestEmptyFilletHasACause();

		Section( "diagnostics: a missing body is an error, not a silent no-op" );
		TestMissingBodySelectionIsAnError();

		Section( "diagnostics: an oversized chamfer is an error too" );
		TestOversizedChamfer();

		Section( "diagnostics: a too-thick shell names a thickness that fits" );
		TestTooThickShell();

		Section( "diagnostics: a boolean that cannot cut says why" );
		TestBooleanMisses();
		TestBooleanOpenSolid();
	}

	static void TestPlainExceptionBecomesADiagnostic()
	{
		var studio = new PartStudio();
		var tube = studio.Add( new PrimitiveFeature() );
		tube.Shape.Index = 4;
		tube.InnerRadius.Value = 10f;
		tube.Radius.Value = 1f;
		studio.Rebuild();

		Check( "a thrown failure becomes a diagnostic", tube.Diagnostic is not null );
		Check( "and it is an error", tube.Diagnostic is { Severity: DiagnosticSeverity.Error } );
		Check( "the problem line names the parameter",
			tube.Diagnostic?.Problem.Contains( "Inner radius" ) == true, tube.Diagnostic?.Problem );
		Check( "the cause has this model's numbers",
			HasNumber( tube.Diagnostic?.Cause ), tube.Diagnostic?.Cause );
		Check( "and there is a remedy",
			tube.Diagnostic is { Remedies.Count: > 0 }, string.Join( "; ", tube.Diagnostic?.Remedies ?? Enumerable.Empty<string>() ) );
	}

	static void TestFeatureExceptionKeepsItsStructure()
	{
		var studio = new PartStudio();
		studio.Add( new PrimitiveFeature() );
		var fillet = studio.Add( new FilletFeature() );
		fillet.Radius.Value = 0.85f;
		studio.Rebuild();

		Check( "the diagnostic is the one the feature threw, not a wrap of its message",
			fillet.Diagnostic is not null && fillet.Error == fillet.Diagnostic.Problem,
			fillet.Error ?? "no error" );
	}

	static void TestSignedVolume()
	{
		var box = Primitives.Box( 2, 2, 2 );
		Check( "a 2x2x2 box encloses 8", Close( box.SignedVolume(), 8f ), $"{box.SignedVolume()}" );
		Check( "and the sign is positive", box.SignedVolume() > 0f );
	}

	// THE NUMBERS IN THE THREE BLEND TESTS BELOW MOVED, and it is worth knowing why before moving
	// them again. Every one of them turns on the ENCLOSED VOLUME of a blended box, and that number
	// used to be wrong: EdgeBlend emitted its vertex caps wound inwards, so the corner triangles
	// subtracted their own contribution instead of adding it (see the note in EdgeBlend.Finish). A
	// chamfered unit box measured 0.811 against a true 0.883.
	//
	// So these radii were chosen against a ruler that read low, and once the ruler was fixed a
	// 0.85 fillet on a 2-unit cube stopped being "inside out" and became what it always actually
	// was: a very rounded cube, and a perfectly good solid. The sizes here are re-picked against
	// the corrected volumes, and every assertion they make is unchanged.

	static void TestOversizedFillet()
	{
		var studio = StudioWithBox();
		var fillet = studio.Add( new FilletFeature() );
		fillet.Radius.Value = 1.3f;
		fillet.AngleThreshold.Value = 15f;
		fillet.Segments.Value = 4;
		studio.Rebuild();

		Check( "Fillet(cube, 1.3) is an error", fillet.Error is not null, "built anyway" );
		Check( "it is structured", fillet.Diagnostic is { Severity: DiagnosticSeverity.Error } );
		Check( "the cause has a number from this model",
			HasNumber( fillet.Diagnostic?.Cause ), fillet.Diagnostic?.Cause );
		Check( "and there is a remedy",
			fillet.Diagnostic is { Remedies.Count: > 0 } );
		Check( "the body is not handed downstream inverted",
			studio.Bodies.Count == 1 && studio.Bodies[0].Mesh.SignedVolume() > 0f,
			$"{studio.Bodies[0].Mesh.SignedVolume()}" );
		Check( "a suggested radius is offered",
			fillet.Diagnostic?.SuggestedValue is > 0f and < 1.3f,
			$"{fillet.Diagnostic?.SuggestedValue}" );
	}

	// THE BAND THE VOLUME GUARD COULD NOT SEE, and the reason this test exists as a sweep rather
	// than as one more radius: the failure was not at a point, it was over an interval, and any
	// single radius picked out of it would have been a magic number nobody could re-derive.
	//
	// On a 2-unit cube the opposite fillets meet exactly at radius 1.0 — each eats r off both ends
	// of a 2-long edge — so every radius above that has consumed more of every face than the face
	// had. ResultVolume <= 0 caught it only from about 1.25 up, because between 1.0 and 1.25 the
	// part is inside out and STILL ENCLOSES A POSITIVE VOLUME. Forty-eight faces pointed into their
	// own body and the mesh was closed, manifold, Euler-correct and valid the whole way: rule 1 of
	// the work order, in the wild, and the reason the fix is a per-edge test in CountCollapsed
	// rather than a tighter number on the volume.
	static void TestFilletsPastTheHalfWidthAllRefuse()
	{
		// Deliberately walks THROUGH the old guard's blind spot and out the far side.
		foreach ( var radius in new[] { 1.05f, 1.1f, 1.15f, 1.2f, 1.24f, 1.3f } )
		{
			var studio = StudioWithBox();
			var fillet = studio.Add( new FilletFeature() );
			fillet.Radius.Value = radius;
			fillet.AngleThreshold.Value = 15f;
			fillet.Segments.Value = 4;
			studio.Rebuild();

			Check( $"Fillet(cube, {radius:0.##}) is refused — it is past the half-width",
				fillet.Error is not null, "built anyway" );

			// The point of refusing. A part that goes downstream with faces pointing into itself
			// is the failure this whole guard exists to prevent, and it is invisible to every
			// validator the kernel owns.
			Check( $"and nothing inside out reaches the body list at {radius:0.##}",
				studio.Bodies.Count == 1 && !PointsInward( studio.Bodies[0].Mesh ),
				"a face points into its own body" );

			Check( $"and the remedy at {radius:0.##} is a radius that actually fits",
				fillet.Diagnostic?.SuggestedValue is { } fit && fit > 0f && fit < 1f,
				$"{fillet.Diagnostic?.SuggestedValue}" );
		}
	}

	// Just below the meeting point is a very rounded cube and a perfectly good solid, so the guard
	// must not have simply become stricter everywhere — a test that only asserts refusals would
	// pass just as well if the feature had stopped working altogether.
	static void TestFilletJustUnderTheHalfWidthStillBuilds()
	{
		var studio = StudioWithBox();
		var fillet = studio.Add( new FilletFeature() );
		fillet.Radius.Value = 0.95f;
		fillet.AngleThreshold.Value = 15f;
		fillet.Segments.Value = 4;
		studio.Rebuild();

		Check( "Fillet(cube, 0.95) still builds", fillet.Error is null, fillet.Error );
		Check( "and it encloses what a 0.95-rounded 2-cube should",
			studio.Bodies[0].Mesh.SignedVolume() > 3f && studio.Bodies[0].Mesh.SignedVolume() < 3.2f,
			$"{studio.Bodies[0].Mesh.SignedVolume()}" );
		Check( "and no face points into its own body",
			!PointsInward( studio.Bodies[0].Mesh ), "a face points inward" );
	}

	/// <summary>
	/// Does any face point back into the solid?
	///
	/// Compares each face normal against the direction from the mesh's own centroid out to that
	/// face. That is only sound for a roughly convex body, which a blended box is, and it is the
	/// measurement that made the bug visible in the first place — every other check the kernel owns
	/// said the mesh was fine.
	/// </summary>
	static bool PointsInward( PolyMesh mesh )
	{
		var centre = Vec3.Zero;

		for ( var i = 0; i < mesh.Positions.Count; i++ )
			centre += mesh.Positions[i];

		centre /= System.Math.Max( 1, mesh.Positions.Count );

		foreach ( var face in mesh.Faces )
		{
			if ( Vec3.Dot( mesh.FaceNormal( face ), mesh.FaceCentroid( face ) - centre ) < 0f )
				return true;
		}

		return false;
	}

	static void TestSmallFilletStillBuilds()
	{
		var studio = StudioWithBox();
		var fillet = studio.Add( new FilletFeature() );
		fillet.Radius.Value = 0.2f;
		fillet.AngleThreshold.Value = 15f;
		fillet.Segments.Value = 4;
		studio.Rebuild();

		Check( "Fillet(cube, 0.2) is not an error", fillet.Error is null, fillet.Error );
		Check( "and the solid stayed a solid",
			studio.Bodies[0].Mesh.SignedVolume() > 0f, $"{studio.Bodies[0].Mesh.SignedVolume()}" );
		Check( "and it actually rounded something",
			studio.Bodies[0].Mesh.FaceCount > 6, $"{studio.Bodies[0].Mesh.FaceCount} faces" );
	}

	static void TestGenerousFilletWarnsItTookHalf()
	{
		var studio = StudioWithBox();
		var fillet = studio.Add( new FilletFeature() );
		fillet.Radius.Value = 0.9f;
		fillet.AngleThreshold.Value = 15f;
		fillet.Segments.Value = 4;
		studio.Rebuild();

		Check( "Fillet(cube, 0.9) still builds", fillet.Error is null, fillet.Error );
		Check( "but warns that more than half the solid is gone",
			fillet.Warning is not null && fillet.Diagnostic is { Severity: DiagnosticSeverity.Warning },
			fillet.Warning ?? "no warning" );
		Check( "the cause names the volumes",
			HasNumber( fillet.Diagnostic?.Cause ), fillet.Diagnostic?.Cause );
	}

	static void TestUnreachableThresholdIsAnError()
	{
		var cube = Primitives.Box( 2, 2, 2 );
		var untouched = EdgeBlend.Fillet( cube, 0.1f, 179f, 4 );

		Check( "the kernel still leaves the cube alone",
			untouched.FaceCount == cube.FaceCount && untouched.VertexCount == cube.VertexCount );

		var studio = StudioWithBox();
		var fillet = studio.Add( new FilletFeature() );
		fillet.Radius.Value = 0.1f;
		fillet.AngleThreshold.Value = 179f;
		fillet.Segments.Value = 4;
		studio.Rebuild();

		Check( "the feature reports the no-op as an error", fillet.Error is not null, "reported success" );
		Check( "naming the sharpest edge",
			HasNumber( fillet.Diagnostic?.Cause ) && fillet.Diagnostic?.Cause.Contains( "°" ) == true,
			fillet.Diagnostic?.Cause );
		Check( "and the geometry stays unchanged",
			studio.Bodies[0].Mesh.FaceCount == 6, $"{studio.Bodies[0].Mesh.FaceCount} faces" );
	}

	static void TestEmptyFilletHasACause()
	{
		var studio = new PartStudio();
		var fillet = studio.Add( new FilletFeature() );
		studio.Rebuild();

		Check( "an empty-studio fillet is an error", fillet.Error is not null );
		Check( "with a cause", !string.IsNullOrEmpty( fillet.Diagnostic?.Cause ), fillet.Error );
		Check( "and a remedy", fillet.Diagnostic is { Remedies.Count: > 0 } );
	}

	static void TestMissingBodySelectionIsAnError()
	{
		var studio = StudioWithBox();
		var subdivide = studio.Add( new SubdivideFeature() );
		subdivide.Bodies.BodyIds.Add( "body-that-was-deleted" );
		studio.Rebuild();

		Check( "it is an error", subdivide.Error is not null, "reported success" );
		Check( "with a cause", !string.IsNullOrEmpty( subdivide.Diagnostic?.Cause ), subdivide.Error );
		Check( "and a remedy", subdivide.Diagnostic is { Remedies.Count: > 0 } );
		Check( "and the box is left as a box",
			studio.Bodies.Count == 1 && studio.Bodies[0].Mesh.FaceCount == 6,
			$"{studio.Bodies[0].Mesh.FaceCount} faces" );
	}

	static void TestOversizedChamfer()
	{
		var studio = StudioWithBox();
		var chamfer = studio.Add( new ChamferFeature() );
		chamfer.Width.Value = 1.3f;
		chamfer.AngleThreshold.Value = 15f;
		studio.Rebuild();

		Check( "Chamfer(cube, 1.3) is an error", chamfer.Error is not null, "built anyway" );
		Check( "the body is not handed downstream inverted",
			studio.Bodies.Count == 1 && studio.Bodies[0].Mesh.SignedVolume() > 0f,
			$"{studio.Bodies[0].Mesh.SignedVolume()}" );
		Check( "a suggested distance is offered",
			chamfer.Diagnostic?.SuggestedValue is > 0f and < 1.3f,
			$"{chamfer.Diagnostic?.SuggestedValue}" );
	}

	static void TestTooThickShell()
	{
		var studio = new PartStudio();
		var plate = studio.Add( new PrimitiveFeature() );
		plate.SizeX.Value = plate.SizeY.Value = 4f;
		plate.SizeZ.Value = 1f;
		var shell = studio.Add( new ShellFeature() );
		shell.Thickness.Value = 0.6f;
		studio.Rebuild();

		Check( "shelling a 1-thick plate by 0.6 is an error", shell.Error is not null, "built anyway" );
		Check( "the cause has this model's numbers",
			HasNumber( shell.Diagnostic?.Cause ), shell.Diagnostic?.Cause );
		Check( "a suggested thickness is offered",
			shell.Diagnostic?.SuggestedValue is > 0f and < 0.5f,
			$"{shell.Diagnostic?.SuggestedValue}" );
		Check( "the first remedy names that number",
			shell.Diagnostic is { Remedies.Count: > 0 }
			&& HasNumber( shell.Diagnostic.Remedies[0] ),
			string.Join( "; ", shell.Diagnostic?.Remedies ?? Enumerable.Empty<string>() ) );
	}

	static void TestBooleanMisses()
	{
		var previous = MeshBoolean.Provider;

		try
		{
			MeshBoolean.Provider = new RefusingBoolean();

			var target = Primitives.Box( 2, 2, 2 );
			var tool = MeshTransform.Transformed( Primitives.Box( 2, 2, 2 ), Xform.Translate( new Vec3( 10, 0, 0 ) ) );

			var diagnostic = Caught( () => MeshBoolean.Apply( BooleanOp.Subtract, target, tool ) );

			Check( "a pair that misses is an error", diagnostic is { Severity: DiagnosticSeverity.Error } );
			Check( "naming the gap along X",
				diagnostic?.Cause?.Contains( "X" ) == true && HasNumber( diagnostic.Cause ),
				diagnostic?.Cause );
			Check( "with a remedy", diagnostic is { Remedies.Count: > 0 } );
		}
		finally
		{
			MeshBoolean.Provider = previous;
		}
	}

	static void TestBooleanOpenSolid()
	{
		var previous = MeshBoolean.Provider;

		try
		{
			MeshBoolean.Provider = new RefusingBoolean();

			var diagnostic = Caught( () =>
				MeshBoolean.Apply( BooleanOp.Subtract, Primitives.Plane( 2, 2 ), Primitives.Box( 1, 1, 1 ) ) );

			Check( "cutting with an open mesh is an error", diagnostic is { Severity: DiagnosticSeverity.Error } );
			Check( "naming the boundary",
				HasNumber( diagnostic?.Cause ) && diagnostic?.Cause.Contains( "boundary" ) == true,
				diagnostic?.Cause );
			Check( "with a remedy", diagnostic is { Remedies.Count: > 0 } );
		}
		finally
		{
			MeshBoolean.Provider = previous;
		}
	}

	static FeatureDiagnostic Caught( Action act )
	{
		try
		{
			act();
			return null;
		}
		catch ( FeatureException e )
		{
			return e.Diagnostic;
		}
	}

	sealed class RefusingBoolean : IMeshBoolean
	{
		public bool TryApply( BooleanOp op, PolyMesh target, PolyMesh tool, out PolyMesh result, out string error )
		{
			result = null;
			error = "engine said no";
			return false;
		}
	}

	static PartStudio StudioWithBox()
	{
		var studio = new PartStudio();
		var box = studio.Add( new PrimitiveFeature() );
		box.Shape.Index = 0;
		box.SizeX.Value = box.SizeY.Value = box.SizeZ.Value = 2f;
		studio.Rebuild();
		return studio;
	}

	static bool HasNumber( string text )
	{
		if ( string.IsNullOrEmpty( text ) )
			return false;

		foreach ( var c in text )
		{
			if ( char.IsDigit( c ) )
				return true;
		}

		return false;
	}

	static bool Close( float a, float b, float eps = 1e-3f ) => MathF.Abs( a - b ) <= eps;

	static void Section( string title ) => Report.Section( title );
	static void Check( string what, bool ok, string detail = null ) => Report.Check( what, ok, detail );
}