class_name Player extends PhysicsBody3D

enum MovementType { VERTICAL, LATERAL }

@export var jump_speed := 4.5
@export var max_speed := 8
@export var slow_speed := 2
@export var mouse_sensitivity := 0.002  # radians/pixel

## The character will be blocked from moving up slopes steeper than this angle
## The character will be not be flagged as 'grounded' when stood on slopes steeper than this angle
@export var slope_limit: float = 45

## The character will automatically adjust height to step over obstacles this high
@export var step_height := 0.2

## When grounded, the character will snap down this distance
## This keeps the character on steps, slopes, and helps keep behaviour consistent
@export var snap_to_ground_distance := 0.2

@export_group("Connector Nodes")
@export var head: Node3D
@export var body: Node3D
@export var camera: DampenedCamera3D
@export var collision_shape: CollisionShape3D
@export var state_chart: StateChart

@export_group("Advanced")
## Stop movement under this distance, but only if the movement touches at least 2 steep slopes
## The slope movement code in this class does not handle all edge cases; this is a hack to eliminate
## jitter movement
@export var steep_slope_jitter_reduce := 0.03

## The godot move_and_collide method has built in depenetration
## Higher values can eliminate jittery movement against obscure geometry, but in my experience
## this comes at the cost of making movement across flush collision planes a bit unreliable
@export var depenetration_margin := 0.001

## The distance under the player to check for ground at the start of movement
## This is in addition to the usual method of setting grounded state by collision
@export var ground_cast_distance := 0.004

## If a collision happens within this distance of the bottom of the collider
## it's considered the "bottom"
## This value is used to determine if slopes should actually make the player
## rise, or if they should be considered a wall, in the case where the slope
## is above the players feet
@export var bottom_height := 0.05

## The movement code in this class tries to adjust translation to confirm to the collision plane
## This means the same plane should never be hit more than once within 1 frame
## This sometimes happens anyway, typically when there is a small safe margin
## If it happens, the movement will be blocked and the rest of the movement iterations will be
## consumed
## This is a little hack to slightly adjust the translation to break out of this infinite loop
@export var same_surface_adjust_distance := 0.001

## How many times to move_and_collide. The algorithm early exits anyway
## The value of turning this up is to make movement in very complicated terrain more
## accurate. 4 is a decent number for low poly terrain!
@export var max_iteration_count := 4

var jump_pressed := false
var escape_pressed_prev := false

var gravity := 9.8
var _velocity: Vector3 = Vector3()
var at_max_speed := true

var grounded: bool = false
var ground_normal: Vector3
var steep_slope_normals: Array[Vector3] = []
var total_stepped_height: float = 0

# TODO Should these be class variables? Could get away with just locals
var vertical_collisions: Array[KinematicCollision3D]
var lateral_collisions: Array[KinematicCollision3D]
var snap_collisions: Array[KinematicCollision3D]


func _ready() -> void:
	lock_mouse()


func lock_mouse() -> void:
	Input.mouse_mode = Input.MOUSE_MODE_CAPTURED


func unlock_mouse() -> void:
	Input.mouse_mode = Input.MOUSE_MODE_VISIBLE


# TODO should this have an action associated?
# TODO should it be in unhandled?
func _input(event: InputEvent) -> void:
	if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
		var motion := event as InputEventMouseMotion
		body.rotate_y(-motion.relative.x * mouse_sensitivity)
		head.rotate_x(-motion.relative.y * mouse_sensitivity)
		head.rotation.x = clamp(head.rotation.x, -1.4, 1.4)


# TODO should this be in unhandled input?
# TODO this should return void and just set the appropriate fields (input_dir!!)
# Input buffering? lol lmao
func get_input() -> Vector2:
	# Toggle mouse capture mode
	var escape_pressed_curr := Input.is_key_pressed(KEY_ESCAPE)
	if escape_pressed_curr && !escape_pressed_prev:
		if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
			unlock_mouse()
		else:
			lock_mouse()
	escape_pressed_prev = escape_pressed_curr

	# We don't want to be moving if the mouse isn't captured!
	if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
		return Vector2(0, 0)

	jump_pressed = Input.is_action_just_pressed("cc_jump")
	at_max_speed = Input.is_action_pressed("cc_sprint")

	var input_dir: Vector2 = Vector2()
	if Input.is_action_pressed("cc_forward"):
		input_dir += Vector2.UP
	if Input.is_action_pressed("cc_backward"):
		input_dir -= Vector2.UP
	if Input.is_action_pressed("cc_left"):
		input_dir += Vector2.LEFT
	if Input.is_action_pressed("cc_right"):
		input_dir -= Vector2.LEFT
	input_dir = input_dir.normalized()

	# Local rotation is fine given the parent isn't rotating ever
	return input_dir.rotated(-body.rotation.y)


