class_name GameplayCamera extends Camera3D

@export var target: Node3D
@export var target_distance: float = 5.0
@export var target_min_distance: float = 2.0
@export var target_max_distance: float = 10.0
@export var target_decentering_factor: float = 0.4
@export var target_leveling_bias: float = 20.0
@export var allow_losing_target := false
@export var follow_lerp_speed: float = 0.99
@export var turn_lerp_speed: float = 1.0
@export var fov_lerp_speed: float = 0.999
@export var clip_margin: float = 0.5
@export var gimbal_margin: float = 0.75
@export var player_control := false
@export var look_speed: float = 1.0
@export var look_lerp_speed: float = 0.99
@export var zoom_speed: float = 4.0
@export var mouse_sensitivity: float = 0.075
@export var mouse_wheel_sensitivity: float = 5.0
@export var analog_sensitivity: float = 1.5
@export var analog_zoom_sensitivity: float = 1.0

var target_direction := Vector3.ZERO
var look_impetus := Vector3.ZERO
var look_velocity := Vector3.ZERO

@onready var _raycast_object := PhysicsRayQueryParameters3D.new()

func _ready() -> void:
	snap_to_target()

func _update_mouse_mode() -> void:
	if player_control && !Engine.is_embedded_in_editor():
		Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
	else:
		Input.mouse_mode = Input.MOUSE_MODE_VISIBLE

func _unhandled_input(event: InputEvent) -> void:
	if !player_control || Engine.is_embedded_in_editor(): return
	var move_event := event as InputEventMouseMotion
	var button_event := event as InputEventMouseButton
	if move_event:
		look_impetus.x -= move_event.relative.x*mouse_sensitivity
		look_impetus.y += move_event.relative.y*mouse_sensitivity
	elif button_event:
		match button_event.button_index:
			MOUSE_BUTTON_WHEEL_UP:
				look_impetus.z -= mouse_wheel_sensitivity
			MOUSE_BUTTON_WHEEL_DOWN:
				look_impetus.z += mouse_wheel_sensitivity

func _handle_analog_input() -> void:
	if !player_control: return
	look_impetus.x -= (
		Input.get_axis(&'look_left', &'look_right')*analog_sensitivity
	)
	look_impetus.y += (
		Input.get_axis(&'look_down', &'look_up')*analog_sensitivity
	)
	look_impetus.z += (
		Input.get_axis(&'zoom_in', &'zoom_out')*analog_sensitivity
	)

func _respond_to_impetus(delta: float) -> void:
	if !target: return
	var gck: float = _gimbal_check()
	if (
		(look_impetus.y > 0.0 and gck < -gimbal_margin) ||
		(look_impetus.y < 0.0 and gck > gimbal_margin)
	):
		look_impetus.y = 0.0
	var desired_look_velocity := Vector3(
		look_impetus.x*look_speed*target_distance,
		look_impetus.y*look_speed*target_distance,
		look_impetus.z*zoom_speed
	)
	look_impetus = look_impetus.limit_length()
	look_velocity = lerp(look_velocity, desired_look_velocity, look_lerp_speed)
	_try_go(
		global_position +
		global_basis.x*look_velocity.x*delta +
		global_basis.y*look_velocity.y*delta
	)
	target_distance = clamp(
		target_distance + look_velocity.z*delta,
		target_min_distance, target_max_distance
	)
	look_impetus = Vector3.ZERO

func _find_closest_safe_spot(
	where: Vector3, from: Vector3 = global_position
) -> Vector3:
	_raycast_object.from = from
	_raycast_object.to = where + (
		(where - from).normalized()*clip_margin
	)
	if target:
		_raycast_object.exclude = [target]
	else:
		_raycast_object.exclude = []
	var raycast_results := (
		get_world_3d().direct_space_state.intersect_ray(_raycast_object)
	)
	var desired_position: Vector3
	if raycast_results.is_empty():
		desired_position = where
	else:
		desired_position = raycast_results.position + (
			raycast_results.normal*clip_margin
		)
	if target && !allow_losing_target:
		allow_losing_target = true
		var posn_a := _find_closest_safe_spot(
			desired_position, target.global_position
		)
		if target is Character && target.is_node_ready():
			var posn_b := _find_closest_safe_spot(
				desired_position,
				target.global_position + target.height*target.global_basis.y
			)
			if (
				(posn_b - desired_position).length() <
				(posn_a - desired_position).length()
			):
				posn_a = posn_b
		allow_losing_target = false
		return posn_a
	else:
		return desired_position

func _try_go(where: Vector3) -> void:
	global_position = _find_closest_safe_spot(where)

func snap_to_target() -> void:
	if !target: return
	target_direction = (
		-target.global_basis.z - target.global_basis.y/2.0
	).normalized()
	_follow_target(0.0, true)

func _gimbal_check() -> float:
	return target_direction.dot(target.global_basis.y)

func _follow_target(
	delta: float,
	snap: bool = false
) -> void:
	if !target: return
	var velocity := Vector3.ZERO
	var top_speed: float = 30.0
	if target is RigidBody3D:
		velocity = (target as RigidBody3D).linear_velocity
		if target is Character:
			top_speed = (target as Character).get_top_speed()
	var decentering: float = target_distance*target_decentering_factor
	var leveling := (
		target_leveling_bias*velocity.project(target.global_basis.y)
	)*delta
	var target_position := (
		target.global_position +
		decentering*target.global_basis.y +
		velocity*delta +
		leveling
	)
	var target_offset := target_position - global_position
	if !snap:
		target_direction = target_offset.normalized()
	var desired_position := target_position - target_distance*target_direction
	_try_go(desired_position if snap else lerp(
		global_position, desired_position,
		1.0 - (1.0 - follow_lerp_speed)**delta
	))
	if !is_zero_approx(1.0 - abs(_gimbal_check())):
		var desired_basis := Basis.looking_at(
			target_offset - leveling,
			target.global_basis.y
		)
		global_basis = desired_basis if snap else global_basis.slerp(
			desired_basis,
			1.0 - (1.0 - turn_lerp_speed)**delta
		)
	var desired_fov: float = min(
		75.0 + 15.0*velocity.project(global_basis.z).length()/top_speed,
		179.0
	)
	fov = desired_fov if snap else lerp(
		fov, desired_fov,
		1.0 - (1.0 - fov_lerp_speed)**delta
	)

func _physics_process(delta: float) -> void:
	_update_mouse_mode()
	_handle_analog_input()
	_respond_to_impetus(delta)
	_follow_target(delta)