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

namespace Grains.RazorDesigner.Canvas;

public sealed class CanvasViewportFrame : Widget
{
	private const string LogPrefix = "[Grains.RazorDesigner]";

	private DesignerCanvas _canvas;
	private ZoomHud _hud;
	private CoordChip _coordChip;


	private Vector2? _viewport;
	private PreviewTargetMode _previewTarget = PreviewTargetMode.None;

	private float _zoom = 1.0f;
	private Vector2 _panOffset = Vector2.Zero;

	private const float MinZoom = 0.1f;
	private const float MaxZoom = 10f;

	private const float FitPaddingFraction       = 0.05f;
	private const float SelectionPaddingFraction = 0.10f;
	private const float FrameToContentMaxZoom    = 4.0f; // shared by Fit + Sel

	public CanvasViewportFrame( Widget parent ) : base( parent )
	{
		HorizontalSizeMode = SizeMode.Default | SizeMode.Expand;
		VerticalSizeMode = SizeMode.Default | SizeMode.Expand;
		AcceptDrops = true;
	}

	public DesignerCanvas Canvas
	{
		get => _canvas;
		set
		{
			_canvas = value;
			Log.Info( $"{LogPrefix} CanvasViewportFrame.Canvas setter fired (value={(value is null ? "null" : "valid")}, _hud={(_hud is null ? "null" : (_hud.IsValid() ? "valid" : "STALE"))}, _coordChip={(_coordChip is null ? "null" : (_coordChip.IsValid() ? "valid" : "STALE"))})" );
			if ( _canvas is not null )
			{
				// Back-reference so the canvas's OnKeyPress can dispatch zoom shortcuts to the frame.
				_canvas.ViewportFrame = this;

				// This frame is the sole layout authority; the canvas just fills it.
				_canvas.HorizontalSizeMode = SizeMode.Ignore;
				_canvas.VerticalSizeMode = SizeMode.Ignore;
				_canvas.MinimumWidth = 0;
				_canvas.MinimumHeight = 0;


				if ( TrackedFilenameSource is not null )
					_canvas.DesignerScene.FilenameSource = TrackedFilenameSource;

				PushGridToScene();

				if ( _hud is null || !_hud.IsValid() )
				{
					Log.Info( $"{LogPrefix} CanvasViewportFrame constructing ZoomHud (prior _hud={(_hud is null ? "null" : "STALE")})" );
					_hud = new ZoomHud( this, _canvas );
					_hud.Show();
					Log.Info( $"{LogPrefix} CanvasViewportFrame hosting ZoomHud" );
				}

				if ( _coordChip is null || !_coordChip.IsValid() )
				{
					Log.Info( $"{LogPrefix} CanvasViewportFrame constructing CoordChip (prior _coordChip={(_coordChip is null ? "null" : "STALE")})" );
					_coordChip = new CoordChip( _canvas, this );
					_coordChip.Show();
					_coordChip.Raise(); // ensure it paints on top of the canvas (same as ZoomHud).
					Log.Info( $"{LogPrefix} CanvasViewportFrame hosting CoordChip" );
				}
			}
			ApplyChildLayout();
		}
	}

	public Vector2? Viewport
	{
		get => _viewport;
		set
		{
			if ( _viewport == value ) return;
			_viewport = value;
			Log.Info( $"{LogPrefix} CanvasViewportFrame.Viewport={(value.HasValue ? $"{value.Value.x}x{value.Value.y}" : "Fit")}" );
			if ( _canvas is not null )
				_canvas.DesignerScene.ViewportLogical = value;
			// Mode change invalidates any prior pan/zoom (it was relative to a different surface).
			ResetView();
		}
	}

	public PreviewTargetMode PreviewTarget
	{
		get => _previewTarget;
		set
		{
			if ( _previewTarget == value ) return;
			_previewTarget = value;
			Log.Info( $"{LogPrefix} CanvasViewportFrame.PreviewTarget={value.DisplayLabel()}" );
			if ( _canvas is not null )
				_canvas.DesignerScene.PreviewTarget = value;
			ResetView();
		}
	}

	public LiveTreeMirror Mirror { get; set; }
	public SelectionController Selection { get; set; }

	private System.Func<string> _trackedFilenameSource;
	public System.Func<string> TrackedFilenameSource
	{
		get => _trackedFilenameSource;
		set
		{
			_trackedFilenameSource = value;
			if ( _canvas?.DesignerScene is { } scene )
				scene.FilenameSource = value;
		}
	}

	public readonly record struct GridSettings( bool Show, bool Snap, float StepCss );

	public GridSettings Grid { get; private set; } = new( false, false, 8f );

