Editor/Canvas/SuiCanvasViewport.cs
using System;
using System.Collections.Generic;
using Editor;
using Sandbox;
using SboxUiDesigner.Runtime;

namespace SboxUiDesigner.EditorUi.Canvas;

/// <summary>
/// The 2D paint-based design viewport. Replaces the SceneRenderingWidget-based
/// preview that was the canvas during M10. This widget is responsible for:
///
/// 1. Painting the document via <see cref="SuiCanvasRenderer"/> — fast, zero
///    runtime hot-load churn.
/// 2. Applying pan + zoom (logical→widget transform) so designers can navigate.
/// 3. Drawing selection chrome / hover outline / marquee / alignment guides
///    on top of the document, in widget pixels (after resetting the transform).
/// 4. Routing mouse events to the parent SuiCanvasWidget for
///    hit-test + drag/resize logic (Phase 2).
///
/// Coordinate systems (see PRD doc 15 §4.3):
/// - <b>Logical</b>: pixels in the document's drawable area (0..PanelSize).
/// - <b>Widget</b>: pixels of this widget's local rect (Paint API native space).
/// - Conversion: <see cref="LogicalToWidget"/> / <see cref="WidgetToLogical"/>.
/// </summary>
public sealed class SuiCanvasViewport : Widget
{
	private SuiDocument _document;
	private SuiDesignerController _controller;
	private readonly SuiLayoutSolver _solver = new( new Vector2( 1920, 1080 ) );
	private SuiCanvasRenderer _renderer;
	private string _projectAssetsRoot;

	// View state (zoom/pan) — persisted on the document via Settings.CanvasZoom/Pan.
	private float _zoom = 1.0f;
	private Vector2 _pan = Vector2.Zero;

	// Hover + selection state — Phase 2 will populate these via mouse events.
	internal SuiElement HoverElement;
	internal Rect? MarqueeRect; // in widget-pixel space

	/// <summary>
	/// Optional error banner shown over the canvas. Populated by the Window
	/// when Compile fails or schema validation surfaces errors. Click X to dismiss.
	/// </summary>
	public SuiCanvasErrorBanner ErrorBanner { get; set; }

	// Public API for the parent SuiCanvasWidget to query and update.
	public Vector2 PanelSize => _solver.PanelSize;
	public SuiLayoutSolver Solver => _solver;
	public float Zoom => _zoom;
	public Vector2 Pan => _pan;

	public event Action OnRepaintRequested;

	/// <summary>
	/// External handler for mouse events. Returns true if the event was
	/// consumed (Phase 2 wires this up; Phase 1 leaves it null = ignore).
	/// </summary>
	public Func<MouseEvent, bool> MousePressHandler;
	public Action<MouseEvent> MouseMoveHandler;
	public Action<MouseEvent> MouseReleaseHandler;

	public SuiCanvasViewport( Widget parent ) : base( parent )
	{
		MouseTracking = true;
		FocusMode = FocusMode.Click;
		AcceptDrops = true;

		var projectRoot = Sandbox.Project.Current?.RootDirectory?.FullName;
		_projectAssetsRoot = string.IsNullOrEmpty( projectRoot ) ? null : System.IO.Path.Combine( projectRoot, "Assets" );

		_renderer = new SuiCanvasRenderer( _solver, _projectAssetsRoot );
	}

	public void SetDocument( SuiDocument document )
	{
		_document = document;
		// Restore view state if persisted on the document.
		if ( document?.Settings != null )
		{
			_zoom = document.Settings.CanvasZoom > 0.01f ? document.Settings.CanvasZoom : 1.0f;
			_pan = new Vector2( document.Settings.CanvasPanX, document.Settings.CanvasPanY );
		}
		// Solver panel size follows the canvas/preview settings so changing
		// PreviewWidth/Height in the toolbar updates the canvas dimensions.
		ApplyPanelSizeFromDocument();
		Invalidate();
	}

	/// <summary>
	/// Resolve the effective panel size for the solver from the document settings.
	/// PreviewWidth/Height (when &gt; 0) override BaseWidth/Height — lets the
	/// designer simulate different viewport sizes without touching BaseWidth.
	/// </summary>
	public void ApplyPanelSizeFromDocument()
	{
		if ( _document?.Canvas == null ) return;
		var w = _document.Canvas.PreviewWidth > 0 ? _document.Canvas.PreviewWidth : _document.Canvas.BaseWidth;
		var h = _document.Canvas.PreviewHeight > 0 ? _document.Canvas.PreviewHeight : _document.Canvas.BaseHeight;
		_solver.PanelSize = new Vector2( w, h );
	}

	public void SetController( SuiDesignerController controller )
	{
		_controller = controller;
	}

	public void Invalidate()
	{
		Update();
		OnRepaintRequested?.Invoke();
	}

	// ─────────────────────────────────────────────────────────────────────
	//  Coordinate transforms (the "matemática 1:1" — single source)
	// ─────────────────────────────────────────────────────────────────────

	/// <summary>
	/// Where the document's (0,0) lands in widget pixels when the canvas is
	/// centered with the current zoom. Pan offset is added on top.
	/// </summary>
	public Vector2 CanvasOriginInWidget()
	{
		var w = Size.x;
		var h = Size.y;
		var panel = _solver.PanelSize;
		// Center the panel-at-current-zoom inside the widget.
		var ox = (w - panel.x * _zoom) * 0.5f;
		var oy = (h - panel.y * _zoom) * 0.5f;
		return new Vector2( ox, oy ) + _pan;
	}

	public Vector2 LogicalToWidget( Vector2 logical )
		=> logical * _zoom + CanvasOriginInWidget();

