Editor/MovieMaker/Editor/MovieEditor.cs
using System.Collections.Immutable;
using Sandbox.MovieMaker;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;

namespace Editor.MovieMaker;

#nullable enable

public partial class MovieEditor : Widget, IHotloadManaged
{
	private static readonly ConditionalWeakTable<MovieEditor, object?> _editors = new();

	public static IEnumerable<Session> ActiveSessions => _editors
		.Select( x => x.Key )
		.Where( x => x.IsValid() )
		.Where( x => x.Session is not null )
		.Select( x => x.Session! );

	// We want sessions to survive entering play mode etc., so identify by MoviePlayer component ID
	// and which resource they're editing. A null resource means embedded.

	public readonly record struct SessionKey( Guid PlayerId, string? ResourcePath );

	private readonly Dictionary<SessionKey, Session> _sessions;

	public Session? Session { get; private set; }

	public ListPanel? ListPanel { get; private set; }
	public TimelinePanel? TimelinePanel { get; private set; }
	public HistoryPanel? HistoryPanel { get; private set; }

	public bool ShowHistory
	{
		get => HistoryPanel is { Visible: true };
		set
		{
			if ( HistoryPanel is not { } history ) return;

			EditorCookie.Set( "moviemaker.showhistory", value );
			history.Visible = value;
		}
	}

	public MovieEditor( Widget parent, IReadOnlyDictionary<SessionKey, Session>? sessions = null ) : base( parent )
	{
		_editors.Add( this, null );
		_sessions = sessions?.ToDictionary() ?? new Dictionary<SessionKey, Session>();

		Layout = Layout.Column();
		FocusMode = FocusMode.TabOrClickOrWheel;

		MinimumSize = new Vector2( 800, 300 );

		UpdateEditorContext();

		if ( Session is null )
		{
			CreateStartupHelper();
		}

		EditorUtility.OnInspect += EditorUtility_OnInspect;
	}

	internal IReadOnlyDictionary<SessionKey, Session> Sessions => _sessions.ToImmutableDictionary();

	public override void OnDestroyed()
	{
		Session?.Deactivate();
		Session = null;

		_editors.Remove( this );

		EditorUtility.OnInspect -= EditorUtility_OnInspect;

		base.OnDestroyed();
	}

	private void EditorUtility_OnInspect( EditorUtility.OnInspectArgs ev )
	{
		if ( GetMoviePlayer( ev.Object ) is { } player && Session?.Player != player )
		{
			Switch( player );
		}
	}

	private MoviePlayer? GetMoviePlayer( object? obj )
	{
		if ( obj is Array { Length: > 0 } array )
		{
			obj = array.GetValue( 0 );
		}

		return (obj as GameObject)?.GetComponent<MoviePlayer>();
	}

	private void Initialize( MoviePlayer player, IMovieResource? resource, SessionContext? context )
	{
		Session?.Deactivate();

		var key = new SessionKey( player.Id, (resource as MovieResource)?.ResourcePath );

		if ( _sessions.TryGetValue( key, out var session ) )
		{
			Session = session;
		}
		else
		{
			Session = _sessions[key] = new Session( resource );
		}

		Session.Initialize( this, player, context );

		Layout.Clear( true );

		var splitter = new Splitter( this );

		Layout.Add( splitter );

		ListPanel = new ListPanel( this, Session );
		TimelinePanel = new TimelinePanel( this, Session );
		HistoryPanel = new HistoryPanel( this, Session );

		splitter.AddWidget( ListPanel );
		splitter.AddWidget( TimelinePanel );
		splitter.AddWidget( HistoryPanel );

		splitter.SetCollapsible( 0, false );
		splitter.SetStretch( 0, 1 );
		splitter.SetCollapsible( 1, false );
		splitter.SetStretch( 1, 3 );

		HistoryPanel.Visible = EditorCookie.Get( "moviemaker.showhistory", false );

		Session.Activate();
	}

