Editor/WeaponImporter/Tool/UI/Preview/WeaponViewport.FirstPerson.cs
using Editor;
using Sandbox;
using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Maths;
using CoreClip = WeaponImporter.Core.Rig.Clip;
using CorePose = WeaponImporter.Core.Rig.Pose;

namespace WeaponImporter.Tool.UI;

/// <summary>
/// View-only first-person preview: the imported file's own animation (weapon and, when the file
/// has them, its first-person arms) exactly as authored, before anything is compiled. The mesh is
/// rigidly skinned to each triangle's dominant bone: one render piece per bone, moved every frame
/// by boneWorld(frame) · restWorld⁻¹, so nothing is uploaded per frame.
/// </summary>
public sealed partial class WeaponViewport
{
	/// <summary>Keeps huge files responsive (the Uzi pack is 42k triangles / 1072 bones).</summary>
	private const int MaxFirstPersonTriangles = 120_000;

	private GameObject _fpRoot;
	private readonly List<(int Bone, GameObject Piece)> _fpPieces = new();
	private WeaponAnalysis _fpAnalysis;
	private XForm[] _fpInverseRest;
	private int _fpCameraBone = -1;
	private Material[] _fpMaterials;
	private int _fpFrame = -1;
	private CoreClip _fpClip;

	// The bone most of the weapon's triangles follow, and the pose shown this frame (canonical
	// space, which is also the first-person scene's world space).
	private int _fpWeaponBone = -1;
	private XForm[] _fpWorld;

	/// <summary>The weapon (canonical space) in the first-person scene as its animation moves it.</summary>
	public Transform FirstPersonWeaponWorld => _fpWorld is { } world && _fpWeaponBone >= 0 && _fpWeaponBone < world.Length
		? XForm.Compose( world[_fpWeaponBone], _fpInverseRest[_fpWeaponBone] ).ToEngine()
		: Transform.Zero;

	/// <summary>
	/// How a weapon part's bone has moved the canonical weapon this frame: the bone itself when it
	/// is the weapon's bone or one of its children (a slide, a bolt), else the weapon's bone (a
	/// point parented to the rig root must still follow the weapon). Null outside first person.
	/// </summary>
	public Transform? FirstPersonBoneFrame( int bone )
	{
		if ( _fpWorld is not { } world || _fpWeaponBone < 0 )
			return null;
		var skeleton = _fpAnalysis.Asset.Skeleton;
		var follow = _fpWeaponBone;
		for ( var b = bone; b >= 0 && b < skeleton.Count; b = skeleton[b].ParentIndex )
			if ( b == _fpWeaponBone )
			{
				follow = bone;
				break;
			}
		return XForm.Compose( world[follow], _fpInverseRest[follow] ).ToEngine();
	}

	// The file's own arms as anatomical segments (upper arm, forearm, finger joints).
	private readonly List<(int Parent, int Bone)> _fpArmSegments = new();

	/// <summary>Arm bones worth drawing in first person: forearms, hands and fingers (no body, no camera).</summary>
	private bool IsOwnArmDisplayBone( int bone )
	{
		var skeleton = _fpAnalysis?.Asset.Skeleton;
		return skeleton is not null && bone >= 0 && bone < skeleton.Count && !IsBodyBone( skeleton, bone )
			&& !skeleton[bone].Name.Contains( "camera", StringComparison.OrdinalIgnoreCase );
	}

	/// <summary>A bone of the imported file in the first-person scene this frame.</summary>
	public Vector3? FirstPersonBone( int bone ) => _fpWorld is { } world && bone >= 0 && bone < world.Length ? world[bone].Pos.ToEngine() : null;

	public bool FirstPerson => CameraMode == ViewportCamera.FirstPerson;

	/// <summary>
	/// First person with the file's own arms (its first-person rig plays as authored). Weapons without
	/// arms show first person through the character's eyes instead: its arms, the fitted grip and the
	/// same correction as third person.
	/// </summary>
	public bool OwnArmsView => FirstPerson && (_c.Analysis?.ArmBones.Count ?? 0) > 0;