	public Vector2 WidgetToLogical( Vector2 widget )
		=> (widget - CanvasOriginInWidget()) / _zoom;

	/// <summary>The visible panel rect in widget pixels.</summary>
	public Rect PanelRectInWidget()
	{
		var origin = CanvasOriginInWidget();
		return new Rect( origin.x, origin.y, _solver.PanelSize.x * _zoom, _solver.PanelSize.y * _zoom );
	}

	// ─────────────────────────────────────────────────────────────────────
	//  Zoom + pan
	// ─────────────────────────────────────────────────────────────────────

	public void SetZoom( float zoom, Vector2? anchorWidgetPos = null )
	{
		zoom = MathF.Max( 0.1f, MathF.Min( 8f, zoom ) );
		if ( MathF.Abs( zoom - _zoom ) < 0.001f ) return;

		// Anchor the zoom around a widget-space point so the user's cursor stays
		// over the same logical pixel after the change.
		if ( anchorWidgetPos.HasValue )
		{
			var anchor = anchorWidgetPos.Value;
			var logicalAtAnchor = WidgetToLogical( anchor );
			_zoom = zoom;
			var newWidgetAtLogical = LogicalToWidget( logicalAtAnchor );
			_pan += anchor - newWidgetAtLogical;
		}
		else
		{
			_zoom = zoom;
		}

		PersistView();
		Invalidate();
	}

	public void SetPan( Vector2 pan )
	{
		_pan = pan;
		PersistView();
		Invalidate();
	}

	public void FitCanvas()
	{
		var w = Size.x;
		var h = Size.y;
		if ( w <= 0 || h <= 0 ) return;
		var panel = _solver.PanelSize;
		var fitZoom = MathF.Min( w / panel.x, h / panel.y ) * 0.95f;
		_zoom = MathF.Max( 0.1f, fitZoom );
		_pan = Vector2.Zero;
		PersistView();
		Invalidate();
	}

	private void PersistView()
	{
		if ( _document?.Settings == null ) return;
		_document.Settings.CanvasZoom = _zoom;
		_document.Settings.CanvasPanX = _pan.x;
		_document.Settings.CanvasPanY = _pan.y;
	}

	// ─────────────────────────────────────────────────────────────────────
	//  Paint
	// ─────────────────────────────────────────────────────────────────────

	protected override void OnPaint()
	{
		// Background: canvas dark area — #141414 (rgba 20,20,20) per user spec.
		var bg = new Color( 20 / 255f, 20 / 255f, 20 / 255f );
		Paint.SetBrush( bg );
		Paint.ClearPen();
		Paint.DrawRect( LocalRect );

		var panelRect = PanelRectInWidget();
		PaintCheckerboard( panelRect );

		if ( _document == null ) return;

		// Reapply panel size each paint so live PreviewWidth/Height changes are picked up.
		ApplyPanelSizeFromDocument();

		// Solve layout once per paint. Cheap — pure data math, no allocation.
		_solver.Solve( _document );

		// Push logical→widget transform: translate to canvas origin, scale to zoom.
		// Document elements draw at their logical coords; the transform handles
		// the rest. After document, we ResetTransform and draw chrome in widget pixels.
		var origin = CanvasOriginInWidget();
		Paint.Translate( origin );
		Paint.Scale( _zoom, _zoom );

		// Pass zoom to renderer so image rendering can decide whether the native
		// pixmap needs a CPU pre-resize (heavy downsample = aliasing on GPU).
		_renderer.Zoom = _zoom;
		_renderer.Paint( _document );

		// Grid overlay (in logical space, so it lives under content but over checkerboard).
		if ( _document?.Settings?.ShowGrid == true )
			PaintGridOverlay( _document.Settings.GridSize > 0 ? _document.Settings.GridSize : 8 );

		Paint.ResetTransform();

		// Document boundary outline — #1F1F1F (rgba 31,31,31) per user spec.
		Paint.SetPen( new Color( 31 / 255f, 31 / 255f, 31 / 255f ), 1f );
		Paint.ClearBrush();
		Paint.DrawRect( panelRect );

		// Rulers — top + left, with pixel marks. Drawn AFTER content so they
		// never get clipped, BEFORE chrome so selection chrome sits on top.
		if ( _document?.Settings?.ShowRulers == true )
			PaintRulers( panelRect );

		// Safe area overlay (designer aid) — dashed rect inside the panel rect.
		if ( _document?.Settings?.ShowSafeArea == true && _document?.Canvas?.SafeArea != null )
			PaintSafeArea( panelRect, _document.Canvas.SafeArea );

		// Anchor reference points overlay — small cross at the resolved anchor
		// position of every visible element (designer aid; not generated).
		if ( _document?.Settings?.ShowAnchors == true )
			PaintAnchorMarkers();

		// Layout bounds: outline every element rect for visual debugging.
		if ( _document?.Settings?.ShowLayoutBounds == true || _document?.Settings?.ResponsiveDebug == true )
			PaintLayoutBounds();

		// Widget info: small label per element showing name + size.
		if ( _document?.Settings?.ShowWidgetInfo == true || _document?.Settings?.ResponsiveDebug == true )
			PaintWidgetInfo();

		// Chrome (Phase 2 will populate these).
		PaintChromeOverlays( panelRect );

		// Responsive Debug overlay — issues panel + safe area watermark.
		if ( _document?.Settings?.ResponsiveDebug == true )
			PaintResponsiveDebug( panelRect );

		// Error banner (T3): always last so it sits above all canvas content.
		PaintErrorBanner();
	}

