Editor/MovieMaker/Timeline/Timeline.cs
using System.Linq;
using Sandbox.MovieMaker;

namespace Editor.MovieMaker;

#nullable enable

public partial class Timeline : GraphicsView
{
	public const float TrackHeight = 32f;
	public const float RootTrackSpacing = 8f;

	public static class Colors
	{
		public static Color Background => Theme.WidgetBackground;
		public static Color ChannelBackground => Theme.ControlBackground;
		public static Color HandleSelected => Color.White;
	}

	public Session Session { get; }

	private readonly BackgroundItem _backgroundItem;
	private readonly GridItem _gridItem;
	private readonly SynchronizedSet<TrackView, TimelineTrack> _tracks;

	private readonly CurrentPointerItem _currentPointerItem;
	private readonly CurrentPointerItem _previewPointerItem;

	public ScrubberItem ScrubBarTop { get; }
	public ScrubberItem ScrubBarBottom { get; }

	public IEnumerable<TimelineTrack> Tracks => _tracks;

	public Rect VisibleRect
	{
		get
		{
			var screenRect = ScreenRect;
			var topLeft = FromScreen( screenRect.TopLeft );
			var bottomRight = FromScreen( screenRect.BottomRight );

			return ToScene( new Rect( topLeft, bottomRight - topLeft ) );
		}
	}

	public Timeline( Session session )
	{
		Session = session;
		MinimumWidth = 256;

		_tracks = new SynchronizedSet<TrackView, TimelineTrack>(
			AddTrack, RemoveTrack, UpdateTrack );

		_backgroundItem = new BackgroundItem( Session );
		Add( _backgroundItem );

		_gridItem = new GridItem( Session );
		Add( _gridItem );

		_currentPointerItem = new CurrentPointerItem( Theme.Yellow );
		Add( _currentPointerItem );

		_previewPointerItem = new CurrentPointerItem( Theme.Blue );
		Add( _previewPointerItem );

		ScrubBarTop = new ScrubberItem( Session.Editor, true ) { Size = new Vector2( Width, 24f ) };
		Add( ScrubBarTop );
		ScrubBarBottom = new ScrubberItem( Session.Editor, false ) { Size = new Vector2( Width, 24f ) };
		Add( ScrubBarBottom );

		Session.PlayheadChanged += UpdateCurrentPosition;
		Session.PreviewChanged += UpdatePreviewPosition;
		Session.ViewChanged += UpdateView;

		FocusMode = FocusMode.TabOrClickOrWheel;

		AcceptDrops = true;

		var bg = new Pixmap( 8 );
		bg.Clear( Colors.Background );

		SetBackgroundImage( bg );

		Antialiasing = true;

		ToolTip =
			"""
			<h3>Timeline</h3>
			<p><b>Scroll</b> to scroll vertically through track list.</p>
			<p><b>Shift+Scroll</b> or <b>Middle-Click+Drag</b> to pan horizontally.</p>
			<p><b>Ctrl+Scroll</b> to zoom in / out.</p>
			<p><b>Alt+Scroll</b> to scrub forwards / backwards by a frame.</p>
			<p><b>Hold Shift</b> to smoothly preview the time under the mouse cursor.</p>
			""";
	}

	public override void OnDestroyed()
	{
		DeleteAllItems();

		Session.PlayheadChanged -= UpdateCurrentPosition;
		Session.PreviewChanged -= UpdatePreviewPosition;
		Session.ViewChanged -= UpdateView;
	}

	private int _lastState;
	private int _lastVisibleRectHash;

	[EditorEvent.Frame]
	public void Frame()
	{
		ScrubBarTop.Frame();
		ScrubBarBottom.Frame();

		UpdateScrubBars();
		UpdateTracksIfNeeded();

		var visibleRectHash = VisibleRect.GetHashCode();

		if ( visibleRectHash != _lastVisibleRectHash )
		{
			Session.DispatchViewChanged();

			if ( (Application.KeyboardModifiers & KeyboardModifiers.Shift) != 0 )
			{
				UpdatePreviewTime( ToScene( _lastMouseLocalPos ) );
			}
		}

		_lastVisibleRectHash = visibleRectHash;

		if ( Session.PreviewTime is not null
			&& (Application.KeyboardModifiers & KeyboardModifiers.Shift) == 0
			&& (Application.MouseButtons & MouseButtons.Left) == 0 )
		{
			Session.PreviewTime = null;
		}
	}

