Editor/CitizenRetarget/DmxOutputWriter.cs
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;

#nullable enable

namespace Editor.CitizenRetarget;

using NQuaternion = System.Numerics.Quaternion;
using NVector3 = System.Numerics.Vector3;

[Obsolete( "Deprecated diagnostic-only DMX spike. Production retarget output uses the template-FBX path.", false )]
internal sealed class DmxOutputWriter : IRetargetOutputWriter
{
	private const string PythonLauncher = "py";
	private const string PythonVersionSwitch = "-3";
	private static readonly string BlenderSourceToolsDataModelDir = Path.Combine(
		CitizenRetargetPaths.GetTempPath( "BlenderSourceTools" ),
		"io_scene_valvesource" );
	private static readonly JsonSerializerOptions JsonOptions = new()
	{
		WriteIndented = true
	};
	private readonly BlenderDmxExportBridge _blenderBridge = new();
	public BlenderForensicArtifactResult? LastForensicArtifactResult { get; private set; }

	public RetargetOutputFormat Format => RetargetOutputFormat.DmxSpike;
	public string FileExtension => ".dmx";

	public BlenderTargetBindCache EnsureTargetBindCache()
	{
		return _blenderBridge.EnsureTargetBindCache();
	}

	public string WriteClip( RetargetedClip clip, string outputFolder )
	{
		var absoluteFolder = CitizenRetargetPaths.GetAssetAbsolutePath( outputFolder );
		Directory.CreateDirectory( absoluteFolder );
		Directory.CreateDirectory( Path.Combine( absoluteFolder, "anims" ) );

		CleanupStaleGeneratedAssets( absoluteFolder );
		var outputAbsolutePath = Path.Combine( absoluteFolder, "anims", $"{clip.SequenceName}.dmx" );
		var payloadPath = WritePayloadJson( clip, CitizenTargetProfile.BlenderGeneratedPayloadRelativeFolder );
		var templateAbsolutePath = Ual2SourceAdapter.ResolveAssetAbsolutePath( CitizenTargetProfile.ReferenceStockDmxPath );
		LastForensicArtifactResult = null;
		return _blenderBridge.ExportPayloadJsonToDmx( payloadPath, outputAbsolutePath, templateAbsolutePath );
	}

	public string WriteDirectTransferClip(
		string sourceFbxAbsolutePath,
		RetargetClipDescriptor clip,
		string outputFolder,
		string sequencePrefix,
		string mappingJsonAbsolutePath,
		string compensationJsonAbsolutePath,
		bool includeHands )
	{
		var sequenceName = BuildSequenceName( sequencePrefix, clip.DisplayName );
		var absoluteFolder = CitizenRetargetPaths.GetAssetAbsolutePath( outputFolder );
		Directory.CreateDirectory( absoluteFolder );
		Directory.CreateDirectory( Path.Combine( absoluteFolder, "anims" ) );

		CleanupStaleGeneratedAssets( absoluteFolder );
		var outputAbsolutePath = Path.Combine( absoluteFolder, "anims", $"{sequenceName}.dmx" );
		LastForensicArtifactResult = null;
		return _blenderBridge.ExportDirectTransferClipToDmx(
			sourceFbxAbsolutePath,
			clip.SourceName,
			sequenceName,
			mappingJsonAbsolutePath,
			compensationJsonAbsolutePath,
			includeHands,
			outputAbsolutePath );
	}

