Editor/OzmiumEditorHandlers.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Editor;
using Sandbox;

namespace SboxMcpServer;

/// <summary>
/// Handlers for editor control MCP tools:
/// select_game_object, open_asset, get_play_state, start/stop play mode,
/// get_editor_log, list_console_commands, run_console_command.
/// </summary>
internal static class OzmiumEditorHandlers
{
	private static readonly JsonSerializerOptions _json = new()
	{
		PropertyNamingPolicy   = JsonNamingPolicy.CamelCase,
		DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
	};

	// Circular log buffer — editor feeds into this from LogMessage
	private static readonly System.Collections.Concurrent.ConcurrentQueue<string> _log
		= new System.Collections.Concurrent.ConcurrentQueue<string>();
	private const int MaxLogLines = 500;

	internal static void AppendLog( string msg )
	{
		_log.Enqueue( msg );
		while ( _log.Count > MaxLogLines ) _log.TryDequeue( out _ );
	}

	// ── select_game_object ──────────────────────────────────────────────────

	internal static object SelectGameObject( JsonElement args )
	{
		var scene = OzmiumSceneHelpers.ResolveScene();
		if ( scene == null ) return OzmiumSceneHelpers.Txt( "No active scene." );

		string id   = OzmiumSceneHelpers.Get( args, "id",   (string)null );
		string name = OzmiumSceneHelpers.Get( args, "name", (string)null );

		var go = OzmiumSceneHelpers.FindGo( scene, id, name );
		if ( go == null ) return OzmiumSceneHelpers.Txt( $"Object not found: id='{id}' name='{name}'." );

		try
		{
			var session = SceneEditorSession.Active;
			if ( session != null )
			{
				// Use reflection to access Selection.Set — avoids hard dependency on Selection type
				var selProp = session.GetType().GetProperty( "Selection",
					System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance );
				var selObj = selProp?.GetValue( session );
				if ( selObj != null )
				{
					var setMethod = selObj.GetType().GetMethod( "Set",
						new[] { typeof( GameObject ) } );
					setMethod?.Invoke( selObj, new object[] { go } );
				}
			}
			return OzmiumSceneHelpers.Txt( $"Selected '{go.Name}' (ID: {go.Id})." );
		}
		catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error: {ex.Message}" ); }
	}

	// ── open_asset ──────────────────────────────────────────────────────────

	internal static object OpenAsset( JsonElement args )
	{
		string path = OzmiumSceneHelpers.Get( args, "path", (string)null );
		if ( string.IsNullOrEmpty( path ) ) return OzmiumSceneHelpers.Txt( "Provide 'path'." );

		try
		{
			var asset = AssetSystem.FindByPath( path );
			if ( asset == null ) return OzmiumSceneHelpers.Txt( $"Asset not found: '{path}'." );
			asset.OpenInEditor();
			return OzmiumSceneHelpers.Txt( $"Opened '{path}' in editor." );
		}
		catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error: {ex.Message}" ); }
	}

	// ── get_play_state ──────────────────────────────────────────────────────

	internal static object GetPlayState()
	{
		var session = SceneEditorSession.Active;
		var state = session?.IsPlaying == true ? "Playing" : "Stopped";
		return OzmiumSceneHelpers.Txt( JsonSerializer.Serialize( new { playState = state }, _json ) );
	}

	// ── start_play_mode ─────────────────────────────────────────────────────

	internal static object StartPlayMode()
	{
		try
		{
			var session = SceneEditorSession.Active;
			if ( session == null ) return OzmiumSceneHelpers.Txt( "No editor session." );
			if ( session.IsPlaying ) return OzmiumSceneHelpers.Txt( "Already playing." );
			session.SetPlaying( session.Scene );
			return OzmiumSceneHelpers.Txt( "Play mode started." );
		}
		catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error starting play mode: {ex.Message}" ); }
	}

	// ── stop_play_mode ──────────────────────────────────────────────────────

	internal static object StopPlayMode()
	{
		try
		{
			var session = SceneEditorSession.Active;
			if ( session == null ) return OzmiumSceneHelpers.Txt( "No editor session." );
			if ( !session.IsPlaying ) return OzmiumSceneHelpers.Txt( "Already stopped." );
			session.StopPlaying();
			return OzmiumSceneHelpers.Txt( "Play mode stopped." );
		}
		catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error stopping play mode: {ex.Message}" ); }
	}

	// ── get_editor_log ──────────────────────────────────────────────────────

