@tool
extends Node

## Multi-connection WebSocket client.
## Connects to multiple Node.js MCP server instances on ports 6505-6514.
## Each Claude Code session gets its own port; Godot talks to all of them.
## Ports 6505-6509: MCP servers (stdio), 6510-6514: CLI tool connections.

signal client_connected()
signal client_disconnected()
signal message_received(text: String)
signal command_executed(method: String, success: bool)
signal command_completed(method: String, success: bool, response: String, source_port: int)

var command_router: Node

const BASE_PORT := 6505
const MAX_PORT := 6514
const RECONNECT_INTERVAL := 3.0
const BUFFER_SIZE := 16 * 1024 * 1024  # 16MB
const PING_INTERVAL := 5.0  # send ping every N seconds while connected
const INACTIVITY_TIMEOUT := 30.0  # force-close if no message received for N seconds

# Per-port connection state
var _peers: Dictionary = {}  # port -> WebSocketPeer
var _connected: Dictionary = {}  # port -> bool
var _timers: Dictionary = {}  # port -> float (reconnect countdown)
var _connect_times: Dictionary = {}  # port -> float (elapsed seconds since connect)
var _last_activity: Dictionary = {}  # port -> float (seconds since last received message)
var _ping_timers: Dictionary = {}  # port -> float (seconds since last sent ping)
var _stale_ports: Dictionary = {}  # port -> bool (heartbeat timeout flag, exposed to UI)
var _running: bool = false


func start_server() -> void:
	_running = true
	for p in range(BASE_PORT, MAX_PORT + 1):
		_connected[p] = false
		_timers[p] = 0.0
		_try_connect(p)
	print("[MCP] Connecting to ports %d-%d" % [BASE_PORT, MAX_PORT])


func stop_server() -> void:
	_running = false
	for p in _peers:
		var ws: WebSocketPeer = _peers[p]
		if ws:
			ws.close(1000, "Plugin shutting down")
	_peers.clear()
	_connected.clear()
	_timers.clear()
	_last_activity.clear()
	_ping_timers.clear()
	_stale_ports.clear()
	print("[MCP] WebSocket client stopped")


func get_client_count() -> int:
	var count: int = 0
	for p in _connected:
		if _connected[p]:
			count += 1
	return count


func get_connected_ports() -> Array[int]:
	var ports: Array[int] = []
	for p: int in _connected:
		if _connected[p]:
			ports.append(p)
	return ports


func get_port_connect_time(port: int) -> float:
	return _connect_times.get(port, -1.0)


func get_port_idle_time(port: int) -> float:
	return _last_activity.get(port, -1.0)


func is_port_stale(port: int) -> bool:
	return _stale_ports.get(port, false)


func _try_connect(p: int) -> void:
	var ws := WebSocketPeer.new()
	ws.outbound_buffer_size = BUFFER_SIZE
	ws.inbound_buffer_size = BUFFER_SIZE
	var err := ws.connect_to_url("ws://127.0.0.1:%d" % p)
	if err == OK:
		_peers[p] = ws
	else:
		_peers[p] = null


