Editor/MovieMaker/Session/TrackView.cs
using System.Diagnostics.CodeAnalysis;
using Sandbox.MovieMaker;
using System.Linq;

namespace Editor.MovieMaker;

#nullable enable

/// <summary>
/// Describes how a track should be displayed in the track list / dope sheet.
/// </summary>
public sealed partial class TrackView : IComparable<TrackView>
{
	private TrackListView TrackList { get; }

	public float Position { get; private set; } = -1f;
	public float Height { get; private set; } = -1f;

	public TrackView? Parent { get; }
	public IProjectTrack Track { get; }
	public ITrackTarget Target { get; }

	private IPropertyTrack<Transform>? _transformTrack;

	public IPropertyTrack<Transform> TransformTrack => _transformTrack ??= CreateTransformTrack();

	private bool _isExpanded;
	private bool _isLockedSelf;

	private bool _wasExpanded;

	public bool IsSelected { get; internal set; }

	public bool IsExpanded
	{
		get => _isExpanded;
		set
		{
			if ( _isExpanded == value ) return;

			_isExpanded = value;

			SetCookie( nameof( IsExpanded ), value );
			TrackList.Update();
		}
	}

	public bool IsLockedSelf
	{
		get => _isLockedSelf;
		set
		{
			if ( _isLockedSelf == value ) return;

			_isLockedSelf = value;

			SetCookie( nameof( IsLockedSelf ), value );
			DispatchChanged( true );
		}
	}

	public bool IsLocked => IsLockedSelf || Parent?.IsLocked is true;

	public string Title => Track.Name;
	public string Description
	{
		get
		{
			var path = Track.GetPath();
			string[] propertyNames = [path.ReferenceTrack.Name, .. path.PropertyNames];
			return string.Join( $" \u2192 ", propertyNames );
		}
	}

	private readonly SynchronizedSet<IProjectTrack, TrackView> _children;

	private bool _dispatchValueChanged = false;

	public IReadOnlyList<TrackView> Children => _children;

	public int StateHash { get; private set; }
	public bool IsEmpty => _children.Count == 0 && Track.IsEmpty;

	/// <summary>
	/// Invoked when properties of this track are changed.
	/// </summary>
	public event Action<TrackView>? Changed;

	/// <summary>
	/// Invoked when the contents of the track are modified.
	/// </summary>
	public event Action<TrackView>? ValueChanged;

	/// <summary>
	/// Invoked when this track is removed.
	/// </summary>
	public event Action<TrackView>? Removed;

	public TrackView( TrackListView trackList, TrackView? parent, IProjectTrack track, ITrackTarget target )
	{
		TrackList = trackList;
		Parent = parent;
		Track = track;
		Target = target;

		_isExpanded = GetCookie( nameof( IsExpanded ), true );
		_isLockedSelf = GetCookie( nameof( IsLockedSelf ), false );

		_children = new SynchronizedSet<IProjectTrack, TrackView>(
			AddChildTrack, RemoveChildTrack, UpdateChildTrack );
	}

	private void DispatchChanged( bool recurse )
	{
		Changed?.Invoke( this );

		if ( !recurse ) return;

		foreach ( var child in _children )
		{
			child.DispatchChanged( true );
		}
	}

	private static PropertySignal<object?> DefaultSignal { get; } = (object?)null;

	private TrackView AddChildTrack( IProjectTrack source )
	{
		return new( TrackList, this, source, TrackList.Session.Binder.Get( source ) );
	}

	private void RemoveChildTrack( TrackView item ) => item.OnRemoved();
	private bool UpdateChildTrack( IProjectTrack source, TrackView item ) => item.Update();

	public void Select()
	{
		TrackList.DeselectAll();
		IsSelected = true;
	}

	public bool Update()
	{
		_transformTrack = null;
		return _children.Update( Track.Children ) || _wasExpanded != IsExpanded;
	}

	public bool UpdatePosition( ref float position )
	{
		var changed = !Position.Equals( position ) || _wasExpanded != IsExpanded;
		var hashCode = new HashCode();

		hashCode.Add( Track );
		hashCode.Add( IsExpanded );

		Position = position;
		_wasExpanded = IsExpanded;

		position += Timeline.TrackHeight;

		var childPosition = position;

		foreach ( var child in _children )
		{
			changed |= child.UpdatePosition( ref childPosition );
			hashCode.Add( child.StateHash );
		}

		if ( IsExpanded )
		{
			position = childPosition;
		}

		Height = position - Position;
		StateHash = hashCode.ToHashCode();

		if ( changed ) Changed?.Invoke( this );

		return changed;
	}

	private bool _removed;

	internal void OnRemoved()
	{
		if ( _removed ) return;
		_removed = true;

		_children.Clear();

		Removed?.Invoke( this );
	}

	public void Remove()
	{
		Track.Remove();
		TrackList.Update();
	}

	public bool MarkValueChanged()
	{
		_blocksInvalid = true;
		_previewBlocksInvalid = true;
		_dispatchValueChanged = true;

		Parent?.MarkValueChanged();
		TrackList.Session.ClipModified();
		TrackList.Session.RefreshNextFrame();

		return true;
	}

	public void Frame()
	{
		if ( _dispatchValueChanged )
		{
			_dispatchValueChanged = false;
			ValueChanged?.Invoke( this );
		}

		foreach ( var child in _children )
		{
			child.Frame();
		}
	}