	public void CloseSession()
	{
		Layout.Clear( true );

		Session?.Deactivate();
		Session = null;

		ListPanel = null;
		TimelinePanel = null;
		HistoryPanel = null;

		CreateStartupHelper();
	}

	void CreateStartupHelper()
	{
		var row = Layout.AddRow();

		row.AddStretchCell();

		var col = row.AddColumn();
		col.AddStretchCell();

		col.Add( new Label( "Create a Movie Player component to get started. The\nMovie Player is responsible for playing the clip in-game." ) );
		col.AddSpacingCell( 32 );

		var button = col.Add( new Button.Primary( "Create Player Component", "add_circle" ) );

		button.Clicked = CreateNewPlayer;
		button.Enabled = SceneEditorSession.Active is { Scene.IsValid: true };

		col.AddStretchCell();

		row.AddStretchCell();
	}

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

		Session?.Frame();
	}

	[Shortcut( "timeline.playtoggle", "Space", ShortcutType.Window )]
	public void PlayToggle()
	{
		if ( Session is null )
			return;

		Session.IsPlaying = !Session.IsPlaying;
	}

	[Shortcut( "editor.save", "CTRL+S" )]
	public void OnSave()
	{
		Session?.Save();
	}

	[Shortcut( "timeline.selectall", "CTRL+A" )]
	public void OnSelectAll()
	{
		Session?.EditMode?.SelectAll();
	}

	[Shortcut( "timeline.cut", "CTRL+X" )]
	public void OnCut()
	{
		Session?.EditMode?.Cut();
	}

	[Shortcut( "timeline.copy", "CTRL+C" )]
	public void OnCopy()
	{
		Session?.EditMode?.Copy();
	}

	[Shortcut( "timeline.paste", "CTRL+V" )]
	public void OnPaste()
	{
		Session?.EditMode?.Paste();
	}

	[Shortcut( "timeline.backspace", "BACKSPACE" )]
	public void OnBackspace()
	{
		Session?.EditMode?.Backspace();
	}

	[Shortcut( "timeline.delete", "DEL" )]
	public void OnDelete()
	{
		Session?.EditMode?.Delete();
	}

	[Shortcut( "timeline.insert", "TAB" )]
	public void OnInsert()
	{
		Session?.EditMode?.Insert();
	}

	[Shortcut( "editor.undo", "CTRL+Z" )]
	public void OnUndo()
	{
		Session?.Undo();
	}

	[Shortcut( "editor.redo", "CTRL+Y" )]
	public void OnRedo()
	{
		Session?.Redo();
	}

	/// <summary>
	/// Look for any clips we can edit. If the clip we're editing has gone - stop editing it.
	/// </summary>
	void UpdateEditorContext()
	{
		if ( SceneEditorSession.Active?.Scene is not { } scene ) return;

		// The current session exists
		if ( Session is { } session )
		{
			// Whatever we were editing doesn't exist anymore!
			if ( !session.Player.IsValid || session.Player.Scene != scene )
			{
				CloseSession();
			}
		}

		// session is null, lets load the first player
		if ( Session is null )
		{
			if ( scene.GetAllComponents<MoviePlayer>().FirstOrDefault() is { } player )
			{
				Switch( player );
			}
		}
	}

	public void Switch( MoviePlayer player )
	{
		Initialize( player, player.Resource, null );
	}

	public void EnterSequence( MovieResource resource, MovieTransform transform, MovieTimeRange timeRange )
	{
		var timeOffset = transform.Inverse * Session!.TimeOffset;
		var pixelsPerSecond = (float)(transform.Inverse.Scale.FrequencyScale * Session.PixelsPerSecond);

		Initialize( Session.Player, resource, new SessionContext( Session, transform, timeRange ) );

		Session.SetView( timeOffset, pixelsPerSecond );
	}

	public void ExitSequence()
	{
		if ( Session?.Context is not { } context ) return;

		var timeOffset = Session.SequenceTransform * Session!.TimeOffset;
		var pixelsPerSecond = (float)(Session.SequenceTransform.Scale.FrequencyScale * Session.PixelsPerSecond);

		var resource = Session.Resource;

		Session.Save();
		Initialize( Session.Player, context.Parent.Resource, context.Parent.Context );

		Session.SetView( timeOffset, pixelsPerSecond );

		if ( resource is MovieResource movieResource )
		{
			Session.Project.RefreshSequenceTracks( movieResource );
		}
	}

	public void CreateNewPlayer()
	{
		using ( SceneEditorSession.Active.Scene.Push() )
		{
			var go = new GameObject( true, "New Movie Player" );
			go.Components.Create<MoviePlayer>();

			SceneEditorSession.Active.Selection.Set( go );
		}
	}

	public void SwitchToEmbedded()
	{
		if ( Session!.Resource is EmbeddedMovieResource ) return;

		Session.Player.Resource = new EmbeddedMovieResource
		{
			Compiled = Session.Resource.Compiled,
			EditorData = Session.Project.Serialize()
		};

		Switch( Session.Player );
	}

	public void SwitchToNewEmbedded()
	{
		if ( Session is not { } session ) return;

		if ( session is { Resource: EmbeddedMovieResource, Project.IsEmpty: false } )
		{
			Dialog.AskConfirm( ConfirmSwitchToNewEmbedded, question: "The current embedded clip will be lost. Are you sure?" );
			return;
		}

		if ( session is { Resource: MovieResource resource, HasUnsavedChanges: true } )
		{
			Dialog.AskConfirm( () =>
			{
				session.Save();
				ConfirmSwitchToNewEmbedded();
			}, ConfirmSwitchToNewEmbedded, question: $"Save unsaved changes to {resource.ResourceName}.movie?", okay: "Save", cancel: "Don't Save" );
			return;
		}

		ConfirmSwitchToNewEmbedded();
	}

	private void ConfirmSwitchToNewEmbedded()
	{
		if ( Session is not { } session ) return;

		var player = session.Player;

		player.Resource = new EmbeddedMovieResource();

		Switch( player );
	}

	public void SwitchResource( MovieResource resource )
	{
		if ( Session is not { } session ) return;
		if ( session.Root.Resource == resource ) return;

		if ( session is { Resource: EmbeddedMovieResource, Project.IsEmpty: false } )
		{
			Dialog.AskConfirm( () =>
			{
				ConfirmedSwitchResource( resource );
			}, question: "Switching to a clip resource will cause your embedded clip to be lost. Are you sure?" );
			return;
		}

		if ( session is { Resource: MovieResource unsaved, HasUnsavedChanges: true } )
		{
			Dialog.AskConfirm( () =>
				{
					session.Save();
					ConfirmedSwitchResource( resource );
				}, () => ConfirmedSwitchResource( resource ), question: $"Save unsaved changes to {unsaved.ResourceName}.movie?",
				okay: "Save", cancel: "Don't Save" );
			return;
		}

		ConfirmedSwitchResource( resource );
	}

	private void ConfirmedSwitchResource( MovieResource resource )
	{
		Session!.Player.Resource = resource;

		Switch( Session.Player );
	}

	public void SaveFileAs() => SaveAsDialog( "Save Movie As..",
		() => new MovieResource { Compiled = Session!.Project.Compile(), EditorData = Session.Project.Serialize() },
		ConfirmedSwitchResource );

	public void SaveAsDialog( string title, Func<MovieResource> createResource, Action<MovieResource>? afterSave = null )
	{
		var extension = typeof(MovieResource).GetCustomAttribute<GameResourceAttribute>()!.Extension;

		var fd = new FileDialog( null );
		fd.Title = title;
		fd.Directory = Project.Current.GetAssetsPath();
		fd.DefaultSuffix = $".{extension}";
		fd.SetFindFile();
		fd.SetModeSave();
		fd.SetNameFilter( $"Movie Clip File (*.{extension})" );

		if ( !fd.Execute() )
			return;

		var sceneAsset = AssetSystem.CreateResource( extension, fd.SelectedFile );
		var file = createResource();

		sceneAsset.SaveToDisk( file );

		afterSave?.Invoke( file );
	}
}