Rides/TrackRides/TrackElementDefinition.cs
using System;
using System.Collections.Immutable;
using System.Text.Json.Serialization;

namespace HC3.Rides;

#nullable enable

/// <summary>
/// Describes the general shape of a track element. Will generate multiple <see cref="TrackElement"/>s for all possible
/// axis flips, and all allowed combinations of start and end banking. These are accessed through <see cref="Elements"/>.
/// </summary>
[AssetType( Name = "Track Element Definition", Extension = "trkelem" )]
public sealed class TrackElementDefinition : GameResource
{
	private static TrackElementDefinition? _default;
	private static TrackElementDefinition? _station;

	protected override Bitmap CreateAssetTypeIcon( int width, int height )
	{
		return CreateSimpleAssetTypeIcon( "shortcut", width, height );
	}

	/// <summary>
	/// Flat, level piece of normal track.
	/// </summary>
	public static TrackElementDefinition Default =>
		_default ??= ResourceLibrary.Get<TrackElementDefinition>( "resources/tracks/elements/default.trkelem" ) ?? new TrackElementDefinition();

	/// <summary>
	/// Station platform element.
	/// </summary>
	public static TrackElementDefinition Station =>
		_station ??= ResourceLibrary.Get<TrackElementDefinition>( "resources/tracks/elements/station.trkelem" ) ?? Default;

	private ImmutableArray<TrackElement>? _elements;

	private TrackBanking _startMinBanking = TrackBanking.VerticalLeft;
	private TrackBanking _startMaxBanking = TrackBanking.VerticalRight;
	private TrackBanking _endMinBanking = TrackBanking.VerticalLeft;
	private TrackBanking _endMaxBanking = TrackBanking.VerticalRight;
	private int _minBankingChange = 0;
	private int _maxBankingChange = 1;
	private TrackIncline _startIncline = TrackIncline.None;
	private TrackIncline _endIncline = TrackIncline.None;
	private TrackHeading _endHeading = TrackHeading.Forward;
	private Vector3Int _endOffset = new( 2, 0, 0 );
	private bool _endInverted;

	/// <summary>
	/// Base player-facing name of elements created from this definition.
	/// </summary>
	[Group( "Display" )]
	public string Title { get; set; } = "Track";

	/// <summary>
	/// Describes the size of this element, to help categorize it in the <see cref="TrackBuilder"/> UI.
	/// </summary>
	[Group( "Display" )]
	public TrackElementSize Size { get; set; }

	/// <summary>
	/// Do we need to create variants of this element with the X axis flipped?
	/// </summary>
	[JsonIgnore]
	public bool CanFlipX
	{
		get
		{
			// TODO
			if ( CustomSpline ) return false;

			return StartIncline != TrackIncline.None
				|| EndIncline != TrackIncline.None
				|| EndInverted;
		}
	}

	/// <summary>
	/// Do we need to create variants of this element with the Y axis flipped?
	/// </summary>
	[JsonIgnore] public bool CanFlipY => Turn is not TurnDirection.Straight;

	/// <summary>
	/// Do we need to create variants of this element with the Z axis flipped?
	/// </summary>
	[JsonIgnore] public bool CanFlipZ => CustomSpline ? CustomIncline != TrackIncline.None : StartIncline != EndIncline && !EndInverted;

	/// <summary>
	/// Descriptive turn direction to help filter track elements in the <see cref="TrackBuilder"/>.
	/// </summary>
	[JsonIgnore]
	public TurnDirection? Turn => TrackElement.GetTurnDirection( EndHeading, EndOffset, false, EndInverted, false );

	[Group( "Spline" )]
	public bool CustomSpline { get; set; }

	[Group( "Spline" ), ShowIf( nameof( CustomSpline ), true )]
	public float StartTangentScale { get; set; } = 1f;

	[Group( "Spline" ), ShowIf( nameof( CustomSpline ), true )]
	public float EndTangentScale { get; set; } = 1f;

	[Group( "Spline" ), ShowIf( nameof( CustomSpline ), true )]
	public List<TrackSplinePoint> MidPoints { get; set; } = new();

	[Group( "Spline" ), Title( "Track Builder Incline" ), ShowIf( nameof( CustomSpline ), true )]
	public TrackIncline CustomIncline { get; set; }

