Editor/RigControlEditor/RigObjectsPanel.cs
using Editor;
using Marionette;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

namespace Marionette.Tools;

/// <summary>
/// The objects in the clip that are not the main model, each with its parts folded up underneath.
///
/// WHY IT IS A SECOND TREE AND NOT MORE ROWS IN THE BONE TREE. A bone tree is one skeleton, deep
/// and long; the objects list is several shallow things. Mixed together, an imported door with
/// forty parts buries the arm you are actually posing, and there is no way to get it back. Two
/// trees means either can be collapsed out of the way on its own, which is the only reason a dense
/// scene stays workable.
///
/// THIS IS THE ONLY PLACE OBJECTS ARE MANAGED. They used to be editable through a generic list row
/// in the BonesObject property sheet as well, which opened a floating serialized-list editor over
/// the viewport - a second editing surface for the same thing, and the worst thing on screen.
/// Everything that editor did is here instead: the buttons along the top, a right-click on a row,
/// and the Inspector for the numbers.
///
/// COLLAPSED BY DEFAULT, because the object is what you place and the parts are what you open once
/// it is where it belongs.
///
/// Selection runs both ways: clicking a row selects that thing in the viewport, and picking
/// something in the viewport (or on its timeline lane) marks its row here.
/// </summary>
internal sealed class RigObjectsPanel : Widget
{
	private readonly RigViewport _viewport;
	private readonly TreeView _tree;
	private readonly Editor.Label _empty;
	private readonly Editor.Label _unnamed;
	private readonly IconButton _delete;

	private readonly Dictionary<string, RigObjectNode> _nodes = new();

	private RigAnimDocument _anim;

	/// <summary>The names the tree was last built from. A rebuild clears the tree, which collapses
	/// every object somebody had opened - so it only happens when this changes, not every time a
	/// bone is clicked and the panel above asks for a refresh.</summary>
	private string _shape;

	/// <summary>Set while the tree's selection is being moved to match the viewport, so doing that
	/// cannot bounce back as a click and re-select the thing in the viewport.</summary>
	private bool _syncing;

	/// <summary>Import OBJ lives on the window, which owns the file dialog and the copy beside the
	/// clip. The button here is the same action as File → Import OBJ.</summary>
	public Action ImportRequested { get; set; }

	/// <summary>
	/// Raised after this panel has changed the document, with the undo label.
	///
	/// The panel edits the lists and the window does everything else - rebuilds the viewport's
	/// copies, refreshes the timeline, records the undo step - the same shape every other panel in
	/// this window reports itself with.
	/// </summary>
	public Action<string> Changed { get; set; }

	public RigObjectsPanel( Widget parent, RigViewport viewport ) : base( parent )
	{
		_viewport = viewport;

		Layout = Layout.Column();

		var heading = Layout.AddRow();
		heading.Margin = new Sandbox.UI.Margin( 8, 6, 8, 2 );
		heading.Spacing = 4;
		heading.Add( new Editor.Label( "Objects" ) { ToolTip = "Everything in this clip that is not the main model. Click one to select it; each object and each of its parts keys to the timeline exactly as a bone does. Right-click a row to rename, hide, duplicate or delete it." }, 1 );

		heading.Add( new Button( "Import OBJ", "file_upload" )
		{
			Clicked = () => ImportRequested?.Invoke(),
			ToolTip = "Load a Wavefront OBJ as one object, one part per o/g group in the file - each part moves and keys on its own",
		} );

		heading.Add( new Button( "Add Model", "add" )
		{
			Clicked = () => PickModel( null ),
			ToolTip = "Add a compiled model (.vmdl) as an object. Its bones, if it has any, pose alongside the main model's.",
		} );

		_delete = heading.Add( new IconButton( "delete", () => Delete( _viewport.SelectedReferencePropName ) )
		{
			IconSize = 16,
			Background = Color.Transparent,
			ToolTip = "Delete the selected object, part or prop, and its keyframes. Ctrl+Z brings both back.",
		} );

		_empty = new Editor.Label( "Nothing yet - Import OBJ or Add Model" ) { Enabled = false };
		_empty.ToolTip = "An imported OBJ arrives as one object with a part per o/g group in the file";
		Layout.Add( _empty );

		// Said out loud rather than skipped silently. A nameless object has no track to key under,
		// so it cannot be shown - but "it isn't there" reads as a bug unless something says why.
		_unnamed = new Editor.Label( "" ) { Enabled = false, Visible = false };
		Layout.Add( _unnamed );

		_tree = new TreeView( this );
		_tree.OnSelectionChanged = selected =>
		{
			if ( _syncing )
				return;

			if ( selected?.FirstOrDefault() is RigObjectNode node )
				_viewport.SelectReferenceProp( node.Key );

			UpdateButtons();
		};

		Layout.Add( _tree, 1 );

		Rebuild();
	}

