@tool
extends "res://addons/godot_mcp/commands/base_command.gd"


func get_commands() -> Dictionary:
	return {
		"get_editor_errors": _get_editor_errors,
		"get_output_log": _get_output_log,
		"get_editor_screenshot": _get_editor_screenshot,
		"get_game_screenshot": _get_game_screenshot,
		"execute_editor_script": _execute_editor_script,
		"clear_output": _clear_output,
		"reload_plugin": _reload_plugin,
		"reload_project": _reload_project,
		"get_signals": _get_signals,
		"compare_screenshots": _compare_screenshots,
		"set_auto_dismiss": _set_auto_dismiss,
		"get_editor_camera": _get_editor_camera,
		"set_editor_camera": _set_editor_camera,
	}


func _get_editor_errors(params: Dictionary) -> Dictionary:
	var errors: Array = []
	var max_lines: int = optional_int(params, "max_lines", 50)
	var base: Control = get_editor().get_base_control()

	# 1. Read from the editor's Output panel (EditorLog RichTextLabel)
	#    This captures runtime errors, warnings, and print output
	var editor_log: Node = base.find_child("Output", true, false)
	if editor_log:
		var rtl: RichTextLabel = _find_rtl(editor_log)
		if rtl:
			var content: String = rtl.get_parsed_text()
			var lines: PackedStringArray = content.split("\n")
			var start: int = maxi(0, lines.size() - max_lines)
			for i in range(start, lines.size()):
				var line: String = lines[i]
				if line.contains("ERROR") or line.contains("SCRIPT ERROR") or line.contains("Parse Error") or line.contains("WARNING"):
					errors.append(line.strip_edges())

	# 2. Check the script editor for compile errors (red background lines)
	#    These don't appear in the Output panel
	var script_errors: Array = []
	var script_editor: ScriptEditor = EditorInterface.get_script_editor()
	if script_editor:
		var current_script: Script = script_editor.get_current_script()
		var ce: CodeEdit = _find_code_edit(script_editor)
		if ce and current_script:
			var script_path: String = current_script.resource_path
			for i in range(ce.get_line_count()):
				var bg: Color = ce.get_line_background_color(i)
				if bg.r > 0.8 and bg.a > 0:  # Red-ish background = error
					var line_text: String = ce.get_line(i).strip_edges()
					script_errors.append("COMPILE ERROR: %s:%d - %s" % [script_path, i + 1, line_text])

	# 3. Read from script editor error/warning panels (GDScript analyzer messages)
	#    Each open script editor has a VSplitContainer with two RichTextLabels:
	#    child[1] = warnings panel, child[2] = errors panel
	var analyzer_errors: Array = []
	if script_editor:
		var open_editors: Array = script_editor.get_open_script_editors()
		var open_scripts: Array = script_editor.get_open_scripts()
		for ei in range(open_editors.size()):
			var editor_node: Node = open_editors[ei]
			var script_path: String = ""
			if ei < open_scripts.size() and open_scripts[ei] != null:
				script_path = (open_scripts[ei] as Resource).resource_path
			var vsplit: VSplitContainer = null
			for c in editor_node.get_children():
				if c is VSplitContainer:
					vsplit = c as VSplitContainer
					break
			if vsplit == null:
				continue
			var children: Array = vsplit.get_children()
			# child[1] = warnings panel (RichTextLabel)
			if children.size() > 1 and children[1] is RichTextLabel:
				var text: String = (children[1] as RichTextLabel).get_parsed_text().strip_edges()
				if not text.is_empty():
					for line in text.split("\n"):
						var stripped: String = line.strip_edges()
						if stripped.is_empty() or stripped == "[Ignore]":
							continue
						# Remove leading "[Ignore]" prefix from warning lines
						stripped = stripped.trim_prefix("[Ignore]")
						var prefix: String = "WARNING: %s:" % script_path if not script_path.is_empty() else "WARNING: "
						analyzer_errors.append(prefix + stripped)
			# child[2] = errors panel (RichTextLabel)
			if children.size() > 2 and children[2] is RichTextLabel:
				var text: String = (children[2] as RichTextLabel).get_parsed_text().strip_edges()
				if not text.is_empty():
					for line in text.split("\n"):
						var stripped: String = line.strip_edges()
						if stripped.is_empty():
							continue
						var prefix: String = "SCRIPT ERROR: %s:" % script_path if not script_path.is_empty() else "SCRIPT ERROR: "
						analyzer_errors.append(prefix + stripped)

	# 4. Read from the debugger Errors tab (runtime errors/warnings)
	#    Path: ScriptEditorDebugger > TabContainer > "Errors" VBoxContainer > Tree
	var debugger_errors: Array = []
	var base2: Control = get_editor().get_base_control()
	if base2:
		var queue: Array[Node] = [base2]
		while not queue.is_empty():
			var node := queue.pop_front()
			if node.get_class() == "ScriptEditorDebugger":
				# Find TabContainer inside the debugger
				for child in node.get_children():
					if child is TabContainer:
						var tab_container := child as TabContainer
						for tab_idx in range(tab_container.get_tab_count()):
							var tab_control: Control = tab_container.get_tab_control(tab_idx)
							if tab_control is VBoxContainer and tab_control.name.begins_with("Errors"):
								# Find Tree inside the Errors tab
								for vchild in tab_control.get_children():
									if vchild is Tree:
										var tree := vchild as Tree
										var root_item: TreeItem = tree.get_root()
										if root_item:
											var item: TreeItem = root_item.get_first_child()
											while item:
												var col0: String = item.get_text(0).strip_edges()
												var col1: String = item.get_text(1).strip_edges()
												if not col0.is_empty() or not col1.is_empty():
													var msg: String = col0
													if not col1.is_empty():
														msg += " " + col1 if not msg.is_empty() else col1
													debugger_errors.append("DEBUGGER: " + msg)
												# Also check child items (expanded error details)
												var sub: TreeItem = item.get_first_child()
												while sub:
													var sub0: String = sub.get_text(0).strip_edges()
													var sub1: String = sub.get_text(1).strip_edges()
													if not sub0.is_empty() or not sub1.is_empty():
														var sub_msg: String = sub0
														if not sub1.is_empty():
															sub_msg += " " + sub1 if not sub_msg.is_empty() else sub1
														debugger_errors.append("DEBUGGER:   " + sub_msg)
													sub = sub.get_next()
												item = item.get_next()
								break  # Found Errors tab, stop searching tabs
						break  # Found TabContainer, stop searching debugger children
				break  # Found ScriptEditorDebugger, stop BFS
			for child in node.get_children():
				queue.append(child)

	# Fallback: read from log file if Output panel not accessible
	if errors.size() == 0 and script_errors.size() == 0 and analyzer_errors.size() == 0 and debugger_errors.size() == 0:
		var log_path := "user://logs/godot.log"
		if FileAccess.file_exists(log_path):
			var file := FileAccess.open(log_path, FileAccess.READ)
			if file:
				var content := file.get_as_text()
				file.close()
				var lines := content.split("\n")
				var start: int = maxi(0, lines.size() - max_lines)
				for i in range(start, lines.size()):
					var line: String = lines[i]
					if line.contains("ERROR") or line.contains("SCRIPT ERROR"):
						errors.append(line.strip_edges())

	errors.append_array(script_errors)
	errors.append_array(analyzer_errors)
	errors.append_array(debugger_errors)
	return success({"errors": errors, "count": errors.size()})


