Editor/Selection/SelectionController.cs
using System;
using Grains.RazorDesigner.Diagnostics;
using Grains.RazorDesigner.Document;
using Sandbox;

namespace Grains.RazorDesigner.Selection;

// Hit-test: last hit wins (child-over-parent on overlap). Selection visual is .selected CSS class.
public sealed class SelectionController
{
	private const string LogPrefix = "[Grains.RazorDesigner]";
	private const string SelectedClass = "selected";

	private readonly DesignerDocument _document;

	// Raised after Selected changes. Subscribers should read Selected directly.
	public event Action Changed;

	public ControlRecord Selected { get; private set; }

	public SelectionController( DesignerDocument document )
	{
		_document = document;
	}

	public ControlRecord FindDeepestAt( float widgetX, float widgetY, float dpiScale )
	{
		if ( dpiScale < 0.01f ) dpiScale = 1f;
		var fbPos = new Vector2( widgetX * dpiScale, widgetY * dpiScale );

		// Perf probe (grd-z82h): O(N) WalkAll + IsInside per click. Surfaces document size cost.
		var probeSw = PerfProbes.Enabled ? System.Diagnostics.Stopwatch.StartNew() : null;
		var probeScanned = 0;
		var probeHitTested = 0;

		ControlRecord deepest = null;
		foreach ( var r in _document.WalkAll() )
		{
			probeScanned++;
			if ( r.LivePanel is null || !r.LivePanel.IsValid ) continue;
			probeHitTested++;
			if ( r.LivePanel.IsInside( fbPos ) ) deepest = r;
		}

		if ( probeSw != null )
			Log.Info( $"{LogPrefix} SelectionController.FindDeepestAt: {probeSw.Elapsed.TotalMilliseconds:F2}ms (scanned={probeScanned}, hit-tested={probeHitTested}, deepest={(deepest?.ClassName ?? "<none>")})" );

		return deepest;
	}

	public bool TrySelectAt( float widgetX, float widgetY, float dpiScale )
	{
		if ( dpiScale < 0.01f ) dpiScale = 1f;
		var fbPos = new Vector2( widgetX * dpiScale, widgetY * dpiScale );
		var hit = FindDeepestAt( widgetX, widgetY, dpiScale );

		if ( hit is not null )
		{
			Log.Info( $"{LogPrefix} TrySelectAt widget=({widgetX:F0},{widgetY:F0}) fb=({fbPos.x:F0},{fbPos.y:F0}) hit {hit.ClassName} (rect={hit.LivePanel.Box.Rect})" );
			Select( hit );
			return true;
		}

		Log.Info( $"{LogPrefix} TrySelectAt widget=({widgetX:F0},{widgetY:F0}) fb=({fbPos.x:F0},{fbPos.y:F0}) miss" );
		Deselect();
		return false;
	}

	public void Select( ControlRecord record )
	{
		if ( Selected == record ) return;

		if ( Selected?.LivePanel is { IsValid: true } prev )
			prev.RemoveClass( SelectedClass );

		Selected = record;

		if ( Selected?.LivePanel is { IsValid: true } cur )
			cur.AddClass( SelectedClass );

		Log.Info( $"{LogPrefix} Select({Selected?.ClassName ?? "<none>"})" );
		Changed?.Invoke();
	}

	public void Deselect()
	{
		if ( Selected is null ) return;
		if ( Selected.LivePanel is { IsValid: true } prev )
			prev.RemoveClass( SelectedClass );
		Log.Info( $"{LogPrefix} Deselect (was {Selected.ClassName})" );
		Selected = null;
		Changed?.Invoke();
	}

	public void DeleteSelected()
	{
		if ( Selected is null ) return;
		if ( Selected == _document.RootRecord )
		{
			Log.Warning( $"{LogPrefix} Delete: RootRecord is not deletable; ignored" );
			return;
		}
		var rec = Selected;
		Selected = null;
		_document.Remove( rec );
		Log.Info( $"{LogPrefix} DeleteSelected: removed {rec.ClassName}" );
		Changed?.Invoke();
	}
}