Editor/EffigyEditor/EffigyAnimClipsWindow.cs
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

namespace Marionette.EditorTools;

/// <summary>
/// The clips that will be compiled into the model, and the buttons that add and remove them.
///
/// A DIALOG RATHER THAN A SUBMENU, which was the first shape this took. The list changes while the
/// tool is open, and a menu built once in BuildMenuBar shows whatever was true when the window
/// opened. Rebuilding the menu bar to refresh it is exactly the mistake SetPalette records at the
/// bottom of EffigyWindow — it throws away the Edit and View menus to redraw one entry. A dialog
/// owns its own list and can rebuild it as often as it likes.
///
/// WHAT THIS DOES NOT DO. It does not edit animation. A clip is a `.riganim` authored in
/// Marionette, and this picks one — Effigy owns rigging, Marionette owns motion, and that division
/// is on the record (WHAT-IS-BUILT, "The rig design, as decided"). The two things editable here are
/// the two that belong to the EXPORT rather than to the animation: what the clip is called inside
/// the model, and whether it loops.
/// </summary>
internal sealed class EffigyAnimClipsWindow : Window
{
	private readonly List<EffigyAnimExport.ClipSource> _clips;

	private Widget _list;
	private Editor.Label _empty;

	public EffigyAnimClipsWindow( Widget owner, List<EffigyAnimExport.ClipSource> clips )
	{
		_clips = clips;

		// Owned by Effigy, for the reason EffigySettingsWindow spells out: parenting a dialog to a
		// different top-level window hands focus to that window's group and drops Effigy behind it.
		Parent = owner;

		WindowFlags = WindowFlags.Dialog | WindowFlags.Customized | WindowFlags.CloseButton
			| WindowFlags.WindowSystemMenuHint | WindowFlags.WindowTitle;

		WindowTitle = "Animation Clips";
		Size = new Vector2( 460, 380 );

		SetWindowIcon( "movie" );

		Build();
	}

	private void Build()
	{
		var canvas = new Widget( this ) { Layout = Layout.Column() };
		canvas.Layout.Margin = 12;
		canvas.Layout.Spacing = 8;

		var intro = new Editor.Label(
			"Clips compiled into the model by File → Compile .vmdl. Each one is a .riganim "
			+ "posed in Marionette against this model's own rig." )
		{
			WordWrap = true,
		};

		canvas.Layout.Add( intro );

		var scroll = canvas.Layout.Add( new ScrollArea( canvas ), 1 );
		scroll.VerticalScrollbarMode = ScrollbarMode.Auto;
		scroll.HorizontalScrollbarMode = ScrollbarMode.Off;

		_list = new Widget( canvas ) { Layout = Layout.Column() };
		_list.Layout.Margin = 2;
		_list.Layout.Spacing = 6;
		scroll.Canvas = _list;

		var buttons = canvas.Layout.AddRow();
		buttons.Spacing = 6;
		buttons.Add( new Button( "Add Clip...", "add" ) { Clicked = OpenPicker } );
		buttons.AddStretchCell();
		buttons.Add( new Button( "Close" ) { Clicked = Close } );

		Canvas = canvas;

		Rebuild();
	}

	/// <summary>
	/// The rows, thrown away and rebuilt.
	///
	/// Cheap enough at this size, and it is the version that cannot go stale — a row holds a
	/// reference to its ClipSource, so a partial update would have to track which row was which
	/// after a removal reindexed everything.
	/// </summary>
	private void Rebuild()
	{
		_list.Layout.Clear( true );

		if ( _clips.Count == 0 )
		{
			_empty = new Editor.Label(
				"No clips. The model still compiles — it just has no animation in it." )
			{
				WordWrap = true,
			};

			_list.Layout.Add( _empty );
			_list.Layout.AddStretchCell();

			return;
		}

		foreach ( var clip in _clips.ToList() )
			AddRow( clip );

		_list.Layout.AddStretchCell();
	}

	private void AddRow( EffigyAnimExport.ClipSource clip )
	{
		var row = new Widget( _list ) { Layout = Layout.Column() };
		row.Layout.Spacing = 4;

		var top = row.Layout.AddRow();
		top.Spacing = 6;

		var name = new LineEdit( clip.Name, row )
		{
			ToolTip = "The name this clip has INSIDE the model — what AnimGraph and "
				+ "SetAnimParameter will ask for. It does not have to match the file name.",
		};

		// TextEdited rather than EditingFinished: the dialog can be closed with the caret still in
		// the box, and a name typed but not committed would export under the old one.
		name.TextEdited += text => clip.Name = text;

		top.Add( name, 1 );

		var remove = new Button( "", "delete", row )
		{
			FixedWidth = 28,
			ToolTip = "Take this clip out of the export. The .riganim file is left alone.",
			Clicked = () =>
			{
				_clips.Remove( clip );
				Rebuild();
			},
		};

		top.Add( remove );

		var bottom = row.Layout.AddRow();
		bottom.Spacing = 6;

		var path = new Editor.Label( clip.Asset?.Path ?? "(missing)" )
		{
			ToolTip = clip.Asset?.Path,
		};

		bottom.Add( path, 1 );
		bottom.Add( new Editor.Label( "Looping" ) );

		var looping = new EffigyToggleSwitch( row, clip.Looping );
		looping.ValueChanged = value => clip.Looping = value;

		bottom.Add( looping );

		_list.Layout.Add( row );
	}

	/// <summary>
	/// Same picker Marionette opens its own clips with, filtered to the same resource type.
	/// </summary>
	private void OpenPicker()
	{
		var picker = AssetPicker.Create( this, AssetType.FromType( typeof( RigAnimDocument ) ),
			new AssetPicker.PickerOptions() );

		picker.Title = "Add Animation Clip";

		picker.OnAssetPicked = assets =>
		{
			foreach ( var asset in assets ?? Enumerable.Empty<Asset>() )
			{
				if ( asset is null )
					continue;

				// Adding the same file twice would write one .dmx and list it twice, so the model
				// would carry two identical animations under two names.
				if ( _clips.Any( c => c.Asset?.Path == asset.Path ) )
					continue;

				_clips.Add( new EffigyAnimExport.ClipSource
				{
					Asset = asset,
					Name = UniqueName( EffigyAnimExport.ClipSource.NameOf( asset ) ),
				} );
			}

			Rebuild();
		};

		picker.Show();
	}

	/// <summary>
	/// A name no other queued clip is using. Two clips called the same thing is a warning at export
	/// time and a dropped clip; catching it here means the list never contains the problem.
	/// </summary>
	private string UniqueName( string wanted )
	{
		if ( !_clips.Any( c => string.Equals( c.Name, wanted, StringComparison.OrdinalIgnoreCase ) ) )
			return wanted;

		for ( var n = 2; n < 1000; n++ )
		{
			var candidate = $"{wanted}_{n}";

			if ( !_clips.Any( c => string.Equals( c.Name, candidate, StringComparison.OrdinalIgnoreCase ) ) )
				return candidate;
		}

		return wanted;
	}
}