Editor/Canvas/OverlayController.cs
using System;
using Grains.RazorDesigner.Selection;
using Sandbox;
using Sandbox.UI;

namespace Grains.RazorDesigner.Canvas;

// The eight resize grips, in reading order. Body == "drag to move".
public enum CanvasGrab { None, Body, NW, N, NE, E, SE, S, SW, W }

public sealed class OverlayController
{
	private const string LogPrefix = "[Grains.RazorDesigner]";
	private const float HandlePx = 12f;       // CSS-px size of a handle square
	private const float OutsetPx = 2f;        // overlay rect bleeds this far outside the target

	private readonly SelectionController _selection;

	private Panel _overlay;                    // the outline rect
	// Indexed by (int)CanvasGrab; CanvasGrab.W == 9 is the largest, so length 10. [0]=None/[1]=Body unused.
	private readonly Panel[] _handles = new Panel[(int)CanvasGrab.W + 1];

	// Last on-screen geometry in widget px (top-left origin), for hit-testing. Default = empty/off.
	private Rect _bodyRectWidget;
	private readonly Rect[] _handleRectsWidget = new Rect[(int)CanvasGrab.W + 1];
	private bool _visible;

	private Label _dimBadge;                    // small "W × H" / "Δ x, y" pill shown during a drag
	private string _badgeText;                  // null/empty == hidden

	// Hover overlay (Tier 2 feature I, grd-snsc): faint outline tracking the deepest node under cursor.
	private Panel _hoverOverlay;                // the hover rect (sibling of _overlay under canvas root)
	private Label _hoverLabel;                  // classname chip, child of _hoverOverlay
	private Document.ControlRecord _hoverTarget; // current hover record; null == hidden
	private bool _hoverVisible;

	private string _hoverLabelLastText;
	private float _hoverLabelLastFontSize = -1f;
	private bool _hoverLabelStylesPinned;

	private bool _previewActive;
	public void SetPreviewActive( bool active )
	{
		if ( active == _previewActive ) return;
		_previewActive = active;
		if ( _hoverTarget?.LivePanel is { IsValid: true } p )
			p.Switch( PseudoClass.Hover, !active );
		Log.Info( $"{LogPrefix} OverlayController.SetPreviewActive: {active}" );
	}

	public OverlayController( SelectionController selection )
	{
		_selection = selection;
		Log.Info( $"{LogPrefix} OverlayController ctor" );
	}

