Editor/MovieMaker/Session/Mapping.cs
using System.Linq;
using Sandbox.MovieMaker;
using Sandbox.MovieMaker.Properties;

namespace Editor.MovieMaker;

#nullable enable

partial class Session
{
	public IProjectTrack? GetTrack( GameObject go )
	{
		return Project.Tracks
			.OfType<ProjectReferenceTrack<GameObject>>()
			.FirstOrDefault( x => Binder.Get( x ) is { IsBound: true } binder && binder.Value == go );
	}

	public IProjectTrack? GetTrack( Component cmp )
	{
		return Project.Tracks
			.OfType<IProjectReferenceTrack>()
			.FirstOrDefault( x => Binder.Get( x ) is { IsBound: true } binder && binder.Value == cmp );
	}

	public IProjectTrack? GetTrack( GameObject go, string propertyPath )
	{
		return GetTrack( GetTrack( go ), propertyPath );
	}

	public IProjectTrack? GetTrack( Component cmp, string propertyPath )
	{
		return GetTrack( GetTrack( cmp ), propertyPath );
	}

	private IProjectTrack? GetTrack( IProjectTrack? parentTrack, string propertyPath )
	{
		while ( parentTrack is not null && propertyPath.Length > 0 )
		{
			var propertyName = propertyPath;

			// TODO: Hack for anim graph parameters including periods

			if ( parentTrack.TargetType != typeof( SkinnedModelRenderer.ParameterAccessor ) && propertyPath.IndexOf( '.' ) is var index and > -1 )
			{
				propertyName = propertyPath[..index];
				propertyPath = propertyPath[(index + 1)..];
			}
			else
			{
				propertyPath = string.Empty;
			}

			parentTrack = parentTrack.Children.FirstOrDefault( x => x.Name == propertyName );
		}

		return parentTrack;
	}

	public ProjectSequenceTrack? GetTrack( MovieResource resource )
	{
		return Project.Tracks
			.OfType<ProjectSequenceTrack>()
			.FirstOrDefault( x => x.Blocks.Any( y => y.Resource == resource ) );
	}

	public IProjectTrack GetOrCreateTrack( GameObject go )
	{
		if ( GetTrack( go ) is { } existing ) return existing;

		IProjectTrack? parentTrack = null;

		if ( go.Parent is { } parentGo and not Scene )
		{
			// Procedural bone objects need a parent track

			if ( (go.Flags & GameObjectFlags.Bone) != 0 )
			{
				parentTrack = GetOrCreateTrack( parentGo );
			}

			// Otherwise, if parent has a track, use it

			else
			{
				parentTrack = GetTrack( parentGo );
			}
		}

		var track = Project.AddReferenceTrack( go.Name, typeof(GameObject), parentTrack );

		track.ReferenceId = go.Id;

		Binder.Get( track ).Bind( go );

		return track;
	}

	public IProjectTrack GetOrCreateTrack( Component cmp )
	{
		if ( GetTrack( cmp ) is { } existing ) return existing;

		// Nest component tracks inside the containing game object's track
		var goTrack = GetOrCreateTrack( cmp.GameObject );
		var track = Project.AddReferenceTrack( cmp.GetType().Name, cmp.GetType(), goTrack );

		track.ReferenceId = cmp.Id;

		Binder.Get( track ).Bind( cmp );

		return track;
	}

	public IProjectTrack GetOrCreateTrack( GameObject go, string propertyPath )
	{
		if ( GetTrack( go, propertyPath ) is { } existing ) return existing;

		// Nest property tracks inside the containing GameObject's track

		return GetOrCreateTrack( GetOrCreateTrack( go ), propertyPath );
	}

	public IProjectTrack GetOrCreateTrack( Component cmp, string propertyPath )
	{
		if ( GetTrack( cmp, propertyPath ) is { } existing ) return existing;

		// Nest property tracks inside the containing Component's track

		return GetOrCreateTrack( GetOrCreateTrack( cmp ), propertyPath );
	}

	public ProjectSequenceTrack GetOrCreateTrack( MovieResource resource )
	{
		if ( GetTrack( resource ) is { } existing ) return existing;

		return Project.AddSequenceTrack( $"{resource.ResourceName.ToTitleCase()} Sequence" );
	}

	public IProjectTrack GetOrCreateTrack( IProjectTrack parentTrack, string propertyPath )
	{
		while ( propertyPath.Length > 0 )
		{
			var propertyName = propertyPath;

			// TODO: Hack for anim graph parameters including periods

			if ( parentTrack.TargetType != typeof( SkinnedModelRenderer.ParameterAccessor ) && propertyPath.IndexOf( '.' ) is var index and > -1 )
			{
				propertyName = propertyPath[..index];
				propertyPath = propertyPath[(index + 1)..];
			}
			else
			{
				propertyPath = string.Empty;
			}

			parentTrack = GetOrCreateTrackCore( parentTrack, propertyName );
		}

		return parentTrack;
	}

	private IProjectTrack GetOrCreateTrackCore( IProjectTrack parentTrack, string propertyName )
	{
		if ( parentTrack.Children.FirstOrDefault( x => x.Name == propertyName ) is { } existingTrack )
		{
			return existingTrack;
		}

		if ( Binder.Get( parentTrack ) is not { } parentProperty )
		{
			throw new Exception( "Parent track not registered." );
		}

		var property = TrackProperty.Create( parentProperty, propertyName )
			?? throw new Exception( $"Unknown property \"{propertyName}\" in type \"{parentProperty.TargetType}\"." );

		return Project.AddPropertyTrack( property.Name, property.TargetType, parentTrack );
	}

	/// <summary>
	/// Advance all bound <see cref="SkinnedModelRenderer"/>s by the given <paramref name="deltaTime"/>.
	/// </summary>
	public void AdvanceAnimations( MovieTime deltaTime )
	{
		// Negative deltas aren't supported :(

		var dt = Math.Min( (float)deltaTime.Absolute.TotalSeconds, 1f );

		using var sceneScope = Player.Scene.Push();

		foreach ( var controller in Binder.GetComponents<PlayerController>( Project ) )
		{
			((IScenePhysicsEvents)controller).PrePhysicsStep();
			((IScenePhysicsEvents)controller).PostPhysicsStep();

			if ( controller.Renderer is { } renderer )
			{
				controller.UpdateAnimation( renderer );

				UpdateAnimationPlaybackRate( renderer, dt );
			}
		}

		foreach ( var renderer in Binder.GetComponents<SkinnedModelRenderer>( Project ) )
		{
			UpdateAnimationPlaybackRate( renderer, dt );
		}
	}

	private void UpdateAnimationPlaybackRate( SkinnedModelRenderer renderer, float dt )
	{
		if ( renderer.SceneModel is not { } model ) return;

		if ( dt > 0f )
		{
			model.PlaybackRate = renderer.PlaybackRate;
			model.Update( dt );
		}

		model.PlaybackRate = IsEditorScene ? 0f : 1f;
	}
}