## Plugin Manager — Autoload Singleton
##
## Scans the server/plugins/ directory for plugin manifests
## (plugin.tres), loads enabled plugins, and dispatches engine
## events to every loaded plugin via their hook methods.
##
## This singleton must be registered in project.godot under [autoload]:
##   PluginManager="*res://server/scripts/plugin_api/plugin_manager.gd"
##
## Usage from game code:
##   var pm: PluginManager = get_node("/root/PluginManager")
##   pm.load_plugin("res://server/plugins/my_plugin/")
##   pm.dispatch_hook("on_server_start")
##
## RCON integration (commands handled via PluginManager.rcon_command()):
##   plugin list          — list all discovered and loaded plugins
##   plugin load <name>   — load a discovered plugin by name
##   plugin unload <name> — unload a running plugin
##   plugin reload <name> — reload a plugin (re-compiles its script)
##   plugin info <name>   — show detailed information about a plugin
##   plugin rescan        — re-scan the plugins directory

extends Node
## Duplicate class_name removed — already registered as autoload singleton.
## class_name PluginManager

# Force dependency scripts to load (headless compat)
const _pm_manifest_ref = preload("res://server/scripts/plugin_api/plugin_manifest.gd")
const _pm_base_ref = preload("res://server/scripts/plugin_api/plugin_base.gd")

# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------

## Emitted when a plugin is successfully loaded.
signal plugin_loaded(name: String)
## Emitted when a plugin is unloaded.
signal plugin_unloaded(name: String)
## Emitted when a plugin is reloaded (load after unload).
signal plugin_reloaded(name: String)
## Emitted when a plugin hook call fails with an error.
signal plugin_error(name: String, hook: String, message: String)

# ---------------------------------------------------------------------------
# Exports
# ---------------------------------------------------------------------------

## Directory under res:// where plugin subdirectories live.
## Each subdirectory should contain a plugin.tres manifest + .gd script(s).
@export var plugins_root: String = "res://server/plugins"

## When true, the manager periodically re-scans the plugins directory
## for new or removed plugins.  Enable for development convenience;
## disable in production to avoid filesystem overhead.
@export var auto_rescan: bool = false

## Interval in seconds between auto-rescan passes
## (only applies when auto_rescan is true).
@export var rescan_interval: float = 10.0

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

## Current API version of the PluginManager.
## Bump the MAJOR version when breaking hook signature changes are made.
## Bump the MINOR version when new hooks are added (non-breaking).
## Bump the PATCH version for bug fixes and internal changes.
const API_VERSION: String = "1.0.0"

# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------

## Dictionary of loaded plugins: plugin_name (String) -> Node (plugin instance).
var loaded_plugins: Dictionary = {}

## Dictionary of all discovered manifests: plugin_name -> PluginManifest resource.
var discovered_manifests: Dictionary = {}

## Internal metadata store: plugin_name -> {path, manifest, tres_path, ...}.
var _plugin_info: Dictionary = {}

## Timer accumulator for auto-rescan.
var _rescan_timer: float = 0.0

# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------

func _ready() -> void:
	# Discover and load all enabled plugins from the plugins directory.
	rescan()
	log_info("PluginManager ready — %d plugin(s) loaded, %d discovered total" %
		[loaded_plugins.size(), discovered_manifests.size()])
	log_info("PluginManager API version: %s" % API_VERSION)

func _exit_tree() -> void:
	# Gracefully shut down all plugins before the tree exits.
	unload_all()

func _process(delta: float) -> void:
	if not auto_rescan:
		return
	_rescan_timer += delta
	if _rescan_timer >= rescan_interval:
		_rescan_timer = 0.0
		rescan()

# ---------------------------------------------------------------------------
# Discovery — scan filesystem for plugin manifests
# ---------------------------------------------------------------------------

