local mod_name = minetest.get_current_modname()

-- Node replacements that emit light
-- Sets of lighting_node={ node=original_node, level=light_level }
local lighting_nodes = {}

-- The nodes that can be replaced with lighting nodes
-- Sets of original_node={ [1]=lighting_node_1, [2]=lighting_node_2, ... }
local lightable_nodes = {}

-- Prefixes used for each node so we can avoid overlap
-- Pairs of prefix=original_node
local lighting_prefixes = {}

-- node_name=true pairs of lightable nodes that are liquids and can flood some light sources
local lightable_liquids = {}

-- How often will the positions of lights be recalculated
local update_interval = 0.2

-- How long until a previously lit node should be updated - reduces flicker
local removal_delay = update_interval * 0.5

-- How often will a node attempt to check itself for deletion
local cleanup_interval = update_interval * 3

-- How far in the future will the position be projected based on the velocity
local velocity_projection = update_interval * 1

-- How many light levels should an item held in the hand be reduced by, compared to the placed node
-- does not apply to manually registered light levels
local level_delta = 2

-- item=light_level pairs of registered wielded lights
local shiny_items = {}

-- List of custom callbacks for each update step
local update_callbacks = {}
local update_player_callbacks = {}

-- position={id=light_level} sets of known about light sources and their levels by position
local active_lights = {}

--[[ Sets of entities being tracked, in the form:
entity_id = {
	obj = entity,
	items = {
		category_id..entity_id = {
			level = light_level,
			item? = item_name
		}
	},
	update = true | false,
	pos? = position_vector,
	offset? = offset_vector,
}
]]
local tracked_entities = {}

-- position=true pairs of positions that need to be recaculated this update step
local light_recalcs = {}

--[[
	Using 2-digit hex codes for categories
	Starts at 00, ends at FF
	This makes it easier extract `uid` from `cat_id..uid` by slicing off 2 characters
	The category ID must be of a fixed length (2 characters)
]]
local cat_id = 0
local cat_codes = {}
local function get_light_category_id(cat)
	-- If the category id does not already exist generate a new  one
	if not cat_codes[cat] then
		if cat_id >= 256 then
			error("Wielded item category limit exceeded, maximum 256 wield categories")
		end
		local code = string.format("%02x", cat_id)
		cat_id = cat_id+1
		cat_codes[cat] = code
	end
	-- If the category id does exist, return it
	return cat_codes[cat]
end

-- Log an error coming from this mod
local function error_log(message, ...)
	minetest.log("error", "[Wielded Light] " .. (message:format(...)))
end

-- Is a node lightable and a liquid capable of flooding some light sources
local function is_lightable_liquid(pos)
	local node = minetest.get_node_or_nil(pos)
	if not node then return end
	return lightable_liquids[node.name]
end

-- Check if an entity instance still exists in the world
local function is_entity_valid(entity)
	return entity and (entity.obj:is_player() or (entity.obj:get_luaentity() and entity.obj:get_luaentity().name) or false)
end

