extends GdUnitTestSuite

## NEO-24: GdUnit4 suite for `TargetSelectionClient`. Mirrors
## `position_authority_client_test.gd` — mock HTTP transport enqueues JSON responses and
## (optionally) tracks request count so the movement-triggered refresh cooldown is testable.

const TargetSelectionTestDouble := preload("res://test/target_selection_test_double.gd")

const ALPHA_ID := "prototype_target_alpha"
const BETA_ID := "prototype_target_beta"


# In-process transport: not an HTTPRequest subclass (Godot 4 will not call script `request()`
# on those). Counts requests so "no lock → no GET" and "cooldown collapses snaps" are testable.
class MockHttpTransport:
	extends Node
	signal request_completed(
		result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
	)
	var request_count: int = 0
	var last_url: String = ""
	var last_body: String = ""
	var last_method: HTTPClient.Method = HTTPClient.METHOD_GET
	var _queue: Array[Dictionary] = []

	func enqueue(result: int, code: int, body: String) -> void:
		_queue.append({"result": result, "code": code, "body": body})

	func request(
		url: String,
		_custom_headers: PackedStringArray = PackedStringArray(),
		method: HTTPClient.Method = HTTPClient.METHOD_GET,
		request_data: String = ""
	) -> Error:
		request_count += 1
		last_url = url
		last_method = method
		last_body = request_data
		if _queue.is_empty():
			return ERR_UNAVAILABLE
		var r: Dictionary = _queue.pop_front()
		var body_bytes: PackedByteArray = str(r["body"]).to_utf8_buffer()
		request_completed.emit(r["result"], r["code"], PackedStringArray(), body_bytes)
		return OK


func _make_client(http_transport: Node) -> Node:
	var c: Node = TargetSelectionTestDouble.new()
	c.set("injected_http", http_transport)
	auto_free(http_transport)
	auto_free(c)
	add_child(c)
	return c


func _target_state_json(locked_id_or_null: Variant, validity: String, sequence: int = 0) -> String:
	var locked := "null"
	if locked_id_or_null is String and not (locked_id_or_null as String).is_empty():
		locked = '"%s"' % (locked_id_or_null as String)
	return (
		'{"schemaVersion":1,"playerId":"dev-local-1","lockedTargetId":%s,' % locked
		+ '"validity":"%s","sequence":%d}' % [validity, sequence]
	)


func _select_response_json(
	applied: bool,
	locked_id_or_null: Variant,
	validity: String,
	sequence: int = 0,
	reason: String = ""
) -> String:
	var target_state := _target_state_json(locked_id_or_null, validity, sequence)
	if applied:
		return '{"schemaVersion":1,"selectionApplied":true,"targetState":%s}' % target_state
	var reason_segment := ""
	if not reason.is_empty():
		reason_segment = ',"reasonCode":"%s"' % reason
	return (
		'{"schemaVersion":1,"selectionApplied":false%s,"targetState":%s}'
		% [reason_segment, target_state]
	)


func test_sync_get_200_emits_state() -> void:
	# Arrange
	var transport := MockHttpTransport.new()
	transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _target_state_json(null, "none"))
	var c := _make_client(transport)
	monitor_signals(c)
	# Act
	c.request_sync_from_server()
	# Assert
	assert_signal(c).is_emitted("target_state_changed")
	var state: Dictionary = c.cached_state()
	assert_that(state.get("validity", "")).is_equal("none")
	assert_that(state.get("lockedTargetId", "non-null-sentinel")).is_null()


func test_tab_from_no_lock_selects_first_id() -> void:
	# Arrange
	var transport := MockHttpTransport.new()
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
	)
	var c := _make_client(transport)
	monitor_signals(c)
	# Act
	c.request_tab_next()
	# Assert
	assert_that(transport.last_url).contains("/target/select")
	assert_that(transport.last_method).is_equal(HTTPClient.METHOD_POST)
	assert_that(transport.last_body).contains('"targetId":"%s"' % ALPHA_ID)
	assert_signal(c).is_emitted("target_state_changed")
	assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID)


