Editor/Canvas/CanvasGeometry.cs
using System.Collections.Generic;
using Sandbox;
using Sandbox.UI;

namespace Grains.RazorDesigner.Canvas;

public readonly struct CanvasGeometry
{
	private const string LogPrefix = "[Grains.RazorDesigner]";
	private static bool _logged;

	/// <summary>RootPanel.Scale — framebuffer px per CSS px. Clamped ≥ ~0.001 in <see cref="From"/>.</summary>
	public float Scale { get; }

	/// <summary>The RootPanel rect in framebuffer px, relative to the canvas widget's top-left.</summary>
	public Rect RootBoundsFb { get; }

	/// <summary>Framebuffer px per widget px. Clamped ≥ ~0.01 in <see cref="From"/>.</summary>
	public float DpiScale { get; }

	private CanvasGeometry( float scale, Rect rootBoundsFb, float dpiScale )
	{
		Scale = scale;
		RootBoundsFb = rootBoundsFb;
		DpiScale = dpiScale;
	}

	public static CanvasGeometry From( DesignerScene scene, float widgetDpiScale )
	{
		var scale = (scene is not null && scene.CurrentScale >= 0.001f) ? scene.CurrentScale : 1f;
		var dpi = widgetDpiScale >= 0.01f ? widgetDpiScale : 1f;
		var rootFb = scene?.RootBoundsFb ?? default;

		if ( !_logged )
		{
			Log.Info( $"{LogPrefix} CanvasGeometry first built (scale={scale:F3} rootFb={rootFb} dpi={dpi:F2})" );
			_logged = true;
		}

		return new CanvasGeometry( scale, rootFb, dpi );
	}

	// ---- point conversions (Vector2 → Vector2) ----
	public Vector2 WidgetToFb( Vector2 p ) => p * DpiScale;
	public Vector2 FbToWidget( Vector2 p ) => p / DpiScale;
	public Vector2 FbToCss( Vector2 p ) => new( (p.x - RootBoundsFb.Left) / Scale, (p.y - RootBoundsFb.Top) / Scale );
	public Vector2 CssToFb( Vector2 p ) => new( p.x * Scale + RootBoundsFb.Left, p.y * Scale + RootBoundsFb.Top );
	public Vector2 WidgetToCss( Vector2 p ) => FbToCss( WidgetToFb( p ) );
	public Vector2 CssToWidget( Vector2 p ) => FbToWidget( CssToFb( p ) );

	public Rect WidgetToFb( Rect r ) => new( r.Left * DpiScale, r.Top * DpiScale, r.Width * DpiScale, r.Height * DpiScale );
	public Rect FbToWidget( Rect r ) => new( r.Left / DpiScale, r.Top / DpiScale, r.Width / DpiScale, r.Height / DpiScale );
	public Rect FbToCss( Rect r ) => new( (r.Left - RootBoundsFb.Left) / Scale, (r.Top - RootBoundsFb.Top) / Scale, r.Width / Scale, r.Height / Scale );
	public Rect CssToFb( Rect r ) => new( r.Left * Scale + RootBoundsFb.Left, r.Top * Scale + RootBoundsFb.Top, r.Width * Scale, r.Height * Scale );
	public Rect WidgetToCss( Rect r ) => FbToCss( WidgetToFb( r ) );
	public Rect CssToWidget( Rect r ) => FbToWidget( CssToFb( r ) );

	// ---- deltas: scale only, no origin shift (drag math) ----
	public Vector2 WidgetDeltaToCss( Vector2 widgetDelta ) => widgetDelta * DpiScale / Scale;

	public float ConstantCss( float fbPx ) => fbPx / Scale;
	public float ConstantWidget( float widgetPx ) => widgetPx * DpiScale / Scale;

	// ---- live-panel box rects (framebuffer px). Caller must ensure p is a valid Panel. ----
	public Rect MarginBoxFb( Panel p ) => p.Box.RectOuter;
	public Rect BorderBoxFb( Panel p ) => p.Box.Rect;
	public Rect ContentBoxFb( Panel p ) => p.Box.RectInner;

	public Rect? SubtreeContentBoundsFb( IEnumerable<Panel> panels )
	{
		if ( panels is null ) return null;

		bool any = false;
		float minX = float.PositiveInfinity, minY = float.PositiveInfinity;
		float maxX = float.NegativeInfinity, maxY = float.NegativeInfinity;

		foreach ( var p in panels )
		{
			if ( p is null || !p.IsValid ) continue;
			var r = p.Box.Rect; // border box, framebuffer px
			if ( r.Width <= 0f || r.Height <= 0f ) continue;
			if ( r.Left   < minX ) minX = r.Left;
			if ( r.Top    < minY ) minY = r.Top;
			if ( r.Right  > maxX ) maxX = r.Right;
			if ( r.Bottom > maxY ) maxY = r.Bottom;
			any = true;
		}

		if ( !any ) return null;
		return new Rect( minX, minY, maxX - minX, maxY - minY );
	}

	public static (float Left, float Top, float Width, float Height) ResolveAbsoluteCss(
		Rect borderBoxFb, Rect parentContentBoxFb, Margin marginFb, float scale )
	{
		if ( scale < 0.001f ) scale = 1f;
		// Only Left/Top margins shift the CSS `left`/`top`; Width/Height are border-box dimensions, so margin.Right/Bottom don't enter.
		return (
			(borderBoxFb.Left - parentContentBoxFb.Left) / scale - marginFb.Left / scale,
			(borderBoxFb.Top  - parentContentBoxFb.Top ) / scale - marginFb.Top  / scale,
			borderBoxFb.Width  / scale,
			borderBoxFb.Height / scale );
	}
}