	private void UpdateTracksIfNeeded()
	{
		var state = HashCode.Combine( Session.PixelsPerSecond, Session.TimeOffset, Session.FrameRate, Session.TrackList.StateHash );

		if ( state == _lastState ) return;

		_lastState = state;

		UpdateTracks();
		Update();
	}

	private void UpdateView()
	{
		UpdateSceneFrame();
		UpdateScrubBars();

		UpdateCurrentPosition( Session.PlayheadTime );
		UpdatePreviewPosition( Session.PreviewTime );

		UpdateTracksIfNeeded();
	}

	private void UpdateScrubBars()
	{
		_backgroundItem.Update();

		ScrubBarTop.PrepareGeometryChange();
		ScrubBarBottom.PrepareGeometryChange();

		var visibleRect = VisibleRect;

		ScrubBarTop.Position = visibleRect.TopLeft;
		ScrubBarBottom.Position = visibleRect.BottomLeft - new Vector2( 0f, ScrubBarBottom.Height );

		ScrubBarTop.Width = Width;
		ScrubBarBottom.Width = Width;
	}

	protected override void OnResize()
	{
		base.OnResize();

		UpdateScrubBars();
		UpdateTracks();
	}

	private void UpdateCurrentPosition( MovieTime time )
	{
		_currentPointerItem.PrepareGeometryChange();

		_currentPointerItem.Position = new Vector2( Session.TimeToPixels( time ), VisibleRect.Top + 12f );
		_currentPointerItem.Size = new Vector2( 1, VisibleRect.Height - 24f );
	}

	private void UpdatePreviewPosition( MovieTime? time )
	{
		_previewPointerItem.PrepareGeometryChange();

		if ( time is not null )
		{
			_previewPointerItem.Position = new Vector2( Session.TimeToPixels( time.Value ), VisibleRect.Top + 12f );
			_previewPointerItem.Size = new Vector2( 1, VisibleRect.Height - 24f );
		}
		else
		{
			_previewPointerItem.Position = new Vector2( -50000f, 0f );
		}
	}

	void UpdateSceneFrame()
	{
		Session.TrackListViewHeight = Height - 64f;

		var x = Session.TimeToPixels( Session.TimeOffset );
		SceneRect = new Rect( x - 8, Session.TrackListScrollPosition - Session.TrackListScrollOffset, Width - 4, Height - 4 ); // I don't know where the fuck this 4 comes from, but it stops it having some scroll

		_backgroundItem.PrepareGeometryChange();
		_backgroundItem.SceneRect = SceneRect;
		_backgroundItem.Update();

		_gridItem.PrepareGeometryChange();
		_gridItem.SceneRect = SceneRect;
		_gridItem.Update();

		UpdateCurrentPosition( Session.PlayheadTime );
		UpdatePreviewPosition( Session.PreviewTime );
	}

	public void UpdateTracks()
	{
		UpdateSceneFrame();

		_tracks.Update( Session.TrackList.VisibleTracks );

		Update();
	}

	private TimelineTrack AddTrack( TrackView source )
	{
		var item = new TimelineTrack( this, source );

		Add( item );

		return item;
	}

	private void RemoveTrack( TimelineTrack item ) => item.Destroy();
	private bool UpdateTrack( TrackView source, TimelineTrack item )
	{
		item.UpdateLayout();

		return true;
	}

	protected override void OnWheel( WheelEvent e )
	{
		base.OnWheel( e );

		Session.EditMode?.MouseWheel( e );

		if ( e.Accepted ) return;

		// scoll
		if ( e.HasShift )
		{
			Session.ScrollBy( -e.Delta / 10.0f * (Session.PixelsPerSecond / 10.0f), true );
			e.Accept();
			return;
		}

		// zoom
		if ( e.HasCtrl )
		{
			Session.Zoom( e.Delta / 10.0f, _lastMouseTime );
			e.Accept();
			return;
		}

		// scrub
		if ( e.HasAlt )
		{
			var dt = MovieTime.FromFrames( 1, Session.FrameRate );
			var nextTime = Session.PlayheadTime.Round( dt ) + Math.Sign( e.Delta ) * dt;

			Session.PlayheadTime = nextTime;
			e.Accept();
			return;
		}

		Session.TrackListScrollPosition -= e.Delta / 5f;
		e.Accept();
	}

