extends Area2D
class_name SnakePart

const TILE_SIZE = 40
enum PartTypes {HEAD, BODY, TAIL}
enum States {ALIVE, DEAD, OUROBOROS, OLD_OUROBOROS}
const COLOURS = [Color.DARK_RED, Color.ORANGE_RED, Color.YELLOW, Color.WEB_GREEN, Color.BLUE, Color.REBECCA_PURPLE]

var part_type : PartTypes = PartTypes.HEAD
var state : States = States.ALIVE
var colour_index : int = 0
var current_direction : Vector2 = Vector2.RIGHT
var old_direction : Vector2 = Vector2.RIGHT

var inputs : Dictionary[String, Vector2] = {"right": Vector2.RIGHT,
											"left": Vector2.LEFT,
											"up": Vector2.UP,
											"down": Vector2.DOWN}

var possible_x : Array
var possible_y : Array

@onready
var raycast_right : RayCast2D = $RightRayCast2D
@onready
var raycast_left : RayCast2D = $LeftRayCast2D
@onready
var raycast_up : RayCast2D = $UpRayCast2D
@onready
var raycast_down : RayCast2D = $DownRayCast2D


var snake_part_obj : PackedScene = preload("res://Scenes/snake_part.tscn")

@onready
var timer_ref : Timer = $"../Timer"

@onready
var game_over_menu : VBoxContainer = get_tree().get_first_node_in_group("GameOverMenu")

@onready
var sprite : Sprite2D = $Sprite2D

var image_head : CompressedTexture2D = preload("res://Sprites/snake_head.png")
var image_head_dead : CompressedTexture2D = preload("res://Sprites/snake_head_dead.png")
var image_head_ouroboros : CompressedTexture2D = preload("res://Sprites/snake_head_ouroboros.png")
var image_head_ouroboros_left : CompressedTexture2D = preload("res://Sprites/snake_head_ouroboros_left.png")
var image_head_ouroboros_right : CompressedTexture2D = preload("res://Sprites/snake_head_ouroboros_right.png")
var image_left : CompressedTexture2D = preload("res://Sprites/snake_left.png")
var image_right : CompressedTexture2D = preload("res://Sprites/snake_right.png")
var image_straight : CompressedTexture2D = preload("res://Sprites/snake_straight.png")
var image_tail : CompressedTexture2D = preload("res://Sprites/snake_tail.png")

var next_part : SnakePart = null
var commanding_head : SnakePart = null
var skip_next_move_propagation : bool = false
var queued_growth : int = 0

signal on_movement(new_dir, prevent_move)
signal on_death
signal on_ouroboros

func _ready() -> void:
	if part_type == PartTypes.HEAD:
		
		# Add to group for easy access from other nodes
		add_to_group("Head")
		
		# Work out allowed positions
		
		# Determine the level limits
		var level : Area2D = get_tree().get_first_node_in_group("Level")
		var x_bounds : Vector2 = level.position.x * Vector2.ONE + Vector2(-TILE_SIZE, TILE_SIZE) * level.scale.x / 2
		var y_bounds : Vector2 = level.position.y * Vector2.ONE + Vector2(-TILE_SIZE, TILE_SIZE) * level.scale.y / 2

		# Determine the possible x and y coordinates
		possible_x = range(x_bounds.x, x_bounds.y + 20, TILE_SIZE)
		possible_y = range(y_bounds.x, y_bounds.y + 20, TILE_SIZE)
		
		# Attach clock
		var timer : Node = get_tree().get_first_node_in_group("GameClock")
		if timer is Timer:
			timer.timeout.connect(process_movement.bind(Vector2.ZERO, false))
		
		# Set up a body part
		var body = snake_part_obj.instantiate()
		body.part_type = PartTypes.BODY
		body.colour_index = colour_index
		body.position = (position - current_direction * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE)
		next_part = body
		get_parent().add_child.call_deferred(body)
		on_movement.connect(body.process_movement)
		on_death.connect(body.lose_game)
		on_ouroboros.connect(body.ouroboros)
		
		# Set up a tail part
		var tail = snake_part_obj.instantiate()
		tail.part_type = PartTypes.TAIL
		tail.colour_index = colour_index
		tail.position = (position - current_direction * TILE_SIZE * 2).snapped(Vector2.ONE * TILE_SIZE)
		body.next_part = tail
		get_parent().add_child.call_deferred(tail)
		body.on_movement.connect(tail.process_movement)
		on_death.connect(tail.lose_game)
		on_ouroboros.connect(tail.ouroboros)
		tail.commanding_head = self
	else:
		
		# Free raycasts if we don't need them (consider only loading them in for the head for performance if needed)
		raycast_down.queue_free()
		raycast_up.queue_free()
		raycast_left.queue_free()
		raycast_right.queue_free()
	update_sprite()

