Code/Binder/Binder.References.cs
using System;
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;

namespace Sandbox.MovieMaker;

#nullable enable

// When MovieProperties get serialized, we write track mappings to GameObject or Component references.
// Here we store those mappings, and handle creating ITarget instances that access them.

[JsonConverter( typeof(BinderConverter) )]
partial class TrackBinder : IJsonPopulator
{
	private readonly Dictionary<Guid, GameObject?> _gameObjectMap = new();
	private readonly Dictionary<Guid, Component?> _componentMap = new();

	/// <summary>
	/// Finds track IDs currently explicitly bound to the given <paramref name="gameObject"/>.
	/// </summary>
	public IEnumerable<Guid> GetTrackIds( GameObject gameObject ) => _gameObjectMap
		.Where( x => x.Value == gameObject )
		.Select( x => x.Key );

	/// <summary>
	/// Finds track IDs currently explicitly bound to the given <paramref name="component"/>.
	/// </summary>
	public IEnumerable<Guid> GetTrackIds( Component component ) => _componentMap
		.Where( x => x.Value == component )
		.Select( x => x.Key );

	#region Serialization

	private record struct Model(
		ImmutableArray<MappingModel>? GameObjects = null,
		ImmutableArray<MappingModel>? Components = null );

	private record struct MappingModel( Guid Track, Guid? Reference );

	public JsonNode Serialize()
	{
		// TODO: prune mappings if there aren't any matching tracks on any clip in the project?

		var model = new Model(
			_gameObjectMap
				.Select( x => new MappingModel( x.Key, x.Value.IsValid() ? x.Value.Id : null ) )
				.ToImmutableArray(),
			_componentMap
				.Select( x => new MappingModel( x.Key, x.Value.IsValid() ? x.Value.Id : null ) )
				.ToImmutableArray() );

		return Json.ToNode( model );
	}

	public void Deserialize( JsonNode? node )
	{
		_gameObjectMap.Clear();
		_componentMap.Clear();

		if ( Json.FromNode<Model?>( node ) is not { } model ) return;

		if ( model.GameObjects is { } objects )
		{
			foreach ( var mapping in objects )
			{
				_gameObjectMap[mapping.Track] = mapping.Reference is { } id && Scene.Directory.FindByGuid( id ) is { } gameObject
					? gameObject : null;
			}
		}

		if ( model.Components is { } components )
		{
			foreach ( var mapping in components )
			{
				_componentMap[mapping.Track] = mapping.Reference is { } id && Scene.Directory.FindComponentByGuid( id ) is { } component
					? component
					: null;
			}
		}
	}

	#endregion

	#region Reference Targets

	private abstract class Reference<T>( ITrackReference<GameObject>? parent, Guid id ) : ITrackReference<T>
		where T : class, IValid
	{
		private T? _autoBound;

		public Guid Id => id;
		public abstract string Name { get; }
		public virtual Type TargetType => typeof(T);
		public abstract bool IsActive { get; }
		public ITrackReference<GameObject>? Parent => parent;

		public T? Value
		{
			get
			{
				if ( TryGetBinding( out var bound ) ) return bound;

				return _autoBound.IsValid() ? _autoBound : _autoBound ??= AutoBind();
			}
		}

		public void Reset()
		{
			OnReset();
			_autoBound = null;
		}

		public abstract void Bind( T? value );

		protected abstract T? AutoBind();

		protected abstract bool TryGetBinding( out T? value );
		protected abstract void OnReset();
	}

	/// <summary>
	/// Target that references a <see cref="GameObject"/> in a scene.
	/// </summary>
	private sealed class GameObjectReference( ITrackReference<GameObject>? parent, string name, TrackBinder binder, Guid id, Guid? referenceId )
		: Reference<GameObject>( parent, id ), ITrackReference<GameObject>
	{
		public override string Name => Value?.Name ?? name;
		public override bool IsActive => Value is { IsValid: true, Active: true };

		public override void Bind( GameObject? value ) => binder._gameObjectMap[Id] = value;

		/// <summary>
		/// If our parent object is bound, try to bind to a child object with a matching name.
		/// If we have no parent, look up by referenceId, or default to a root object with the right name.
		/// </summary>
		protected override GameObject? AutoBind()
		{
			if ( Parent is null )
			{
				if ( referenceId is { } refId && binder.Scene.Directory.FindByGuid( refId ) is { } match )
				{
					return match;
				}

				return binder.Scene.Children.FirstOrDefault( x => x.Name == name );
			}

			return Parent?.Value is { } go
				? go.Children.FirstOrDefault( x => x.Name == name )
				: null;
		}

		protected override bool TryGetBinding( out GameObject? value ) => binder._gameObjectMap.TryGetValue( Id, out value );
		protected override void OnReset() => binder._gameObjectMap.Remove( Id );
	}

	private TypeDescription? _componentReferenceType;

	private ITrackReference CreateComponentReference( ITrackReference<GameObject>? parent, Type componentType, TrackBinder binder, Guid id )
	{
		_componentReferenceType ??= TypeLibrary.GetType( typeof(ComponentReference<>) );

		return _componentReferenceType.CreateGeneric<ITrackReference>( [componentType], [parent, binder, id] );
	}

	/// <summary>
	/// Target that references a <see cref="Component"/> in a scene.
	/// </summary>
	private sealed class ComponentReference<T>( ITrackReference<GameObject>? parent, TrackBinder binder, Guid id )
		: Reference<T>( parent, id ) where T : Component
	{
		public override string Name => typeof(T).Name;
		public override Type TargetType => typeof(T);
		public override bool IsActive => Value is { IsValid: true, Active: true };

		public override void Bind( T? value ) => binder._componentMap[Id] = value;

		/// <summary>
		/// If our parent object is bound, try to bind to a component with a matching type.
		/// </summary>
		protected override T? AutoBind()
		{
			return Parent?.Value is { } go
				? go.Components.Get<T>( FindMode.EverythingInSelf )
				: null;
		}

		protected override bool TryGetBinding( out T? value )
		{
			if ( binder._componentMap.TryGetValue( Id, out var binding ) )
			{
				value = binding as T;
				return true;
			}

			value = default;
			return false;
		}

		protected override void OnReset() => binder._componentMap.Remove( Id );
	}

	#endregion
}

file sealed class BinderConverter : JsonConverter<TrackBinder>
{
	public override TrackBinder Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options )
	{
		var node = JsonSerializer.Deserialize<JsonNode>( ref reader, options );
		var binder = new TrackBinder( Game.ActiveScene );

		binder.Deserialize( node );

		return binder;
	}

	public override void Write( Utf8JsonWriter writer, TrackBinder value, JsonSerializerOptions options )
	{
		JsonSerializer.Serialize( writer, value.Serialize(), options );
	}
}