Editor/Handlers/EditorHandler.cs
using Sandbox;
using System.IO;
using SboxMcp;

namespace SboxMcp.Handlers;

/// <summary>
/// Handles editor-specific commands: editor.get_selection, editor.select,
/// editor.undo, editor.redo, editor.save_scene, editor.screenshot, scene.hierarchy.
/// </summary>
public static class EditorHandler
{
	/// <summary>
	/// editor.get_selection — Get the currently selected GameObjects in the editor.
	/// </summary>
	public static Task<object> HandleGetSelection( HandlerRequest request )
	{
		try
		{
			var selected = EditorSession.SelectionGameObjects.ToList();

			if ( selected.Count == 0 )
				return Task.FromResult<object>( new List<object>() );

			var results = selected.Select( go => (object)new
			{
				id   = go.Id.ToString(),
				name = go.Name,
			} ).ToList();

			return Task.FromResult<object>( results );
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[MCP] editor.get_selection failed: {ex.Message}" );
			throw new InvalidOperationException( $"Could not get editor selection: {ex.Message}", ex );
		}
	}

	/// <summary>
	/// editor.select — Select a GameObject by ID.
	/// Params: { "objectId": "guid-string" }
	/// </summary>
	public static Task<object> HandleSelectObject( HandlerRequest request )
	{
		var objectId = GetParam( request, "objectId" );

		if ( !Guid.TryParse( objectId, out var guid ) )
			throw new ArgumentException( $"Invalid GUID: {objectId}" );

		var scene = EditorSession.ActiveScene ?? Game.ActiveScene;
		if ( scene is null )
			throw new InvalidOperationException( "No active scene." );

		GameObject target = null;
		foreach ( var go in EnumerateAll( scene ) )
		{
			if ( go.Id == guid )
			{
				target = go;
				break;
			}
		}

		if ( target is null )
			throw new KeyNotFoundException( $"GameObject not found: {objectId}" );

		try
		{
			EditorSession.SelectionClear();
			EditorSession.SelectionAdd( target );
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[MCP] editor.select could not set gizmo selection: {ex.Message}" );
		}

		Log.Info( $"[MCP] Selected GameObject '{target.Name}' ({objectId})" );
		return Task.FromResult<object>( (object)new
		{
			selected = true,
			id       = target.Id.ToString(),
			name     = target.Name,
		} );
	}

	/// <summary>
	/// editor.undo — Undo the last editor action.
	/// </summary>
	public static Task<object> HandleUndo( HandlerRequest request )
	{
		try
		{
			// NOTE: s&box API - verify
			EditorSession.Undo();
			Log.Info( "[MCP] editor.undo dispatched" );
			return Task.FromResult<object>( (object)new { success = true, action = "undo" } );
		}
		catch ( Exception ex )
		{
			throw new InvalidOperationException( $"Undo failed: {ex.Message}", ex );
		}
	}

	/// <summary>
	/// editor.redo — Redo the last undone editor action.
	/// </summary>
	public static Task<object> HandleRedo( HandlerRequest request )
	{
		try
		{
			// NOTE: s&box API - verify
			EditorSession.Redo();
			Log.Info( "[MCP] editor.redo dispatched" );
			return Task.FromResult<object>( (object)new { success = true, action = "redo" } );
		}
		catch ( Exception ex )
		{
			throw new InvalidOperationException( $"Redo failed: {ex.Message}", ex );
		}
	}

	/// <summary>
	/// editor.save_scene — Save the current scene.
	/// </summary>
	public static Task<object> HandleSaveScene( HandlerRequest request )
	{
		try
		{
			// NOTE: s&box API - verify
			EditorSession.Save( false );
			Log.Info( "[MCP] editor.save_scene dispatched" );
			return Task.FromResult<object>( (object)new { success = true, action = "save_scene" } );
		}
		catch ( Exception ex )
		{
			throw new InvalidOperationException( $"Save scene failed: {ex.Message}", ex );
		}
	}