	private static void CleanupStaleGeneratedAssets( string absoluteFolder )
	{
		var prefix = CitizenTargetProfile.DefaultSequencePrefix;
		foreach ( var candidate in Directory.GetFiles( absoluteFolder, "*.dmx", SearchOption.TopDirectoryOnly ) )
		{
			var fileName = Path.GetFileNameWithoutExtension( candidate );
			if ( fileName.StartsWith( prefix, StringComparison.OrdinalIgnoreCase ) )
				File.Delete( candidate );
		}

		var animsFolder = Path.Combine( absoluteFolder, "anims" );
		if ( !Directory.Exists( animsFolder ) )
			return;

		foreach ( var candidate in Directory.GetFiles( animsFolder, "*.dmx", SearchOption.AllDirectories ) )
		{
			var fileName = Path.GetFileNameWithoutExtension( candidate );
			if ( fileName.StartsWith( prefix, StringComparison.OrdinalIgnoreCase ) )
				File.Delete( candidate );
		}

		foreach ( var nestedFolder in Directory.GetDirectories( animsFolder, "*", SearchOption.TopDirectoryOnly ) )
		{
			if ( !Directory.EnumerateFileSystemEntries( nestedFolder ).Any() )
				Directory.Delete( nestedFolder, recursive: true );
		}
	}

	public DmxReferenceSpikeResult EnsureReferenceAssets()
	{
		if ( !CitizenTargetProfile.EnableGeneratedDebugAssets )
		{
			return new DmxReferenceSpikeResult
			{
				MarkdownAbsolutePath = CitizenRetargetPaths.GetProjectAbsolutePath( CitizenTargetProfile.DmxAuditMarkdownRelativePath ),
				BindCacheJsonAbsolutePath = CitizenRetargetPaths.GetProjectAbsolutePath( CitizenTargetProfile.BlenderBindCacheRelativePath )
			};
		}

		var bindCache = EnsureTargetBindCache();
		var tempReferenceFolder = CitizenRetargetPaths.GetProjectAbsolutePath( CitizenTargetProfile.BlenderReferencePayloadRelativeFolder );
		Directory.CreateDirectory( tempReferenceFolder );

		var referenceJsonPath = Path.Combine( tempReferenceFolder, "citizen_reference_pose.json" );
		var referenceDmxPath = CitizenRetargetPaths.GetAssetAbsolutePath( $"{CitizenTargetProfile.ReferenceAnimationFolder}/ref_citizen_anim_raw.dmx" );
		var forceMotionJsonPath = Path.Combine( tempReferenceFolder, "citizen_reference_force_motion.json" );
		var forceMotionDmxPath = CitizenRetargetPaths.GetAssetAbsolutePath( $"{CitizenTargetProfile.ReferenceAnimationFolder}/ref_citizen_anim_force_motion.dmx" );
		var stockSummaryJsonPath = CitizenRetargetPaths.GetProjectAbsolutePath( ".tmp/citizen_retarget/dmx/stock_summary.json" );
		var referenceSummaryJsonPath = CitizenRetargetPaths.GetProjectAbsolutePath( ".tmp/citizen_retarget/dmx/reference_summary_generated.json" );
		var markdownPath = CitizenRetargetPaths.GetProjectAbsolutePath( CitizenTargetProfile.DmxAuditMarkdownRelativePath );
		var referenceVmdlPath = CitizenRetargetPaths.GetAssetAbsolutePath( CitizenTargetProfile.ReferenceDmxVmdlPath );
		Directory.CreateDirectory( Path.GetDirectoryName( referenceDmxPath )! );
		Directory.CreateDirectory( Path.GetDirectoryName( stockSummaryJsonPath )! );
		Directory.CreateDirectory( Path.GetDirectoryName( markdownPath )! );

		var referencePayload = CreateReferencePayload( bindCache, "ref_citizen_anim_raw", looping: false );
		File.WriteAllText( referenceJsonPath, JsonSerializer.Serialize( referencePayload, JsonOptions ) );
		var exportedReferenceDmxPath = _blenderBridge.ExportPayloadJsonToDmx( referenceJsonPath, referenceDmxPath );

		var stockDmxAbsolutePath = Ual2SourceAdapter.ResolveAssetAbsolutePath( CitizenTargetProfile.ReferenceStockDmxPath );
		WriteForceMotionReferenceJson( referenceJsonPath, forceMotionJsonPath );
		var exportedForceMotionDmxPath = _blenderBridge.ExportPayloadJsonToDmx( forceMotionJsonPath, forceMotionDmxPath, stockDmxAbsolutePath );
		RunPython( $"\"{GetInspectorScriptPath()}\" --input \"{stockDmxAbsolutePath}\" --output \"{stockSummaryJsonPath}\" --datamodel-dir \"{BlenderSourceToolsDataModelDir}\"" );
		RunPython( $"\"{GetInspectorScriptPath()}\" --input \"{exportedReferenceDmxPath}\" --output \"{referenceSummaryJsonPath}\" --datamodel-dir \"{BlenderSourceToolsDataModelDir}\"" );

		var referenceVmdl = new CitizenVmdlWriter().WriteSharedVmdl( CitizenTargetProfile.ReferenceDmxVmdlPath, new[]
		{
			new GeneratedAnimationSource
			{
				SequenceName = "ref_citizen_anim_raw",
				ResourcePath = CitizenRetargetPaths.GetAssetResourcePath( exportedReferenceDmxPath ),
				Looping = false
			},
			new GeneratedAnimationSource
			{
				SequenceName = "ref_citizen_anim_force_motion",
				ResourcePath = CitizenRetargetPaths.GetAssetResourcePath( exportedForceMotionDmxPath ),
				Looping = true
			}
		} );

		WriteMarkdownSummary( stockSummaryJsonPath, referenceSummaryJsonPath, markdownPath, exportedReferenceDmxPath, referenceVmdl, bindCache.JsonAbsolutePath );

		return new DmxReferenceSpikeResult
		{
			ReferenceDmxAbsolutePath = exportedReferenceDmxPath,
			ReferenceVmdlAbsolutePath = referenceVmdl,
			StockSummaryJsonAbsolutePath = stockSummaryJsonPath,
			ReferenceSummaryJsonAbsolutePath = referenceSummaryJsonPath,
			MarkdownAbsolutePath = markdownPath,
			BindCacheJsonAbsolutePath = bindCache.JsonAbsolutePath
		};
	}

