Editor/Effigy/Features/FaceMaterialEdit.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace Effigy;

/// <summary>
/// Putting ONE face on a material slot.
///
/// FaceMaterialFeature is built for the other case — a set of faces chosen deliberately in a dialog
/// — and the editor's right-click menu wants the small one: this face, this slot, now. The two
/// differ in bookkeeping rather than in effect, and the bookkeeping is the part worth having in the
/// kernel where it can be tested: which existing assignment to reuse, what to do with the one the
/// face is leaving, and where a new one goes in a tree that may be rolled back.
///
/// It edits the HISTORY, never the mesh. Writing the slot straight onto Body.Mesh would hold until
/// the next rebuild and then silently revert, because bodies are remade from scratch every time.
/// </summary>
public static class FaceMaterialEdit
{
	/// <summary>
	/// Move one face onto <paramref name="slot"/>, and report whether anything changed.
	///
	/// The face is identified by the body and face index it resolved to a moment ago — the caller
	/// has just raycast it — and stored as the FaceRef it captured, which is what survives the
	/// rebuild. Both are needed: the index says which face to take OUT of the assignments that
	/// currently hold it, the reference is what goes IN.
	///
	/// Call Rebuild afterwards. This deliberately does not, so a caller changing several faces pays
	/// for one rebuild rather than one each.
	/// </summary>
	public static bool Assign( PartStudio studio, string bodyId, int faceIndex, FaceRef reference, int slot )
	{
		if ( studio is null || string.IsNullOrEmpty( bodyId ) || faceIndex < 0 )
			return false;

		if ( slot < 0 )
			return false;

		var changed = Detach( studio, bodyId, faceIndex );

		// Slot 0 is the ABSENCE of an assignment rather than an assignment to zero — it is what every
		// face starts on, and what the viewport pointedly does not tint. Detaching has already done
		// the whole job.
		if ( slot == 0 )
			return changed;

		var target = SlotFeature( studio, slot );

		if ( target is null )
		{
			target = new FaceMaterialFeature();
			target.Material.Value = slot;

			// AT THE ROLLBACK BAR, not at the end. Below the bar a feature is not evaluated, so the
			// face would sit there unpainted with nothing on screen to explain why.
			if ( studio.RollbackIndex < studio.Features.Count )
			{
				studio.Insert( studio.RollbackIndex, target );
				studio.RollbackIndex++;
			}
			else
			{
				studio.Add( target );
			}
		}

		target.Faces.Add( reference );
		studio.MarkDirty( target );

		return true;
	}

	/// <summary>
	/// Take a face out of every assignment currently holding it, and drop any assignment that just
	/// lost its last face.
	///
	/// Relying on tree order instead — the later feature wins, so why bother — works today and rots:
	/// right-clicking the same face four times would leave four assignments to it, three of them
	/// invisible on screen and all four written to the file.
	/// </summary>
	public static bool Detach( PartStudio studio, string bodyId, int faceIndex )
	{
		if ( studio is null || string.IsNullOrEmpty( bodyId ) || faceIndex < 0 )
			return false;

		var changed = false;
		var emptied = new List<FaceMaterialFeature>();

		foreach ( var feature in studio.Features.OfType<FaceMaterialFeature>().ToList() )
		{
			var removed = false;

			// Matched by RESOLVED FACE, not by comparing the stored references. Two captures of the
			// same face record different hit points and are not equal to one another, which is the
			// same trap the dialog's selection box documents.
			for ( var i = feature.Faces.Count - 1; i >= 0; i-- )
			{
				if ( !FacePlane.TryResolveFace( studio.Bodies, feature.Faces[i], out var body, out var index ) )
					continue;

				if ( body.Id != bodyId || index != faceIndex )
					continue;

				feature.Faces.RemoveAt( i );
				removed = true;
			}

			if ( !removed )
				continue;

			changed = true;
			studio.MarkDirty( feature );

			if ( feature.Faces.Count == 0 )
				emptied.Add( feature );
		}

		// An assignment with no faces left FAILS the moment it runs, which is right when the faces
		// went missing under it and wrong here: it only emptied because this edit took its last face,
		// and leaving a red feature in the tree for that would be baffling.
		foreach ( var feature in emptied )
			studio.Remove( feature );

		return changed;
	}

	/// <summary>
	/// The live assignment for a slot above the rollback bar, or null.
	///
	/// The LAST one rather than the first, because that is the one that would win anyway — the tree
	/// runs in order and a later assignment overrides an earlier one on any face they share.
	/// Reusing it is what keeps a session of clicking faces from growing a feature per click.
	/// </summary>
	public static FaceMaterialFeature SlotFeature( PartStudio studio, int slot )
	{
		FaceMaterialFeature found = null;

		var limit = Math.Min( studio.EffectiveCount, studio.Features.Count );

		for ( var i = 0; i < limit; i++ )
		{
			if ( studio.Features[i] is FaceMaterialFeature feature
				&& !feature.Suppressed
				&& feature.Material.Clamped == slot )
				found = feature;
		}

		return found;
	}

	/// <summary>Every slot the document has an opinion about — one an assignment uses, or one
	/// somebody has named. What a menu offers on top of this is the menu's business.</summary>
	public static IEnumerable<int> UsedSlots( PartStudio studio )
	{
		var slots = new SortedSet<int>();

		if ( studio is null )
			return slots;

		foreach ( var feature in studio.Features.OfType<FaceMaterialFeature>() )
			slots.Add( feature.Material.Clamped );

		foreach ( var slot in studio.MaterialNames.Keys )
			slots.Add( slot );

		return slots;
	}
}