-- LUALOCALS < ---------------------------------------------------------
local core, math, next, pairs, setmetatable, string
    = core, math, next, pairs, setmetatable, string
local math_ceil, math_cos, math_pi, math_random, math_sin,
      string_format
    = math.ceil, math.cos, math.pi, math.random, math.sin,
      string.format
-- LUALOCALS > ---------------------------------------------------------

------------------------------------------------------------------------
-- Standard visualization helpers for AP Items, providing a pseudo-3D
-- model to display AP Items in-world. These are ephemeral, not stored
-- in the map, and destroyed when the game is shut down. They are not
-- pointable or tangible. The idea is that game-specific mods define
-- things (entities, nodes, etc) tangibly representing AP Items, and
-- can just make those things invisible and use this model, both
-- simplifying implementation and keeping visuals standard.
------------------------------------------------------------------------

local env = ...
local include = env.include

-- export table of functions
local itemvis = {}

------------------------------------------------------------------------

local modname = core.get_current_modname()
local util = include("util")
local deepcopy = util.deepcopy
local tau = math_pi * 2

-- Fine-tune the size/distance of balls for intended appearance
local ballsize = 0.5
local balldist = 0.3
local explodevel = 3

-- Define the entity for drawing each ball
local entname = modname .. ":apitem"
core.register_entity(entname, {
		initial_properties = {
			visual_size = {x = ballsize, y = ballsize, z = ballsize},
			visual = "sprite",
			glow = 4,
			pointable = false,
			static_save = false,
		},
		explode = function(self)
			local pos = self.object:get_pos()
			if pos then
				core.add_particlespawner({
						time = 0.05,
						amount = 20,
						exptime = {
							min = 0.1,
							max = 1,
							bias = 1,
						},
						size = 2,
						minvel = vector.new(-explodevel, -explodevel, -explodevel),
						maxvel = vector.new(explodevel, explodevel, explodevel),
						texture = {
							name = self.object:get_properties().textures[1],
							scale_tween = {{x = 1, y = 1}, {x = 0, y = 0}},
						},
						pos = pos,
						drag = 1,
						collisiondetection = true,
						bounce = 0.5,
					})
			end
			self.object:remove()
		end
	})

-- Keep track of all extant instances of the visualization objects, so
-- we can iterate/patrol over them and keep them in sync.
local allinstances = {}

------------------------------------------------------------------------
-- Class Definition

local prototype = {}

-- Destroy the visual model
function prototype:remove()
	allinstances[self] = nil
	if self.ents then
		for _, luaent in pairs(self.ents) do
			luaent.object:remove()
		end
		self.ents = nil
	end
end

-- Destroy the visual model with an "exploding" visual effect,
-- useful for when the item has been collected.
function prototype:explode()
	if self.ents then
		for _, luaent in pairs(self.ents) do
			luaent:explode()
		end
		self.ents = nil
	end
	return self:remove()
end

-- Allow the model to be moved, e.g. to track a moving entity
function prototype:move_to(pos, ...)
	local rel = vector.subtract(pos, self.pos)
	self.pos = pos
	if self.ents then
		-- Move each ball independently
		for _, luaent in pairs(self.ents) do
			local obj = luaent.object
			local ep = obj:get_pos()
			if ep then
				obj:move_to(vector.add(ep, rel))
			end
		end
	end
end

function prototype:check_regen()
	-- Check if the thing is still valid, and destroy it if not
	if self.check and not self:check() then
		return self:remove()
	end

	-- Check to make sure all 6 of the associated ball sprites is still
	-- present in the world, and if not, recreate the missing ones.
	-- Ball sprites can be destroyed by moving outside of loaded areas.
	if not self.ents then self.ents = {} end
	if not self.rtheta then self.rtheta = math_random() * tau end
	if not self.ctheta then self.ctheta = math_random() * tau end
	for i = 1, 6 do
		if not (self.ents[i] and self.ents[i].object
			and self.ents[i].object:get_pos()) then
			local dtheta = tau * i / 6
			local p = vector.offset(self.pos,
				math_cos(self.rtheta + dtheta) * balldist,
				0,
				math_sin(self.rtheta + dtheta) * balldist)
			local obj = core.add_entity(p, entname)
			if obj then
				-- Generate rainbow colors using sine waves with phase shifts
				local bt = self.ctheta + dtheta
				local r = math_sin(bt) * 63 + 160
				bt = bt + tau / 3
				local g = math_sin(bt) * 63 + 160
				bt = bt + tau / 3
				local b = math_sin(bt) * 63 + 160
				local color = string_format("#%02x%02x%02x",
					math_ceil(r), math_ceil(g), math_ceil(b))
				obj:set_properties({
						textures = {"arclib_ball.png^[multiply:" .. color},
					})
				luaent = obj:get_luaentity()
				self.ents[i] = luaent
				luaent.parent = self
			end
		end
	end
