Editor/HierarchyExtensionMcpTools.cs
using System;
using Editor.Mcp;

namespace HierarchyExtension;

/// <summary>
/// Editor bridge tools for Hierarchy Extension, so an agent driving the editor can see and set which objects are
/// unselectable in the scene view (<see cref="Unselectable"/>), try a click without making it, and see the Hierarchy's
/// hand column, which the bridge can't otherwise see.
/// </summary>
[McpToolset( "hierarchyextension", "Hierarchy Extension - objects unselectable in the scene view: list, set, try a click through them, draw the Hierarchy's hand column" )]
public static class HierarchyExtensionMcpTools
{
	/// <summary>
	/// The open scene's unselectable objects (each with how many objects under it that makes unselectable too), whether
	/// each viewport of the scene view picks through the click filter, and where the Hierarchy's hand column sits
	/// against the rows in view.
	/// </summary>
	[McpTool.ReadOnly( "hierarchy_unselectable" )]
	public static object List()
	{
		var scene = SceneEditorSession.Active?.Scene ?? throw new Exception( "No scene is open" );

		var view = SceneViewWidget.Current;
		var column = SelectableColumn.Current;
		var rows = column?.Rows().ToList();
		var firstRow = rows?.Count > 0 ? rows[0].Row.Rect : (Rect?)null;

		return new
		{
			Scene = scene.Name,
			Unselectable = scene.GetAllObjects( false ).Where( Unselectable.IsSet ).Select( x => new
			{
				x.Name,
				x.Id,
				Under = x.GetAllObjects( false ).Count() - 1,
			} ),
			Viewports = view?._viewports?.Values.Select( x => new
			{
				x.Id,
				Selection = x.GizmoInstance?.Selection?.GetType().Name,
			} ),
			Hierarchy = SceneTreeWidget.Current?.TreeView?.Selection is { } shown ? new
			{
				Selection = shown.GetType().Name,
				Shows = shown.Select( x => x is GameObject go ? go.Name : x?.ToString() ),
				Selected = SceneEditorSession.Active?.Selection.Select( x => x is GameObject go ? go.Name : x?.ToString() ),
			} : null,
			Column = column is null ? null : new
			{
				Position = column.Position,
				Size = column.Size,
				Visible = column.Visible,
				Tree = column.Tree.Size,
				Canvas = column.Tree.CanvasRect,
				FirstRow = firstRow,
				StockEye = firstRow is { } r ? new Rect( r.Right - 22, r.Top, 18, r.Height ) : (Rect?)null,
				RowsInView = rows.Count,
			},
		};
	}

	/// <summary>
	/// Make objects unselectable in the scene view, or selectable again: one undo step, and the scene counts as changed,
	/// as with the Hierarchy's hands. Objects under an unselectable one stay unselectable until it's made selectable.
	/// </summary>
	/// <param name="ids">Object ids, comma separated, from scene_tree or find_game_objects.</param>
	/// <param name="selectable">True to make them selectable again, false to make them unselectable.</param>
	[McpTool( "hierarchy_set_selectable" )]
	public static object SetSelectable( string ids, bool selectable )
	{
		var objects = Find( ids );
		Unselectable.Set( objects, !selectable );

		return objects.Select( x => new { x.Name, x.Id, Unselectable = Unselectable.Has( x ), Own = Unselectable.IsSet( x ) } );
	}

	/// <summary>
	/// What a click at a world point in the scene view would select with some objects held out, worked out the way the
	/// scene view does it for real (<see cref="ClickThrough"/>). Nothing is clicked: the selection and the scene stay as
	/// they are. Uses the viewport last clicked in, and its camera, so the point should be in its view.
	/// </summary>
	/// <param name="point">The world point to click, "x,y,z".</param>
	/// <param name="holdOut">Ids to hold out instead of the unselectable objects, comma separated. Empty holds out nothing.</param>
	[McpTool.ReadOnly( "hierarchy_click_through" )]
	public static object ClickAt( string point, string holdOut = null )
	{
		var (session, gizmos, input, world) = CursorAt( point );

