extends Node3D

## NS-16: server authority; see `position_authority_client.gd`. NS-18: interaction POST + radius
## glow preview (see child nodes).
## NEO-22: WASD locomotion + `move-stream` samples (`player.gd`,
## `position_authority_client.gd`).
## NS-23: bakes `NavigationRegion3D` on startup; boot snap from authority `GET`.
## NEO-15: follow camera is `World/IsometricFollowCamera/Camera3D`
## (see `isometric_follow_camera.gd`).
## Prototype: two random short bumps (sibling StaticBody3D under NavigationRegion3D; see
## `random_floor_bumps.gd`) before nav bake.
## Prototype: `PhysicsRampTest*` (+X extension: west slab ends before the dip;
## `PhysicsRampTestFloorExtensionNorthFill` / `SouthFill` restore walkable floor past the dip
## in ±Z;
## block; gentle ramp on **+Z**; steep on **+X**), plus `PhysicsSmoothHillTest` /
## `PhysicsSmoothDipTest` (HeightMap collision).
## Steep ramp: **2 m / 1.5 m** rise/run (~53°) so slope exceeds `Player` `floor_max_angle` (~50°)
## and is not walkable; gentle ramp stays shallower than `NavigationMesh.agent_max_climb`. Steep is
## under `World` only (not nav source geometry).
## Prototype HUD: world `CharacterBody3D` position in `UICanvas/PlayerPositionLabel`
## (updated in `_physics_process` so it matches physics ticks).

const MOVE_REJECT_MSG_SECONDS: float = 4.0

## Throttle `move-stream` POSTs (~20 Hz at 120 physics ticks/sec).
const STREAM_PHYSICS_INTERVAL: int = 6

## Skip `move-stream` while standing still so idle + anchor are not fighting HTTP snaps.
const STREAM_IDLE_VEL_HZ_SQ: float = 0.0004

## Ignore authority snaps smaller than this (m) to avoid micro-flicker from float / echo noise.
const AUTH_SNAP_MIN_HORIZONTAL: float = 0.08
const AUTH_SNAP_MIN_VERTICAL: float = 0.04

## While moving, ignore stream snaps smaller than this (XZ m): responses are often one RTT behind
## integrated `move_and_slide`, so snapping causes rubber-band / bounciness.
const AUTH_SNAP_LOCOMOTION_SKIP_MAX_DH: float = 0.55
## Horizontal speed² above this (m²/s²) counts as “moving” for snap suppression (~0.35 m/s).
const AUTH_SNAP_LOCOMOTION_VEL_HZ_SQ: float = 0.12

const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd")
const HotbarCastSlotResolver := preload("res://scripts/hotbar_cast_slot_resolver.gd")
const CooldownStateScript := preload("res://scripts/cooldown_state.gd")
const COOLDOWN_REASON_ON_CD := "on_cooldown"

## Bump on each rejection so older one-shot timers do not clear a newer message.
var _move_reject_msg_token: int = 0
var _stream_physics_counter: int = 0
## Cached latest `target_state_changed` payload so the HUD can re-render with live
## distance readouts on every physics tick without missing server-side fields.
var _last_target_state: Dictionary = {}
## Last server-acknowledged world position (from `PositionAuthorityClient.authoritative_ack`;
## boot GET 200 or `move-stream` 200). Used purely to render the server-vs-client divergence
## line in the HUD so "target says denied but I'm obviously in range" becomes self-explanatory.
var _last_ack_world: Vector3 = Vector3.ZERO
var _have_last_ack: bool = false
var _last_ack_msec: int = 0
## After the first `apply_as_snap` authority update, micro-snaps may be skipped
## (boot always applies).
var _allow_authority_soft_snap_skip: bool = false
## Next `apply_as_snap` must not be suppressed (e.g. `move-stream` 400 → `sync_from_server`).
var _authority_force_snap_next: bool = false