func _get_output_log(params: Dictionary) -> Dictionary:
	var max_lines: int = optional_int(params, "max_lines", 100)
	var filter: String = optional_string(params, "filter", "")
	var base: Control = get_editor().get_base_control()

	var editor_log: Node = base.find_child("Output", true, false)
	if editor_log == null:
		# Fallback: read from log file
		var log_path := "user://logs/godot.log"
		if not FileAccess.file_exists(log_path):
			return error_internal("Output panel not found and no log file available")
		var file := FileAccess.open(log_path, FileAccess.READ)
		if file == null:
			return error_internal("Cannot read log file")
		var content := file.get_as_text()
		file.close()
		var lines := content.split("\n")
		var start: int = maxi(0, lines.size() - max_lines)
		var output_lines: Array = []
		for i in range(start, lines.size()):
			var line: String = lines[i]
			if filter.is_empty() or line.contains(filter):
				output_lines.append(line)
		return success({"lines": output_lines, "count": output_lines.size(), "source": "log_file"})

	var rtl: RichTextLabel = _find_rtl(editor_log)
	if rtl == null:
		return error_internal("Could not find RichTextLabel in Output panel")

	var content: String = rtl.get_parsed_text()
	var all_lines: PackedStringArray = content.split("\n")
	var start: int = maxi(0, all_lines.size() - max_lines)
	var output_lines: Array = []
	for i in range(start, all_lines.size()):
		var line: String = all_lines[i]
		if filter.is_empty() or line.contains(filter):
			output_lines.append(line)

	return success({"lines": output_lines, "count": output_lines.size(), "source": "output_panel"})