	internal static object GetEditorLog( JsonElement args )
	{
		int lines = OzmiumSceneHelpers.Get( args, "lines", 50 );
		var recent = _log.TakeLast( lines ).ToList();
		return OzmiumSceneHelpers.Txt( string.Join( "\n", recent ) );
	}

	// ── list_console_commands ───────────────────────────────────────────────

	internal static object ListConsoleCommands( JsonElement args )
	{
		string filter = OzmiumSceneHelpers.Get( args, "filter", (string)null );
		var entries = new List<Dictionary<string, object>>();

		foreach ( var asm in AppDomain.CurrentDomain.GetAssemblies() )
		{
			try
			{
				foreach ( var type in asm.GetTypes() )
					foreach ( var prop in type.GetProperties(
						System.Reflection.BindingFlags.Public |
						System.Reflection.BindingFlags.NonPublic |
						System.Reflection.BindingFlags.Static ) )
					{
						var attr = prop.GetCustomAttributes( typeof( ConVarAttribute ), false ).FirstOrDefault() as ConVarAttribute;
						if ( attr == null ) continue;
						var cvarName = !string.IsNullOrEmpty( attr.Name ) ? attr.Name : prop.Name.ToLowerInvariant();
						if ( !string.IsNullOrEmpty( filter ) && cvarName.IndexOf( filter, StringComparison.OrdinalIgnoreCase ) < 0 ) continue;
						string val = null;
						try { val = Sandbox.ConsoleSystem.GetValue( cvarName ); } catch { }
						entries.Add( new Dictionary<string, object>
						{
							["name"] = cvarName, ["help"] = attr.Help ?? "",
							["flags"] = attr.Flags.ToString(), ["saved"] = attr.Flags.HasFlag( ConVarFlags.Saved ),
							["currentValue"] = val, ["declaringType"] = type.Name
						} );
					}
			}
			catch { }
		}

		entries = entries.GroupBy( e => e["name"]?.ToString() ).Select( g => g.First() )
			.OrderBy( e => e["name"]?.ToString() ).ToList();

		return OzmiumSceneHelpers.Txt( JsonSerializer.Serialize( new { summary = $"Found {entries.Count} [ConVar] entries{( !string.IsNullOrEmpty( filter ) ? $" matching '{filter}'" : "" )}.", entries, skippedAssemblies = Array.Empty<string>() }, _json ) );
	}

	// ── run_console_command ─────────────────────────────────────────────────

	internal static object RunConsoleCommand( JsonElement args )
	{
		var cmd   = args.GetProperty( "command" ).GetString()?.Trim() ?? "";
		var parts = cmd.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
		if ( parts.Length == 0 ) return OzmiumSceneHelpers.Txt( "Provide a command." );

		var cmdName = parts[0];
		string current = null;
		try { current = Sandbox.ConsoleSystem.GetValue( cmdName ); } catch { }

		if ( current == null )
			return OzmiumSceneHelpers.Txt( $"Unknown convar '{cmdName}'. Only [ConVar] properties are supported." );

		if ( parts.Length == 1 ) return OzmiumSceneHelpers.Txt( $"{cmdName} = {current}" );

		var newVal = string.Join( " ", parts.Skip( 1 ) );
		Sandbox.ConsoleSystem.SetValue( cmdName, newVal );
		string readback = null;
		try { readback = Sandbox.ConsoleSystem.GetValue( cmdName ); } catch { }
		return OzmiumSceneHelpers.Txt( $"Set {cmdName} = {readback ?? newVal}" );
	}

	// ── get_selected_objects ──────────────────────────────────────────────

	internal static object GetSelectedObjects()
	{
		var selected = OzmiumSceneHelpers.GetSelectedGameObjects();
		var results = selected.Select( go => OzmiumSceneHelpers.BuildSummary( go ) ).ToList();
		var summary = $"{results.Count} object(s) selected.";
		return OzmiumSceneHelpers.Txt( JsonSerializer.Serialize( new { summary, results }, _json ) );
	}

	// ── set_selected_objects ──────────────────────────────────────────────

	internal static object SetSelectedObjects( JsonElement args )
	{
		if ( !args.TryGetProperty( "ids", out var idsEl ) || idsEl.ValueKind != JsonValueKind.Array )
			return OzmiumSceneHelpers.Txt( "Provide 'ids' as a string array of GUIDs." );