## [InputMap] [code]dev_toggle_occluder_obstacle[/code] (default **Ctrl+Shift+K**):
## Restored when the obstacle is shown again.
var _dev_saved_obstacle_layer: int = -1
var _dev_saved_obstacle_mask: int = -1
var _dev_saved_obstacle_process_mode: Node.ProcessMode = Node.PROCESS_MODE_INHERIT
## Cached `Obstacle` (path breaks after reparent for nav bake; no fixed [NodePath]).
var _dev_obstacle_smoke: Node3D
var _hotbar_state: Node = null
var _hotbar_client: Node = null
var _ability_cast_client: Node = null
var _cooldown_state: RefCounted = null
var _cooldown_client: Node = null
var _cooldown_poll_timer: Timer = null

@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
@onready var _world: Node3D = $World
@onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D
@onready var _player: CharacterBody3D = $World/Player
@onready var _authority: Node = $PositionAuthorityClient
@onready var _catalog: Node = $InteractablesCatalogClient
@onready var _interactables_root: Node3D = $World/NavigationRegion3D/InteractablesRoot
@onready var _radius_preview: Node3D = $World/InteractionMarkers
@onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel
@onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel
@onready var _target_lock_label: Label = $UICanvas/TargetLockLabel
@onready var _cast_feedback_label: Label = $UICanvas/CastFeedbackLabel
@onready var _cooldown_slots_label: Label = $UICanvas/CooldownSlotsLabel
@onready var _target_client: Node = $TargetSelectionClient
@onready var _interaction_client: Node = $InteractionRequestClient
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor


func _ready() -> void:
	set_physics_process(true)
	set_process_unhandled_key_input(true)
	await get_tree().process_frame
	_dev_obstacle_smoke = get_node_or_null("World/NavigationRegion3D/Obstacle") as Node3D
	# Runtime load + .call avoids class_name / preload static resolution issues across Godot parses.
	load("res://scripts/random_floor_bumps.gd").call("spawn_short_random_bumps", _floor)
	# Default `bake_navigation_mesh()` bakes on a background thread; sync bake ensures the map
	# exists before any `NavigationAgent3D` queries (see Godot navigation_using_navigationagents).
	_nav_region.bake_navigation_mesh(false)
	await get_tree().physics_frame
	await get_tree().physics_frame
	_authority.connect(
		"authoritative_position_received", Callable(self, "_on_authoritative_position")
	)
	_authority.connect("move_rejected", Callable(self, "_on_move_rejected"))
	# NEO-24: movement-driven refresh — `TargetSelectionClient` no-ops on boot (no lock yet)
	# and throttles bursts (see Decision 2 in NEO-24 plan). `authoritative_ack` fires on
	# every server-confirmed position (boot GET 200 + `move-stream` 200) so a held lock is
	# revalidated during ordinary WASD locomotion without re-introducing snap rubber-banding.
	_authority.connect("authoritative_ack", Callable(_target_client, "on_authoritative_ack"))
	_authority.connect("authoritative_ack", Callable(self, "_on_authoritative_ack_for_hud"))
	_target_client.connect("target_state_changed", Callable(self, "_on_target_state_changed"))
	# NEO-24 post-merge: tell the target client how to nudge the authority with the current
	# capsule position before each POST /target/select. Shrinks the common "Tab right after
	# stopping" false-denial race by making the server's stored snapshot as fresh as possible.
	if _target_client.has_method("set_freshness_kick"):
		_target_client.call("set_freshness_kick", _authority, _player)
	_catalog.connect("catalog_ready", Callable(self, "_on_interactables_catalog_ready"))
	_catalog.connect("catalog_failed", Callable(self, "_on_interactables_catalog_failed"))
	if _radius_preview.has_method("setup_player"):
		_radius_preview.call("setup_player", _player)
	_catalog.call("request_catalog")
	_authority.call("sync_from_server")
	_target_client.call("request_sync_from_server")
	_setup_hotbar_loadout_sync()


func _physics_process(_delta: float) -> void:
	if not is_instance_valid(_player) or not is_instance_valid(_player_pos_label):
		return
	var wish: Vector3 = _locomotion_wish_world_xz_from_camera()
	_player.call("set_locomotion_wish_world_xz", wish)
	var vel_hz_sq: float = Vector2(_player.velocity.x, _player.velocity.z).length_squared()
	var moving: bool = wish.length_squared() > 1e-10 or vel_hz_sq > STREAM_IDLE_VEL_HZ_SQ
	if moving:
		_stream_physics_counter += 1
		if _stream_physics_counter >= STREAM_PHYSICS_INTERVAL:
			_stream_physics_counter = 0
			var batch: Array = [_player.global_position]
			_authority.call("submit_stream_targets", batch)
	else:
		_stream_physics_counter = 0
	var p: Vector3 = _player.global_position
	_player_pos_label.text = _build_player_position_text(p)
	_render_target_lock_label(p)
	if _cooldown_state != null and _cooldown_state.has_method("any_slot_cooling"):
		if bool(_cooldown_state.call("any_slot_cooling")):
			_render_cooldown_slots_label()


