local S = minetest.get_translator("lzr_hook")

if not minetest.raycast then
	minetest.log("error", "lzr_hook requires Luanti version 5.0 or newer")
	return
end

local setting_hook_overlay

lzr_hook = {}

lzr_hook.update_hook_overlay_setting = function()
	setting_hook_overlay = minetest.settings:get_bool("lzr_hook_overlay", true)
end

lzr_hook.update_hook_overlay_setting()

local registered_after_rotates = {}
lzr_hook.register_after_rotate = function(callback)
	table.insert(registered_after_rotates, callback)
end
local call_rotate_callbacks = function(pos, new_node, player)
	for c=1, #registered_after_rotates do
		local callback = registered_after_rotates[c]
		callback(pos, new_node, player)
	end
end

local get_pointed = dofile(minetest.get_modpath("lzr_hook").."/pointed.lua")

-- Functions to choose rotation based on pointed location
local insanity_2 = {xy = 1, yz = 1, zx = 1; zy = -1, yx = -1, xz = -1} -- Don't worry about this
local function push_edge(normal, point)
	local biggest = 0
	local biggest_axis
	local normal_axis
	-- Find the normal axis, and the axis of the with the
	-- greatest magnitude (other than the normal axis)
	for axis in pairs(point) do
		if normal[axis] ~= 0 then
			normal_axis = axis
		elseif math.abs(point[axis])>biggest then
			biggest = math.abs(point[axis])
			biggest_axis = axis
		end
	end
	-- Find the third axis, which is the one to rotate around
	if normal_axis and biggest_axis then

		for axis in pairs(point) do
			if axis ~= normal_axis and axis ~= biggest_axis then
				-- Decide which direction to rotate (+ or -)
				return axis, insanity_2[normal_axis..biggest_axis] * math.sign(normal[normal_axis] * point[biggest_axis])
			end
		end
	end
	return "y", 0
end
local function rotate_face(normal, _)
	-- Find the normal axis
	for axis, value in pairs(normal) do
		if value ~= 0 then
			return axis, math.sign(value)
		end
	end
	return "y", 0
end

-- Numbers taken from https://forum.minetest.net/viewtopic.php?p=73195#p73195
-- "How to rotate (clockwise) by axis from any facedir:"
-- "(this will be made into a lua function)"
-- 5 years later...
local facedir_cycles = {
	x = {{12,13,14,15},{16,19,18,17},{ 0, 4,22, 8},{ 1, 5,23, 9},{ 2, 6,20,10},{ 3, 7,21,11}},
	y = {{ 0, 1, 2, 3},{20,23,22,21},{ 4,13,10,19},{ 8,17, 6,15},{12, 9,18, 7},{16, 5,14,11}},
	z = {{ 4, 5, 6, 7},{ 8,11,10, 9},{ 0,16,20,12},{ 1,17,21,13},{ 2,18,22,14},{ 3,19,23,15}},
}
local wallmounted_cycles = {
	x = {0, 4, 1, 5},
	y = {4, 2, 5, 3},
	z = {0, 3, 1, 2},
}
local wallmounted_cycle_rot90 = {
	[0] = 6,
	[1] = 7,
	[6] = 0,
	[7] = 1,
}