func test_tab_from_alpha_requests_beta() -> void:
	# Arrange
	var transport := MockHttpTransport.new()
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
	)
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, BETA_ID, "ok", 2)
	)
	var c := _make_client(transport)
	# Act
	c.request_tab_next()
	c.request_tab_next()
	# Assert
	assert_that(transport.last_body).contains('"targetId":"%s"' % BETA_ID)
	assert_that(c.cached_state().get("lockedTargetId")).is_equal(BETA_ID)


func test_tab_wraps_from_beta_to_alpha() -> void:
	# Arrange
	var transport := MockHttpTransport.new()
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
	)
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, BETA_ID, "ok", 2)
	)
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 3)
	)
	var c := _make_client(transport)
	# Act
	c.request_tab_next()
	c.request_tab_next()
	c.request_tab_next()
	# Assert
	assert_that(transport.last_body).contains('"targetId":"%s"' % ALPHA_ID)
	assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID)


func test_denial_reflects_authoritative_target_state() -> void:
	# Arrange
	var transport := MockHttpTransport.new()
	# Server denies: selectionApplied=false, lockedTargetId=null, reasonCode=out_of_range.
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS,
		200,
		_select_response_json(false, null, "none", 0, "out_of_range")
	)
	var c := _make_client(transport)
	# Act
	c.request_select_target_id(ALPHA_ID)
	# Assert
	var state: Dictionary = c.cached_state()
	assert_that(bool(state.get("selectionApplied", true))).is_false()
	assert_that(state.get("reasonCode", "")).is_equal("out_of_range")
	assert_that(state.get("lockedTargetId", "sentinel")).is_null()


func test_clear_issues_post_without_target_id() -> void:
	# Arrange
	var transport := MockHttpTransport.new()
	transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, null, "none", 4))
	var c := _make_client(transport)
	# Act
	c.request_clear_target()
	# Assert
	assert_that(transport.last_url).contains("/target/select")
	assert_that(transport.last_method).is_equal(HTTPClient.METHOD_POST)
	assert_that(transport.last_body).not_contains("targetId")


func test_authoritative_ack_while_locked_fires_refresh_get() -> void:
	# Arrange
	var transport := MockHttpTransport.new()
	# Step 1: lock alpha via POST.
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
	)
	# Step 2: movement-triggered GET shows out_of_range (soft lock).
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _target_state_json(ALPHA_ID, "out_of_range", 1)
	)
	var c := _make_client(transport)
	# Act
	c.request_select_target_id(ALPHA_ID)
	var count_before_ack: int = transport.request_count
	c.on_authoritative_ack(Vector3.ZERO)
	# Assert
	assert_that(transport.request_count).is_equal(count_before_ack + 1)
	assert_that(c.cached_state().get("validity")).is_equal("out_of_range")
	assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID)


func test_authoritative_ack_without_lock_fires_nothing() -> void:
	# Arrange
	var transport := MockHttpTransport.new()
	var c := _make_client(transport)
	# Act
	c.on_authoritative_ack(Vector3.ZERO)
	# Assert
	assert_that(transport.request_count).is_equal(0)


func test_two_acks_within_cooldown_collapse_to_one_get() -> void:
	# Arrange
	var transport := MockHttpTransport.new()
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
	)
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _target_state_json(ALPHA_ID, "out_of_range", 1)
	)
	var c := _make_client(transport)
	# Act
	c.request_select_target_id(ALPHA_ID)
	var count_before: int = transport.request_count
	c.on_authoritative_ack(Vector3.ZERO)
	c.on_authoritative_ack(Vector3.ZERO)
	# Assert
	assert_that(transport.request_count).is_equal(count_before + 1)


# Authority stub that captures the last `submit_stream_targets` batch so we can verify
# `TargetSelectionClient._post_select` kicks a freshness stream before POSTing.
class FreshnessAuthorityStub:
	extends Node
	var last_batch: Array = []
	var submit_count: int = 0

	func submit_stream_targets(targets: Array) -> void:
		submit_count += 1
		last_batch = targets.duplicate()