# Entry point to moving
func move(intended_velocity: Vector3, delta: float) -> void:
	var start_position := position

	var lateral_translation := _horz(intended_velocity * delta)
	var initial_lateral_translation := lateral_translation
	var vertical_translation := _vert(intended_velocity * delta)
	var initial_vertical_translation := vertical_translation

	grounded = false
	steep_slope_normals = []
	total_stepped_height = 0

	vertical_collisions.clear()
	lateral_collisions.clear()
	snap_collisions.clear()

	# An initial grounded check is important because ground normal is used
	# to detect seams with steep slopes; which often are collided with before the ground
	if vertical_translation.y <= 0:
		var collision := move_and_collide(
			Vector3.DOWN * ground_cast_distance, true, depenetration_margin
		)
		if collision:
			var normal := collision.get_normal(0)
			if _under_slope_limit(normal):
				grounded = true
				ground_normal = normal

	# Lateral movement
	for i in max_iteration_count:
		if lateral_translation.length() <= 0:
			break
		lateral_translation = move_iteration(
			MovementType.LATERAL,
			lateral_collisions,
			initial_lateral_translation,
			lateral_translation
		)

		# De-jitter by just ignoring lateral movement
		# (multiple steep slopes have been collided, but movement is very small)
		if (
			steep_slope_normals.size() > 1
			and _horz(position - start_position).length() < steep_slope_jitter_reduce
		):
			position = start_position

	# Vertical movement
	for i in max_iteration_count:
		if vertical_translation.length() <= 0:
			break
		vertical_translation = move_iteration(
			MovementType.VERTICAL,
			vertical_collisions,
			initial_vertical_translation,
			vertical_translation
		)

	# Don't include step height in actual velocity
	var actual_translation := position - start_position
	var actual_translation_no_step := actual_translation - Vector3.UP * total_stepped_height
	var actual_velocity := actual_translation_no_step / delta

	# HACK!
	# For some reason it's difficult to accumulate velocity when sliding down steep slopes
	# Here I just ignore the actual velocity in favour of:
	# "If intended travel was down, and actual travel was down, just keep the intended velocity"
	# This means the user is responsible for resetting vertical velocity when grounded
	if intended_velocity.y < 0 and actual_velocity.y < 0:
		_velocity = Vector3(actual_velocity.x, intended_velocity.y, actual_velocity.z)
	else:
		_velocity = actual_velocity

	# Snap Down
	# Happens last so it doesn't affect velocity
	# Keeps the character on slopes and on steps when travelling down
	if grounded:
		camera.damp()
		snap_down()
	else:
		camera.donmp()