-- Functions to rotate a facedir/wallmounted/degrotate/4dir/... value around an axis by a certain amount
local rotate = {
	-- Facedir: lower 5 bits used for direction, 0 - 23
	facedir = function(param2, axis, amount)
		local facedir = param2 % 32
		for _, cycle in ipairs(facedir_cycles[axis]) do
			-- Find the current facedir
			-- Luanti adds table.indexof, but I refuse to use it because it returns -1 rather than nil
			for i, fd in ipairs(cycle) do
				if fd == facedir then
					return param2 - facedir + cycle[1+(i-1 + amount) % 4] -- If only Lua didn't use 1 indexing...
				end
			end
		end
		return param2
	end,
	-- Wallmounted: lower 3 bits used, 0 - 7
	wallmounted = function(param2, axis, amount)
		local cycle = function(param2, axis, amount, wallmounted)
			for i, wm in ipairs(wallmounted_cycles[axis]) do
				if wm == wallmounted then
					return param2 - wallmounted + wallmounted_cycles[axis][1+(i-1 + amount) % 4]
				end
			end
		end
		local wallmounted = param2 % 8
		-- Regular wallmounted
		local new_param2 = cycle(param2, axis, amount, wallmounted)
		if new_param2 then
			return new_param2
		end

		-- Special 90°-rotated wallmounted variant
		if axis == "y" and wallmounted_cycle_rot90[wallmounted] then
			return wallmounted_cycle_rot90[wallmounted]
		elseif axis == "x" or axis == "z" then
			if wallmounted >= 6 then
				local new_wallmounted = wallmounted_cycle_rot90[wallmounted]
				local div_param2 = math.floor(param2 / 8)
				local mod_param2 = wallmounted_cycle_rot90[param2 % 8]
				if not mod_param2 then
					mod_param2 = param2 % 8
				end
				local cycle_param2 = div_param2 + mod_param2
				new_param2 = cycle(cycle_param2, axis, amount, new_wallmounted)
				if new_param2 then
					return new_param2
				end
			end
		end
		return param2
	end,
	-- Degrotate: 0-239
	degrotate = function(param2, axis, amount)
		return (param2 - amount) % 240
	end,
	-- 4dir: 0-3
	["4dir"] = function(param2, axis, amount)
		return (param2 + amount) % 4
	end,
}
local rotate_with_color = function(rotate_function, base_size)
	return function(param2, axis, amount)
		local base = math.floor(param2 / base_size)
		local dir = param2 % base_size
		dir = rotate_function(dir, axis, amount)
		return base * base_size + dir
	end
end
rotate.color4dir = rotate_with_color(rotate["4dir"], 4)
rotate.colorfacedir = rotate_with_color(rotate["facedir"], 32)
rotate.colorwallmounted = rotate_with_color(rotate["wallmounted"], 8)

local function rect(angle, radius)
	return math.cos(2*math.pi * angle) * radius, math.sin(2*math.pi * angle) * radius
end

-- Generate the hook particle effects
local other_axes = {x = {"y","z"}, y = {"z","x"}, z = {"x","y"}}
local function particle_ring(pos, axis, direction, roll)
	local axis2, axis3 = unpack(other_axes[axis])
	local particle_pos = vector.new()
	local particle_vel = vector.new()
	local tex
	if roll then
		tex = "lzr_hook_rotate_roll_particle.png"
	else
		tex = "lzr_hook_rotate_particle.png"
	end
	for i = 0, 0.999, 1/6 do
		particle_pos[axis3], particle_pos[axis2] = rect(i, 0.5^0.5)
		particle_vel[axis3], particle_vel[axis2] = rect(i - 1/4 * direction, 2)

		minetest.add_particlespawner({
			amount = 1,
			time = 0.001,
			pos = vector.add(pos, particle_pos),
			vel = particle_vel,
			acc = vector.multiply(particle_pos, -7),
			exptime = 0.3,
			size = 2,
			texture = {
				name = tex,
				alpha_tween = { 1, 0, start = 0.8 },
			},
		})
	end
end

local function play_rotate_sound(def, pos)
	if def.sounds and def.sounds._rotate then
		if def.sounds._rotate then
			minetest.sound_play(def.sounds._rotate, {pos=pos}, true)
		end
	else
		minetest.sound_play({name="lzr_hook_rotate", gain=1}, {pos=pos}, true)
	end
end

