extends CharacterBody3D
class_name GridBasedMovable

#signals
signal target_changed(target_pos)

signal pushed(direction)
signal tween_started()
#enums
#fixme: make DIRECTIONS unique
enum DIRECTIONS {UP, DOWN, LEFT, RIGHT, NONE}

#constants
const GRAVITY = -20

#exports
@export var step_size = 2
@export var step_time = 0.15
@export var stop_time = 0.0
@export var can_slide = false
@export var enable_gravity = true
@export var invincible = false
@export var vulnerable_to_spikes = true
@export var enable_rotation = false


#vars
var requested_direction = DIRECTIONS.NONE
var local_tween_position = Vector3()
var target_position = Vector3()
var distant_target_position = Vector3()
var gravity_applied = false
var enable_movement = true
var level
var speed_modifier = 1.0
var already_hit_by_spikes = false
var animated_mesh = null
var has_animated_mesh = false
var mesh_direction
var animation_player = null


#onready
@onready var rc_holder = $RC_Holder
@onready var ray_front = $RC_Holder/RayCastFront
@onready var ray_downramp = $RC_Holder/RayCastDownRamp
@onready var ray_front_pull = $RC_Holder/RayCastFrontPull
@onready var ray_back = $RC_Holder/RayCastBack
@onready var ray_ground = $RC_Holder/RayCastGround
@onready var ray_top = $RC_Holder/RayCastTop
@onready var rotation_point = $RotationPoint
@onready var mesh_instance = $RotationPoint/StaticMesh
@onready var detection_area = $DetectionArea
@onready var protection_area = $RotationPoint/PlacementProtectionArea
@onready var timer = $Timer
@onready var SMR_timer = $SpeedModResetTimer
@onready var health_node = $Health


#----------- built-in functions -----------
func _ready() -> void:

	animated_mesh = find_child("AnimatedMesh")
	if animated_mesh != null:
		has_animated_mesh = true
		animated_mesh.visible = true
		mesh_instance.visible = false
		animation_player = animated_mesh.find_child("AnimationPlayer")
	
	position.x = position.snapped(Vector3(step_size, step_size, step_size) - Vector3(1,0,1)).x
	position.z = position.snapped(Vector3(step_size, step_size, step_size) - Vector3(1,0,1)).z
		
	requested_direction = DIRECTIONS.NONE
	local_tween_position = position
	timer.wait_time = stop_time + (step_time * speed_modifier)
	target_position = position
	distant_target_position = position
	update_detection_area()
	
	level = get_tree().get_nodes_in_group("level")[0]
	already_hit_by_spikes = false

func _physics_process(delta):

	_handle_movement(delta)

#----------- movement related -----------
func _handle_movement(delta):
	
	update_detection_area()
	
	#reset the raycast to be disabled when not moving
	#_update_raycast_direction(DIRECTIONS.NONE) 
	rc_holder.update_raycast_direction(DIRECTIONS.NONE)
	gravity_applied = false
	
	if is_grid_aligned():
		if !is_on_floor():
			if enable_gravity:
				velocity.y += GRAVITY * delta
				gravity_applied = true
			target_position.y = position.y
			distant_target_position.y = position.y
		#else:
		if can_slide:
			_handle_slope_sliding()
		
		move_to(requested_direction)
	
	else:
		requested_direction = DIRECTIONS.NONE
	
	if !ray_ground.is_colliding() and on_floor_or_flying() and !gravity_applied and enable_gravity:
		velocity.y += GRAVITY * delta * 6

	position.x = local_tween_position.x
	position.z = local_tween_position.z
	if ray_ground.is_colliding():
		var groundObject = ray_ground.get_collider()
		if groundObject.is_in_group("spiky") and vulnerable_to_spikes == true and invincible == false:
			
			if already_hit_by_spikes == false:
				hit()
				already_hit_by_spikes = true
				
	

	move_and_slide()

func can_move_to(direction: DIRECTIONS) -> bool:
	if direction != DIRECTIONS.NONE and is_grid_aligned() and on_floor_or_flying() and is_target_free(direction):
		rc_holder.update_raycast_direction(direction)
		return !ray_front.is_colliding()
	
	return false
	
