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

namespace Effigy;

/// <summary>What a rebuild did. Returned rather than logged, so a UI can show it and a test can
/// assert on it.</summary>
public sealed class RebuildReport
{
	public int FeaturesEvaluated;
	public int FeaturesReused;
	public int FeaturesSuppressed;
	public List<(string FeatureId, string Message)> Errors = new();

	/// <summary>Features that built, but not from everything they were given.</summary>
	public List<(string FeatureId, string Message)> Warnings = new();

	public bool HasErrors => Errors.Count > 0;

	public bool HasWarnings => Warnings.Count > 0;

	public override string ToString() =>
		$"{FeaturesEvaluated} evaluated, {FeaturesReused} reused, {FeaturesSuppressed} suppressed"
		+ (HasErrors ? $", {Errors.Count} errors" : "")
		+ (HasWarnings ? $", {Warnings.Count} warnings" : "");
}

/// <summary>
/// An ordered feature history that rebuilds into a set of bodies. Onshape's Part Studio.
///
/// TWO THINGS MAKE THIS PARAMETRIC RATHER THAN A PILE OF BAKES:
///
///   Rollback — evaluate only the first N features, so you can go back and work as the model was
///   at that point. RollbackIndex is the bar Onshape draws between two features.
///
///   Incremental rebuild — the body list after each feature is cached, so editing feature 7 of 20
///   re-runs 7 onward and reuses the snapshot from 6. Without this every parameter drag re-runs
///   the whole tree, and the tool stops feeling live at about a dozen features.
///
/// The cache holds deep clones, which costs memory proportional to tree length times model size.
/// That is the obvious thing to optimise later — snapshot only at intervals, or make features
/// declare what they read — but not before it actually hurts, because the correctness of "editing
/// upstream cannot be affected by downstream state" is worth more than the memory.
/// </summary>
public sealed class PartStudio
{
	public List<Feature> Features = new();

	/// <summary>How many features to evaluate. int.MaxValue means all of them; anything less is
	/// the rollback bar sitting above feature RollbackIndex.</summary>
	public int RollbackIndex = int.MaxValue;

	/// <summary>
	/// What each material slot is called, for the slots someone has bothered to name.
	///
	/// Faces carry a slot NUMBER, which is all the geometry needs and all it should know. But a
	/// number is what the exporters were writing out too — material_0, material_3 — so binding a
	/// model in ModelDoc meant remembering which number meant what. A name travels with the file.
	///
	/// On the studio rather than on the mesh because a slot means the same thing across every body
	/// in the document: slot 2 is "rubber" everywhere or it is nothing.
	/// </summary>
	public Dictionary<int, string> MaterialNames = new();

	/// <summary>
	/// How many world units one repeat of a slot's material covers, for the slots somebody has
	/// resized. Absent means one-to-one — see <see cref="MaterialScale"/> for why that is the
	/// default and why the number lives on the slot rather than on the face.
	///
	/// Beside MaterialNames because it is the same kind of fact about the same thing: what the slot
	/// carries, and how big it is. A dictionary keyed the same way, saved on the same line-per-slot
	/// rule, and cleared by the same drop that retires a name.
	/// </summary>
	public Dictionary<int, Vec2> MaterialScales = new();

	/// <summary>The name for a slot, falling back to the numbered default. Pass this straight to any
	/// of the exporters.</summary>
	public string NameForSlot( int slot ) =>
		MaterialNames.TryGetValue( slot, out var name ) && !string.IsNullOrWhiteSpace( name )
			? name
			: ObjWriter.DefaultMaterialName( slot );

	/// <summary>
	/// Names given to a body, keyed by body id.
	///
	/// Bodies are remade every rebuild and otherwise take the name of the feature that made them.
	/// That is the right default and the wrong override: a pattern of eight cubes would all follow
	/// "Linear pattern 1", and renaming one in the Parts list would have nowhere to live. Body ids
	/// are stable (<c>{featureId}b{n}</c>), so a name keyed by id survives a rebuild the way
	/// MaterialNames survive one.
	/// </summary>
	public Dictionary<string, string> BodyNames = new();

	/// <summary>
	/// Bodies hidden from the viewport, keyed by id. Independent of Feature.Visible: hiding one
	/// copy of a pattern must not hide the rest, and hiding is not suppression.
	/// </summary>
	public HashSet<string> HiddenBodyIds = new();