	public void SetDocument( RigAnimDocument anim )
	{
		_anim = anim;

		// A different document - or the same one restored by undo - holds different objects even
		// when the names match, and the rows paint visibility off the objects themselves.
		_shape = null;

		Rebuild();
	}

	public void Rebuild()
	{
		var objects = _anim?.Objects?.Where( o => o is not null && !string.IsNullOrWhiteSpace( o.Name ) ).ToList()
			?? new List<RigObject>();

		// Reference props are listed here too, as objects with no parts. They are posed and keyed
		// through the same selection, so leaving them out of the one list that shows what is in the
		// viewport would only raise the question of where they went.
		var props = _anim?.ReferenceProps?.Where( p => p is not null && !string.IsNullOrWhiteSpace( p.Name ) ).ToList()
			?? new List<ReferenceProp>();

		var unnamed = _anim?.Objects?.Count( o => o is not null && string.IsNullOrWhiteSpace( o.Name ) ) ?? 0;

		_unnamed.Visible = unnamed > 0;
		_unnamed.Text = unnamed == 1
			? "1 object has no name, so it has nothing to key under and is not shown"
			: $"{unnamed} objects have no name, so they have nothing to key under and are not shown";

		_empty.Visible = objects.Count == 0 && props.Count == 0;
		_tree.Visible = !_empty.Visible;

		var shape = string.Join( "\n", objects.Select( o => $"{o.Name}:{string.Join( ",", (o.Parts ?? new()).Select( p => $"{p?.Name}>{p?.ParentPart}" ) )}" ) )
			+ "\n|" + string.Join( "\n", props.Select( p => p.Name ) );

		if ( shape != _shape )
		{
			_shape = shape;
			BuildTree( objects, props );
		}
		else
		{
			// Same rows - only something they paint (visibility, a part count) can have changed.
			_tree.Update();
		}

		ShowSelection( _viewport.SelectedReferencePropName );
	}

	private void BuildTree( List<RigObject> objects, List<ReferenceProp> props )
	{
		_tree.Clear();
		_nodes.Clear();

		foreach ( var owner in objects )
		{
			var node = new RigObjectNode( this, owner.Name, owner.Name, RowKind.Object, null,
				() => owner.Visible, () => owner.Parts?.Count ?? 0 );

			_tree.AddItem( node );
			_nodes[node.Key] = node;

			AddFollowers( owner, node, null );
		}

		foreach ( var prop in props )
		{
			// Two props of the same name share one track and one row - the first one wins, which
			// is also the one the viewport selects.
			if ( _nodes.ContainsKey( prop.Name ) )
				continue;

			var node = new RigObjectNode( this, prop.Name, prop.Name, RowKind.Prop, null, () => prop.Visible, () => 0 );

			_tree.AddItem( node );
			_nodes[node.Key] = node;
		}
	}