# TODO: Optimise this!
func generate_spawn_grid() -> Array[Vector2]:
	
	var spawn_grid : Array[Vector2] = []
	
	# Determine the level limits
	var level : Area2D = get_tree().get_first_node_in_group("Level")
	var x_bounds : Vector2 = level.position.x * Vector2.ONE + Vector2(-TILE_SIZE, TILE_SIZE) * level.scale.x / 2
	var y_bounds : Vector2 = level.position.y * Vector2.ONE + Vector2(-TILE_SIZE, TILE_SIZE) * level.scale.y / 2
	
	#var possible_x : Array = range(x_bounds.x, x_bounds.y, TILE_SIZE)
	#var possible_y : Array = range(y_bounds.x, y_bounds.y, TILE_SIZE)
	
	# Flood fill all possibilities until we get an inside to use
	for x in possible_x:
		for y in possible_y:
			var start_position : Vector2 = Vector2(x, y).snapped(Vector2.ONE * TILE_SIZE)
			spawn_grid.clear()
			if !check_position_for_snake(start_position) and flood_fill(spawn_grid, start_position, x_bounds, y_bounds):
				GridManager.current_allowed_spawns = spawn_grid
				return spawn_grid
	
	GridManager.current_allowed_spawns = spawn_grid
	return spawn_grid

func flood_fill(positions : Array[Vector2], new_position : Vector2, x_bounds : Vector2, y_bounds : Vector2) -> bool:
	var directions : Array[Vector2] = [Vector2.DOWN, Vector2.LEFT, Vector2.UP, Vector2.RIGHT]
	
	#var possible_x : Array = range(x_bounds.x, x_bounds.y, TILE_SIZE)
	#var possible_y : Array = range(y_bounds.x, y_bounds.y, TILE_SIZE)
	
	var inside : bool = true
	
	for direction in directions:
		var test_position : Vector2 = (new_position + direction * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE)
		if not int(test_position.x) in possible_x or not int(test_position.y) in possible_y:
			inside = false
		if not test_position in positions and int(test_position.x) in possible_x and int(test_position.y) in possible_y and !check_position_for_snake(test_position):
			positions.append(test_position)
			inside = inside and flood_fill(positions, test_position, x_bounds, y_bounds)
				
	return inside

func check_position_for_snake(query_position : Vector2) -> bool:
	
	var obstacles : Array[Node] = get_tree().get_nodes_in_group("BlocksSpawn")
	
	for obstacle in obstacles:
		if obstacle is SnakePart:
			if obstacle.state == States.OUROBOROS and obstacle.position == query_position:
				return true
	return false

