Editor/EffigyEditor/EffigyAnimExport.cs
using Editor;
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

using Skeleton = Effigy.Skeleton;

namespace Marionette.EditorTools;

/// <summary>
/// The bridge from a Marionette clip to an Effigy animation export.
///
/// WHY THE BRIDGE IS HERE AND NOT IN THE KERNEL. `Effigy/` contains no reference to any engine type
/// anywhere in it, and a test enforces that. `RigAnimDocument`, `Transform` and `Asset` are all
/// engine types, so the sampling has to live on this side of the line. What the kernel owns is the
/// part that does not need an engine: `AnimClip` holds the sampled poses and `DmxAnimWriter` writes
/// them, both covered headlessly.
///
/// WHY THIS DOES NOT AUTHOR CLIPS. Effigy owns rig authoring and binding; Marionette owns posing,
/// keyframes, IK and constraints, and that split is a decision on the record rather than an
/// accident (see WHAT-IS-BUILT, "The rig design, as decided"). So the clip picker takes a
/// `.riganim` that already exists and samples it. Effigy does not grow a second timeline.
///
/// The pipeline this closes:
///
///     Effigy: model → rig → Compile .vmdl → export.vmdl
///           → Marionette opens it, poses it, saves a .riganim
///           → Effigy adds that .riganim and compiles it INTO the model
/// </summary>
internal static class EffigyAnimExport
{
	/// <summary>
	/// A clip queued for export: the asset the user picked, and the name it will carry inside the
	/// model. The asset is kept rather than the loaded document so a clip edited in Marionette
	/// after being added is re-read at compile time rather than exported stale.
	/// </summary>
	internal sealed class ClipSource
	{
		public Asset Asset;
		public string Name;
		public bool Looping = true;

		/// <summary>The clip's own file name, which is what a person recognises in a list. The
		/// activity name inside the model defaults to it for the same reason.</summary>
		public static string NameOf( Asset asset ) =>
			Path.GetFileNameWithoutExtension( asset?.Path ?? "clip" );
	}

	// --- sampling -----------------------------------------------------------------------------

	/// <summary>
	/// An engine Transform as a kernel Xform.
	///
	/// AN XFORM'S COLUMNS ARE WHERE THE UNIT AXES LAND — that is Xform's own definition of itself,
	/// and `DmxText.Quaternion` reads them back as exactly that when it turns a basis into the
	/// quaternion a DmeChannel carries. So this has to be the true decomposition of the rotation,
	/// not a naming convention:
	///
	///     X ← the image of (1,0,0), which s&amp;box calls Forward
	///     Y ← the image of (0,1,0), which is Left, i.e. -Right
	///     Z ← the image of (0,0,1), which is Up
	///
	/// THIS USED TO BE (Right, Forward, Up), COPIED FROM `EffigyViewport.ApplyBoneTransform`, and
	/// it is the reason exported clips crumpled the model on playback. That triple is not the
	/// rotation's basis — it is the basis with X and Y swapped and one of them negated, which is a
	/// perfectly valid rotation matrix and therefore compiles without a murmur. What it writes is
	/// `R · Rz(-90°)`: every bone turned a quarter turn about its own Z.
	///
	/// It does not cancel out, which is why it was worth this much comment. Each bone's POSITION is
	/// written correctly in its parent's true frame, so the extra quarter turn on the parent throws
	/// the child sideways rather than merely spinning it in place, and the error compounds down
	/// every chain — a shoulder off by 90° puts the hand somewhere else entirely. Both the bind
	/// pose and the frames come through here, so nothing downstream had a correct value to
	/// disagree with.
	///
	/// SCALE IS DROPPED, deliberately. A DmeChannel writes position and orientation and nothing
	/// else, so a scaled keyframe has nowhere to go in the format; carrying it into the basis
	/// vectors instead would bake a scale into the bone's rotation and read as a skew.
	/// </summary>
	public static Xform ToXform( Transform t )
	{
		var rot = t.Rotation;
		var x = rot.Forward;
		var y = -rot.Right;
		var z = rot.Up;

		return new Xform(
			new Vec3( x.x, x.y, x.z ),
			new Vec3( y.x, y.y, y.z ),
			new Vec3( z.x, z.y, z.z ),
			new Vec3( t.Position.x, t.Position.y, t.Position.z ) );
	}

