Editor/SuiPreviewHost.cs
using System;
using System.Reflection;
using Editor;
using Sandbox;
using Sandbox.UI;
using SboxUiDesigner.Runtime;
using WorldPanel = Sandbox.WorldPanel;

namespace SboxUiDesigner.EditorUi;

/// <summary>
/// Editor-owned <see cref="Scene"/> that hosts the runtime UI preview rendered
/// inside the designer canvas via <see cref="Editor.SceneRenderingWidget"/>.
///
/// The scene contains:
///  - A <see cref="CameraComponent"/> aimed at the panel
///  - Ambient + directional lights (so any 3D content composited inside is lit)
///  - A "UIHost" GameObject carrying a <see cref="WorldPanel"/> and the
///    currently-loaded PanelComponent (the type generated from the .sui doc;
///    falls back to <see cref="SuiTestPanel"/> until a doc is loaded).
///
/// Critical lifecycle quirk: editor-owned scenes don't dispatch
/// <c>IPreRenderSubscriber</c> callbacks automatically, so neither
/// <see cref="WorldPanel.OnEnabled"/> (which creates the runtime panel) nor
/// <see cref="WorldPanel.OnPreRender"/> (which positions it) ever fire on their
/// own. We manually invoke them via reflection — see <see cref="WireWorldPanelLifecycle"/>
/// and <see cref="WirePanelComponentLifecycle"/>. PanelComponent's
/// <c>OnEnabledInternal</c> is similarly dormant; reflection again.
/// </summary>
public sealed class SuiPreviewHost
{
	public Scene Scene { get; }
	public CameraComponent Camera { get; private set; }

	/// <summary>Logical pixel size of the preview surface (matches WorldPanel.PanelSize).</summary>
	public Vector2 PanelSize { get; } = new( 1920, 1080 );

	private GameObject _uiHost;
	private WorldPanel _worldPanel;
	private Component _panelComponent;          // currently mounted PanelComponent
	private string _panelComponentTypeName;      // for swap detection
	private int _frameCounter;

	private MethodInfo _wpOnEnabled;
	private MethodInfo _wpOnPreRender;

	public SuiPreviewHost()
	{
		Scene = Scene.CreateEditorScene();

		using ( Scene.Push() )
		{
			BuildCamera();
			BuildLights();
			BuildUiHost();
		}

		var bf = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
		_wpOnEnabled = typeof( WorldPanel ).GetMethod( "OnEnabled", bf );
		_wpOnPreRender = typeof( WorldPanel ).GetMethod( "OnPreRender", bf );

		WireWorldPanelLifecycle();

		// Mount the static fallback panel on first construction so the canvas
		// shows something before SetPanelType is ever called.
		SetPanelType( typeof( SuiTestPanel ) );

		Log.Info( $"[Sui preview] Scene built. IsEditor={Scene.IsEditor}, SceneWorld={(Scene.SceneWorld != null ? "valid" : "null")}, Camera={(Scene.Camera?.GameObject?.Name ?? "null")}" );
	}

	private void BuildCamera()
	{
		var cameraGo = new GameObject( true, "Camera" );
		Camera = cameraGo.GetOrAddComponent<CameraComponent>( false );
		Camera.BackgroundColor = new Color( 0.08f, 0.08f, 0.10f, 1f );
		Camera.ZFar = 4096;
		Camera.FieldOfView = 60;
		// CameraComponent default = Horizontal FOV (per Facepunch source). Setting
		// FovAxis=Vertical caused the panel to disappear (probably runs the conversion
		// at a moment where Screen aspect isn't valid yet). Stick with the default.
		// CameraComponent default = Horizontal FOV. Visible horizontal at D = D*tan(FOV/2)*2.
		// Pra panel 1920 logical (96 world) preencher horizontalmente: D = 96/1.155 = 83.
		// Em widget 16:9, isso também faz vertical fit (panel = 54 world, vertical visible
		// = 96/aspect = 54). Pra outras aspects o GetPreviewRect aspect-fita o panel.
		Camera.WorldPosition = new Vector3( -85f, 0, 0 );
		Camera.WorldRotation = Rotation.Identity;
		Camera.Enabled = true;
	}