	/// <summary>
	/// THE MODEL'S PIVOT, in the same coordinates every feature builds in.
	///
	/// Everything the kernel makes is built where the features put it; this says which point of
	/// that is (0,0,0) to whatever consumes the model. The writers subtract it on the way out, so a
	/// part built ten units up exports standing on zero with its origin under its feet rather than
	/// floating ten units above it. Nothing inside the rebuild reads it — moving the pivot cannot
	/// change what the features produce, only where the result is measured from, which is why
	/// dragging the handle does not dirty a single feature.
	///
	/// On the studio rather than on the viewport because it is a property of the MODEL and has to
	/// survive being saved: a pivot that is lost on reopen is not a pivot, it is a camera setting.
	/// </summary>
	public Vec3 Origin;


	/// <summary>Result of the last rebuild.</summary>
	public List<Body> Bodies { get; private set; } = new();

	/// <summary>A snapshot of everything a feature can see, taken after each one runs.</summary>
	sealed class Snapshot
	{
		public List<Body> Bodies;
		public Dictionary<string, Sketch> Sketches;

		/// <summary>Carried like everything else a feature can see. Left out, an incremental
		/// rebuild that resumes from the cache would find the sketch but not what it is attached
		/// to, and the extrude above it would quietly start making its own body again.</summary>
		public Dictionary<string, string> SketchHostBodies;

		public static Snapshot Of( FeatureContext ctx ) => new()
		{
			Bodies = ctx.Bodies.Select( b => b.Clone() ).ToList(),
			Sketches = ctx.Sketches.ToDictionary( kv => kv.Key, kv => kv.Value.Clone() ),
			SketchHostBodies = new Dictionary<string, string>( ctx.SketchHostBodies )
		};

		public void RestoreInto( FeatureContext ctx )
		{
			ctx.Bodies = Bodies.Select( b => b.Clone() ).ToList();
			ctx.Sketches = Sketches.ToDictionary( kv => kv.Key, kv => kv.Value.Clone() );
			ctx.SketchHostBodies = new Dictionary<string, string>( SketchHostBodies );
		}
	}

	// _cache[i] is the state AFTER feature i ran.
	readonly List<Snapshot> _cache = new();
	int _dirtyFrom;

	public PartStudio()
	{
		_dirtyFrom = 0;
	}

	public int EffectiveCount => Math.Min( RollbackIndex, Features.Count );

	// --- editing ---------------------------------------------------------------------------

	public T Add<T>( T feature ) where T : Feature
	{
		feature.Name ??= DefaultName( feature );
		Features.Add( feature );
		MarkDirty( Features.Count - 1 );
		return feature;
	}

	public T Insert<T>( int index, T feature ) where T : Feature
	{
		feature.Name ??= DefaultName( feature );
		Features.Insert( index, feature );
		MarkDirty( index );
		return feature;
	}

	public void Remove( Feature feature )
	{
		var index = Features.IndexOf( feature );

		if ( index < 0 )
			return;

		Features.RemoveAt( index );
		MarkDirty( index );
	}

	public void Move( int from, int to )
	{
		if ( from == to || from < 0 || from >= Features.Count || to < 0 || to >= Features.Count )
			return;

		var f = Features[from];
		Features.RemoveAt( from );
		Features.Insert( to, f );

		// Everything from the earlier of the two positions is now standing on different geometry.
		MarkDirty( Math.Min( from, to ) );
	}

	/// <summary>Call after changing any parameter on a feature. Everything from here down is
	/// standing on geometry that may have changed.</summary>
	public void MarkDirty( int index )
	{
		_dirtyFrom = Math.Min( _dirtyFrom, Math.Max( 0, index ) );
	}

	public void MarkDirty( Feature feature )
	{
		var index = Features.IndexOf( feature );

		if ( index >= 0 )
			MarkDirty( index );
	}

	public void MarkAllDirty() => _dirtyFrom = 0;

	/// <summary>Unique name in the style Onshape uses — "Box 1", "Box 2", "Linear pattern 1".</summary>
	string DefaultName( Feature feature )
	{
		var n = 1;

		while ( Features.Any( f => f.Name == $"{feature.TypeName} {n}" ) )
			n++;

		return $"{feature.TypeName} {n}";
	}

	// --- rebuild ---------------------------------------------------------------------------

