extends Node

## NEO-24 (E1M3-02): client tab-target + lock UI synced to server (NEO-23 `TargetState` v1).
## NEO-26 (E1M3-04): emits [signal selection_event] when server-acknowledged
## [code]lockedTargetId[/code] changes (see [member log_selection_events]).
##
## Flow:
## - `request_sync_from_server()` → `GET …/target` returns `PlayerTargetStateResponse` v1.
## - `request_tab_next()` picks the next id from `PrototypeTargetConstants.ORDERED_IDS`
##   (first id if no lock) and POSTs `TargetSelectRequest` v1.
## - `request_select_target_id(id)` POSTs a specific id.
## - `request_clear_target()` POSTs with `targetId` omitted (NEO-23 clear rule).
## - Movement-triggered refresh: while a lock is held, `on_authoritative_ack(...)` fires a
##   throttled `GET` so `validity` flips to `out_of_range` after locomotion without duplicating
##   server radius math on the client (plan Decision 2). The ack signal fires on every
##   server-confirmed position — both boot and successful `move-stream` — so a held lock is
##   revalidated during normal WASD locomotion without re-introducing snap rubber-banding.
##
## Not an `HTTPRequest` subclass — tests inject a `Node` exposing `request()` +
## `request_completed` (same pattern as `position_authority_client.gd`).

signal target_state_changed(state: Dictionary)

## NEO-26: fired only when normalized [code]lockedTargetId[/code] changes after a GET/POST.
## Payload: [code]previous[/code] ([String] or [code]null[/code]), [code]next[/code] (same),
## [code]cause[/code] ([code]"tab"[/code] | [code]"clear"[/code] |
## [code]"server_correction"[/code]), [code]sequence[/code] ([int], from new state).
## E1.M4+ may [code]connect[/code] here without reshaping.
## NEO-27 telemetry hook: this transition maps to product event name [code]target_changed[/code]
## (TODO(E9.M1): wire schema/catalog and ingest once telemetry contracts land).
signal selection_event(event: Dictionary)

enum Phase { IDLE, GET, POST_SELECT }

const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd")

## Cooldown between movement-triggered refresh GETs. Drops further snaps inside the window so
## one `move-stream` burst does not produce one GET per snap.
const _REFRESH_COOLDOWN_MSEC: int = 250

@export var base_url: String = "http://127.0.0.1:5253"
@export var dev_player_id: String = "dev-local-1"

## When true, each [signal selection_event] is also printed to the Godot output
## (dev aid only).
@export var log_selection_events: bool = false

var _http: Node
var _busy: bool = false
var _phase: Phase = Phase.IDLE

## Optional "freshness kick": before each `POST /target/select` we ask the authority to
## submit a `move-stream` with the current player position so the server's stored snapshot
## catches up with the visible capsule. Since NEO-24 follow-up #5 the `target/select` decision
## itself is made against the `positionHint` in the request body (race-free), so the kick is
## no longer load-bearing for denial correctness — its role is to keep the stored snap fresh
## for the movement-triggered validity GET that runs on the next `authoritative_ack`. Both
## refs are optional so tests that inject a plain script can still run without wiring.
var _freshness_authority: Node = null
var _freshness_player: Node3D = null

## Last acknowledged state from the server. Mirrors `PlayerTargetStateResponse` v1:
## `schemaVersion` (int), `playerId` (String), `lockedTargetId` (String or `null`),
## `validity` (String: `none` | `ok` | `out_of_range` | `invalid_target`),
## `sequence` (int). POST responses additionally carry `selectionApplied` (bool) and an optional
## `reasonCode` (String).
var _state: Dictionary = {}

## `-_REFRESH_COOLDOWN_MSEC` so the first snap is never blocked by the window on boot.
var _last_refresh_msec: int = -_REFRESH_COOLDOWN_MSEC

## Cause for the in-flight HTTP request (GET → [code]server_correction[/code];
## POST → [code]tab[/code] / [code]clear[/code]).
## Set immediately before a request starts; cleared when the response is applied or dropped.
var _http_selection_cause: String = ""


func _create_http_request() -> Node:
	return HTTPRequest.new()


func _ready() -> void:
	_http = _create_http_request()
	add_child(_http)
	if _http is HTTPRequest:
		(_http as HTTPRequest).timeout = 30.0
	@warning_ignore("unsafe_method_access")
	_http.request_completed.connect(_on_request_completed)


## Matches `interaction_request_client.gd`: `_input` is more reliable than `_unhandled_input`
## inside the embedded Game dock / focus quirks. Tab is captured before UI focus navigation,
## which is fine while the prototype HUD has no focusable controls.
func _input(event: InputEvent) -> void:
	if event.is_action_pressed("target_tab"):
		request_tab_next()
	elif event.is_action_pressed("target_clear"):
		request_clear_target()