	private Vector2 _lastMouseLocalPos;
	private MovieTime _lastMouseTime;

	protected override void OnMouseMove( MouseEvent e )
	{
		base.OnMouseMove( e );

		var delta = e.LocalPosition - _lastMouseLocalPos;
		var scenePos = ToScene( e.LocalPosition );

		if ( e.HasShift )
		{
			UpdatePreviewTime( scenePos );
		}

		if ( e.ButtonState == MouseButtons.Left && IsDragging )
		{
			Drag( ToScene( e.LocalPosition ) );
			e.Accepted = true;
			return;
		}

		if ( e.ButtonState == 0 && !e.HasCtrl && GetItemAt( scenePos ) is { Selectable: true } item )
		{
			UpdateCursor( scenePos, item );
			return;
		}

		if ( e.ButtonState == MouseButtons.Middle )
		{
			Session.ScrollBy( delta.x, false );
		}

		if ( e.ButtonState == MouseButtons.Right )
		{
			ScrubBarTop.Scrub( e.KeyboardModifiers, scenePos );
		}

		_lastMouseLocalPos = e.LocalPosition;
		_lastMouseTime = Session.PixelsToTime( ToScene( e.LocalPosition ).x );

		Session.EditMode?.MouseMove( e );
	}

	public void UpdatePreviewTime( Vector2 scenePos )
	{
		Session.PreviewTime = Application.MouseButtons != 0
			? Session.ScenePositionToTime( scenePos )
			: Session.PixelsToTime( scenePos.x );
	}

