extends Node

## `GET` position for boot snap; `submit_stream_targets` POSTs `move-stream` (WASD locomotion).
## NS-19: failed validation (HTTP 400 + rejection body) emits `move_rejected`.
## NS-23: second signal arg on `authoritative_position_received` — always `true` (boot snap only).
## A successful `move-stream` 200 is ack-only (no snap signal; avoids RTT rubber-band).
## NEO-24: `authoritative_ack` fires on **every** server-confirmed position (BOOT_GET 200 **and**
## successful move-stream 200), giving target / cooldown-throttled systems a heartbeat during
## normal locomotion without re-introducing snap rubber-banding.
## (No `class_name` so headless/CI can load the project before `.godot` import exists.)

signal authoritative_position_received(world: Vector3, apply_as_snap: bool)
signal authoritative_ack(world: Vector3)
signal move_rejected(reason_code: String)

enum Phase { BOOT_GET, STREAM_POST }

const MOVE_STREAM_MAX_PER_REQUEST: int = 12

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

## `HTTPRequest` at runtime; tests may inject a `Node` with `request()` + `request_completed`.
var _http: Node
var _busy: bool = false
var _phase: Phase = Phase.BOOT_GET

## Queued world samples for NEO-22 move-stream (FIFO).
var _stream_queue: Array[Vector3] = []
var _stream_in_flight_count: int = 0


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)


func sync_from_server() -> void:
	if _busy:
		return
	_busy = true
	_phase = Phase.BOOT_GET
	_request_get()


## NEO-22: append samples; each POST sends **only the latest** queued position so slow HTTP cannot
## apply a stale multi-step chain after the capsule has already moved (rubber-band / bounce).
func submit_stream_targets(targets: Array) -> void:
	if _busy:
		# In-flight POST already encodes an older chain; drop queued crumbs so we do not replay
		# past intent after this response lands.
		_stream_queue.clear()
	for t: Variant in targets:
		if t is Vector3:
			_stream_queue.append(t as Vector3)
	while _stream_queue.size() > 48:
		_stream_queue.pop_front()
	_try_flush_pending()


func _try_flush_pending() -> void:
	if _busy:
		return
	if not _stream_queue.is_empty():
		var latest: Vector3 = _stream_queue[_stream_queue.size() - 1]
		_stream_queue.clear()
		_start_stream_post([latest])


func _start_stream_post(batch: Array) -> void:
	if batch.is_empty():
		return
	_busy = true
	_stream_in_flight_count = batch.size()
	_phase = Phase.STREAM_POST
	var targets_json: Array = []
	for t: Variant in batch:
		if t is Vector3:
			var v: Vector3 = t as Vector3
			targets_json.append({"x": v.x, "y": v.y, "z": v.z})
	var payload := {"schemaVersion": 1, "targets": targets_json}
	var body := JSON.stringify(payload)
	var headers := PackedStringArray(["Content-Type: application/json"])
	var url := "%s/game/players/%s/move-stream" % [_base_root(), _player_path_segment()]
	var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, body)
	if err != OK:
		push_warning("PositionAuthorityClient: move-stream POST failed to start (%s)" % err)
		_busy = false
		_stream_in_flight_count = 0
		_try_flush_pending()


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


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


func _request_get() -> void:
	var url := "%s/game/players/%s/position" % [_base_root(), _player_path_segment()]
	var err: Error = _http.request(url)
	if err != OK:
		push_warning("PositionAuthorityClient: GET failed to start (%s)" % err)
		_busy = false
		_try_flush_pending()


func _on_request_completed(
	_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
	if _result != HTTPRequest.RESULT_SUCCESS:
		push_warning("PositionAuthorityClient: HTTP failed (result=%s); releasing busy." % _result)
		_busy = false
		_stream_in_flight_count = 0
		_try_flush_pending()
		return

	var text := body.get_string_from_utf8()

	match _phase:
		Phase.BOOT_GET:
			_busy = false
			if response_code == 200:
				_emit_position_from_response(text, true)
			_try_flush_pending()
		Phase.STREAM_POST:
			if response_code == 400:
				_emit_move_rejection_if_present(text)
				_stream_queue.clear()
				_stream_in_flight_count = 0
				_busy = false
				sync_from_server()
				return
			if response_code != 200:
				push_warning(
					"PositionAuthorityClient: move-stream unexpected code %s" % response_code
				)
				_stream_in_flight_count = 0
				_busy = false
				_try_flush_pending()
				return
			_stream_in_flight_count = 0
			_busy = false
			# Do not emit `authoritative_position_received` here. The body echoes server position
			# after applying the batch, which typically lags the client's integrated capsule by
			# roughly one RTT — snapping caused visible rubber-banding during WASD. We do emit
			# `authoritative_ack` (NEO-24) so cooldown-throttled consumers like
			# `TargetSelectionClient` get a heartbeat during normal locomotion.
			_emit_ack_if_position_present(text)
			_try_flush_pending()


func _emit_move_rejection_if_present(json_text: String) -> void:
	var parsed: Variant = JSON.parse_string(json_text)
	if parsed != null and parsed is Dictionary:
		var data: Dictionary = parsed
		var code_variant: Variant = data.get("reasonCode", "")
		if code_variant is String:
			var code: String = code_variant as String
			if code.is_empty():
				move_rejected.emit("unknown")
			else:
				move_rejected.emit(code)
			return
	move_rejected.emit("unknown")


func _emit_position_from_response(json_text: String, apply_as_snap: bool) -> void:
	var world_variant: Variant = _parse_world_from_response(json_text, true)
	if world_variant == null:
		return
	var world: Vector3 = world_variant
	authoritative_position_received.emit(world, apply_as_snap)
	# NEO-24: boot / rejection-resync is also an authoritative ack for cooldown-throttled
	# consumers. Emit both so subscribers do not have to dedupe across two code paths.
	authoritative_ack.emit(world)


## NEO-24: `move-stream` 200 path — echo position is authoritative but intentionally NOT snapped
## (see rubber-band comment in `_on_request_completed`). Still emit `authoritative_ack` so
## `TargetSelectionClient` can run its throttled refresh during ordinary locomotion.
func _emit_ack_if_position_present(json_text: String) -> void:
	var world_variant: Variant = _parse_world_from_response(json_text, false)
	if world_variant == null:
		return
	authoritative_ack.emit(world_variant as Vector3)


## Shared `{"position":{"x","y","z"}}` parser. `warn_on_missing` lets BOOT_GET complain about
## malformed boot responses while STREAM_POST stays quiet (some 200 bodies may legitimately
## omit `position` in future response shapes; the ack then simply does not fire).
func _parse_world_from_response(json_text: String, warn_on_missing: bool) -> Variant:
	var parsed: Variant = JSON.parse_string(json_text)
	if parsed == null:
		if warn_on_missing:
			push_warning("PositionAuthorityClient: position response JSON parse failed")
		return null
	if not parsed is Dictionary:
		if warn_on_missing:
			push_warning("PositionAuthorityClient: position response is not a JSON object")
		return null
	var data: Dictionary = parsed
	var pos_variant: Variant = data.get("position", null)
	if not pos_variant is Dictionary:
		if warn_on_missing:
			push_warning("PositionAuthorityClient: position response missing `position` object")
		return null
	var pos: Dictionary = pos_variant
	var x: float = float(pos.get("x", 0.0))
	var y: float = float(pos.get("y", 0.0))
	var z: float = float(pos.get("z", 0.0))
	return Vector3(x, y, z)