## HUD: client position on top, and (once a `move-stream` / boot GET has landed) the
## **server**-acknowledged position + horizontal delta underneath. When the server line
## is far from the client line the capsule has outrun the authoritative state — any
## targeting denials are being computed against the stale server position, and the next
## `move-stream` 200 (or the recovery resync on a 400) will reconcile.
func _build_player_position_text(p: Vector3) -> String:
	var lines: PackedStringArray = ["x: %.3f" % p.x, "y: %.3f" % p.y, "z: %.3f" % p.z]
	if _have_last_ack:
		var s: Vector3 = _last_ack_world
		var dh: float = Vector2(p.x - s.x, p.z - s.z).length()
		var age_msec: int = max(0, Time.get_ticks_msec() - _last_ack_msec)
		lines.append("srv: (%.2f, %.2f, %.2f) Δ=%.2fm age=%dms" % [s.x, s.y, s.z, dh, age_msec])
	else:
		lines.append("srv: — (no ack yet)")
	return "\n".join(lines)


func _locomotion_wish_world_xz_from_camera() -> Vector3:
	var v: Vector2 = Input.get_vector("move_left", "move_right", "move_forward", "move_back")
	if v.length_squared() < 1e-8:
		return Vector3.ZERO
	if not is_instance_valid(_camera):
		return Vector3.ZERO
	var forward: Vector3 = -_camera.global_transform.basis.z
	forward.y = 0.0
	if forward.length_squared() < 1e-10:
		return Vector3.ZERO
	forward = forward.normalized()
	var right: Vector3 = forward.cross(Vector3.UP)
	if right.length_squared() < 1e-10:
		return Vector3.ZERO
	right = right.normalized()
	var w: Vector3 = right * v.x + forward * (-v.y)
	if w.length_squared() < 1e-10:
		return Vector3.ZERO
	return w.normalized()


func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
	if not apply_as_snap:
		return
	var force: bool = _authority_force_snap_next
	_authority_force_snap_next = false
	var p: Vector3 = _player.global_position
	var dh: float = Vector2(world.x - p.x, world.z - p.z).length()
	var dy: float = absf(world.y - p.y)
	if _allow_authority_soft_snap_skip and not force:
		var wish_len2: float = _player._locomotion_wish_world_xz.length_squared()
		var vel_hz_sq: float = Vector2(_player.velocity.x, _player.velocity.z).length_squared()
		var locomoting: bool = wish_len2 > 1e-10 or vel_hz_sq > AUTH_SNAP_LOCOMOTION_VEL_HZ_SQ
		if locomoting and dh < AUTH_SNAP_LOCOMOTION_SKIP_MAX_DH:
			return
		if dh < AUTH_SNAP_MIN_HORIZONTAL and dy < AUTH_SNAP_MIN_VERTICAL:
			return
	_allow_authority_soft_snap_skip = true
	_player.snap_to_server(world)


## NEO-24 diagnostic: cache the server's last authoritative position so the HUD can
## surface client-vs-server divergence. This is the same signal `TargetSelectionClient`
## hooks for movement-driven lock refresh — we do not snap the capsule to this value
## (that is `authoritative_position_received`'s job on boot + move rejection).
##
## NEO-27 / NEO-31 / NEO-30: Slice 3 [code]ability_cast_requested[/code] — [code]print[/code] from
## [method _request_hotbar_cast_slot] after [code]request_cast[/code] returns [code]true[/code]
## (POST queued on [code]HTTPRequest[/code]).
## Server hooks: [code]AbilityCastApi.cs[/code] (accept path). [code]ability_cast_denied[/code]:
## [code]push_warning[/code] in [code]ability_cast_client.gd[/code].
## HUD [member _cast_feedback_label] (NEO-28). Deny branches in [code]AbilityCastApi.cs[/code].
## TODO(E9.M1): catalog + ingest.
func _on_authoritative_ack_for_hud(world: Vector3) -> void:
	_last_ack_world = world
	_have_last_ack = true
	_last_ack_msec = Time.get_ticks_msec()