	public (string VmdlAbsolutePath, string AnimationAbsolutePath) EnsureCompensatedReferenceGuide()
	{
		var bindCache = EnsureTargetBindCache();
		var payloadFolder = CitizenRetargetPaths.GetProjectAbsolutePath( CitizenTargetProfile.BlenderReferencePayloadRelativeFolder );
		var animationFolder = CitizenRetargetPaths.GetAssetAbsolutePath( CitizenTargetProfile.CompensatedReferenceAnimationFolder );
		Directory.CreateDirectory( payloadFolder );
		Directory.CreateDirectory( animationFolder );
		Directory.CreateDirectory( Path.Combine( animationFolder, "anims" ) );

		var sequenceName = CitizenTargetProfile.CompensatedReferenceSequenceName;
		var payload = CreateReferencePayload( bindCache, sequenceName, looping: false );
		ApplyLocalCompensationToReferencePayload( payload, new Ual2SourceProfile().LoadLocalCompensation() );

		var payloadPath = Path.Combine( payloadFolder, $"{sequenceName}.json" );
		File.WriteAllText( payloadPath, JsonSerializer.Serialize( payload, JsonOptions ) );

		var outputFbxPath = Path.Combine( animationFolder, "anims", $"{sequenceName}.fbx" );
		var exportedAnimationPath = _blenderBridge.ExportPayloadJsonToFbx( payloadPath, outputFbxPath );

		var vmdlPath = new CitizenVmdlWriter().WriteSharedVmdl( CitizenTargetProfile.CompensatedReferenceVmdlPath, new[]
		{
			new GeneratedAnimationSource
			{
				SequenceName = sequenceName,
				ResourcePath = CitizenRetargetPaths.GetAssetResourcePath( exportedAnimationPath ),
				Looping = false
			}
		} );

		return (vmdlPath, exportedAnimationPath);
	}