func process_movement(new_direction : Vector2, prevent_movment : bool = false) -> void:
	
	# Only alive snakes can move
	if state != States.ALIVE:
		return
	
	# The head needs to check if we are about to collide with something
	if part_type == PartTypes.HEAD:
		check_movement()
		if queued_growth and state == States.ALIVE:
			extend()
	
	# Only alive snakes can move
	update_sprite()
	if state != States.ALIVE:
		return
	
	# Update the position of this part
	if !prevent_movment:
		position = (position + current_direction * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE)
	
	# Tell the next part to move and give it the direction we are moving
	on_movement.emit(current_direction, prevent_movment or skip_next_move_propagation)
	skip_next_move_propagation = false
	
	# Unless we are the head, change our direction to the direction given
	if !prevent_movment:
		old_direction = current_direction
	if new_direction != Vector2.ZERO and !prevent_movment:
		current_direction = new_direction
	
	# Update the sprite
	update_sprite()

func update_sprite() -> void:
	
	sprite.modulate = COLOURS[colour_index]
	
	# Set the sprite based on state and part type
	match part_type:
		PartTypes.HEAD:
			match state:
				States.ALIVE:
					sprite.texture = image_head
				States.DEAD:
					sprite.texture = image_head_dead
					return
				_:
					var tail_rel_direction : float = current_direction.angle_to(old_direction)
					if abs(tail_rel_direction) < 0.1:
						sprite.texture = image_head_ouroboros
					elif tail_rel_direction > 0:
						sprite.texture = image_head_ouroboros_left
					elif tail_rel_direction < 0:
						sprite.texture = image_head_ouroboros_right
		PartTypes.TAIL:
			if state == States.OUROBOROS or state == States.OLD_OUROBOROS:
				var tail_rel_direction : float = current_direction.angle_to(position - commanding_head.position)
				if abs(tail_rel_direction) < 0.1:
					sprite.texture = image_straight
				elif tail_rel_direction > 0:
					sprite.texture = image_left
				elif tail_rel_direction < 0:
					sprite.texture = image_right
			else:
				sprite.texture = image_tail
		PartTypes.BODY:
			var tail_rel_direction : float = current_direction.angle_to(old_direction)
			if abs(tail_rel_direction) < 0.1:
				sprite.texture = image_straight
			elif tail_rel_direction > 0:
				sprite.texture = image_left
			elif tail_rel_direction < 0:
				sprite.texture = image_right
	
	if current_direction != Vector2.ZERO:
		sprite.rotation = current_direction.angle() + PI / 2
	else:
		sprite.rotation = old_direction.angle() + PI / 2

func check_movement() -> void:
	
	# Make sure we are going to stay in the arena
	var new_position : Vector2 = (position + current_direction * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE)
	if not int(new_position.x) in possible_x or not int(new_position.y) in possible_y:
		lose_game()
	
	# Get the correct raycast for the direction we are moving
	var raycast_to_use : RayCast2D
	if current_direction == Vector2.RIGHT:
		raycast_to_use = raycast_right
	elif current_direction == Vector2.LEFT:
		raycast_to_use = raycast_left
	elif current_direction == Vector2.UP:
		raycast_to_use = raycast_up
	elif current_direction == Vector2.DOWN:
		raycast_to_use = raycast_down
	
	# Look using the raycast for anything to collide with
	var object_in_path : Object = raycast_to_use.get_collider()
	
	# If we found nothing, stop processing collision
	if object_in_path == null:
		return
		
	# If we found a snake part, then either end the game or perform an ouroboros maneouvre
	if object_in_path is SnakePart:
		if object_in_path.state == States.ALIVE:
			match object_in_path.part_type:
				PartTypes.BODY:
					lose_game()
				PartTypes.TAIL:
					ouroboros()
		else:
			lose_game()
	if object_in_path is Fruit:
		queued_growth += object_in_path.growth_amount
		object_in_path.respawn()