	/// <summary>
	/// Move the camera so the 96×54 world-space panel fits inside the
	/// SceneRenderingWidget at the given aspect. With CameraComponent's
	/// horizontal FOV, the camera-tuned distance D=83 fits exactly at 16:9.
	/// Other aspects need a different distance:
	///
	///   visible_width  at distance D = 2·D·tan(FOV/2)
	///   visible_height = visible_width / widget_aspect
	///
	/// To keep BOTH panel dimensions inside view: take the larger of
	/// (fit-width distance, fit-height distance). Call every frame from the
	/// SceneRenderingWidget host so layout changes update the camera.
	/// </summary>
	public void FitCameraToWidgetSize( Vector2 widgetSize )
	{
		if ( Camera == null || !Camera.IsValid() ) return;
		if ( widgetSize.x < 1 || widgetSize.y < 1 ) return;

		var widgetAspect = widgetSize.x / widgetSize.y;

		const float panelWorldWidth = 96f;
		const float panelWorldHeight = 54f;
		const float tanHalfFovHorizontal = 0.57735f; // tan(60°/2)

		var distanceForWidth = panelWorldWidth / (2f * tanHalfFovHorizontal);            // ≈ 83
		var distanceForHeight = panelWorldHeight * widgetAspect / (2f * tanHalfFovHorizontal); // ≈ 46.75 · aspect

		var distance = System.MathF.Max( distanceForWidth, distanceForHeight );

		var current = Camera.WorldPosition;
		// Skip if effectively unchanged — avoid Update spam in OnPreFrame.
		if ( System.MathF.Abs( -current.x - distance ) < 0.05f ) return;
		Camera.WorldPosition = new Vector3( -distance, current.y, current.z );
	}

	private void BuildLights()
	{
		var ambient = new GameObject( true, "Ambient" ).GetOrAddComponent<AmbientLight>( false );
		ambient.Color = new Color( 0.3f, 0.32f, 0.36f, 1f );
		ambient.Enabled = true;

		var directional = new GameObject( true, "Directional" ).GetOrAddComponent<DirectionalLight>( false );
		directional.WorldRotation = Rotation.From( 45, 45, 0 );
		directional.LightColor = Color.White;
		directional.Enabled = true;
	}

	private void BuildUiHost()
	{
		_uiHost = new GameObject( true, "UIHost" );
		_uiHost.WorldPosition = Vector3.Zero;
		_uiHost.WorldScale = Vector3.One;

		_worldPanel = _uiHost.AddComponent<WorldPanel>();
		_worldPanel.LookAtCamera = true;
		_worldPanel.PanelSize = PanelSize;
		_worldPanel.RenderScale = 1.0f;
		_worldPanel.HorizontalAlign = WorldPanel.HAlignment.Center;
		_worldPanel.VerticalAlign = WorldPanel.VAlignment.Center;

		// SceneRenderingWidget doesn't fire the Game render pass for panels in
		// editor-owned scenes — Overlay + AfterUI are drawn outside the camera's
		// post-process compositor and reliably show up.
		_worldPanel.RenderOptions.Game = true;
		_worldPanel.RenderOptions.Overlay = true;
		_worldPanel.RenderOptions.AfterUI = true;
	}

	// ─────────────────────────────────────────────────────────────────────
	//  Public API for the canvas widget
	// ─────────────────────────────────────────────────────────────────────

	/// <summary>
	/// Swap the currently-mounted PanelComponent for one of the given known C# type.
	/// Used for the static SuiTestPanel fallback. Generated Razor types should go
	/// through <see cref="TrySetPanelTypeByName"/> because their TargetType isn't
	/// always exposed.
	/// </summary>
	public bool SetPanelType( Type panelType )
	{
		if ( panelType == null ) return false;
		var desc = TypeLibrary.GetType( panelType );
		if ( desc == null ) return false;
		return MountByDescriptor( desc );
	}