	/// <summary>The weapon's own clip for a role (or the role it falls back to), or null.</summary>
	public CoreClip WeaponClip( AnimationRole role )
	{
		var setup = _c.Setup;
		var asset = _c.Analysis?.Asset;
		if ( setup is null || asset is null )
			return null;
		for ( AnimationRole? r = role; r is { } current; r = AnimationRoles.Fallback( current ) )
			if ( setup.WeaponAnimations.TryGetValue( current, out var b ) && !string.IsNullOrEmpty( b.Clip ) && asset.FindClip( b.Clip ) is { FrameCount: > 0 } clip )
				return clip;
		return null;
	}

	private void EnsureFirstPersonPieces()
	{
		var analysis = _c.Analysis;
		if ( analysis is null || ReferenceEquals( analysis, _fpAnalysis ) && ReferenceEquals( _fpMaterials, _materials ) && _fpRoot.IsValid() )
			return;
		_fpAnalysis = analysis;
		_fpMaterials = _materials;
		_fpRoot?.Destroy();
		_fpPieces.Clear();
		_fpRoot = new GameObject( true, "first person" );

		var asset = analysis.Asset;
		var mesh = asset.Mesh;
		var skeleton = asset.Skeleton;
		var byBone = WeaponImporter.Core.Rig.ViewmodelParts.TrianglesByBone( analysis, Math.Max( 1, (mesh.TriangleCount + MaxFirstPersonTriangles - 1) / MaxFirstPersonTriangles ) );
		foreach ( var (bone, triangles) in byBone )
		{
			var piece = new GameObject( _fpRoot, true, skeleton.Count > 0 ? skeleton[bone].Name : "mesh" );
			var renderer = piece.AddComponent<ModelRenderer>();
			renderer.Model = PreviewModels.Build( mesh, triangles, _materials );
			_fpPieces.Add( (bone, piece) );
		}

		_fpInverseRest = new XForm[skeleton.Count];
		for ( var i = 0; i < skeleton.Count; i++ )
			_fpInverseRest[i] = skeleton.RestWorld[i].Inverse();
		_fpCameraBone = WeaponImporter.Core.Rig.ViewmodelParts.CameraBone( skeleton );
		_fpFrame = -1;
		_fpClip = null;
		_fpWorld = null;
		_fpArmSegments.Clear();
		foreach ( var side in new[] { WeaponImporter.Core.Hands.Side.Right, WeaponImporter.Core.Hands.Side.Left } )
		{
			if ( analysis.ArmBones.Count == 0 || WeaponImporter.Core.Hands.HandRig.Build( skeleton, side ) is not { } arm || !analysis.ArmBones.Contains( arm.Hand ) )
				continue;
			_fpArmSegments.Add( (arm.UpperArm, arm.LowerArm) );
			_fpArmSegments.Add( (arm.LowerArm, arm.Hand) );
			foreach ( var finger in arm.Fingers )
			{
				var previous = arm.Hand;
				foreach ( var joint in finger.Joints )
				{
					_fpArmSegments.Add( (previous, joint) );
					previous = joint;
				}
			}
		}
		_fpWeaponBone = analysis.WeaponTriangles.Length == 0 || skeleton.Count == 0 ? -1
			: analysis.WeaponTriangles.GroupBy( t => Math.Clamp( mesh.TriangleBone( t ), 0, skeleton.Count - 1 ) ).MaxBy( g => g.Count() )!.Key;
	}

	private static bool IsBodyBone( WeaponImporter.Core.Rig.Skeleton skeleton, int bone ) => WeaponImporter.Core.Rig.ViewmodelParts.IsBodyBone( skeleton, bone );

