Code/Utility/MovieTime.cs
using System;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Sandbox.MovieMaker;

#nullable enable

/// <summary>
/// Represents a duration of time in a movie. Uses fixed point so precision is consistent at any absolute time.
/// Defaults to <see cref="Zero"/>.
/// </summary>
[JsonConverter( typeof( MovieTimeConverter ) )]
public readonly struct MovieTime : IEquatable<MovieTime>, IComparable<MovieTime>
{
	/// <summary>
	/// How many <see cref="Ticks"/> per second. This value should nicely divide into
	/// common frame rates.
	/// </summary>
	public const int TickRate = 3600;

	public static MovieTime Zero => default;
	public static MovieTime Epsilon => FromTicks( 1 );
	public static MovieTime MinValue => FromTicks( int.MinValue );
	public static MovieTime MaxValue => FromTicks( int.MaxValue );

	/// <summary>
	/// Frame rates <c>&lt;= 120</c> that can be perfectly represented by <see cref="TickRate"/>, in ascending order.
	/// Venturing outside these rates will lead to some frames being slightly different durations than others.
	/// </summary>
	public static IReadOnlyList<int> SupportedFrameRates { get; } = Enumerable.Range( 1, 120 )
		.Where( x => TickRate % x == 0 )
		.ToImmutableArray();

	public static MovieTime FromTicks( int ticks ) => new ( ticks );

	public static MovieTime FromSeconds( double time )
	{
		return FromTicks( (int)Math.Round( time * TickRate ) );
	}

	public static MovieTime FromFrames( int frameCount, int frameRate )
	{
		return FromTicks( (int)((long)frameCount * TickRate / frameRate) );
	}

	public static MovieTime Max( MovieTime a, MovieTime b )
	{
		return FromTicks( Math.Max( a._ticks, b._ticks ) );
	}

	public static MovieTime Min( MovieTime a, MovieTime b )
	{
		return FromTicks( Math.Min( a._ticks, b._ticks ) );
	}

	public static MovieTime Distance( MovieTime a, MovieTime b )
	{
		return (a - b).Absolute;
	}

	public static MovieTime Lerp( MovieTime a, MovieTime b, double fraction )
	{
		return fraction <= 0d ? a : fraction >= 1d ? b : FromTicks( (int)(a.Ticks + (b.Ticks - a.Ticks) * fraction) );
	}

	private readonly int _ticks;

	public int Ticks => _ticks;

	public bool IsZero => _ticks == 0;
	public bool IsPositive => _ticks > 0;
	public bool IsNegative => _ticks < 0;

	public double TotalSeconds => (double)_ticks / TickRate;

	public MovieTime Absolute => _ticks < 0 ? -this : this;

	private MovieTime( int ticks )
	{
		_ticks = ticks;
	}

	public MovieTime Clamp( MovieTimeRange? range )
	{
		return range is { Start: var start, End: var end }
			? Max( start, Min( end, this ) )
			: this;
	}

	public MovieTime Floor( MovieTime gridInterval )
	{
		if ( gridInterval.Ticks <= 0 ) return this;

		return FromTicks( Ticks / gridInterval.Ticks * gridInterval.Ticks );
	}

	public MovieTime Round( MovieTime gridInterval )
	{
		if ( gridInterval.Ticks <= 0 ) return this;

		return FromTicks( (Ticks + gridInterval.Ticks / 2) / gridInterval.Ticks * gridInterval.Ticks );
	}

	/// <summary>
	/// Given a <paramref name="frameRate"/>, how many frames have passed before reaching
	/// this time.
	/// </summary>
	public int GetFrameIndex( int frameRate )
	{
		return (int)((long)_ticks * frameRate / TickRate);
	}

	/// <summary>
	/// Given a <paramref name="frameRate"/>, how many frames have passed before reaching
	/// this time, and how far into the current frame are we.
	/// </summary>
	public int GetFrameIndex( int frameRate, out MovieTime remainder )
	{
		var index = GetFrameIndex( frameRate );

		remainder = this - FromFrames( index, frameRate );

		return index;
	}

	public int GetFrameIndex( MovieTime frameInterval )
	{
		return (int)((long)_ticks / frameInterval.Ticks);
	}

	public int GetFrameIndex( MovieTime frameInterval, out MovieTime remainder )
	{
		var index = GetFrameIndex( frameInterval );

		remainder = this - frameInterval * index;

		return index;
	}

	/// <summary>
	/// Given a <paramref name="frameRate"/>, how many frames would need to be allocated
	/// to represent every moment of time up until now. This is always at least <c>1</c>,
	/// and will be <c>1</c> more than <see cref="GetFrameIndex(int)"/> unless this time
	/// is exactly on a frame boundary.
	/// </summary>
	public int GetFrameCount( int frameRate )
	{
		return Math.Max( 1, (int)(((long)_ticks * frameRate + TickRate - 1) / TickRate) );
	}

	public float GetFraction( MovieTime time )
	{
		return time <= 0d ? 0f : time >= this ? 1f : (float)time.Ticks / Ticks;
	}

	public bool Equals( MovieTime other ) => _ticks == other._ticks;
	public int CompareTo( MovieTime other ) => _ticks.CompareTo( other._ticks );

	public override bool Equals( [NotNullWhen( true )] object? obj ) => obj is MovieTime span && Equals( span );
	public override int GetHashCode() => _ticks.GetHashCode();
	public override string ToString()
	{
		var timeSpan = TimeSpan.FromSeconds( TotalSeconds );
		return Math.Abs( timeSpan.TotalHours ) < 1
			? timeSpan.ToString( timeSpan >= TimeSpan.Zero ? @"mm\:ss\.fff" : @"\-mm\:ss\.fff" )
			: timeSpan.ToString( timeSpan >= TimeSpan.Zero ? @"hh\:mm\:ss\.fff" : @"\-hh\:mm\:ss\.fff" );
	}

	#region Operators

	public static implicit operator MovieTime( float seconds ) => FromSeconds( seconds );
	public static implicit operator MovieTime( double seconds ) => FromSeconds( seconds );

	public static MovieTime operator +( MovieTime time ) => time;
	public static MovieTime operator -( MovieTime time ) => FromTicks( -time._ticks );
	public static MovieTime operator +( MovieTime a, MovieTime b ) => FromTicks( a._ticks + b._ticks );
	public static MovieTime operator -( MovieTime a, MovieTime b ) => FromTicks( a._ticks - b._ticks );
	public static MovieTime operator *( MovieTime time, int scale ) => FromTicks( time._ticks * scale );
	public static MovieTime operator *( int scale, MovieTime time ) => FromTicks( time._ticks * scale );

	public static bool operator ==( MovieTime a, MovieTime b ) => a._ticks == b._ticks;
	public static bool operator !=( MovieTime a, MovieTime b ) => a._ticks != b._ticks;

	public static bool operator >( MovieTime a, MovieTime b ) => a._ticks > b._ticks;
	public static bool operator <( MovieTime a, MovieTime b ) => a._ticks < b._ticks;
	public static bool operator >=( MovieTime a, MovieTime b ) => a._ticks >= b._ticks;
	public static bool operator <=( MovieTime a, MovieTime b ) => a._ticks <= b._ticks;

	#endregion
}

file class MovieTimeConverter : JsonConverter<MovieTime>
{
	public override MovieTime Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options )
	{
		return MovieTime.FromTicks( reader.GetInt32() );
	}

	public override void Write( Utf8JsonWriter writer, MovieTime value, JsonSerializerOptions options )
	{
		writer.WriteNumberValue( value.Ticks );
	}
}