Editor/TransitionItem.cs
using System;
using System.Linq;
using Editor;

namespace Sandbox.States.Editor;

public sealed partial class TransitionItem : GraphicsItem, IContextMenuSource, IDeletable, IComparable<TransitionItem>
{
	public Transition? Transition { get; }
	public StateItem Source { get; }
	public StateItem? Target { get; set; }
	public Vector2 TargetPosition { get; set; }

	private readonly TransitionLabel? _delayLabel;
	private readonly TransitionLabel? _messageLabel;
	private readonly TransitionLabel? _conditionLabel;
	private readonly TransitionLabel? _actionLabel;

	public bool IsPreview { get; }

	public TransitionItem( Transition? transition, StateItem source, StateItem? target )
		: base( null )
	{
		Transition = transition;

		Source = source;
		Target = target;

		ZIndex = -10;

		if ( Target is not null )
		{
			Source.PositionChanged += OnStatePositionChanged;
			Target.PositionChanged += OnStatePositionChanged;

			Cursor = CursorShape.Finger;

			Selectable = true;
			HoverEvents = true;
		}
		else
		{
			IsPreview = true;
		}

		if ( Transition is not null )
		{
			_delayLabel = new TransitionLabel( this, new TransitionDelay( this ) );
			_messageLabel = new TransitionLabel( this, new TransitionMessage( this ) );
			_conditionLabel = new TransitionLabel( this, new TransitionCondition( this ) );
			_actionLabel = new TransitionLabel( this, new TransitionAction( this ) );
		}

		Layout();
	}

	protected override void OnDestroy()
	{
		if ( IsPreview ) return;

		Source.PositionChanged -= OnStatePositionChanged;
		Target!.PositionChanged -= OnStatePositionChanged;
	}

	protected override void OnMouseMove( GraphicsMouseEvent e )
	{
		if ( IsPreview ) return;

		if ( Selected && e.LeftMouseButton )
		{
			e.Accepted = true;

			Source.View.StartCreatingTransition( Source, Transition );

			if ( !e.HasShift )
			{
				DeleteInternal();
			}
		}
	}

	private void OnStatePositionChanged()
	{
		Layout();
	}

	private (Vector2 Start, Vector2 End, Vector2 Tangent)? GetSceneStartEnd()
	{
		// TODO: transitions to self

		var (index, count) = Source.View.GetTransitionPosition( this );

		var sourceCenter = Source.Center;
		var targetCenter = Target?.Center ?? TargetPosition;

		if ( (targetCenter - sourceCenter).IsNearZeroLength )
		{
			return null;
		}

		var tangent = (targetCenter - sourceCenter).Normal;
		var normal = tangent.Perpendicular;

		if ( Target is null || Target.State.Id.CompareTo( Source.State.Id ) < 0 )
		{
			normal = -normal;
		}

		var maxWidth = Source.Radius * 2f;
		var usedWidth = count * 48f;

		var itemWidth = Math.Min( usedWidth, maxWidth ) / count;
		var offset = (index - count * 0.5f + 0.5f) * itemWidth;
		var curve = MathF.Sqrt( Source.Radius * Source.Radius - offset * offset );

		var start = sourceCenter + tangent * curve;
		var end = targetCenter - tangent * (Target is null ? 0f : curve);

		return (start + offset * normal, end + offset * normal, tangent);
	}

	public (bool Hovered, bool Selected) GetSelectedState()
	{
		var selected = Selected || IsPreview;
		var hovered = Hovered;

		return (hovered, selected);
	}

	public static Color GetPenColor( bool hovered, bool selected )
	{
		return selected
			? Color.Yellow: hovered
				? Color.White: Color.White.Darken( 0.125f );
	}