	/// <summary>
	/// Render an error banner at the top of the viewport. Click anywhere on
	/// the banner triggers <see cref="SuiCanvasErrorBanner.OnClick"/>; clicking
	/// the X area triggers <see cref="SuiCanvasErrorBanner.OnDismiss"/>.
	/// </summary>
	private void PaintErrorBanner()
	{
		var b = ErrorBanner;
		if ( b == null || string.IsNullOrEmpty( b.Title ) ) return;

		var rect = new Rect( 8, 8, MathF.Max( 200, Size.x - 16 ), 56 );

		// Slightly translucent red bg so the canvas underneath is still hinted at.
		Paint.SetBrush( new Color( 0.55f, 0.10f, 0.10f, 0.92f ) );
		Paint.SetPen( new Color( 0.85f, 0.25f, 0.25f, 1.0f ), 1.5f );
		Paint.DrawRect( rect, 4f );

		// Icon area
		Paint.SetPen( Color.White );
		Paint.DrawIcon( new Rect( rect.Left + 10, rect.Top + 8, 20, 20 ), "error", 18, TextFlag.LeftCenter );

		// Title (bold) + detail (single-line elided)
		Paint.SetFont( Theme.DefaultFont, 12, 700 );
		Paint.SetPen( Color.White );
		Paint.DrawText( new Rect( rect.Left + 36, rect.Top + 6, rect.Width - 72, 20 ), b.Title, TextFlag.LeftTop );

		if ( !string.IsNullOrEmpty( b.Detail ) )
		{
			Paint.SetFont( Theme.DefaultFont, 10, 400 );
			Paint.SetPen( new Color( 1f, 1f, 1f, 0.85f ) );
			var elided = Paint.GetElidedText( b.Detail, rect.Width - 72, ElideMode.Right, TextFlag.LeftTop );
			Paint.DrawText( new Rect( rect.Left + 36, rect.Top + 26, rect.Width - 72, 24 ), elided, TextFlag.LeftTop );
		}

		// Dismiss X (right side)
		var xRect = new Rect( rect.Right - 28, rect.Top + 6, 22, 22 );
		Paint.SetFont( Theme.DefaultFont, 14, 700 );
		Paint.SetPen( Color.White );
		Paint.DrawText( xRect, "×", TextFlag.Center );

		_bannerRect = rect;
		_bannerDismissRect = xRect;
	}

	private Rect _bannerRect;
	private Rect _bannerDismissRect;

	private static bool PointInside( Rect r, Vector2 p )
		=> p.x >= r.Left && p.x <= r.Right && p.y >= r.Top && p.y <= r.Bottom;

	private bool TryHandleBannerClick( Vector2 widgetPos )
	{
		if ( ErrorBanner == null ) return false;
		if ( _bannerRect.Width < 1 ) return false;
		// Check dismiss first.
		if ( PointInside( _bannerDismissRect, widgetPos ) )
		{
			ErrorBanner.OnDismiss?.Invoke();
			ErrorBanner = null;
			Invalidate();
			return true;
		}
		if ( PointInside( _bannerRect, widgetPos ) )
		{
			ErrorBanner.OnClick?.Invoke();
			return true;
		}
		return false;
	}

	/// <summary>
	/// Paint rulers along the top and left edges of the canvas, in widget
	/// pixels. Major tick = 100 logical px (with label), minor tick = 50 logical px.
	/// Tick density adapts to zoom so we don't overcrowd at small zooms.
	/// </summary>
	private void PaintRulers( Rect panelRect )
	{
		const float rulerThickness = 18f;
		// Ruler bar background — #1E1E1D (rgba 30,30,29) per user spec.
		// Same family as top toolbar so the chrome reads as one cohesive ring.
		var bg = new Color( 30 / 255f, 30 / 255f, 29 / 255f, 1.0f );
		var fg = new Color( 0.55f, 0.55f, 0.60f, 1.0f );
		var fgDim = new Color( 0.40f, 0.40f, 0.45f, 1.0f );

		// Top ruler bar.
		Paint.SetBrush( bg );
		Paint.ClearPen();
		Paint.DrawRect( new Rect( 0, 0, Size.x, rulerThickness ) );
		// Left ruler bar.
		Paint.DrawRect( new Rect( 0, 0, rulerThickness, Size.y ) );

		// Choose tick spacing so major ticks are ≥ 50 widget pixels apart.
		float majorLogical = 100f;
		while ( majorLogical * _zoom < 50f ) majorLogical *= 2f;
		var minorLogical = majorLogical * 0.5f;

		Paint.SetFont( Theme.DefaultFont, 9, 400 );

		// Horizontal ticks (top ruler).
		var firstX = MathF.Floor( WidgetToLogical( new Vector2( rulerThickness, 0 ) ).x / minorLogical ) * minorLogical;
		var lastX = WidgetToLogical( new Vector2( Size.x, 0 ) ).x;
		for ( var lx = firstX; lx <= lastX; lx += minorLogical )
		{
			var wx = LogicalToWidget( new Vector2( lx, 0 ) ).x;
			if ( wx < rulerThickness ) continue;
			var isMajor = MathF.Abs( (lx % majorLogical + majorLogical) % majorLogical ) < 0.001f;
			var tickH = isMajor ? 8f : 4f;
			Paint.SetPen( isMajor ? fg : fgDim, 1f );
			Paint.DrawLine( new Vector2( wx, rulerThickness - tickH ), new Vector2( wx, rulerThickness ) );
			if ( isMajor )
			{
				Paint.ClearBrush();
				Paint.SetPen( fg );
				Paint.DrawText( new Rect( wx + 2, 0, 60, rulerThickness ), $"{(int)lx}", TextFlag.LeftCenter );
			}
		}

		// Vertical ticks (left ruler).
		var firstY = MathF.Floor( WidgetToLogical( new Vector2( 0, rulerThickness ) ).y / minorLogical ) * minorLogical;
		var lastY = WidgetToLogical( new Vector2( 0, Size.y ) ).y;
		for ( var ly = firstY; ly <= lastY; ly += minorLogical )
		{
			var wy = LogicalToWidget( new Vector2( 0, ly ) ).y;
			if ( wy < rulerThickness ) continue;
			var isMajor = MathF.Abs( (ly % majorLogical + majorLogical) % majorLogical ) < 0.001f;
			var tickW = isMajor ? 8f : 4f;
			Paint.SetPen( isMajor ? fg : fgDim, 1f );
			Paint.DrawLine( new Vector2( rulerThickness - tickW, wy ), new Vector2( rulerThickness, wy ) );
			if ( isMajor )
			{
				Paint.ClearBrush();
				Paint.SetPen( fg );
				Paint.DrawText( new Rect( 1, wy - 8, rulerThickness - 2, 14 ), $"{(int)ly}", TextFlag.RightCenter );
			}
		}

		// Corner cap.
		Paint.SetBrush( bg.Darken( 0.1f ) );
		Paint.ClearPen();
		Paint.DrawRect( new Rect( 0, 0, rulerThickness, rulerThickness ) );
	}

