extends Node3D

## NEO-15: client-local isometric follow; updates [CameraState] each physics tick.
## NEO-16: discrete zoom bands via [member zoom_band_config]; wheel / `camera_zoom_*` actions.
## NEO-18: occluder overrides are keyed by [method Object.get_instance_id] only — never [Object]
## keys, or [method Dictionary.keys] / [method Dictionary.erase] can freeze after an occluder is
## [method Node.queue_free]'d (dev smoke: action [code]dev_toggle_occluder_obstacle[/code] in
## [code]main.gd[/code]).
## Pitch / roll are fixed by presentation exports; **orbit yaw** in state stays **0** while
## [member allow_yaw] is false (no rotate input bound). Future orbit: read relative input here,
## add to `_orbit_yaw_rad`, clamp with [member max_yaw_deg].

## TODO(E9.M1): map throttled product telemetry (for example `camera_zoom_changed`) to
## [signal zoom_band_changed] when schema exists; optional occlusion/perf counters per module doc.
signal zoom_band_changed(new_index: int, distance: float)

const CameraStateScript := preload("res://scripts/camera_state.gd")
const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd")
const OcclusionPolicyScript := preload("res://scripts/occlusion_policy.gd")

## Tuned to match pre-NEO-15 static `Camera3D` at (12,10,12) vs player at (-5,0.9,-5).
## When [member zoom_band_config] is null or has no bands, this is the sole follow distance.
@export var follow_target_path: NodePath = NodePath("../Player")
@export var follow_distance: float = 25.709
@export var pitch_elevation_deg: float = 20.693
@export var presentation_yaw_deg: float = 45.0
@export var focus_vertical_offset: float = 0.0

## Designer-tunable discrete bands (`res://resources/isometric_zoom_bands.tres` on main rig).
@export var zoom_band_config: Resource

## RayCast-based occluder fade config (`res://resources/isometric_occlusion_policy.tres`).
@export var occlusion_policy: Resource

@export var allow_yaw: bool = false
@export var max_yaw_deg: float = 45.0

## Higher = snappier follow (`1 - exp(-smoothness * delta)`).
@export var follow_smoothness: float = 12.0
## When eye-to-desired distance exceeds this, snap (server snap / huge teleports).
@export var snap_distance: float = 24.0

## Latest [CameraState] tick; same object each frame (see NEO-15 plan).
var camera_state:
	get:
		return _state

var _state = null
var _smoothed_eye: Vector3 = Vector3.ZERO
var _orbit_yaw_rad: float = 0.0
var _zoom_band_index: int = 0
var _warned_missing_follow_target: bool = false
## Keys: [code]int[/code] [method Object.get_instance_id] of the occluder body. Values: Array of
## {mesh, surface, original} restore entries.
var _occluder_overrides: Dictionary = {}

@onready var camera: Camera3D = $Camera3D


func _ready() -> void:
	# After `Player` (default priority 0): read `global_position` post–`move_and_slide` this tick.
	process_physics_priority = 1
	_state = CameraStateScript.new()
	_init_zoom_band_index()
	if camera == null:
		push_error("IsometricFollowCamera: expected child Camera3D")
		return
	var t: Node3D = _resolve_target()
	if t != null:
		if not allow_yaw:
			_orbit_yaw_rad = 0.0
		else:
			_orbit_yaw_rad = clampf(
				_orbit_yaw_rad, -deg_to_rad(max_yaw_deg), deg_to_rad(max_yaw_deg)
			)
		var focus0: Vector3 = t.global_position + Vector3(0.0, focus_vertical_offset, 0.0)
		var yaw0: float = deg_to_rad(presentation_yaw_deg) + _orbit_yaw_rad
		var dist0: float = _current_follow_distance()
		_smoothed_eye = desired_eye_world(focus0, dist0, deg_to_rad(pitch_elevation_deg), yaw0)
		global_position = _smoothed_eye
		camera.look_at(focus0, Vector3.UP)
		_sync_camera_state(focus0, dist0)
	else:
		_smoothed_eye = global_position
		_warn_missing_follow_target_once()


func _physics_process(delta: float) -> void:
	if camera == null or _state == null:
		_restore_all_occluders()
		return
	var target: Node3D = _resolve_target()
	if target == null:
		# No eye/state update until the path resolves — avoids chasing a freed or miswired node.
		_restore_all_occluders()
		_warn_missing_follow_target_once()
		return
	_warned_missing_follow_target = false

	if not allow_yaw:
		_orbit_yaw_rad = 0.0
	else:
		_orbit_yaw_rad = clampf(_orbit_yaw_rad, -deg_to_rad(max_yaw_deg), deg_to_rad(max_yaw_deg))

	var focus: Vector3 = target.global_position + Vector3(0.0, focus_vertical_offset, 0.0)
	var yaw_total: float = deg_to_rad(presentation_yaw_deg) + _orbit_yaw_rad
	var effective_dist: float = _current_follow_distance()
	var desired: Vector3 = desired_eye_world(
		focus, effective_dist, deg_to_rad(pitch_elevation_deg), yaw_total
	)

	if _smoothed_eye.distance_to(desired) >= snap_distance:
		_smoothed_eye = desired
	else:
		var k: float = 1.0 - exp(-follow_smoothness * delta)
		_smoothed_eye = _smoothed_eye.lerp(desired, k)

	global_position = _smoothed_eye
	camera.look_at(focus, Vector3.UP)
	_sync_camera_state(focus, effective_dist)
	_update_occlusion(focus)