	protected override void OnPaint()
	{
		var start = new Vector2( 0f, Size.y * 0.5f );
		var end = new Vector2( Size.x, Size.y * 0.5f );
		var tangent = new Vector2( 1f, 0f );

		var normal = tangent.Perpendicular;

		var (hovered, selected) = GetSelectedState();
		var thickness = selected || hovered ? 6f : 4f;
		var pulse = MathF.Pow( Math.Max( 1f - (Transition?.LastTransitioned ?? float.PositiveInfinity), 0f ), 8f );
		var pulseScale = 1f + pulse * 3f;

		thickness *= pulseScale;

		var offset = thickness * 0.5f * normal;

		var color = GetPenColor( hovered, selected );
		
		var arrowEnd = Vector2.Lerp( end, start, pulse );
		var lineEnd = arrowEnd - tangent * 14f;

		Paint.ClearPen();
		Paint.SetBrushLinear( start, end, color.Darken( 0.667f / pulseScale ), color );
		Paint.DrawPolygon( start - offset, lineEnd - offset, lineEnd + offset, start + offset );

		var arrowScale = hovered || selected ? 1.25f : pulseScale;

		Paint.SetBrush( color );
		Paint.DrawArrow( arrowEnd - tangent * 16f * arrowScale, arrowEnd, 12f * arrowScale );
	}

	public void Layout()
	{
		PrepareGeometryChange();

		if ( GetSceneStartEnd() is not var (start, end, tangent) )
		{
			Size = 0f;
		}
		else
		{
			var diff = end - start;
			var length = diff.Length;

			Position = start - tangent.Perpendicular * 8f;
			Size = new Vector2( length, 16f );
			Rotation = MathF.Atan2( diff.y, diff.x ) * 180f / MathF.PI;
		}

		if ( Target is not null )
		{
			ToolTip = $"Transition <b>{Source.State.Name}</b> \u2192 <b>{Target.State.Name}</b>";
		}

		LabelLayout();
		Update();
	}

	private void AlignLabels( bool source, params TransitionLabel?[] labels )
	{
		var count = labels.Count( x => x != null );
		if ( count == 0 ) return;

		const float sourceMargin = 8f;
		const float targetMargin = 24f;

		var maxWidth = (Width - sourceMargin - targetMargin) / count;

		foreach ( var label in labels )
		{
			if ( label is null ) continue;

			label.MaxWidth = maxWidth;
			label.Layout();
		}

		var totalWidth = labels.Sum( x => x?.Width ?? 0f );
		var flip = Rotation is > 90f or < -90f;
		var origin = source
			? new Vector2( sourceMargin, Size.y * 0.5f )
			: new Vector2( Width - totalWidth - targetMargin, Size.y * 0.5f );

		origin.y += source != flip ? -28f : 4f;

		if ( flip )
		{
			origin.y += 24f;
		}

		foreach ( var label in labels )
		{
			if ( label is null ) continue;

			if ( flip )
			{
				origin.x += label.Width;
			}

			label.Position = origin;
			label.Rotation = flip ? 180f : 0f;

			if ( !flip )
			{
				origin.x += label.Width;
			}

			label.Update();
		}
	}

	private void LabelLayout()
	{
		AlignLabels( true, _delayLabel, _messageLabel, _conditionLabel );
		AlignLabels( false, _actionLabel );
	}

	public void Delete()
	{
		Source.View.LogEdit( "Transition Removed" );
		DeleteInternal();
	}

	public void Flip()
	{
		if ( IsPreview ) return;

		Source.View.LogEdit( "Transition Flipped" );

		var copy = Target!.State.AddTransition( Source.State );

		copy.CopyFrom( Transition! );
		Transition!.Remove();

		Source.View.UpdateItems();
	}

	private void DeleteInternal()
	{
		Transition!.Remove();
		Destroy();
	}

	public void OnContextMenu( ContextMenuEvent e )
	{
		if ( IsPreview ) return;

		e.Accepted = true;
		Selected = true;

		var menu = new global::Editor.Menu { DeleteOnClose = true };

		menu.AddHeading( "Transition" );

		foreach ( var label in Children.OfType<TransitionLabel>() )
		{
			label.Source.BuildAddContextMenu( menu );
		}

		menu.AddSeparator();
		menu.AddOption( "Delete", "delete", action: Delete );

		menu.OpenAtCursor( true );
	}

	public void Frame()
	{
		if ( Transition is null || Transition.LastTransitioned > 1f )
		{
			return;
		}

		Update();
	}

	public void ForceUpdate()
	{
		if ( !IsValid ) return;

		LabelLayout();
		Update();
	}

	public int CompareTo( TransitionItem? other )
	{
		return Source.State.Id.CompareTo( other?.Source.State.Id ?? -1 );
	}
}