	/// <summary>
	/// Look up <paramref name="typeFullName"/> in the TypeLibrary and mount it as
	/// the preview's PanelComponent. Returns false if the type isn't loaded yet
	/// (caller should retry — the editor's hotload may still be compiling).
	/// </summary>
	public bool TrySetPanelTypeByName( string typeFullName )
	{
		if ( string.IsNullOrEmpty( typeFullName ) ) return false;
		var desc = TypeLibrary.GetType( typeFullName );
		if ( desc == null )
		{
			// Throttled — only log first miss per pending name (caller polls every frame).
			if ( _lastTypeMissLogged != typeFullName )
			{
				Log.Info( $"[Sui preview] TypeLibrary.GetType('{typeFullName}') returned null — type not loaded yet." );
				_lastTypeMissLogged = typeFullName;
			}
			return false;
		}
		_lastTypeMissLogged = null;
		return MountByDescriptor( desc );
	}

	private string _lastTypeMissLogged;

	/// <summary>The currently-mounted PanelComponent, or null.</summary>
	public Component PanelComponent => _panelComponent;

	// ─────────────────────────────────────────────────────────────────────
	//  Mount / swap mechanics
	// ─────────────────────────────────────────────────────────────────────

	private bool MountByDescriptor( Sandbox.TypeDescription typeDesc )
	{
		var fullName = typeDesc?.FullName ?? "<null>";

		// Already-mounted is treated as success — caller is asking "is this type
		// now mounted?" and the answer is yes. Avoids the polling loop
		// re-firing every frame because we kept returning false.
		if ( _panelComponent != null && _panelComponent.IsValid() && _panelComponentTypeName == fullName )
			return true;

		Log.Info( $"[Sui preview] MountByDescriptor: fullName='{fullName}', current='{_panelComponentTypeName}', current valid={_panelComponent?.IsValid() ?? false}" );

		try
		{
			using ( Scene.Push() )
			{
				// Tear down old component cleanly.
				if ( _panelComponent != null && _panelComponent.IsValid() )
				{
					Log.Info( $"[Sui preview] tearing down old '{_panelComponentTypeName}'" );
					TryInvokeLifecycle( _panelComponent, "OnDisabledInternal" );
					try { _panelComponent.Destroy(); } catch ( Exception destroyEx ) { Log.Warning( $"[Sui preview] destroy old threw: {destroyEx.Message}" ); }
					_panelComponent = null;
				}

				Log.Info( $"[Sui preview] calling Components.Create for '{fullName}'" );
				var created = _uiHost.Components.Create( typeDesc );
				Log.Info( $"[Sui preview] Components.Create returned: {(created == null ? "null" : created.GetType().FullName)}" );
				_panelComponent = created as Component;

				if ( _panelComponent == null )
				{
					Log.Warning( $"[Sui preview] Components.Create returned non-Component or null for {fullName} (raw type: {created?.GetType().FullName ?? "null"})" );
					return false;
				}

				_panelComponentTypeName = fullName;
				WirePanelComponentLifecycle();
				Log.Info( $"[Sui preview] MountByDescriptor: success, mounted '{fullName}'" );
				return true;
			}
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[Sui preview] MountByDescriptor threw for '{fullName}': {ex.GetType().Name}: {ex.Message}" );
			if ( ex.InnerException != null ) Log.Warning( $"[Sui preview]   inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}" );
			return false;
		}
	}

	private void WireWorldPanelLifecycle()
	{
		// WorldPanel.OnEnabled creates the runtime Sandbox.UI.WorldPanel that
		// hosts the panel tree — the GetPanel() call returns null until this runs.
		try
		{
			_wpOnEnabled?.Invoke( _worldPanel, null );
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[Sui preview] WorldPanel.OnEnabled threw: {ex.InnerException?.Message ?? ex.Message}" );
		}

		// Sandbox.UI.WorldPanel constructor sets Scale = 2.0f, which makes every
		// child render at twice its CSS size (300px shows as 600). Override to 1.0
		// so the user-authored pixel sizes match what the SCSS generator emits and
		// what our hit-test math expects. Note: GetPanel() returns the base Panel —
		// the Scale field lives on RootPanel and we set it via reflection.
		var rootPanel = _worldPanel.GetPanel();
		if ( rootPanel != null )
		{
			try
			{
				var scaleProp = rootPanel.GetType().GetProperty( "Scale",
					BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic );
				if ( scaleProp != null && scaleProp.CanWrite )
				{
					scaleProp.SetValue( rootPanel, 1.0f );
					Log.Info( "[Sui preview] forced runtime panel Scale=1.0 (override default 2.0)" );
				}
				else
				{
					Log.Warning( "[Sui preview] couldn't find writable Scale property on runtime panel" );
				}
			}
			catch ( Exception ex )
			{
				Log.Warning( $"[Sui preview] setting Scale threw: {ex.Message}" );
			}
		}
	}