func _process(delta: float) -> void:
	if not _running:
		return

	for p in range(BASE_PORT, MAX_PORT + 1):
		var ws: WebSocketPeer = _peers.get(p)

		# No peer - try reconnect on timer
		if ws == null:
			_timers[p] = _timers.get(p, 0.0) + delta
			if _timers[p] >= RECONNECT_INTERVAL:
				_timers[p] = 0.0
				_try_connect(p)
			continue

		ws.poll()
		var state := ws.get_ready_state()

		match state:
			WebSocketPeer.STATE_OPEN:
				if not _connected.get(p, false):
					_connected[p] = true
					_connect_times[p] = 0.0
					_last_activity[p] = 0.0
					_ping_timers[p] = 0.0
					_stale_ports[p] = false
					_timers[p] = 0.0
					print_verbose("[MCP] Connected on port %d" % p)
					client_connected.emit()
				else:
					_connect_times[p] = _connect_times.get(p, 0.0) + delta
					_last_activity[p] = _last_activity.get(p, 0.0) + delta
					_ping_timers[p] = _ping_timers.get(p, 0.0) + delta

				var received_any := false
				while ws.get_available_packet_count() > 0:
					var packet := ws.get_packet()
					var text := packet.get_string_from_utf8()
					received_any = true
					_dispatch_message(text, p)

				if received_any:
					_last_activity[p] = 0.0
					if _stale_ports.get(p, false):
						_stale_ports[p] = false
						print("[MCP] Port %d recovered from stale state" % p)

				# Force-close if no message received for INACTIVITY_TIMEOUT.
				# The MCP server pings every 10s, so 30s of silence means the
				# connection is half-open and reconnect is the only way out.
				if _last_activity.get(p, 0.0) > INACTIVITY_TIMEOUT:
					push_warning("[MCP] Port %d silent for %.1fs — forcing reconnect" % [p, _last_activity[p]])
					_stale_ports[p] = true
					ws.close(4000, "Heartbeat timeout")
					_connected[p] = false
					_peers[p] = null
					_timers[p] = 0.0
					client_disconnected.emit()
					continue

				# Send periodic ping so the server can detect our death too,
				# and so any reply resets our own inactivity timer.
				if _ping_timers.get(p, 0.0) >= PING_INTERVAL:
					_ping_timers[p] = 0.0
					ws.send_text(JSON.stringify({"jsonrpc": "2.0", "method": "ping", "params": {}}))

			WebSocketPeer.STATE_CLOSING:
				pass

			WebSocketPeer.STATE_CLOSED:
				if _connected.get(p, false):
					_connected[p] = false
					print_verbose("[MCP] Disconnected from port %d" % p)
					client_disconnected.emit()
				_peers[p] = null
				_timers[p] = 0.0
				_last_activity[p] = 0.0
				_ping_timers[p] = 0.0

			WebSocketPeer.STATE_CONNECTING:
				pass


func _send_to_port(p: int, text: String) -> void:
	var ws: WebSocketPeer = _peers.get(p)
	if ws and _connected.get(p, false):
		ws.send_text(text)


func send_message(text: String) -> void:
	# Broadcast to all connected peers
	for p in _peers:
		_send_to_port(p, text)


## Synchronous dispatch - parse JSON, handle ping/pong, queue command execution
func _dispatch_message(text: String, source_port: int) -> void:
	message_received.emit(text)

	var json := JSON.new()
	var err := json.parse(text)
	if err != OK:
		_send_response(source_port, null, null, {"code": -32700, "message": "Parse error"})
		return

	var msg: Variant = json.data
	if not msg is Dictionary:
		_send_response(source_port, null, null, {"code": -32600, "message": "Invalid request"})
		return

	var msg_dict: Dictionary = msg

	if msg_dict.get("method") == "ping":
		_send_to_port(source_port, JSON.stringify({"jsonrpc": "2.0", "method": "pong", "params": {}}))
		return

	if msg_dict.get("method") == "pong":
		return

	var id: Variant = msg_dict.get("id")
	var method: String = msg_dict.get("method", "")
	var params: Dictionary = msg_dict.get("params", {})

	if method.is_empty():
		_send_response(source_port, id, null, {"code": -32600, "message": "Missing method"})
		return

	if not command_router:
		_send_response(source_port, id, null, {"code": -32603, "message": "No command router"})
		return

	_execute_command.call_deferred(source_port, id, method, params)


func _execute_command(source_port: int, id: Variant, method: String, params: Dictionary) -> void:
	var cmd_result: Dictionary = await command_router.execute(method, params)
	if cmd_result.has("error"):
		var err_data: Variant = cmd_result["error"]
		_send_response(source_port, id, null, err_data)
		var response_text := JSON.stringify(err_data)
		command_executed.emit(method, false)
		command_completed.emit(method, false, response_text, source_port)
	else:
		var result_data: Variant = cmd_result.get("result", {})
		_send_response(source_port, id, result_data, null)
		var response_text := JSON.stringify(result_data)
		command_executed.emit(method, true)
		command_completed.emit(method, true, response_text, source_port)


func _send_response(source_port: int, id: Variant, result: Variant, err: Variant) -> void:
	var response: Dictionary = {"jsonrpc": "2.0", "id": id}
	if err != null:
		response["error"] = err
	else:
		response["result"] = result if result != null else {}
	_send_to_port(source_port, JSON.stringify(response))