		try
		{
			var session = SceneEditorSession.Active;
			if ( session == null ) return OzmiumSceneHelpers.Txt( "No editor session active." );

			var selProp = session.GetType().GetProperty( "Selection",
				System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance );
			var selObj = selProp?.GetValue( session );
			if ( selObj == null ) return OzmiumSceneHelpers.Txt( "Selection API not available." );

			// Clear then Add each object — SelectionSystem has Set(object) and Add(object),
			// not Set(IEnumerable). Using Add after Clear supports multi-select.
			var clearMethod = selObj.GetType().GetMethod( "Clear" );
			clearMethod?.Invoke( selObj, null );

			var addMethod = selObj.GetType().GetMethod( "Add", new[] { typeof( object ) } );
			if ( addMethod == null ) return OzmiumSceneHelpers.Txt( "Selection.Add not available." );

			var scene = OzmiumSceneHelpers.ResolveScene();
			if ( scene == null ) return OzmiumSceneHelpers.Txt( "No active scene." );

			int count = 0;
			foreach ( var idEl in idsEl.EnumerateArray() )
			{
				var guidStr = idEl.GetString();
				if ( !string.IsNullOrEmpty( guidStr ) && Guid.TryParse( guidStr, out var guid ) )
				{
					var go = OzmiumSceneHelpers.WalkAll( scene, true ).FirstOrDefault( g => g.Id == guid );
					if ( go != null )
					{
						addMethod.Invoke( selObj, new object[] { go } );
						count++;
					}
				}
			}
			return OzmiumSceneHelpers.Txt( $"Selected {count} object(s)." );
		}
		catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error: {ex.Message}" ); }
	}

	// ── clear_selection ──────────────────────────────────────────────────

	internal static object ClearSelection()
	{
		try
		{
			var session = SceneEditorSession.Active;
			if ( session == null ) return OzmiumSceneHelpers.Txt( "No editor session active." );

			var selProp = session.GetType().GetProperty( "Selection",
				System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance );
			var selObj = selProp?.GetValue( session );
			if ( selObj == null ) return OzmiumSceneHelpers.Txt( "Selection API not available." );

			var clearMethod = selObj.GetType().GetMethod( "Clear" );
			clearMethod?.Invoke( selObj, null );

			return OzmiumSceneHelpers.Txt( "Selection cleared." );
		}
		catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error: {ex.Message}" ); }
	}

	// ── Schemas ─────────────────────────────────────────────────────────────