	/// <summary>
	/// The parts that follow <paramref name="leader"/> - or only the object, when it is null - as
	/// rows under <paramref name="under"/>, each with its own followers beneath it.
	///
	/// NESTED, so the tree reads as what moves what: the eyes sit under the head. A flat list would
	/// leave following as something you had to remember rather than something you can see.
	/// </summary>
	private void AddFollowers( RigObject owner, RigObjectNode under, RigObjectPart leader )
	{
		foreach ( var part in owner.Parts ?? new List<RigObjectPart>() )
		{
			if ( part is null || string.IsNullOrWhiteSpace( part.Name ) || owner.ParentOf( part ) != leader )
				continue;

			var row = new RigObjectNode( this, RigTrackName.Qualify( owner.Name, part.Name ), part.Name, RowKind.Part, under,
				() => IsShown( owner, part ), () => owner.Parts.Count( p => p is not null && owner.ParentOf( p ) == part ) );

			under.AddItem( row );
			_nodes[row.Key] = row;

			AddFollowers( owner, row, part );
		}
	}

	/// <summary>Showing only when everything it follows is showing too - hiding the head hides the
	/// eyes, and the row should say so.</summary>
	private static bool IsShown( RigObject owner, RigObjectPart part )
	{
		if ( !owner.Visible )
			return false;

		for ( var p = part; p is not null; p = owner.ParentOf( p ) )
		{
			if ( !p.Visible )
				return false;
		}

		return true;
	}

	/// <summary>Marks the row for whatever the viewport has selected - or no row, when that is a
	/// bone or nothing. Everything above a part is opened so the row can actually be seen.</summary>
	public void ShowSelection( string key )
	{
		_syncing = true;

		try
		{
			if ( !string.IsNullOrEmpty( key ) && _nodes.TryGetValue( key, out var node ) )
			{
				for ( var up = node.Owner; up is not null; up = up.Owner )
					_tree.Open( up );

				_tree.SelectItem( node, false, true );
				_tree.ScrollTo( node );
			}
			else
			{
				_tree.SelectItems( Array.Empty<object>(), false, true );
			}
		}
		finally
		{
			_syncing = false;
		}

		UpdateButtons();
	}

	private void UpdateButtons()
	{
		_delete.Enabled = Resolve( _viewport.SelectedReferencePropName ).Exists;
	}

	// --- what a row means ---------------------------------------------------------------------

	private enum RowKind { Object, Part, Prop }

	/// <summary>The thing a track name means in this document. Exactly one of the three is set for
	/// a name that exists; a part carries its object as well, since a part is edited within it.</summary>
	private readonly record struct Target( RigObject Object, RigObjectPart Part, ReferenceProp Prop )
	{
		public bool Exists => Part is not null || Object is not null || Prop is not null;

		public bool Visible => Part?.Visible ?? Object?.Visible ?? Prop?.Visible ?? false;

		public string Kind => Part is not null ? "part" : Object is not null ? "object" : "prop";
	}

	private Target Resolve( string key )
	{
		if ( _anim is null || string.IsNullOrEmpty( key ) )
			return default;

		var (owner, part) = _anim.FindPartTarget( key );

		if ( part is not null )
			return new Target( owner, part, null );

		// FindPartTarget hands back the owner for "door/nonsense" too - only an exact name is the
		// object itself.
		if ( owner is not null && owner.Name == key )
			return new Target( owner, null, null );

		if ( _anim.ReferenceProps?.FirstOrDefault( p => p?.Name == key ) is { } prop )
			return new Target( null, null, prop );

		return default;
	}

	/// <summary>Records the edit through the window, then selects what it produced - after, not
	/// before, because the viewport only knows about a new thing once the window has rebuilt it.</summary>
	private void Commit( string label, string select )
	{
		Changed?.Invoke( label );

		if ( select is not null )
			_viewport.SelectReferenceProp( select );
	}

	// --- the row menu -------------------------------------------------------------------------