# Moves are composed of multiple iterates
# In each iteration, move until collision, then calculate and return the next movement
func move_iteration(
	movement_type: MovementType,
	collision_array: Array,
	initial_direction: Vector3,
	translation: Vector3
) -> Vector3:
	var collisions: KinematicCollision3D

	# If Lateral movement, try stepping
	if movement_type == MovementType.LATERAL:
		var temp_position := position
		var walk_test_collision := move_and_collide(translation, true, 0)

		# To correctly step we need to cast up, then forwards, then down
		# This makes sure that the full step motion is actually clear, and ensures
		# that the step action is reversible
		var current_step_height := step_height
		var step_up_collisions := move_and_collide(Vector3.UP * step_height, false, 0)
		if step_up_collisions:
			current_step_height = step_up_collisions.get_travel().length()
		var raised_forward_collisions := move_and_collide(translation, false, 0)
		var down_collision := move_and_collide(Vector3.DOWN * current_step_height, false, 0)

		# Only step if the step algorithm landed on a walkable surface
		# AND the walk lands on a non-walkable surface
		# This stops stepping up ramps
		if (
			down_collision
			and _under_slope_limit(down_collision.get_normal(0))
			and walk_test_collision
			and !_under_slope_limit(walk_test_collision.get_normal(0))
		):  # Keep track of stepepd distance to cancel it out later
			total_stepped_height += position.y - temp_position.y
			collisions = raised_forward_collisions
			camera.damp()
		else:  # Reset and move normally
			position = temp_position
			collisions = move_and_collide(translation, false, depenetration_margin)

	# If Vertical movement, just move; no need to step
	else:
		collisions = move_and_collide(translation, false, depenetration_margin)

	# Moved all remaining distance
	if !collisions:
		return Vector3.ZERO

	collision_array.append(collisions)

	# If any ground collisions happen during movement, the character is grounded
	# Imporant to keep this up-to-date rather than just rely on the initial grounded state
	var normal := collisions.get_normal(0)
	if _under_slope_limit(normal):
		grounded = true
		ground_normal = normal

	# This algorithm for determining where to move on a collisions uses "projection plane"
	# Whatever surface the character hits, we generate a blocking "plane" that we will slide along
	#
	# We calculate the normal of the plane we want to use, projection_normal, then
	# transform into a plane at the end
	#
	# By default, projection normal is just the normal of the surface
	# This may be unnecessary after we account for all edge cases
	#
	# We also want to "block" movement in some directions based on a surface angle
	# For Vertical, blocking angle is between 0 - slopeLimit
	# For Lateral, blocking angle is slopeLimit - 360 (grounded) or 90 (not grounded)
	# The latter allows players to slide down ceilings while in the air
	# TODO These can be precalculated on slope limit change
	var projection_normal := normal
	var surface_angle := normal.angle_to(Vector3.UP)
	match movement_type:
		MovementType.LATERAL:
			var min_block_angle := deg_to_rad(slope_limit)
			var max_block_angle := 2 * PI if grounded else PI / 2

			# If collision happens on the "side" of the cylinder, treat it as a vertical
			# wall in all cases (we use the tangent of the cylinder)
			var cylinder := collision_shape.shape as CylinderShape3D
			var collision_point := collisions.get_position(0)
			var side_y := (collision_shape.global_position.y - cylinder.height / 2) + bottom_height
			if collision_point.y > side_y:
				projection_normal = collision_shape.global_position - collision_point
				projection_normal.y = 0
				projection_normal = projection_normal.normalized()
			elif surface_angle >= min_block_angle and surface_angle <= max_block_angle:
				# "Wall off" the slope
				projection_normal = _horz(normal).normalized()

				# Or, "Wall off" the slope by figuring out the seam with the ground
				if grounded and surface_angle < PI / 2:
					if !already_touched_slope_close_match(normal):
						steep_slope_normals.append(normal)

					var seam := normal.cross(ground_normal)
					var temp_projection_plane := Plane(Vector3.ZERO, seam, seam + Vector3.UP)
					projection_normal = temp_projection_plane.normal
			# Otherwise force the direction to align with input direction
			# (projecting translation over the normal of a slope does not align with input direction)
			elif surface_angle < (PI / 2):
				projection_normal = relative_slope_normal(normal, translation)
		MovementType.VERTICAL:
			# If vertical is blocked, you're on solid ground - just stop moving
			var min_block_angle := 0
			var max_block_angle := deg_to_rad(slope_limit)
			if surface_angle >= min_block_angle and surface_angle <= max_block_angle:
				return Vector3.ZERO

	# Don't let one move call ping pong around
	var projection_plane := Plane(projection_normal)
	var continued_translation := projection_plane.project(collisions.get_remainder())
	var initial_influenced_translation := projection_plane.project(initial_direction)
	var next_translation: Vector3
	if initial_influenced_translation.dot(continued_translation) >= 0:
		next_translation = continued_translation
	else:
		next_translation = (
			initial_influenced_translation.normalized() * continued_translation.length()
		)

	# See same_surface_adjust_distance
	if next_translation.normalized() == translation.normalized():
		next_translation += normal * same_surface_adjust_distance

	return next_translation


# TODO Should this use same_surface_adjust_distance
func already_touched_slope_close_match(normal: Vector3) -> bool:
	return steep_slope_normals.any(
		func(n: Vector3) -> bool: return n.distance_squared_to(normal) < 0.001
	)