func _make_player(pos: Vector3) -> Node3D:
	var n := Node3D.new()
	n.global_position = pos
	auto_free(n)
	add_child(n)
	return n


func test_select_post_kicks_freshness_stream_before_posting() -> void:
	# Arrange
	var transport := MockHttpTransport.new()
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
	)
	var c := _make_client(transport)
	var authority := FreshnessAuthorityStub.new()
	auto_free(authority)
	add_child(authority)
	var player := _make_player(Vector3(1.5, 0.5, -2.25))
	c.set_freshness_kick(authority, player)
	# Act
	c.request_select_target_id(ALPHA_ID)
	# Assert
	# Exactly one freshness submit, matching the pre-POST capsule position.
	assert_that(authority.submit_count).is_equal(1)
	assert_that(authority.last_batch.size()).is_equal(1)
	assert_that(authority.last_batch[0]).is_equal(Vector3(1.5, 0.5, -2.25))
	# And the select POST still went out as usual.
	assert_that(transport.last_url).contains("/target/select")


func test_select_post_includes_position_hint_when_player_wired() -> void:
	# Arrange
	# NEO-24 follow-up #5: the select POST body must carry the live capsule position as
	# `positionHint` so the server's range check can bypass any stale stored snap.
	var transport := MockHttpTransport.new()
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, BETA_ID, "ok", 1)
	)
	var c := _make_client(transport)
	var authority := FreshnessAuthorityStub.new()
	auto_free(authority)
	add_child(authority)
	# Values chosen so `JSON.stringify` emits exact decimal representations (no float precision
	# drift): 3.0 → "3", 0.5 → "0.5", 5.0 → "5". Re-parse the JSON for precise assertions.
	var player := _make_player(Vector3(3.0, 0.5, 5.0))
	c.set_freshness_kick(authority, player)
	# Act
	c.request_select_target_id(BETA_ID)
	# Assert
	var parsed: Variant = JSON.parse_string(transport.last_body)
	assert_that(parsed is Dictionary).is_true()
	var body: Dictionary = parsed
	assert_that(body.get("targetId")).is_equal(BETA_ID)
	assert_that(body.has("positionHint")).is_true()
	var hint: Dictionary = body["positionHint"]
	assert_float(hint.get("x")).is_equal_approx(3.0, 0.0001)
	assert_float(hint.get("y")).is_equal_approx(0.5, 0.0001)
	assert_float(hint.get("z")).is_equal_approx(5.0, 0.0001)


func test_select_post_without_freshness_wiring_is_a_noop() -> void:
	# Arrange
	# Regression: tests that construct the client without `set_freshness_kick(...)` must
	# still POST normally, *without* a `positionHint` (hint is opt-in per wiring).
	var transport := MockHttpTransport.new()
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
	)
	var c := _make_client(transport)
	# Act
	c.request_select_target_id(ALPHA_ID)
	# Assert
	assert_that(transport.last_url).contains("/target/select")
	# No `positionHint` key in the body when no player is wired — re-parse so the assertion
	# is robust against JSON whitespace / key ordering.
	var parsed: Variant = JSON.parse_string(transport.last_body)
	assert_that(parsed is Dictionary).is_true()
	assert_that((parsed as Dictionary).has("positionHint")).is_false()
	assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID)


func test_tab_from_no_lock_skips_out_of_range_and_picks_beta() -> void:
	# Arrange
	# Player stands next to beta (well outside alpha's ring). Without the range-aware
	# tab cycle the server denies with `out_of_range` on alpha — see NEO-24 follow-up #3.
	var transport := MockHttpTransport.new()
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, BETA_ID, "ok", 1)
	)
	var c := _make_client(transport)
	var authority := FreshnessAuthorityStub.new()
	auto_free(authority)
	add_child(authority)
	# Near beta anchor (3, 3), ~1.4 m horizontal — inside 6 m. Far from alpha at (-3, -3).
	var player := _make_player(Vector3(4.0, 0.5, 4.0))
	c.set_freshness_kick(authority, player)
	# Act
	c.request_tab_next()
	# Assert
	assert_that(transport.last_body).contains('"targetId":"%s"' % BETA_ID)
	assert_that(c.cached_state().get("lockedTargetId")).is_equal(BETA_ID)