-- Main
-- Idea: split this into 2 functions
-- 1: on_use parameters -> axis/amount/etc.
-- 2: param2/axis/amount/etc. -> new param2
function lzr_hook.use(itemstack, player, pointed_thing, is_right_click)
	-- Object interaction takes precedence
	if pointed_thing.type == "object" then
		local obj = pointed_thing.ref
		local ent = obj:get_luaentity()
		if ent then
			if not is_right_click and ent.on_punch then
				local dir = vector.direction(player:get_pos(), obj:get_pos())
				ent:on_punch(player, 1000000, itemstack:get_tool_capabilities(), dir)
			elseif is_rightclick and ent.right_click then
				ent:right_click(player)
			end
		end
		return
	end
	if pointed_thing.type ~= "node" then
		return
	end
	local gs = lzr_gamestate.get_state()
	if gs == lzr_gamestate.LEVEL_COMPLETE or gs == lzr_gamestate.LEVEL_TEST then
		return
	end
	if pointed_thing.type ~= "node" then
		return
	end
	local pos = pointed_thing.under

	-- Get node info
	local node = minetest.get_node_or_nil(pos)
	if not node then
		return
	end
	local def = minetest.registered_nodes[node.name]
	if not def then
		return
	end

	local rotatable = minetest.get_item_group(node.name, "rotatable")
	local gs = lzr_gamestate.get_state()
	-- Node MUST have 'rotatable' group
	if rotatable <= 0 then
		return itemstack
	-- rotatable=3: rotatable in editor/dev mode only
	elseif rotatable == 3 then
		if not (gs == lzr_gamestate.EDITOR or gs == lzr_gamestate.DEV) then
			-- Rotation denied
			local g_screws = minetest.get_item_group(node.name, "screws")
			if g_screws == 2 then
				-- Play sound when node has iron screws
				minetest.sound_play({name="lzr_hook_rotate_denied", gain=0.3}, {pos=pos}, true)
			end
			return itemstack
		end
	end
	-- rotatable=1: always rotatable

	-- Check protection
	local player_name = player:get_player_name()
	if minetest.is_protected(pos, player_name) then
		minetest.record_protection_violation(pos, player_name)
		return
	end

	local control = player:get_player_control()
	local rotate_reverse = control.sneak == true

	-- Choose rotation axis/direction and param2 based on click type and pointed location
	local axis, amount
	local normal, point = get_pointed(player, pointed_thing)
	if not normal or vector.length(normal) == 0 then
		-- Raycast failed or player is inside selection box
		return
	end


	if def._lzr_on_rotate then
		local rotate_type
		local axis, amount
		if is_right_click then
			rotate_type = "rotate_face"
			axis, amount = rotate_face(normal, point)
		else
			rotate_type = "push_edge"
			axis, amount = push_edge(normal, point)
		end
		if rotate_reverse then
			amount = -amount
		end

		--[[ CALLBACK FUNCTION:

			_lzr_on_rotate(pos, node, rotate_axis, rotate_dir)

		If this function is set in the node definition, it
		overrides the default rotation behavior, allowing you
		to specify a custom behavior.
		group MUST still be set for this to work, or else
		this function will be ignored.

		If a normal node is being rotated, you can set the new node
		in this function directly. If the node is a laser block,
		you must instead pass the new node table in the return value.

		Params:
		* pos: node position
		* node: node table
		* rotate_axis: axis that will be rotated ("x", "y" or "z")
		* rotate_dir: rotation direction (1 = clockwise, -1 = counter-clockwise)

		Returns: <rotated>, <deferred_node>
		* <rotated>: true if rotation was successful. On successful
		  rotation, a rotation sound and particles will appear.
		  false if rotation was unsuccessful or is forbidden
		* <deferred_node>: (optional) if set, this will force a full laser update
		  where this argument is a node table you want to be set at pos. Required
		  if node is a laser block
		]]
		local rotated, deferred_node = def._lzr_on_rotate(table.copy(pos), table.copy(node), axis, amount)

		if rotated then
			-- If rotated_pos and rotated_node are set, this forces a laser update
			-- and the node change is deferred to the lzr_laser mod
			if lzr_gamestate.get_state() == lzr_gamestate.DEV then
				-- rotate node directly in DevMode
				if deferred_node then
					minetest.set_node(pos, deferred_node)
				else
					minetest.set_node(pos, node)
				end
			elseif deferred_node and not lzr_laser.get_lasers_frozen() then
				lzr_laser.full_laser_update_if_needed({rotated_pos=table.copy(pos), rotated_node=table.copy(deferred_node)})
			end

			-- Calculate particle position
			local particle_offset = vector.new()
			particle_offset[axis] = point[axis]

			-- Draw particles
			particle_ring(vector.add(pos, particle_offset), axis, math.sign(amount), is_right_click)

			play_rotate_sound(def, pos)

			-- Call callback functions
			local new_node
			if deferred_node then
				new_node = deferred_node
			else
				new_node = minetest.get_node(pos)
			end
			call_rotate_callbacks(pos, new_node, player)
		end
		return itemstack
	end


	-- Choose rotation function based on paramtype2 (facedir/wallmounted/degrotate/4dir/...)
	local rotate_function = rotate[def.paramtype2]
	if not rotate_function then
		return
	end


	local action
	if def.paramtype2 == "degrotate" or def.paramtype2 == "4dir" or def.paramtype2 == "color4dir" then
		axis, amount = push_edge(normal, point)
		if axis ~= "y" and is_right_click then
			if pointed_thing.under.y < pointed_thing.above.y then
				axis = "y"
				amount = 1
			elseif pointed_thing.under.y > pointed_thing.above.y then
				axis = "y"
				amount = -1
			else
				return
			end
		elseif axis == "y" and not is_right_click then
			-- no-op
		else
			return
		end
	elseif is_right_click then
		axis, amount = rotate_face(normal, point)
	else
		axis, amount = push_edge(normal, point)
	end
	if rotate_reverse then
		if def.paramtype2 ~= "degrotate" then
			amount = -amount
		end
	else
		if def.paramtype2 == "degrotate" then
			amount = amount * 10
		end
	end
	local new_param2 = rotate_function(node.param2, axis, amount)

	-- Calculate particle position
	local particle_offset = vector.new()
	particle_offset[axis] = point[axis]

	-- Draw particles
	particle_ring(vector.add(pos, particle_offset), axis, math.sign(amount), is_right_click)

	-- Play sound
	play_rotate_sound(def, pos)

	-- Configure the new node
	if new_param2 == node.param2 then
 		-- no rotation was done
		return
	end
	node.param2 = new_param2

	-- Update the node.
	-- There are two possibilities ...
	local lasers_updated = false
	if minetest.get_item_group(node.name, "laser_block") ~= 0 and not lzr_laser.get_lasers_frozen() then
		-- If the node change requires a laser update, we do *NOT* set the node directly,
		-- but defer the new node information to the laser update routine in the VManip.
		-- This is done to avoid potential side effects.
		lasers_updated = lzr_laser.full_laser_update_if_needed({rotated_pos=pos, rotated_node=node})
		call_rotate_callbacks(pos, node, player)
	end
	if not lasers_updated then
		-- If no laser update was neccessary, we can set the node directly.
		minetest.swap_node(pos, node)
		call_rotate_callbacks(pos, node, player)
	end