	public RebuildReport Rebuild()
	{
		var report = new RebuildReport();
		var count = EffectiveCount;

		// The cache can only be trusted up to the first dirty feature, and only as far as it was
		// filled last time.
		var reusableUpTo = Math.Min( _dirtyFrom, _cache.Count );
		reusableUpTo = Math.Min( reusableUpTo, count );

		// A feature holding mutable state of its own gets to veto its own cache entry — see
		// Feature.IsStale. Checked before anything is restored, so the snapshot is never read at all.
		for ( var i = 0; i < reusableUpTo; i++ )
		{
			if ( !Features[i].IsStale )
				continue;

			reusableUpTo = i;
			break;
		}

		var ctx = new FeatureContext();

		if ( reusableUpTo > 0 )
		{
			// Clone out of the cache rather than handing the cached state to the features, or the
			// next rebuild reuses a snapshot that a feature has since mutated.
			_cache[reusableUpTo - 1].RestoreInto( ctx );
			report.FeaturesReused = reusableUpTo;
		}

		// Keep body ids climbing past anything already present, so a rebuild starting mid-tree
		// does not reissue an id a cached body already holds.
		ctx.SeedIdCounter( HighestBodyNumber( ctx.Bodies ) + 1 );

		if ( _cache.Count > reusableUpTo )
			_cache.RemoveRange( reusableUpTo, _cache.Count - reusableUpTo );

		// A REUSED FEATURE'S ERROR IS STILL AN ERROR. Features before the dirty point are not re-run,
		// so they never re-report - and the report came back clean while an upstream feature was
		// still broken, purely because the edit happened downstream of it. Carrying the errors
		// forward keeps HasErrors meaning "this model has something wrong with it" rather than
		// "something went wrong during this particular rebuild".
		for ( var i = 0; i < reusableUpTo; i++ )
		{
			if ( Features[i].Error is not null )
				report.Errors.Add( (Features[i].Id, Features[i].Error) );

			if ( Features[i].Warning is not null )
				report.Warnings.Add( (Features[i].Id, Features[i].Warning) );
		}

		for ( var i = reusableUpTo; i < count; i++ )
		{
			var feature = Features[i];

			// Bodies added by this feature inherit its visibility. Doing it here rather than in
			// every feature's Execute means a new feature type gets it for free and cannot forget.
			var bodiesBefore = ctx.Bodies.Count;

			feature.Run( ctx );

			for ( var b = bodiesBefore; b < ctx.Bodies.Count; b++ )
			{
				ctx.Bodies[b].Visible = feature.Visible;
				ctx.Bodies[b].FeatureId = feature.Id;
			}

			if ( feature.Suppressed )
				report.FeaturesSuppressed++;
			else
				report.FeaturesEvaluated++;

			if ( feature.Error is not null )
				report.Errors.Add( (feature.Id, feature.Error) );

			if ( feature.Warning is not null )
				report.Warnings.Add( (feature.Id, feature.Warning) );

			_cache.Add( Snapshot.Of( ctx ) );
		}

		// Features past the rollback bar are neither evaluated nor errors; just note them.
		for ( var i = count; i < Features.Count; i++ )
		{
			Features[i].Error = null;
			Features[i].Warning = null;
			Features[i].Diagnostic = null;
		}

		Bodies = ctx.Bodies;
		ApplyBodyPresentation();

		// AFTER the feature loop, for the same reason ApplyBodyPresentation is: the features produce
		// the UVs this divides, so running it any earlier would hand already-scaled UVs to the
		// feature above and scale them twice. Safe to run on a rebuild that re-evaluated nothing,
		// because the cache holds clones taken inside the loop and ctx.Bodies was cloned back out of
		// it — what this touches is never what the next rebuild reads.
		MaterialScale.Apply( this );

		_dirtyFrom = count;

		return report;
	}

	/// <summary>
	/// Put Parts-list names and hide flags onto the bodies this rebuild just produced.
	///
	/// MUST RUN AFTER the feature loop. Each feature writes Body.Visible from Feature.Visible and
	/// Body.Name from Feature.Name as it goes, which is the right default and would wipe a rename
	/// or a hide if this ran first. Incremental rebuilds restore those same defaults from the
	/// cache, so this also has to run when nothing was re-evaluated.
	/// </summary>
	void ApplyBodyPresentation()
	{
		foreach ( var body in Bodies )
		{
			if ( BodyNames.TryGetValue( body.Id, out var name ) && !string.IsNullOrWhiteSpace( name ) )
				body.Name = name;

			if ( HiddenBodyIds.Contains( body.Id ) )
				body.Visible = false;
		}
	}