	public new GraphicsItem? GetItemAt( Vector2 scenePosition )
	{
		// TODO: Is there a nicer way?

		var oldGridZIndex = _gridItem.ZIndex;

		_gridItem.ZIndex = -1000;

		var item = base.GetItemAt( scenePosition );

		_gridItem.ZIndex = oldGridZIndex;

		return item;
	}

	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );

		DragType = DragTypes.None;

		if ( e.ButtonState == MouseButtons.Middle )
		{
			e.Accepted = true;
			return;
		}

		var scenePos = ToScene( e.LocalPosition );

		if ( GetItemAt( scenePos ) is { Selectable: true } item )
		{
			if ( e.LeftMouseButton && !e.HasCtrl )
			{
				StartDragging( scenePos, item );
			}

			return;
		}

		Session.EditMode?.MousePress( e );

		if ( e.Accepted ) return;

		if ( e.LeftMouseButton )
		{
			DragType = DragTypes.SelectionRect;
			return;
		}

		if ( e.RightMouseButton )
		{
			var time = Session.ScenePositionToTime( ToScene( e.LocalPosition ), SnapFlag.Playhead );

			ScrubBarTop.StartScrubbing( time, e.KeyboardModifiers );
			Session.PlayheadTime = time;
			return;
		}
	}

	protected override void OnMouseReleased( MouseEvent e )
	{
		base.OnMouseReleased( e );

		ScrubBarTop.StopScrubbing();

		if ( IsDragging )
		{
			StopDragging();
			return;
		}

		Session.EditMode?.MouseRelease( e );
	}

	public void DeselectAll()
	{
		Session.TrackList.DeselectAll();

		foreach ( var item in SelectedItems.ToArray() )
		{
			if ( !item.IsValid() ) continue;

			item.Selected = false;
		}
	}

	protected override void OnKeyPress( KeyEvent e )
	{
		base.OnKeyPress( e );

		Session.EditMode?.KeyPress( e );

		if ( e.Accepted ) return;

		if ( e.Key == KeyCode.Shift )
		{
			e.Accepted = true;
			Session.PreviewTime = Session.ScenePositionToTime( ToScene( _lastMouseLocalPos ) );
		}
	}

	protected override void OnKeyRelease( KeyEvent e )
	{
		base.OnKeyRelease( e );

		Session.EditMode?.KeyRelease( e );
	}

	private MovieResource? GetDraggedClip( DragData data )
	{
		if ( data.Assets.FirstOrDefault( x => x.AssetPath?.EndsWith( ".movie" ) ?? false ) is not { } assetData )
		{
			return null;
		}

		var assetTask = assetData.GetAssetAsync();

		if ( !assetTask.IsCompleted ) return null;
		if ( assetTask.Result?.LoadResource<MovieResource>() is not { } resource ) return null;

		if ( !Session.CanReferenceMovie( resource ) ) return null;

		return resource;
	}

	private ProjectSequenceTrack? _draggedTrack;
	private ProjectSequenceBlock? _draggedBlock;
	private readonly HashSet<ITrackBlock> _draggedBlocks = new();

	public override void OnDragHover( DragEvent ev )
	{
		if ( _draggedBlock is null || _draggedTrack is null )
		{
			if ( GetDraggedClip( ev.Data ) is not { } resource )
			{
				ev.Action = DropAction.Ignore;
				return;
			}

			var clip = resource.GetCompiled();

			_draggedTrack = Session.GetOrCreateTrack( resource );
			_draggedBlock = _draggedTrack.AddBlock( (0d, clip.Duration), default, resource );

			Session.TrackList.Update();
			UpdateTracksIfNeeded();
		}

		_draggedBlocks.Clear();
		_draggedBlocks.Add( _draggedBlock );

		var time = Session.ScenePositionToTime( ToScene( ev.LocalPosition ),
			new SnapOptions( IgnoreBlocks: _draggedBlocks ) );

		_draggedBlock.TimeRange = (time, time + _draggedBlock.TimeRange.Duration);
		_draggedBlock.Transform = new MovieTransform( -time );

		Session.TrackList.Find( _draggedTrack )?.MarkValueChanged();

		Log.Info( time );

		ev.Action = DropAction.Link;
	}

	public override void OnDragLeave()
	{
		base.OnDragLeave();

		if ( _draggedBlock is { } block && _draggedTrack is { } track )
		{
			track.RemoveBlock( block );

			if ( track.IsEmpty )
			{
				track.Remove();
			}

			_draggedTrack = null;
			_draggedBlock = null;
		}
	}

	public override void OnDragDrop( DragEvent ev )
	{
		if ( GetDraggedClip( ev.Data ) is not { } movie )
		{
			return;
		}

		_draggedTrack = null;
		_draggedBlock = null;
	}

	public void GetSnapTimes( ref TimeSnapHelper snap )
	{
		var mouseScenePos = ToScene( _lastMouseLocalPos );

		if ( mouseScenePos.y <= ScrubBarTop.SceneRect.Bottom || mouseScenePos.y >= ScrubBarBottom.SceneRect.Top )
		{
			snap.Add( SnapFlag.MinorTick, snap.Time.Round( Session.MinorTick.Interval ), -2, force: true );
			snap.Add( SnapFlag.MajorTick, snap.Time.Round( Session.MajorTick.Interval ), -1 );
		}

		if ( Session.EditMode?.SourceTimeRange is { } pasteRange )
		{
			snap.Add( SnapFlag.PasteBlock, pasteRange.Start );
			snap.Add( SnapFlag.PasteBlock, pasteRange.End );
		}

		foreach ( var dopeTrack in _tracks )
		{
			if ( dopeTrack.View == snap.Options.IgnoreTrack ) continue;
			if ( dopeTrack.View.IsLocked ) continue;
			if ( mouseScenePos.y < dopeTrack.SceneRect.Top ) continue;
			if ( mouseScenePos.y > dopeTrack.SceneRect.Bottom ) continue;

			foreach ( var block in dopeTrack.View.Blocks )
			{
				if ( snap.Options.IgnoreBlocks?.Contains( block ) is true ) continue;

				snap.Add( SnapFlag.TrackBlock, block.TimeRange.Start );
				snap.Add( SnapFlag.TrackBlock, block.TimeRange.End );
			}
		}
	}
}