## FPSCharacterController — first-person character for a networked tactical shooter.
##
## Bridges Godot Input → SimulationServer (GDExtension C++ core).
## Handles mouse look, WASD movement, sprint toggle, hold-to-crouch, jump.
##
## Usage (scene tree):
##   FPSCharacterController (CharacterBody3D)
##   ├── FpsCamera (Camera3D)              — script: fps_camera.gd
##   ├── CollisionShape3D (capsule)
##   └── (optional weapons/arms child)
##
## Connects to SimulationServer via `apply_input()` each frame.
## Reads server snapshot data and updates the node transform.
##
## Walk Toggle:     Tap Shift to toggle sprint on/off.
## Crouch:          Hold Ctrl to crouch. Smooth lerp transition.
## Jump:            Press Space.
## Mouse Look:      Capture mouse on click, yaw rotates body, pitch rotates camera.
class_name FPSCharacterController
extends CharacterBody3D

# ---------------------------------------------------------------------------
# Exports — tune per weapon / game feel
# ---------------------------------------------------------------------------

## Mouse look sensitivity. At default 0.005, 100 px mouse motion = 0.5° rotation.
@export_range(0.001, 0.1, 0.0005) var mouse_sensitivity: float = 0.005

## Mouse vertical sensitivity multiplier (1.0 = same as horizontal).
@export_range(0.1, 2.0, 0.05) var mouse_vertical_ratio: float = 0.8

## Invert vertical look.
@export var invert_y: bool = false

## Maximum pitch angle (degrees) — prevents looking upside down.
@export_range(1, 89) var max_pitch: float = 89.0

## Entity ID assigned by SimulationServer. Set via spawn_entity() response.
@export var entity_id: int = -1

## If true, pressing Sprint toggles between walk/sprint. If false, hold to sprint.
@export var walk_toggle: bool = true

## If true, crouch toggles on press. If false, hold Ctrl to crouch.
@export var crouch_toggle: bool = false

## Time in seconds for crouch height transition (lerp).
@export var crouch_transition_time: float = 0.15

## Eye height when standing (Godot units, from feet).
@export var eye_height_stand: float = 1.7

## Eye height when crouching.
@export var eye_height_crouch: float = 0.9

## Collision shape height when standing.
@export var collision_height_stand: float = 1.8

## Collision shape height when crouching.
@export var collision_height_crouch: float = 1.0

# ---------------------------------------------------------------------------
# Internal state
# ---------------------------------------------------------------------------

## Reference to SimulationServer singleton (set in _ready).
var _server: Object = null

## Current look rotation.
var _yaw: float = 0.0
var _pitch: float = 0.0

## Crouch lerp target (0.0 = standing, 1.0 = full crouch).
var _crouch_target: float = 0.0
var _crouch_current: float = 0.0

## Walk-toggle state. If walk_toggle is true, toggles each time sprint input activates.
var _sprint_active: bool = false
var _sprint_pressed_last: bool = false

## Crouch toggle state.
var _crouch_active: bool = false
var _crouch_pressed_last: bool = false

## Input sequence counter — increment each frame for server reconciliation.
var _input_sequence: int = 0

## Cached input dictionary (avoids alloc per frame).
var _input_dict: Dictionary = {}

## Prediction controller reference (set by ClientPrediction system).
## When non-null and prediction_enabled, the controller uses client-side
## prediction with local movement for instant feedback instead of
## reading position from the SimulationServer entity directly.
var _prediction: Node = null

## Mouse capture state.
var _mouse_captured: bool = false
var _mouse_clicked_this_frame: bool = false

## Weapon manager reference.
var _weapon: WeaponManager = null

# ---------------------------------------------------------------------------
# Node references (set in _ready)
# ---------------------------------------------------------------------------

var _camera: Node3D = null
var _collision_shape: CollisionShape3D = null
var _capsule_shape: CapsuleShape3D = null

# Cached standing/crouching shape parameters.
var _stand_capsule_height: float = 0.0
var _crouch_capsule_height: float = 0.0
var _capsule_radius: float = 0.0

# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------

