-----------------
-- Pathfinding --
-----------------

local a_star_alloted_time = tonumber(minetest.settings:get("waterdragon_a_star_alloted_time")) or 500
local theta_star_alloted_time = tonumber(minetest.settings:get("waterdragon_theta_star_alloted_time")) or 700

waterdragon.pathfinder = {}

local max_open = 300

-- Math

local floor = math.floor
local abs = math.abs

local vec_add, vec_dist, vec_new, vec_round = vector.add, vector.distance, vector.new, vector.round

local function vec_raise(v, n)
	return {x = v.x, y = v.y + n, z = v.z}
end

-- Heuristic

local function get_distance(start_pos, end_pos)
	local distX = abs(start_pos.x - end_pos.x)
	local distZ = abs(start_pos.z - end_pos.z)

	if distX > distZ then
		return 14 * distZ + 10 * (distX - distZ)
	else
		return 14 * distX + 10 * (distZ - distX)
	end
end

local function get_distance_to_neighbor(start_pos, end_pos)
	local distX = abs(start_pos.x - end_pos.x)
	local distY = abs(start_pos.y - end_pos.y)
	local distZ = abs(start_pos.z - end_pos.z)

	if distX > distZ then
		return (14 * distZ + 10 * (distX - distZ)) * (distY + 1)
	else
		return (14 * distX + 10 * (distZ - distX)) * (distY + 1)
	end
end

-- Blocked Movement Checks

local is_blocked = waterdragon.is_blocked

local function get_line_of_sight(a, b)
	local steps = floor(vec_dist(a, b))
	local line = {}

	for i = 0, steps do
		local pos

		if steps > 0 then
			pos = {
				x = a.x + (b.x - a.x) * (i / steps),
				y = a.y + (b.y - a.y) * (i / steps),
				z = a.z + (b.z - a.z) * (i / steps)
			}
		else
			pos = a
		end
		table.insert(line, pos)
	end

	if #line < 1 then
		return false
	else
		for i = 1, #line do
			local node = minetest.get_node(line[i])
			if waterdragon.get_node_def(node.name).walkable then
				return false
			end
		end
	end
	return true
end

local function is_on_ground(pos)
	local ground = {
		x = pos.x,
		y = pos.y - 1,
		z = pos.z
	}
	if waterdragon.get_node_def(ground).walkable then
		return true
	end
	return false
end

-- Neighbor Check Grids

local neighbor_grid = {
	{x = 1, y = 0, z = 0},
	{x = 1, y = 0, z = 1},
	{x = 0, y = 0, z = 1},
	{x = -1, y = 0, z = 1},
	{x = -1, y = 0, z = 0},
	{x = -1, y = 0, z = -1},
	{x = 0, y = 0, z = -1},
	{x = 1, y = 0, z = -1}
}

local neighbor_grid_climb = {
	{x = 1, y = 0, z = 0},
	{x = 1, y = 0, z = 1},
	{x = 0, y = 0, z = 1},
	{x = -1, y = 0, z = 1},
	{x = -1, y = 0, z = 0},
	{x = -1, y = 0, z = -1},
	{x = 0, y = 0, z = -1},
	{x = 1, y = 0, z = -1},

	{x = 0, y = 1, z = 0},
	{x = 0, y = -1, z = 0}
}

local neighbor_grid_3d = {
	-- Central
	{x = 1, y = 0, z = 0},
	{x = 0, y = 0, z = 1},
	{x = -1, y = 0, z = 0},
	{x = 0, y = 0, z = -1},
	-- Directly Up or Down
	{x = 0, y = 1, z = 0},
	{x = 0, y = -1, z = 0}
}

-- Get Neighbors