	public BindPassthroughDebugResult WriteBindPassthroughDebugAssets()
	{
		var bindCache = EnsureTargetBindCache();
		var debugFolder = CitizenRetargetPaths.GetAssetAbsolutePath( CitizenTargetProfile.BindPassthroughAnimationFolder );
		var payloadFolder = CitizenRetargetPaths.GetProjectAbsolutePath( CitizenTargetProfile.BlenderBindPassthroughPayloadRelativeFolder );
		Directory.CreateDirectory( debugFolder );
		Directory.CreateDirectory( payloadFolder );
		Directory.CreateDirectory( Path.Combine( debugFolder, "anims" ) );

		var payload = CreateReferencePayload( bindCache, "dbg_citizen_bind_passthrough", looping: false );
		var payloadPath = Path.Combine( payloadFolder, "dbg_citizen_bind_passthrough.json" );
		File.WriteAllText( payloadPath, JsonSerializer.Serialize( payload, JsonOptions ) );

		var outputDmxPath = Path.Combine( debugFolder, "anims", "dbg_citizen_bind_passthrough.dmx" );
		var templateAbsolutePath = Ual2SourceAdapter.ResolveAssetAbsolutePath( CitizenTargetProfile.ReferenceStockDmxPath );
		var exportedDmxPath = _blenderBridge.ExportPayloadJsonToDmx( payloadPath, outputDmxPath, templateAbsolutePath );

		var vmdlPath = new CitizenVmdlWriter().WriteSharedVmdl( CitizenTargetProfile.BindPassthroughVmdlPath, new[]
		{
			new GeneratedAnimationSource
			{
				SequenceName = "dbg_citizen_bind_passthrough",
				ResourcePath = CitizenRetargetPaths.GetAssetResourcePath( exportedDmxPath ),
				Looping = false
			}
		} );

		return new BindPassthroughDebugResult
		{
			PayloadJsonAbsolutePath = payloadPath,
			DmxAbsolutePath = exportedDmxPath,
			VmdlAbsolutePath = vmdlPath
		};
	}

	public StockRoundTripDebugResult WriteStockRoundTripDebugAssets()
	{
		var debugFolder = CitizenRetargetPaths.GetAssetAbsolutePath( CitizenTargetProfile.StockRoundTripAnimationFolder );
		Directory.CreateDirectory( debugFolder );
		Directory.CreateDirectory( Path.Combine( debugFolder, "anims" ) );

		var sourceDmxPath = Ual2SourceAdapter.ResolveAssetAbsolutePath( CitizenTargetProfile.ReferenceStockDmxPath );
		var outputDmxPath = Path.Combine( debugFolder, "anims", "dbg_citizen_stock_roundtrip.dmx" );
		var exportedDmxPath = _blenderBridge.RoundTripDmx( sourceDmxPath, outputDmxPath );

		var vmdlPath = new CitizenVmdlWriter().WriteSharedVmdl( CitizenTargetProfile.StockRoundTripVmdlPath, new[]
		{
			new GeneratedAnimationSource
			{
				SequenceName = "dbg_citizen_stock_roundtrip",
				ResourcePath = CitizenRetargetPaths.GetAssetResourcePath( exportedDmxPath ),
				Looping = false
			}
		} );

		return new StockRoundTripDebugResult
		{
			SourceDmxAbsolutePath = sourceDmxPath,
			RoundTripDmxAbsolutePath = exportedDmxPath,
			VmdlAbsolutePath = vmdlPath
		};
	}