func move_to(direction, force_move: bool = false, instant: bool = false):
	#print ("move_to called: " + str(instant))
	if direction != DIRECTIONS.NONE and is_grid_aligned():
		rc_holder.update_raycast_direction(direction)
		update_rotation_point(direction)
		
			
		if (!ray_front.is_colliding() and is_target_free(direction) and on_floor_or_flying()) or force_move == true:
			if !instant:
				if timer.get_time_left() == 0:
					timer.start()
					_movement_tween(direction)
			else:
				print ("................")
				_instant_move(direction)

func movement_started():
	pass

func _movement_tween(direction: DIRECTIONS):
	#called in move_to, part of the actual movement, the vector "local_tween_position" is animated here
	#later, in _handle_movement, the actual position is set (only x and z coords)
	
	if not enable_movement:
		return
	var movedir = direction_to_vector(direction)
	

	var tween := create_tween()
	if tween == null:
		#fixes ugly crash with tween = null ;-)
		return
	tween.stop()
	tween.set_trans(Tween.TRANS_LINEAR)
	tween.set_process_mode(Tween.TWEEN_PROCESS_PHYSICS)
	target_position = position + movedir * step_size
	distant_target_position = target_position + movedir * step_size
	emit_signal("target_changed", target_position)
	

	tween.tween_property(self, "local_tween_position", target_position, step_time * speed_modifier)
	#print (str(name) + " starts movement, target pos: " + str(target_position))

	tween.play()
	emit_signal("tween_started")
	movement_started()
	already_hit_by_spikes = false

func _instant_move(direction: DIRECTIONS):
	if not enable_movement:
		return
	var movedir = direction_to_vector(direction)
	
	target_position = position + movedir * step_size
	distant_target_position = target_position + movedir * step_size
	emit_signal("target_changed", target_position)
	local_tween_position = target_position
	position.x = target_position.x
	position.z = target_position.z

func modify_speed_timed(amount, seconds = 0.01):
	#print ("modify speed")
	speed_modifier = amount
	SMR_timer.wait_time = seconds
	SMR_timer.start()

func _on_speed_mod_reset_timer_timeout() -> void:
	speed_modifier = 1.0
	#print ("reset speed")

func _handle_slope_sliding():
	if is_on_slope() and requested_direction == DIRECTIONS.NONE:
		move_and_slide()

		if get_floor_normal().x > 0.2:
			requested_direction = DIRECTIONS.RIGHT
		elif get_floor_normal().x < -0.2:
			requested_direction = DIRECTIONS.LEFT
		elif get_floor_normal().z > 0.2:
			requested_direction = DIRECTIONS.DOWN
		elif get_floor_normal().z < -0.2:
			requested_direction = DIRECTIONS.UP

#----------- push and pull -----------
func push(object_to_push: GridBasedMovable, direction: DIRECTIONS, force: bool = false, instant: bool = false):
	#print ("push......!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
	#prevents side pushing
	if object_to_push.enable_movement == false:
		return
	if direction == DIRECTIONS.UP or direction == DIRECTIONS.DOWN:
		if position.x != object_to_push.position.x:
			return
					
	elif direction == DIRECTIONS.LEFT or direction == DIRECTIONS.RIGHT:
		if position.z != object_to_push.position.z:	
			return


	if object_to_push.can_move_to(direction) and object_to_push.timer.get_time_left() == 0:
		object_to_push.move_to(direction, force, instant)
		object_to_push.emit_signal("pushed", direction)
		
		move_to(direction, true, instant)
		return true
	return false

func can_be_pulled_to(direction: DIRECTIONS) -> bool:
	rc_holder.update_raycast_direction(direction)
	
	if ray_front_pull.is_colliding():
		var _frontObject = ray_front_pull.get_collider()
		if _frontObject != null:
			print ("front collision: " + str(_frontObject.name))
			return false
			
	return true			