## NEO-24: cache the last server-acknowledged target state; the label text is
## re-rendered every physics tick in [method _render_target_lock_label] so the
## live distance readouts stay in sync with the player capsule even while
## the server state is idle.
func _on_target_state_changed(state: Dictionary) -> void:
	_last_target_state = state.duplicate()
	if is_instance_valid(_player):
		_render_target_lock_label(_player.global_position)


func _setup_hotbar_loadout_sync() -> void:
	# NEO-29: hydrate a local hotbar state mirror from server-owned HotbarLoadout on boot.
	_hotbar_state = load("res://scripts/hotbar_state.gd").new()
	_hotbar_state.name = "HotbarState"
	add_child(_hotbar_state)
	_hotbar_client = load("res://scripts/hotbar_loadout_client.gd").new()
	_hotbar_client.name = "HotbarLoadoutClient"
	add_child(_hotbar_client)
	_ability_cast_client = load("res://scripts/ability_cast_client.gd").new()
	_ability_cast_client.name = "AbilityCastClient"
	add_child(_ability_cast_client)
	if is_instance_valid(_authority):
		var authority_base_url: Variant = _authority.get("base_url")
		var authority_player_id: Variant = _authority.get("dev_player_id")
		if authority_base_url is String and not (authority_base_url as String).is_empty():
			_hotbar_client.set("base_url", authority_base_url)
			_ability_cast_client.set("base_url", authority_base_url)
		if authority_player_id is String and not (authority_player_id as String).is_empty():
			_hotbar_client.set("dev_player_id", authority_player_id)
			_ability_cast_client.set("dev_player_id", authority_player_id)
	if _ability_cast_client.has_signal("cast_result_received"):
		_ability_cast_client.connect(
			"cast_result_received", Callable(self, "_on_cast_result_received")
		)
	if _hotbar_client.has_method("set_hotbar_state"):
		_hotbar_client.call("set_hotbar_state", _hotbar_state)
	if _hotbar_client.has_method("request_sync_from_server"):
		_hotbar_client.call("request_sync_from_server")
	_setup_cooldown_sync()


func _setup_cooldown_sync() -> void:
	# NEO-32: server cooldown snapshot + local mirror for HUD and cast spam guard.
	_cooldown_state = CooldownStateScript.new()
	_cooldown_client = load("res://scripts/cooldown_snapshot_client.gd").new()
	_cooldown_client.name = "CooldownSnapshotClient"
	add_child(_cooldown_client)
	if is_instance_valid(_authority):
		var authority_base_url: Variant = _authority.get("base_url")
		var authority_player_id: Variant = _authority.get("dev_player_id")
		if authority_base_url is String and not (authority_base_url as String).is_empty():
			_cooldown_client.set("base_url", authority_base_url)
		if authority_player_id is String and not (authority_player_id as String).is_empty():
			_cooldown_client.set("dev_player_id", authority_player_id)
	if _cooldown_client.has_signal("snapshot_received"):
		_cooldown_client.connect(
			"snapshot_received", Callable(self, "_on_cooldown_snapshot_received")
		)
	if _hotbar_client.has_signal("loadout_changed"):
		_hotbar_client.connect(
			"loadout_changed", Callable(self, "_on_hotbar_loadout_changed_sync_cooldown")
		)
	_ensure_cooldown_poll_timer()


func _ensure_cooldown_poll_timer() -> void:
	if is_instance_valid(_cooldown_poll_timer):
		return
	_cooldown_poll_timer = Timer.new()
	_cooldown_poll_timer.name = "CooldownPollTimer"
	_cooldown_poll_timer.one_shot = true
	_cooldown_poll_timer.wait_time = 0.25
	_cooldown_poll_timer.timeout.connect(_on_cooldown_poll_timeout)
	add_child(_cooldown_poll_timer)