	/// <summary>
	/// How many frames of this document are worth writing.
	///
	/// NOT `FrameCount`, which defaults to 900 — thirty seconds at 30fps — because the timeline
	/// reads as broken when it is shorter (see RigAnimDocument). Writing to that default would put
	/// 900 frames of mostly-identical poses in the file for every bone and two channels each, for a
	/// clip whose last key is at frame 20. The last keyframe is where the animation actually ends;
	/// past it every track is constant by `Evaluate`'s own definition.
	///
	/// Clamped to FrameCount so a document whose keys were dragged out beyond its declared length
	/// exports what it claims to be rather than silently more.
	/// </summary>
	public static int FrameSpan( RigAnimDocument doc )
	{
		var last = 0;

		foreach ( var track in doc.BoneTracks )
		{
			foreach ( var key in track.Keyframes )
				last = Math.Max( last, key.Frame );
		}

		return Math.Min( last, Math.Max( doc.FrameCount - 1, 0 ) );
	}

	/// <summary>
	/// The clip, sampled onto the skeleton it will be compiled against.
	///
	/// THE FALLBACK IS THE BIND POSE, NOT ZERO, and that is the whole reason this is not a two-line
	/// loop. `BoneTrack.Evaluate` returns `Transform.Zero` for a track with no keyframes, and a
	/// bone with no track at all has no Evaluate to call — so the obvious version poses every
	/// unkeyed bone at the origin and collapses the parts of the model nobody animated into a heap
	/// at the root. An unkeyed bone should stay exactly where the rig put it.
	///
	/// THE SKELETON IS EFFIGY'S, and bones are matched to tracks BY NAME. That is the same contract
	/// ModelDoc uses to match a clip to a model, so a track naming a bone this rig does not have is
	/// dropped here for the same reason the compiler would drop it — but here it can be counted and
	/// reported, which is the difference between a clip that quietly animates less of the model and
	/// one that says so.
	/// </summary>
	public static AnimClip ToClip( RigAnimDocument doc, Skeleton skeleton, string name,
		bool looping, out int matched, out List<string> unmatched )
	{
		if ( doc is null )
			throw new ArgumentNullException( nameof( doc ) );

		if ( skeleton is null )
			throw new ArgumentNullException( nameof( skeleton ) );

		// Tracks with no keys are not tracks: Evaluate would answer Transform.Zero for every frame
		// of them, which is the collapse-to-origin case above.
		// THE MAIN MODEL'S BONES ONLY. A clip can also carry whole-part tracks and the bones of
		// other objects in the scene (a weapon's bolt, a door's hinge) - none of which exist in
		// THIS skeleton. Left in, every one of them would be reported as a bone the model is
		// missing, which reads as the clip being authored against the wrong model.
		var tracks = doc.SkeletonTracks
			.Where( t => !string.IsNullOrWhiteSpace( t.BoneName ) && t.Keyframes.Count > 0 )
			.Where( t => RigTrackName.SubjectOf( t.BoneName ) == RigTrackName.RootSubject )
			.GroupBy( t => t.BoneName )
			.ToDictionary( g => g.Key, g => g.First() );

		matched = skeleton.Bones.Count( b => tracks.ContainsKey( b.Name ) );

		unmatched = tracks.Keys
			.Where( boneName => skeleton.IndexOf( boneName ) < 0 )
			.OrderBy( n => n )
			.ToList();

		var rate = doc.AnimationSpeed > 0 ? doc.AnimationSpeed : 30;
		var span = FrameSpan( doc );

		var clip = new AnimClip
		{
			Name = name,
			FrameRate = rate,
			Looping = looping,
		};

		// A single-frame clip is a pose, and the writer accepts one: N frames span N-1 intervals,
		// so one frame is a zero-length clip rather than an error.
		for ( var f = 0; f <= span; f++ )
		{
			var pose = new Xform[skeleton.Count];

			for ( var b = 0; b < skeleton.Count; b++ )
			{
				pose[b] = tracks.TryGetValue( skeleton.Bones[b].Name, out var track )
					? ToXform( track.Evaluate( f ) )
					: skeleton.Bones[b].Local;
			}

			clip.AddFrame( pose );
		}

		return clip;
	}

