Editor/ClickThrough.cs
using System.Collections.Generic;
using System.Linq;

namespace HierarchyExtension;

/// <summary>
/// Works out what a click in the scene view would select if the unselectable objects weren't there. It plays the click
/// again through a gizmo instance of its own, over the scene's own gizmos, with the unselectable objects held out:
/// the engine picks, so whatever it picks normally (render meshes, sprites, handles, a component's hitboxes, selection
/// bases) is picked the same way.
/// <para>
/// Why not stop the engine picking them in the first place: the scene view has no per-object switch. The only objects
/// it skips are ones tagged "hidden" or flagged Hidden, and both stop them drawing or listing too (engine source,
/// <c>GameObject.DrawGizmos</c> and <c>SceneViewportWidget.UpdateHovered</c>, 26.09.29). So the objects are flagged
/// Hidden only while the click is replayed here, and put back straight after, before anything draws or lists them.
/// </para>
/// </summary>
public static class ClickThrough
{
	/// <summary>A change a click makes to the selection, in the order the click makes them.</summary>
	public enum Change { Clear, Add, Remove }

	/// <summary>
	/// The selection changes a click with <paramref name="input"/> would make in <paramref name="scene"/> if the
	/// unselectable objects weren't there, in order. Empty when it would hit nothing. The selection isn't touched:
	/// the click reads <paramref name="selection"/>, to know what's selected, and only records what it would do.
	/// Also used by <see cref="ScenePicking"/>; call it outside any gizmo drawing.
	/// </summary>
	public static List<(Change Change, object Target)> Run( Scene scene, Gizmo.SceneSettings settings, Gizmo.Inputs input, SelectionSystem selection )
	{
		return Run( scene, settings, input, selection, scene.GetAllObjects( true ).Where( Unselectable.IsSet ).ToArray() );
	}

	/// <summary>The same with <paramref name="heldOut"/>, and everything under them, held out.</summary>
	public static List<(Change Change, object Target)> Run( Scene scene, Gizmo.SceneSettings settings, Gizmo.Inputs input, SelectionSystem selection, IReadOnlyList<GameObject> heldOut )
	{
		var recorder = new Recorder( selection );
		if ( !scene.IsValid() ) return recorder.Changes;

		// a GameObject flagged Hidden draws no gizmos, and nor do its children
		var flagged = new List<GameObject>();
		foreach ( var go in heldOut )
		{
			if ( !go.IsValid() || go.Flags.Contains( GameObjectFlags.Hidden ) ) continue;

			go.Flags |= GameObjectFlags.Hidden;
			flagged.Add( go );
		}

		try
		{
			using var instance = new Gizmo.Instance { Settings = settings, Selection = recorder };

			// The gizmo state reads the mouse a frame late: a press is seen the frame after the button goes down, and a
			// click the frame after it comes up, when the object hovered then is the one pressed.
			input.IsHovered = true;
			Frame( instance, scene, input, false, heldOut, draw: false );
			Frame( instance, scene, input, true, heldOut );
			Frame( instance, scene, input, false, heldOut );
			Frame( instance, scene, input, false, heldOut );
		}
		finally
		{
			foreach ( var go in flagged )
			{
				if ( go.IsValid() ) go.Flags &= ~GameObjectFlags.Hidden;
			}
		}

		return recorder.Changes;
	}

	/// <summary>
	/// Plays the button going down at <paramref name="input"/>, nothing held out, and asks <paramref name="ask"/> on the
	/// frame the press is seen, as the scene view's own code would see it (<c>Gizmo.Pressed</c>). For the bridge's
	/// press probe; call it outside any gizmo drawing.
	/// </summary>
	public static T WhilePressed<T>( Scene scene, Gizmo.SceneSettings settings, Gizmo.Inputs input, System.Func<T> ask )
	{
		var none = System.Array.Empty<GameObject>();

		using var instance = new Gizmo.Instance { Settings = settings, Selection = new Recorder( null ) };

		input.IsHovered = true;
		Frame( instance, scene, input, false, none, draw: false );
		Frame( instance, scene, input, true, none );

		input.LeftMouse = true;
		instance.Input = input;

		using var gizmos = instance.Push();
		using var sceneScope = scene.Push();
		return ask();
	}

	static void Frame( Gizmo.Instance instance, Scene scene, Gizmo.Inputs input, bool leftMouse, IReadOnlyList<GameObject> heldOut, bool draw = true )
	{
		input.LeftMouse = leftMouse;
		instance.Input = input;

		using var gizmos = instance.Push();
		using var sceneScope = scene.Push();

		if ( !draw ) return;

		HoverRenderMeshes( scene, heldOut );
		scene.EditorDraw();
	}

	/// <summary>
	/// What the scene view does before the gizmos draw: trace the render meshes under the cursor and hover the object
	/// hit. The stock one is private (<c>SceneViewportWidget.UpdateHovered</c>); this is it, holding out the objects.
	/// </summary>
	static void HoverRenderMeshes( Scene scene, IReadOnlyList<GameObject> heldOut )
	{
		if ( !Gizmo.Settings.Selection ) return;

		var trace = scene.Trace.Ray( Gizmo.CurrentRay, Gizmo.RayDepth )
			.UseRenderMeshes( true )
			.UsePhysicsWorld( false )
			.WithoutTags( "hidden" );

		foreach ( var go in heldOut )
		{
			if ( go.IsValid() ) trace = trace.IgnoreGameObjectHierarchy( go );
		}

		var tr = trace.Run();
		if ( !tr.Hit || !tr.Component.IsValid() ) return;

		using ( Gizmo.ObjectScope( tr.GameObject, tr.GameObject.WorldTransform ) )
		{
			Gizmo.Hitbox.DepthBias = 1;
			Gizmo.Hitbox.TrySetHovered( tr.Distance );
		}
	}

	/// <summary>
	/// The replayed click's selection: it answers what's selected from the real one, and writes down the changes
	/// instead of making them.
	/// </summary>
	sealed class Recorder : SelectionSystem
	{
		readonly SelectionSystem _selection;

		public readonly List<(Change Change, object Target)> Changes = new();

		public Recorder( SelectionSystem selection )
		{
			_selection = selection;
		}

		public override bool Contains( object obj ) => _selection?.Contains( obj ) ?? false;

		public override bool Any() => _selection?.Any() ?? false;

		public override void Clear() => Changes.Add( (Change.Clear, null) );

		public override bool Add( object obj )
		{
			if ( obj is null ) return false;

			Changes.Add( (Change.Add, obj) );
			return true;
		}

		public override bool Remove( object obj )
		{
			if ( obj is null ) return false;

			Changes.Add( (Change.Remove, obj) );
			return true;
		}

		public override bool Set( object obj )
		{
			Clear();
			return Add( obj );
		}
	}
}