# I wrote this a while ago in Unity
# I ported it here but I only have a vague grasp of how it works
func relative_slope_normal(slope_normal: Vector3, lateral_desired_direction: Vector3) -> Vector3:
	var surface_angle := slope_normal.angle_to(Vector3.UP)
	var inverse_surface_angle := PI / 2 - surface_angle
	if inverse_surface_angle <= 0:
		push_error("Trying to calculate relative slope normal for a ceiling")

	# Geometry!
	# This is the component of the desired travel that points straight into the slope
	var angle_to_straight := _horz(slope_normal).angle_to(-lateral_desired_direction)
	var straight_length := cos(angle_to_straight) * lateral_desired_direction.length()

	# Which helps us calculate the height on the slope at the end of the desired travel
	var height := straight_length / tan(inverse_surface_angle)

	# Which gives us the actual desired movement
	var vector_up_slope := Vector3(lateral_desired_direction.x, height, lateral_desired_direction.z)

	# Due to the way the movement algorithm works we need to figure out the normal that defines
	# the plane that will give this result
	var rotation_axis := vector_up_slope.cross(Vector3.UP).normalized()
	var emulated_normal := vector_up_slope.rotated(rotation_axis, PI / 2)
	return emulated_normal.normalized()


# TODO this doesn't always work. Why?
func snap_down() -> void:
	# We allow snap to slide down slopes
	# It really helps reduce jitter on steep slopes
	var before_snap_pos := position
	var ground_snap_translation := Vector3.DOWN * snap_to_ground_distance
	for i in max_iteration_count:
		if ground_snap_translation.length() <= 0:
			break
		ground_snap_translation = move_iteration(
			MovementType.VERTICAL, snap_collisions, Vector3.DOWN, ground_snap_translation
		)

	# Decide whether to keep the snap or not
	if snap_collisions.is_empty():
		var ground_collision := move_and_collide(
			Vector3.DOWN * ground_cast_distance, true, depenetration_margin
		)
		if ground_collision && _under_slope_limit(ground_collision.get_normal(0)):
			# There was no snap collisions, but there is ground underneath
			# This can be due to an edge case where the snap movement falls through the ground
			# Why does this check not fall through the ground? I don't know
			# In any case, manually set the y
			position.y = ground_collision.get_position(0).y
		else:
			# No snap collisions and no floor, reset
			position = before_snap_pos
	elif !_under_slope_limit(snap_collisions[snap_collisions.size() - 1].get_normal(0)):
		# Collided with steep ground, reset
		position = before_snap_pos


func _under_slope_limit(normal: Vector3) -> bool:
	return normal.angle_to(Vector3.UP) < deg_to_rad(slope_limit)


func _horz(value: Vector3) -> Vector3:
	return Vector3(value.x, 0, value.z)


func _vert(value: Vector3) -> Vector3:
	return Vector3(0, value.y, 0)


func _on_grounded_state_physics_processing(delta: float) -> void:
	var move_speed := max_speed if at_max_speed else slow_speed
	var target_velocity_h := get_input() * move_speed
	var target_velocity_v := -gravity * delta
	var target_velocity := Vector3(target_velocity_h.x, target_velocity_v, target_velocity_h.y)
	move(target_velocity, delta)

	if jump_pressed:
		state_chart.send_event("Jump")
	elif !grounded:
		state_chart.send_event("Airborne")


func _on_falling_state_physics_processing(delta: float) -> void:
	var move_speed := max_speed if at_max_speed else slow_speed
	var target_velocity_h := get_input() * move_speed
	var target_velocity_v := _velocity.y - gravity * delta
	var target_velocity := Vector3(target_velocity_h.x, target_velocity_v, target_velocity_h.y)
	move(target_velocity, delta)

	if grounded:
		state_chart.send_event("Grounded")


func _on_jumping_state_physics_processing(delta: float) -> void:
	var move_speed := max_speed if at_max_speed else slow_speed
	var target_velocity_h := get_input() * move_speed
	var target_velocity_v := jump_speed
	var target_velocity := Vector3(target_velocity_h.x, target_velocity_v, target_velocity_h.y)
	move(target_velocity, delta)

	state_chart.send_event("Airborne")
