Editor/McpExtras.cs
using Sandbox;
using System;
using System.IO;
using System.Linq;

namespace Editor.Mcp;

/// <summary>
/// Local extensions to the built in MCP tools, living in a shared library so every project here
/// gets them without waiting on an engine release. Tool names are prefixed 'x_' so they can never
/// collide with the engine's own once the upstream equivalents land.
/// </summary>
[McpToolset( "extras", "Local extensions to the built-in MCP tools" )]
public static partial class ExtrasTools
{
	/// <summary>
	/// Make a scene the active editor tab, opening it from its asset path when it isn't open yet.
	/// Scene edits always target the active scene, so switch before editing a background scene.
	/// Returns the tab it settled on - name, resource path, type, unsaved changes and root object
	/// count - plus a message saying what happened. list_scenes shows what's already open.
	/// </summary>
	/// <param name="scene">Scene name or resource path as list_scenes reports it, or a .scene/.prefab path from asset_search.</param>
	[McpTool( "x_open_scene" )]
	public static SceneTab OpenSceneTab( string scene )
	{
		if ( string.IsNullOrWhiteSpace( scene ) )
			throw new Exception( "Give a scene name or resource path - list_scenes shows what's open, asset_search type:scene finds scene assets on disk" );

		if ( Game.IsPlaying )
			throw new Exception( "Can't switch scene tabs while playing - play_stop first" );

		var session = FindSession( scene );

		if ( session is GameEditorSession )
			throw new Exception( "That's the running game session, which has no tab to switch to - play_stop first, then open the scene you want to edit" );

		if ( session is not null && session == SceneEditorSession.Active )
			return Row( session, $"'{session.Scene?.Name}' was already the active tab - nothing changed" );

		var opened = session is null;

		session ??= SceneEditorSession.CreateFromPath( scene )
			?? throw new Exception( $"Nothing to open for '{scene}' - list_scenes shows what's already open, asset_search type:scene finds scene assets on disk" );

		session.MakeActive();

		return Row( session, opened
			? $"Opened '{session.Scene?.Name}' from disk and made it the active tab"
			: $"Switched the active tab to the already open '{session.Scene?.Name}'" );
	}

	/// <summary>
	/// Create a new empty scene beneath scenes/diagnostics, save it without prompting, and make
	/// its tab active. Existing tabs, including dirty tabs, are left untouched.
	/// </summary>
	/// <param name="path">Project-relative .scene path beneath scenes/diagnostics.</param>
	/// <param name="name">Optional scene name. Defaults to the destination file name.</param>
	[McpTool( "x_create_scene" )]
	public static SceneTab CreateScene( string path, string name = "" )
	{
		if ( Game.IsPlaying )
			throw new Exception( "Can't create a scene while playing - play_stop first" );

		var destination = ValidateDiagnosticScenePath( path );
		var resourcePath = destination.RelativePath;
		var sceneName = string.IsNullOrWhiteSpace( name )
			? Path.GetFileNameWithoutExtension( resourcePath )
			: name.Trim();

		if ( File.Exists( destination.AbsolutePath ) || AssetSystem.FindByPath( resourcePath ) is not null )
			throw new Exception( $"A scene already exists at '{resourcePath}' - choose a new diagnostic path" );

		var previous = SceneEditorSession.Active;
		SceneEditorSession created = null;
		Asset asset = null;
		var saved = false;

		try
		{
			created = SceneEditorSession.CreateDefault()
				?? throw new Exception( "Couldn't create a new editor scene session" );

			var scene = created.Scene;
			foreach ( var child in scene.Children.ToArray() )
				child.Destroy();
			scene.ProcessDeletes();
			scene.Name = sceneName;

			Directory.CreateDirectory( Path.GetDirectoryName( destination.AbsolutePath ) );

			asset = AssetSystem.CreateResource( "scene", destination.AbsolutePath )
				?? throw new Exception( $"Couldn't create the scene resource at '{resourcePath}'" );

			var sceneFile = new SceneFile
			{
				Id = Guid.NewGuid(),
				GameObjects = [],
				SceneProperties = scene.SerializeProperties()
			};

			saved = asset.SaveToDisk( sceneFile );
			if ( !saved )
				throw new Exception( $"Couldn't save the new scene at '{resourcePath}'" );

			created.Destroy();
			created = null;

			var opened = SceneEditorSession.CreateFromPath( resourcePath )
				?? throw new Exception( $"The new scene was saved but couldn't be opened at '{resourcePath}'" );

			opened.MakeActive();
			return Row( opened, $"Created '{opened.Scene?.Name}' at '{resourcePath}' and made it the active tab" );
		}
		catch
		{
			created?.Destroy();

			if ( asset is not null )
				asset.Delete();

			if ( previous is not null && previous != SceneEditorSession.Active )
				previous.MakeActive();

			throw;
		}
	}