local function get_neighbors(pos, width, height, open, closed, parent, evaluated)
	local result = {}
	local neighbor
	local can_move
	local hashed_pos
	local step

	for i = 1, #neighbor_grid do
		neighbor = vec_add(pos, neighbor_grid[i])
		can_move = get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)
		hashed_pos = minetest.hash_node_position(neighbor)

		if parent
		and vec_dist(parent, neighbor) < vec_dist(pos, neighbor) then
			can_move = false
		end

		if open[hashed_pos]
		or closed[hashed_pos]
		or evaluated[hashed_pos] then
			can_move = false
		elseif can_move then
			can_move = not is_blocked(neighbor, width, height)

			if not can_move then -- Step Up
				step = vec_raise(neighbor, 1)
				can_move = not is_blocked(vec_round(step), width, height)
				neighbor = vec_round(step)
			else
				step = waterdragon.get_ground_level(vec_new(neighbor), 1)
				if step.y < neighbor.y
				and not is_blocked(vec_round(step), width, height) then
					neighbor = step
				end
			end
		end

		if can_move then
			table.insert(result, neighbor)
		end

		evaluated[hashed_pos] = true
	end
	return result
end

function waterdragon.pathfinder.get_neighbors_climb(pos, width, height, open, closed)
	local result = {}
	local neighbor
	local can_move
	local hashed_pos
	local step

	for i = 1, #neighbor_grid_climb do
		neighbor = vec_add(pos, neighbor_grid_climb[i])
		can_move = get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)
		hashed_pos = minetest.hash_node_position(neighbor)

		if open[hashed_pos]
		or closed[hashed_pos] then
			can_move = false
		elseif can_move then
			can_move = not is_blocked(neighbor, width, height)

			if not can_move then -- Step Up
				step = vec_raise(neighbor, 1)
				can_move = not is_blocked(vec_round(step), width, height)
				neighbor = vec_round(step)
			elseif i < 9 then
				step = waterdragon.get_ground_level(vec_new(neighbor), 1)
				if step.y < neighbor.y
				and not is_blocked(vec_round(step), width, height) then
					neighbor = step
				end
			end
		end

		if can_move then
			table.insert(result, neighbor)
		end
	end
	return result
end

function waterdragon.pathfinder.get_neighbors_fly(pos, width, height, open, closed, parent)
	local result = {}
	local neighbor
	local can_move
	local hashed_pos

	for i = 1, #neighbor_grid_3d do
		neighbor = vec_add(pos, neighbor_grid_3d[i])
		can_move = get_line_of_sight({x = pos.x, y = pos.y, z = pos.z}, neighbor)
		hashed_pos = minetest.hash_node_position(neighbor)

		if parent
		and vec_dist(parent, neighbor) < vec_dist(pos, neighbor) then
			can_move = false
		end

		if open[hashed_pos]
		or closed[hashed_pos] then
			can_move = false
		elseif can_move then
			can_move = not is_blocked(neighbor, width, height)
		end

		if can_move then
			table.insert(result, neighbor)
		end
	end
	return result, true
end

function waterdragon.pathfinder.get_neighbors_swim(pos, width, height, open, closed, parent)
	local result = {}
	local neighbor
	local can_move
	local hashed_pos

	for i = 1, #neighbor_grid_3d do
		neighbor = vec_add(pos, neighbor_grid_3d[i])
		can_move = get_line_of_sight({x = pos.x, y = pos.y, z = pos.z}, neighbor)
		hashed_pos = minetest.hash_node_position(neighbor)

		if (parent
		and vec_dist(parent, neighbor) < vec_dist(pos, neighbor))
		or waterdragon.get_node_def(neighbor).drawtype ~= "liquid" then
			can_move = false
		end

		if open[hashed_pos]
		or closed[hashed_pos] then
			can_move = false
		elseif can_move then
			can_move = not is_blocked(neighbor, width, height)
		end

		if can_move then
			table.insert(result, neighbor)
		end
	end
	return result, true
end

-- A*