	public class TrackSplinePoint
	{
		[KeyProperty]
		public Vector3 Position { get; set; }

		public Rotation Rotation { get; set; } = Rotation.Identity;

		public float TangentScale { get; set; } = 1f;
	}

	/// <summary>
	/// How far left this element can be banking at the start.
	/// </summary>
	[Group( "Banking" )]
	public TrackBanking StartMinBanking
	{
		get => _startMinBanking;
		set
		{
			_startMinBanking = value;
			InvalidateElements();
		}
	}
	/// <summary>
	/// How far right this element can be banking at the start.
	/// </summary>

	[Group( "Banking" )]
	public TrackBanking StartMaxBanking
	{
		get => _startMaxBanking;
		set
		{
			_startMaxBanking = value;
			InvalidateElements();
		}
	}

	/// <summary>
	/// How far left this element can be banking at the end.
	/// </summary>
	[Group( "Banking" )]
	public TrackBanking EndMinBanking
	{
		get => _endMinBanking;
		set
		{
			_endMinBanking = value;
			InvalidateElements();
		}
	}

	/// <summary>
	/// How far right this element can be banking at the end.
	/// </summary>
	[Group( "Banking" )]
	public TrackBanking EndMaxBanking
	{
		get => _endMaxBanking;
		set
		{
			_endMaxBanking = value;
			InvalidateElements();
		}
	}

	/// <summary>
	/// How little the banking can change between the start and end of this element.
	/// </summary>
	[Group( "Banking" )]
	public int MinBankingChange
	{
		get => _minBankingChange;
		set
		{
			_minBankingChange = value;
			InvalidateElements();
		}
	}

	/// <summary>
	/// How much the banking can change between the start and end of this element.
	/// </summary>
	[Group( "Banking" )]
	public int MaxBankingChange
	{
		get => _maxBankingChange;
		set
		{
			_maxBankingChange = value;
			InvalidateElements();
		}
	}

	/// <summary>
	/// Incline of the track at the start of this element.
	/// </summary>
	[Group( "Incline" )]
	public TrackIncline StartIncline
	{
		get => _startIncline;
		set
		{
			_startIncline = value;
			InvalidateElements();
		}
	}

	/// <summary>
	/// Incline of the track at the end of this element.
	/// </summary>
	[Group( "Incline" )]
	public TrackIncline EndIncline
	{
		get => _endIncline;
		set
		{
			_endIncline = value;
			InvalidateElements();
		}
	}

	/// <summary>
	/// Gets the final heading of this element, relative to <see cref="TrackHeading.Forward"/>.
	/// </summary>
	[Group( "End" )]
	public TrackHeading EndHeading
	{
		get => _endHeading;
		set
		{
			_endHeading = value;
			InvalidateElements();
		}
	}

	/// <summary>
	/// Gets how much this element moves the track spline, in track units.
	/// Track grid units are at twice the resolution of terrain grid units.
	/// </summary>
	[Group( "End" )]
	public Vector3Int EndOffset
	{
		get => _endOffset;
		set
		{
			_endOffset = value;
			InvalidateElements();
		}
	}

	/// <summary>
	/// Does this element end upside down?
	/// </summary>
	[Group( "End" )]
	public bool EndInverted
	{
		get => _endInverted;
		set
		{
			_endInverted = value;
			InvalidateElements();
		}
	}

	/// <summary>
	/// Does this element have a special function, like a lift hill or station platform?
	/// </summary>
	[Group( "Special" )]
	public TrackFeature Feature { get; set; }

	/// <summary>
	/// All valid reflections and banking variations generated from this definition.
	/// </summary>
	[JsonIgnore]
	public ImmutableArray<TrackElement> Elements =>
		_elements ??= GeneratePermutations().ToImmutableArray();

	private readonly record struct Metrics( float Length, float Length2D, ImmutableArray<float> TileBoundaries );

	private Metrics? _metrics;

	[JsonIgnore]
	public float Length => (_metrics ??= CalculateMetrics()).Length;

	[JsonIgnore]
	public float Length2D => (_metrics ??= CalculateMetrics()).Length2D;

	[JsonIgnore]
	public ImmutableArray<float> TileBoundaries => (_metrics ??= CalculateMetrics()).TileBoundaries;

	/// <summary>
	/// Regenerate <see cref="Elements"/> next time it is accessed.
	/// </summary>
	private void InvalidateElements()
	{
		_elements = null;
		_metrics = null;
	}