	private void OpenMenu( string key )
	{
		var target = Resolve( key );

		if ( !target.Exists )
			return;

		var menu = new Menu( this );

		menu.AddHeading( RigTrackName.Display( key ) );

		menu.AddOption( "Select", "my_location", () => _viewport.SelectReferenceProp( key ) )
			.StatusTip = "Select it in the viewport, with its numbers in the Inspector";

		menu.AddSeparator();

		menu.AddOption( "Rename", "edit", () => BeginRename( key ) )
			.StatusTip = "Its keyframes follow the new name";

		var visible = target.Visible;

		menu.AddOption( visible ? "Hide" : "Show", visible ? "visibility_off" : "visibility", () => SetVisible( key, !visible ) )
			.StatusTip = "Hidden, not deleted - its placement and keyframes stay";

		menu.AddOption( "Duplicate", "content_copy", () => Duplicate( key ) )
			.StatusTip = "A copy in the same place. Keyframes stay with the original.";

		if ( target.Part is not null )
			AddFollowMenu( menu, key, target.Object, target.Part );

		if ( target.Object is not null && target.Part is null )
		{
			menu.AddOption( "Add Part from Model...", "add", () => PickModel( target.Object ) )
				.StatusTip = "Add a compiled model to this object as another part";
		}

		menu.AddSeparator();

		menu.AddOption( "Delete", "delete", () => Delete( key ) )
			.StatusTip = "Remove it and its keyframes. Ctrl+Z brings both back.";

		menu.OpenAtCursor();
	}

	// --- following ----------------------------------------------------------------------------

	/// <summary>Follow → pick it in the viewport, stop following, or choose from the object's other
	/// parts by name. The pick comes first because an import's part names (mesh_6, mesh_13) say
	/// nothing about which is the head; the list is there for parts that have been renamed.</summary>
	private void AddFollowMenu( Menu menu, string key, RigObject owner, RigObjectPart part )
	{
		var leader = owner.ParentOf( part );
		var follow = menu.AddMenu( leader is null ? "Follow" : $"Follow ({leader.Name})", "link" );

		follow.AddOption( "Pick in Viewport...", "ads_click", () => BeginPickLeader( key ) )
			.StatusTip = "Then click the part it should follow - the head, for the eyes";

		var none = follow.AddOption( $"Only {owner.Name}", "link_off", () => SetFollow( key, null ) );
		none.Enabled = leader is not null;
		none.StatusTip = "Stop following another part - it stays where it is";

		var candidates = (owner.Parts ?? new List<RigObjectPart>())
			.Where( p => p is not null && !string.IsNullOrWhiteSpace( p.Name ) && !Follows( owner, p, part ) )
			.ToList();

		if ( candidates.Count > 0 )
			follow.AddSeparator();

		foreach ( var candidate in candidates )
		{
			var option = follow.AddOption( candidate.Name, null, () => SetFollow( key, candidate ) );
			option.Checkable = true;
			option.Checked = candidate == leader;
		}
	}

	/// <summary>Whether <paramref name="candidate"/> is <paramref name="part"/> or follows it,
	/// however far down - either way it cannot be what <paramref name="part"/> follows.</summary>
	private static bool Follows( RigObject owner, RigObjectPart candidate, RigObjectPart part )
	{
		for ( var up = candidate; up is not null; up = owner.ParentOf( up ) )
		{
			if ( up == part )
				return true;
		}

		return false;
	}

	/// <summary>Selects the part, then asks the viewport for the next thing clicked. Clicking the
	/// part's own object means follow only the object.</summary>
	private void BeginPickLeader( string key )
	{
		var target = Resolve( key );

		if ( target.Part is null )
			return;

		_viewport.SelectReferenceProp( key );

		_viewport.PickMovable( $"Click what {target.Part.Name} should follow - click empty space to cancel", picked =>
		{
			// Re-resolved: the document can have been undone under the question while it was open.
			var now = Resolve( key );
			var chosen = Resolve( picked );

			if ( now.Part is null )
				return;

			if ( chosen.Part is null && chosen.Object is not null && chosen.Object == now.Object )
			{
				SetFollow( key, null );
				return;
			}

			if ( chosen.Part is null || chosen.Object != now.Object )
			{
				RigStatusBar.Show( $"{RigTrackName.Display( picked )} is not part of {now.Object.Name} - a part can only follow another part of its own object" );
				return;
			}

			SetFollow( key, chosen.Part );
		} );
	}