	// --- export -------------------------------------------------------------------------------

	/// <summary>
	/// Write every queued clip beside the model and return the entries that name them in the
	/// .vmdl. A clip that cannot be read or does not fit the rig is reported and SKIPPED rather
	/// than failing the whole compile — the model and the clips that did work are still worth
	/// having, and the log says which one was dropped and why.
	/// </summary>
	public static List<VmdlAnimation.ClipEntry> WriteClips( IEnumerable<ClipSource> sources,
		string folder, string assetPrefix, Skeleton skeleton )
	{
		var entries = new List<VmdlAnimation.ClipEntry>();
		var used = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		foreach ( var source in sources ?? Enumerable.Empty<ClipSource>() )
		{
			if ( source?.Asset is null )
				continue;

			// Re-read at compile time: the asset is the source of truth, so a clip edited in
			// Marionette since it was added exports as it is now.
			// Same call shape RigControlWindow.LoadAsset uses, which is the one known to work.
			if ( !source.Asset.TryLoadResource( out RigAnimDocument doc ) || doc is null )
			{
				Log.Warning( $"[Effigy] clip '{source.Name}' could not be loaded from "
					+ $"{source.Asset.Path} - skipping it" );
				continue;
			}

			var name = string.IsNullOrWhiteSpace( source.Name )
				? ClipSource.NameOf( source.Asset )
				: source.Name;

			// Two clips sharing a name would overwrite each other's .dmx and give the model two
			// animations it cannot tell apart.
			if ( !used.Add( name ) )
			{
				Log.Warning( $"[Effigy] two clips are both called '{name}' - skipping the second" );
				continue;
			}

			AnimClip clip;
			int matched;
			List<string> unmatched;

			try
			{
				clip = ToClip( doc, skeleton, name, source.Looping, out matched, out unmatched );
			}
			catch ( Exception e )
			{
				Log.Warning( $"[Effigy] clip '{name}' could not be sampled: {e.Message}" );
				continue;
			}

			// A clip that poses none of this model's bones compiles perfectly and animates nothing,
			// which is the single most confusing outcome available here. It is almost always a clip
			// authored against a different rig.
			if ( matched == 0 )
			{
				Log.Warning( $"[Effigy] clip '{name}' names none of this rig's bones - skipping it. "
					+ "It was probably authored against a different model; ModelDoc matches clips to "
					+ "bones by name." );
				continue;
			}

			if ( unmatched.Count > 0 )
			{
				Log.Warning( $"[Effigy] clip '{name}' poses {unmatched.Count} bone(s) this rig does "
					+ $"not have, which will not animate: {string.Join( ", ", unmatched.Take( 8 ) )}"
					+ (unmatched.Count > 8 ? " ..." : "") );
			}

			var fileName = $"anim_{Sanitise( name )}.dmx";

			try
			{
				DmxAnimWriter.WriteFile( Path.Combine( folder, fileName ), skeleton, clip,
					"effigy_export" );
			}
			catch ( Exception e )
			{
				Log.Warning( $"[Effigy] clip '{name}' could not be written: {e.Message}" );
				continue;
			}

			entries.Add( new VmdlAnimation.ClipEntry( name, $"{assetPrefix}/{fileName}",
				source.Looping ) );

			Log.Info( $"[Effigy] wrote {fileName} - {clip.FrameCount} frame(s) at "
				+ $"{clip.FrameRate}fps, {matched}/{skeleton.Count} bone(s) posed" );
		}

		return entries;
	}

	/// <summary>
	/// A clip name as a file name. The name itself is the user's and goes into the model unchanged;
	/// this only governs what lands on disk, because a clip called "reach / grab" is a valid
	/// animation name and not a valid path.
	/// </summary>
	static string Sanitise( string name )
	{
		var chars = name.Select( c => char.IsLetterOrDigit( c ) || c == '_' || c == '-' ? c : '_' )
			.ToArray();

		var cleaned = new string( chars ).Trim( '_' );

		return string.IsNullOrEmpty( cleaned ) ? "clip" : cleaned.ToLowerInvariant();
	}
}