	/// <summary>
	/// Paint a dashed safe area rect inside the panel — designer aid showing
	/// where critical UI should fit (TVs, ultrawide, etc).
	/// </summary>
	private void PaintSafeArea( Rect panelRect, SuiSafeArea sa )
	{
		if ( !sa.Enabled ) return;
		var inner = new Rect(
			panelRect.Left + sa.Left * _zoom,
			panelRect.Top + sa.Top * _zoom,
			panelRect.Width - (sa.Left + sa.Right) * _zoom,
			panelRect.Height - (sa.Top + sa.Bottom) * _zoom );

		Paint.SetPen( new Color( 0.30f, 0.65f, 1.0f, 0.85f ), 1.5f );
		Paint.ClearBrush();
		Paint.DrawRect( inner );

		Paint.SetFont( Theme.DefaultFont, 9, 600 );
		Paint.SetPen( new Color( 0.30f, 0.65f, 1.0f, 0.85f ) );
		Paint.DrawText( new Rect( inner.Left + 4, inner.Top + 2, 200, 14 ), "Safe Area", TextFlag.LeftTop );
	}

	/// <summary>
	/// Paint a small cross/anchor glyph at the resolved anchor reference point
	/// of every visible element. Helps designers see where elements are anchored
	/// without selecting each one.
	/// </summary>
	private void PaintAnchorMarkers()
	{
		if ( _document == null ) return;
		// Only paint the anchor for the selected element — drawing every
		// element's anchor at once turns the canvas into yellow spaghetti.
		var sel = _controller?.Selected;
		if ( sel == null ) return;
		if ( string.IsNullOrEmpty( sel.ParentId ) ) return;
		if ( !_solver.TryGetRect( sel.ParentId, out var pr ) ) return;

		var anchor = sel.Layout?.Anchor ?? SuiAnchor.TopLeft;

		// Resolve up to 4 marker positions in parent-local space depending
		// on the anchor type, plus an optional bracket connecting them.
		switch ( anchor )
		{
			case SuiAnchor.Stretch:
				// Four corners of the parent.
				DrawAnchorBracket( pr );
				DrawQuadPin( LogicalToWidget( new Vector2( pr.Left, pr.Top ) ) );
				DrawQuadPin( LogicalToWidget( new Vector2( pr.Right, pr.Top ) ) );
				DrawQuadPin( LogicalToWidget( new Vector2( pr.Left, pr.Bottom ) ) );
				DrawQuadPin( LogicalToWidget( new Vector2( pr.Right, pr.Bottom ) ) );
				break;

			case SuiAnchor.StretchHorizontal:
			{
				var midY = pr.Top + pr.Height * 0.5f;
				var a = LogicalToWidget( new Vector2( pr.Left, midY ) );
				var b = LogicalToWidget( new Vector2( pr.Right, midY ) );
				DrawDashedLine( a, b );
				DrawQuadPin( a );
				DrawQuadPin( b );
				break;
			}

			case SuiAnchor.StretchVertical:
			{
				var midX = pr.Left + pr.Width * 0.5f;
				var a = LogicalToWidget( new Vector2( midX, pr.Top ) );
				var b = LogicalToWidget( new Vector2( midX, pr.Bottom ) );
				DrawDashedLine( a, b );
				DrawQuadPin( a );
				DrawQuadPin( b );
				break;
			}

			default:
			{
				// Single anchor — one pin at the resolved point.
				var p = AnchorPointInParent( anchor, pr );
				DrawQuadPin( LogicalToWidget( p ) );
				break;
			}
		}
	}

	/// <summary>
	/// "Quad Pin" anchor marker — small + shape with a soft circular core.
	/// Tuned to harmonize with the rest of the dark editor (no neon, no
	/// glow halos), but bright enough to spot at a glance.
	/// </summary>
	private void DrawQuadPin( Vector2 pos )
	{
		// Halo / core: subtle blue circle inside a thin light ring.
		var coreColor = new Color( 0f, 0.55f, 1f, 0.85f );
		var ringColor = new Color( 0.85f, 0.88f, 0.95f, 0.85f );

		// Outer thin ring (12px diameter).
		Paint.SetBrush( new Color( 0.85f, 0.88f, 0.95f, 0.18f ) );
		Paint.SetPen( ringColor, 1f );
		Paint.DrawCircle( pos, new Vector2( 12, 12 ) );

		// Solid blue core (6px diameter).
		Paint.SetBrush( coreColor );
		Paint.ClearPen();
		Paint.DrawCircle( pos, new Vector2( 6, 6 ) );

		// Four short tabs (cross arms) — 5px length, gap of 3px from the ring.
		Paint.SetPen( ringColor, 1.5f );
		const float gap = 5f;
		const float len = 4f;
		Paint.DrawLine( new Vector2( pos.x - gap - len, pos.y ), new Vector2( pos.x - gap, pos.y ) );
		Paint.DrawLine( new Vector2( pos.x + gap, pos.y ),       new Vector2( pos.x + gap + len, pos.y ) );
		Paint.DrawLine( new Vector2( pos.x, pos.y - gap - len ), new Vector2( pos.x, pos.y - gap ) );
		Paint.DrawLine( new Vector2( pos.x, pos.y + gap ),       new Vector2( pos.x, pos.y + gap + len ) );
	}

