---------
-- API --
---------

animalia.walkable_nodes = {}

minetest.register_on_mods_loaded(function()
	for name in pairs(minetest.registered_nodes) do
		if name ~= "air" and name ~= "ignore" then
			if minetest.registered_nodes[name].walkable then
				table.insert(animalia.walkable_nodes, name)
			end
		end
	end
end)

-- Math --

local abs = math.abs
local atan2 = math.atan2
local cos = math.cos
local deg = math.deg
local min = math.min
local pi = math.pi
local pi2 = pi * 2
local rad = math.rad
local random = math.random
local sin = math.sin
local sqrt = math.sqrt


local function diff(a, b) -- Get difference between 2 angles
	return atan2(sin(b - a), cos(b - a))
end

local function interp_angle(a, b, w)
	local cs = (1 - w) * cos(a) + w * cos(b)
	local sn = (1 - w) * sin(a) + w * sin(b)
	return atan2(sn, cs)
end

local function lerp_step(a, b, dtime, rate)
	return min(dtime * rate, abs(diff(a, b)) % (pi2))
end

local function clamp(val, _min, _max)
	if val < _min then
		val = _min
	elseif _max < val then
		val = _max
	end
	return val
end

-- Vector Math --

local vec_dir = vector.direction
local vec_add = vector.add
local vec_sub = vector.subtract
local vec_multi = vector.multiply
local vec_normal = vector.normalize
local vec_divide = vector.divide
local vec_len = vector.length

local dir2yaw = minetest.dir_to_yaw
local yaw2dir = minetest.yaw_to_dir

--------------
-- Settings --
--------------

local creative = minetest.settings:get_bool("creative_mode")

------------
-- Common --
------------

