Editor/PropertyFinderWindow.cs
using System;
using Editor;
using Sandbox;
using System.Collections.Generic;
using System.Linq;

namespace PropertyFinder;

/// <summary>
/// Searches the selected objects' component properties by name and shows only the matches,
/// editable in place. The stock inspector's filter is private, so this is its own window:
/// frameless, opened with CTRL+F, hidden when focus or a click goes elsewhere in the editor.
/// </summary>
public sealed class PropertyFinderWindow : Widget
{
	private const int MaxObjects = 16;
	private const float Grip = 14;
	private const string PositionCookie = "PropertyFinder.Position";
	private const string SizeCookie = "PropertyFinder.Size";

	private static PropertyFinderWindow instance;

	// Frames left in which to (re)claim keyboard focus after opening.
	private static int pendingFocus;

	private readonly LineEdit search;
	private readonly Label status;
	private readonly ScrollArea scroll;
	private int lastHash;

	// Hiding on focus loss only arms once focus has actually reached the window, otherwise
	// the viewport still holding focus in the frames after CTRL+F would hide it at once.
	private bool armed;
	private bool mouseWasDown;

	private bool dragging;
	private bool resizing;
	private Vector2 grabOffset;

	// Stored whole-component, like the stock inspector, so undo survives a later delete.
	private IDisposable undoScope;

	private PropertyFinderWindow( Widget parent ) : base( parent )
	{
		WindowFlags = WindowFlags.Tool | WindowFlags.FramelessWindowHint;
		NoSystemBackground = true;
		TranslucentBackground = true;
		MinimumSize = new Vector2( 260, 120 );

		Layout = Layout.Column();
		Layout.Margin = 10;
		Layout.Spacing = 6;

		search = Layout.Add( new LineEdit( this ) );
		search.PlaceholderText = "Search properties on the selection…";
		search.TextEdited += _ => Rebuild();

		status = Layout.Add( new Label( "", this ) );
		// Let presses fall through so the status line doubles as a drag handle.
		status.TransparentForMouseEvents = true;

		scroll = Layout.Add( new ScrollArea( this ), 1 );
		scroll.Canvas = new Widget( scroll );
		scroll.Canvas.Layout = Layout.Column();
		scroll.Canvas.Layout.Spacing = 4;

		Size = EditorCookie.Get( SizeCookie, new Vector2( 380, 460 ) );
		var fallback = EditorWindow.ScreenRect.Center - Size * 0.5f;
		Position = EditorCookie.Get( PositionCookie, fallback );

		Rebuild();
	}

	// The mesh Edge/Vertex tools also bind CTRL+F (weld UVs) inside the scene view.
	[Menu( "Editor", "Edit/Property Finder" )]
	[Shortcut( "propertyfinder.open", "CTRL+F" )]
	public static void Open()
	{
		if ( instance is null || !instance.IsValid() )
			instance = new PropertyFinderWindow( EditorWindow );

		instance.ApplyStyle();
		instance.armed = false;
		instance.mouseWasDown = true; // ignore the click that may have opened it (menu)
		instance.Show();
		instance.Raise();
		// Focusing inside the key press doesn't stick: activation lands afterwards and takes
		// focus back. Claim it over the next few frames instead.
		pendingFocus = 5;
		instance.Rebuild();
	}

	private void Dismiss()
	{
		dragging = resizing = false;
		StoreGeometry();
		Hide();
	}

	private void StoreGeometry()
	{
		EditorCookie.Set( PositionCookie, Position );
		EditorCookie.Set( SizeCookie, Size );
	}