func _ready() -> void:
	# Find SimulationServer
	if Engine.has_singleton("SimulationServer"):
		_server = Engine.get_singleton("SimulationServer")
	else:
		push_warning("FPSCharacterController: SimulationServer singleton not found. " +
				"Running in standalone (local physics) mode.")

	# Locate camera
	_camera = get_node_or_null("%FpsCamera")
	if _camera == null:
		# Try to find any Camera3D child
		for child in get_children():
			if child is Camera3D:
				_camera = child
				break
	if _camera == null:
		push_error("FPSCharacterController: No Camera3D child found. Mouse look disabled.")

	# Locate collision shape
	for child in get_children():
		if child is CollisionShape3D:
			_collision_shape = child
			if child.shape is CapsuleShape3D:
				_capsule_shape = child.shape
				_capsule_radius = _capsule_shape.radius
				_stand_capsule_height = collision_height_stand - 2.0 * _capsule_radius
				_crouch_capsule_height = collision_height_crouch - 2.0 * _capsule_radius
				_capsule_shape.height = _stand_capsule_height
			break

	# Start with mouse captured
	_capture_mouse(true)

	# Initialize yaw/pitch from current transform
	_yaw = rotation.y
	if _camera:
		_pitch = _camera.rotation.x

	# Initialize crouch animation state
	_crouch_current = 0.0
	_update_crouch(0.0)

	# Find weapon manager child
	for child in get_children():
		if child is WeaponManager:
			_weapon = child
			break


func _input(event: InputEvent) -> void:
	# Mouse look
	if event is InputEventMouseMotion and _mouse_captured:
		var rel: Vector2 = event.relative
		# Yaw (body rotation)
		_yaw -= rel.x * mouse_sensitivity
		# Pitch (camera rotation) — negative = look down, positive = look up
		var invert: float = -1.0 if invert_y else 1.0
		_pitch += rel.y * mouse_sensitivity * mouse_vertical_ratio * invert
		_pitch = clamp(_pitch, deg_to_rad(-max_pitch), deg_to_rad(max_pitch))

	# Mouse capture toggle
	if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
		if not _mouse_captured:
			_capture_mouse(true)
		else:
			# LMB fires weapon — handled in _physics_process via shoot_pressed
			_mouse_clicked_this_frame = true


func _unhandled_input(event: InputEvent) -> void:
	# Escape to release mouse
	if event.is_action_pressed("ui_cancel"):
		_capture_mouse(false)


# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------