func request_sync_from_server() -> void:
	if _busy:
		return
	_http_selection_cause = "server_correction"
	_busy = true
	_phase = Phase.GET
	_last_refresh_msec = Time.get_ticks_msec()
	var url := "%s/game/players/%s/target" % [_base_root(), _player_path_segment()]
	var err: Error = _http.request(url)
	if err != OK:
		push_warning("TargetSelectionClient: GET failed to start (%s)" % err)
		_busy = false
		_phase = Phase.IDLE
		_http_selection_cause = ""


func request_tab_next() -> void:
	var current: Variant = _state.get("lockedTargetId", null)
	var next_id: String = ""
	# Prefer a client-side in-range pick when the player ref is wired so Tab locks the
	# anchor the user is visibly standing next to instead of cycling to whatever happens
	# to come first in `ORDERED_IDS`. Fall back to the plain cycle order (and let the
	# server pick the denial reason) when nothing is in range or no player is wired.
	if _freshness_player != null and is_instance_valid(_freshness_player):
		next_id = PrototypeTargetConstants.next_in_range_id_after(
			current, _freshness_player.global_position
		)
	if next_id.is_empty():
		next_id = PrototypeTargetConstants.next_id_after(current)
	if next_id.is_empty():
		return
	request_select_target_id(next_id)


func request_select_target_id(target_id: String) -> void:
	var trimmed := target_id.strip_edges()
	if trimmed.is_empty():
		push_warning("TargetSelectionClient: refusing empty targetId; use request_clear_target()")
		return
	var payload: Dictionary = {"schemaVersion": 1, "targetId": trimmed}
	_maybe_attach_position_hint(payload)
	_post_select(payload, "tab")


func request_clear_target() -> void:
	# NEO-23: omit `targetId` to clear. JSON `null` also works; omission keeps the body minimal.
	_post_select({"schemaVersion": 1}, "clear")


## NEO-24 follow-up #5: attach the live capsule position as an advisory `positionHint` so the
## server's range check runs against what the player sees *right now* instead of the last
## `move-stream` sample the server happens to have stored. Only populated when the player ref
## is wired (via [method set_freshness_kick]) — tests and other consumers that skip the wiring
## fall through to today's stored-snap behavior. Server remains authoritative (hint is advisory
## and does not write to the position store).
func _maybe_attach_position_hint(payload: Dictionary) -> void:
	if _freshness_player == null or not is_instance_valid(_freshness_player):
		return
	var p: Vector3 = _freshness_player.global_position
	payload["positionHint"] = {"x": p.x, "y": p.y, "z": p.z}


## Connected to `PositionAuthorityClient.authoritative_ack` in `main.gd`.
## Boot is naturally skipped because no lock is held yet; once a lock exists, subsequent acks
## (boot resync or `move-stream` 200) fire a refresh GET subject to a cooldown so bursts
## collapse to one GET per window.
func on_authoritative_ack(_world: Vector3) -> void:
	if not _has_lock():
		return
	if _busy:
		return
	var now_msec: int = Time.get_ticks_msec()
	if now_msec - _last_refresh_msec < _REFRESH_COOLDOWN_MSEC:
		return
	request_sync_from_server()


func cached_state() -> Dictionary:
	return _state.duplicate()


## Wire the authority + player so each `POST /target/select` is preceded by a fresh
## `move-stream` submit (see `_freshness_authority`). Pass `null` for either to disable.
## Safe to call multiple times; the last wiring wins.
func set_freshness_kick(authority: Node, player: Node3D) -> void:
	_freshness_authority = authority
	_freshness_player = player


func _has_lock() -> bool:
	if _state.is_empty():
		return false
	var v: Variant = _state.get("lockedTargetId", null)
	return v is String and not (v as String).is_empty()


func _post_select(payload: Dictionary, selection_cause: String) -> void:
	if _busy:
		return
	_http_selection_cause = selection_cause
	_kick_freshness_stream()
	_busy = true
	_phase = Phase.POST_SELECT
	var url := "%s/game/players/%s/target/select" % [_base_root(), _player_path_segment()]
	var body := JSON.stringify(payload)
	var headers := PackedStringArray(["Content-Type: application/json"])
	var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, body)
	if err != OK:
		push_warning("TargetSelectionClient: POST failed to start (%s)" % err)
		_busy = false
		_phase = Phase.IDLE
		_http_selection_cause = ""


## Nudges the authority with the current capsule position so the server's stored snapshot
## catches up. Paired with the `positionHint` on the select POST (follow-up #5), this keeps
## subsequent validity GETs honest; the select itself no longer depends on which request
## arrives at the server first.
func _kick_freshness_stream() -> void:
	if _freshness_authority == null or _freshness_player == null:
		return
	if not is_instance_valid(_freshness_authority) or not is_instance_valid(_freshness_player):
		return
	if not _freshness_authority.has_method("submit_stream_targets"):
		return
	_freshness_authority.call("submit_stream_targets", [_freshness_player.global_position])