func _exit_tree() -> void:
	_restore_all_occluders()


func _unhandled_input(event: InputEvent) -> void:
	if event.is_echo():
		return
	if event.is_action_pressed("camera_zoom_in"):
		_apply_zoom_step(-1)
	elif event.is_action_pressed("camera_zoom_out"):
		_apply_zoom_step(1)


## **Near** = lower band index. Zoom in → closer → decrease index (clamped).
func _apply_zoom_step(delta_bands: int) -> void:
	if not _zoom_config_valid():
		return
	var cfg: Resource = zoom_band_config
	var new_idx: int = cfg.clamp_index(_zoom_band_index + delta_bands)
	if new_idx == _zoom_band_index:
		return
	_zoom_band_index = new_idx
	var d: float = _current_follow_distance()
	zoom_band_changed.emit(_zoom_band_index, d)


func _init_zoom_band_index() -> void:
	_zoom_band_index = 0
	if not _zoom_config_valid():
		return
	var cfg: Resource = zoom_band_config
	_zoom_band_index = cfg.clamp_index(cfg.default_band_index)


func _zoom_config_valid() -> bool:
	return (
		zoom_band_config != null
		and zoom_band_config.get_script() == ZoomBandConfigScript
		and zoom_band_config.band_count() > 0
		and zoom_band_config.all_band_distances_positive()
	)


func _current_follow_distance() -> float:
	return effective_follow_distance(zoom_band_config, _zoom_band_index, follow_distance)


static func effective_follow_distance(
	zoom_cfg: Resource, band_index: int, fallback_distance: float
) -> float:
	if zoom_cfg == null or zoom_cfg.get_script() != ZoomBandConfigScript:
		return fallback_distance
	if zoom_cfg.band_count() == 0:
		return fallback_distance
	if not zoom_cfg.all_band_distances_positive():
		return fallback_distance
	return zoom_cfg.distance_at(band_index)


func _sync_camera_state(focus: Vector3, effective_distance: float) -> void:
	# `CameraState.yaw` = orbit delta only; world-fixed diagonal framing =
	# `presentation_yaw_deg`.
	_state.follow_target_path = follow_target_path
	_state.distance = effective_distance
	_state.zoom_band_index = _zoom_band_index if _zoom_config_valid() else 0
	_state.focus_world = focus
	_state.yaw = _orbit_yaw_rad


func _resolve_target() -> Node3D:
	if follow_target_path.is_empty():
		return null
	var n: Node = get_node_or_null(follow_target_path)
	if n is Node3D:
		return n as Node3D
	return null


func _warn_missing_follow_target_once() -> void:
	if _warned_missing_follow_target:
		return
	_warned_missing_follow_target = true
	push_warning(
		(
			"IsometricFollowCamera: follow_target_path is empty or does not resolve to a Node3D; "
			+ "camera not updating."
		)
	)


static func desired_eye_world(
	focus: Vector3, distance: float, pitch_elevation_rad: float, yaw_rad: float
) -> Vector3:
	var h: float = distance * cos(pitch_elevation_rad)
	return focus + Vector3(h * sin(yaw_rad), distance * sin(pitch_elevation_rad), h * cos(yaw_rad))


## Returns true when [member occlusion_policy] is a valid, enabled [OcclusionPolicy].
## Mirrors [method _zoom_config_valid]; delegates to the static helper so tests can call
## it without a live Node.
func _occlusion_policy_valid() -> bool:
	return occlusion_policy_is_valid(occlusion_policy)


## Static guard — safe to call from tests without a live scene.
static func occlusion_policy_is_valid(policy: Resource) -> bool:
	return policy != null and policy.get_script() == OcclusionPolicyScript and policy.is_valid()


## True if [param body] is a live [Node3D] suitable as an [member _occluder_overrides] key.
## Used before occlusion ray work and from tests (NEO-18).
## [code]is_instance_valid[/code] first; [code]is Node3D[/code] on a freed ref errors at runtime.
static func occluder_override_key_is_valid(body: Variant) -> bool:
	if not is_instance_valid(body):
		return false
	return body is Node3D


func _restore_occluder_overrides_list(overrides: Array) -> void:
	for entry in overrides:
		var mi = entry.get("mesh")
		if not is_instance_valid(mi) or not mi is MeshInstance3D:
			continue
		var s: int = int(entry.get("surface", -1))
		if s < 0:
			continue
		var original: Variant = entry.get("original")
		if original != null:
			if typeof(original) != TYPE_OBJECT or not is_instance_valid(original):
				continue
		(mi as MeshInstance3D).set_surface_override_material(s, original)