func _physics_process(delta: float) -> void:
	# PREDICTION HOOK: capture pre-input state snapshot.
	# Must be called BEFORE any input processing or movement so the
	# snapshot represents the state at the start of this tick.
	if _prediction and _prediction.prediction_enabled:
		_prediction.on_before_tick()

	# 1. Capture input state
	var input_dir := _get_move_direction()
	var jump_pressed := Input.is_action_just_pressed(&"jump")
	var sprint_pressed := Input.is_action_pressed(&"sprint")
	var crouch_pressed := Input.is_action_pressed(&"crouch")
	var shoot_pressed := _mouse_clicked_this_frame or Input.is_action_pressed(&"shoot")
	var aim_pressed := Input.is_action_pressed(&"aim")

	_mouse_clicked_this_frame = false

	# 2. Walk toggle logic
	if walk_toggle:
		if sprint_pressed and not _sprint_pressed_last:
			_sprint_active = not _sprint_active
		_sprint_pressed_last = sprint_pressed
	else:
		_sprint_active = sprint_pressed

	# 3. Crouch toggle logic
	if crouch_toggle:
		if crouch_pressed and not _crouch_pressed_last:
			_crouch_active = not _crouch_active
		_crouch_pressed_last = crouch_pressed
	else:
		_crouch_active = crouch_pressed

	# 4. Crouch height transition
	var target_crouch: float = 1.0 if _crouch_active else 0.0
	_crouch_target = target_crouch

	var crouch_speed: float = 1.0 / max(crouch_transition_time, 0.001)
	if _crouch_current < _crouch_target:
		_crouch_current = min(_crouch_current + crouch_speed * delta, _crouch_target)
	elif _crouch_current > _crouch_target:
		_crouch_current = max(_crouch_current - crouch_speed * delta, _crouch_target)

	_update_crouch(_crouch_current)

	# 5. Apply rotation
	rotation.y = _yaw
	if _camera:
		_camera.rotation.x = _pitch

	# 5b. Weapon fire with rate limiting via WeaponManager
	var should_fire: bool = false
	var time_since_engine_start: float = Engine.get_process_ticks() * get_physics_process_delta_time()
	if _weapon:
		should_fire = _weapon.try_fire(time_since_engine_start)
	elif shoot_pressed:
		# No weapon manager — fire every tick (unlimited)
		should_fire = true

	if _server != null:
		if should_fire:
			_server.fire_weapon(entity_id)

	# 6. Build input dictionary (all modes — mutate cached dict to avoid alloc)
	_input_dict["move_direction"] = input_dir
	_input_dict["look_yaw"] = rad_to_deg(_yaw)
	_input_dict["look_pitch"] = rad_to_deg(_pitch)
	_input_dict["jump"] = jump_pressed
	_input_dict["crouch"] = _crouch_active
	_input_dict["sprint"] = _sprint_active
	_input_dict["shoot"] = should_fire
	_input_dict["aim"] = aim_pressed
	_input_dict["input_sequence"] = _input_sequence

	# 6b. Route input to appropriate handler.
	if _prediction and _prediction.prediction_enabled:
		# Client prediction: input sending is handled by the prediction
		# controller's on_after_tick() call at the end of this function.
		pass
	elif _server != null and entity_id >= 0:
		# Listen server: send to local SimulationServer.
		_server.apply_input(entity_id, _input_dict)

		# Check for hit feedback from last tick
		if Engine.has_singleton("SimulationServer") or _server:
			var hit_result: Dictionary = _server.get_last_hit_result()
			if hit_result.get("hit", false) and _weapon:
				var hit_pos := Vector3.ZERO  # approximate — we don't have exact world hit position from server yet
				_weapon.on_hit_confirmed(hit_pos, hit_result.get("damage", 0.0), hit_result.get("killed", false))

	_input_sequence += 1

	# 7. Update position.
	#    Architecture:
	#    - Client prediction: do local CharacterBody3D movement (instant feedback),
	#      the prediction controller handles reconciliation when authoritative
	#      server state arrives.
	#    - Listen server: read authoritative position from SimulationServer entity.
	#    - Standalone (no server): fallback CharacterBody3D movement.
	if _prediction and _prediction.prediction_enabled:
		# Client prediction path — local movement for instant feedback.
		_move_local(input_dir, delta, jump_pressed)
	elif _server != null and entity_id >= 0:
		# Listen server: read from SimulationServer entity.
		var entity = _server.get_entity(entity_id)
		if entity != null and entity.is_alive():
			# Server position is ground truth — apply it to the visual body
			# (only correct on listen-server; pure clients must use replication)
			global_position = entity.position
	else:
		# Standalone mode: do local CharacterBody3D physics
		_move_local(input_dir, delta, jump_pressed)

	# PREDICTION HOOK: post-tick — send input to server via RPC.
	# Must be called AFTER local movement so the prediction controller
	# can send the input that produced this tick's movement.
	if _prediction and _prediction.prediction_enabled:
		_prediction.on_after_tick(_input_dict)


# ---------------------------------------------------------------------------
# Local movement (standalone / no-server fallback)
# ---------------------------------------------------------------------------

## Walk speed (standalone mode).
@export var local_walk_speed: float = 5.0
## Sprint speed (standalone mode).
@export var local_sprint_speed: float = 8.0
## Crouch speed (standalone mode).
@export var local_crouch_speed: float = 2.5
## Jump velocity (standalone mode).
@export var local_jump_velocity: float = 4.5
## Gravity (standalone mode).
@export var local_gravity: float = 15.0
## Acceleration (standalone mode).
@export var local_acceleration: float = 12.0