function animalia.get_average_pos(vectors)
	local sum = {x = 0, y = 0, z = 0}
	for _, vec in pairs(vectors) do sum = vec_add(sum, vec) end
	return vec_divide(sum, #vectors)
end

function animalia.correct_name(str)
	if str then
		if str:match(":") then str = str:split(":")[2] end
		return (string.gsub(" " .. str, "%W%l", string.upper):sub(2):gsub("_", " "))
	end
end

local correct_name = animalia.correct_name

---------------------
-- Local Utilities --
---------------------

local function activate_nametag(self)
	self.nametag = self:recall("nametag") or nil
	if not self.nametag then return end
	self.object:set_properties({
		nametag = self.nametag,
		nametag_color = "#FFFFFF"
	})
end

local animate_player = {}

if minetest.get_modpath("default")
and minetest.get_modpath("player_api") then
	animate_player = player_api.set_animation
elseif minetest.get_modpath("mcl_player") then
	animate_player = mcl_player.set_animation
end

-----------------------
-- Dynamic Animation --
-----------------------

function animalia.rotate_to_pitch(self)
	local rot = self.object:get_rotation()
	if self._anim == "fly" then
		local vel = vec_normal(self.object:get_velocity())
		local step = math.min(self.dtime * 5, abs(diff(rot.x, vel.y)) % (pi2))
		local n_rot = interp_angle(rot.x, vel.y, step)
		self.object:set_rotation({
			x = clamp(n_rot, -0.75, 0.75),
			y = rot.y,
			z = rot.z
		})
	elseif rot.x ~= 0 then
		self.object:set_rotation({
			x = 0,
			y = rot.y,
			z = rot.z
		})
	end
end

function animalia.move_head(self, tyaw, pitch)
	local data = self.head_data
	if not data then return end
	local yaw = self.object:get_yaw()
	local pitch_offset = data.pitch_correction or 0
	local bone = data.bone or "Head.CTRL"
	local _, rot = self.object:get_bone_position(bone)
	if not rot then return end
	local n_yaw = (tyaw ~= yaw and diff(tyaw, yaw) / 2) or 0
	if abs(deg(n_yaw)) > 45 then n_yaw = 0 end
	local dir = yaw2dir(n_yaw)
	dir.y = pitch or 0
	local n_pitch = (sqrt(dir.x^2 + dir.y^2) / dir.z)
	if abs(deg(n_pitch)) > 45 then n_pitch = 0 end
	if self.dtime then
		local yaw_w = lerp_step(rad(rot.z), tyaw, self.dtime, 3)
		n_yaw = interp_angle(rad(rot.z), n_yaw, yaw_w)
		local rad_offset = rad(pitch_offset)
		local pitch_w = lerp_step(rad(rot.x), n_pitch + rad_offset, self.dtime, 3)
		n_pitch = interp_angle(rad(rot.x), n_pitch + rad_offset, pitch_w)
	end
	local pitch_max = pitch_offset + 45
	local pitch_min = pitch_offset - 45
	self.object:set_bone_position(bone, data.offset,
		{x = clamp(deg(n_pitch), pitch_min, pitch_max), y = 0, z = clamp(deg(n_yaw), -45, 45)})
end

function animalia.head_tracking(self)
	if not self.head_data then return end
	-- Calculate Head Position
	local yaw = self.object:get_yaw()
	local pos = self.object:get_pos()
	if not pos then return end
	local y_dir = yaw2dir(yaw)
	local offset_h = self.head_data.pivot_h
	local offset_v = self.head_data.pivot_v
	pos = {
		x = pos.x + y_dir.x * offset_h,
		y = pos.y + offset_v,
		z = pos.z + y_dir.z * offset_h
	}
	local vel = self.object:get_velocity()
	if vec_len(vel) > 2 then
		self.head_tracking = nil
		animalia.move_head(self, yaw, 0)
		return
	end
	local player = self.head_tracking
	local plyr_pos = player and player:get_pos()
	if plyr_pos then
		plyr_pos.y = plyr_pos.y + 1.4
		local dir = vec_dir(pos, plyr_pos)
		local tyaw = dir2yaw(dir)
		if abs(diff(yaw, tyaw)) > pi / 10
		and self._anim == "stand" then
			self:turn_to(tyaw, 1)
		end
		animalia.move_head(self, tyaw, dir.y)
		return
	elseif self:timer(6)
	and random(4) < 2 then

		local players = creatura.get_nearby_players(self, 6)
		self.head_tracking = #players > 0 and players[random(#players)]
	end
	animalia.move_head(self, yaw, 0)

end

---------------
-- Utilities --
---------------

function animalia.alias_mob(old_mob, new_mob)
	minetest.register_entity(":" .. old_mob, {
		on_activate = function(self)
			local pos = self.object:get_pos()
			minetest.add_entity(pos, new_mob)
			self.object:remove()
		end,
	})
end

------------------------
-- Environment Access --
------------------------
function animalia.get_nearby_mate(self)
	local pos = self.object:get_pos()
	if not pos then return end
	local objects = creatura.get_nearby_objects(self, self.name)
	for _, object in ipairs(objects) do
		local obj_pos = object and object:get_pos()
		local ent = obj_pos and object:get_luaentity()
		if obj_pos
		and ent.gender ~= self.gender
		and ent.breeding then
			return object
		end
	end
end

function animalia.find_collision(self, dir)
	local pos = self.object:get_pos()
	local pos2 = vec_add(pos, vec_multi(dir, 16))
	local ray = minetest.raycast(pos, pos2, false, false)
	for pointed_thing in ray do
		if pointed_thing.type == "node" then
			return pointed_thing.under
		end
	end
	return nil
end

function animalia.random_drop_item(self, item, chance)
	local pos = self.object:get_pos()
	if not pos then return end
	if random(chance) < 2 then
		local object = minetest.add_item(pos, ItemStack(item))
		object:add_velocity({
			x = random(-2, 2),
			y = 1.5,
			z = random(-2, 2)
		})
	end
end

---------------
-- Particles --
---------------

function animalia.particle_spawner(pos, texture, type, min_pos, max_pos)
	type = type or "float"
	min_pos = min_pos or {
		x = pos.x - 2,
		y = pos.y - 2,
		z = pos.z - 2,
	}
	max_pos = max_pos or {
		x = pos.x + 2,
		y = pos.y + 2,
		z = pos.z + 2,
	}
	if type == "float" then
		minetest.add_particlespawner({
			amount = 16,
			time = 0.25,
			minpos = min_pos,
			maxpos = max_pos,
			minvel = {x = 0, y = 0.2, z = 0},
			maxvel = {x = 0, y = 0.25, z = 0},
			minexptime = 0.75,
			maxexptime = 1,
			minsize = 4,
			maxsize = 4,
			texture = texture,
			glow = 1,
		})
	elseif type == "splash" then
		minetest.add_particlespawner({
			amount = 6,
			time = 0.25,
			minpos = {x = pos.x - 7/16, y = pos.y + 0.6, z = pos.z - 7/16},
			maxpos = {x = pos.x + 7/16, y = pos.y + 0.6, z = pos.z + 7/16},
			minvel = {x = -1, y = 2, z = -1},
			maxvel = {x = 1, y = 5, z = 1},
			minacc = {x = 0, y = -9.81, z = 0},
			maxacc = {x = 0, y = -9.81, z = 0},
			minsize = 2,
			maxsize = 4,
			collisiondetection = true,
			texture = texture,
		})
	end
end

----------
-- Mobs --
----------

function animalia.protect_from_despawn(self)
	self._despawn = self:memorize("_despawn", false)
	self.despawn_after = self:memorize("despawn_after", false)
end

function animalia.set_nametag(self, clicker)
	local plyr_name = clicker and clicker:get_player_name()
	if not plyr_name then return end
	local item = clicker:get_wielded_item()
	if item
	and item:get_name() ~= "animalia:nametag" then
		return
	end
	local name = item:get_meta():get_string("name")
	if not name
	or name == "" then
		return
	end
	self.nametag = self:memorize("nametag", name)
	self.despawn_after = self:memorize("despawn_after", nil)
	activate_nametag(self)
	if not minetest.is_creative_enabled(plyr_name) then
		item:take_item()
		clicker:set_wielded_item(item)
	end
	return true
end


function animalia.initialize_api(self)
	self.gender = self:recall("gender") or nil
	if not self.gender then
		local genders = {"male", "female"}
		self.gender = self:memorize("gender", genders[random(2)])
		-- Reset Texture ID
		self.texture_no = nil
	end
	self.food = self:recall("food") or 0
	self.gotten = self:recall("gotten") or false
	self.breeding = false
	self.breeding_cooldown = self:recall("breeding_cooldown") or 0
	activate_nametag(self)
	if self.growth_scale then
		self:memorize("growth_scale", self.growth_scale) -- This is for spawning children
	end
	self.growth_scale = self:recall("growth_scale") or 1
	self:set_scale(self.growth_scale)
	local child_textures = self.growth_scale < 0.8 and self.child_textures
	local textures = (not child_textures and self[self.gender .. "_textures"]) or self.textures
	if child_textures then
		if not self.texture_no
		or self.texture_no > #child_textures then
			self.texture_no = random(#child_textures)
		end
		self:set_texture(self.texture_no, child_textures)
	elseif textures then
		if not self.texture_no then
			self.texture_no = random(#textures)
		end
		self:set_texture(self.texture_no, textures)
		return
	end
end

function animalia.step_timers(self)
	local breed_cd = self.breeding_cooldown or 30
	local trust_cd = self.trust_cooldown or 0
	self.breeding_cooldown = (breed_cd > 0 and breed_cd - self.dtime) or 0
	self.trust_cooldown = (trust_cd > 0 and trust_cd - self.dtime) or 0
	if self.breeding
	and self.breeding_cooldown <= 30 then
		self.breeding = false
	end
	self:memorize("breeding_cooldown", self.breeding_cooldown)
	self:memorize("trust_cooldown", self.trust_cooldown)
end

function animalia.do_growth(self, interval)
	if self.growth_scale
	and self.growth_scale < 0.9 then
		if self:timer(interval) then
			self.growth_scale = self.growth_scale + 0.1
			self:set_scale(self.growth_scale)
			if self.growth_scale < 0.8
			and self.child_textures then
				local tex_no = self.texture_no
				if not self.child_textures[tex_no] then
					tex_no = 1
				end
				self:set_texture(tex_no, self.child_textures)
			elseif self.growth_scale == 0.8 then
				if self.male_textures
				and self.female_textures then
					if #self.child_textures == 1 then
						self.texture_no = random(#self[self.gender .. "_textures"])
					end
					self:set_texture(self.texture_no, self[self.gender .. "_textures"])
				else
					if #self.child_textures == 1 then
						self.texture_no = random(#self.textures)
					end
					self:set_texture(self.texture_no, self.textures)
				end
			end
			self:memorize("growth_scale", self.growth_scale)
		end
	end
end

function animalia.random_sound(self)
	if self:timer(8)
	and random(4) < 2 then
		self:play_sound("random")
	end
end

function animalia.add_trust(self, player, amount)
	self.trust_cooldown = 60
	local plyr_name = player:get_player_name()
	local trust = self.trust[plyr_name] or 0
	if trust > 4 then return end
	self.trust[plyr_name] = trust + (amount or 1)
	self:memorize("trust", self.trust)
end

function animalia.feed(self, clicker, breed, tame)
	local yaw = self.object:get_yaw()
	local pos = self.object:get_pos()
	if not pos then return end
	local name = clicker:is_player() and clicker:get_player_name()
	local item, item_name = self:follow_wielded_item(clicker)
	if item_name then
		-- Eat Animation
		local offset_h = self.head_data.pivot_h
		local offset_v = self.head_data.pivot_v
		local head_pos = {
			x = pos.x + sin(yaw) * -offset_h,
			y = pos.y + offset_v,
			z = pos.z + cos(yaw) * offset_h
		}
		local def = minetest.registered_items[item_name]
		if def.inventory_image then
			minetest.add_particlespawner({
				pos = head_pos,
				time = 0.1,
				amount = 3,
				collisiondetection = true,
				collision_removal = true,
				vel = {min = {x = -1, y = 3, z = -1}, max = {x = 1, y = 4, z = 1}},
				acc = {x = 0, y = -9.8, z = 0},
				size = {min = 2, max = 4},
				texture = def.inventory_image
			})
		end
		-- Increase Health
		local feed_no = (self.feed_no or 0) + 1
		local max_hp = self.max_health
		local hp = self.hp
		hp = hp + (max_hp / 5)
		if hp > max_hp then hp = max_hp end
		self.hp = hp
		-- Tame/Breed
		if feed_no >= 5 then
			feed_no = 0
			if tame then
				self.owner = self:memorize("owner", name)
				minetest.add_particlespawner({
					pos = {min = vec_sub(pos, self.width), max = vec_add(pos, self.width)},
					time = 0.1,
					amount = 12,
					vel = {min = {x = 0, y = 3, z = 0}, max = {x = 0, y = 4, z = 0}},
					size = {min = 4, max = 6},
					glow = 16,
					texture = "creatura_particle_green.png"
				})
			end
			if breed
			and self.owner
			and self.owner == name then
				-- TODO: Breeding
			end
			self._despawn = self:memorize("_despawn", false)
			self.despawn_after = self:memorize("despawn_after", false)
		end
		self.feed_no = feed_no
		-- Take item
		if not minetest.is_creative_enabled(name) then
			item:take_item()
			clicker:set_wielded_item(item)
		end
		return true
	end
end

function animalia.mount(self, player, params)
	if not creatura.is_alive(player) then
		return
	end
	local plyr_name = player:get_player_name()
	if (player:get_attach()
	and player:get_attach() == self.object)
	or not params then
		player:set_detach()
		player:set_properties({
			visual_size = {
				x = 1,
				y = 1
			}
		})
		player:set_eye_offset()
		if minetest.get_modpath("player_api") then
			animate_player(player, "stand", 30)
			if player_api.player_attached then
				player_api.player_attached[plyr_name] = false
			end
		end
		self.rider = nil
		return
	end
	if minetest.get_modpath("player_api") then
		player_api.player_attached[plyr_name] = true
	end
	self.rider = player
	player:set_attach(self.object, "Torso", params.pos, params.rot)
	player:set_eye_offset({x = 0, y = 25, z = 0}, {x = 0, y = 15, z = 15})
	self:clear_utility()
	minetest.after(0.3, function()
		animate_player(player, "sit" , 30)
	end)
end

--------------
-- Spawning --
--------------

animalia.registered_biome_groups = {}

function animalia.register_biome_group(name, def)
	animalia.registered_biome_groups[name] = def
	animalia.registered_biome_groups[name].biomes = {}
end

local function assign_biome_group(name)
	local def = minetest.registered_biomes[name]
	local turf = def.node_top
	local heat = def.heat_point or 0
	local humidity = def.humidity_point or 50
	local y_min = def.y_min
	local y_max = def.y_max
	for group, params in pairs(animalia.registered_biome_groups) do -- k, v in pairs
		if name:find(params.name_kw or "")
		and turf and turf:find(params.turf_kw or "")
		and heat >= params.min_heat
		and heat <= params.max_heat
		and humidity >= params.min_humidity
		and humidity <= params.max_humidity
		and (not params.min_height or y_min >= params.min_height)
		and (not params.max_height or y_max <= params.max_height) then
			table.insert(animalia.registered_biome_groups[group].biomes, name)
		end
	end
end

minetest.register_on_mods_loaded(function()
	for name in pairs(minetest.registered_biomes) do
		assign_biome_group(name)
	end
end)

animalia.register_biome_group("temperate", {
	name_kw = "",
	turf_kw = "grass",
	min_heat = 45,
	max_heat = 70,
	min_humidity = 0,
	max_humidity = 50
})

animalia.register_biome_group("urban", {
	name_kw = "",
	turf_kw = "grass",
	min_heat = 0,
	max_heat = 100,
	min_humidity = 0,
	max_humidity = 100
})

animalia.register_biome_group("grassland", {
	name_kw = "",
	turf_kw = "grass",
	min_heat = 45,
	max_heat = 90,
	min_humidity = 0,
	max_humidity = 80
})

animalia.register_biome_group("boreal", {
	name_kw = "",
	turf_kw = "litter",
	min_heat = 10,
	max_heat = 55,
	min_humidity = 0,
	max_humidity = 80
})

animalia.register_biome_group("ocean", {
	name_kw = "ocean",
	turf_kw = "",
	min_heat = 0,
	max_heat = 100,
	min_humidity = 0,
	max_humidity = 100,
	max_height = 0
})

animalia.register_biome_group("swamp", {
	name_kw = "",
	turf_kw = "",
	min_heat = 55,
	max_heat = 90,
	min_humidity = 55,
	max_humidity = 90,
	max_height = 10,
	min_height = -20
})

animalia.register_biome_group("tropical", {
	name_kw = "",
	turf_kw = "litter",
	min_heat = 70,
	max_heat = 90,
	min_humidity = 65,
	max_humidity = 90
})

animalia.register_biome_group("cave", {
	name_kw = "under",
	turf_kw = "",
	min_heat = 0,
	max_heat = 100,
	min_humidity = 0,
	max_humidity = 100,
	max_height = 5
})

animalia.register_biome_group("common", {
	name_kw = "",
	turf_kw = "",
	min_heat = 25,
	max_heat = 75,
	min_humidity = 20,
	max_humidity = 80,
	min_height = 1
})