## Scan the plugins_root directory for plugin.tres files.
## Populates discovered_manifests and automatically loads enabled ones.
## Returns the total number of manifests found.
func rescan() -> int:
	var previous_count: int = discovered_manifests.size()
	discovered_manifests.clear()
	_plugin_info.clear()

	var root: String = plugins_root
	if not ResourceLoader.exists(root):
		log_warn("Plugins root does not exist: %s" % root)
		return 0

	var dir: DirAccess = DirAccess.open(root)
	if not dir:
		log_err("Cannot open plugins root directory: %s" % root)
		return 0

	# Walk one level deep (plugin directories)
	dir.list_dir_begin()
	var entry: String = dir.get_next()
	while entry != &"":
		if entry.begins_with("."):
			entry = dir.get_next()
			continue
		if dir.current_is_dir():
			_process_plugin_directory(root, entry)
		entry = dir.get_next()
	dir.list_dir_end()

	# Auto-load newly discovered enabled plugins that aren't already loaded.
	for pname in discovered_manifests:
		if pname not in loaded_plugins:
			var manifest = discovered_manifests[pname]
			if manifest.enabled:
				_load_plugin_from_manifest(pname, manifest)

	var new_count: int = discovered_manifests.size()
	var delta_count: int = new_count - previous_count
	log_info("Rescan complete: %d manifest(s) found (+%d new)" %
		[new_count, delta_count])
	return new_count

## Inspect a single subdirectory for a plugin.tres file and,
## if valid, record it in the discovered manifests.
func _process_plugin_directory(root: String, dir_name: String) -> void:
	var tres_path: String = root + "/" + dir_name + "/plugin.tres"
	if not ResourceLoader.exists(tres_path):
		return

	var manifest: Resource = ResourceLoader.load(tres_path)
	if manifest == null or manifest.get_script() == null or not manifest.has_method(&"is_valid"):
		log_warn("Invalid manifest (not a PluginManifest resource): %s" % tres_path)
		return

	if not manifest.is_valid():
		log_warn("Invalid manifest (missing required fields 'name' or 'script_path'): %s" % tres_path)
		return

	# Check API compatibility
	if not manifest.is_api_compatible(API_VERSION):
		log_warn("Plugin '%s' requires API version %s-%s, but current API is %s" %
			[manifest.name, manifest.min_api_version, manifest.max_api_version, API_VERSION])
		return

	var pname: String = manifest.name
	discovered_manifests[pname] = manifest
	_plugin_info[pname] = {
		"path": root + "/" + dir_name + "/",
		"manifest": manifest,
		"tres_path": tres_path,
		"dir_name": dir_name
	}
	log_info("Discovered plugin: %s v%s at %s" % [pname, manifest.version, tres_path])

# ---------------------------------------------------------------------------
# Loading / Unloading
# ---------------------------------------------------------------------------

## Load a specific plugin by name (from discovered manifests).
## Returns true on success, false on failure.
func load_plugin(name: String) -> bool:
	if name in loaded_plugins:
		log_warn("Plugin already loaded: %s" % name)
		return false

	if name not in discovered_manifests:
		log_err("Plugin not found: %s. Use 'plugin rescan' to scan for new plugins." % name)
		return false

	var manifest = discovered_manifests[name]
	return _load_plugin_from_manifest(name, manifest)

## Internal: instantiate a plugin from its manifest and add it to the tree.
## Handles resource loading, instance creation, and metadata injection.
func _load_plugin_from_manifest(name: String, manifest) -> bool:
	var info: Dictionary = _plugin_info.get(name, {})
	var plugin_dir: String = info.get("path", plugins_root + "/" + name + "/")
	var script_path: String = plugin_dir + manifest.script_path

	if not ResourceLoader.exists(script_path):
		log_err("Script not found for plugin '%s': %s" % [name, script_path])
		return false

	var script_res: Resource = ResourceLoader.load(script_path)
	if not script_res is GDScript:
		log_err("Script resource is not a GDScript for plugin '%s': got %s" %
			[name, typeof(script_res)])
		return false

	# Attempt to instantiate the script
	var script_class = load(script_path)
	var instance: Node
	var ok: bool = false

	# Try normal Node.new() pattern
	if script_class.has_method(&"new"):
		instance = script_class.new()
		ok = true
	else:
		log_err("Cannot instantiate script for plugin '%s' — no 'new' method" % name)
		return false

	if not instance:
		log_err("Failed to create instance for plugin '%s'" % name)
		return false

	# Warn if the plugin doesn't extend PluginBase (it'll still work,
	# just won't have convenience methods).
	if instance == null or not instance.has_method(&"log_info"):
		log_warn("Plugin '%s' does not extend PluginBase — some features may be unavailable" % name)

	# Inject manifest metadata into the instance
	if not instance:
		push_error("[PluginManager] Plugin '%s' failed to instantiate (null)" % name)
		return false
	if instance.has_method(&"set_plugin_meta"):
		instance.set_plugin_meta(name, manifest.version, manifest.author)
	elif instance.has_method(&"set") or true:
		# Fallback: assign properties directly
		if "plugin_name" in instance:
			instance.plugin_name = name
		if "plugin_version" in instance:
			instance.plugin_version = manifest.version
		if "plugin_author" in instance:
			instance.plugin_author = manifest.author

	instance.name = "Plugin_%s" % name
	add_child(instance)
	loaded_plugins[name] = instance

	# Notify the plugin that it's now active (server lifecycle)
	if instance.has_method(&"on_server_start"):
		_call_plugin_hook(name, instance, "on_server_start", [])

	plugin_loaded.emit(name)
	log_info("Loaded plugin: %s v%s by %s" % [name, manifest.version, manifest.author])
	return true