	private void SetFollow( string key, RigObjectPart leader )
	{
		var target = Resolve( key );

		if ( target.Part is null )
			return;

		var owner = target.Object;
		var part = target.Part;

		if ( leader is not null && (owner.Parts?.Contains( leader ) != true || Follows( owner, leader, part )) )
		{
			RigStatusBar.Show( leader == part
				? $"{part.Name} cannot follow itself"
				: $"{leader.Name} already follows {part.Name}, so {part.Name} cannot follow it back" );
			return;
		}

		if ( owner.ParentOf( part ) == leader )
			return;

		Rehome( owner, part, leader );

		Commit( leader is null ? $"Unfollow {part.Name}" : $"{part.Name} Follows {leader.Name}", key );

		RigStatusBar.Show( leader is null
			? $"{part.Name} follows only {owner.Name} now"
			: $"{part.Name} follows {leader.Name} - move {leader.Name} and it comes along. It can still be moved on its own." );
	}

	/// <summary>
	/// Makes a part follow <paramref name="leader"/> (null: only its object) WITHOUT MOVING IT.
	///
	/// A part is stored relative to what it follows, so changing that changes what its numbers
	/// mean. Left alone they would now be read against the head instead of the object, and the eyes
	/// would jump somewhere else the instant you told them to follow. So the placement and every
	/// keyframe are re-expressed against the new leader, frame by frame: at each key the part is
	/// exactly where it was. Between keys it now rides along with the head - which is the point.
	///
	/// Each key is converted from its OWN value, not from BoneTrack.Evaluate at its frame - on a
	/// Stepped key Evaluate hands back the previous key's value.
	/// </summary>
	private void Rehome( RigObject owner, RigObjectPart part, RigObjectPart leader )
	{
		var oldLeader = owner.ParentOf( part );

		Transform Into( Transform local, float? frame )
		{
			var inObject = oldLeader is null ? local : _anim.PartInObject( owner, oldLeader, frame ).ToWorld( local );

			return leader is null ? inObject : _anim.PartInObject( owner, leader, frame ).ToLocal( inObject );
		}

		if ( _anim.FindPartTrack( RigTrackName.Qualify( owner.Name, part.Name ) ) is { } track )
		{
			foreach ( var key in track.Keyframes )
				key.Local = Into( key.Local, key.Frame );
		}

		var placed = Into( part.LocalTransform, null );

		part.Position = placed.Position;
		part.Rotation = placed.Rotation.Angles();
		part.Scale = placed.Scale.x;

		part.ParentPart = leader?.Name ?? "";
	}

	/// <summary>Rename in place, the same one-field popup Effigy's bone tree renames with.</summary>
	private void BeginRename( string key )
	{
		var target = Resolve( key );

		if ( !target.Exists )
			return;

		var current = target.Part?.Name ?? target.Object?.Name ?? target.Prop?.Name;

		var menu = new Menu( this );
		var edit = new LineEdit( current, menu ) { FixedWidth = 190 };

		edit.ReturnPressed += () =>
		{
			var wanted = edit.Text;
			menu.Close();
			Rename( key, wanted );
		};

		menu.AddWidget( edit );
		menu.OpenAtCursor();

		edit.Focus();
		edit.SelectAll();
	}

	private void Rename( string key, string wanted )
	{
		var target = Resolve( key );

		if ( !target.Exists )
			return;

		var name = CleanName( wanted );

		if ( string.IsNullOrEmpty( name ) )
			return;

		string newKey;

		if ( target.Part is not null )
		{
			if ( name == target.Part.Name )
				return;

			if ( target.Object.FindPart( name ) is not null )
			{
				RigStatusBar.Show( $"{target.Object.Name} already has a part called {name}" );
				return;
			}

			// Followers name what they follow, so they have to hear about the rename too.
			foreach ( var sibling in target.Object.Parts )
			{
				if ( sibling is not null && sibling.ParentPart == target.Part.Name )
					sibling.ParentPart = name;
			}

			target.Part.Name = name;
			newKey = RigTrackName.Qualify( target.Object.Name, name );
		}
		else
		{
			if ( name == key )
				return;

			if ( NameTaken( _anim, name ) )
			{
				RigStatusBar.Show( $"Something in this clip is already called {name} - names are what keyframes are stored under, so they have to be unique" );
				return;
			}

			if ( target.Object is not null )
				target.Object.Name = name;
			else
				target.Prop.Name = name;

			newKey = name;
		}

		// THE KEYFRAMES GO WITH IT. A track is found by name, so renaming the thing without its
		// tracks would leave the animation on a lane that belongs to nothing - which looks exactly
		// like the rename deleting it.
		RenameTracks( key, newKey );

		Commit( $"Rename {RigTrackName.Display( key )}", newKey );

		// The one thing a rename here cannot reach: a scene that already plays this clip binds its
		// GameObjects by name, in RigAnimPlayerComponent.Parts.
		RigStatusBar.Show( target.Part is null
			? $"Renamed to {name}. A scene playing this clip binds it by name - update RigAnimPlayerComponent's Parts there too."
			: $"Renamed to {name}" );
	}