	[EditorEvent.Frame]
	private void Frame()
	{
		if ( !ReferenceEquals( instance, this ) || !Visible ) return;

		if ( pendingFocus > 0 )
		{
			pendingFocus--;
			// Select only when (re)gaining focus, so text typed during the retries is never selected.
			if ( !search.IsFocused )
			{
				search.Focus( true );
				search.SelectAll();
			}
		}

		var focus = Editor.Application.FocusWidget;
		var focusInside = Contains( focus );
		if ( focusInside ) armed = true;

		// Focus moved to another part of the editor (inspector, hierarchy, 3D view).
		// Focus in a separate window (a colour picker or asset picker opened from a row) is ignored.
		if ( armed && focus.IsValid() && !focusInside && IsInMainWindow( focus ) )
		{
			Dismiss();
			return;
		}

		// Clicking the 3D view doesn't always take keyboard focus, so a fresh press outside
		// the window counts too, unless one of the editor's sticky popups is open.
		var mouseDown = Editor.Application.MouseButtons != MouseButtons.None;
		var pressed = mouseDown && !mouseWasDown;
		mouseWasDown = mouseDown;
		// A row's right-click menu can hang past the window edge; clicking it is not "outside".
		if ( pressed && !ScreenRect.IsInside( Editor.Application.CursorPosition ) && StickyPopup.All.Count == 0 && !OverOwnPopup() )
		{
			Dismiss();
			return;
		}

		ExtendContextMenus();

		// Rebuild only when the query, selection or component lists change. Rebuilding every
		// frame would destroy the control being edited and drop its focus mid-drag.
		if ( BuildHash() != lastHash ) Rebuild();
	}

	// The window lives for the whole editor session (hidden, never destroyed), so anything set
	// only in the constructor misses hot-reloaded changes. Styling is re-applied here instead.
	private void ApplyStyle()
	{
		search.SetStyles( $"background-color: {Theme.ControlBackground.Darken( 0.4f ).Hex};" );
	}

	[EditorEvent.Hotload]
	private void Hotload()
	{
		ApplyStyle();
		Rebuild();
	}

	private bool Contains( Widget widget )
	{
		// Walk parents by hand: popups parented to a row are separate windows, and still ours.
		for ( var w = widget; w is not null; w = w.Parent )
			if ( ReferenceEquals( w, this ) ) return true;
		return false;
	}

	private static bool IsInMainWindow( Widget widget )
	{
		var root = widget;
		while ( root.Parent is not null ) root = root.Parent;
		return ReferenceEquals( root, EditorWindow );
	}

	protected override void OnKeyPress( KeyEvent e )
	{
		if ( e.Key == KeyCode.Escape )
		{
			Dismiss();
			e.Accepted = true;
			return;
		}
		base.OnKeyPress( e );
	}

	// No title bar: drag from the margins or status line, resize from the bottom-right corner.
	private bool InGrip( Vector2 local ) => local.x > Width - Grip && local.y > Height - Grip;

	// Controls like dropdowns let their press fall through to this window. Only the bare
	// margins and the status line may start a drag, never the search box or results.
	private bool OnFreeArea( Vector2 local )
	{
		foreach ( var child in new Widget[] { search, scroll } )
			if ( new Rect( child.Position, child.Size ).IsInside( local ) ) return false;
		return true;
	}

	private static bool LeftHeld => Editor.Application.MouseButtons.HasFlag( MouseButtons.Left );

	protected override void OnMousePress( MouseEvent e )
	{
		if ( !e.LeftMouseButton ) return;
		resizing = InGrip( e.LocalPosition );
		dragging = !resizing && OnFreeArea( e.LocalPosition );
		if ( !dragging && !resizing ) return;
		grabOffset = resizing ? Size - e.LocalPosition : e.LocalPosition;
		e.Accepted = true;
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		// A popup that grabs the mouse swallows the release; never keep following a button
		// that is no longer held.
		if ( (dragging || resizing) && !LeftHeld )
		{
			StoreGeometry();
			dragging = resizing = false;
			return;
		}

		if ( resizing )
			Size = new Vector2( MathF.Max( MinimumSize.x, e.LocalPosition.x + grabOffset.x ), MathF.Max( MinimumSize.y, e.LocalPosition.y + grabOffset.y ) );
		else if ( dragging )
			Position = e.ScreenPosition - grabOffset;
	}

	protected override void OnMouseReleased( MouseEvent e )
	{
		if ( dragging || resizing ) StoreGeometry();
		dragging = resizing = false;
	}

