extends Object

## NEO-24: prototype combat target ids in **tab cycle order** — must match
## `server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs`
## (`PrototypeTargetAlphaId`, `PrototypeTargetBetaId`; ascending id order).
## Anchors + radii are a **display-only** mirror of the C# registry so the scene markers
## (`prototype_target_markers.gd`) and HUD distance readouts match server-authoritative
## range decisions. If the server registry moves, update this file in the same commit —
## the two must stay in lock-step to avoid confusing "ok at 10 m / denied at 5 m" bugs.
## Static accessors only (no `class_name`; headless / CI loads before editor import — see client
## README Godot CLI section).

const ORDERED_IDS: Array[String] = ["prototype_target_alpha", "prototype_target_beta"]

## All prototype targets share a single radius (`PrototypeTargetRegistry.SharedLockRadius`
## on the server). Per-target radii will come back with real combat design (E5.M1).
const SHARED_LOCK_RADIUS: float = 6.0

## Keys match [constant ORDERED_IDS]; values mirror `PrototypeTargetRegistry.cs` anchors
## (world space) and horizontal lock radii (XZ meters). Anchors are placed so the two rings
## overlap at origin (~3.5 m wide), letting Tab flip between targets without locomotion.
## Keep in lock-step with the C# registry.
const ANCHORS: Dictionary = {
	"prototype_target_alpha":
	{
		"position": Vector3(-3.0, 0.5, -3.0),
		"radius": SHARED_LOCK_RADIUS,
		"color": Color(1.0, 0.55, 0.2, 1.0),
	},
	"prototype_target_beta":
	{
		"position": Vector3(3.0, 0.0, 3.0),
		"radius": SHARED_LOCK_RADIUS,
		"color": Color(0.7, 0.45, 1.0, 1.0),
	},
}


## Returns the world anchor position for [param target_id], or [code]Vector3.ZERO[/code]
## if the id is not in [constant ANCHORS].
static func anchor_position(target_id: String) -> Vector3:
	var data: Variant = ANCHORS.get(target_id, null)
	if not data is Dictionary:
		return Vector3.ZERO
	var pos: Variant = (data as Dictionary).get("position", Vector3.ZERO)
	return pos if pos is Vector3 else Vector3.ZERO


## Horizontal (XZ) lock radius for [param target_id], or [code]0.0[/code] if unknown.
static func anchor_radius(target_id: String) -> float:
	var data: Variant = ANCHORS.get(target_id, null)
	if not data is Dictionary:
		return 0.0
	return float((data as Dictionary).get("radius", 0.0))


## Marker tint for [param target_id]; falls back to white if unknown.
static func anchor_color(target_id: String) -> Color:
	var data: Variant = ANCHORS.get(target_id, null)
	if not data is Dictionary:
		return Color.WHITE
	var c: Variant = (data as Dictionary).get("color", Color.WHITE)
	return c if c is Color else Color.WHITE


## Horizontal (XZ) distance from [param world] to the anchor of [param target_id], or
## [code]INF[/code] if the id is unknown. Display-only helper — **not** authoritative for
## range decisions (server owns that; see `PlayerTargetStateReader.ComputeValidity`).
static func horizontal_distance_to(target_id: String, world: Vector3) -> float:
	if not ANCHORS.has(target_id):
		return INF
	var anchor: Vector3 = anchor_position(target_id)
	var dx: float = world.x - anchor.x
	var dz: float = world.z - anchor.z
	return sqrt(dx * dx + dz * dz)


static func ordered_ids() -> Array[String]:
	return ORDERED_IDS.duplicate()


## Returns the next id in [constant ORDERED_IDS] that is **in range** of [param world]
## (client-side horizontal distance ≤ anchor radius) **and not equal to [param current]**,
## starting after [param current] and wrapping last → first. Returns [code]""[/code] when
## no such anchor is in range so callers can fall back to [method next_id_after] and let
## the server pick the denial reason (this preserves the "Tab on an out-of-reach swap =
## visible denial" UX from NEO-23's soft-lock rule).
##
## Client math here is a UX convenience (same purpose as the HUD distance lines): the
## server remains authoritative for the actual lock decision in
## `TargetingApi.MapPost(".../target/select")`. If the client says "out" but the server
## says "in" at ~1 radius (float noise / position drift), the request still flows through
## the fallback on the next tick — no state gets stuck.
static func next_in_range_id_after(current: Variant, world: Vector3) -> String:
	if ORDERED_IDS.is_empty():
		return ""
	var current_str: String = ""
	var start_idx: int = 0
	if current is String:
		current_str = (current as String).strip_edges()
		if not current_str.is_empty():
			var found: int = ORDERED_IDS.find(current_str)
			if found >= 0:
				start_idx = (found + 1) % ORDERED_IDS.size()
	for i in range(ORDERED_IDS.size()):
		var probe_idx: int = (start_idx + i) % ORDERED_IDS.size()
		var probe_id: String = ORDERED_IDS[probe_idx]
		if probe_id == current_str:
			continue
		var radius: float = anchor_radius(probe_id)
		if radius <= 0.0:
			continue
		if horizontal_distance_to(probe_id, world) <= radius:
			return probe_id
	return ""


## Returns the next id in [constant ORDERED_IDS] after [param current]. Returns the **first** id
## when [param current] is [code]null[/code], empty, or not found (start of cycle). Wraps from
## last → first.
static func next_id_after(current: Variant) -> String:
	if ORDERED_IDS.is_empty():
		return ""
	if not current is String:
		return ORDERED_IDS[0]
	var current_str: String = (current as String).strip_edges()
	if current_str.is_empty():
		return ORDERED_IDS[0]
	var idx: int = ORDERED_IDS.find(current_str)
	if idx < 0:
		return ORDERED_IDS[0]
	var next_idx: int = (idx + 1) % ORDERED_IDS.size()
	return ORDERED_IDS[next_idx]