## Unload a plugin by name.
## Removes it from the tree and clears internal state.
## Returns true on success, false if the plugin wasn't loaded.
func unload_plugin(name: String) -> bool:
	if name not in loaded_plugins:
		log_warn("Plugin not loaded: %s" % name)
		return false

	var instance: Node = loaded_plugins[name]

	# Notify the plugin of impending shutdown
	if instance.has_method(&"on_server_stop"):
		_call_plugin_hook(name, instance, "on_server_stop", [])

	# Remove from tree and free
	remove_child(instance)
	instance.queue_free()
	loaded_plugins.erase(name)
	plugin_unloaded.emit(name)
	log_info("Unloaded plugin: %s" % name)
	return true

## Reload a plugin: unload, recompile script, then load again.
## Useful for development iteration without restarting the server.
## Returns true on success.
func reload_plugin(name: String) -> bool:
	if name not in loaded_plugins:
		log_warn("Cannot reload — plugin not loaded: %s" % name)
		return false

	log_info("Reloading plugin: %s" % name)
	unload_plugin(name)

	# Re-acquire manifest info
	var manifest = discovered_manifests.get(name)
	if not manifest:
		log_err("Manifest lost for plugin '%s' during reload" % name)
		return false

	var info: Dictionary = _plugin_info.get(name, {})
	var plugin_dir: String = info.get("path", plugins_root + "/" + name + "/")
	var script_path: String = plugin_dir + manifest.script_path

	# Force GDScript recompile from disk
	if ResourceLoader.exists(script_path):
		var gdscript: GDScript = load(script_path)
		if gdscript and gdscript.has_method(&"reload"):
			gdscript.reload()

	var ok: bool = _load_plugin_from_manifest(name, manifest)
	if ok:
		plugin_reloaded.emit(name)
		log_info("Reloaded plugin: %s" % name)
	else:
		log_err("Failed to reload plugin: %s" % name)
	return ok

## Unload all currently loaded plugins. Called automatically on exit.
func unload_all() -> void:
	var names: Array = loaded_plugins.keys()
	for name in names:
		unload_plugin(name)
	if names.size() > 0:
		log_info("All plugins unloaded (%d total)" % names.size())

# ---------------------------------------------------------------------------
# Hook dispatch — call a named method on every loaded plugin
# ---------------------------------------------------------------------------

## Dispatch a hook to all loaded plugins.
## Returns an array of return values (one per plugin that returned non-null).
func dispatch_hook(hook_name: String, args: Array = []) -> Array:
	var results: Array = []
	for name in loaded_plugins:
		var instance: Node = loaded_plugins[name]
		if instance and instance.has_method(hook_name):
			var result = _call_plugin_hook(name, instance, hook_name, args)
			if result != null:
				results.append(result)
	return results

## Dispatch a filter hook (bool-returning) and return true only if ALL
## plugins returned true.  Short-circuits on the first false.
## This is used for on_player_connect and on_player_say.
func dispatch_filter_hook(hook_name: String, args: Array = []) -> bool:
	for name in loaded_plugins:
		var instance: Node = loaded_plugins[name]
		if instance and instance.has_method(hook_name):
			var result = _call_plugin_hook(name, instance, hook_name, args)
			if result is bool and not result:
				return false
	return true