	protected override void OnPaint()
	{
		Paint.Antialiasing = true;
		Paint.SetPen( Theme.ControlBackground.Lighten( 0.3f ) );
		Paint.SetBrush( Theme.WindowBackground );
		Paint.DrawRect( LocalRect.Shrink( 0.5f ), 6 );

		// Resize grip: two short diagonals in the corner.
		Paint.SetPen( Theme.Text.WithAlpha( 0.3f ), 1 );
		var c = LocalRect.BottomRight - 4;
		Paint.DrawLine( c - new Vector2( 8, 0 ), c - new Vector2( 0, 8 ) );
		Paint.DrawLine( c - new Vector2( 4, 0 ), c - new Vector2( 0, 4 ) );
	}

	private static GameObject[] Selected()
	{
		var session = SceneEditorSession.Active;
		if ( session is null ) return [];
		return session.Selection.OfType<GameObject>().Where( go => go.IsValid() ).Take( MaxObjects ).ToArray();
	}

	private int BuildHash()
	{
		var hc = new HashCode();
		hc.Add( search.Text );
		foreach ( var go in Selected() )
		{
			hc.Add( go.Id );
			foreach ( var component in go.Components.GetAll() )
				if ( component.IsValid() ) hc.Add( component.Id );
		}
		return hc.ToHashCode();
	}

	private void Rebuild()
	{
		lastHash = BuildHash();
		var canvas = scroll.Canvas;
		using var _ = SuspendUpdates.For( canvas );
		canvas.Layout.Clear( true );
		rows.Clear();

		var terms = (search.Text ?? "").Split( ' ', StringSplitOptions.RemoveEmptyEntries );
		var objects = Selected();

		if ( objects.Length == 0 )
		{
			status.Text = "Select an object in the hierarchy.";
			canvas.Layout.AddStretchCell();
			return;
		}
		if ( terms.Length == 0 )
		{
			status.Text = "Type part of a property name. Spaces narrow the search.";
			canvas.Layout.AddStretchCell();
			return;
		}

		var matches = 0;
		foreach ( var go in objects )
		{
			foreach ( var component in go.Components.GetAll() )
			{
				if ( !component.IsValid() || component.Flags.HasFlag( ComponentFlags.Hidden ) ) continue;

				var so = component.GetSerialized();
				var hits = so.Where( p => Matches( p, terms ) ).ToArray();
				if ( hits.Length == 0 ) continue;
				matches += hits.Length;

				var header = canvas.Layout.Add( new Label( $"{go.Name}  ›  {so.TypeTitle}", canvas ) );
				header.SetStyles( "font-weight: 600; padding-top: 6px;" );

				so.OnPropertyStartEdit += p => StartEdit( p, component );
				so.OnPropertyChanged += p => Changed( p, component );
				so.OnPropertyFinishEdit += p => FinishEdit( p, component );

				// Rows are added one by one instead of via AddObject, which would rebuild [Feature]
				// tabs and hide matches behind unselected tabs. Tab and group names become sections.
				foreach ( var section in hits.GroupBy( SectionName ) )
				{
					if ( !string.IsNullOrEmpty( section.Key ) )
					{
						var sub = canvas.Layout.Add( new Label( section.Key, canvas ) );
						sub.SetStyles( $"color: {Theme.Text.WithAlpha( 0.55f ).Hex}; padding-top: 2px;" );
					}

					var sheet = new ControlSheet();
					sheet.IncludePropertyNames = true;
					foreach ( var property in section )
					{
						var control = sheet.AddRow( property );
						if ( control.IsValid() ) rows[control] = (component, property.Name);
					}
					canvas.Layout.Add( sheet );
				}
			}
		}

		status.Text = matches == 0
			? "No matching properties."
			: $"{matches} match{(matches == 1 ? "" : "es")}" + (objects.Length == MaxObjects ? $" (first {MaxObjects} objects)" : "");
		canvas.Layout.AddStretchCell();
	}

	// Same visibility rules as the stock ComponentSheet, minus the Advanced toggle:
	// finding an advanced property is a reason to search.
	private static bool Matches( SerializedProperty p, string[] terms )
	{
		if ( p.PropertyType is null ) return false;
		if ( p.PropertyType.IsAssignableTo( typeof( Delegate ) ) && p.Name.StartsWith( "OnComponent" ) ) return false;
		if ( !p.IsMethod && !p.HasAttribute<PropertyAttribute>() ) return false;

		var haystack = $"{p.Name} {p.DisplayName} {SectionName( p )}";
		return terms.All( t => haystack.Contains( t, StringComparison.OrdinalIgnoreCase ) );
	}