func _on_hotbar_loadout_changed_sync_cooldown(_loadout: Dictionary) -> void:
	if (
		is_instance_valid(_cooldown_client)
		and _cooldown_client.has_method("request_sync_from_server")
	):
		_cooldown_client.call("request_sync_from_server")


func _on_cooldown_poll_timeout() -> void:
	if (
		is_instance_valid(_cooldown_client)
		and _cooldown_client.has_method("request_sync_from_server")
	):
		_cooldown_client.call("request_sync_from_server")


func _on_cooldown_snapshot_received(snapshot: Dictionary) -> void:
	if _cooldown_state == null or not _cooldown_state.has_method("apply_snapshot"):
		return
	_cooldown_state.call("apply_snapshot", snapshot)
	_render_cooldown_slots_label()
	if not _cooldown_state.has_method("any_slot_cooling"):
		return
	if bool(_cooldown_state.call("any_slot_cooling")):
		if is_instance_valid(_cooldown_poll_timer) and _cooldown_poll_timer.is_stopped():
			_cooldown_poll_timer.start()
		return
	if is_instance_valid(_cooldown_poll_timer):
		_cooldown_poll_timer.stop()


func _render_cooldown_slots_label() -> void:
	if not is_instance_valid(_cooldown_slots_label):
		return
	if _cooldown_state == null or not _cooldown_state.has_method("build_hud_line"):
		return
	var line: Variant = _cooldown_state.call("build_hud_line")
	if line is String:
		_cooldown_slots_label.text = line as String


## Combines cached server state (`_last_target_state`) with per-anchor horizontal
## distance computed from the live capsule position. Distances are a **display-only**
## hint — the server remains authoritative for `validity`. If the HUD shows
## `Validity: ok` but `alpha: 10.3 m` (> 8 m radius), that means the server's
## position snapshot is lagging the visible capsule (NEO-23 `move-stream` 20 Hz +
## one-way latency); the refresh GET on `authoritative_ack` will reconcile within
## ~250 ms cooldown the next time a `move-stream` lands.
func _render_target_lock_label(world: Vector3) -> void:
	if not is_instance_valid(_target_lock_label):
		return
	var state: Dictionary = _last_target_state
	var locked_variant: Variant = state.get("lockedTargetId", null)
	var locked_text: String = "—"
	if locked_variant is String and not (locked_variant as String).is_empty():
		locked_text = locked_variant as String
	var validity: String = str(state.get("validity", "none"))
	var sequence: int = int(state.get("sequence", 0))
	var lines: PackedStringArray = [
		"Target: %s" % locked_text,
		"Validity: %s" % validity,
		"Seq: %d" % sequence,
	]
	for id_variant: Variant in PrototypeTargetConstants.ordered_ids():
		var tid: String = id_variant as String
		var dist: float = PrototypeTargetConstants.horizontal_distance_to(tid, world)
		var radius: float = PrototypeTargetConstants.anchor_radius(tid)
		var marker: String = "in" if dist <= radius else "out"
		lines.append("%s: %.2f m / %.1f (%s)" % [tid, dist, radius, marker])
	if state.has("reasonCode"):
		lines.append("Denied: %s" % str(state["reasonCode"]))
	_target_lock_label.text = "\n".join(lines)


func _on_cast_result_received(accepted: bool, reason_code: String) -> void:
	if not is_instance_valid(_cast_feedback_label):
		return
	if accepted:
		_cast_feedback_label.text = "Cast: accepted"
		if is_instance_valid(_cooldown_client):
			if _cooldown_client.has_method("request_sync_from_server"):
				_cooldown_client.call("request_sync_from_server")
		return
	var rc := reason_code.strip_edges()
	if rc.is_empty():
		_cast_feedback_label.text = "Cast: denied (no reasonCode)"
	else:
		_cast_feedback_label.text = "ability_cast_denied: %s" % rc


func _on_move_rejected(reason_code: String) -> void:
	_authority_force_snap_next = true
	# Rejected stream: server state may differ; next snap is forced.
	push_warning("Move rejected: %s" % reason_code)
	_move_reject_msg_token += 1
	var token: int = _move_reject_msg_token
	_move_reject_label.text = "Move rejected: %s" % reason_code
	get_tree().create_timer(MOVE_REJECT_MSG_SECONDS).timeout.connect(
		func():
			if token == _move_reject_msg_token:
				_move_reject_label.text = "",
		CONNECT_ONE_SHOT
	)