func test_tab_from_no_lock_with_nothing_in_range_falls_back_to_first_id() -> void:
	# Arrange
	# No anchors are in range → cycle-order fallback. Server owns the denial reason.
	var transport := MockHttpTransport.new()
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS,
		200,
		_select_response_json(false, null, "none", 0, "out_of_range")
	)
	var c := _make_client(transport)
	var authority := FreshnessAuthorityStub.new()
	auto_free(authority)
	add_child(authority)
	var player := _make_player(Vector3(20.0, 0.5, 20.0))
	c.set_freshness_kick(authority, player)
	# Act
	c.request_tab_next()
	# Assert
	assert_that(transport.last_body).contains('"targetId":"%s"' % ALPHA_ID)
	assert_that(c.cached_state().get("reasonCode", "")).is_equal("out_of_range")


func test_tab_from_locked_alpha_at_origin_swaps_to_beta_when_both_in_range() -> void:
	# Arrange
	# With alpha (-3,-3) and beta (3,3) sharing r=6, origin is in both rings. Locked to
	# alpha, Tab should request beta (normal cycle behavior; range-aware skip-current
	# does not interfere when the next id is also in range).
	var transport := MockHttpTransport.new()
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
	)
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, BETA_ID, "ok", 2)
	)
	var c := _make_client(transport)
	var authority := FreshnessAuthorityStub.new()
	auto_free(authority)
	add_child(authority)
	var player := _make_player(Vector3(0.0, 0.5, 0.0))
	c.set_freshness_kick(authority, player)
	# Act
	c.request_tab_next()
	c.request_tab_next()
	# Assert
	assert_that(transport.last_body).contains('"targetId":"%s"' % BETA_ID)
	assert_that(c.cached_state().get("lockedTargetId")).is_equal(BETA_ID)


func test_tab_when_only_current_lock_is_in_range_falls_back_to_cycle_for_denial() -> void:
	# Arrange
	# Locked to alpha, only alpha is in range — Tab should express a *swap* intent and
	# fall through to the plain cycle so the server denies with `out_of_range` (NEO-23
	# soft-lock rule). If the picker returned alpha instead, the user would see no
	# visible reaction to Tab.
	var transport := MockHttpTransport.new()
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
	)
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS,
		200,
		_select_response_json(false, ALPHA_ID, "ok", 1, "out_of_range")
	)
	var c := _make_client(transport)
	var authority := FreshnessAuthorityStub.new()
	auto_free(authority)
	add_child(authority)
	# Near alpha (-3, -3), ~1.4 m in — beta (3, 3) is ~8.5 m out.
	var player := _make_player(Vector3(-2.0, 0.5, -2.0))
	c.set_freshness_kick(authority, player)
	# Act
	c.request_select_target_id(ALPHA_ID)
	c.request_tab_next()
	# Assert
	assert_that(transport.last_body).contains('"targetId":"%s"' % BETA_ID)
	assert_that(c.cached_state().get("reasonCode", "")).is_equal("out_of_range")
	assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID)


func test_tab_without_player_ref_still_uses_plain_cycle_order() -> void:
	# Arrange
	# Regression: headless unit tests and any client that skips `set_freshness_kick(...)`
	# must keep the pre-existing cycle-order semantics. Alpha is requested from no-lock.
	var transport := MockHttpTransport.new()
	transport.enqueue(
		HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1)
	)
	var c := _make_client(transport)
	# Act
	c.request_tab_next()
	# Assert
	assert_that(transport.last_body).contains('"targetId":"%s"' % ALPHA_ID)