	internal static Dictionary<string, object> SchemaSelectGameObject => OzmiumSceneHelpers.S( "select_game_object",
		"Select a GameObject in the editor hierarchy and viewport.",
		new Dictionary<string, object>
		{
			["id"]   = new Dictionary<string, object> { ["type"] = "string", ["description"] = "GUID." },
			["name"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Exact name." }
		} );

	internal static Dictionary<string, object> SchemaOpenAsset => OzmiumSceneHelpers.S( "open_asset",
		"Open an asset in its default editor (scene, prefab, material, etc.).",
		new Dictionary<string, object> { ["path"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Asset path to open." } },
		new[] { "path" } );

	internal static Dictionary<string, object> SchemaGetPlayState => OzmiumSceneHelpers.S( "get_play_state",
		"Returns the current play state: 'Playing' or 'Stopped'.",
		new Dictionary<string, object>() );

	internal static Dictionary<string, object> SchemaStartPlayMode => OzmiumSceneHelpers.S( "start_play_mode",
		"Press the Play button in the editor.",
		new Dictionary<string, object>() );

	internal static Dictionary<string, object> SchemaStopPlayMode => OzmiumSceneHelpers.S( "stop_play_mode",
		"Press the Stop button in the editor.",
		new Dictionary<string, object>() );

	internal static Dictionary<string, object> SchemaGetEditorLog => OzmiumSceneHelpers.S( "get_editor_log",
		"Return recent log lines captured from the editor output.",
		new Dictionary<string, object> { ["lines"] = new Dictionary<string, object> { ["type"] = "integer", ["description"] = "Number of recent lines (default 50)." } } );

	internal static Dictionary<string, object> SchemaGetSelectedObjects => OzmiumSceneHelpers.S( "get_selected_objects",
		"Return the currently selected objects in the editor.",
		new Dictionary<string, object>() );

	internal static Dictionary<string, object> SchemaSetSelectedObjects => OzmiumSceneHelpers.S( "set_selected_objects",
		"Select multiple objects at once.",
		new Dictionary<string, object>
		{
			["ids"] = new Dictionary<string, object>
			{
				["type"] = "array",
				["description"] = "Array of GUID strings to select.",
				["items"] = new Dictionary<string, object> { ["type"] = "string" }
			}
		},
		new[] { "ids" } );

	internal static Dictionary<string, object> SchemaClearSelection => OzmiumSceneHelpers.S( "clear_selection",
		"Clear the editor selection.",
		new Dictionary<string, object>() );

	// ── frame_selection ──────────────────────────────────────────────────

	private static BBox GetGameObjectBounds( GameObject go )
	{
		// Try collider bounds first (most accurate)
		var collider = go.Components.GetAll().FirstOrDefault( c => c is Collider ) as Collider;
		if ( collider != null )
			return collider.GetWorldBounds();

		// Fallback: use position with a small extent
		return BBox.FromPositionAndSize( go.WorldPosition, 1f );
	}

	private static BBox CombineBounds( IEnumerable<GameObject> objects )
	{
		var first = true;
		BBox result = default;
		foreach ( var obj in objects )
		{
			var b = GetGameObjectBounds( obj );
			if ( first ) { result = b; first = false; }
			else { result = new BBox( Vector3.Min( result.Mins, b.Mins ), Vector3.Max( result.Maxs, b.Maxs ) ); }
		}
		return result;
	}

	internal static object FrameSelection( JsonElement args )
	{
		var scene = OzmiumSceneHelpers.ResolveScene();
		if ( scene == null ) return OzmiumSceneHelpers.Txt( "No active scene." );

		try
		{
			var session = SceneEditorSession.Active;
			if ( session == null ) return OzmiumSceneHelpers.Txt( "No editor session." );

			BBox bounds;

			// If specific objects are given, compute their combined bounds
			if ( args.TryGetProperty( "ids", out var idsEl ) && idsEl.ValueKind == JsonValueKind.Array )
			{
				var objects = new List<GameObject>();
				foreach ( var idEl in idsEl.EnumerateArray() )
				{
					var guidStr = idEl.GetString();
					if ( !string.IsNullOrEmpty( guidStr ) && Guid.TryParse( guidStr, out var guid ) )
					{
						var go = OzmiumSceneHelpers.WalkAll( scene, true ).FirstOrDefault( g => g.Id == guid );
						if ( go != null ) objects.Add( go );
					}
				}
				if ( objects.Count == 0 ) return OzmiumSceneHelpers.Txt( "No matching objects found." );

				bounds = CombineBounds( objects );
			}
			else
			{
				// Default: use current selection
				var selected = OzmiumSceneHelpers.GetSelectedGameObjects().ToList();
				if ( selected.Count == 0 ) return OzmiumSceneHelpers.Txt( "No selection to frame." );

				bounds = CombineBounds( selected );
			}

			// Use reflection to call FrameTo(in BBox) on the session
			var frameMethod = session.GetType().GetMethod( "FrameTo",
				new[] { typeof( BBox ) } );
			if ( frameMethod == null ) return OzmiumSceneHelpers.Txt( "FrameTo not available on this session type." );

			frameMethod.Invoke( session, new object[] { bounds } );
			return OzmiumSceneHelpers.Txt( $"Framed selection to {bounds}." );
		}
		catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error: {ex.Message}" ); }
	}

	// ── save_scene_as ──────────────────────────────────────────────────

	internal static object SaveSceneAs( JsonElement args )
	{
		string path = OzmiumSceneHelpers.Get( args, "path", (string)null );
		if ( string.IsNullOrEmpty( path ) ) return OzmiumSceneHelpers.Txt( "Provide 'path' for the new scene file." );

		try
		{
			var session = SceneEditorSession.Active;
			if ( session == null ) return OzmiumSceneHelpers.Txt( "No editor session." );

			// Use reflection: ISceneEditorSession.Save(bool forceSaveAs)
			// The Save method with forceSaveAs=true triggers Save As dialog-like behavior.
			// However, the API doesn't directly accept a path parameter — it uses the engine's
			// file dialog. We'll note this limitation.
			var saveMethod = session.GetType().GetMethod( "Save", new[] { typeof( bool ) } );
			if ( saveMethod != null )
			{
				saveMethod.Invoke( session, new object[] { true } );
				return OzmiumSceneHelpers.Txt( $"Save As dialog opened. Note: S&box API does not support programmatic path; user must choose '{path}' in the dialog." );
			}

			return OzmiumSceneHelpers.Txt( "Save method not available on this session type." );
		}
		catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error: {ex.Message}" ); }
	}

	// ── get_scene_unsaved ───────────────────────────────────────────────

	internal static object GetSceneUnsaved()
	{
		try
		{
			var session = SceneEditorSession.Active;
			if ( session == null ) return OzmiumSceneHelpers.Txt( JsonSerializer.Serialize( new
			{
				hasUnsavedChanges = false,
				message = "No editor session active."
			}, _json ) );

			var hasUnsaved = session.HasUnsavedChanges;
			return OzmiumSceneHelpers.Txt( JsonSerializer.Serialize( new
			{
				hasUnsavedChanges = hasUnsaved,
				message = hasUnsaved ? "Scene has unsaved changes." : "Scene is saved."
			}, _json ) );
		}
		catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error: {ex.Message}" ); }
	}

	// ── break_from_prefab ───────────────────────────────────────────────

	internal static object BreakFromPrefab( JsonElement args )
	{
		var scene = OzmiumSceneHelpers.ResolveScene();
		if ( scene == null ) return OzmiumSceneHelpers.Txt( "No active scene." );

		string id   = OzmiumSceneHelpers.Get( args, "id",   (string)null );
		string name = OzmiumSceneHelpers.Get( args, "name", (string)null );

		var go = OzmiumSceneHelpers.FindGo( scene, id, name );
		if ( go == null ) return OzmiumSceneHelpers.Txt( $"Object not found: id='{id}' name='{name}'." );

		try
		{
			go.BreakFromPrefab();
			return OzmiumSceneHelpers.Txt( $"Broke '{go.Name}' from its prefab source." );
		}
		catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error: {ex.Message}" ); }
	}

	// ── update_from_prefab ──────────────────────────────────────────────

	internal static object UpdateFromPrefab( JsonElement args )
	{
		var scene = OzmiumSceneHelpers.ResolveScene();
		if ( scene == null ) return OzmiumSceneHelpers.Txt( "No active scene." );

		string id   = OzmiumSceneHelpers.Get( args, "id",   (string)null );
		string name = OzmiumSceneHelpers.Get( args, "name", (string)null );

		var go = OzmiumSceneHelpers.FindGo( scene, id, name );
		if ( go == null ) return OzmiumSceneHelpers.Txt( $"Object not found: id='{id}' name='{name}'." );

		try
		{
			go.UpdateFromPrefab();
			return OzmiumSceneHelpers.Txt( $"Updated '{go.Name}' from its prefab source." );
		}
		catch ( Exception ex ) { return OzmiumSceneHelpers.Txt( $"Error: {ex.Message}" ); }
	}

	// ── Schemas (extensions) ─────────────────────────────────────────────

	internal static Dictionary<string, object> SchemaFrameSelection => OzmiumSceneHelpers.S( "frame_selection",
		"Focus the editor camera on the current selection or specified objects.",
		new Dictionary<string, object>
		{
			["ids"] = new Dictionary<string, object>
			{
				["type"] = "array", ["description"] = "Optional GUID array. If omitted, uses current selection.",
				["items"] = new Dictionary<string, object> { ["type"] = "string" }
			}
		} );

	internal static Dictionary<string, object> SchemaSaveSceneAs => OzmiumSceneHelpers.S( "save_scene_as",
		"Save the current scene to a new file path (opens Save As dialog).",
		new Dictionary<string, object>
		{
			["path"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Desired file path." }
		} );

	internal static Dictionary<string, object> SchemaGetSceneUnsaved => OzmiumSceneHelpers.S( "get_scene_unsaved",
		"Check if the current scene has unsaved changes.",
		new Dictionary<string, object>() );

	internal static Dictionary<string, object> SchemaBreakFromPrefab => OzmiumSceneHelpers.S( "break_from_prefab",
		"Break a prefab instance's connection to its source prefab.",
		new Dictionary<string, object>
		{
			["id"]   = new Dictionary<string, object> { ["type"] = "string", ["description"] = "GUID." },
			["name"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Exact name." }
		} );

	internal static Dictionary<string, object> SchemaUpdateFromPrefab => OzmiumSceneHelpers.S( "update_from_prefab",
		"Update a prefab instance to match its source prefab.",
		new Dictionary<string, object>
		{
			["id"]   = new Dictionary<string, object> { ["type"] = "string", ["description"] = "GUID." },
			["name"] = new Dictionary<string, object> { ["type"] = "string", ["description"] = "Exact name." }
		} );
}