	/// <summary>
	/// Dashed connector — used between two pins (stretch H/V) to imply the
	/// element spans between them. Native PenStyle.Dash, no per-segment loop.
	/// </summary>
	private void DrawDashedLine( Vector2 a, Vector2 b )
	{
		Paint.SetPen( new Color( 0.85f, 0.88f, 0.95f, 0.30f ), 1f, PenStyle.Dash );
		Paint.DrawLine( a, b );
	}

	/// <summary>Dotted bracket along the parent rect — used by Fill anchor
	/// to imply the element snaps to all four sides.</summary>
	private void DrawAnchorBracket( Rect parentRect )
	{
		var widgetRect = LogicalRectToWidget( parentRect );
		Paint.SetPen( new Color( 0.85f, 0.88f, 0.95f, 0.30f ), 1f, PenStyle.Dash );
		Paint.ClearBrush();
		Paint.DrawRect( widgetRect );
	}

	private void PaintLayoutBounds()
	{
		if ( _document == null ) return;
		// Native dashed pen — Qt does the dashing, no per-segment loop.
		// Subtle alpha so bounds read as a "what's there" hint, not chrome.
		Paint.SetPen( new Color( 1f, 1f, 1f, 0.27f ), 1f, PenStyle.Dash );
		Paint.ClearBrush();
		foreach ( var el in _document.Elements )
		{
			if ( string.IsNullOrEmpty( el.ParentId ) ) continue;
			if ( el.Flags?.HiddenInDesigner == true ) continue;
			if ( !_solver.TryGetRect( el.Id, out var r ) ) continue;
			Paint.DrawRect( LogicalRectToWidget( r ) );
		}
	}

	private void PaintWidgetInfo()
	{
		if ( _document == null ) return;
		Paint.SetFont( Theme.DefaultFont, 9, 600 );
		Paint.ClearBrush();
		foreach ( var el in _document.Elements )
		{
			if ( string.IsNullOrEmpty( el.ParentId ) ) continue;
			if ( el.Flags?.HiddenInDesigner == true ) continue;
			if ( !_solver.TryGetRect( el.Id, out var r ) ) continue;
			var widgetRect = LogicalRectToWidget( r );
			if ( widgetRect.Width < 50 || widgetRect.Height < 14 ) continue;

			var label = $"{el.Name}  {(int)el.Layout.Width}×{(int)el.Layout.Height}";
			var pillRect = new Rect( widgetRect.Left + 2, widgetRect.Top + 2, MathF.Min( widgetRect.Width - 4, 160 ), 12 );
			Paint.SetBrush( new Color( 0, 0, 0, 0.65f ) );
			Paint.ClearPen();
			Paint.DrawRect( pillRect, 2 );
			Paint.SetPen( new Color( 0.85f, 0.85f, 0.90f, 0.95f ) );
			Paint.ClearBrush();
			Paint.DrawText( pillRect, label, TextFlag.Center );
		}
	}

	private void PaintResponsiveDebug( Rect panelRect )
	{
		// Float a small "Issues found" banner on top-right + safe area watermark
		// at bottom-right. The actual issue list is computed below.
		var issues = ScanResponsiveIssues();
		if ( issues.Count == 0 )
		{
			// "All good" badge.
			var rect = new Rect( Size.x - 180, 28, 170, 22 );
			Paint.SetBrush( new Color( 0.13f, 0.45f, 0.20f, 0.85f ) );
			Paint.SetPen( new Color( 0.30f, 0.85f, 0.45f, 1f ), 1 );
			Paint.DrawRect( rect, 4 );
			Paint.SetFont( Theme.DefaultFont, 10, 600 );
			Paint.SetPen( Color.White );
			Paint.DrawText( rect, "✓ Responsive OK", TextFlag.Center );
			return;
		}

		// Issues panel — top-right, vertical stack.
		const float panelW = 280;
		var panelHeight = 36 + issues.Count * 30;
		var panel = new Rect( Size.x - panelW - 12, 28, panelW, panelHeight );
		Paint.SetBrush( new Color( 0.13f, 0.13f, 0.16f, 0.95f ) );
		Paint.SetPen( new Color( 0.85f, 0.30f, 0.30f, 0.85f ), 1.5f );
		Paint.DrawRect( panel, 4 );

		Paint.SetFont( Theme.DefaultFont, 10, 700 );
		Paint.SetPen( new Color( 1f, 0.5f, 0.5f ) );
		Paint.DrawText( new Rect( panel.Left + 8, panel.Top + 6, panel.Width - 16, 14 ),
			$"⚠ {issues.Count} Responsive Issue{(issues.Count == 1 ? "" : "s")}", TextFlag.LeftCenter );

		Paint.SetFont( Theme.DefaultFont, 9, 400 );
		for ( int i = 0; i < issues.Count; i++ )
		{
			var rowY = panel.Top + 26 + i * 30;
			Paint.SetPen( Color.White );
			Paint.DrawText( new Rect( panel.Left + 8, rowY, panel.Width - 16, 14 ),
				$"{i + 1}. {issues[i].Title}", TextFlag.LeftCenter );
			Paint.SetPen( new Color( 0.7f, 0.7f, 0.75f ) );
			Paint.DrawText( new Rect( panel.Left + 8, rowY + 14, panel.Width - 16, 14 ),
				issues[i].Detail, TextFlag.LeftCenter );
		}
	}