func _find_code_edit(node: Node, depth: int = 0) -> CodeEdit:
	if depth > 8:
		return null
	if node is CodeEdit:
		return node as CodeEdit
	for child in node.get_children():
		var found: CodeEdit = _find_code_edit(child, depth + 1)
		if found:
			return found
	return null


func _find_rtl(node: Node, depth: int = 0) -> RichTextLabel:
	if depth > 6:
		return null
	if node is RichTextLabel:
		return node as RichTextLabel
	for child in node.get_children():
		var found: RichTextLabel = _find_rtl(child, depth + 1)
		if found:
			return found
	return null


func _get_editor_screenshot(params: Dictionary) -> Dictionary:
	# Capture the editor's main viewport - no await to avoid timeout
	var base_control: Control = get_editor().get_base_control()
	if base_control == null:
		return error_internal("Could not access editor base control")

	var viewport: Viewport = base_control.get_viewport()
	if viewport == null:
		return error_internal("Could not access editor viewport")

	var texture: ViewportTexture = viewport.get_texture()
	if texture == null:
		return error_internal("Could not get viewport texture")

	var image: Image = texture.get_image()
	if image == null:
		return error_internal("Could not get image from viewport")

	var save_path: String = params.get("save_path", "")
	if save_path != "":
		var abs_path := _resolve_save_path(save_path)
		var err := image.save_png(abs_path)
		if err != OK:
			return error_internal("Failed to save screenshot: %s" % error_string(err))
		return success({
			"saved_path": save_path,
			"width": image.get_width(),
			"height": image.get_height(),
			"format": "png",
		})

	var png_buffer := image.save_png_to_buffer()
	var base64 := Marshalls.raw_to_base64(png_buffer)

	return success({
		"image_base64": base64,
		"width": image.get_width(),
		"height": image.get_height(),
		"format": "png",
	})


func _get_game_screenshot(params: Dictionary) -> Dictionary:
	var ei := get_editor()
	if not ei.is_playing_scene():
		return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"})

	# Communicate with the game process via file system
	var user_dir := get_game_user_dir()
	var request_path := user_dir + "/mcp_screenshot_request"
	var screenshot_path := user_dir + "/mcp_screenshot.png"

	# Clean up any stale screenshot file
	if FileAccess.file_exists(screenshot_path):
		DirAccess.remove_absolute(screenshot_path)

	# Create the request file to signal the game process
	var req := FileAccess.open(request_path, FileAccess.WRITE)
	if req == null:
		return error_internal("Could not create screenshot request file")
	req.close()

	# Poll for the screenshot file (max 3 seconds, 0.1s interval)
	var attempts := 30
	while attempts > 0:
		await get_tree().create_timer(0.1).timeout
		if FileAccess.file_exists(screenshot_path):
			break
		attempts -= 1

	if not FileAccess.file_exists(screenshot_path):
		# Clean up request file if it still exists
		if FileAccess.file_exists(request_path):
			DirAccess.remove_absolute(request_path)
		return error(-32000, "Screenshot timed out", {
			"suggestion": "Ensure the game is running and MCPScreenshot autoload is active",
		})

	# Load the PNG file
	var image := Image.new()
	var err := image.load(screenshot_path)
	if err != OK:
		DirAccess.remove_absolute(screenshot_path)
		return error_internal("Failed to load screenshot: %s" % error_string(err))

	# Clean up temp file
	DirAccess.remove_absolute(screenshot_path)

	var save_path_param: String = params.get("save_path", "")
	if save_path_param != "":
		var abs_path := _resolve_save_path(save_path_param)
		var save_err := image.save_png(abs_path)
		if save_err != OK:
			return error_internal("Failed to save screenshot: %s" % error_string(save_err))
		return success({
			"saved_path": save_path_param,
			"width": image.get_width(),
			"height": image.get_height(),
			"format": "png",
		})

	var png_buffer := image.save_png_to_buffer()
	var base64 := Marshalls.raw_to_base64(png_buffer)

	return success({
		"image_base64": base64,
		"width": image.get_width(),
		"height": image.get_height(),
		"format": "png",
	})