	/// <summary>(Re)create the overlay panel + handles under <paramref name="rootPanel"/>. Call after every mirror repopulate.</summary>
	public void Reattach( Panel rootPanel )
	{
		_overlay = null;
		for ( int i = 0; i < _handles.Length; i++ ) _handles[i] = null;
		_visible = false;
		_dimBadge = null;
		_badgeText = null;
		_hoverOverlay = null;
		_hoverLabel = null;
		_hoverTarget = null;
		_hoverVisible = false;
		_hoverLabelLastText = null;
		_hoverLabelLastFontSize = -1f;
		_hoverLabelStylesPinned = false;

		if ( rootPanel is null || !rootPanel.IsValid )
		{
			Log.Warning( $"{LogPrefix} OverlayController.Reattach: root not valid; overlay not created" );
			return;
		}

		_overlay = rootPanel.AddChild<Panel>();
		_overlay.AddClass( "rd-selection-overlay" );
		var s = _overlay.Style;
		s.Position = PositionMode.Absolute;
		s.BorderColor = Color.Parse( "#3da9fc" ) ?? Color.Cyan;
		s.BorderWidth = 1f;
		s.PointerEvents = PointerEvents.None;
		s.ZIndex = 10000;
		_overlay.Style.Set( "display", "none" );

		for ( int i = (int)CanvasGrab.NW; i <= (int)CanvasGrab.W; i++ )
		{
			var h = _overlay.AddChild<Panel>();
			h.AddClass( "rd-selection-handle" );
			var hs = h.Style;
			hs.Position = PositionMode.Absolute;
			hs.Width = HandlePx;
			hs.Height = HandlePx;
			hs.BackgroundColor = Color.White;
			hs.BorderColor = Color.Parse( "#3da9fc" ) ?? Color.Cyan;
			hs.BorderWidth = 1f;
			hs.PointerEvents = PointerEvents.None;
			_handles[i] = h;
		}

		_dimBadge = _overlay.AddChild<Label>();
		_dimBadge.AddClass( "rd-dim-badge" );
		{
			var bs = _dimBadge.Style;
			bs.Position = PositionMode.Absolute;
			bs.BackgroundColor = Color.Parse( "#3da9fc" ) ?? Color.Cyan;
			bs.FontColor = Color.Parse( "#06121f" ) ?? Color.Black;
			bs.PointerEvents = PointerEvents.None;
			bs.Set( "border-radius", "4px" );
			bs.Set( "padding", "2px 6px" );
			bs.Set( "white-space", "nowrap" );
			bs.Set( "display", "none" );
		}

		_hoverOverlay = rootPanel.AddChild<Panel>();
		_hoverOverlay.AddClass( "rd-hover-overlay" );
		{
			var hs = _hoverOverlay.Style;
			hs.Position = PositionMode.Absolute;
			var hoverColor = (Color.Parse( "#3da9fc" ) ?? Color.Cyan).WithAlpha( 0.5f );
			hs.BorderColor = hoverColor;
			hs.BorderWidth = 1f;
			hs.PointerEvents = PointerEvents.None;
			hs.ZIndex = 9999;
			hs.Set( "display", "none" );
		}

		_hoverLabel = _hoverOverlay.AddChild<Label>();
		_hoverLabel.AddClass( "rd-hover-label" );
		{
			var ls = _hoverLabel.Style;
			ls.Position = PositionMode.Absolute;
			var chipBg = (Color.Parse( "#3da9fc" ) ?? Color.Cyan).WithAlpha( 0.7f );
			ls.BackgroundColor = chipBg;
			ls.FontColor = Color.Parse( "#06121f" ) ?? Color.Black;
			ls.PointerEvents = PointerEvents.None;
			ls.Set( "white-space", "nowrap" );
			ls.Set( "display", "none" );
		}

		Log.Info( $"{LogPrefix} OverlayController.Reattach: overlay + 8 handles + dim badge + hover rect + hover label created under root" );
	}

	/// <summary>True when the overlay is currently shown (a non-root control is selected with a live panel).</summary>
	public bool Visible => _visible;
	public Rect BodyRectWidget => _bodyRectWidget;
	public Rect HandleRectWidget( CanvasGrab g ) => _handleRectsWidget[(int)g];

	/// <summary>Set the text of the drag dimension badge, or null/empty to hide it. Positioned next Tick.</summary>
	public void SetDimBadge( string text )
	{
		_badgeText = text;
		if ( string.IsNullOrEmpty( text ) && _dimBadge is { IsValid: true } )
			_dimBadge.Style.Set( "display", "none" );
		// Show + positioning are deferred to Tick so it lays out with the current frame's `scale`.
	}

	/// <summary>Which grab (if any) the widget-px point lands on. Handles win over the body. Returns None when hidden.</summary>
	public CanvasGrab HitTest( Vector2 widgetPx )
	{
		if ( !_visible ) return CanvasGrab.None;
		for ( int i = (int)CanvasGrab.NW; i <= (int)CanvasGrab.W; i++ )
			if ( _handleRectsWidget[i].IsInside( widgetPx ) ) return (CanvasGrab)i;
		return _bodyRectWidget.IsInside( widgetPx ) ? CanvasGrab.Body : CanvasGrab.None;
	}

	public void SetHoverTarget( Document.ControlRecord target )
	{
		if ( target == _hoverTarget ) return;

		if ( !_previewActive )
		{
			if ( _hoverTarget?.LivePanel is { IsValid: true } oldP )
				oldP.Switch( PseudoClass.Hover, false );
			if ( target?.LivePanel is { IsValid: true } newP )
				newP.Switch( PseudoClass.Hover, true );
		}

		_hoverTarget = target;
		Log.Info( $"{LogPrefix} hover target → {target?.ClassName ?? "<none>"}" );
	}