	/// <summary>
	/// editor.screenshot — Render a scene camera to a PNG file and return the path.
	///
	/// We pick a camera in this priority order:
	///   1. The first <see cref="CameraComponent"/> found in the active scene.
	///   2. A throwaway editor camera positioned at the current scene-view perspective
	///      (if a SceneViewWidget is available).
	///
	/// The PNG is written into the project's "screenshots/" folder. If neither path
	/// is reachable we return a structured error so the caller can fall back to the
	/// <c>screenshot_highres</c> console command.
	///
	/// Optional params: { "width": 1920, "height": 1080, "path": "absolute/output.png" }
	/// </summary>
	public static Task<object> HandleScreenshot( HandlerRequest request )
	{
		var width  = GetOptionalInt( request, "width",  1920 );
		var height = GetOptionalInt( request, "height", 1080 );
		var explicitPath = GetOptionalString( request, "path" );

		try
		{
			var scene = EditorSession.ActiveScene ?? Game.ActiveScene;
			if ( scene is null )
				throw new InvalidOperationException( "No active scene to capture." );

			var camera = FindUsableCamera( scene );
			if ( camera is null )
				throw new InvalidOperationException(
					"No CameraComponent in scene. Add a camera or use the 'screenshot_highres' console command instead." );

			// Editor.Pixmap and the CameraComponent.RenderToPixmap extension are not
			// resolvable from the publish-wizard's library compile environment, so we
			// reach them reflectively. At runtime in a real editor session the types
			// are present and resolve fine.
			var outPath = ResolveOutputPath( explicitPath );
			var outDir = Path.GetDirectoryName( outPath );
			if ( !string.IsNullOrEmpty( outDir ) )
				Directory.CreateDirectory( outDir );

			var pixmapType = Type.GetType( "Editor.Pixmap, Sandbox.Tools" )
				?? AppDomain.CurrentDomain.GetAssemblies()
					.Select( a => a.GetType( "Editor.Pixmap" ) )
					.FirstOrDefault( t => t is not null );
			if ( pixmapType is null )
				throw new InvalidOperationException( "Editor.Pixmap type not available in this context." );

			var pixmap = Activator.CreateInstance( pixmapType, new object[] { width, height } );

			var renderToPixmap = camera.GetType().GetMethod( "RenderToPixmap", new[] { pixmapType } )
				?? AppDomain.CurrentDomain.GetAssemblies()
					.SelectMany( a => a.GetTypes() )
					.SelectMany( t => t.GetMethods( BindingFlags.Public | BindingFlags.Static ) )
					.FirstOrDefault( m => m.Name == "RenderToPixmap"
						&& m.GetParameters().Length == 2
						&& m.GetParameters()[1].ParameterType == pixmapType );
			if ( renderToPixmap is null )
				throw new InvalidOperationException( "RenderToPixmap method not available in this context." );

			if ( renderToPixmap.IsStatic )
				renderToPixmap.Invoke( null, new object[] { camera, pixmap } );
			else
				renderToPixmap.Invoke( camera, new object[] { pixmap } );

			var savePng = pixmapType.GetMethod( "SavePng", new[] { typeof( string ) } );
			savePng?.Invoke( pixmap, new object[] { outPath } );

			Log.Info( $"[MCP] editor.screenshot saved -> {outPath}" );
			return Task.FromResult<object>( (object)new
			{
				success = true,
				path    = outPath,
				width,
				height,
			} );
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[MCP] editor.screenshot failed: {ex.Message}" );
			return Task.FromResult<object>( (object)new
			{
				success = false,
				error   = ex.Message,
				note    = "Fallback: use the console command 'screenshot_highres' via console.run.",
			} );
		}
	}

	private static CameraComponent FindUsableCamera( Scene scene )
	{
		foreach ( var go in scene.GetAllObjects( false ) )
		{
			var cam = go.Components.Get<CameraComponent>();
			if ( cam is not null ) return cam;
		}
		return null;
	}

	private static string ResolveOutputPath( string explicitPath )
	{
		if ( !string.IsNullOrWhiteSpace( explicitPath ) )
		{
			// Absolute path or project-relative — caller's choice
			return Path.IsPathRooted( explicitPath )
				? explicitPath
				: Path.Combine( Project.Current?.GetRootPath() ?? Environment.CurrentDirectory, explicitPath );
		}

		var stamp = DateTime.UtcNow.ToString( "yyyyMMdd-HHmmss" );
		var rootPath = Project.Current?.GetRootPath() ?? Environment.CurrentDirectory;
		return Path.Combine( rootPath, "screenshots", $"mcp-{stamp}.png" );
	}

	private static int GetOptionalInt( HandlerRequest request, string key, int fallback )
	{
		if ( request.Params is JsonElement el && el.TryGetProperty( key, out var prop ) )
		{
			if ( prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32( out var i ) ) return i;
		}
		return fallback;
	}

	private static string GetOptionalString( HandlerRequest request, string key )
	{
		if ( request.Params is JsonElement el && el.TryGetProperty( key, out var prop ) )
			return prop.GetString();
		return null;
	}