func _move_local(input_dir: Vector3, delta: float, jump: bool) -> void:
	# Simple CharacterBody3D movement for standalone testing
	var target_speed: float = local_walk_speed
	if _sprint_active:
		target_speed = local_sprint_speed
	if _crouch_active:
		target_speed = local_crouch_speed

	var wish_dir := (transform.basis * input_dir).normalized()
	var target_vel := wish_dir * target_speed

	# Horizontal acceleration
	var h_vel := Vector3(velocity.x, 0.0, velocity.z)
	h_vel = h_vel.move_toward(target_vel, local_acceleration * delta)
	velocity.x = h_vel.x
	velocity.z = h_vel.z

	# Gravity
	if not is_on_floor():
		velocity.y -= local_gravity * delta

	# Jump
	if jump and is_on_floor():
		velocity.y = local_jump_velocity

	move_and_slide()


# ---------------------------------------------------------------------------
# Crouch height management
# ---------------------------------------------------------------------------

func _update_crouch(amount: float) -> void:
	## amount: 0.0 = standing, 1.0 = fully crouched
	# Eye height — camera position relative to body origin (character feet at origin)
	if _camera:
		_camera.position.y = lerpf(eye_height_stand, eye_height_crouch, amount)

	# Collision shape — capsule height (cylindrical section)
	if _capsule_shape:
		_capsule_shape.height = lerpf(_stand_capsule_height, _crouch_capsule_height, amount)

	# Move the collision shape center so the capsule base stays on the ground:
	# shift = (crouch_total - stand_total) / 2, negative = moves down
	if _collision_shape:
		_collision_shape.position.y = lerpf(0.0, (collision_height_crouch - collision_height_stand) * 0.5, amount)


# ---------------------------------------------------------------------------
# Input helpers
# ---------------------------------------------------------------------------

func _get_move_direction() -> Vector3:
	var dir := Vector3.ZERO
	if Input.is_action_pressed(&"move_forward") or Input.is_action_pressed(&"move_up"):
		dir.z -= 1.0
	if Input.is_action_pressed(&"move_backward") or Input.is_action_pressed(&"move_down"):
		dir.z += 1.0
	if Input.is_action_pressed(&"move_left"):
		dir.x -= 1.0
	if Input.is_action_pressed(&"move_right"):
		dir.x += 1.0
	# Normalize for analog stick deadzone
	if dir.length_squared() > 0.0:
		dir = dir.normalized()
	return dir


func _capture_mouse(capture: bool) -> void:
	if capture == _mouse_captured:
		return
	_mouse_captured = capture
	if capture:
		Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
	else:
		Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

## Set the entity ID assigned by SimulationServer after spawning.
func set_entity_id(id: int) -> void:
	entity_id = id


## Set the prediction controller for client-side prediction/reconciliation.
## The prediction node must have on_before_tick() / on_after_tick() methods.
## Typically a ClientPrediction instance added as a child or sibling.
func set_prediction(prediction_node: Node) -> void:
	_prediction = prediction_node


## Get current crouch amount (0.0 = standing, 1.0 = fully crouched).
func get_crouch_amount() -> float:
	return _crouch_current


## Is the player currently sprinting?
func is_sprinting() -> bool:
	return _sprint_active


## Is the player currently crouching?
func is_crouching() -> bool:
	return _crouch_active


## Force look direction (useful for spectator / spawn reset).
func set_look(yaw_rad: float, pitch_rad: float) -> void:
	_yaw = yaw_rad
	_pitch = clamp(pitch_rad, deg_to_rad(-max_pitch), deg_to_rad(max_pitch))


## Reset to default standing state.
func reset_pose() -> void:
	_sprint_active = false
	_crouch_active = false
	_sprint_pressed_last = false
	_crouch_pressed_last = false
	_crouch_current = 0.0
	_crouch_target = 0.0
	_update_crouch(0.0)


## Get the look direction as a normalized Vector3 in world space.
## Uses the current yaw/pitch to compute a forward vector.
func get_look_direction() -> Vector3:
	var yaw_rad: float = _yaw
	var pitch_rad: float = _pitch
	return Vector3(
		cos(pitch_rad) * sin(yaw_rad),
		-sin(pitch_rad),
		cos(pitch_rad) * cos(yaw_rad)
	).normalized()
