Code/Binder/Properties/PropertyFactory.cs
using System;
using System.Collections.Immutable;
using System.Reflection;

namespace Sandbox.MovieMaker.Properties;

#nullable enable

/// <summary>
/// Used by <see cref="TrackBinder"/> to create <see cref="ITrackProperty"/> instances that allow <see cref="ITrack"/>s
/// to modify values in a scene.
/// </summary>
public interface ITrackPropertyFactory
{
	/// <summary>
	/// Used to sort the order that factories are considered when trying to create a property.
	/// </summary>
	public int Order => 0;

	/// <summary>
	/// When listing properties to add, what category should we use for properties from this factory?
	/// </summary>
	public string CategoryName => "Other";

	/// <summary>
	/// Lists all available property names provided by this factory from a given <paramref name="parent"/>.
	/// </summary>
	IEnumerable<string> GetPropertyNames( ITrackTarget parent );

	/// <summary>
	/// Decides if this factory can create a property given a <paramref name="parent"/> target and <paramref name="name"/>.
	/// Returns any non-<see langword="null"/> type if this factory can create such a property, after which <see cref="CreateProperty{T}"/>
	/// will be called using that type.
	/// </summary>
	Type? GetTargetType( ITrackTarget parent, string name );

	/// <summary>
	/// Create a property with the given <paramref name="parent"/>, <paramref name="name"/>, and property value type <typeparamref name="T"/>.
	/// The target type was previously returned by <see cref="GetTargetType"/>, or read from a deserialized track.
	/// </summary>
	ITrackProperty<T> CreateProperty<T>( ITrackTarget parent, string name );
}

/// <summary>
/// An <see cref="ITrackPropertyFactory"/> that only creates properties nested inside a particular <typeparamref name="TParent"/>
/// target type.
/// </summary>
/// <typeparam name="TParent">Parent target type that this factory's properties are always nested inside.</typeparam>
public interface ITrackPropertyFactory<in TParent> : ITrackPropertyFactory
	where TParent : ITrackTarget
{
	IEnumerable<string> GetPropertyNames( TParent parent );

	/// <inheritdoc cref="ITrackPropertyFactory.GetTargetType"/>
	Type? GetTargetType( TParent parent, string name );

	/// <inheritdoc cref="ITrackPropertyFactory.CreateProperty{T}"/>
	ITrackProperty<T> CreateProperty<T>( TParent parent, string name );

	IEnumerable<string> ITrackPropertyFactory.GetPropertyNames( ITrackTarget parent ) =>
		parent is TParent typedParent
			? GetPropertyNames( typedParent )
			: Enumerable.Empty<string>();

	Type? ITrackPropertyFactory.GetTargetType( ITrackTarget parent, string name ) =>
		parent is TParent typedParent
			? GetTargetType( typedParent, name )
			: null;

	ITrackProperty<T> ITrackPropertyFactory.CreateProperty<T>( ITrackTarget parent, string name ) =>
		CreateProperty<T>( (TParent)parent, name );
}

/// <summary>
/// An <see cref="ITrackPropertyFactory"/> that only creates properties nested inside a particular <typeparamref name="TParent"/>
/// target type, and that always have the same property value type <typeparamref name="TValue"/>.
/// </summary>
/// <typeparam name="TParent">Parent target type that this factory's properties are always nested inside.</typeparam>
/// <typeparam name="TValue">Property value type for properties created by this factory.</typeparam>
public interface ITrackPropertyFactory<in TParent, TValue> : ITrackPropertyFactory<TParent>
	where TParent : ITrackTarget
{
	/// <summary>
	/// Returns true if this factory can create a property with the given <paramref name="parent"/> and <paramref name="name"/>.
	/// </summary>
	bool PropertyExists( TParent parent, string name );

	/// <summary>
	/// Creates a property with the given <paramref name="parent"/> and <paramref name="name"/>.
	/// </summary>
	ITrackProperty<TValue> CreateProperty( TParent parent, string name );

	Type? ITrackPropertyFactory<TParent>.GetTargetType( TParent parent, string name ) =>
		PropertyExists( parent, name ) ? typeof(TValue) : null;

	ITrackProperty<T> ITrackPropertyFactory<TParent>.CreateProperty<T>( TParent parent, string name ) =>
		(ITrackProperty<T>)CreateProperty( parent, name );
}