	private System.Collections.Generic.List<(string Title, string Detail)> ScanResponsiveIssues()
	{
		var list = new System.Collections.Generic.List<(string, string)>();
		if ( _document == null ) return list;

		var sa = _document.Canvas?.SafeArea;
		var saEnabled = sa?.Enabled ?? false;
		var panel = _solver.PanelSize;
		var safeLeft = saEnabled ? sa.Left : 0;
		var safeTop = saEnabled ? sa.Top : 0;
		var safeRight = panel.x - (saEnabled ? sa.Right : 0);
		var safeBottom = panel.y - (saEnabled ? sa.Bottom : 0);

		foreach ( var el in _document.Elements )
		{
			if ( string.IsNullOrEmpty( el.ParentId ) ) continue;
			if ( el.Flags?.HiddenInDesigner == true ) continue;
			if ( !_solver.TryGetRect( el.Id, out var r ) ) continue;

			if ( saEnabled && (r.Right > safeRight + 1 || r.Left < safeLeft - 1
				|| r.Top < safeTop - 1 || r.Bottom > safeBottom + 1) )
			{
				list.Add( ($"Widget outside safe area", $"{el.Name}") );
			}

			if ( el.Type == SuiElementType.Text && (el.Layout?.Width ?? 0) > 0
				&& !string.IsNullOrEmpty( el.Props?.Text ) )
			{
				// Rough text overflow heuristic — text length × fontSize×0.55 estimate.
				var estW = (el.Props.Text?.Length ?? 0) * (el.Props.FontSize > 0 ? el.Props.FontSize * 0.55f : 8f);
				if ( estW > el.Layout.Width + 4 && el.Props.TextSizeMode == SuiTextSizeMode.Fixed )
					list.Add( ($"Text overflow detected", $"{el.Name} (estimated {(int)estW}px > width {(int)el.Layout.Width}px)") );
			}

			if ( el.Layout?.MinWidth.HasValue == true && r.Width < el.Layout.MinWidth.Value - 0.5f )
				list.Add( ($"Min width exceeded", $"{el.Name} ({(int)r.Width} < {(int)el.Layout.MinWidth.Value})") );
		}

		return list;
	}

	private static Vector2 AnchorPointInParent( SuiAnchor a, Rect parentRect )
	{
		float x = parentRect.Left;
		float y = parentRect.Top;
		switch ( a )
		{
			case SuiAnchor.TopLeft: x = parentRect.Left; y = parentRect.Top; break;
			case SuiAnchor.TopCenter: x = parentRect.Left + parentRect.Width * 0.5f; y = parentRect.Top; break;
			case SuiAnchor.TopRight: x = parentRect.Right; y = parentRect.Top; break;
			case SuiAnchor.MiddleLeft: x = parentRect.Left; y = parentRect.Top + parentRect.Height * 0.5f; break;
			case SuiAnchor.MiddleCenter: x = parentRect.Left + parentRect.Width * 0.5f; y = parentRect.Top + parentRect.Height * 0.5f; break;
			case SuiAnchor.MiddleRight: x = parentRect.Right; y = parentRect.Top + parentRect.Height * 0.5f; break;
			case SuiAnchor.BottomLeft: x = parentRect.Left; y = parentRect.Bottom; break;
			case SuiAnchor.BottomCenter: x = parentRect.Left + parentRect.Width * 0.5f; y = parentRect.Bottom; break;
			case SuiAnchor.BottomRight: x = parentRect.Right; y = parentRect.Bottom; break;
		}
		return new Vector2( x, y );
	}

	/// <summary>
	/// Paint a subtle grid overlay (dots) at <paramref name="step"/> logical-pixel
	/// intervals across the panel rect. Drawn AFTER content but still inside the
	/// Paint.Scale block, so coords are in logical space.
	/// </summary>
	private void PaintGridOverlay( float step )
	{
		var panel = _solver.PanelSize;
		// Skip if dots would be too dense (every dot ≤ 4 widget pixels).
		if ( step * _zoom < 4f ) return;

		var color = new Color( 1f, 1f, 1f, 0.08f );
		Paint.SetBrush( color );
		Paint.ClearPen();
		var dotSize = 1f / _zoom; // keep dots ~1px on screen regardless of zoom
		for ( float y = 0; y <= panel.y; y += step )
		{
			for ( float x = 0; x <= panel.x; x += step )
			{
				Paint.DrawRect( new Rect( x - dotSize * 0.5f, y - dotSize * 0.5f, dotSize, dotSize ) );
			}
		}
	}