## Dev smoke (NEO-18 occluder): action [code]dev_toggle_occluder_obstacle[/code]
## (default **Ctrl+Shift+K**). Toggles `Obstacle` **visibility + collision** —
## does not call [method Node.queue_free].
## Uses [member Node.process_mode] [code]PROCESS_MODE_DISABLED[/code] so the [StaticBody3D] is
## **removed from the physics simulation**
## (default [member CollisionObject3D.disable_mode] is [code]REMOVE[/code]).
## Layer/shape tweaks alone left a **Jolt ghost collider** (invisible wall) in practice.
## When hidden, reparents `Obstacle` under [member _world]: [NavigationMesh] source geometry scans
## [NavigationRegion3D] children only ([code]SOURCE_GEOMETRY_ROOT_NODE_CHILDREN[/code]), and the
## default [code]geometry_parsed_geometry_type[/code] is [code]PARSED_GEOMETRY_BOTH[/code], so a
## child [MeshInstance3D] still carved a hole even with collision disabled — rebake alone was not
## enough.
## After each toggle, rebakes (main thread) and syncs the player [NavigationAgent3D].
## **F9** / **F10** are debugger shortcuts in the embedded game; use the Input Map action.
## For a true **freed-node** occluder test, reload the scene or remove the body in the editor.
func _unhandled_key_input(event: InputEvent) -> void:
	for slot_digit in range(1, 9):
		var action_name := "hotbar_slot_%d" % slot_digit
		if event.is_action_pressed(action_name):
			_request_hotbar_cast_slot(slot_digit - 1)
			return
	# NEO-25: route interact keys here — `InteractionRequestClient._input` is unreliable in the
	# embedded Game dock / focus order; `_unhandled_key_input` on the scene root still fires.
	if event.is_action_pressed("interact"):
		_forward_interact_post("post_interact_terminal")
		return
	if event.is_action_pressed("interact_secondary"):
		_forward_interact_post("post_interact_resource")
		return
	if event is InputEventKey:
		var k := event as InputEventKey
		if k.pressed and not k.echo and (k.physical_keycode == KEY_E or k.keycode == KEY_E):
			_forward_interact_post("post_interact_terminal")
			return
		if k.pressed and not k.echo and (k.physical_keycode == KEY_R or k.keycode == KEY_R):
			_forward_interact_post("post_interact_resource")
			return
	if not event.is_action_pressed("dev_toggle_occluder_obstacle"):
		return
	call_deferred("_dev_toggle_obstacle_smoke_deferred")


func _request_hotbar_cast_slot(slot_index: int) -> void:
	if not is_instance_valid(_hotbar_state) or not _hotbar_state.has_method("slots_snapshot"):
		return
	var slots: Array = _hotbar_state.call("slots_snapshot") as Array
	var target_state: Dictionary = {}
	if is_instance_valid(_target_client) and _target_client.has_method("cached_state"):
		var st: Variant = _target_client.call("cached_state")
		if st is Dictionary:
			target_state = st as Dictionary
	var outcome: Dictionary = HotbarCastSlotResolver.resolve(slot_index, slots, target_state)
	if not bool(outcome.get(HotbarCastSlotResolver.KEY_OK, false)):
		var reason: String = str(outcome.get(HotbarCastSlotResolver.KEY_REASON, ""))
		if reason == HotbarCastSlotResolver.REASON_INVALID_SLOT:
			push_warning("Hotbar cast ignored: slot %d invalid_slot_index" % slot_index)
		elif reason == HotbarCastSlotResolver.REASON_EMPTY_SLOT:
			push_warning("Hotbar cast ignored: slot %d empty_slot" % slot_index)
		return
	var ability_id: String = outcome[HotbarCastSlotResolver.KEY_ABILITY_ID] as String
	var resolved_slot: int = int(outcome.get(HotbarCastSlotResolver.KEY_SLOT_INDEX, slot_index))
	var target_id: Variant = outcome.get(HotbarCastSlotResolver.KEY_TARGET_ID, null)
	if _cooldown_state != null and _cooldown_state.has_method("is_slot_cooling"):
		if bool(_cooldown_state.call("is_slot_cooling", resolved_slot)):
			if is_instance_valid(_cast_feedback_label):
				_cast_feedback_label.text = (
					"ability_cast_denied: %s (client guard)" % COOLDOWN_REASON_ON_CD
				)
			push_warning("ability_cast_denied reasonCode=%s (client guard)" % COOLDOWN_REASON_ON_CD)
			return
	var started: bool = false
	if is_instance_valid(_ability_cast_client) and _ability_cast_client.has_method("request_cast"):
		started = bool(
			_ability_cast_client.call("request_cast", resolved_slot, ability_id, target_id)
		)
	if started:
		var target_label: String = "null"
		if target_id != null:
			target_label = str(target_id)
		print(
			(
				"ability_cast_requested slot=%d ability_id=%s targetId=%s"
				% [resolved_slot, ability_id, target_label]
			)
		)