## Safely call a hook on a single plugin instance, catching errors.
## If the call fails, a plugin_error signal is emitted and null is returned,
## allowing other plugins to continue processing.
func _call_plugin_hook(name: String, instance: Node, hook: String, args: Array):
	if not instance or not is_instance_id_valid(instance.get_instance_id()):
		return null

	var callable: Callable = Callable(instance, hook)
	if callable.is_null():
		return null

	# Use callv for variadic argument passing
	var result
	result = callable.callv(args)
	return result

# ---------------------------------------------------------------------------
# Convenience dispatchers — called by game code (server_main.gd, etc.)
# ---------------------------------------------------------------------------

func on_server_start() -> void:
	dispatch_hook("on_server_start")

func on_server_stop() -> void:
	dispatch_hook("on_server_stop")

func on_map_start(map_name: String) -> void:
	dispatch_hook("on_map_start", [map_name])

func on_map_end(map_name: String) -> void:
	dispatch_hook("on_map_end", [map_name])

func on_player_connect(id: int, name: String, steam_id: String) -> bool:
	return dispatch_filter_hook("on_player_connect", [id, name, steam_id])

func on_player_disconnect(id: int, reason: String) -> void:
	dispatch_hook("on_player_disconnect", [id, reason])

func on_player_say(id: int, msg: String) -> bool:
	return dispatch_filter_hook("on_player_say", [id, msg])

func on_player_spawn(id: int) -> void:
	dispatch_hook("on_player_spawn", [id])

func on_player_killed(victim: int, killer: int, weapon: String) -> void:
	dispatch_hook("on_player_killed", [victim, killer, weapon])

func on_round_start(number: int) -> void:
	dispatch_hook("on_round_start", [number])

func on_round_end(winner_team: int, reason: String) -> void:
	dispatch_hook("on_round_end", [winner_team, reason])

func on_tick(delta: float) -> void:
	dispatch_hook("on_tick", [delta])

# ---------------------------------------------------------------------------
# Cvar integration — register plugin state as server cvars
# ---------------------------------------------------------------------------

## Register cvars so rcon/in-game can inspect plugin state.
## Called automatically when a CvarRegistry singleton is detected.
func _register_plugin_cvars() -> void:
	if not has_node("/root/CvarRegistry"):
		return

	var registry = get_node("/root/CvarRegistry")
	if not registry or not registry.has_method(&"register"):
		return

	# Register a cvar for each loaded plugin indicating its load status
	for pname in loaded_plugins:
		var cvar_name: String = "plugin_" + pname.to_lower().replace(" ", "_") + "_loaded"
		if registry.has_method(&"register"):
			registry.register(cvar_name, true, TYPE_BOOL, "Plugin '%s' loaded status" % pname)

	# Register API version for external inspection
	if registry.has_method(&"register"):
		registry.register("plugin_api_version", API_VERSION, TYPE_STRING, "PluginManager API version")

# ---------------------------------------------------------------------------
# RCON command handler — subcommands for the rcon console
# ---------------------------------------------------------------------------

## Handle a plugin-related RCON command.
## Matches the subcommands added to rcon_command_handler.gd.
## Returns a formatted response string suitable for RCON output.
func rcon_command(args: PackedStringArray) -> String:
	if args.is_empty():
		return _rcon_help()

	var subcmd: String = args[0].to_lower()
	var subargs: PackedStringArray = args.slice(1)

	match subcmd:
		"list":
			return _rcon_list(subargs)
		"load":
			return _rcon_load(subargs)
		"unload":
			return _rcon_unload(subargs)
		"reload":
			return _rcon_reload(subargs)
		"info":
			return _rcon_info(subargs)
		"rescan":
			return _rcon_rescan(subargs)
		"help":
			return _rcon_help()
		_:
			return "Unknown plugin subcommand: '%s'. Use 'plugin help'.\\r\\n" % subcmd