	/// <summary>
	/// Generate all valid reflections and banking variations of this definition.
	/// </summary>
	private IEnumerable<TrackElement> GeneratePermutations()
	{
		IReadOnlyList<bool> flip = [false, true];

		var bankings = GetBankings()
			.OrderBy( x => x.Start.Ordinal() )
			.ThenBy( x => x.End.Ordinal() );

		return
			from flipX in CanFlipX ? flip : [false]
			from flipY in CanFlipY ? flip : [false]
			from flipZ in CanFlipZ ? flip : [false]
			from banking in bankings
			select CreateElement( flipX, flipY, flipZ, banking.Start, banking.End );
	}

	private Metrics CalculateMetrics()
	{
		if ( Elements.Length == 0 ) return default;

		var spline = new Spline();
		var element = Elements.First();

		var start = new TrackNode( default, new TrackOrientation( default, element.StartIncline, element.StartBanking, element.StartInverted ) );

		spline.AddTrack( start, [element] );

		var length2D = 0f;
		var prev = spline.SampleAtDistance( 0f );
		var tileBoundaries = new List<float> { 0f };

		var stepCount = Math.Max( 1, (int)MathF.Round( spline.Length ) );
		var stepSize = spline.Length / stepCount;

		for ( var i = 1; i <= stepCount; ++i )
		{
			var next = spline.SampleAtDistance( i * stepSize );

			length2D += (next.Position - prev.Position).WithZ( 0f ).Length;

			prev = next;
		}

		var tileCount = Math.Max( 1, (int)MathF.Round( length2D / GridManager.GridSize ) );
		var tileSize = length2D / tileCount;

		var sinceTileBoundary = 0f;
		prev = spline.SampleAtDistance( 0f );

		for ( var i = 1; i <= stepCount; ++i )
		{
			var next = spline.SampleAtDistance( i * stepSize );

			sinceTileBoundary += (next.Position - prev.Position).WithZ( 0f ).Length;

			if ( sinceTileBoundary >= tileSize || i == stepCount )
			{
				sinceTileBoundary -= tileSize;
				tileBoundaries.Add( i * stepSize );
			}

			prev = next;
		}

		return new Metrics( spline.Length, length2D, tileBoundaries.ToImmutableArray() );
	}

	/// <summary>
	/// Get all pairs of start and end banking for this element.
	/// </summary>
	private IEnumerable<(TrackBanking Start, TrackBanking End)> GetBankings()
	{
		for ( var start = StartMinBanking; start <= StartMaxBanking; ++start )
		{
			for ( var end = EndMinBanking; end <= EndMaxBanking; ++end )
			{
				if ( Math.Abs( start - end ) < MinBankingChange ) continue;
				if ( Math.Abs( start - end ) > MaxBankingChange ) continue;

				yield return (start, end);
			}
		}
	}

	/// <summary>
	/// Helper to create an element from this definition with the given axis flips
	/// and banking values.
	/// </summary>
	private TrackElement CreateElement(
		bool flipX, bool flipY, bool flipZ,
		TrackBanking startBanking, TrackBanking endBanking )
	{
		if ( flipX )
		{
			// When flipping on the X axis (forward / backward), the end becomes the start

			(startBanking, endBanking) = (endBanking.Inverse(), startBanking.Inverse());
		}

		if ( flipY )
		{
			// When flipping on the Y axis (left / right), banking gets inverted

			(startBanking, endBanking) = (startBanking.Inverse(), endBanking.Inverse());
		}

		var flags =
			(flipX ? TrackElementFlags.FlipX : 0) |
			(flipY ? TrackElementFlags.FlipY : 0) |
			(flipZ ? TrackElementFlags.FlipZ : 0);

		return new TrackElement( this, flags, startBanking, endBanking );
	}

	/// <summary>
	/// Attempts to find a matching valid element with the given axis flips
	/// and banking values.
	/// </summary>
	public TrackElement? GetElement(
		TrackElementFlags flags,
		TrackBanking startBanking,
		TrackBanking endBanking )
	{
		foreach ( var element in Elements )
		{
			if ( element.Flags == flags && element.StartBanking == startBanking && element.EndBanking == endBanking )
			{
				return element;
			}
		}

		return null;
	}
}