func _base_root() -> String:
	return base_url.strip_edges().rstrip("/")


func _player_path_segment() -> String:
	# ASCII-safe ids (e.g. `dev-local-1`); percent-encode if ids gain reserved URL characters.
	return dev_player_id.strip_edges()


func _on_request_completed(
	_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
	var phase: Phase = _phase
	_busy = false
	_phase = Phase.IDLE

	if _result != HTTPRequest.RESULT_SUCCESS:
		push_warning("TargetSelectionClient: HTTP failed (result=%s)" % _result)
		_http_selection_cause = ""
		return

	var text := body.get_string_from_utf8()
	var parsed: Variant = JSON.parse_string(text)
	if not parsed is Dictionary:
		if response_code != 200:
			push_warning("TargetSelectionClient: HTTP %s non-JSON body" % response_code)
		_http_selection_cause = ""
		return
	var data: Dictionary = parsed

	match phase:
		Phase.GET:
			if response_code == 200:
				_update_state_from_get(data)
			else:
				_http_selection_cause = ""
		Phase.POST_SELECT:
			# Server includes authoritative `targetState` on 200 (apply and denial). 400 / 404
			# bodies may lack it — swallow quietly; `_state` keeps last-good.
			if response_code == 200:
				_update_state_from_post(data)
			else:
				_http_selection_cause = ""
		_:
			_http_selection_cause = ""


func _update_state_from_get(data: Dictionary) -> void:
	var state := _extract_target_state_fields(data)
	var cause := _http_selection_cause
	_http_selection_cause = ""
	if state.is_empty():
		return
	var prev: Dictionary = _state.duplicate()
	_state = state
	target_state_changed.emit(_state.duplicate())
	_maybe_emit_selection_event(prev, state, cause)


func _update_state_from_post(data: Dictionary) -> void:
	var target_state_variant: Variant = data.get("targetState", null)
	if not target_state_variant is Dictionary:
		_http_selection_cause = ""
		push_warning("TargetSelectionClient: POST 200 missing targetState")
		return
	var state := _extract_target_state_fields(target_state_variant as Dictionary)
	if state.is_empty():
		_http_selection_cause = ""
		return
	var cause := _http_selection_cause
	_http_selection_cause = ""
	state["selectionApplied"] = bool(data.get("selectionApplied", false))
	var reason_variant: Variant = data.get("reasonCode", null)
	if reason_variant is String and not (reason_variant as String).is_empty():
		state["reasonCode"] = reason_variant as String
	var prev: Dictionary = _state.duplicate()
	_state = state
	target_state_changed.emit(_state.duplicate())
	_maybe_emit_selection_event(prev, state, cause)


## Shape-only parse of the v1 target state payload (works for GET body and POST echo).
## `lockedTargetId` is normalized to either a non-empty [String] or Variant [code]null[/code].
func _extract_target_state_fields(data: Dictionary) -> Dictionary:
	if not data.has("validity"):
		return {}
	var out: Dictionary = {}
	out["schemaVersion"] = int(data.get("schemaVersion", 1))
	var pid_variant: Variant = data.get("playerId", "")
	out["playerId"] = (pid_variant as String) if pid_variant is String else ""
	var locked_variant: Variant = data.get("lockedTargetId", null)
	if locked_variant is String and not (locked_variant as String).is_empty():
		out["lockedTargetId"] = locked_variant as String
	else:
		out["lockedTargetId"] = null
	var validity_variant: Variant = data.get("validity", "none")
	out["validity"] = (validity_variant as String) if validity_variant is String else "none"
	out["sequence"] = int(data.get("sequence", 0))
	return out


func _normalized_lock_id(state: Dictionary) -> Variant:
	if state.is_empty():
		return null
	var v: Variant = state.get("lockedTargetId", null)
	if v is String and not (v as String).is_empty():
		return v as String
	return null


func _maybe_emit_selection_event(
	prev_state: Dictionary, new_state: Dictionary, cause: String
) -> void:
	var prev_id: Variant = _normalized_lock_id(prev_state)
	var next_id: Variant = _normalized_lock_id(new_state)
	if prev_id == next_id:
		return
	if cause.is_empty():
		return
	var ev: Dictionary = {
		"previous": prev_id,
		"next": next_id,
		"cause": cause,
		"sequence": int(new_state.get("sequence", 0)),
	}
	selection_event.emit(ev)
	if log_selection_events:
		# NEO-27 telemetry hook site: `target_changed` mirrors this payload shape.
		# TODO(E9.M1): replace dev print with cataloged telemetry emit.
		print("[TargetSelectionClient] target_changed ", ev)