func _resolve_save_path(path: String) -> String:
	if path.begins_with("res://") or path.begins_with("user://"):
		return ProjectSettings.globalize_path(path)
	return path


func _execute_editor_script(params: Dictionary) -> Dictionary:
	var result := require_string(params, "code")
	if result[1] != null:
		return result[1]
	var code: String = result[0]
	var allow_unsafe_editor_io: bool = optional_bool(params, "allow_unsafe_editor_io", false)
	var unsafe_guard := _guard_editor_script_file_io(code, allow_unsafe_editor_io)
	if not unsafe_guard.is_empty():
		return unsafe_guard

	# Wrap user code in a @tool script
	var wrapped_code := """@tool
extends Node

var _mcp_output: Array = []

func _mcp_print(value: Variant) -> void:
	_mcp_output.append(str(value))

func run() -> Variant:
	# User code begins
%s
	# User code ends
	return _mcp_output
""" % _indent_code(code)

	# Create a temporary script
	var script := GDScript.new()
	script.source_code = wrapped_code
	var err := script.reload()

	if err != OK:
		return error(-32002, "Script compilation failed", {
			"error": error_string(err),
			"code": wrapped_code,
		})

	# Create temp node and execute
	var temp_node := Node.new()
	temp_node.set_script(script)
	add_child(temp_node)

	var output: Variant = null

	# Execute with error handling
	if temp_node.has_method("run"):
		output = temp_node.run()

	var mcp_output: Array = []
	var raw_output: Variant = temp_node.get("_mcp_output")
	if raw_output is Array:
		mcp_output = raw_output

	# Cleanup
	temp_node.queue_free()

	return success({
		"output": mcp_output,
		"return_value": str(output) if output != null else null,
	})


func _guard_editor_script_file_io(code: String, allow_unsafe_editor_io: bool) -> Dictionary:
	if allow_unsafe_editor_io:
		return {}
	var compact := code.replace(" ", "").replace("\t", "").replace("\n", "")
	var unsafe_patterns: Array[String] = []
	if compact.contains("ResourceSaver.save("):
		unsafe_patterns.append("ResourceSaver.save")
	if compact.contains("ProjectSettings.save("):
		unsafe_patterns.append("ProjectSettings.save")
	if compact.contains("ConfigFile.save("):
		unsafe_patterns.append("ConfigFile.save")
	if compact.contains("FileAccess.open(") and _contains_any(compact, ["FileAccess.WRITE", "FileAccess.READ_WRITE", "FileAccess.WRITE_READ"]):
		unsafe_patterns.append("FileAccess.open WRITE")
	if _contains_any(compact, ["DirAccess.remove_absolute(", "DirAccess.rename_absolute(", "DirAccess.copy_absolute(", "DirAccess.make_dir_absolute(", "DirAccess.make_dir_recursive_absolute("]):
		unsafe_patterns.append("DirAccess filesystem mutation")
	if unsafe_patterns.is_empty():
		return {}
	return error_conflict(
		"Refusing to execute editor script with direct file/resource write APIs",
		{
			"unsafe_patterns": unsafe_patterns,
			"open_scenes": get_open_scene_paths(),
			"suggestion": "Use dedicated MCP commands and save_scene for editor-owned resources, or pass allow_unsafe_editor_io=true only when no open editor resource can be overwritten.",
		}
	)