	private void PaintCheckerboard( Rect rect )
	{
		// 16x16 logical-pixel squares, alternating two near-black shades.
		const float cellLogical = 16f;
		var cell = cellLogical * _zoom;
		if ( cell < 4 ) cell = 4; // don't draw sub-pixel cells

		var dark = new Color( 0.10f, 0.10f, 0.12f );
		var light = new Color( 0.13f, 0.13f, 0.15f );

		Paint.ClearPen();
		Paint.SetBrush( dark );
		Paint.DrawRect( rect );

		Paint.SetBrush( light );
		var cols = (int)MathF.Ceiling( rect.Width / cell );
		var rows = (int)MathF.Ceiling( rect.Height / cell );
		for ( int y = 0; y < rows; y++ )
		{
			for ( int x = 0; x < cols; x++ )
			{
				if ( ((x + y) & 1) == 0 ) continue;
				var cellRect = new Rect(
					rect.Left + x * cell,
					rect.Top + y * cell,
					MathF.Min( cell, rect.Right - (rect.Left + x * cell) ),
					MathF.Min( cell, rect.Bottom - (rect.Top + y * cell) )
				);
				Paint.DrawRect( cellRect );
			}
		}
	}

	private void PaintChromeOverlays( Rect panelRect )
	{
		// Hover outline — only if the hovered element isn't already selected
		// (selection chrome already draws on it).
		if ( HoverElement != null && (_controller == null || !_controller.IsSelected( HoverElement ))
			&& _solver.TryGetRect( HoverElement.Id, out var hoverLogical ) )
		{
			var hoverScreen = LogicalRectToWidget( hoverLogical );
			Paint.SetPen( new Color( 0.20f, 0.55f, 1.0f, 0.7f ), 1f );
			Paint.ClearBrush();
			Paint.DrawRect( hoverScreen );
		}

		// Selection chrome — draw secondary borders on every selected element,
		// then overlay the primary's full chrome (with handles + size label) on top.
		if ( _controller != null && _controller.SelectedCount > 0 )
		{
			var primary = _controller.Selected;
			foreach ( var el in _controller.SelectedSet )
			{
				if ( el == null || el == primary ) continue;
				if ( string.IsNullOrEmpty( el.ParentId ) ) continue;
				if ( !_solver.TryGetRect( el.Id, out var r ) ) continue;
				DrawSecondarySelectionBorder( LogicalRectToWidget( r ) );
			}
			if ( primary != null && !string.IsNullOrEmpty( primary.ParentId )
				&& _solver.TryGetRect( primary.Id, out var primLogical ) )
			{
				DrawSelectionChrome( LogicalRectToWidget( primLogical ), primary );
			}

			// "N selected" label when multi.
			if ( _controller.SelectedCount > 1 )
			{
				var label = $"{_controller.SelectedCount} selected";
				Paint.SetFont( Theme.DefaultFont, 10, 600 );
				Paint.SetPen( new Color( 0.20f, 0.55f, 1.0f, 1.0f ) );
				Paint.ClearBrush();
				Paint.DrawText( new Rect( panelRect.Left + 8, panelRect.Top + 8, 200, 16 ), label, TextFlag.LeftTop );
			}
		}

		// Marquee
		if ( MarqueeRect.HasValue )
		{
			Paint.SetPen( new Color( 0.20f, 0.55f, 1.0f, 0.9f ), 1f );
			Paint.SetBrush( new Color( 0.20f, 0.55f, 1.0f, 0.15f ) );
			Paint.DrawRect( MarqueeRect.Value );
		}

		// Drop-target highlight (T4): when a palette item is being dragged
		// over the canvas, outline the container that will receive the drop.
		if ( _dropHoverContainer != null
			&& _solver.TryGetRect( _dropHoverContainer.Id, out var dropRect ) )
		{
			var widgetRect = LogicalRectToWidget( dropRect );
			Paint.SetPen( new Color( 0.30f, 0.90f, 0.45f, 0.95f ), 2f );
			Paint.SetBrush( new Color( 0.30f, 0.90f, 0.45f, 0.10f ) );
			Paint.DrawRect( widgetRect );
		}
	}

	private static void DrawSecondarySelectionBorder( Rect rect )
	{
		// Slightly dimmer + no handles for non-primary multi-select.
		Paint.SetPen( new Color( 0.20f, 0.55f, 1.0f, 0.85f ), 1f );
		Paint.ClearBrush();
		Paint.DrawRect( rect );
	}

	private void DrawSelectionChrome( Rect rect, SuiElement el )
	{
		// M14 refined: thinner 1px border + smaller 6×6 handles + dimmer accent.
		var borderColor = new Color( 0.30f, 0.65f, 1.0f, 0.95f );
		Paint.SetPen( borderColor, 1f );
		Paint.ClearBrush();
		Paint.DrawRect( rect );

		var isFlex = el.Layout.Mode == SuiLayoutMode.Flex;
		if ( isFlex )
		{
			// Mini badge on top-left corner instead of full overlay text.
			var badgeRect = new Rect( rect.Left, rect.Top - 14, 56, 13 );
			Paint.SetBrush( borderColor );
			Paint.ClearPen();
			Paint.DrawRect( badgeRect, 2 );
			Paint.SetFont( Theme.DefaultFont, 8, 700 );
			Paint.SetPen( Color.White );
			Paint.DrawText( badgeRect, "FLEX", TextFlag.Center );
			return;
		}

		// 8 handles — smaller 6×6 squares.
		var handlePoints = new[]
		{
			new Vector2( rect.Left,  rect.Top ),
			new Vector2( (rect.Left + rect.Right) * 0.5f, rect.Top ),
			new Vector2( rect.Right, rect.Top ),
			new Vector2( rect.Left,  (rect.Top + rect.Bottom) * 0.5f ),
			new Vector2( rect.Right, (rect.Top + rect.Bottom) * 0.5f ),
			new Vector2( rect.Left,  rect.Bottom ),
			new Vector2( (rect.Left + rect.Right) * 0.5f, rect.Bottom ),
			new Vector2( rect.Right, rect.Bottom ),
		};
		Paint.SetPen( Color.White, 1f );
		Paint.SetBrush( borderColor );
		foreach ( var p in handlePoints )
			Paint.DrawRect( new Rect( p.x - 3f, p.y - 3f, 6f, 6f ), 1 );
	}