	private void WirePanelComponentLifecycle()
	{
		// PanelComponent.OnEnabledInternal -> EnsurePanelCreated + UpdateParent.
		// The latter walks Components and binds to the IRootPanelComponent
		// (our WorldPanel), which is the moment the user's UI goes live.
		TryInvokeLifecycle( _panelComponent, "OnEnabledInternal" );
	}

	private static void TryInvokeLifecycle( Component target, string methodName )
	{
		if ( target == null ) return;
		var bf = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
		var m = FindMethodInHierarchy( target.GetType(), methodName, bf );
		if ( m == null )
		{
			Log.Warning( $"[Sui preview] {target.GetType().Name}.{methodName} not found via reflection." );
			return;
		}
		try
		{
			m.Invoke( target, null );
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[Sui preview] {target.GetType().Name}.{methodName} threw: {ex.InnerException?.Message ?? ex.Message}" );
		}
	}

	private static MethodInfo FindMethodInHierarchy( Type type, string methodName, BindingFlags flags )
	{
		while ( type != null )
		{
			var m = type.GetMethod( methodName, flags | BindingFlags.DeclaredOnly );
			if ( m != null ) return m;
			type = type.BaseType;
		}
		return null;
	}

	// ─────────────────────────────────────────────────────────────────────
	//  Tick — driven by SceneRenderingWidget.OnPreFrame
	// ─────────────────────────────────────────────────────────────────────

	public void Tick()
	{
		if ( Scene == null ) return;
		Scene.GameTick( RealTime.Delta );

		// Re-run WorldPanel.OnPreRender every frame so Transform/PanelBounds
		// reflect the latest WorldPosition/Rotation (LookAtCamera).
		if ( _worldPanel != null && _worldPanel.IsValid() && _wpOnPreRender != null )
		{
			try { _wpOnPreRender.Invoke( _worldPanel, null ); }
			catch { /* swallowed — would flood log on transient errors */ }
		}

		_frameCounter++;
	}

	/// <summary>
	/// Pixel-space → logical-pixel-space mapping. The preview is always laid out
	/// to fill the canvas while preserving the panel's aspect ratio (see
	/// SuiCanvasWidget.LayoutPreviewRect). Returns true if the point falls inside
	/// the panel and outputs the logical pixel position (0..PanelSize).
	/// </summary>
	public bool MapWidgetPixelToPanel( Vector2 widgetPixel, Rect previewRect, out Vector2 logical )
	{
		logical = default;
		if ( previewRect.Width <= 0 || previewRect.Height <= 0 ) return false;

		var local = widgetPixel - previewRect.TopLeft;
		if ( local.x < 0 || local.y < 0 || local.x > previewRect.Width || local.y > previewRect.Height )
			return false;

		logical = new Vector2(
			local.x / previewRect.Width * PanelSize.x,
			local.y / previewRect.Height * PanelSize.y );
		return true;
	}

	/// <summary>
	/// Logical-pixel-space → widget pixel-space. Inverse of MapWidgetPixelToPanel.
	/// </summary>
	public Vector2 MapPanelToWidgetPixel( Vector2 logical, Rect previewRect )
	{
		return new Vector2(
			previewRect.TopLeft.x + logical.x / PanelSize.x * previewRect.Width,
			previewRect.TopLeft.y + logical.y / PanelSize.y * previewRect.Height );
	}
}