	/// <summary>
	/// Moves every track a thing owns onto its new name: its own whole-part track, and every track
	/// underneath it - its parts, and the bones of anything skinned inside it.
	///
	/// THE MAIN MODEL'S BONES CANNOT BE CAUGHT BY THIS. Their names are bare and never contain the
	/// separator, and an exact match is only taken on a whole-part track - so an object that happens
	/// to share a bone's name still leaves that bone's track alone.
	/// </summary>
	private void RenameTracks( string oldKey, string newKey )
	{
		var prefix = oldKey + RigTrackName.Separator;

		foreach ( var track in _anim.BoneTracks )
		{
			if ( track.Target == TrackTarget.Part && track.BoneName == oldKey )
				track.BoneName = newKey;
			else if ( track.BoneName?.StartsWith( prefix, StringComparison.Ordinal ) == true )
				track.BoneName = newKey + track.BoneName[oldKey.Length..];
		}
	}

	private int RemoveTracks( string key )
	{
		var prefix = key + RigTrackName.Separator;

		return _anim.BoneTracks.RemoveAll( t =>
			(t.Target == TrackTarget.Part && t.BoneName == key)
			|| t.BoneName?.StartsWith( prefix, StringComparison.Ordinal ) == true );
	}

	private void SetVisible( string key, bool visible )
	{
		var target = Resolve( key );

		if ( !target.Exists )
			return;

		if ( target.Part is not null )
			target.Part.Visible = visible;
		else if ( target.Object is not null )
			target.Object.Visible = visible;
		else
			target.Prop.Visible = visible;

		Commit( $"{(visible ? "Show" : "Hide")} {RigTrackName.Display( key )}", null );
	}

	/// <summary>A copy in the same place, under a name nothing else has. Placement only: two
	/// objects playing the same keyframes is a different request from "another one of these", and
	/// Copy/Paste on the timeline already does it.</summary>
	private void Duplicate( string key )
	{
		var target = Resolve( key );

		if ( !target.Exists )
			return;

		string newKey;

		if ( target.Part is not null )
		{
			var copy = RigSnapshot.Clone( target.Part );
			copy.Name = UniquePartName( target.Object, target.Part.Name );

			target.Object.Parts.Insert( target.Object.Parts.IndexOf( target.Part ) + 1, copy );
			newKey = RigTrackName.Qualify( target.Object.Name, copy.Name );
		}
		else if ( target.Object is not null )
		{
			var copy = RigSnapshot.Clone( target.Object );
			copy.Name = UniqueObjectName( _anim, target.Object.Name );

			_anim.Objects.Insert( _anim.Objects.IndexOf( target.Object ) + 1, copy );
			newKey = copy.Name;
		}
		else
		{
			var copy = RigSnapshot.Clone( target.Prop );
			copy.Name = UniqueObjectName( _anim, target.Prop.Name );

			_anim.ReferenceProps.Insert( _anim.ReferenceProps.IndexOf( target.Prop ) + 1, copy );
			newKey = copy.Name;
		}

		Commit( $"Duplicate {RigTrackName.Display( key )}", newKey );

		RigStatusBar.Show( $"Duplicated as {RigTrackName.Display( newKey )} - in the same place, with no keyframes of its own yet" );
	}