func _rcon_help() -> String:
	var lines: PackedStringArray = [
		"=== Plugin Commands ===",
		"  plugin list                     List all loaded/available plugins",
		"  plugin load <name>              Load a discovered plugin by name",
		"  plugin unload <name>            Unload a running plugin",
		"  plugin reload <name>            Reload a plugin (re-compiles script)",
		"  plugin info <name>              Show detailed info about a plugin",
		"  plugin rescan                   Re-scan the plugins directory for changes",
	]
	return "\\r\\n".join(lines) + "\\r\\n"

func _rcon_list(_args: PackedStringArray) -> String:
	var lines: PackedStringArray = []
	lines.append("=== Plugins (%d loaded, %d discovered) ===" %
		[loaded_plugins.size(), discovered_manifests.size()])
	lines.append(&"")

	var all_names: Array = discovered_manifests.keys()
	all_names.sort()

	if all_names.is_empty():
		lines.append("  No plugins discovered. Use 'plugin rescan' to scan.")
	else:
		for pname in all_names:
			var manifest = discovered_manifests[pname]
			var status: String
			if pname in loaded_plugins:
				status = "LOADED"
			elif not manifest.enabled:
				status = "DISABLED"
			else:
				status = "AVAILABLE"
			lines.append("  %s v%s  [%s]" % [pname, manifest.version, status])

	return "\\r\\n".join(lines) + "\\r\\n"

func _rcon_load(args: PackedStringArray) -> String:
	if args.is_empty():
		return "Usage: plugin load <name>\\r\\n"

	var name: String = args[0]
	if load_plugin(name):
		return "Loaded plugin: %s\\r\\n" % name
	return "Failed to load plugin: %s. Check server logs for details.\\r\\n" % name

func _rcon_unload(args: PackedStringArray) -> String:
	if args.is_empty():
		return "Usage: plugin unload <name>\\r\\n"

	var name: String = args[0]
	if unload_plugin(name):
		return "Unloaded plugin: %s\\r\\n" % name
	return "Failed to unload plugin: %s. Check server logs for details.\\r\\n" % name

func _rcon_reload(args: PackedStringArray) -> String:
	if args.is_empty():
		return "Usage: plugin reload <name>\\r\\n"

	var name: String = args[0]
	if reload_plugin(name):
		return "Reloaded plugin: %s\\r\\n" % name
	return "Failed to reload plugin: %s. Check server logs for details.\\r\\n" % name

func _rcon_info(args: PackedStringArray) -> String:
	if args.is_empty():
		return "Usage: plugin info <name>\\r\\n"

	var name: String = args[0]
	if name not in discovered_manifests:
		return "Plugin not found: %s. Use 'plugin list' to see available plugins.\\r\\n" % name

	var manifest = discovered_manifests[name]
	var info: Dictionary = _plugin_info.get(name, {})
	var full_script_path: String = info.get("path", "") + manifest.script_path
	var lines: PackedStringArray = []
	lines.append("=== Plugin Info: %s ===" % name)
	lines.append("  Name:       %s" % name)
	lines.append("  Version:    %s" % manifest.version)
	lines.append("  Author:     %s" % manifest.author)
	lines.append("  Script:     %s" % full_script_path)
	lines.append("  Enabled:    %s" % str(manifest.enabled))
	lines.append("  Loaded:     %s" % str(name in loaded_plugins))
	lines.append("  Min API:    %s" % (manifest.min_api_version if manifest.min_api_version else "(none)"))
	lines.append("  Max API:    %s" % (manifest.max_api_version if manifest.max_api_version else "(none)"))
	lines.append("  Metadata:   %s" % str(manifest.metadata))

	return "\\r\\n".join(lines) + "\\r\\n"

func _rcon_rescan(_args: PackedStringArray) -> String:
	var count: int = rescan()
	return "Rescan complete: %d manifest(s) found, %d plugin(s) loaded.\\r\\n" % [count, loaded_plugins.size()]

# ---------------------------------------------------------------------------
# Utility / logging
# ---------------------------------------------------------------------------

func log_info(msg: String) -> void:
	print("[PluginManager] %s" % msg)

func log_warn(msg: String) -> void:
	push_warning("[PluginManager] %s" % msg)

func log_err(msg: String) -> void:
	push_error("[PluginManager] %s" % msg)