	internal Rect LogicalRectToWidget( Rect logical )
	{
		var tl = LogicalToWidget( new Vector2( logical.Left, logical.Top ) );
		var br = LogicalToWidget( new Vector2( logical.Right, logical.Bottom ) );
		return new Rect( tl.x, tl.y, br.x - tl.x, br.y - tl.y );
	}

	// ─────────────────────────────────────────────────────────────────────
	//  Mouse events — viewport handles pan locally; forwards selection /
	//  drag / resize to the parent canvas via the *Handler delegates.
	// ─────────────────────────────────────────────────────────────────────

	private bool _panning;
	private Vector2 _panLastWidget;

	protected override void OnMousePress( MouseEvent e )
	{
		// Banner click takes precedence over everything (covers top of canvas).
		if ( e.LeftMouseButton && TryHandleBannerClick( e.LocalPosition ) )
		{
			e.Accepted = true;
			return;
		}

		// Middle-mouse OR Alt+left = pan. Match Unity UI Builder + UMG conventions.
		var alt = (e.KeyboardModifiers & KeyboardModifiers.Alt) != 0;
		if ( e.MiddleMouseButton || (e.LeftMouseButton && alt) )
		{
			_panning = true;
			_panLastWidget = e.LocalPosition;
			Cursor = CursorShape.SizeAll;
			e.Accepted = true;
			return;
		}

		if ( MousePressHandler != null && MousePressHandler( e ) )
		{
			e.Accepted = true;
			Update();
			return;
		}
		base.OnMousePress( e );
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		if ( _panning )
		{
			var delta = e.LocalPosition - _panLastWidget;
			_panLastWidget = e.LocalPosition;
			_pan += delta;
			PersistView();
			Invalidate();
			e.Accepted = true;
			return;
		}

		MouseMoveHandler?.Invoke( e );
		base.OnMouseMove( e );
	}

	protected override void OnMouseReleased( MouseEvent e )
	{
		if ( _panning )
		{
			_panning = false;
			Cursor = CursorShape.Arrow;
			e.Accepted = true;
			return;
		}

		MouseReleaseHandler?.Invoke( e );
		base.OnMouseReleased( e );
	}

	protected override void OnMouseWheel( WheelEvent e )
	{
		// Zoom anchored at cursor.
		var step = e.Delta > 0 ? 1.1f : (1f / 1.1f);
		SetZoom( _zoom * step, e.Position );
		e.Accepted = true;
	}

	// ─────────────────────────────────────────────────────────────────────
	//  Drop target — palette element drop here adds element via controller.
	// ─────────────────────────────────────────────────────────────────────

	/// <summary>
	/// Element under cursor during a drag-over (used to highlight the
	/// container that will receive the drop). Cleared on leave/drop.
	/// </summary>
	private SuiElement _dropHoverContainer;

	public override void OnDragHover( DragEvent ev )
	{
		base.OnDragHover( ev );
		if ( ev.Data?.Object is not SuiElementType ) return;
		ev.Action = DropAction.Copy;

		// Resolve container under cursor for visual feedback.
		_dropHoverContainer = ResolveDropContainer( WidgetToLogical( ev.LocalPosition ) );
		Update();
	}

	public override void OnDragLeave()
	{
		base.OnDragLeave();
		_dropHoverContainer = null;
		Update();
	}

	public override void OnDragDrop( DragEvent ev )
	{
		base.OnDragDrop( ev );
		_dropHoverContainer = null;

		if ( _document == null || _controller == null ) return;
		if ( ev.Data?.Object is not SuiElementType type ) return;

		var logical = WidgetToLogical( ev.LocalPosition );
		var parent = ResolveDropContainer( logical ) ?? _document.GetRoot();
		if ( parent == null ) return;

		var added = _controller.AddElement( type, parent );
		if ( added != null && added.Layout?.Mode == SuiLayoutMode.Absolute && parent != null )
		{
			// Position the added element at the drop point relative to its parent.
			// Anchor stays at the default (TopLeft) — drop point becomes top-left.
			if ( _solver.TryGetRect( parent.Id, out var pRect ) )
			{
				var localX = MathF.Round( logical.x - pRect.Left );
				var localY = MathF.Round( logical.y - pRect.Top );
				_controller.MoveElement( added, localX, localY );
			}
		}
		ev.Action = DropAction.Copy;
		Update();
	}

	/// <summary>
	/// Resolve the container element under <paramref name="logical"/>: deepest
	/// container element whose rect contains the point. Falls back to root.
	/// </summary>
	private SuiElement ResolveDropContainer( Vector2 logical )
	{
		if ( _document == null ) return null;
		SuiElement best = null;
		foreach ( var el in _document.Elements )
		{
			if ( !IsContainerType( el.Type ) ) continue;
			if ( !_solver.TryGetRect( el.Id, out var rect ) ) continue;
			if ( logical.x < rect.Left || logical.x > rect.Right ) continue;
			if ( logical.y < rect.Top || logical.y > rect.Bottom ) continue;
			best = el; // document order = parent-then-child, so deeper container wins
		}
		return best ?? _document.GetRoot();
	}

	private static bool IsContainerType( SuiElementType type ) => type switch
	{
		SuiElementType.Canvas
			or SuiElementType.Panel
			or SuiElementType.Overlay
			or SuiElementType.HorizontalBox
			or SuiElementType.VerticalBox
			or SuiElementType.Grid
			or SuiElementType.ScrollPanel
			or SuiElementType.InventoryGrid
			or SuiElementType.Hotbar => true,
		_ => false,
	};
}