end

-- Constructor
-- options = table containing:
-- pos = required, center position for visual
-- check = optional, auto-destroy if this returns non-truthy
-- (other custom values allowed, but may collide with future arclib uses)
local function add(options)
	local self = deepcopy(options)
	setmetatable(self, {__index = prototype})
	allinstances[self] = true
	self:check_regen()
	return self
end
itemvis.add = add

-- Find all instances of itemvis inside a radius and return
-- them as an array.
local function inside_radius(pos, radius)
	local seen = {}
	local array = {}
	for obj in core.objects_inside_radius(pos, radius) do
		local ent = obj:get_luaentity()
		if ent and ent.name == entname and ent.parent and ent.parent.pos then
			local diff = vector.subtract(pos, ent.parent.pos)
			local dsqr = vector.dot(diff, diff)
			if dsqr <= radius * radius then
				if not seen[ent.parent] then
					array[#array + 1] = ent.parent
				end
				seen[ent.parent] = true
			end
		end
	end
	return array
end
itemvis.inside_radius = inside_radius

------------------------------------------------------------------------
-- Check/Patrol Timer

-- Function to trigger a check automatically. Runs on a 1 second timer,
-- and triggering a check earlier will shift future scheduled runs.
do
	local checkjob
	local function check_all()
		if checkjob then checkjob:cancel() end
		checkjob = core.after(1, check_all)

		-- Look for "stray" entities not associated with a valid itemvis
		for _, ent in pairs(core.luaentities) do
			if ent.name == entname and not (ent.parent and allinstances[ent.parent]) then
				ent.object:remove()
			end
		end

		-- Run self-check on all itemvis instances
		for vis in pairs(allinstances) do
			vis:check_regen()
		end
	end
	itemvis.check_all = check_all
	checkjob = core.after(0, check_all)
end

------------------------------------------------------------------------
-- Apply to Nodes

do
	local hashpos = core.hash_node_position
	local check = {}
	-- show visualization for a single node (for on_construct, after_destruct)
	-- pos = node position
	-- node = node table or nil to get current node
	-- if node does not now match (e.g. for after_destruct) then visual is
	-- removed immediately on next check
	local function node_apply(pos, node)
		node = node or core.get_node(pos)
		check[hashpos(pos)] = {pos, node}
	end
	itemvis.node_apply = node_apply
	-- register visualization applying to all matching nodes by ABM/LBMs
	-- nodenames = list of node names to apply visualization to
	local function node_register(nodenames)
		-- Detect when a matching node is loaded/activated and
		-- queue validation for it.
		core.register_lbm({
				name = ":" .. entname .. "_" .. core.sha1(core.serialize(nodenames)),
				nodenames = nodenames,
				run_at_every_load = true,
				action = node_apply
			})
		-- Periodically scan and queue validation for matching nodes
		-- in case any slip past other detection methods (ABMs are always
		-- guaranteed eventually-consistent)
		core.register_abm({
				interval = 1,
				chance = 1,
				nodenames = nodenames,
				action = node_apply
			})
	end
	itemvis.node_register = node_register
	-- Run queued checks in batch on the very next tick.
	core.register_globalstep(function()
			if not next(check) then return end

			-- We will consider a visualization as matching
			-- the given node if it has an exact position match
			local exist = {}
			for vis in pairs(allinstances) do
				if vis.applied_to_node and vis.check then
					if vis:check() then
						exist[hashpos(vector.round(vis.pos))] = vis
					else
						vis:remove()
					end
				end
			end

			-- Process pending node validation and recreate any
			-- missing item visuals.
			for k, v in pairs(check) do
				if not exist[k] then
					local pos = v[1]
					local nodename = v[2].name
					add({
							pos = pos,
							applied_to_node = nodename,
							check = function()
								return core.get_node(pos).name == nodename
							end
						})
				end
			end
		end)
end

------------------------------------------------------------------------

return itemvis