	// "Feature tab / Group", either part optional.
	private static string SectionName( SerializedProperty p )
	{
		var feature = p.TryGetAttribute<FeatureAttribute>( out var f ) ? f.Title : null;
		var parts = new[] { feature, p.GroupName }.Where( s => !string.IsNullOrWhiteSpace( s ) ).Distinct();
		return string.Join( " / ", parts );
	}

	// --- "Show in Inspector" -----------------------------------------------------------------
	// The stock row builds its context menu internally with no extension hook, so the option is
	// appended to that menu once it has opened: same Copy/Paste/Reset/Jump to code, plus ours.

	private readonly Dictionary<ControlWidget, (Component Component, string Property)> rows = new();
	private readonly HashSet<ContextMenu> extendedMenus = new();
	private int menuScanFrames;
	private bool rightWasDown;

	private bool OverOwnPopup()
	{
		// Open menus aren't reachable by walking the widget tree (measured), so ask what's hovered.
		for ( var w = Editor.Application.HoveredWidget; w is not null; w = w.Parent )
			if ( w is Menu ) return true;
		return false;
	}

	private void ExtendContextMenus()
	{
		var rightDown = Editor.Application.MouseButtons.HasFlag( MouseButtons.Right );
		if ( rightDown && !rightWasDown )
		{
			// Remember which row was right-clicked. On Windows the menu opens on RELEASE,
			// so keep looking for it for about a second.
			pressedRow = RowUnder( Editor.Application.HoveredWidget );
			menuScanFrames = 60;
		}
		rightWasDown = rightDown;
		if ( menuScanFrames <= 0 ) return;
		menuScanFrames--;

		extendedMenus.RemoveWhere( m => !m.IsValid() );

		// The open menu isn't reachable by walking the widget tree (measured: 0 found), but
		// it pops up under the cursor, so the hovered widget leads to it.
		ContextMenu menu = null;
		for ( var w = Editor.Application.HoveredWidget; w is not null && menu is null; w = w.Parent )
			menu = w as ContextMenu;

		if ( menu is null || pressedRow is null || extendedMenus.Contains( menu ) ) return;

		extendedMenus.Add( menu );
		menuScanFrames = 0;
		var target = pressedRow.Value;
		menu.AddSeparator();
		menu.AddOption( "Show in Inspector", "manage_search", () => ShowInInspector( target.Component, target.Property ) );
	}

	private (Component Component, string Property)? pressedRow;

	// Climb from the widget under the cursor to the nearest ancestor holding a tracked control,
	// never past the results canvas (which holds every row).
	private (Component Component, string Property)? RowUnder( Widget w )
	{
		for ( ; w is not null && !ReferenceEquals( w, scroll.Canvas ) && !ReferenceEquals( w, this ); w = w.Parent )
		{
			if ( w is ControlWidget c && Lookup( c ) is { } direct ) return direct;
			var controls = w.GetDescendants<ControlWidget>().Select( Lookup ).Where( x => x is not null ).Distinct().ToArray();
			if ( controls.Length == 1 ) return controls[0];
			if ( controls.Length > 1 ) return null;
		}
		return null;
	}

	// Match by the control's property, not by widget identity: a widget found by walking the
	// tree may be a different managed wrapper than the one AddRow returned.
	private (Component Component, string Property)? Lookup( ControlWidget control )
	{
		if ( control is null ) return null;
		if ( rows.TryGetValue( control, out var direct ) ) return direct;
		var p = control.SerializedProperty;
		if ( p is null ) return null;
		foreach ( var entry in rows.Values )
			if ( Targets( p, entry.Component, entry.Property ) ) return entry;
		return null;
	}

	private static (Component Component, string Property) reveal;
	private static int revealFrames;

	private void ShowInInspector( Component component, string property )
	{
		if ( !component.IsValid() ) return;
		var session = SceneEditorSession.Resolve( component );
		session?.Selection.Set( component.GameObject );
		Dismiss();
		EditorWindow.DockManager.RaiseDock( "Inspector" );
		// The inspector rebuilds for the new selection over the next frames; keep looking.
		reveal = (component, property);
		revealFrames = 60;
	}