func _contains_any(value: String, needles: Array[String]) -> bool:
	for needle: String in needles:
		if value.contains(needle):
			return true
	return false


func _indent_code(code: String) -> String:
	var lines := code.split("\n")
	var indented: PackedStringArray = []
	for line in lines:
		indented.append("\t" + line)
	return "\n".join(indented)


func _clear_output(params: Dictionary) -> Dictionary:
	print("\n".repeat(50))
	return success({"cleared": true})


func _reload_plugin(params: Dictionary) -> Dictionary:
	# Disable and re-enable this plugin to reload all scripts
	var plugin_name := "godot_mcp"
	var ei := get_editor()

	# Send success BEFORE reloading (connection will briefly drop)
	# Use call_deferred so the response is sent first
	_deferred_reload_plugin.call_deferred(ei, plugin_name)
	return success({"reloading": true, "message": "Plugin will reload momentarily. Connection will briefly drop and auto-reconnect."})


func _deferred_reload_plugin(ei: EditorInterface, plugin_name: String) -> void:
	ei.set_plugin_enabled(plugin_name, false)
	ei.set_plugin_enabled(plugin_name, true)
	print("[MCP] Plugin reloaded")


func _reload_project(params: Dictionary) -> Dictionary:
	# Rescan filesystem and reload changed scripts
	var ei := get_editor()
	ei.get_resource_filesystem().scan()

	return success({"reloaded": true, "message": "Filesystem rescanned."})


func _get_signals(params: Dictionary) -> Dictionary:
	var result := require_string(params, "node_path")
	if result[1] != null:
		return result[1]
	var node_path: String = result[0]

	var root := get_edited_root()
	if root == null:
		return error_no_scene()

	var node := find_node_by_path(node_path)
	if node == null:
		return error_not_found("Node '%s'" % node_path)

	var signals: Array = []
	for sig in node.get_signal_list():
		var sig_info: Dictionary = {
			"name": sig["name"],
			"args": [],
		}
		for arg in sig["args"]:
			sig_info["args"].append({"name": arg["name"], "type": arg["type"]})

		# Get connections for this signal
		var connections: Array = []
		for conn in node.get_signal_connection_list(sig["name"]):
			connections.append({
				"target": str(root.get_path_to(conn["callable"].get_object())),
				"method": conn["callable"].get_method(),
			})
		sig_info["connections"] = connections
		signals.append(sig_info)

	return success({
		"node_path": str(root.get_path_to(node)),
		"type": node.get_class(),
		"signals": signals,
		"count": signals.size(),
	})


func _load_image_from_param(value: String, label: String) -> Array:
	## Returns [Image, null] on success or [null, error_dict] on failure.
	## Accepts a file path (res://, user://) or raw base64 PNG data.
	var img := Image.new()
	if value.begins_with("res://") or value.begins_with("user://"):
		var err := img.load(value)
		if err != OK:
			return [null, error_invalid_params("Failed to load %s from path '%s': %s" % [label, value, error_string(err)])]
		return [img, null]
	# Treat as base64 PNG
	var buf := Marshalls.base64_to_raw(value)
	var err := img.load_png_from_buffer(buf)
	if err != OK:
		return [null, error_invalid_params("Failed to decode %s from base64: %s" % [label, error_string(err)])]
	return [img, null]