func _forward_interact_post(method: String) -> void:
	if not is_instance_valid(_interaction_client):
		return
	if _interaction_client.has_method(method):
		_interaction_client.call(method)


func _dev_toggle_obstacle_smoke_deferred() -> void:
	var obstacle := _dev_obstacle_smoke
	if obstacle == null or not is_instance_valid(obstacle):
		return
	var rig: Node = get_node_or_null("World/IsometricFollowCamera")
	if obstacle.visible:
		if rig != null and rig.has_method("restore_all_occluder_materials_now"):
			rig.call("restore_all_occluder_materials_now")
		_dev_saved_obstacle_process_mode = obstacle.process_mode
		var co := obstacle as CollisionObject3D
		if co != null:
			_dev_saved_obstacle_layer = co.collision_layer
			_dev_saved_obstacle_mask = co.collision_mask
			co.collision_layer = 0
			co.collision_mask = 0
		_dev_collision_shapes_set_disabled(obstacle, true)
		obstacle.process_mode = Node.PROCESS_MODE_DISABLED
		obstacle.visible = false
		if obstacle.get_parent() == _nav_region:
			obstacle.reparent(_world)
	else:
		if obstacle.get_parent() == _world:
			obstacle.reparent(_nav_region)
		obstacle.visible = true
		obstacle.process_mode = _dev_saved_obstacle_process_mode
		var co2 := obstacle as CollisionObject3D
		if co2 != null and _dev_saved_obstacle_layer >= 0:
			co2.collision_layer = _dev_saved_obstacle_layer
			co2.collision_mask = _dev_saved_obstacle_mask
		_dev_collision_shapes_set_disabled(obstacle, false)
	call_deferred("_dev_rebake_nav_after_obstacle_toggle_deferred")


func _dev_rebake_nav_after_obstacle_toggle_deferred() -> void:
	if not is_instance_valid(_nav_region):
		return
	if _nav_region.is_baking():
		await _nav_region.bake_finished
	if not is_instance_valid(_nav_region):
		return
	_nav_region.bake_navigation_mesh(false)
	if is_instance_valid(_player):
		_player.sync_navigation_agent_after_map_rebuild(_nav_region)


func _dev_collision_shapes_set_disabled(root: Node, disabled: bool) -> void:
	if root is CollisionShape3D:
		(root as CollisionShape3D).disabled = disabled
	for c in root.get_children():
		_dev_collision_shapes_set_disabled(c, disabled)


func _on_interactables_catalog_ready(descriptors: Array) -> void:
	var groups: Variant = load("res://scripts/interactable_world_builder.gd").call(
		"build_from_catalog", descriptors, _interactables_root, _radius_preview
	)
	if _radius_preview.has_method("setup_glow_groups"):
		_radius_preview.call("setup_glow_groups", groups as Array)


func _on_interactables_catalog_failed(reason: String) -> void:
	# `InteractablesCatalogClient` already `push_error`s; this hook exists for wiring clarity.
	var readme := "Interaction + range preview (NEO-9 + NEO-25)"
	push_warning(
		(
			"Interactables catalog failed (%s) — start the game server first; see client README (%s)."
			% [reason, readme]
		)
	)