	/// <summary>Toolbar entry point. Idempotent — re-pushing identical settings is a no-op.</summary>
	public void SetGrid( GridSettings value )
	{
		if ( Grid == value ) return;
		Grid = value;
		Log.Info( $"{LogPrefix} Grid set: show={value.Show} snap={value.Snap} step={value.StepCss:F0}" );
		PushGridToScene();
		ApplyChildLayout();
	}

	private void PushGridToScene()
	{
		if ( _canvas?.DesignerScene is not { } scene ) return;
		scene.GridShow = Grid.Show;
		scene.GridStepCss = Grid.StepCss;
	}

	public event System.Action ZoomChanged;

	// True only when a fixed viewport is set — pan/zoom does nothing in Fit mode.
	public bool CanPanZoom => _viewport is { } v && v.x >= 1f && v.y >= 1f;

	public float Zoom => _zoom;
	public Vector2 PanOffset => _panOffset;

	public void ResetView()
	{
		_zoom = 1.0f;
		_panOffset = Vector2.Zero;
		ApplyChildLayout();
	}

	// Apply a screen-px pan delta (from DesignerCanvas's middle-drag — grd-bkgu). No-op in Fit mode.
	public void Pan( Vector2 deltaScreenPx )
	{
		if ( !CanPanZoom || _canvas is null ) return;
		_panOffset += deltaScreenPx;
		ReclampAndPush();
	}

	public void ZoomBy( float factor, Vector2? anchorWidgetPx = null )
	{
		if ( !CanPanZoom || _canvas is null || factor <= 0f ) return;

		var anchor = anchorWidgetPx ?? new Vector2( Size.x * 0.5f, Size.y * 0.5f );
		var dpi = _canvas.DpiScale > 0.01f ? _canvas.DpiScale : 1.0f;

		var r0 = PanelRectWidgetPx( dpi );
		var fx = r0.Width  > 1f ? (anchor.x - r0.Left) / r0.Width  : 0.5f;
		var fy = r0.Height > 1f ? (anchor.y - r0.Top ) / r0.Height : 0.5f;

		_zoom = Math.Clamp( _zoom * factor, MinZoom, MaxZoom );

		var r1 = PanelRectWidgetPx( dpi );
		_panOffset += new Vector2( (anchor.x - fx * r1.Width) - r1.Left, (anchor.y - fy * r1.Height) - r1.Top );

		ReclampAndPush();
	}

	public void SetView( float zoom, Vector2 panWidgetPx )
	{
		if ( !CanPanZoom ) return;
		_zoom = Math.Clamp( zoom, MinZoom, MaxZoom );
		_panOffset = panWidgetPx;
		ReclampAndPush();
	}

	public void ResetZoomOnly()
	{
		if ( !CanPanZoom ) return;
		_zoom = 1.0f;
		ReclampAndPush();
	}

	public void ApplyZoomToRect( Rect targetFb, float maxZoom, float paddingFraction )
	{
		if ( !CanPanZoom || _canvas is null ) return;
		if ( targetFb.Width <= 0f || targetFb.Height <= 0f )
		{
			Log.Warning( $"{LogPrefix} ApplyZoomToRect: degenerate target ({targetFb}); no-op" );
			return;
		}

		var dpi = _canvas.DpiScale > 0.01f ? _canvas.DpiScale : 1.0f;
		var fbW = MathF.Max( 1f, Size.x * dpi );
		var fbH = MathF.Max( 1f, Size.y * dpi );

		// Target zoom: the rect's scaled-up size should fit in (1 - 2*padding) of each axis.
		var availW = fbW * (1f - 2f * paddingFraction);
		var availH = fbH * (1f - 2f * paddingFraction);
		var zoomX  = availW / targetFb.Width;
		var zoomY  = availH / targetFb.Height;
		var targetZoom = MathF.Min( zoomX, zoomY );

		targetZoom *= _zoom;
		targetZoom = MathF.Min( targetZoom, maxZoom );
		targetZoom = Math.Clamp( targetZoom, MinZoom, MaxZoom );

		var pinned = PinnedScaleOrZero;
		var (centredBounds, scaleAtNewZoom, _) = DesignerScene.ComputeView(
			Size, dpi, _viewport, pinned, targetZoom, Vector2.Zero );

		var currentScale = _canvas.DesignerScene?.CurrentScale ?? 1f;
		if ( currentScale < 0.001f ) currentScale = 1f;
		var currentRootFb = _canvas.DesignerScene?.RootBoundsFb ?? new Rect( 0, 0, fbW, fbH );

		var targetCssX = (targetFb.Left   + targetFb.Width  * 0.5f - currentRootFb.Left) / currentScale;
		var targetCssY = (targetFb.Top    + targetFb.Height * 0.5f - currentRootFb.Top ) / currentScale;
		var targetCentreNewFb = new Vector2(
			centredBounds.Left + targetCssX * scaleAtNewZoom,
			centredBounds.Top  + targetCssY * scaleAtNewZoom );

		var canvasCentreFb = new Vector2( fbW * 0.5f, fbH * 0.5f );
		var requiredPanFb  = canvasCentreFb - targetCentreNewFb;
		var requiredPanWidget = requiredPanFb / dpi;

		SetView( targetZoom, requiredPanWidget );

		Log.Info( $"{LogPrefix} ApplyZoomToRect: targetFb={targetFb} → zoom={targetZoom:F3} pan=({requiredPanWidget.x:F1},{requiredPanWidget.y:F1})" );
	}