func get_push_down_ramp_object():
	if requested_direction != DIRECTIONS.NONE and is_on_slope() and ray_downramp.is_colliding() and is_grid_aligned():
		var downramp = ray_downramp.get_collider()
		if downramp != null:
			if downramp.is_in_group("pushable"):
				print ("downramp object detected")
				return downramp
			elif downramp.get_parent().is_in_group("pushable"):
				return downramp.get_parent()
	return null

#----------- physics related  -----------
func on_floor_or_flying() -> bool:
	return is_on_floor() or enable_gravity == false

func is_on_slope() -> bool:
	
	return get_floor_normal().y <= 0.96 and on_floor_or_flying()

func hit():
	pass

func update_detection_area():
	detection_area.position.x = target_position.x - position.x
	rc_holder.position.x = target_position.x - position.x
	detection_area.position.z = target_position.z - position.z
	rc_holder.position.z = target_position.z - position.z
	

	ray_front.force_raycast_update()
	ray_back.force_raycast_update()
	ray_ground.force_raycast_update()
	ray_downramp.force_raycast_update()
	ray_front_pull.force_raycast_update()

func print_front_collision():
	if ray_front.is_colliding():
		var frontObject = ray_front.get_collider()
		if frontObject != null:
			print ("front collision: " + str(frontObject.name))
		else:
			print ("front object is null")
	else:
		print ("no front collision")

func print_pull_collision():
	if ray_front_pull.is_colliding():
		var frontObject = ray_front_pull.get_collider()
		if frontObject != null:
			print ("pull collision: " + str(frontObject.name))
		else:
			print ("pull object is null")
	else:
		print ("no pull collision")

func print_back_collision():
	if ray_back.is_colliding():
		var backObject = ray_back.get_collider()
		if backObject != null:
			print ("back collision: " + str(backObject.name))
		else:
			print ("back object is null")
	else:
		print ("no back collision")

#----------- helper  -----------
func is_target_free(direction: DIRECTIONS):
	var target2 = position + direction_to_vector(direction) * step_size
	return level.is_target_free(self, target2)

func is_grid_aligned() -> bool:
	return abs(fmod(position.x - 1, step_size)) == 0 and abs(fmod(position.z - 1, step_size)) == 0

func get_opposite_direction(direction):
	if direction == DIRECTIONS.LEFT:
		return DIRECTIONS.RIGHT
	elif direction == DIRECTIONS.RIGHT:
		return DIRECTIONS.LEFT
	elif direction == DIRECTIONS.UP:
		return DIRECTIONS.DOWN
	elif direction == DIRECTIONS.DOWN:
		return DIRECTIONS.UP	
	else:
		return DIRECTIONS.NONE
		
func direction_to_vector(direction) -> Vector3:
	var dir = Vector3(0, 0, 0)
	
	if direction == DIRECTIONS.UP:	
		dir = Vector3(0, 0, -1)
	elif direction == DIRECTIONS.DOWN:	
		dir = Vector3(0, 0, 1)
	elif direction == DIRECTIONS.LEFT:	
		dir = Vector3(-1, 0, 0)
	elif direction == DIRECTIONS.RIGHT:
		dir = Vector3(1, 0, 0)
	return dir

func update_rotation_point(direction):
	if not enable_rotation:
		mesh_direction = DIRECTIONS.NONE
		return
	if direction != DIRECTIONS.NONE:
		mesh_direction = direction
	match direction:
		DIRECTIONS.UP:
			rotation_point.rotation = Vector3(0,0,0)
		DIRECTIONS.DOWN:
			rotation_point.rotation = Vector3(0,deg_to_rad(180),0)
		DIRECTIONS.LEFT:
			rotation_point.rotation = Vector3(0,deg_to_rad(90),0)
		DIRECTIONS.RIGHT:
			rotation_point.rotation = Vector3(0,deg_to_rad(-90),0)
	update_detection_area()
	rc_holder.update_raycast_direction(direction)

func check_distant_object():
	if not is_grid_aligned():
		return level.find_object_at_position(self, distant_target_position)
		
	return null
		
func check_current_target_object():
	if is_grid_aligned():
		var target_pos = position + direction_to_vector(mesh_direction) * step_size
		return level.find_object_at_position(self, target_pos)
	return null
		