	private static string WritePayloadJson( RetargetedClip clip, string tempRelativeFolder )
	{
		var tempFolder = CitizenRetargetPaths.GetProjectAbsolutePath( tempRelativeFolder );
		Directory.CreateDirectory( tempFolder );
		var payloadPath = Path.Combine( tempFolder, $"{clip.SequenceName}.json" );

		var payload = new
		{
			sequence_name = clip.SequenceName,
			display_name = clip.DisplayName,
			model_name = CitizenTargetProfile.CitizenAnimationDmxModelName,
			skeleton_name = "citizen_noscale1",
			frame_rate = clip.FrameRate,
			looping = clip.Looping,
			output_unit_meters = 0.01,
			bones = clip.Bones.Select( bone => new
			{
				id = bone.Id,
				name = bone.Name,
				parent_id = bone.ParentId,
				rest_translation = ToArray( bone.RestLocal.Translation ),
				rest_rotation = ToArray( bone.RestLocal.Rotation )
			} ),
			frames = clip.Frames.Select( frame => new
			{
				index = frame.Index,
				bone_transforms = frame.BoneTransforms.Select( transform => new
				{
					translation = ToArray( transform.Translation ),
					rotation = ToArray( transform.Rotation )
				} )
			} )
		};

		File.WriteAllText( payloadPath, JsonSerializer.Serialize( payload, JsonOptions ) );
		return payloadPath;
	}

	private static DmxClipPayload CreateReferencePayload( BlenderTargetBindCache bindCache, string sequenceName, bool looping )
	{
		var bones = bindCache.Bones
			.Select( ( bone, index ) => new DmxBonePayload
			{
				Id = index,
				Name = bone.Name,
				ParentId = string.IsNullOrWhiteSpace( bone.ParentName )
					? -1
					: bindCache.Bones.FindIndex( candidate => candidate.Name.Equals( bone.ParentName, StringComparison.OrdinalIgnoreCase ) ),
				RestTranslation = bone.Translation.ToArray(),
				RestRotation = bone.Rotation.ToArray()
			} )
			.ToList();

		return new DmxClipPayload
		{
			SequenceName = sequenceName,
			DisplayName = sequenceName,
			ModelName = CitizenTargetProfile.CitizenAnimationDmxModelName,
			SkeletonName = "citizen_noscale1",
			FrameRate = 30.0,
			Looping = looping,
			OutputUnitMeters = bindCache.OutputUnitMeters,
			Bones = bones,
			Frames = new List<DmxFramePayload>
			{
				new DmxFramePayload
				{
					Index = 0,
					BoneTransforms = bones.Select( bone => new DmxBoneTransformPayload
					{
						Translation = bone.RestTranslation.ToArray(),
						Rotation = bone.RestRotation.ToArray()
					} ).ToList()
				}
			}
		};
	}

	public static string BuildSequenceName( string sequencePrefix, string clipDisplayName )
	{
		return RetargetSequenceNames.Build( sequencePrefix, clipDisplayName );
	}

	private static float[] ToArray( System.Numerics.Vector3 vector )
	{
		return new[] { vector.X, vector.Y, vector.Z };
	}

	private static string Slugify( string value )
	{
		var builder = new StringBuilder( value.Length );
		foreach ( var character in value )
		{
			if ( char.IsLetterOrDigit( character ) )
			{
				builder.Append( char.ToLowerInvariant( character ) );
			}
			else if ( builder.Length > 0 && builder[^1] != '_' )
			{
				builder.Append( '_' );
			}
		}

		return builder.ToString().Trim( '_' );
	}

	private static float[] ToArray( System.Numerics.Quaternion quaternion )
	{
		return new[] { quaternion.X, quaternion.Y, quaternion.Z, quaternion.W };
	}

	private static void WriteForceMotionReferenceJson( string inputJsonPath, string outputJsonPath )
	{
		var payload = JsonSerializer.Deserialize<DmxClipPayload>( File.ReadAllText( inputJsonPath ) ) ?? throw new InvalidOperationException( "Could not deserialize reference DMX payload." );
		if ( payload.Frames.Count == 0 )
			throw new InvalidOperationException( "Reference DMX payload does not contain any frames." );

		var referenceFrame = payload.Frames[0];
		var secondFrame = referenceFrame.Clone();
		secondFrame.Index = 15;
		var thirdFrame = referenceFrame.Clone();
		thirdFrame.Index = 60;

		ApplyForceMotion( payload, secondFrame );
		ApplyForceMotion( payload, thirdFrame );

		payload.SequenceName = "ref_citizen_anim_force_motion";
		payload.DisplayName = payload.SequenceName;
		payload.Looping = true;
		payload.Frames = new List<DmxFramePayload>
		{
			referenceFrame.CloneWithIndex( 0 ),
			secondFrame,
			thirdFrame
		};

		File.WriteAllText( outputJsonPath, JsonSerializer.Serialize( payload, JsonOptions ) );
	}