## Drop stale entries (freed nodes or non-int legacy keys) without touching meshes.
func _purge_invalid_occluder_override_keys() -> void:
	var next: Dictionary = {}
	for k in _occluder_overrides.keys():
		if typeof(k) != TYPE_INT:
			continue
		var occ_id: int = k as int
		var node := instance_from_id(occ_id)
		if not is_instance_valid(node) or not node is Node3D:
			continue
		next[occ_id] = _occluder_overrides[k]
	_occluder_overrides = next


## Cast iterative rays from the smoothed eye to the player focus.
## Bodies in the [member occluder_group] that intersect the ray are faded;
## bodies that leave the path have their materials restored.
func _update_occlusion(focus: Vector3) -> void:
	if not _occlusion_policy_valid():
		_restore_all_occluders()
		return

	_purge_invalid_occluder_override_keys()

	var policy = occlusion_policy
	var space_state := get_world_3d().direct_space_state
	var newly_blocking: Array[Node3D] = []
	var excluded: Array[RID] = []

	for _i in range(policy.max_occluder_cast_depth):
		var params := PhysicsRayQueryParameters3D.create(
			_smoothed_eye, focus, policy.occluder_collision_mask
		)
		params.exclude = excluded
		var result := space_state.intersect_ray(params)
		if result.is_empty():
			break
		var body = result.get("collider")
		if body is Node3D and (body as Node3D).is_in_group(policy.occluder_group):
			newly_blocking.append(body as Node3D)
		excluded.append(result.get("rid"))

	if (
		policy.occluder_count_log_threshold > 0
		and newly_blocking.size() >= policy.occluder_count_log_threshold
	):
		push_warning(
			(
				"OcclusionPolicy: %d occluder(s) active (threshold %d)"
				% [newly_blocking.size(), policy.occluder_count_log_threshold]
			)
		)

	var blocking_ids: Dictionary = {}
	for body in newly_blocking:
		if occluder_override_key_is_valid(body):
			blocking_ids[(body as Node3D).get_instance_id()] = true

	for occ_id in _occluder_overrides.keys():
		if typeof(occ_id) != TYPE_INT:
			continue
		var id: int = occ_id as int
		if blocking_ids.has(id):
			continue
		_restore_occluder_id(id)

	for body in newly_blocking:
		if not occluder_override_key_is_valid(body):
			continue
		var bid: int = (body as Node3D).get_instance_id()
		if not _occluder_overrides.has(bid):
			_apply_occluder_fade(body as Node3D)


## Apply per-surface fade overrides to all [MeshInstance3D] children of [param body].
## - [StandardMaterial3D]: duplicated and alpha-set to [member OcclusionPolicy.fade_alpha].
## - null (no material): a plain [StandardMaterial3D] is created so the surface fades.
## - Anything else (e.g. [ShaderMaterial]): skipped with [method push_warning].
func _apply_occluder_fade(body: Node3D) -> void:
	var policy = occlusion_policy
	var overrides: Array = []
	for mesh_node in body.find_children("*", "MeshInstance3D", true, false):
		var mi: MeshInstance3D = mesh_node
		var n: int = mi.get_surface_override_material_count()
		for s in range(n):
			var saved: Material = mi.get_surface_override_material(s)
			var effective: Material = saved
			if effective == null and mi.mesh != null:
				effective = mi.mesh.surface_get_material(s)
			var faded: StandardMaterial3D
			if effective is StandardMaterial3D:
				faded = (effective as StandardMaterial3D).duplicate()
			elif effective == null:
				faded = StandardMaterial3D.new()
			else:
				push_warning(
					(
						"OcclusionPolicy: %s surface %d is %s — skipping fade."
						% [mi.get_path(), s, effective.get_class()]
					)
				)
				continue
			faded.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
			faded.albedo_color.a = policy.fade_alpha
			mi.set_surface_override_material(s, faded)
			overrides.append({"mesh": mi, "surface": s, "original": saved})
	_occluder_overrides[body.get_instance_id()] = overrides


## Restore saved surface override materials for occluder [param occ_id] and remove it from tracking.
func _restore_occluder_id(occ_id: int) -> void:
	if not _occluder_overrides.has(occ_id):
		return
	_restore_occluder_overrides_list(_occluder_overrides[occ_id])
	_occluder_overrides.erase(occ_id)


## Restore all currently faded occluders (called on policy disable or node exit).
func _restore_all_occluders() -> void:
	_purge_invalid_occluder_override_keys()
	for occ_id in _occluder_overrides.keys():
		if typeof(occ_id) == TYPE_INT:
			_restore_occluder_id(occ_id as int)


## Public hook for [code]dev_toggle_occluder_obstacle[/code] smoke; [code]main.gd[/code] dev toggle.
## Restore materials and clear [member _occluder_overrides] **while bodies are still live**,
## then the caller may [method Node.queue_free] the occluder. Avoids post-free occlusion touching a
## tearing-down [StaticBody3D] (semi-transparent faded box).
func restore_all_occluder_materials_now() -> void:
	_restore_all_occluders()