end

minetest.register_tool("lzr_hook:hook",{
	description = S("Rotating Hook"),
	_tt_help = S("Punch to push edge, place to rotate face").."\n"..
		S("Sneak to reverse rotation direction"),
	inventory_image = "lzr_hook_hook.png",
	-- Ensure infinite durability if used as digging tool by automation
	tool_capabilities = {
		full_punch_interval = 0,
		punch_attack_uses = 0,
	},
	on_use = function(itemstack, player, pointed_thing)
		return lzr_hook.use(itemstack, player, pointed_thing, false)
	end,
	on_place = function(itemstack, player, pointed_thing)
		return lzr_hook.use(itemstack, player, pointed_thing, true)
	end,
})

local current_hook_overlay
local current_hook_overlay_pos
local current_hook_overlay_side
local current_hook_overlay_rot
local current_hook_overlay_box

minetest.register_entity("lzr_hook:overlay", {
	initial_properties = {
		physical = false,
		collide_with_objects = false,
		visual = "upright_sprite",
		selectionbox = { -0.5, -0.5, -1/64, 0.5, 0.5, 1/64 },
		textures = { "lzr_hook_overlay.png", "lzr_hook_overlay.png" },
		use_texture_alpha = true,
		static_save = false,
		shaded = false,
		pointable = false,
	},
	on_activate = function(self)
		self.object:set_armor_groups({immortal=1})
		if current_hook_overlay and current_hook_overlay.object:is_valid() then
			current_hook_overlay.object:remove()
		end
		current_hook_overlay = self
	end,
	on_deactivate = function(self)
		current_hook_overlay = nil
		current_hook_overlay_pos = nil
		current_hook_overlay_side = nil
		current_hook_overlay_rot = nil
		current_hook_overlay_box = nil
	end,
})