	/// <summary>
	/// What the editor is doing right now - which project is open, which scene tab is active and
	/// whether it has unsaved changes, and whether play mode is running or paused. ActiveScene here
	/// is the editor's active tab, which is what the scene tools edit; the built in editor_status
	/// reports the running game's scene instead and can disagree while playing. Follow up with
	/// scene_tree for the hierarchy, or x_open_scene to switch tabs.
	/// </summary>
	[McpTool.ReadOnly( "x_editor_status" )]
	public static EditorStatusExtras GetEditorStatus()
	{
		var session = SceneEditorSession.Active;
		var scene = session?.Scene ?? Game.ActiveScene;

		return new EditorStatusExtras
		{
			Project = Project.Current?.Config?.Ident,
			ProjectTitle = Project.Current?.Config?.Title,
			ActiveScene = scene?.Name,
			ActiveScenePath = scene?.Source?.ResourcePath,
			SceneHasUnsavedChanges = session?.HasUnsavedChanges ?? false,
			OpenSceneCount = SceneEditorSession.All.Count,
			IsPlaying = Game.IsPlaying,
			IsPaused = Game.IsPaused
		};
	}

	/// <summary>
	/// Render a camera in the scene and return it as an image, with UI text intact. Give any
	/// CameraComponent's id or its game object's id, or nothing for the scene's main camera.
	/// Use this instead of camera_screenshot whenever the shot includes Razor UI: camera_screenshot
	/// renders every text label as a flat gray rectangle at any size other than the live viewport's,
	/// because rendering offscreen relayouts the UI, which throws away each label's text texture
	/// without rebuilding the render descriptors that point at it. In play mode this tool always
	/// renders at the native screen resolution - where that relayout is a no-op, so the descriptors
	/// stay valid - and downscales the result to the size you asked for: the output matches the
	/// requested width and height exactly, but its detail is capped at the viewport's resolution,
	/// so asking for more pixels than the viewport has gets you an upscale, not more detail. In
	/// edit mode there is no game viewport, so no live screen-size UI exists to corrupt and it
	/// renders directly at the requested size, at full detail - making this a drop in replacement
	/// for camera_screenshot in both modes. find_game_objects with component 'Camera' lists the
	/// cameras in a scene.
	/// </summary>
	/// <param name="camera">A CameraComponent id or its game object's id. Empty uses the scene's main camera.</param>
	/// <param name="width">Image width in pixels.</param>
	/// <param name="height">Image height in pixels.</param>
	/// <param name="includeUi">Include any UI the camera renders.</param>
	[McpTool.ReadOnly( "x_camera_screenshot" )]
	public static object CameraScreenshotNative( string camera = "", [Sandbox.Range( 16, 4096 )] int width = 1280,
		[Sandbox.Range( 16, 4096 )] int height = 720, bool includeUi = true )
	{
		var target = ResolveCamera( camera );

		if ( !target.IsValid() )
			throw new Exception( "The scene has no camera - find one with find_game_objects component 'Camera', or add one" );

		// The one size the engine bug can't bite: identical to the screen, so the offscreen
		// relayout changes no panel's size and no text texture gets released underneath its
		// descriptor. Everything else is a downscale we do ourselves.
		var nativeWidth = Screen.Width.CeilToInt();
		var nativeHeight = Screen.Height.CeilToInt();

		// No screen size means no game viewport - edit mode. Nothing live is laid out at the
		// screen's size, so there are no text textures a relayout can destroy, and we can render
		// straight at the size asked for, exactly as the built in camera_screenshot does.
		if ( nativeWidth <= 1 || nativeHeight <= 1 )
		{
			var direct = new Bitmap( width, height );
			target.RenderToBitmap( direct, includeUi );
			return direct;
		}

		var bitmap = new Bitmap( nativeWidth, nativeHeight );
		target.RenderToBitmap( bitmap, includeUi );

		if ( nativeWidth == width && nativeHeight == height )
			return bitmap;

		// Resize hands back a new bitmap, so the native capture is ours to release
		using ( bitmap )
		{
			return bitmap.Resize( width, height );
		}
	}