	/// <summary>
	/// editor.play — Start playing the active scene.
	/// </summary>
	public static Task<object> HandlePlay( HandlerRequest request )
	{
		var editorScene = EditorSession.ActiveScene;
		if ( editorScene is null )
			throw new InvalidOperationException( "No active editor session." );

		if ( EditorSession.IsPlaying )
			return Task.FromResult<object>( (object)new { success = false, error = "Already playing" } );

		try
		{
			// SetPlaying requires a game scene — create one from the editor scene
			var gameScene = Scene.CreateEditorScene();
			gameScene.Load( editorScene.Source );
			EditorSession.SetPlaying( gameScene );
			Log.Info( "[MCP] editor.play dispatched" );
			return Task.FromResult<object>( (object)new { success = true, action = "play" } );
		}
		catch ( Exception ex )
		{
			throw new InvalidOperationException( $"editor.play failed: {ex.Message}", ex );
		}
	}

	/// <summary>
	/// editor.stop — Stop playing the active scene.
	/// </summary>
	public static Task<object> HandleStop( HandlerRequest request )
	{
		try
		{
			EditorSession.StopPlaying();
			Log.Info( "[MCP] editor.stop dispatched" );
			return Task.FromResult<object>( (object)new { success = true, action = "stop" } );
		}
		catch ( Exception ex )
		{
			throw new InvalidOperationException( $"editor.stop failed: {ex.Message}", ex );
		}
	}

	/// <summary>
	/// editor.is_playing — Return whether the editor is currently in play mode.
	/// </summary>
	public static Task<object> HandleIsPlaying( HandlerRequest request )
	{
		return Task.FromResult<object>( (object)new { playing = EditorSession.IsPlaying } );
	}

	/// <summary>
	/// editor.scene_info — Return metadata about the currently open scene.
	/// </summary>
	public static Task<object> HandleSceneInfo( HandlerRequest request )
	{
		var scene = EditorSession.ActiveScene;
		if ( scene is null )
			throw new InvalidOperationException( "No active editor session." );

		return Task.FromResult<object>( (object)new
		{
			name              = scene.Name ?? "",
			sourcePath        = scene.Source?.ResourcePath ?? "",
			hasUnsavedChanges = EditorSession.HasUnsavedChanges,
			isPlaying         = EditorSession.IsPlaying,
		} );
	}

	/// <summary>
	/// editor.console_output — Return recent log entries captured by the addon.
	/// </summary>
	public static Task<object> HandleConsoleOutput( HandlerRequest request )
	{
		var lines = ConsoleCapture.GetRecent();
		return Task.FromResult<object>( (object)new { lines = lines } );
	}

	// -------------------------------------------------------------------------
	// Hierarchy helpers (used by SceneHandler.HandleHierarchy)
	// -------------------------------------------------------------------------

	/// <summary>
	/// Builds an indented tree string for the given scene, e.g.:
	/// Scene
	/// ├── Directional Light
	/// ├── Player
	/// │   ├── Camera
	/// │   └── Model
	/// └── Ground
	/// </summary>
	public static string BuildHierarchyText( Scene scene )
	{
		var sb = new System.Text.StringBuilder();
		sb.AppendLine( scene.Name ?? "Scene" );

		var rootChildren = scene.GetAllObjects( false )
			.Where( go => go.Parent is null && !go.Flags.HasFlag( GameObjectFlags.Hidden ) )
			.ToList();

		for ( var i = 0; i < rootChildren.Count; i++ )
		{
			var isLast = i == rootChildren.Count - 1;
			AppendNode( sb, rootChildren[i], "", isLast );
		}

		return sb.ToString().TrimEnd();
	}

	private static void AppendNode( System.Text.StringBuilder sb, GameObject go, string indent, bool isLast )
	{
		var connector = isLast ? "└── " : "├── ";
		sb.AppendLine( indent + connector + go.Name );

		var childIndent = indent + ( isLast ? "    " : "│   " );
		var children    = go.Children.ToList();
		for ( var i = 0; i < children.Count; i++ )
		{
			var childIsLast = i == children.Count - 1;
			AppendNode( sb, children[i], childIndent, childIsLast );
		}
	}

	// -------------------------------------------------------------------------
	// Private helpers
	// -------------------------------------------------------------------------

	private static IEnumerable<GameObject> EnumerateAll( Scene scene )
	{
		return scene.GetAllObjects( false );
	}

	private static string GetParam( HandlerRequest request, string key )
	{
		if ( request.Params is JsonElement el && el.TryGetProperty( key, out var prop ) )
		{
			var val = prop.GetString();
			if ( val is not null ) return val;
		}
		throw new ArgumentException( $"Missing required parameter: {key}" );
	}
}