	public void Tick( DesignerScene scene, float widgetDpiScale )
	{
		if ( _overlay is null || !_overlay.IsValid )
		{
			_visible = false;
			if ( _hoverVisible && _hoverOverlay is { IsValid: true } )
			{
				_hoverOverlay.Style.Set( "display", "none" );
				_hoverVisible = false;
			}
			return;
		}

		var sel = _selection?.Selected;
		var live = sel?.LivePanel;
		var isRoot = sel is not null && scene is not null && live == scene.Root;
		bool selectionValid = sel is not null && !isRoot && live is not null && live.IsValid && !_previewActive;
		if ( !selectionValid )
		{
			if ( _visible ) { _overlay.Style.Set( "display", "none" ); _visible = false; }
		}

		var geo = CanvasGeometry.From( scene, widgetDpiScale );

		if ( selectionValid )
		{
			var targetFb = geo.BorderBoxFb( live ); // framebuffer px, absolute
			var targetCss = geo.FbToCss( targetFb ); // overlay is a child of Root, so Root-CSS == overlay-CSS

			float outsetCss = geo.ConstantWidget( OutsetPx );
			float handleCss = geo.ConstantWidget( HandlePx );

			float cssLeft = targetCss.Left - outsetCss;
			float cssTop = targetCss.Top - outsetCss;
			float cssW = targetCss.Width + outsetCss * 2f;
			float cssH = targetCss.Height + outsetCss * 2f;

			var s = _overlay.Style;
			s.Left = cssLeft; s.Top = cssTop; s.Width = cssW; s.Height = cssH;
			s.BorderWidth = MathF.Max( geo.ConstantWidget( 1f ), 0.5f );
			if ( !_visible ) { s.Set( "display", "flex" ); _visible = true; }

			// place the 8 handles, centred on the corners/edge-midpoints, in the overlay's local CSS space
			PlaceHandle( CanvasGrab.NW, 0f, 0f, cssW, cssH, handleCss );
			PlaceHandle( CanvasGrab.N, 0.5f, 0f, cssW, cssH, handleCss );
			PlaceHandle( CanvasGrab.NE, 1f, 0f, cssW, cssH, handleCss );
			PlaceHandle( CanvasGrab.E, 1f, 0.5f, cssW, cssH, handleCss );
			PlaceHandle( CanvasGrab.SE, 1f, 1f, cssW, cssH, handleCss );
			PlaceHandle( CanvasGrab.S, 0.5f, 1f, cssW, cssH, handleCss );
			PlaceHandle( CanvasGrab.SW, 0f, 1f, cssW, cssH, handleCss );
			PlaceHandle( CanvasGrab.W, 0f, 0.5f, cssW, cssH, handleCss );

			// record on-screen widget-px rects for hit-testing.
			_bodyRectWidget = geo.FbToWidget( targetFb );
			float halfWidget = HandlePx * 0.5f + 1f;
			RecordHandleWidgetRect( CanvasGrab.NW, _bodyRectWidget.Left, _bodyRectWidget.Top, halfWidget );
			RecordHandleWidgetRect( CanvasGrab.N, _bodyRectWidget.Left + _bodyRectWidget.Width * 0.5f, _bodyRectWidget.Top, halfWidget );
			RecordHandleWidgetRect( CanvasGrab.NE, _bodyRectWidget.Left + _bodyRectWidget.Width, _bodyRectWidget.Top, halfWidget );
			RecordHandleWidgetRect( CanvasGrab.E, _bodyRectWidget.Left + _bodyRectWidget.Width, _bodyRectWidget.Top + _bodyRectWidget.Height * 0.5f, halfWidget );
			RecordHandleWidgetRect( CanvasGrab.SE, _bodyRectWidget.Left + _bodyRectWidget.Width, _bodyRectWidget.Top + _bodyRectWidget.Height, halfWidget );
			RecordHandleWidgetRect( CanvasGrab.S, _bodyRectWidget.Left + _bodyRectWidget.Width * 0.5f, _bodyRectWidget.Top + _bodyRectWidget.Height, halfWidget );
			RecordHandleWidgetRect( CanvasGrab.SW, _bodyRectWidget.Left, _bodyRectWidget.Top + _bodyRectWidget.Height, halfWidget );
			RecordHandleWidgetRect( CanvasGrab.W, _bodyRectWidget.Left, _bodyRectWidget.Top + _bodyRectWidget.Height * 0.5f, halfWidget );

			if ( _dimBadge is { IsValid: true } )
			{
				if ( string.IsNullOrEmpty( _badgeText ) )
				{
					_dimBadge.Style.Set( "display", "none" );
				}
				else
				{
					_dimBadge.Text = _badgeText;
					var bs = _dimBadge.Style;
					bs.Set( "display", "flex" );
					bs.FontSize = geo.ConstantWidget( 13f );
					bs.Set( "border-radius", $"{geo.ConstantWidget( 4f )}px" );
					bs.Set( "padding", $"{geo.ConstantWidget( 2f )}px {geo.ConstantWidget( 6f )}px" );
					bs.Right = geo.ConstantWidget( 4f );
					bs.Bottom = geo.ConstantWidget( 4f );
					bs.Left = null;
					bs.Top = null;
				}
			}
		}

		if ( _hoverOverlay is { IsValid: true } )
		{
			var hoverTarget = _hoverTarget;
			var hoverLive = hoverTarget?.LivePanel;
			var hoverIsRoot = scene is not null && hoverLive == scene.Root;
			bool show = hoverTarget is not null
				&& hoverLive is { IsValid: true }
				&& hoverTarget != _selection?.Selected
				&& !hoverIsRoot
				&& !_previewActive;

			if ( !show )
			{
				if ( _hoverVisible ) { _hoverOverlay.Style.Set( "display", "none" ); _hoverVisible = false; }
			}
			else
			{
				var hoverFb = geo.BorderBoxFb( hoverLive );
				var hoverCss = geo.FbToCss( hoverFb );
				var hs = _hoverOverlay.Style;
				hs.Left = hoverCss.Left;
				hs.Top = hoverCss.Top;
				hs.Width = hoverCss.Width;
				hs.Height = hoverCss.Height;
				hs.BorderWidth = MathF.Max( geo.ConstantWidget( 1f ), 0.5f );
				if ( !_hoverVisible ) { hs.Set( "display", "flex" ); _hoverVisible = true; }

				if ( _hoverLabel is { IsValid: true } )
				{
					var ls = _hoverLabel.Style;
					var newText = hoverTarget.ClassName ?? string.Empty;
					if ( _hoverLabelLastText != newText )
					{
						_hoverLabel.Text = newText;
						_hoverLabelLastText = newText;
					}

					var newFontSize = geo.ConstantWidget( 11f );
					// Tolerance: only re-emit on changes > 0.25 widget-px — anything smaller is float jitter.
					if ( MathF.Abs( newFontSize - _hoverLabelLastFontSize ) > 0.25f )
					{
						ls.FontSize = newFontSize;
						ls.Set( "border-radius", $"{geo.ConstantWidget( 3f )}px" );
						ls.Set( "padding", $"{geo.ConstantWidget( 1f )}px {geo.ConstantWidget( 4f )}px" );
						_hoverLabelLastFontSize = newFontSize;
					}

					// Pin once on first show — these props never change while the label is visible.
					if ( !_hoverLabelStylesPinned )
					{
						ls.Set( "bottom", "100%" );    // chip sits above the rect
						ls.Left = 0f;                    // flush left
						ls.Right = null;
						ls.Top = null;
						ls.Set( "display", "flex" );
						_hoverLabelStylesPinned = true;
					}
				}
			}
		}
	}

	private void PlaceHandle( CanvasGrab g, float fx, float fy, float overlayW, float overlayH, float handleCss )
	{
		var h = _handles[(int)g];
		if ( h is null || !h.IsValid ) return;
		var hs = h.Style;
		hs.Width = handleCss;
		hs.Height = handleCss;
		hs.BorderWidth = MathF.Max( handleCss / HandlePx, 0.5f ); // == 1/scale; keeps the 1px border ~constant on screen
		hs.Left = overlayW * fx - handleCss * 0.5f;
		hs.Top = overlayH * fy - handleCss * 0.5f;
	}

	private void RecordHandleWidgetRect( CanvasGrab g, float cx, float cy, float half )
	{
		_handleRectsWidget[(int)g] = new Rect( cx - half, cy - half, half * 2f, half * 2f );
	}
}