using System.Linq;
using Sandbox.MovieMaker;

namespace Editor.MovieMaker;

#nullable enable

public partial class Timeline : GraphicsView, ISnapSource
{
	[FromTheme]
	public static Color PlayheadColor { get; set; } = Theme.Yellow;

	[FromTheme]
	public static Color PreviewColor { get; set; } = Theme.Blue;

	/// <summary>
	/// Color outside the current movie's time range.
	/// </summary>
	[FromTheme]
	public static Color BackgroundColor { get; set; } = Theme.ControlBackground;

	/// <summary>
	/// Color outside the current sequence block's time range.
	/// </summary>
	[FromTheme]
	public static Color OuterColor { get; set; } = Theme.ControlBackground.LerpTo( Theme.WidgetBackground, 0.5f );

	/// <summary>
	/// Color inside the current sequence block's time range.
	/// </summary>
	[FromTheme]
	public static Color InnerColor { get; set; } = Theme.WidgetBackground;

	public const int TrackHeight = 32;
	public const int BlockHeight = 30;
	public const int RootTrackSpacing = 8;

	public static class Colors
	{
		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 TimeCursor _playhead;
	private readonly TimeCursor _preview;

	public ScrubBar ScrubBarTop { get; }
	public ScrubBar 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 ) );
		}
	}

	private readonly SynchronizedSet<(MovieTime Time, Rect SceneRect), GraphicsItem> _snapTargets;

	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 );

		_playhead = new TimeCursor( this, PlayheadColor, true ) { LabelFadeTime = 1f };
		Add( _playhead );

		_preview = new TimeCursor( this, PreviewColor, false );
		Add( _preview );

		ScrubBarTop = new ScrubBar( Session.Editor, true ) { Width = Width };
		Add( ScrubBarTop );
		ScrubBarBottom = new ScrubBar( Session.Editor, false ) { Width = Width };
		Add( ScrubBarBottom );

		_snapTargets = new(
			addFunc: _ =>
			{
				var item = new SnapTargetItem();
				Add( item );
				return item;
			},
			removeAction: item => item.Destroy(),
			updateAction: ( target, item ) =>
			{
				var x = session.TimeToPixels( target.Time );

				item.Position = new Vector2( x, target.SceneRect.Top );
				item.Height = target.SceneRect.Height;

				return true;
			} );

		Session.PlayheadChanged += UpdatePlayheadTime;
		Session.PreviewChanged += UpdatePreviewTime;
		Session.ViewChanged += UpdateView;

		FocusMode = FocusMode.TabOrClickOrWheel;

		AcceptDrops = true;

		var bg = new Pixmap( 8 );
		bg.Clear( BackgroundColor );

		SetBackgroundImage( bg );

		Antialiasing = true;
	}

	public override void OnDestroyed()
	{
		DeleteAllItems();

		Session.PlayheadChanged -= UpdatePlayheadTime;
		Session.PreviewChanged -= UpdatePreviewTime;
		Session.ViewChanged -= UpdateView;
	}

	private int _lastState;
	private int _lastVisibleRectHash;

	[EditorEvent.Frame]
	public void Frame()
	{
		_backgroundItem.Frame();

		ScrubBarTop.Frame();
		ScrubBarBottom.Frame();

		UpdateScrubBars();
		UpdateTracksIfNeeded();

		var visibleRectHash = HashCode.Combine( VisibleRect, Session.PixelsPerSecond );

		if ( visibleRectHash != _lastVisibleRectHash )
		{
			if ( (Application.KeyboardModifiers & KeyboardModifiers.Shift) != 0 && IsFocused )
			{
				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();

		UpdatePlayheadTime( Session.PlayheadTime );
		UpdatePreviewTime( 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, ScrubBar.Height );

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

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

		UpdateScrubBars();
		UpdateTracks();
	}

	private void UpdatePlayheadTime( MovieTime time )
	{
		_playhead.Value = time;
	}

	private void UpdatePreviewTime( MovieTime? time )
	{
		_preview.PrepareGeometryChange();

		if ( time is { } t )
		{
			_preview.Value = t;
		}
		else
		{
			_preview.PrepareGeometryChange();
			_preview.Position = new Vector2( -50000f, 0f );
		}
	}

	void UpdateSceneFrame()
	{
		const float leftMargin = 8f;

		Session.TrackListViewHeight = Height - 64f;

		var x = Session.TimeToPixels( Session.TimeOffset );

		SceneRect = new Rect(
			x - leftMargin,
			Session.TrackListScrollPosition - Session.TrackListScrollOffset,
			Width,
			Height );

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

		_gridItem.PrepareGeometryChange();
		_gridItem.SceneRect = SceneRect with { Left = SceneRect.Left + leftMargin };
		_gridItem.Update();

		UpdatePlayheadTime( Session.PlayheadTime );
		UpdatePreviewTime( 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.ScrollBySmooth( -e.Delta / 10.0f * (Session.PixelsPerSecond / 10.0f) );
			Session.DispatchViewChanged();

			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;
			Session.ScrollToPlayheadTime();

			e.Accept();
			return;
		}

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

	private Vector2 _lastMouseLocalPos;
	private MovieTime _lastMouseTime;

	public Vector2 MouseScenePos => ToScene( _lastMouseLocalPos );

	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.ScrollByImmediate( delta.x );
			Session.DispatchViewChanged();
		}

		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?

		const int zIndexOffset = 100_000;

		var nonSelectables = new HashSet<GraphicsItem>();

		try
		{
			while ( true )
			{
				var item = base.GetItemAt( scenePosition );

				if ( item is { Selectable: true } or null )
				{
					return item;
				}

				// If we found something unselectable, move it backwards

				if ( nonSelectables.Add( item ) )
				{
					item.ZIndex -= zIndexOffset;
				}
				else
				{
					// There was nothing selectable, so we hit something unselectable twice!

					return null;
				}
			}
		}
		finally
		{
			// Move unselectables back to their original z-index.

			foreach ( var item in nonSelectables )
			{
				item.ZIndex += zIndexOffset;
			}
		}
	}

	public void UpdateSnapTargets( IEnumerable<(MovieTime Time, Rect SceneRect)> targets )
	{
		_snapTargets.Update( targets );
	}

	private MovieTime? _contextMenuTime;
	private TimeSince _sinceClick;
	private IMovieItem? _lastClickedItem;

	private const float DoubleClickTime = 0.5f;

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

		DragType = DragTypes.None;
		_contextMenuTime = null;

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

		var scenePos = ToScene( e.LocalPosition );
		var time = Session.ScenePositionToTime( scenePos, new SnapOptions( source => source is not TimeCursor ) );

		var accepted = false;

		// Look for an item we clicked on. If shift is pressed, let us drag a time selection instead

		if ( !e.HasShift && GetItemAt( scenePos ) is { Selectable: true } item and IMovieItem movieItem )
		{
			accepted = true;

			// We're handling selection manually so we can multi-select etc. This means we
			// need to Accept the event so the default selection logic doesn't run.

			// If the item wants to handle the event itself, we don't set Accepted here,
			// and assume it handles the selection logic itself.

			if ( !movieItem.OverridesMouseEvents )
			{
				e.Accepted = true;
			}

			if ( !item.Selected )
			{
				// Ctrl multi-selects, if possible

				foreach ( var selected in SelectedItems.ToArray() )
				{
					if ( selected is not IMovieItem { MultiSelectable: true } )
					{
						selected.Selected = false;
					}
				}

				if ( !movieItem.MultiSelectable || !e.HasCtrl )
				{
					DeselectAll();
				}

				item.Selected = true;

				if ( item is ITrackItem trackItem )
				{
					trackItem.Track.View.Select();
				}
			}

			// Move the playhead to be within whatever we clicked on

			if ( movieItem.MovePlayheadOnSelect || e.RightMouseButton )
			{
				time = time.Clamp( movieItem.TimeRange );
				Session.PlayheadTime = time;
			}

			if ( e.LeftMouseButton )
			{
				// If we've clicked on something draggable, start dragging!

				if ( StartDragging( scenePos, item ) ) return;
			}
		}

		if ( !accepted )
		{
			Session.EditMode?.MousePress( e );
		}

		if ( e.LeftMouseButton )
		{
			if ( accepted ) return;

			DragType = DragTypes.SelectionRect;
			return;
		}

		if ( e.RightMouseButton )
		{
			_contextMenuTime = time;

			if ( accepted ) return;

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

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

		UpdateSnapTargets( [] );

		var scenePos = ToScene( e.LocalPosition );
		var time = Session.ScenePositionToTime( scenePos, new SnapOptions( source => source is not TimeCursor ), showSnap: false );

		// Detect double-click

		if ( e.LeftMouseButton && !e.HasShift && !e.HasCtrl )
		{
			var item = GetItemAt( scenePos ) as IMovieItem;

			if ( item is not null && _sinceClick < DoubleClickTime && item == _lastClickedItem )
			{
				e.Accepted = true;

				item.DoubleClick();

				_sinceClick = float.PositiveInfinity;
				_lastClickedItem = null;
				return;
			}

			_sinceClick = 0f;
			_lastClickedItem = item;
		}

		// Don't open context menu if we right-click + drag

		if ( e.RightMouseButton && time == _contextMenuTime )
		{
			e.Accepted = true;

			OpenContextMenu( scenePos, time );
			return;
		}

		ScrubBarTop.StopScrubbing();

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

		Session.EditMode?.MouseRelease( e );
	}

	public void OpenContextMenu( Vector2 scenePos, MovieTime time )
	{
		var menu = new Menu();
		var timelineTrack = Tracks.FirstOrDefault( x => x.SceneRect.IsInside( scenePos ) );
		var titleLabel = menu.AddHeading( time.ToString() );

		Session.CreateImportMenu( menu, time );

		var ev = new EditMode.ContextMenuEvent( scenePos, time, timelineTrack, menu, titleLabel );

		if ( GetItemAt( scenePos ) is { } item and IMovieContextMenu ctxMenuItem )
		{
			if ( !item.Selected )
			{
				item.Selected = true;
			}

			ctxMenuItem.ShowContextMenu( ev );
		}

		if ( !ev.Accepted )
		{
			Session.EditMode?.ContextMenu( ev );
		}

		menu.OpenAtCursor();
	}

	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 ) );

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

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

		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;
	}

	Rect ISnapSource.SceneSnapBounds => SceneRect;
	IEnumerable<SnapTarget> ISnapSource.GetSnapTargets( MovieTime sourceTime, bool isPrimary )
	{
		yield return MovieTime.Zero;

		if ( !isPrimary || !Session.FrameSnap ) yield break;

		yield return new SnapTarget( sourceTime.Round( MovieTime.FromFrames( 1, Session.FrameRate ) ), -3, false );
	}
}

file sealed class SnapTargetItem : GraphicsItem
{
	public SnapTargetItem()
	{
		Width = 1f;
		ZIndex = 50_000;
	}

	protected override void OnPaint()
	{
		Paint.SetPen( Color.White.WithAlpha( 0.75f ), style: PenStyle.Dash );
		Paint.DrawLine( LocalRect.TopLeft, LocalRect.BottomLeft );
	}
}