	/// <summary>
	/// Aim a camera game object at a world-space point using the engine's Rotation.LookAt.
	/// The camera must belong to the active editor scene. The edit is undoable.
	/// </summary>
	/// <param name="camera">A CameraComponent id or its game object's id.</param>
	/// <param name="target">World-space target as 'x,y,z'.</param>
	/// <param name="up">World-space up direction as 'x,y,z'. Defaults to +Z.</param>
	[McpTool( "x_camera_look_at" )]
	public static CameraLookAtResult CameraLookAt( string camera, string target, string up = "0,0,1" )
	{
		if ( string.IsNullOrWhiteSpace( camera ) )
			throw new Exception( "Give a CameraComponent or camera game object id - find_game_objects component 'Camera' lists them" );

		var session = SceneEditorSession.Active
			?? throw new Exception( "No editor scene tab is active" );
		var component = ResolveCamera( camera );
		if ( component.Scene != session.Scene )
			throw new Exception( "The camera isn't in the active editor scene - use x_open_scene or switch_scene first" );

		var targetPosition = Vector3.Parse( target );
		var upDirection = Vector3.Parse( up );
		var direction = targetPosition - component.WorldPosition;
		if ( direction.LengthSquared < 0.000001f )
			throw new Exception( "The camera position and look-at target must differ" );
		if ( upDirection.LengthSquared < 0.000001f )
			throw new Exception( "The look-at up direction must be non-zero" );

		var gameObject = component.GameObject;
		using ( session.UndoScope( "Aim Camera" ).WithGameObjectChanges( gameObject, GameObjectUndoFlags.All ).Push() )
		{
			gameObject.WorldRotation = Rotation.LookAt( direction.Normal, upDirection.Normal );
		}

		return new CameraLookAtResult
		{
			Id = gameObject.Id,
			Name = gameObject.Name,
			Position = gameObject.WorldPosition,
			Target = targetPosition,
			Angles = gameObject.WorldRotation.Angles()
		};
	}

	/// <summary>The resulting camera aim.</summary>
	public class CameraLookAtResult
	{
		public Guid Id { get; set; }
		public string Name { get; set; }
		public Vector3 Position { get; set; }
		public Vector3 Target { get; set; }
		public Angles Angles { get; set; }
	}

	/// <summary>
	/// Set one game object's saved networking mode in the active editor scene. The edit is undoable.
	/// </summary>
	/// <param name="id">Game object GUID from scene_tree or find_game_objects.</param>
	/// <param name="mode">Never, Object, or Snapshot.</param>
	[McpTool( "x_set_network_mode" )]
	public static NetworkModeResult SetNetworkMode( string id, NetworkMode mode )
	{
		if ( Game.IsPlaying )
			throw new Exception( "Can't change saved network mode while playing - play_stop first" );
		if ( !Guid.TryParse( id, out var guid ) )
			throw new Exception( $"'{id}' isn't a game object GUID" );

		var session = SceneEditorSession.Active
			?? throw new Exception( "No editor scene tab is active" );
		var gameObject = session.Scene?.Directory?.FindByGuid( guid )
			?? throw new Exception( $"No game object with id {guid} exists in the active scene" );

		using ( session.UndoScope( "Set Network Mode" ).WithGameObjectChanges( gameObject, GameObjectUndoFlags.All ).Push() )
		{
			gameObject.NetworkMode = mode;
		}

		return new NetworkModeResult { Id = gameObject.Id, Name = gameObject.Name, Mode = gameObject.NetworkMode };
	}

	public class NetworkModeResult
	{
		public Guid Id { get; set; }
		public string Name { get; set; }
		public NetworkMode Mode { get; set; }
	}

	/// <summary>One scene tab open in the editor.</summary>
	public class SceneTab
	{
		/// <summary>What happened - opened, switched, or already active.</summary>
		public string Message { get; set; }

		/// <summary>The scene's name, as list_scenes reports it.</summary>
		public string Name { get; set; }

		/// <summary>The scene asset's resource path. Null for a scene that was never saved.</summary>
		public string ResourcePath { get; set; }

		/// <summary>Scene, Prefab, or Game for the running session.</summary>
		public string Type { get; set; }

		/// <summary>Whether this is the active tab - true unless something else took focus.</summary>
		public bool IsActive { get; set; }

		/// <summary>Whether the scene has edits that save_scene hasn't written yet.</summary>
		public bool HasUnsavedChanges { get; set; }

		/// <summary>How many objects sit at the scene root.</summary>
		public int RootObjectCount { get; set; }
	}

	/// <summary>The editor's current state, from the editor's point of view rather than the game's.</summary>
	public class EditorStatusExtras
	{
		/// <summary>The open project's ident.</summary>
		public string Project { get; set; }

		/// <summary>The open project's title.</summary>
		public string ProjectTitle { get; set; }

		/// <summary>The active scene tab's name. Falls back to the running game's scene when no tab is active.</summary>
		public string ActiveScene { get; set; }

		/// <summary>The active scene's resource path. Null for a scene that was never saved.</summary>
		public string ActiveScenePath { get; set; }

		/// <summary>Whether the active scene has edits that save_scene hasn't written yet.</summary>
		public bool SceneHasUnsavedChanges { get; set; }

		/// <summary>How many scene tabs are open. list_scenes names them.</summary>
		public int OpenSceneCount { get; set; }