		IReadOnlyList<GameObject> heldOut = holdOut is null
			? session.Scene.GetAllObjects( true ).Where( Unselectable.IsSet ).ToArray()
			: Find( holdOut );

		var changes = ClickThrough.Run( session.Scene, gizmos.Settings, input, session.Selection, heldOut );

		return new
		{
			Point = world,
			HeldOut = heldOut.Select( x => x.Name ),
			Changes = changes.Select( x => new
			{
				Change = x.Change.ToString(),
				Target = x.Target is GameObject go ? $"{go.Name} ({go.Id})" : x.Target?.ToString(),
			} ),
		};
	}

	/// <summary>
	/// What a press at a world point in the scene view lands on, as its gizmos see it: whether anything is pressed (which
	/// stops a drag from starting a drag selection), which object it's found on, and whether that object is unselectable,
	/// in which case a drag from there lets go of the press. Nothing is pressed for real. Uses the viewport last clicked
	/// in, and its camera, so the point should be in its view.
	/// </summary>
	/// <param name="point">The world point to press, "x,y,z".</param>
	[McpTool.ReadOnly( "hierarchy_press_probe" )]
	public static object PressProbe( string point )
	{
		var (session, gizmos, input, world) = CursorAt( point );

		var (pressedAnything, pressed) = ClickThrough.WhilePressed( session.Scene, gizmos.Settings, input,
			() => (Gizmo.Pressed.Any, ScenePicking.PressedObject( session.Scene, _ => true )) );

		return new
		{
			Point = world,
			PressedAnything = pressedAnything,
			PressedObject = pressed is null ? null : $"{pressed.Name} ({pressed.Id})",
			Unselectable = pressed is not null && Unselectable.Has( pressed ),
		};
	}

	/// <summary>
	/// What a drag selection between two world points would take, by Hierarchy Extension's rule (fully inside, a prefab
	/// only whole, never what's under a taken object, never unselectable ones), next to what the stock rule takes (any
	/// object whose bounds, with everything under it, the box touches). Selects nothing. Uses the viewport last clicked
	/// in, and its camera, so both points should be in its view.
	/// </summary>
	/// <param name="from">One corner of the box, a world point "x,y,z".</param>
	/// <param name="to">The opposite corner.</param>
	[McpTool.ReadOnly( "hierarchy_box_probe" )]
	public static object BoxProbe( string from, string to )
	{
		var (session, gizmos, _, _) = CursorAt( from );
		var camera = gizmos.Input.Camera;
		var scene = session.Scene;

		var box = Rect.FromPoints( camera.ToScreen( Vector3.Parse( from ) ), camera.ToScreen( Vector3.Parse( to ) ) );
		var frustum = camera.GetFrustum( box );

		var bounds = new Dictionary<GameObject, BBox>();
		BBox BoundsOf( GameObject go )
		{
			if ( !bounds.TryGetValue( go, out var b ) ) bounds[go] = b = go.GetBounds();
			return b;
		}

		// ObjectEditorTool.OnBoxSelect
		var stock = scene.GetAllObjects( true )
			.Where( go => go is not Scene && !go.Tags.Has( "hidden" ) )
			.Where( go => !(BoundsOf( go ).Size.AlmostEqual( 0.0f ) && !go.HasGizmoHandle) && frustum.IsInside( BoundsOf( go ), true ) )
			.Select( x => x.Name );

		return new
		{
			ScreenBox = box,
			Takes = ScenePicking.Take( scene, frustum, ScenePicking.VisibleBounds ).Select( x => x.Name ),
			StockTakes = stock,
		};
	}

	/// <summary>The session, gizmo instance and cursor input for pointing at <paramref name="point"/> in the scene view.</summary>
	static (SceneEditorSession Session, Gizmo.Instance Gizmos, Gizmo.Inputs Input, Vector3 World) CursorAt( string point )
	{
		var view = SceneViewWidget.Current ?? throw new Exception( "No scene view is open" );
		var viewport = view.LastSelectedViewportWidget ?? view._viewports?.Values.FirstOrDefault() ?? throw new Exception( "The scene view has no viewport" );
		var gizmos = viewport.GizmoInstance;
		var camera = gizmos.Input.Camera ?? throw new Exception( "The viewport hasn't drawn yet" );

		var world = Vector3.Parse( point );
		var forward = camera.Rotation.Forward;

		var input = gizmos.Input;
		input.CursorRay = camera.Ortho
			? new Ray( world - forward * Vector3.Dot( world - camera.Position, forward ), forward )
			: new Ray( camera.Position, (world - camera.Position).Normal );
		input.CursorPosition = camera.ToScreen( world );
		input.Modifiers = KeyboardModifiers.None;
		input.LeftMouse = false;

		return (view.Session, gizmos, input, world);
	}