	public void ApplyFit()
	{
		if ( !CanPanZoom || _canvas is null ) return;

		// Build a CanvasGeometry snapshot.
		var geo = CanvasGeometry.From( _canvas.DesignerScene, _canvas.DpiScale );

		Rect targetFb;
		if ( Mirror is not null )
		{
			var bbox = geo.SubtreeContentBoundsFb( Mirror.TopLevelAuthoredPanels() );
			if ( bbox.HasValue )
			{
				targetFb = bbox.Value;
			}
			else
			{
				// Empty doc fallback — frame the RootPanel.
				targetFb = geo.RootBoundsFb;
				Log.Info( $"{LogPrefix} ApplyFit: empty content, falling back to RootPanel bbox" );
			}
		}
		else
		{
			targetFb = geo.RootBoundsFb;
			Log.Warning( $"{LogPrefix} ApplyFit: mirror unavailable; using RootPanel bbox" );
		}

		Log.Info( $"{LogPrefix} ApplyFit: targetFb={targetFb}" );
		ApplyZoomToRect( targetFb, FrameToContentMaxZoom, FitPaddingFraction );
	}

	protected override void OnMouseWheel( WheelEvent e )
	{
		if ( !CanPanZoom || _canvas is null )
			return; // not accepted -> bubbles

		var dpi = _canvas.DpiScale > 0.01f ? _canvas.DpiScale : 1.0f;
		var m = e.Position; // widget px (this frame == the canvas widget)

		// Fraction of the panel rect under the cursor — preserve it across the zoom change.
		var r0 = PanelRectWidgetPx( dpi );
		var fx = r0.Width > 1f ? (m.x - r0.Left) / r0.Width : 0.5f;
		var fy = r0.Height > 1f ? (m.y - r0.Top) / r0.Height : 0.5f;

		_zoom = Math.Clamp( _zoom * (1f + e.Delta * 0.001f), MinZoom, MaxZoom );

		// Re-pin so the same logical point stays under the cursor (zoom-toward-cursor).
		var r1 = PanelRectWidgetPx( dpi );
		_panOffset += new Vector2( (m.x - fx * r1.Width) - r1.Left, (m.y - fy * r1.Height) - r1.Top );

		ReclampAndPush();
		e.Accepted = true;
	}

	protected override void DoLayout()
	{
		base.DoLayout();
		ApplyChildLayout();
	}



	// Pin the canvas to fill us, re-clamp the pan to the (possibly new) size, and push the view to the scene.
	private void ApplyChildLayout()
	{
		if ( _canvas is null ) return;
		var size = Size;
		if ( size.x < 1f || size.y < 1f ) return;

		if ( _canvas.Position != Vector2.Zero || _canvas.Size != size )
		{
			_canvas.Position = Vector2.Zero;
			_canvas.Size = size;
		}



		ReclampAndPush();
	}

	private void ReclampAndPush()
	{
		if ( _canvas is null ) return;

		if ( CanPanZoom )
		{
			var dpi = _canvas.DpiScale > 0.01f ? _canvas.DpiScale : 1.0f;
			var (_, _, clampedPan) = DesignerScene.ComputeView( Size, dpi, _viewport, PinnedScaleOrZero, _zoom, _panOffset );
			_panOffset = clampedPan;
		}
		else
		{
			_zoom = 1.0f;
			_panOffset = Vector2.Zero;
		}

		if ( _canvas.DesignerScene is { } scene )
		{
			scene.Zoom = CanPanZoom ? _zoom : 1.0f;
			scene.PanOffsetWidgetPx = CanPanZoom ? _panOffset : Vector2.Zero;
		}

		ZoomChanged?.Invoke();
	}

	// The RootPanel rect in this widget's pixels, for cursor math. Mirrors what the scene will lay out.
	private Rect PanelRectWidgetPx( float dpi )
	{
		var (bounds, _, _) = DesignerScene.ComputeView( Size, dpi, _viewport, PinnedScaleOrZero, _zoom, _panOffset );
		return new Rect( bounds.Position / dpi, bounds.Size / dpi );
	}

	private float PinnedScaleOrZero => _previewTarget.IsPinned() ? _previewTarget.PinnedScale() : 0f;

	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( new Color( 0.06f, 0.07f, 0.08f ) );
		Paint.DrawRect( LocalRect, 0f );
	}
}