func extend() -> void:
	
	# Remove old connection to previous next part
	on_movement.disconnect(next_part.process_movement)
	
	# Set up a body part to be in the chain
	var new_body = snake_part_obj.instantiate()
	new_body.part_type = PartTypes.BODY
	new_body.position = (position).snapped(Vector2.ONE * TILE_SIZE)
	new_body.colour_index = colour_index
	get_parent().add_child(new_body)
	on_movement.connect(new_body.process_movement)
	new_body.on_movement.connect(next_part.process_movement)
	on_death.connect(new_body.lose_game)
	on_ouroboros.connect(new_body.ouroboros)
	new_body.next_part = next_part
	
	# Set direction of new part
	new_body.current_direction = current_direction
	new_body.old_direction = old_direction
	
	# Update reference to next part
	next_part = new_body
	
	# Mark the next movement to not propagate the movement down the chain
	skip_next_move_propagation = true
	
	# Mark the growth step as done
	queued_growth -= 1
	

func lose_game(from_lack_of_space : bool = false) -> void:
	
	# You lose!
	if !from_lack_of_space:
		state = States.DEAD
		on_death.emit()
	game_over_menu.visible = true

func ouroboros() -> void:
	if state == States.OUROBOROS:
		state = States.OLD_OUROBOROS
	elif state == States.ALIVE:
		state = States.OUROBOROS
	if part_type == PartTypes.TAIL:
		update_sprite()
	on_ouroboros.emit()
	
	# The head can spawn a new snake
	if part_type == PartTypes.HEAD and state == States.OUROBOROS:
		GameManager.score += 1
		remove_from_group("Head")
		get_tree().get_first_node_in_group("GameClockPause").start(1)
		get_tree().get_first_node_in_group("GameClock").stop()
		spawn_new_snake()

func spawn_new_snake() -> void:
	
	# Find out where we can spawn a snake
	#var start = Time.get_ticks_usec()
	var all_spawn_positions : Array[Vector2] = generate_spawn_grid().duplicate()
	#var end = Time.get_ticks_usec()
	#var worker_time = (end-start)/1000000.0
	#print(worker_time)
	
	# We cannot spawn too close to a snake on the left, so add a condition
	var spawn_positions : Array[Vector2] = []
	for pos in all_spawn_positions:
		if (pos + Vector2.LEFT * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE) in all_spawn_positions and \
			(pos + Vector2.LEFT * TILE_SIZE * 2).snapped(Vector2.ONE * TILE_SIZE) in all_spawn_positions:
			spawn_positions.append(pos)
	
	var slightly_preferred_spawns : Array[Vector2] = []
	for pos in spawn_positions:
		if (pos + Vector2.RIGHT * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE) in all_spawn_positions:
			slightly_preferred_spawns.append(pos)
	
	var most_preferred_spawns : Array[Vector2] = []
	for pos in slightly_preferred_spawns:
		if (pos + Vector2.UP * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE) in all_spawn_positions and \
			(pos + Vector2.DOWN * TILE_SIZE).snapped(Vector2.ONE * TILE_SIZE) in all_spawn_positions:
			most_preferred_spawns.append(pos)
	
	if !most_preferred_spawns.is_empty():
		spawn_positions = most_preferred_spawns
	elif !slightly_preferred_spawns.is_empty():
		spawn_positions = slightly_preferred_spawns
	
	# If there is no room to spawn a snake, we lose
	if spawn_positions.is_empty():
		lose_game(true)
		return
	
	# Pick a position
	var chosen_position : Vector2 = spawn_positions.pick_random()
	
	# Spawn in the new snake
	var new_head = snake_part_obj.instantiate()
	new_head.position = chosen_position.snapped(Vector2.ONE * TILE_SIZE)
	new_head.colour_index = colour_index + 1
	new_head.on_ouroboros.connect(ouroboros)
	get_parent().add_child(new_head)

func _unhandled_input(event: InputEvent) -> void:
	
	# Change direction if we are the head
	if event is InputEventKey and part_type == PartTypes.HEAD:
		for dir in inputs.keys():
			if event.is_action_pressed(dir):
				current_direction = inputs[dir]


# If we leave the arena
#func _on_level_area_exited(_area: Area2D) -> void:
	#lose_game()