	/// <summary>
	/// Draws the Hierarchy's rows in view into a PNG as the dock paints them (not the expand arrows), with the hand
	/// column over them. The bridge can't see editor windows, so this is how to check the column lines up. The preview
	/// ids draw as if they were unselectable, and the hovered one with its faint hand, without changing anything.
	/// </summary>
	/// <param name="path">Where to write the PNG, an absolute path.</param>
	/// <param name="preview">Ids to draw as unselectable, comma separated, as if their hands had been clicked.</param>
	/// <param name="hovered">An id to draw as the row under the cursor.</param>
	[McpTool.ReadOnly( "hierarchy_render_rows" )]
	public static object RenderRows( string path, string preview = null, string hovered = null )
	{
		var previewed = preview is null ? new HashSet<GameObject>() : Find( preview ).ToHashSet();
		var hoveredObject = hovered is null ? null : Find( hovered ).FirstOrDefault();

		SelectableColumn.Hand HandOf( GameObject go )
		{
			if ( Unselectable.IsSet( go ) || previewed.Contains( go ) ) return SelectableColumn.Hand.Own;

			for ( var o = go.Parent; o.IsValid() && o is not Scene; o = o.Parent )
			{
				if ( Unselectable.IsSet( o ) || previewed.Contains( o ) ) return SelectableColumn.Hand.FromParent;
			}

			return go == hoveredObject ? SelectableColumn.Hand.Offered : SelectableColumn.Hand.None;
		}

		var column = SelectableColumn.Current ?? throw new Exception( "The Hierarchy isn't open" );
		var tree = column.Tree;
		var rows = column.Rows().ToList();
		if ( rows.Count == 0 ) throw new Exception( "No rows in view" );

		var top = rows.Min( x => x.Row.Rect.Top );
		var bottom = rows.Max( x => x.Row.Rect.Bottom );
		var pixmap = new Pixmap( (int)tree.Width, (int)MathF.Ceiling( bottom - top ) );

		using ( Paint.ToPixmap( pixmap ) )
		{
			Paint.Translate( new Vector2( 0, -top ) );

			Paint.ClearPen();
			Paint.SetBrush( Theme.ControlBackground );
			Paint.DrawRect( new Rect( 0, top, tree.Width, bottom - top ) );

			foreach ( var (row, go) in rows )
			{
				if ( row.Object is not TreeNode node ) continue;

				// as TreeView.PaintItem shifts a row by its depth before the node paints it
				var indent = tree.IndentWidth * row.Column + tree.ExpandWidth;
				row.Rect.Left += indent;
				try
				{
					node.OnPaint( row );
				}
				finally
				{
					row.Rect.Left -= indent;
				}

				SelectableColumn.PaintHand( new Rect( column.Position.x, row.Rect.Top, column.Size.x, row.Rect.Height ), HandOf( go ), go.Active );
			}
		}

		pixmap.SavePng( path );

		return new { Path = path, Size = new Vector2( pixmap.Width, pixmap.Height ), Rows = rows.Select( x => x.Object.Name ) };
	}

	static GameObject[] Find( string ids )
	{
		var scene = SceneEditorSession.Active?.Scene ?? throw new Exception( "No scene is open" );

		var found = new List<GameObject>();
		foreach ( var id in (ids ?? "").Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ) )
		{
			var go = Guid.TryParse( id, out var guid ) ? scene.Directory.FindByGuid( guid ) : null;
			found.Add( go ?? throw new Exception( $"No object with id {id}" ) );
		}

		return found.ToArray();
	}
}