function waterdragon.pathfinder.find_path(self, pos1, pos2, neighbor_func)
	local us_time = minetest.get_us_time()
	local check_vertical = false
	neighbor_func = neighbor_func or get_neighbors

	local start = self._path_data.start or {
		x = floor(pos1.x + 0.5),
		y = floor(pos1.y + 0.5),
		z = floor(pos1.z + 0.5)
	}
	local goal = {
		x = floor(pos2.x + 0.5),
		y = floor(pos2.y + 0.5),
		z = floor(pos2.z + 0.5)
	}

	self._path_data.start = start

	if goal.x == start.x
	and goal.z == start.z then -- No path can be found
		self._path_data = {}
		return
	end

	local openSet = self._path_data.open or {}
	local closedSet = self._path_data.closed or {}
	local evaluated = {}

	local start_index = minetest.hash_node_position(start)

	openSet[start_index] = {
		pos = start,
		parent = nil,
		gScore = 0,
		fScore = get_distance(start, goal)
	}

	local count = self._path_data.count or 1
	local current_id, current
	local adjacent
	local neighbor

	local temp_gScore
	local new_gScore
	local hCost

	local hashed_pos
	local parent_open
	local parent_closed

	while count > 0 do

		-- Initialize ID and data
		current_id, current = next(openSet)

		-- Find lowest f cost
		for i, v in pairs(openSet) do
			if v.fScore < current.fScore then
				current_id = i
				current = v
			end
		end

		if not current_id then self._path_data = {} return end -- failsafe

		-- Add lowest fScore to closedSet and remove from openSet
		openSet[current_id] = nil
		closedSet[current_id] = current

		if ((check_vertical or is_on_ground(goal))
		and current_id == minetest.hash_node_position(goal))
		or ((not check_vertical and not is_on_ground(goal))
		and goal.x == current.pos.x
		and goal.z == current.pos.z) then
			local path = {}
			local fail_safe = 0

			for _ in pairs(closedSet) do
				fail_safe = fail_safe + 1
			end

			repeat
				if not closedSet[current_id] then self._path_data = {} return end
				table.insert(path, closedSet[current_id].pos)
				current_id = closedSet[current_id].parent
			until current_id == start_index or #path >= fail_safe

			if not closedSet[current_id] then self._path_data = {} return nil end

			table.insert(path, closedSet[current_id].pos)

			local reverse_path = {}
			repeat table.insert(reverse_path, table.remove(path)) until #path == 0

			self._path_data = {}
			return reverse_path
		end

		parent_open = openSet[current.parent]
		parent_closed = closedSet[current.parent]
		adjacent, check_vertical = neighbor_func(
			current.pos,
			self.width,
			self.height,
			openSet,
			closedSet,
			(parent_closed and parent_closed.pos) or (parent_open and parent_open.pos),
			evaluated
		)
		-- Fly, Swim, and Climb all return true for check_vertical to properly check if goal has been reached

		-- Go through neighboring nodes
		for i = 1, #adjacent do
			neighbor = {
				pos = adjacent[i],
				parent = current_id,
				gScore = 0,
				fScore = 0
			}

			temp_gScore = current.gScore + get_distance_to_neighbor(current.pos, neighbor.pos)
			new_gScore = 0

			hashed_pos = minetest.hash_node_position(neighbor.pos)

			if openSet[hashed_pos] then
				new_gScore = openSet[hashed_pos].gScore
			end

			if (temp_gScore < new_gScore
			or not openSet[hashed_pos])
			and not closedSet[hashed_pos] then
				if not openSet[hashed_pos] then
					count = count + 1
				end

				hCost = get_distance_to_neighbor(neighbor.pos, goal)

				neighbor.gScore = temp_gScore
				neighbor.fScore = temp_gScore + hCost
				openSet[hashed_pos] = neighbor
			end
		end

		if minetest.get_us_time() - us_time > a_star_alloted_time then
			self._path_data = {
				start = start,
				open = openSet,
				closed = closedSet,
				count = count
			}
			return
		end

		if count > (max_open or 100) then
			self._path_data = {}
			return
		end
	end
end

-- Theta*

