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


func get_commands() -> Dictionary:
	return {
		"find_unused_resources": _find_unused_resources,
		"analyze_signal_flow": _analyze_signal_flow,
		"analyze_scene_complexity": _analyze_scene_complexity,
		"find_script_references": _find_script_references,
		"detect_circular_dependencies": _detect_circular_dependencies,
		"get_project_statistics": _get_project_statistics,
	}


# =============================================================================
# find_unused_resources
# =============================================================================

## Scan project for resources not referenced by any .tscn, .gd, or .tres file.
func _find_unused_resources(params: Dictionary) -> Dictionary:
	var path: String = optional_string(params, "path", "res://")
	var include_addons: bool = optional_bool(params, "include_addons", false)

	# Step 1: Collect all resource files
	var resource_extensions: Array = ["tres", "tscn", "png", "jpg", "jpeg", "svg",
		"wav", "ogg", "mp3", "ttf", "otf", "gdshader", "material",
		"theme", "stylebox", "font", "anim"]
	var all_resources: Array = []
	_collect_files_by_ext(path, resource_extensions, all_resources, include_addons)

	# Step 2: Collect all referencing files (.tscn, .gd, .tres)
	var ref_extensions: Array = ["tscn", "gd", "tres", "cfg", "godot"]
	var ref_files: Array = []
	_collect_files_by_ext(path, ref_extensions, ref_files, include_addons)

	# Step 3: Build a set of all referenced paths (both res:// and uid:// forms)
	var referenced: Dictionary = {}  # path -> true
	for ref_file in ref_files:
		var content := _read_file_text(ref_file as String)
		if content.is_empty():
			continue
		for prefix: String in ["res://", "uid://"]:
			var idx := 0
			while idx < content.length():
				var found := content.find(prefix, idx)
				if found == -1:
					break
				# Extract the path (up to quote, space, or end of line)
				var end := found + prefix.length()
				while end < content.length():
					var c := content[end]
					if c == '"' or c == "'" or c == ' ' or c == '\n' or c == '\r' or c == ')' or c == ']' or c == '}':
						break
					end += 1
				var ref_path := content.substr(found, end - found)
				if ref_path.begins_with("uid://"):
					# Map uid:// back to its res:// path. Skip self-references:
					# .tres/.tscn headers carry the file's OWN uid, which must
					# not count as the file being referenced by someone else.
					var uid := ResourceUID.text_to_id(ref_path)
					if uid != ResourceUID.INVALID_ID and ResourceUID.has_id(uid):
						var uid_path := ResourceUID.get_id_path(uid)
						if uid_path != str(ref_file):
							referenced[uid_path] = true
				else:
					referenced[ref_path] = true
				idx = end

	# Step 3b: Seed references held by the engine via ProjectSettings
	# (main scene, audio bus layout, icon, autoloads…) — these may be default
	# values that never appear as literals in project.godot
	for setting in ProjectSettings.get_property_list():
		var setting_name: String = setting["name"]
		var value: Variant = ProjectSettings.get_setting(setting_name) if ProjectSettings.has_setting(setting_name) else null
		if value is String:
			var s: String = value
			s = s.trim_prefix("*")  # autoload paths are prefixed with '*'
			if s.begins_with("res://"):
				referenced[s] = true
			elif s.begins_with("uid://"):
				var uid := ResourceUID.text_to_id(s)
				if uid != ResourceUID.INVALID_ID and ResourceUID.has_id(uid):
					referenced[ResourceUID.get_id_path(uid)] = true

	# Step 4: Find unreferenced resources
	var unused: Array = []
	for res_path in all_resources:
		var p: String = res_path
		if not referenced.has(p):
			unused.append(p)

	return success({
		"unused_resources": unused,
		"unused_count": unused.size(),
		"total_resources_scanned": all_resources.size(),
		"total_files_checked": ref_files.size(),
	})


# =============================================================================
# analyze_signal_flow
# =============================================================================

## Map all signal connections in the currently edited scene.
func _analyze_signal_flow(params: Dictionary) -> Dictionary:
	var root := get_edited_root()
	if root == null:
		return error_no_scene()

	var nodes_data: Array = []
	_collect_signal_data(root, root, nodes_data)

	return success({
		"scene": root.scene_file_path,
		"nodes": nodes_data,
		"total_nodes": nodes_data.size(),
	})