-- Check whether a node was registered by the wield_light mod
local function is_wieldlight_node(pos_vec)
	local name = string.sub(minetest.get_node(pos_vec).name, 1, #mod_name)
	return name == mod_name
end

-- Get the projected position of an entity based on its velocity, rounded to the nearest block
local function entity_pos(obj, offset)
	local velocity
	if (minetest.features.direct_velocity_on_players or not obj:is_player()) and obj.get_velocity then
		velocity = obj:get_velocity()
	else
		velocity = obj:get_player_velocity()
	end

	return wielded_light.get_light_position(
		vector.round(
			vector.add(
				vector.add(
					offset or { x=0, y=0, z=0 },
					obj:get_pos()
				),
				vector.multiply(
					velocity or { x=0, y=0, z=0 },
					velocity_projection
				)
			)
		)
	)
end

-- Add light to active light list and mark position for update
local function add_light(pos, id, light_level)
	if not active_lights[pos] then
		active_lights[pos] = {}
	end
	if active_lights[pos][id] ~= light_level then
		-- minetest.log("error", "add "..id.." "..pos.." "..tostring(light_level))
		active_lights[pos][id] = light_level
		light_recalcs[pos] = true
	end
end

-- Remove light from active light list and mark position for update
local function remove_light(pos, id)
	if not active_lights[pos] then return end
	-- minetest.log("error", "rem "..id.." "..pos)
	active_lights[pos][id] = nil
	minetest.after(removal_delay, function ()
		light_recalcs[pos] = true
	end)
end

-- Track an entity's position and update its light, will be called on every update step
local function update_entity(entity)
	local pos = entity_pos(entity.obj, entity.offset)
	local pos_str = pos and minetest.pos_to_string(pos)

	-- If the position has changed, remove the old light and mark the entity for update
	if entity.pos and pos_str ~= entity.pos then
		entity.update = true
		for id,_ in pairs(entity.items) do
			remove_light(entity.pos, id)
		end
	end

	-- Update the recorded position
	entity.pos = pos_str

	-- If the position is still loaded, pump the timer up so it doesn't get removed
	if pos then
		-- If the entity is marked for an update, add the light in the position if it emits light
		if entity.update then
			for id, item in pairs(entity.items) do
				if item.level > 0 and not (item.floodable and is_lightable_liquid(pos)) then
					add_light(pos_str, id, item.level)
				else
					remove_light(pos_str, id)
				end
			end
		end
	end
	if active_lights[pos_str] then
		if is_wieldlight_node(pos) then
			minetest.get_node_timer(pos):start(cleanup_interval)
		end
	end
	entity.update = false
end


-- Save the original nodes timer if it has one
local function save_timer(pos_vec)
	local timer = minetest.get_node_timer(pos_vec)
	if timer:is_started() then
		local meta = minetest.get_meta(pos_vec)
		meta:set_float("saved_timer_timeout", timer:get_timeout())
		meta:set_float("saved_timer_elapsed", timer:get_elapsed())
	end
end

-- Restore the original nodes timer if it had one
local function restore_timer(pos_vec)
	local meta = minetest.get_meta(pos_vec)
	local timeout = meta:get_float("saved_timer_timeout")
	if timeout > 0 then
		local elapsed = meta:get_float("saved_timer_elapsed")
		local timer = minetest.get_node_timer(pos_vec)
		timer:set(timeout, elapsed)
		meta:set_string("saved_timer_timeout","")
		meta:set_string("saved_timer_elapsed","")
	end
end

-- Replace a lighting node with its original counterpart
local function reset_lighting_node(pos)
	local existing_node = minetest.get_node(pos)
	local lighting_node = wielded_light.get_lighting_node(existing_node.name)
	if not lighting_node then
		return
	end
	minetest.swap_node(pos, { name = lighting_node.node,param2 = existing_node.param2 })
	restore_timer(pos)
end

-- Will be run once the node timer expires
local function cleanup_timer_callback(pos, elapsed)
	local pos_str = minetest.pos_to_string(pos)
	local lights = active_lights[pos_str]
	-- If no active lights for this position, remove itself
	if not lights then
		reset_lighting_node(pos)
	else
	-- Clean up any tracked entities for this position that no longer exist
		for id,_ in pairs(lights) do
			local uid = string.sub(id,3)
			local entity = tracked_entities[uid]
			if not is_entity_valid(entity) then
				remove_light(pos_str, id)
			end
		end
		minetest.get_node_timer(pos):start(cleanup_interval)
	end
end


-- Recalculate the total light level for a given position and update the light level there
local function recalc_light(pos)
	-- If not in active lights list we can't do anything
	if not active_lights[pos] then return end

	-- Calculate the light level of the node
	local any_light = false
	local max_light = 0
	for id, light_level in pairs(active_lights[pos]) do
		any_light = true
		if light_level > max_light then
			max_light = light_level
		end
	end

	-- Convert the position back to a vector
	local pos_vec = minetest.string_to_pos(pos)

	-- If no items in this position, delete it from the list and remove any light node
	if not any_light then
		active_lights[pos] = nil
		reset_lighting_node(pos_vec)
		return
	end

	-- If no light in this position remove any light node
	if max_light == 0 then
		reset_lighting_node(pos_vec)
		return
	end

	-- Limit the light level
	max_light = math.min(max_light, minetest.LIGHT_MAX)

	-- Get the current light level in this position
	local existing_node = minetest.get_node(pos_vec)
	local name = existing_node.name
	local old_value = wielded_light.level_of_lighting_node(name) or 0

	-- If the light level has changed, set the coresponding light node and initiate the cleanup timer
	if old_value ~= max_light then
		local node_name
		if lightable_nodes[name] then
			node_name = name
		elseif lighting_nodes[name] then
			node_name = lighting_nodes[name].node
		end
		if node_name then
			if not is_wieldlight_node(pos_vec) then
				save_timer(pos_vec)
			end

			minetest.swap_node(pos_vec, {
				name = lightable_nodes[node_name][max_light],
				param2 = existing_node.param2
			})
			minetest.get_node_timer(pos_vec):start(cleanup_interval)
		else
			active_lights[pos] = nil
		end
	end
end

local timer = 0
-- Will be run on every global step
local function global_timer_callback(dtime)
	-- Only run once per update interval, global step will be called much more often than that
	timer = timer + dtime;
	if timer < update_interval then
		return
	end
	timer = 0

	-- Run all custom player callbacks for each player
	local connected_players = minetest.get_connected_players()
	for _,callback in pairs(update_player_callbacks) do
		for _, player in pairs(connected_players) do
			callback(player)
		end
	end

	-- Run all custom callbacks
	for _,callback in pairs(update_callbacks) do
		callback()
	end

	-- Look at each tracked entity and update its position
	for uid, entity in pairs(tracked_entities) do
		if is_entity_valid(entity) then
			update_entity(entity)
		else
			-- If the entity no longer exists, stop tracking it
			tracked_entities[uid] = nil
		end
	end

	-- Recalculate light levels
	for pos,_ in pairs(light_recalcs) do
		recalc_light(pos)
	end
	light_recalcs = {}
end

--- Shining API ---
wielded_light = {}

-- Registers a callback to be called every time the update interval is passed
function wielded_light.register_lightstep(callback)
	table.insert(update_callbacks, callback)
end

-- Registers a callback to be called for each player every time the update interval is passed
function wielded_light.register_player_lightstep(callback)
	table.insert(update_player_callbacks, callback)
end

-- Returns the node name for a given light level
function wielded_light.lighting_node_of_level(light_level, prefix)
	return mod_name..":"..(prefix or "")..light_level
end

-- Gets the light level for a given node name, inverse of lighting_node_of_level
function wielded_light.level_of_lighting_node(node_name)
	local lighting_node = wielded_light.get_lighting_node(node_name)
	if lighting_node then
		return lighting_node.level
	end
end

-- Check if a node name is one of the wielded light nodes
function wielded_light.get_lighting_node(node_name)
	return lighting_nodes[node_name]
end

-- Register any node as lightable, register all light level variations for it
function wielded_light.register_lightable_node(node_name, property_overrides, custom_prefix)
	-- Node name must be string
	if type(node_name) ~= "string" then
		error_log("You must provide a node name to be registered as lightable, '%s' given.", type(node_name))
		return
	end

	-- Node must already be registered
	local original_definition = minetest.registered_nodes[node_name]
	if not original_definition then
		error_log("The node '%s' cannot be registered as lightable because it does not exist.", node_name)
		return
	end

	-- Decide the prefix for the lighting node
	local prefix = custom_prefix or node_name:gsub(":", "_", 1, true) .. "_"
	if lighting_prefixes[prefix] then
		error_log("The lighting prefix '%s' cannot be used for '%s' as it is already used for '%s'.", prefix, node_name, lighting_prefixes[prefix])
		return
	end
	lighting_prefixes[prefix] = node_name

	-- Default for property overrides
	if not property_overrides then property_overrides = {} end

	-- Copy the node definition and provide required settings for a lighting node
	local new_definition = table.copy(original_definition)
	new_definition.on_timer = cleanup_timer_callback
	new_definition.paramtype = "light"
	new_definition.mod_origin = mod_name
	new_definition.groups = new_definition.groups or {}
	new_definition.groups.not_in_creative_inventory = 1
	-- Make sure original node is dropped if a lit node is dug
	if not new_definition.drop then
		new_definition.drop = node_name
	end

	-- Allow any properties to be overridden on registration
	for prop, val in pairs(property_overrides) do
		new_definition[prop] = val
	end

	-- If it's a liquid, we need to stop it flowing
	if new_definition.groups.liquid then
		new_definition.liquid_range = 0
		lightable_liquids[node_name] = true
	end

	-- Register the lighting nodes
	lightable_nodes[node_name] = {}
	for i=1, minetest.LIGHT_MAX do
		local lighting_node_name = wielded_light.lighting_node_of_level(i, prefix)

		-- Index for quick finding later
		lightable_nodes[node_name][i] = lighting_node_name
		lighting_nodes[lighting_node_name] = {
			node = node_name,
			level = i
		}

		-- Copy the base definition and apply the light level
		local level_definition = table.copy(new_definition)
		level_definition.light_source = i

		-- If it's a liquid, we need to stop it replacing itself with the original
		if level_definition.groups.liquid then
			level_definition.liquid_alternative_source = lighting_node_name
			level_definition.liquid_alternative_flowing = lighting_node_name
		end

		minetest.register_node(":"..lighting_node_name, level_definition)
	end
end

-- Check if node can have a wielded light node placed in it
function wielded_light.is_lightable_node(node_pos)
	local name = minetest.get_node(node_pos).name
	if lightable_nodes[name] then
		return true
	elseif wielded_light.get_lighting_node(name) then
		return true
	end
	return false
end

-- Gets the closest position to pos that's a lightable node
function wielded_light.get_light_position(pos)
	local around_vector = {
		{x=0, y=0, z=0},
		{x=0, y=1, z=0}, {x=0, y=-1, z=0},
		{x=1, y=0, z=0}, {x=-1, y=0, z=0},
		{x=0, y=0, z=1}, {x=0, y=0, z=-1},
	}
	for _, around in ipairs(around_vector) do
		local light_pos = vector.add(pos, around)
		if wielded_light.is_lightable_node(light_pos) then
			return light_pos
		end
	end
end

-- Gets the emitted light level of a given item name
function wielded_light.get_light_def(item_name)
	-- Invalid item? No light
	if not item_name or item_name == "" then
		return 0, false
	end

	-- If the item is cached return the cached level
	local cached_definition = shiny_items[item_name]
	if cached_definition then
		return cached_definition.level, cached_definition.floodable
	end

	-- Get the item definition
	local stack = ItemStack(item_name)
	local itemdef = stack:get_definition()

	-- If invalid, no light
	if not itemdef then
		return 0, false
	end

	-- Get the light level of an item from its definition
	-- Reduce the light level by level_delta - original functionality
	-- Limit between 0 and the max light level
	return math.min(math.max((itemdef.light_source or 0) - level_delta, 0), minetest.LIGHT_MAX), itemdef.floodable
end

-- Register an item as shining
function wielded_light.register_item_light(item_name, light_level, floodable)
	if shiny_items[item_name] then
		if light_level then
			shiny_items[item_name].level = light_level
		end
		if floodable ~= nil then
			shiny_items[item_name].floodable = floodable
		end
	else
		if floodable == nil then
			local stack = ItemStack(item_name)
			local itemdef = stack:get_definition()
			floodable = itemdef.floodable
		end
		shiny_items[item_name] = {
			level = light_level,
			floodable = floodable or false
		}
	end
end

-- Mark an item as floodable or not
function wielded_light.register_item_floodable(item_name, floodable)
	if floodable == nil then floodable = true end
	if shiny_items[item_name] then
		shiny_items[item_name].floodable = floodable
	else
		local calced_level = wielded_light.get_light_def(item_name)
		shiny_items[item_name] = {
			level = calced_level,
			floodable = floodable
		}
	end
end

-- Keep track of an item entity. Should be called once for an item
function wielded_light.track_item_entity(obj, cat, item)
	if not is_entity_valid({ obj=obj }) then return end

	local light_level, light_is_floodable = wielded_light.get_light_def(item)
	-- If the item does not emit light do not track it
	if light_level <= 0 then return end

	-- Generate the uid for the item and the id for the light category
	local uid = tostring(obj)
	local id = get_light_category_id(cat)..uid

	-- Create the main tracking object for this item instance if it does not already exist
	if not tracked_entities[uid] then
		tracked_entities[uid] = { obj=obj, items={}, update = true }
	end

	-- Create the item tracking object for this item + category
	tracked_entities[uid].items[id] = { level=light_level, floodable=light_is_floodable }

	-- Add the light in on creation so it's immediate
	local pos = entity_pos(obj)
	local pos_str = pos and minetest.pos_to_string(pos)
	if pos_str then
		if not (light_is_floodable and is_lightable_liquid(pos)) then
			add_light(pos_str, id, light_level)
		end
	end
	tracked_entities[uid].pos = pos_str
end

-- A player's light should appear near their head not their feet
local player_height_offset = { x=0, y=1, z=0 }

-- Keep track of a user / player entity. Should be called as often as the user updates
function wielded_light.track_user_entity(obj, cat, item)
	-- Generate the uid for the player and the id for the light category
	local uid = tostring(obj)
	local id = get_light_category_id(cat)..uid

	-- Create the main tracking object for this player instance if it does not already exist
	if not tracked_entities[uid] then
		tracked_entities[uid] = { obj=obj, items={}, offset = player_height_offset, update = true }
	end

	local tracked_entity = tracked_entities[uid]
	local tracked_item = tracked_entity.items[id]

	-- If the item being tracked for the player changes, update the item tracking object for this item + category
	if not tracked_item or tracked_item.item ~= item then
		local light_level, light_is_floodable = wielded_light.get_light_def(item)
		tracked_entity.items[id] = { level=light_level, item=item, floodable=light_is_floodable }
		tracked_entity.update = true
	end
end


-- Setup --

-- Wielded item shining globalstep
minetest.register_globalstep(global_timer_callback)

-- Dropped item on_step override
-- https://github.com/minetest/minetest/issues/6909
local builtin_item = minetest.registered_entities["__builtin:item"]
local item = {
	on_step = function(self, dtime, ...)
		builtin_item.on_step(self, dtime, ...)
		-- Register an item once for tracking
		-- If it's already being tracked, exit
		if self.wielded_light then return end
		self.wielded_light = true
		local stack = ItemStack(self.itemstring)
		local item_name = stack:get_name()
		wielded_light.track_item_entity(self.object, "item", item_name)
	end
}
setmetatable(item, {__index = builtin_item})
minetest.register_entity(":__builtin:item", item)

-- Track a player's wielded item
wielded_light.register_player_lightstep(function (player)
	wielded_light.track_user_entity(player, "wield", player:get_wielded_item():get_name())
end)

-- Register helper nodes
wielded_light.register_lightable_node("air", nil, "")

if minetest.get_modpath("default") then
	wielded_light.register_lightable_node("default:water_source", nil, "water_")
	wielded_light.register_lightable_node("default:river_water_source", nil, "river_water_")
elseif minetest.get_modpath("hades_core") then
	wielded_light.register_lightable_node("hades_core:water_source", nil, "water_")
else
	error_log("Not running in a supported game, lightable water disabled")
end

---TEST
--wielded_light.register_item_light('default:dirt', 14)