	private void Delete( string key )
	{
		var target = Resolve( key );

		if ( !target.Exists )
			return;

		if ( target.Part is not null )
		{
			// Anything following it moves up to follow what it followed, staying put. Done before
			// the removal, while the chain being converted out of still exists.
			var leader = target.Object.ParentOf( target.Part );

			foreach ( var follower in target.Object.Parts.Where( p => p is not null && target.Object.ParentOf( p ) == target.Part ).ToList() )
				Rehome( target.Object, follower, leader );

			target.Object.Parts.Remove( target.Part );
		}
		else if ( target.Object is not null )
			_anim.Objects.Remove( target.Object );
		else
			_anim.ReferenceProps.Remove( target.Prop );

		// Its keyframes go too. Left behind they are lanes on the timeline driving nothing, and a
		// later object that happened to take the same name would inherit someone else's animation.
		var removed = RemoveTracks( key );

		Commit( $"Delete {RigTrackName.Display( key )}", null );

		RigStatusBar.Show( removed > 0
			? $"Deleted {RigTrackName.Display( key )} and {removed} track{(removed == 1 ? "" : "s")} - Ctrl+Z brings them back"
			: $"Deleted {RigTrackName.Display( key )} - Ctrl+Z brings it back" );
	}

	/// <summary>A compiled model as a new object with one part, or as another part of
	/// <paramref name="into"/>.</summary>
	private void PickModel( RigObject into )
	{
		if ( _anim is null )
			return;

		var picker = AssetPicker.Create( this, AssetType.Model, new AssetPicker.PickerOptions() );
		picker.Title = into is null ? "Add Model" : $"Add Part to {into.Name}";
		picker.OnAssetPicked = assets =>
		{
			if ( assets.FirstOrDefault() is not { } asset )
				return;

			var model = Model.Load( asset.Path );

			if ( model is null || model.IsError )
			{
				RigStatusBar.Show( $"{asset.Name} did not load as a model" );
				return;
			}

			AddModel( model, asset.Name, into );
		};

		picker.Show();
	}

	private void AddModel( Model model, string assetName, RigObject into )
	{
		var stem = CleanName( Path.GetFileNameWithoutExtension( assetName ?? "" ) );

		if ( string.IsNullOrEmpty( stem ) )
			stem = "model";

		// Re-resolved rather than trusted: the picker is modal-less, and the object it was opened
		// for can have been deleted or undone away while it was up.
		if ( into is not null && _anim.Objects?.Contains( into ) != true )
			into = null;

		if ( into is null )
		{
			var owner = new RigObject { Name = UniqueObjectName( _anim, stem ) };
			owner.Parts.Add( new RigObjectPart { Name = stem, Model = model } );

			_anim.Objects ??= new List<RigObject>();
			_anim.Objects.Add( owner );

			Commit( $"Add {owner.Name}", owner.Name );
			RigStatusBar.Show( $"Added {owner.Name} - drag it in the viewport, then press K to key it" );
			return;
		}

		into.Parts ??= new List<RigObjectPart>();

		var part = new RigObjectPart { Name = UniquePartName( into, stem ), Model = model };
		into.Parts.Add( part );

		var key = RigTrackName.Qualify( into.Name, part.Name );

		Commit( $"Add {RigTrackName.Display( key )}", key );
	}

	// --- names --------------------------------------------------------------------------------

	/// <summary>Trimmed, with the separator taken out - a slash in a name would make it read as an
	/// object and a part, and its track would be found under something else.</summary>
	private static string CleanName( string wanted ) =>
		(wanted ?? "").Trim().Replace( RigTrackName.Separator, '_' );

	/// <summary>Whether an object or a reference prop already has this name. They share one track
	/// namespace, so they share one set of names.</summary>
	private static bool NameTaken( RigAnimDocument anim, string name ) =>
		(anim.Objects?.Any( o => o?.Name == name ) ?? false)
		|| (anim.ReferenceProps?.Any( p => p?.Name == name ) ?? false);