	private static void ApplyForceMotion( DmxClipPayload payload, DmxFramePayload frame )
	{
		var boneMap = payload.Bones.Select( ( bone, index ) => new { bone.Name, Index = index } )
			.ToDictionary( item => item.Name, item => item.Index, StringComparer.OrdinalIgnoreCase );

		if ( boneMap.TryGetValue( "pelvis", out var pelvisIndex ) )
		{
			var translation = frame.BoneTransforms[pelvisIndex].Translation;
			translation[0] += 20f;
			translation[2] += 8f;
		}

		if ( boneMap.TryGetValue( "arm_upper_L", out var armUpperLIndex ) )
		{
			frame.BoneTransforms[armUpperLIndex].Rotation = MultiplyRotation( frame.BoneTransforms[armUpperLIndex].Rotation, NQuaternion.CreateFromAxisAngle( new NVector3( 0f, 0f, 1f ), MathF.PI * 0.5f ) );
		}

		if ( boneMap.TryGetValue( "arm_lower_L", out var armLowerLIndex ) )
		{
			frame.BoneTransforms[armLowerLIndex].Rotation = MultiplyRotation( frame.BoneTransforms[armLowerLIndex].Rotation, NQuaternion.CreateFromAxisAngle( new NVector3( 0f, 1f, 0f ), -MathF.PI * 0.45f ) );
		}

		if ( boneMap.TryGetValue( "hand_L", out var handLIndex ) )
		{
			frame.BoneTransforms[handLIndex].Rotation = MultiplyRotation( frame.BoneTransforms[handLIndex].Rotation, NQuaternion.CreateFromAxisAngle( new NVector3( 1f, 0f, 0f ), MathF.PI * 0.35f ) );
		}
	}

	private static void ApplyLocalCompensationToReferencePayload( DmxClipPayload payload, IReadOnlyDictionary<string, NQuaternion> localCompensation )
	{
		if ( payload.Frames.Count == 0 || localCompensation.Count == 0 )
			return;

		var boneMap = payload.Bones.Select( ( bone, index ) => new { bone.Name, Index = index } )
			.ToDictionary( item => item.Name, item => item.Index, StringComparer.OrdinalIgnoreCase );
		var frame = payload.Frames[0];

		foreach ( var (boneName, compensation) in localCompensation )
		{
			if ( !boneMap.TryGetValue( boneName, out var boneIndex ) || boneIndex < 0 || boneIndex >= frame.BoneTransforms.Count )
				continue;

			frame.BoneTransforms[boneIndex].Rotation = PostMultiplyRotation( frame.BoneTransforms[boneIndex].Rotation, compensation );
		}
	}

	private static float[] PostMultiplyRotation( float[] currentRotation, NQuaternion deltaRotation )
	{
		var current = new NQuaternion( currentRotation[0], currentRotation[1], currentRotation[2], currentRotation[3] );
		var next = NQuaternion.Normalize( current * deltaRotation );
		return ToArray( next );
	}

	private static float[] MultiplyRotation( float[] currentRotation, NQuaternion deltaRotation )
	{
		var current = new NQuaternion( currentRotation[0], currentRotation[1], currentRotation[2], currentRotation[3] );
		var next = NQuaternion.Normalize( deltaRotation * current );
		return ToArray( next );
	}