		/// <summary>Whether play mode is running - play_stop returns to editing.</summary>
		public bool IsPlaying { get; set; }

		/// <summary>Whether play mode is paused.</summary>
		public bool IsPaused { get; set; }
	}

	/// <summary>
	/// The open session whose scene matches a name or resource path, case insensitive. Null when
	/// nothing open matches - the caller decides whether to open it from disk.
	/// </summary>
	private static SceneEditorSession FindSession( string nameOrPath )
	{
		return SceneEditorSession.All
			.FirstOrDefault( x => string.Equals( x.Scene?.Name, nameOrPath, StringComparison.OrdinalIgnoreCase )
				|| string.Equals( x.Scene?.Source?.ResourcePath, nameOrPath, StringComparison.OrdinalIgnoreCase ) );
	}

	private static (string RelativePath, string AbsolutePath) ValidateDiagnosticScenePath( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) )
			throw new Exception( "Give a project-relative .scene path beneath scenes/diagnostics/" );

		var normalized = path.Trim().Replace( '\\', '/' );
		if ( Path.IsPathRooted( normalized ) )
			throw new Exception( "Scene path must be project-relative and beneath scenes/diagnostics/" );

		if ( normalized.Split( '/', StringSplitOptions.RemoveEmptyEntries ).Contains( ".." ) )
			throw new Exception( "Scene path can't contain traversal segments" );

		if ( !string.Equals( Path.GetExtension( normalized ), ".scene", StringComparison.OrdinalIgnoreCase ) )
			throw new Exception( "Scene path must use the .scene extension" );

		if ( !normalized.StartsWith( "scenes/diagnostics/", StringComparison.OrdinalIgnoreCase )
			|| normalized.Length == "scenes/diagnostics/".Length )
			throw new Exception( "Scene path must be beneath scenes/diagnostics/" );

		try
		{
			var assetsRoot = Path.GetFullPath( Project.Current.GetAssetsPath() );
			var diagnosticsRoot = Path.GetFullPath( Path.Combine( assetsRoot, "scenes", "diagnostics" ) )
				.TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar ) + Path.DirectorySeparatorChar;
			var absolute = Path.GetFullPath( Path.Combine( assetsRoot, normalized.Replace( '/', Path.DirectorySeparatorChar ) ) );

			if ( !absolute.StartsWith( diagnosticsRoot, StringComparison.OrdinalIgnoreCase ) )
				throw new Exception( "Scene path must be beneath scenes/diagnostics/" );

			return (normalized, absolute);
		}
		catch ( Exception exception ) when ( exception is ArgumentException or NotSupportedException or PathTooLongException )
		{
			throw new Exception( "Scene path isn't a valid project-relative path" );
		}
	}


	/// <summary>
	/// The camera a tool argument names - a CameraComponent id, or a game object id whose
	/// CameraComponent we take. Empty means the active scene's main camera. The engine's own
	/// resolvers are private to the tools addon, so this repeats them.
	/// </summary>
	private static CameraComponent ResolveCamera( string camera )
	{
		if ( string.IsNullOrWhiteSpace( camera ) )
		{
			var scene = SceneEditorSession.Active?.Scene ?? Game.ActiveScene
				?? throw new Exception( "No scene is open in the editor" );

			return scene.Camera;
		}

		if ( !Guid.TryParse( camera, out var guid ) )
			throw new Exception( $"'{camera}' isn't a guid - find_game_objects and scene_tree show object ids, get_game_object shows component ids" );

		foreach ( var session in SceneEditorSession.All )
		{
			if ( session.Scene?.Directory?.FindComponentByGuid( guid ) is Component component )
			{
				return component as CameraComponent
					?? throw new Exception( "That component isn't a camera - give a CameraComponent or its game object" );
			}

			if ( session.Scene?.Directory?.FindByGuid( guid ) is GameObject go )
			{
				// includeDisabled, matching the built-in resolver's view of a game object's components
				return go.Components.Get<CameraComponent>( true )
					?? throw new Exception( $"'{go.Name}' has no camera component - find one with find_game_objects component 'Camera'" );
			}
		}

		throw new Exception( $"Nothing in any open scene has id {guid} - find_game_objects and scene_tree show what's there" );
	}

	private static SceneTab Row( SceneEditorSession session, string message )
	{
		return new SceneTab
		{
			Message = message,
			Name = session.Scene?.Name,
			ResourcePath = session.Scene?.Source?.ResourcePath,
			Type = session is GameEditorSession ? "Game" : session.Scene is PrefabScene ? "Prefab" : "Scene",
			IsActive = session == SceneEditorSession.Active,
			HasUnsavedChanges = session.HasUnsavedChanges,
			RootObjectCount = session.Scene?.Children.Count ?? 0
		};
	}
}