func _collect_signal_data(node: Node, root: Node, out: Array) -> void:
	var node_path := str(root.get_path_to(node))
	var signals_emitted: Array = []
	var signals_connected_to: Array = []

	# Get all signals this node defines
	for sig in node.get_signal_list():
		var sig_name: String = sig["name"]
		var connections := node.get_signal_connection_list(sig_name)
		var targets: Array = []
		for conn in connections:
			# Skip editor-internal bookkeeping connections — only persistent
			# (scene-serialized) connections are user-relevant
			if int(conn.get("flags", 0)) & Object.CONNECT_PERSIST == 0:
				continue
			var callable: Callable = conn["callable"]
			var target_node: Node = callable.get_object() as Node
			# Skip targets outside the edited scene (editor dock nodes)
			if target_node == null or (target_node != root and not root.is_ancestor_of(target_node)):
				continue
			targets.append({
				"target_node": str(root.get_path_to(target_node)),
				"method": callable.get_method(),
			})
			# Also record on the target side
			signals_connected_to.append({
				"from_node": node_path,
				"signal": sig_name,
				"method": callable.get_method(),
			})
		if targets.size() > 0:
			signals_emitted.append({
				"signal": sig_name,
				"targets": targets,
			})

	# Only include nodes that have signal activity
	if signals_emitted.size() > 0 or signals_connected_to.size() > 0:
		out.append({
			"name": node.name,
			"path": node_path,
			"type": node.get_class(),
			"signals_emitted": signals_emitted,
			"signals_connected_to": signals_connected_to,
		})

	for child in node.get_children():
		_collect_signal_data(child, root, out)


# =============================================================================
# analyze_scene_complexity
# =============================================================================

## Analyze a scene's complexity: node count, depth, types, scripts, potential issues.
func _analyze_scene_complexity(params: Dictionary) -> Dictionary:
	var scene_path: String = optional_string(params, "path", "")

	var root: Node = null
	if scene_path.is_empty():
		root = get_edited_root()
		if root == null:
			return error_no_scene()
		scene_path = root.scene_file_path
	else:
		if not ResourceLoader.exists(scene_path):
			return error_not_found("Scene '%s'" % scene_path)
		var packed := ResourceLoader.load(scene_path) as PackedScene
		if packed == null:
			return error_internal("Failed to load scene: %s" % scene_path)
		root = packed.instantiate()

	var total_nodes := 0
	var max_depth := 0
	var types: Dictionary = {}  # class_name -> count
	var scripts_attached: Array = []
	var resources_used: Dictionary = {}  # resource path -> count
	var issues: Array = []

	_analyze_node(root, root, 0, total_nodes, max_depth, types, scripts_attached, resources_used)

	# Count totals from recursive walk
	total_nodes = _count_nodes_recursive(root)
	max_depth = _get_max_depth(root, 0)

	# Detect potential issues
	if total_nodes > 1000:
		issues.append({"severity": "warning", "message": "Scene has %d nodes (>1000). Consider splitting into sub-scenes." % total_nodes})
	elif total_nodes > 500:
		issues.append({"severity": "info", "message": "Scene has %d nodes (>500). Monitor performance." % total_nodes})

	if max_depth > 15:
		issues.append({"severity": "warning", "message": "Max nesting depth is %d (>15). Deep hierarchies can be hard to maintain." % max_depth})
	elif max_depth > 10:
		issues.append({"severity": "info", "message": "Max nesting depth is %d (>10)." % max_depth})

	# If we instantiated the scene ourselves, free it
	if not scene_path.is_empty() and root != get_edited_root():
		root.queue_free()

	return success({
		"scene_path": scene_path,
		"total_nodes": total_nodes,
		"max_depth": max_depth,
		"nodes_by_type": types,
		"scripts_attached": scripts_attached,
		"unique_resource_count": resources_used.size(),
		"issues": issues,
	})


func _analyze_node(node: Node, root: Node, depth: int,
		total_nodes: int, max_depth: int,
		types: Dictionary, scripts: Array, resources: Dictionary) -> void:
	var type_name := node.get_class()
	types[type_name] = types.get(type_name, 0) + 1

	if node.get_script() != null:
		var script: Script = node.get_script()
		var script_path := script.resource_path
		if not script_path.is_empty():
			scripts.append({
				"node": str(root.get_path_to(node)),
				"script": script_path,
			})

	for child in node.get_children():
		_analyze_node(child, root, depth + 1, total_nodes, max_depth, types, scripts, resources)


func _count_nodes_recursive(node: Node) -> int:
	var count := 1
	for child in node.get_children():
		count += _count_nodes_recursive(child)
	return count


func _get_max_depth(node: Node, current_depth: int) -> int:
	var max_d := current_depth
	for child in node.get_children():
		var child_depth := _get_max_depth(child, current_depth + 1)
		if child_depth > max_d:
			max_d = child_depth
	return max_d