	/// <summary>An object name nothing else is using - including the reference props, which share
	/// the same track namespace. Names are the identity a track is stored under, so two objects
	/// called "door" would share one track and move as one.</summary>
	public static string UniqueObjectName( RigAnimDocument anim, string wanted )
	{
		var taken = new HashSet<string>();

		foreach ( var existing in anim.Objects ?? new List<RigObject>() )
		{
			if ( existing is not null )
				taken.Add( existing.Name );
		}

		foreach ( var prop in anim.ReferenceProps ?? new List<ReferenceProp>() )
		{
			if ( prop is not null )
				taken.Add( prop.Name );
		}

		return Unique( wanted, taken );
	}

	private static string UniquePartName( RigObject owner, string wanted ) =>
		Unique( wanted, (owner.Parts ?? new List<RigObjectPart>()).Where( p => p is not null ).Select( p => p.Name ).ToHashSet() );

	/// <summary>A name not in <paramref name="taken"/>, recorded there so the next call cannot
	/// hand out the same one.</summary>
	public static string Unique( string wanted, HashSet<string> taken )
	{
		var name = string.IsNullOrWhiteSpace( wanted ) ? "part" : wanted.Trim();

		if ( taken.Add( name ) )
			return name;

		for ( var n = 2; ; n++ )
		{
			var candidate = $"{name}_{n}";

			if ( taken.Add( candidate ) )
				return candidate;
		}
	}

	/// <summary>One row: an object, a part of one, or a reference prop. Carries the track name
	/// rather than the label, since that is what selection, keying and the timeline all key off.</summary>
	private sealed class RigObjectNode : TreeNode<string>
	{
		private readonly RigObjectsPanel _panel;
		private readonly string _label;
		private readonly RowKind _kind;
		private readonly Func<bool> _visible;
		private readonly Func<int> _parts;

		public string Key => Value;

		/// <summary>The object row a part row sits under, so selecting the part from the viewport
		/// can open it.</summary>
		public RigObjectNode Owner { get; }

		public RigObjectNode( RigObjectsPanel panel, string key, string label, RowKind kind, RigObjectNode owner,
			Func<bool> visible, Func<int> parts ) : base( key )
		{
			_panel = panel;
			_label = label;
			_kind = kind;
			_visible = visible;
			_parts = parts;
			Owner = owner;
		}

		public override void OnActivated() => _panel.BeginRename( Key );

		public override bool OnContextMenu()
		{
			_panel.OpenMenu( Key );
			return true;
		}

		public override void OnPaint( VirtualWidget item )
		{
			PaintSelection( item );

			// Hidden rows are dimmed rather than removed - hidden is a state you come back from,
			// and the row is how you come back.
			var visible = _visible();
			var alpha = visible ? 1f : 0.4f;

			// Green for objects, matching their handles in the viewport; parts dimmer and marked
			// with a smaller glyph, so the hierarchy reads without having to expand it.
			var (icon, size, tint) = _kind switch
			{
				RowKind.Part => ("chevron_right", 11, Theme.Green.WithAlpha( 0.6f )),
				RowKind.Prop => ("view_in_ar", 13, Theme.Green.WithAlpha( 0.8f )),
				_ => ("widgets", 13, Theme.Green),
			};

			Paint.SetPen( tint.WithAlpha( tint.a * alpha ) );
			Paint.DrawIcon( item.Rect, icon, size, TextFlag.LeftCenter );

			Paint.SetPen( Theme.Text.WithAlpha( alpha ) );
			Paint.DrawText( item.Rect.Shrink( 20, 0, 40, 0 ), _label, TextFlag.LeftCenter );

			var right = item.Rect.Shrink( 0, 0, 8, 0 );

			if ( !visible )
			{
				Paint.SetPen( Theme.TextControl.WithAlpha( 0.5f ) );
				Paint.DrawIcon( right, "visibility_off", 12, TextFlag.RightCenter );
				right = right.Shrink( 0, 0, 16, 0 );
			}

			var parts = _parts();

			if ( parts <= 0 )
				return;

			// How many parts are folded away in here. Without it a collapsed object with forty
			// parts looks exactly like one with none.
			Paint.SetPen( Theme.TextControl.WithAlpha( 0.5f ) );
			Paint.DrawText( right, $"{parts}", TextFlag.RightCenter );
		}
	}
}