	private static void RunPython( string arguments )
	{
		RunProcess( PythonLauncher, $"{PythonVersionSwitch} {arguments}", "Python DMX helper" );
	}

	private static void RunProcess( string fileName, string arguments, string stepName )
	{
		using var process = new Process();
		process.StartInfo = new ProcessStartInfo
		{
			FileName = fileName,
			Arguments = arguments,
			WorkingDirectory = CitizenRetargetPaths.ProjectRoot,
			CreateNoWindow = true,
			UseShellExecute = false,
			RedirectStandardOutput = true,
			RedirectStandardError = true
		};

		process.Start();
		var standardOutput = process.StandardOutput.ReadToEnd();
		var standardError = process.StandardError.ReadToEnd();
		process.WaitForExit();

		if ( process.ExitCode == 0 )
			return;

		var builder = new StringBuilder();
		builder.AppendLine( $"{stepName} failed with exit code {process.ExitCode}." );
		if ( !string.IsNullOrWhiteSpace( standardOutput ) )
		{
			builder.AppendLine( "stdout:" );
			builder.AppendLine( standardOutput.Trim() );
		}

		if ( !string.IsNullOrWhiteSpace( standardError ) )
		{
			builder.AppendLine( "stderr:" );
			builder.AppendLine( standardError.Trim() );
		}

		throw new InvalidOperationException( builder.ToString().TrimEnd() );
	}

	private static string GetInspectorScriptPath()
	{
		return CitizenRetargetPaths.GetProjectAbsolutePath( "tools/inspect_dmx_structure.py" );
	}

	private static void WriteMarkdownSummary(
		string stockSummaryJsonPath,
		string referenceSummaryJsonPath,
		string markdownPath,
		string referenceDmxPath,
		string referenceVmdlPath,
		string bindCacheJsonPath )
	{
		var stockSummary = JsonSerializer.Deserialize<DmxSummary>( File.ReadAllText( stockSummaryJsonPath ) ) ?? new DmxSummary();
		var referenceSummary = JsonSerializer.Deserialize<DmxSummary>( File.ReadAllText( referenceSummaryJsonPath ) ) ?? new DmxSummary();
		var builder = new StringBuilder();
		builder.AppendLine( "# Citizen DMX Output Spike" );
		builder.AppendLine();
		builder.AppendLine( "## Summary" );
		builder.AppendLine();
		builder.AppendLine( "- This spike freezes `SMD` as debug-only and validates a Blender Source Tools backed `DMX` path against a shipped Citizen animation." );
		builder.AppendLine( $"- Reference output: `{referenceDmxPath}`" );
		builder.AppendLine( $"- Reference preview model: `{referenceVmdlPath}`" );
		builder.AppendLine( $"- Canonical target bind cache: `{bindCacheJsonPath}`" );
		builder.AppendLine();
		builder.AppendLine( "## Stock Citizen DMX" );
		builder.AppendLine();
		builder.AppendLine( $"- Header: `{stockSummary.Header}`" );
		builder.AppendLine( $"- Root keys: `{string.Join( ", ", stockSummary.RootKeys ?? new List<string>() )}`" );
		builder.AppendLine( $"- Skeleton child count: `{stockSummary.Skeleton?.ChildCount ?? 0}`" );
		builder.AppendLine( $"- Clip channel count: `{stockSummary.Clip?.ChannelCount ?? 0}`" );
		builder.AppendLine();
		builder.AppendLine( "## Generated Reference DMX" );
		builder.AppendLine();
		builder.AppendLine( $"- Header: `{referenceSummary.Header}`" );
		builder.AppendLine( $"- Root keys: `{string.Join( ", ", referenceSummary.RootKeys ?? new List<string>() )}`" );
		builder.AppendLine( $"- Skeleton child count: `{referenceSummary.Skeleton?.ChildCount ?? 0}`" );
		builder.AppendLine( $"- Clip channel count: `{referenceSummary.Clip?.ChannelCount ?? 0}`" );
		builder.AppendLine();
		builder.AppendLine( "## Takeaways" );
		builder.AppendLine();
		builder.AppendLine( "- Stock Citizen animation DMX uses `binary 9 / model 22` with `DmElement -> skeleton + animationList + exportTags`." );
		builder.AppendLine( "- The generated reference DMX now comes from a Blender Source Tools action export instead of the earlier hand-authored channel experiment." );
		builder.AppendLine( "- The canonical target bind for solve and export is the Blender-imported `citizen.fbx` cache, not a separate custom DMX skeleton approximation." );
		builder.AppendLine( "- Manual ModelDoc validation is still required to decide whether the Blender-backed `DMX` path is a sane round-trip path for Citizen." );

		File.WriteAllText( markdownPath, builder.ToString() );
	}