func _compare_screenshots(params: Dictionary) -> Dictionary:
	var result := require_string(params, "image_a")
	if result[1] != null:
		return result[1]
	var image_a_value: String = result[0]

	var result2 := require_string(params, "image_b")
	if result2[1] != null:
		return result2[1]
	var image_b_value: String = result2[0]

	var threshold: int = optional_int(params, "threshold", 10)

	# Load images (from path or base64)
	var load_a := _load_image_from_param(image_a_value, "image_a")
	if load_a[1] != null:
		return load_a[1]
	var img_a: Image = load_a[0]

	var load_b := _load_image_from_param(image_b_value, "image_b")
	if load_b[1] != null:
		return load_b[1]
	var img_b: Image = load_b[0]

	if img_a.get_size() != img_b.get_size():
		return error_invalid_params("Image sizes differ: %s vs %s" % [str(img_a.get_size()), str(img_b.get_size())])

	var width := img_a.get_width()
	var height := img_a.get_height()
	var diff_image := Image.create(width, height, false, Image.FORMAT_RGBA8)

	var changed_pixels: int = 0
	var total_pixels: int = width * height

	for y in height:
		for x in width:
			var ca: Color = img_a.get_pixel(x, y)
			var cb: Color = img_b.get_pixel(x, y)
			var dr := absi(int(ca.r8) - int(cb.r8))
			var dg := absi(int(ca.g8) - int(cb.g8))
			var db := absi(int(ca.b8) - int(cb.b8))
			var max_diff := maxi(dr, maxi(dg, db))
			if max_diff > threshold:
				changed_pixels += 1
				# Red highlight for changed pixels
				diff_image.set_pixel(x, y, Color(1, 0, 0, clampf(float(max_diff) / 255.0, 0.3, 1.0)))
			else:
				# Dim version of original
				diff_image.set_pixel(x, y, Color(ca.r * 0.3, ca.g * 0.3, ca.b * 0.3, 1.0))

	var diff_percentage: float = (float(changed_pixels) / float(total_pixels)) * 100.0
	var identical: bool = changed_pixels == 0

	# Encode diff image
	var diff_png := diff_image.save_png_to_buffer()
	var diff_base64 := Marshalls.raw_to_base64(diff_png)

	return success({
		"identical": identical,
		"changed_pixels": changed_pixels,
		"total_pixels": total_pixels,
		"diff_percentage": snappedf(diff_percentage, 0.01),
		"threshold": threshold,
		"width": width,
		"height": height,
		"diff_image_base64": diff_base64,
	})


func _get_editor_camera(_params: Dictionary) -> Dictionary:
	var vp3d := EditorInterface.get_editor_viewport_3d()
	var cam := vp3d.get_camera_3d() if vp3d else null
	if not cam:
		return error(-32000, "No 3D editor camera found", {
			"suggestion": "Make sure a 3D scene is open in the editor",
		})
	var pos := cam.global_position
	var rot := cam.rotation_degrees
	return success({
		"position": {"x": pos.x, "y": pos.y, "z": pos.z},
		"rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z},
		"fov": cam.fov,
		"near": cam.near,
		"far": cam.far,
	})


func _set_editor_camera(params: Dictionary) -> Dictionary:
	var vp3d := EditorInterface.get_editor_viewport_3d()
	var cam := vp3d.get_camera_3d() if vp3d else null
	if not cam:
		return error(-32000, "No 3D editor camera found", {
			"suggestion": "Make sure a 3D scene is open in the editor",
		})

	# Set position
	if params.has("position"):
		var p: Dictionary = params["position"]
		cam.global_position = Vector3(
			float(p.get("x", cam.global_position.x)),
			float(p.get("y", cam.global_position.y)),
			float(p.get("z", cam.global_position.z)),
		)

	# Set rotation
	if params.has("rotation_degrees"):
		var r: Dictionary = params["rotation_degrees"]
		cam.rotation_degrees = Vector3(
			float(r.get("x", cam.rotation_degrees.x)),
			float(r.get("y", cam.rotation_degrees.y)),
			float(r.get("z", cam.rotation_degrees.z)),
		)

	# Look at target (overrides rotation if set)
	if params.has("look_at"):
		var t: Dictionary = params["look_at"]
		cam.look_at(Vector3(float(t.get("x", 0)), float(t.get("y", 0)), float(t.get("z", 0))))

	# Set FOV
	if params.has("fov"):
		cam.fov = float(params["fov"])

	var pos := cam.global_position
	var rot := cam.rotation_degrees
	return success({
		"position": {"x": pos.x, "y": pos.y, "z": pos.z},
		"rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z},
		"fov": cam.fov,
	})


func _set_auto_dismiss(params: Dictionary) -> Dictionary:
	var enabled: bool = params.get("enabled", true)
	editor_plugin.auto_dismiss_dialogs = enabled
	return success({
		"auto_dismiss": enabled,
		"message": "Auto-dismiss dialogs %s" % ("enabled" if enabled else "disabled"),
	})