# =============================================================================
# find_script_references
# =============================================================================

## Find all places where a given script, class_name, or resource path is used.
func _find_script_references(params: Dictionary) -> Dictionary:
	var result := require_string(params, "query")
	if result[1] != null:
		return result[1]
	var query: String = result[0]

	var path: String = optional_string(params, "path", "res://")
	var include_addons: bool = optional_bool(params, "include_addons", false)

	var search_extensions: Array = ["tscn", "gd", "tres", "cfg", "godot"]
	var search_files: Array = []
	_collect_files_by_ext(path, search_extensions, search_files, include_addons)

	var references: Array = []
	for file_path in search_files:
		var fp: String = file_path
		var content := _read_file_text(fp)
		if content.is_empty():
			continue

		var lines := content.split("\n")
		var line_num := 0
		for line in lines:
			line_num += 1
			var l: String = line
			if l.contains(query):
				references.append({
					"file": fp,
					"line": line_num,
					"content": l.strip_edges(),
				})

	return success({
		"query": query,
		"references": references,
		"reference_count": references.size(),
		"files_searched": search_files.size(),
	})


# =============================================================================
# detect_circular_dependencies
# =============================================================================

## Check for circular scene dependencies (.tscn files referencing each other).
func _detect_circular_dependencies(params: Dictionary) -> Dictionary:
	var path: String = optional_string(params, "path", "res://")
	var include_addons: bool = optional_bool(params, "include_addons", false)

	# Step 1: Collect all .tscn files
	var tscn_files: Array = []
	_collect_files_by_ext(path, ["tscn"], tscn_files, include_addons)

	# Step 2: Build dependency graph: scene_path -> [referenced_scene_paths]
	var dep_graph: Dictionary = {}  # String -> Array[String]
	for tscn_path in tscn_files:
		var tp: String = tscn_path
		var content := _read_file_text(tp)
		if content.is_empty():
			continue

		var deps: Array = []
		for line in content.split("\n"):
			var l: String = line
			# Match [ext_resource ... path="res://..." ...] lines that reference .tscn
			if l.begins_with("[ext_resource") and ".tscn" in l:
				var path_start := l.find('path="')
				if path_start == -1:
					continue
				path_start += 6  # len('path="')
				var path_end := l.find('"', path_start)
				if path_end == -1:
					continue
				var ref_path := l.substr(path_start, path_end - path_start)
				if ref_path.ends_with(".tscn"):
					deps.append(ref_path)
		dep_graph[tp] = deps

	# Step 3: Detect cycles using DFS
	var cycles: Array = []
	var visited: Dictionary = {}  # path -> "unvisited" | "visiting" | "visited"
	for scene in dep_graph:
		visited[scene] = "unvisited"

	for scene in dep_graph:
		if visited[scene] == "unvisited":
			var path_stack: Array = []
			_dfs_detect_cycle(scene as String, dep_graph, visited, path_stack, cycles)

	return success({
		"scenes_checked": tscn_files.size(),
		"circular_dependencies": cycles,
		"has_circular": cycles.size() > 0,
		"dependency_graph": dep_graph,
	})


func _dfs_detect_cycle(node: String, graph: Dictionary, visited: Dictionary,
		path_stack: Array, cycles: Array) -> void:
	visited[node] = "visiting"
	path_stack.append(node)

	if graph.has(node):
		var deps: Array = graph[node]
		for dep in deps:
			var d: String = dep
			if not visited.has(d):
				# Scene referenced but not in our graph (might not exist or outside scope)
				continue
			if visited[d] == "visiting":
				# Found a cycle — extract it from the stack
				var cycle_start := path_stack.find(d)
				var cycle: Array = path_stack.slice(cycle_start)
				cycle.append(d)  # Close the cycle
				cycles.append(cycle)
			elif visited[d] == "unvisited":
				_dfs_detect_cycle(d, graph, visited, path_stack, cycles)

	path_stack.pop_back()
	visited[node] = "visited"


# =============================================================================
# get_project_statistics
# =============================================================================