	public int CompareTo( TrackView? other )
	{
		if ( ReferenceEquals( this, other ) )
		{
			return 0;
		}

		if ( other is null )
		{
			return 1;
		}

		var childrenCompare = (Children.Count > 0).CompareTo( other.Children.Count > 0 );
		if ( childrenCompare != 0 ) return childrenCompare;

		return string.Compare( Track.Name, other.Track.Name, StringComparison.Ordinal );
	}

	private T GetCookie<T>( string name, T fallback ) =>
		TrackList.Session.GetCookie( $"{Track.Id}.{name}", fallback );

	private void SetCookie<T>( string name, T value ) =>
		TrackList.Session.SetCookie( $"{Track.Id}.{name}", value );

	public void InspectProperty()
	{
		Select();

		if ( Target is not { } property ) return;
		if ( property.GetTargetGameObject() is not { } go ) return;

		SceneEditorSession.Active.Selection.Clear();
		SceneEditorSession.Active.Selection.Add( go );

		if ( Track.Parent is not IReferenceTrack<GameObject> )
		{
			return;
		}

		EditorToolManager.SetTool( nameof(ObjectEditorTool) );

		switch ( property.Name )
		{
			case nameof( GameObject.LocalPosition ):
				EditorToolManager.SetSubTool( nameof( PositionEditorTool ) );
				break;

			case nameof( GameObject.LocalRotation ):
				EditorToolManager.SetSubTool( nameof( RotationEditorTool ) );
				break;

			case nameof( GameObject.LocalScale ):
				EditorToolManager.SetSubTool( nameof( ScaleEditorTool ) );
				break;
		}
	}

	public TrackView? Find( string propertyPath )
	{
		var parent = this;

		while ( parent is not null && propertyPath.Length > 0 )
		{
			var propertyName = propertyPath;

			// TODO: Hack for anim graph parameters including periods

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

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

		return parent;
	}

	public void ApplyFrame( MovieTime time )
	{
		switch ( Track )
		{
			case ProjectSequenceTrack sequenceTrack:
				var binder = TrackList.Session.Binder;

				foreach ( var propertyTrack in sequenceTrack.PropertyTracks )
				{
					propertyTrack.Update( time, binder );
				}

				break;

			case IPropertyTrack propertyTrack:
				if ( Target is not ITrackProperty { CanWrite: true } property ) break;

				UpdatePreviewBlocks();

				if ( _previewBlocks.GetBlock( time ) is IPropertySignal block )
				{
					property.Value = block.GetValue( time + TrackList.PreviewOffset );
				}
				else
				{
					property.Update( propertyTrack, time );
				}

				break;
		}
	}

	public bool TryGetValue<T>( MovieTime time, [MaybeNullWhen( false )] out T value )
	{
		value = default;

		if ( Track is not IPropertyTrack<T> track ) return false;

		UpdatePreviewBlocks();

		if ( _previewBlocks.GetBlock( time ) is IPropertySignal<T> signal )
		{
			value = signal.GetValue( time );
			return true;
		}

		return track.TryGetValue( time, out value );
	}

	private IPropertyTrack<Transform> CreateTransformTrack()
	{
		if ( Track is not IReferenceTrack<GameObject> )
		{
			return Parent?.TransformTrack ?? new TransformTrack( this );
		}

		return new TransformTrack( this,
			Find( nameof(GameObject.Enabled) ),
			Find( nameof(GameObject.LocalPosition) ),
			Find( nameof(GameObject.LocalRotation) ),
			Find( nameof(GameObject.LocalScale) ) );
	}
}

file sealed class TransformTrack : IPropertyTrack<Transform>
{
	public string Name => "Transform";
	public ITrack Parent => View.Track;

	public TrackView View { get; }

	public TrackView? Enabled { get; }
	public TrackView? LocalPosition { get; }
	public TrackView? LocalRotation { get; }
	public TrackView? LocalScale { get; }

	public TransformTrack( TrackView view,
		TrackView? enabled = null,
		TrackView? localPosition = null,
		TrackView? localRotation = null,
		TrackView? localScale = null )
	{
		View = view;

		Enabled = enabled;
		LocalPosition = localPosition;
		LocalRotation = localRotation;
		LocalScale = localScale;
	}

	public bool TryGetValue( MovieTime time, out Transform value )
	{
		value = Transform.Zero;

		// This track only returns a value if:
		//   1) Enabled is true, or
		//   2) Enabled is undefined, and any component track is defined

		if ( Enabled?.TryGetValue( time, out bool enabled ) is true )
		{
			if ( !enabled ) return false;
		}
		else
		{
			enabled = false;
		}

		if ( LocalPosition?.TryGetValue( time, out Vector3 pos ) is true )
		{
			value.Position = pos;
			enabled = true;
		}

		if ( LocalRotation?.TryGetValue( time, out Rotation rot ) is true )
		{
			value.Rotation = rot;
			enabled = true;
		}

		if ( LocalScale?.TryGetValue( time, out Vector3 scale ) is true )
		{
			value.Scale = scale;
			enabled = true;
		}

		if ( View.Parent?.TransformTrack.TryGetValue( time, out var parentTransform ) is true )
		{
			value = parentTransform.ToWorld( value );
		}

		return enabled;
	}
}