public static class TrackProperty
{
	[SkipHotload]
	private static ImmutableArray<ITrackPropertyFactory>? _factories;

	private static IReadOnlyList<ITrackPropertyFactory> Factories => _factories ??=
		TypeLibrary.GetTypes<ITrackPropertyFactory>()
			.Where( x => x is { IsAbstract: false, IsInterface: false, IsGenericType: false } )
			.Select( CreateFactory )
			.OfType<ITrackPropertyFactory>()
			.OrderBy( x => x.Order )
			// ReSharper disable once UseCollectionExpression
			.ToImmutableArray();

	public static ITrackProperty? Create( ITrackTarget parent, string name )
	{
		var factory = Factories.FirstOrDefault( x => IsMatchingFactory( x, parent, name ) );
		var targetType = factory?.GetTargetType( parent, name );

		if ( factory is null || targetType is null ) return null;

		return GenericHelper.Get( targetType )
			.CreateProperty( factory, parent, name );
	}

	public static ITrackProperty Create( ITrackTarget parent, string name, Type targetType )
	{
		var factory = Factories.FirstOrDefault( x => IsMatchingFactory( x, parent, name, targetType ) )
			?? throw new Exception( $"We should have at least found the UnknownPropertyFactory." );

		return GenericHelper.Get( targetType )
			.CreateProperty( factory, parent, name );
	}

	public static IEnumerable<(string Name, string Category, Type Type)> GetAll( ITrackTarget parent )
	{
		return Factories.SelectMany( x => x.GetPropertyNames( parent )
				.Select( y => (Name: y, Category: x.CategoryName, Type: x.GetTargetType( parent, y )) )
				.Where( y => y.Type is not null && y.Type != typeof(Unknown) ) )
			.DistinctBy( x => x.Name )!;
	}

	public static ITrackProperty<T> Create<T>( ITrackTarget parent, string name ) =>
		(ITrackProperty<T>)Create( parent, name, typeof( T ) );

	private static bool IsMatchingFactory( ITrackPropertyFactory factory,
		ITrackTarget parent, string name )
	{
		if ( factory.GetTargetType( parent, name ) is not { } valueType ) return false;
		return valueType != typeof(Unknown);
	}

	private static bool IsMatchingFactory( ITrackPropertyFactory factory,
		ITrackTarget parent, string name, Type targetType )
	{
		if ( factory.GetTargetType( parent, name ) is not { } valueType ) return false;
		return valueType == targetType || valueType == typeof(Unknown);
	}

	private static ITrackPropertyFactory? CreateFactory( TypeDescription factoryType )
	{
		try
		{
			return factoryType.Create<ITrackPropertyFactory>();
		}
		catch
		{
			return null;
		}
	}
}

/// <summary>
/// To get around TypeLibrary not having a way to call generic methods.
/// </summary>
file abstract class GenericHelper
{
	[SkipHotload]
	private static Dictionary<Type, GenericHelper> Cache { get; } = new();

	public static GenericHelper Get( Type propertyType )
	{
		if ( Cache.TryGetValue( propertyType, out var cached ) )
		{
			return cached;
		}

		cached = TypeLibrary.GetType( typeof(GenericHelper<>) ).CreateGeneric<GenericHelper>( [propertyType] );

		Cache[propertyType] = cached;

		return cached;
	}

	public abstract ITrackProperty CreateProperty( ITrackPropertyFactory factory, ITrackTarget parent, string name );
}

file sealed class GenericHelper<T> : GenericHelper
{
	public override ITrackProperty CreateProperty( ITrackPropertyFactory factory, ITrackTarget parent, string name )
	{
		return factory.CreateProperty<T>( parent, name );
	}
}