	static int HighestBodyNumber( IEnumerable<Body> bodies )
	{
		var highest = 0;

		foreach ( var b in bodies )
		{
			if ( b.Id.StartsWith( "body" ) && int.TryParse( b.Id[4..], out var n ) )
				highest = Math.Max( highest, n );
		}

		return highest;
	}

	/// <summary>Every body merged into one mesh, which is what export wants.</summary>
	/// <summary>Every body, hidden or not. Export takes this: hiding a part is a working
	/// convenience, not a statement that it should leave the model.</summary>
	public PolyMesh ToMesh()
	{
		var merged = new PolyMesh();

		foreach ( var b in Bodies )
			MeshTransform.Append( merged, b.Mesh );

		return merged;
	}

	/// <summary>Only what is being drawn. The viewport preview takes this.</summary>
	public PolyMesh ToVisibleMesh()
	{
		var merged = new PolyMesh();

		foreach ( var b in Bodies )
		{
			if ( b.Visible )
				MeshTransform.Append( merged, b.Mesh );
		}

		return merged;
	}

	/// <summary>
	/// The same merge as ToMesh, but recording which run of vertices came from which body.
	///
	/// This is what makes feature-bound rigging possible. Vertex indices are meaningless across a
	/// rebuild — change one number upstream and every index after it moves — but body ids are
	/// stable, so a rig stored as "body3 is the forearm" can be reapplied to the new geometry
	/// instead of being invalidated by it. See SkinBinder.BindBodies.
	/// </summary>
	public (PolyMesh Mesh, List<BodyRange> Ranges) ToMeshWithBodies()
	{
		var merged = new PolyMesh();
		var ranges = new List<BodyRange>( Bodies.Count );

		foreach ( var b in Bodies )
		{
			var start = merged.VertexCount;
			MeshTransform.Append( merged, b.Mesh );
			ranges.Add( new BodyRange( b.Id, b.Name, start, merged.VertexCount - start ) );
		}

		return (merged, ranges);
	}

	/// <summary>
	/// Which SketchFeature a sketch-consuming feature actually builds from, resolving the empty
	/// "most recent one" case the same way SketchConsumingFeature.ResolveSketch does at rebuild
	/// time - the nearest running SketchFeature ABOVE it in the tree. Null when there is none.
	/// </summary>
	public string ResolveSketchFeatureId( SketchConsumingFeature consumer )
	{
		if ( consumer is null )
			return null;

		// A feature waiting to be pointed at a sketch resolves to NOTHING rather than to the most
		// recent one: it consumes nothing yet, so the sketch it might end up using has to stay visible
		// and pickable - which is how you point at it in the first place. See
		// SketchConsumingFeature.AwaitingPick.
		if ( consumer.IsAwaitingPick )
			return null;

		if ( !string.IsNullOrEmpty( consumer.SketchFeatureId ) )
			return consumer.SketchFeatureId;

		var index = Features.IndexOf( consumer );

		if ( index < 0 )
			index = Features.Count;

		for ( var i = Math.Min( index, EffectiveCount ) - 1; i >= 0; i-- )
		{
			if ( Features[i] is SketchFeature sketch && !sketch.Suppressed )
				return sketch.Id;
		}

		return null;
	}

	/// <summary>
	/// The sketches some later feature has already turned into geometry.
	///
	/// Onshape hides these as soon as they are consumed, and it is not decoration: a sketch left
	/// on screen sits in exactly the same place as the solid built from it, so the part reads as a
	/// wireframe shell rather than as a finished body. Rolled-back and suppressed features are
	/// excluded - a feature that is not running has not consumed anything.
	/// </summary>
	public HashSet<string> ConsumedSketchIds()
	{
		var consumed = new HashSet<string>();

		for ( var i = 0; i < EffectiveCount; i++ )
		{
			if ( Features[i].Suppressed || Features[i] is not SketchConsumingFeature consumer )
				continue;

			if ( ResolveSketchFeatureId( consumer ) is { Length: > 0 } id )
				consumed.Add( id );
		}

		return consumed;
	}

	public int TotalFaceCount => Bodies.Sum( b => b.Mesh.FaceCount );
	public int TotalVertexCount => Bodies.Sum( b => b.Mesh.VertexCount );
}