	private sealed class DmxSummary
	{
		public string Header { get; set; } = string.Empty;
		public List<string> RootKeys { get; set; } = new();
		public DmxSkeletonSummary Skeleton { get; set; } = new();
		public DmxClipSummary Clip { get; set; } = new();
	}

	private sealed class DmxClipPayload
	{
		[JsonPropertyName( "sequence_name" )]
		public string SequenceName { get; set; } = string.Empty;
		[JsonPropertyName( "display_name" )]
		public string DisplayName { get; set; } = string.Empty;
		[JsonPropertyName( "model_name" )]
		public string ModelName { get; set; } = string.Empty;
		[JsonPropertyName( "skeleton_name" )]
		public string SkeletonName { get; set; } = string.Empty;
		[JsonPropertyName( "frame_rate" )]
		public double FrameRate { get; set; }
		[JsonPropertyName( "looping" )]
		public bool Looping { get; set; }
		[JsonPropertyName( "output_unit_meters" )]
		public double OutputUnitMeters { get; set; } = 0.01;
		[JsonPropertyName( "bones" )]
		public List<DmxBonePayload> Bones { get; set; } = new();
		[JsonPropertyName( "frames" )]
		public List<DmxFramePayload> Frames { get; set; } = new();
	}

	private sealed class DmxBonePayload
	{
		[JsonPropertyName( "id" )]
		public int Id { get; set; }
		[JsonPropertyName( "name" )]
		public string Name { get; set; } = string.Empty;
		[JsonPropertyName( "parent_id" )]
		public int ParentId { get; set; }
		[JsonPropertyName( "rest_translation" )]
		public float[] RestTranslation { get; set; } = [0f, 0f, 0f];
		[JsonPropertyName( "rest_rotation" )]
		public float[] RestRotation { get; set; } = [0f, 0f, 0f, 1f];
	}

	private sealed class DmxFramePayload
	{
		[JsonPropertyName( "index" )]
		public int Index { get; set; }
		[JsonPropertyName( "bone_transforms" )]
		public List<DmxBoneTransformPayload> BoneTransforms { get; set; } = new();

		public DmxFramePayload Clone()
		{
			return new DmxFramePayload
			{
				Index = Index,
				BoneTransforms = BoneTransforms.Select( transform => transform.Clone() ).ToList()
			};
		}

		public DmxFramePayload CloneWithIndex( int index )
		{
			var clone = Clone();
			clone.Index = index;
			return clone;
		}
	}

	private sealed class DmxBoneTransformPayload
	{
		[JsonPropertyName( "translation" )]
		public float[] Translation { get; set; } = [0f, 0f, 0f];
		[JsonPropertyName( "rotation" )]
		public float[] Rotation { get; set; } = [0f, 0f, 0f, 1f];

		public DmxBoneTransformPayload Clone()
		{
			return new DmxBoneTransformPayload
			{
				Translation = Translation.ToArray(),
				Rotation = Rotation.ToArray()
			};
		}
	}

	private sealed class DmxSkeletonSummary
	{
		public int ChildCount { get; set; }
	}

	private sealed class DmxClipSummary
	{
		public int ChannelCount { get; set; }
	}
}