function waterdragon.pathfinder.find_path_theta(self, pos1, pos2, neighbor_func)
	local us_time = minetest.get_us_time()
	local check_vertical = false
	neighbor_func = neighbor_func or get_neighbors

	local start = self._path_data.start or {
		x = floor(pos1.x + 0.5),
		y = floor(pos1.y + 0.5),
		z = floor(pos1.z + 0.5)
	}
	local goal = {
		x = floor(pos2.x + 0.5),
		y = floor(pos2.y + 0.5),
		z = floor(pos2.z + 0.5)
	}

	self._path_data.start = start

	if goal.x == start.x
	and goal.z == start.z then -- No path can be found
		return
	end

	local openSet = self._path_data.open or {}
	local closedSet = self._path_data.closed or {}
	local evaluated = {}

	local start_index = minetest.hash_node_position(start)

	openSet[start_index] = {
		pos = start,
		parent = nil,
		gScore = 0,
		fScore = get_distance(start, goal)
	}

	local count = self._path_data.count or 1
	local current_id, current
	local current_parent
	local adjacent
	local neighbor

	local temp_gScore
	local new_gScore
	local hCost

	local hashed_pos
	local parent_open
	local parent_closed

	while count > 0 do

		-- Initialize ID and data
		current_id, current = next(openSet)

		-- Find lowest f cost
		for i, v in pairs(openSet) do
			if v.fScore < current.fScore then
				current_id = i
				current = v
			end
		end

		if not current_id then return end -- failsafe

		-- Add lowest fScore to closedSet and remove from openSet
		openSet[current_id] = nil
		closedSet[current_id] = current

		if ((check_vertical or is_on_ground(goal))
		and current_id == minetest.hash_node_position(goal))
		or ((not check_vertical and not is_on_ground(goal))
		and goal.x == current.pos.x
		and goal.z == current.pos.z) then
			local path = {}
			local fail_safe = 0

			for _ in pairs(closedSet) do
				fail_safe = fail_safe + 1
			end

			repeat
				if not closedSet[current_id] then return end
				table.insert(path, closedSet[current_id].pos)
				current_id = closedSet[current_id].parent
			until current_id == start_index or #path >= fail_safe

			if not closedSet[current_id] then self._path_data = {} return nil end

			table.insert(path, closedSet[current_id].pos)

			local reverse_path = {}
			repeat table.insert(reverse_path, table.remove(path)) until #path == 0

			self._path_data = {}
			return reverse_path
		end

		parent_open = openSet[current.parent]
		parent_closed = closedSet[current.parent]
		adjacent, check_vertical = neighbor_func(
			current.pos,
			self.width,
			self.height,
			openSet,
			closedSet,
			(parent_closed and parent_closed.pos) or (parent_open and parent_open.pos),
			evaluated
		)
		-- Fly, Swim, and Climb all return true for check_vertical to properly check if goal has been reached

		-- Go through neighboring nodes
		for i = 1, #adjacent do
			neighbor = {
				pos = adjacent[i],
				parent = current_id,
				gScore = 0,
				fScore = 0
			}

			hashed_pos = minetest.hash_node_position(neighbor.pos)

			if not openSet[hashed_pos]
			and not closedSet[hashed_pos] then
				current_parent = closedSet[current.parent] or closedSet[start_index]
				if not current_parent then
					current_parent = openSet[current.parent] or openSet[start_index]
				end

				if current_parent
				and get_line_of_sight(current_parent.pos, neighbor.pos) then
					temp_gScore = current_parent.gScore + get_distance_to_neighbor(current_parent.pos, neighbor.pos)
					new_gScore = 999

					if openSet[hashed_pos] then
						new_gScore = openSet[hashed_pos].gScore
					end

					if temp_gScore < new_gScore then
						hCost = get_distance_to_neighbor(neighbor.pos, goal)
						neighbor.gScore = temp_gScore
						neighbor.fScore = temp_gScore + hCost
						neighbor.parent = minetest.hash_node_position(current_parent.pos)
						openSet[hashed_pos] = neighbor
						count = count + 1
					end
				else
					temp_gScore = current.gScore + get_distance_to_neighbor(current_parent.pos, neighbor.pos)
					new_gScore = 999

					if openSet[hashed_pos] then
						new_gScore = openSet[hashed_pos].gScore
					end

					if temp_gScore < new_gScore then
						hCost = get_distance_to_neighbor(neighbor.pos, goal)
						neighbor.gScore = temp_gScore
						neighbor.fScore = temp_gScore + hCost

						openSet[hashed_pos] = neighbor
						count = count + 1
					end
				end
			end
		end

		if minetest.get_us_time() - us_time > theta_star_alloted_time then
			self._path_data = {
				start = start,
				open = openSet,
				closed = closedSet,
				count = count
			}
			return
		end

		if count > (max_open or 100) then
			self._path_data = {}
			return
		end
	end
end