	/// <summary>One first-person frame: advance time, pose the pieces, place the camera.</summary>
	private void TickFirstPerson( float dt )
	{
		EnsureFirstPersonPieces();
		if ( _fpRoot.IsValid() )
			_fpRoot.Enabled = true;
		var role = _c.Role;
		var clip = WeaponClip( role );
		if ( _c.Playing )
		{
			if ( _c.RestartRequested )
			{
				_c.RestartRequested = false;
				_c.Time = _c.Time >= 0.999f ? 0f : _c.Time;
			}
			else
			{
				_c.Time += dt * _c.Speed / Seconds( role );
				if ( _c.Time >= 1f )
				{
					if ( _c.Loop || AnimationRoles.Loops( role ) )
						_c.Time %= 1f;
					else
					{
						_c.Time = 1f;
						_c.Playing = false;
					}
				}
			}
		}
		TickScene( dt );

		var skeleton = _fpAnalysis?.Asset.Skeleton;
		if ( skeleton is null || skeleton.Count == 0 )
			return;
		var frame = clip is null ? -1 : Math.Clamp( (int)MathF.Round( _c.Time * (clip.FrameCount - 1) ), 0, clip.FrameCount - 1 );
		if ( frame != _fpFrame || !ReferenceEquals( clip, _fpClip ) )
		{
			_fpFrame = frame;
			_fpClip = clip;
			var world = clip is null ? skeleton.RestWorld.ToArray() : new CorePose( clip.Frames[frame] ).ToWorld( skeleton );
			_fpWorld = world;
			foreach ( var (bone, piece) in _fpPieces )
				if ( piece.IsValid() )
					piece.WorldTransform = XForm.Compose( world[bone], _fpInverseRest[bone] ).ToEngine();
			_fpEye = _fpCameraBone >= 0 ? world[_fpCameraBone].ToEngine() : (Transform?)null;
		}
		UpdateFirstPersonCamera();
	}

	private Transform? _fpEye;

	private void UpdateFirstPersonCamera()
	{
		var analysis = _fpAnalysis;
		if ( analysis is null || !Camera.IsValid() )
			return;
		var b = analysis.WeaponBounds;
		var center = new Vector3( b.Center.X, b.Center.Y, b.Center.Z );
		var length = MathF.Max( 4f, b.Size.X );
		Vector3 eye, look;
		var up = Vector3.Up;
		if ( _fpEye is { } cameraBone )
		{
			// The rig's own camera, looking the way it was authored. Exporters disagree on the
			// camera's axes: forward is the bone axis pointing most at the weapon, up the one
			// closest to world up. Aim at the weapon when no axis fits.
			eye = cameraBone.Position;
			var toWeapon = (center - eye).Normal;
			var axes = new[] { Vector3.Forward, Vector3.Backward, Vector3.Left, Vector3.Right, Vector3.Up, Vector3.Down }
				.Select( a => cameraBone.Rotation * a ).ToArray();
			var forward = axes.OrderByDescending( a => Vector3.Dot( a, toWeapon ) ).First();
			if ( Vector3.Dot( forward, toWeapon ) > 0.6f )
			{
				up = axes.Where( a => MathF.Abs( Vector3.Dot( a, forward ) ) < 0.5f ).OrderByDescending( a => Vector3.Dot( a, Vector3.Up ) ).First();
				look = eye + forward * 10f;
			}
			else
			{
				look = center;
			}
		}
		else
		{
			// No camera in the file (weapon only): a steady three-quarter view from behind the
			// right shoulder, far enough that moving parts stay in frame.
			var radius = MathF.Max( length, MathF.Max( b.Size.Y, b.Size.Z ) ) * 0.75f;
			var distance = MathX.SphereCameraDistance( radius, 60f );
			var dir = new Vector3( -0.72f, -0.55f, 0.42f ).Normal;
			eye = center + dir * distance;
			look = center;
		}
		Camera.FieldOfView = 60f;
		Camera.ZNear = _fpEye is null ? 0.5f : 2f; // clip the arm stubs right at a rig camera
		Camera.WorldPosition = eye;
		Camera.WorldRotation = Rotation.LookAt( (look - eye).Normal, up );
	}

	private void HideFirstPerson()
	{
		if ( _fpRoot.IsValid() )
			_fpRoot.Enabled = false;
		if ( Camera.IsValid() )
			Camera.ZNear = 1f;
	}

	private void DrawFirstPersonHud()
	{
		var clip = WeaponClip( _c.Role );
		Gizmo.Draw.Color = Theme.TextLight;
		var text = clip is null
			? $"First person · {AnimationRoles.Label( _c.Role )} has no weapon clip (bind pose)"
			: $"First person · {clip.Name} · as imported, view only";
		Gizmo.Draw.ScreenText( text, new Vector2( 12, 10 ), "Roboto", 12, TextFlag.LeftTop );
	}
}