	private static bool Targets( SerializedProperty p, Component component, string name )
		=> p is not null && p.Name == name && (p.Parent?.Targets?.Contains( component ) ?? false);

	[EditorEvent.Frame]
	private static void RevealFrame()
	{
		if ( revealFrames <= 0 ) return;
		revealFrames--;

		var (component, name) = reveal;
		var inspector = EditorWindow.GetDescendants<Inspector>().FirstOrDefault( i => i.IsValid() && i.Visible );
		if ( !component.IsValid() || inspector is null ) return;

		// A collapsed component builds no rows. Expanding goes through the stock SetExpanded, which
		// is internal (the header's own OnExpandChanged is protected), so reflection it is; if a
		// future editor renames it, the reveal just stops at the component.
		var header = inspector.GetDescendants<ComponentSheetHeader>().FirstOrDefault( h => ReferenceEquals( h.GetComponent(), component ) );
		if ( header is not null && !header.IsExpanded )
		{
			var sheet = header.Parent as ComponentSheet;
			var setExpanded = typeof( ComponentSheet ).GetMethod( "SetExpanded", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public );
			if ( sheet is not null && setExpanded is not null )
			{
				header.IsExpanded = true;
				setExpanded.Invoke( sheet, [true] );
				header.Update();
				return; // rows build this frame; look for them next frame
			}
		}

		// A property on an unselected [Feature] tab has no row yet: select its tab first.
		foreach ( var tab in inspector.GetDescendants<FeatureTabOption>() )
		{
			if ( tab.IsSelected || tab.Feature.Properties is null ) continue;
			if ( !tab.Feature.Properties.Any( p => Targets( p, component, name ) ) ) continue;
			inspector.GetDescendants<FeatureTabWidget>().FirstOrDefault( w => w.GetDescendants<FeatureTabOption>().Contains( tab ) )?.Select( tab );
			return; // the page builds this frame; find the row next frame
		}

		var control = inspector.GetDescendants<ControlWidget>()
			.FirstOrDefault( c => c.IsValid() && c.Visible && Targets( c.SerializedProperty, component, name ) );
		if ( control is null ) return;

		revealFrames = 0;
		var row = control.Parent ?? control;
		for ( var w = row.Parent; w is not null; w = w.Parent )
		{
			if ( w is ScrollArea area )
			{
				area.MakeVisible( row );
				break;
			}
		}
		_ = new FlashOverlay( row );
	}

	private void StartEdit( SerializedProperty property, Component component )
	{
		var session = SceneEditorSession.Resolve( component );
		if ( session is null ) return;
		using var scene = session.Scene.Push();
		undoScope?.Dispose();
		undoScope = session.UndoScope( $"Edit {property.Name} on {component.GetType().Name}" ).WithComponentChanges( component ).Push();
		property.DispatchPreEdited();
	}

	private static void Changed( SerializedProperty property, Component component )
	{
		using var scene = component.Scene?.Push();
		property.DispatchEdited();
	}

	private void FinishEdit( SerializedProperty property, Component component )
	{
		using var scene = component.Scene?.Push();
		property.DispatchEdited();
		undoScope?.Dispose();
		undoScope = null;
	}
}

/// <summary>Briefly tints a widget so the eye lands on it after a jump.</summary>
file sealed class FlashOverlay : Widget
{
	private const float Duration = 1.2f;
	private readonly RealTimeSince age = 0;

	public FlashOverlay( Widget target ) : base( target )
	{
		TransparentForMouseEvents = true;
		Position = 0;
		Size = target.Size;
		Show();
	}

	[EditorEvent.Frame]
	private void Frame()
	{
		if ( age > Duration ) { Destroy(); return; }
		if ( Parent is not null ) Size = Parent.Size;
		Update();
	}

	protected override void OnPaint()
	{
		var fade = 1 - MathF.Min( 1, age / Duration );
		Paint.ClearPen();
		Paint.SetBrush( Theme.Primary.WithAlpha( 0.35f * fade ) );
		Paint.DrawRect( LocalRect, 3 );
	}
}