## Overall project stats: file counts, script lines, scenes, resources, autoloads, plugins.
func _get_project_statistics(params: Dictionary) -> Dictionary:
	var path: String = optional_string(params, "path", "res://")
	var include_addons: bool = optional_bool(params, "include_addons", false)

	var file_counts: Dictionary = {}  # extension -> count
	var total_script_lines := 0
	var scene_count := 0
	var resource_count := 0
	var total_files := 0

	_collect_statistics(path, include_addons, file_counts)

	# Extract internal counters and remove them from the visible dict
	total_script_lines = int(file_counts.get("_total_script_lines", 0))
	scene_count = int(file_counts.get("_scene_count", 0))
	resource_count = int(file_counts.get("_resource_count", 0))
	total_files = int(file_counts.get("_total_files", 0))
	file_counts.erase("_total_script_lines")
	file_counts.erase("_scene_count")
	file_counts.erase("_resource_count")
	file_counts.erase("_total_files")

	# Autoloads
	var autoloads: Dictionary = {}
	for prop in ProjectSettings.get_property_list():
		var prop_name: String = prop["name"]
		if prop_name.begins_with("autoload/"):
			autoloads[prop_name.substr(9)] = str(ProjectSettings.get_setting(prop_name))

	# Enabled plugins
	var plugins: Array = []
	var plugin_cfg_path := "res://addons"
	var enabled_plugins: PackedStringArray = ProjectSettings.get_setting(
		"editor_plugins/enabled", PackedStringArray()
	)
	var plugin_dir := DirAccess.open(plugin_cfg_path)
	if plugin_dir != null:
		plugin_dir.list_dir_begin()
		var dir_name := plugin_dir.get_next()
		while not dir_name.is_empty():
			if plugin_dir.current_is_dir() and not dir_name.begins_with("."):
				var cfg_path := plugin_cfg_path.path_join(dir_name).path_join("plugin.cfg")
				if FileAccess.file_exists(cfg_path):
					var plugin_path := "res://addons/%s/plugin.cfg" % dir_name
					plugins.append({
						"name": dir_name,
						"enabled": plugin_path in enabled_plugins,
					})
			dir_name = plugin_dir.get_next()
		plugin_dir.list_dir_end()

	return success({
		"file_counts_by_extension": file_counts,
		"total_files": total_files,
		"total_script_lines": total_script_lines,
		"scene_count": scene_count,
		"resource_count": resource_count,
		"autoloads": autoloads,
		"plugins": plugins,
	})


func _collect_statistics(path: String, include_addons: bool,
		file_counts: Dictionary) -> void:
	var dir := DirAccess.open(path)
	if dir == null:
		return

	dir.list_dir_begin()
	var file_name := dir.get_next()

	while not file_name.is_empty():
		if file_name.begins_with("."):
			file_name = dir.get_next()
			continue

		var full_path := path.path_join(file_name)

		if dir.current_is_dir():
			if file_name == "addons" and not include_addons:
				file_name = dir.get_next()
				continue
			_collect_statistics(full_path, include_addons, file_counts)
		else:
			var ext := file_name.get_extension().to_lower()
			file_counts[ext] = file_counts.get(ext, 0) + 1

			if ext == "gd":
				var content := _read_file_text(full_path)
				var line_count := content.count("\n") + 1 if not content.is_empty() else 0
				# We can't modify int params, so we store in the dict
				file_counts["_total_script_lines"] = file_counts.get("_total_script_lines", 0) + line_count

			if ext == "tscn":
				file_counts["_scene_count"] = file_counts.get("_scene_count", 0) + 1

			if ext in ["tres", "material", "theme", "stylebox", "font"]:
				file_counts["_resource_count"] = file_counts.get("_resource_count", 0) + 1

			file_counts["_total_files"] = file_counts.get("_total_files", 0) + 1

		file_name = dir.get_next()

	dir.list_dir_end()


# =============================================================================
# Shared helpers
# =============================================================================

## Recursively collect files matching given extensions.
func _collect_files_by_ext(path: String, extensions: Array, out: Array, include_addons: bool) -> void:
	var dir := DirAccess.open(path)
	if dir == null:
		return

	dir.list_dir_begin()
	var file_name := dir.get_next()

	while not file_name.is_empty():
		if file_name.begins_with("."):
			file_name = dir.get_next()
			continue

		var full_path := path.path_join(file_name)

		if dir.current_is_dir():
			if file_name == "addons" and not include_addons:
				file_name = dir.get_next()
				continue
			_collect_files_by_ext(full_path, extensions, out, include_addons)
		else:
			var ext := file_name.get_extension().to_lower()
			if ext in extensions:
				out.append(full_path)

		file_name = dir.get_next()

	dir.list_dir_end()


## Read a file's text content. Returns empty string on failure.
func _read_file_text(file_path: String) -> String:
	var file := FileAccess.open(file_path, FileAccess.READ)
	if file == null:
		return ""
	var content := file.get_as_text()
	file.close()
	return content