local get_pointed_node_thing = function(player)
	local props = player:get_properties()
	local pos = player:get_pos()
	local eye_pos = vector.new(pos.x, pos.y+props.eye_height, pos.z)
	local look_dir = player:get_look_dir()

	-- get hand range
	local range = 4
	local inv = player:get_inventory()
	local hand = inv:get_stack("hand", 1)
	local hdef
	if hand:is_empty() then
		hdef = minetest.registered_items[""]
	else
		hdef = hand:get_definition()
	end
	if hdef and hdef.range then
		range = hdef.range
	end

	local look_target = vector.add(eye_pos, vector.multiply(look_dir, range))
	local ray = Raycast(eye_pos, look_target, true, false)
	for pt in ray do
		-- non-player object
		if pt.type == "object" and not pt.ref:is_player() then
			return
		-- node
		elseif pt.type == "node" then
			return pt
		end
	end
end

local remove_all_overlays = function()
	if current_hook_overlay and current_hook_overlay.object:is_valid() then
		current_hook_overlay.object:remove()
		current_hook_overlay = nil
		current_hook_overlay_pos = nil
		current_hook_overlay_side = nil
		current_hook_overlay_rot = nil
		current_hook_overlay_box = nil
	end
end

local htimer = 0
minetest.register_globalstep(function(dtime)
	if not setting_hook_overlay then
		return
	end
	-- Reduce load slightly
	htimer = htimer + dtime
	if htimer < 0.05 then
		return
	end
	htimer = 0

	local gs = lzr_gamestate.get_state()
	if gs == lzr_gamestate.LEVEL_COMPLETE or lzr_gamestate.is_loading() then
		remove_all_overlays()
		return
	end

	local players = minetest.get_connected_players()

	for p=1, #players do
		local player = players[p]
		local witem = player:get_wielded_item()
		local can_show = false

		local pt
		local opos, oside, orot
		local pnode
		if witem:get_name() == "lzr_hook:hook" then
			pt = get_pointed_node_thing(player)
			if pt and pt.type == "node" then
				pnode = minetest.get_node(pt.under)
				local rotatable = minetest.get_item_group(pnode.name, "rotatable")
				if rotatable <= 0 then
					can_show = false
				elseif rotatable == 3 then
					if (gs == lzr_gamestate.EDITOR or gs == lzr_gamestate.DEV) then
						can_show = true

					else
						can_show = false
					end
				elseif rotatable == 1 then
					can_show = true
				end
				if can_show then
					opos = table.copy(pt.under)
				end
			end
		end
		if can_show then
			local normal, point, box_id = get_pointed(player, pt)
			if not normal or vector.length(normal) == 0 then
				return
			end
			local dir = vector.subtract(pt.above, pt.under)
			do
				oside = table.copy(dir)

				local axis, amount = push_edge(normal, point)
				local ctrl = player:get_player_control()
				local pdef = minetest.registered_nodes[pnode.name]
				local rotate_reverse = false
				if ctrl.sneak then
					if pdef and pdef.paramtype2 ~= "degrotate" then
						amount = -amount
						rotate_reverse = true
					end
				end
				local orot = 0
				if dir.y ~= 0 then
					if axis == "x" then
						if dir.y > 0 then
							if amount < 0 then
								orot = 0
							elseif amount > 0 then
								orot = 2
							end
						else
							if amount < 0 then
								orot = 0
							elseif amount > 0 then
								orot = 2
							end
						end
					elseif axis == "z" then
						if dir.y > 0 then
							if amount < 0 then
								orot = 1
							elseif amount > 0 then
								orot = 3
							end
						else
							if amount < 0 then
								orot = 3
							elseif amount > 0 then
								orot = 1
							end
						end
					end
				else
					if axis == "y" then
						if amount < 0 then
							orot = 3
						elseif amount > 0 then
							orot = 1
						end
					elseif axis == "x" or axis == "z" then
						if dir.x > 0 or dir.z < 0 then
							if amount < 0 then
								orot = 2
							elseif amount > 0 then
								orot = 0
							end
						else
							if amount < 0 then
								orot = 0
							elseif amount > 0 then
								orot = 2
							end
						end
					end
				end

				if not ((current_hook_overlay_pos and vector.equals(current_hook_overlay_pos, opos)) and (current_hook_overlay_side and vector.equals(current_hook_overlay_side, oside)) and (current_hook_overlay_rot and current_hook_overlay_rot == orot) and (current_hook_overlay_box and current_hook_overlay_box == box_id)) then
					remove_all_overlays()
				end
				if not current_hook_overlay_pos then
					local boxes = minetest.get_node_boxes("selection_box", pt.under, pnode)
					local realpos
					if boxes then
						local box = boxes[box_id]
						local box_dir = 5
						local sub = false
						if oside.x < 0 then
							box_dir = 1
							sub = true
						elseif oside.x > 0 then
							box_dir = 4
						elseif oside.y < 0 then
							box_dir = 2
							sub = true
						elseif oside.y > 0 then
							box_dir = 5
						elseif oside.z < 0 then
							box_dir = 3
							sub = true
						elseif oside.z > 0 then
							box_dir = 6
						end
						if sub then
							realpos = vector.subtract(pt.above, vector.multiply(dir, box[box_dir] + 127/128))
						else
							realpos = vector.add(pt.above, vector.multiply(dir, box[box_dir] - 127/128))
						end
					end
					if not realpos then
						realpos = vector.subtract(pt.above, vector.multiply(dir, 63/128))
					end
					local ent = minetest.add_entity(realpos, "lzr_hook:overlay")
					if ent then
						if oside.x < 0 then
							ent:set_rotation({x=0,y=math.pi/2,z=0})
						elseif oside.x > 0 then
							ent:set_rotation({x=0,y=-math.pi/2,z=0})
						elseif oside.z < 0 then
							ent:set_rotation({x=0,y=math.pi,z=0})
						elseif oside.z > 0 then
							ent:set_rotation({x=0,y=0,z=0})
						elseif oside.y < 0 then
							ent:set_rotation({x=-math.pi/2,y=0,z=0})
						elseif oside.y > 0 then
							ent:set_rotation({x=math.pi/2,y=0,z=0})
						end
						local props = ent:get_properties()
						local texes = props.textures

						local roll = false
						if pdef and (pdef.paramtype2 == "4dir" or pdef.paramtype2 == "color4dir" or pdef.paramtype2 == "degrotate") then
							if oside.y ~= 0 then
								if rotate_reverse then
									texes[1] = "lzr_hook_overlay_rotate_reverse.png"
								else
									texes[1] = "lzr_hook_overlay_rotate.png"
								end
								roll = true
							elseif (oside.x ~= 0 or oside.z ~= 0) then
								if (orot == 1 or orot == 3) then
									if orot == 1 then
										texes[1] = "lzr_hook_overlay_2sides.png^[transformR90"
									elseif orot == 3 then
										texes[1] = "lzr_hook_overlay_2sides.png^[transformR270"
									end
								else
									texes[1] = "lzr_hook_overlay_2sides_disabled.png^[transformR90"
								end
								roll = true
							end
						end
						if not roll then
							texes[1] = "lzr_hook_overlay.png"
							if orot == 1 then
								texes[1] = texes[1] .. "^[transformR90"
							elseif orot == 2 then
								texes[1] = texes[1] .. "^[transformR180"
							elseif orot == 3 then
								texes[1] = texes[1] .. "^[transformR270"
							end
							if rotate_reverse then
								texes[1] = "("..texes[1]..")^lzr_hook_overlay_rotate_reverse.png"
							else
								texes[1] = "("..texes[1]..")^lzr_hook_overlay_rotate.png"
							end
							texes[2] = texes[1]
						end
						ent:set_properties(props)
						current_hook_overlay_pos = table.copy(opos)
						current_hook_overlay_side = table.copy(oside)
						current_hook_overlay_rot = orot
						current_hook_overlay_box = box_id
						minetest.log("verbose", "[lzr_hook] New hook overlay at "..minetest.pos_to_string(opos)..", side="..minetest.pos_to_string(oside)..", rot="..orot..", box="..box_id)
					else
						remove_all_overlays()
					end
				end
			end
		else
			remove_all_overlays()
		end
	end
end)

-- Legacy support
minetest.register_alias("screwdriver2:screwdriver", "lzr_hook")
