lzr_solutions = {}

local S = minetest.get_translator("lzr_solutions")

-- "idle", "recording", "playing" or "wait_for_level"
local state = "idle"
-- true if recording should automatically stop when reaching LEVEL_COMPLETE state
-- and trigger a file save
local autostop = false

-- true if running a full solution test of a level pack
local full_test = false
-- level number of currently tested level pack level
local full_test_level = 0
-- level pack name for the full solution test
local full_test_pack = nil
-- list of failed levels in full level pack test
local full_test_failed_levels = {}

local current_replay_time = 0
local current_action
local current_solution
local current_solution_start_time

-- shortcuts
local w2l = lzr_world.world_pos_to_level_pos
local l2w = lzr_world.level_pos_to_world_pos

--[[
solution: {
	actions = {
		action,
		action,
		action,
		...
	},
}

action: {
	type = "dig" / "place" / "rotate" / "pull_lever", "find_treasure",
	time = <number>,
	origin = {
		pos,
		pitch,
		yaw,
	},

	-- for action "dig":
	pos = <pos of node to dig>,

	-- for action "place":
	pos = <pos of placed node>,
	node = <node to place>,
	itemstack = <itemstack to place>,
	param2 = <param2 of created node>,
	pointed_thing = <pointed_thing of placement (of type "node")>

	-- for action "rotate":
	pos = <pos of rotated node>,
	node = <node after rotation>,

	-- for action "pull_lever":
	pos = <pos of lever node>,
	node = <node after lever pulling>,
}

]]

local test_next_pack_solution_callback = function()
	local level_id = full_test_level
	minetest.log("action", "[lzr_solutions] Testing solution for level "..level_id.." of level pack '"..full_test_pack.."'")

	local level_data = lzr_levels.get_level_pack(full_test_pack)
	if not level_data.solutions_path then
		-- No solutions path. Nothing to test!
		minetest.log("error", "[lzr_solutions] No solutions path")
		return false
	end

	local level = level_data[level_id]
	if not level then
		-- Level does not exist
		minetest.log("error", "[lzr_solutions] Level "..tostring(level_id).." does not exist in level pack '"..full_test_pack.."'")
		return false
	end

	if not level.filename_solution then
		-- No solution in level. Skip test.
		minetest.log("error", "[lzr_solutions] Level "..tostring(level_id).." of level pack '"..full_test_pack.."' doesn't have a solution file")
		return false
	end

	local full_path = level_data.solutions_path.."/"..level.filename_solution
	local solution_file = io.open(full_path, "rb")
	if solution_file then
		local csv = solution_file:read("*a")
		local solution = lzr_solutions.csv_to_solution(csv)
		if solution then
			lzr_solutions.replay_solution(solution)
			minetest.log("action", "[lzr_solutions] Playing solution for level "..level_id.." of level pack '"..full_test_pack.."'")
			return true
		else
			minetest.log("error", "[lzr_solutions] Error in solution CSV file for level "..level_id.." of level pack '"..full_test_pack.."'")
			return false
		end
	else
		minetest.log("error", "[lzr_solutions] Error while loading solution CSV file for level "..level_id.." of level pack '"..full_test_pack.."'")
		return false
	end
end

local test_next_pack_solution = function(pack)
	local level_data = lzr_levels.get_level_pack(pack)
	full_test_level = full_test_level + 1
	if full_test_level > #level_data then
		return false
	end
	minetest.log("info", "[lzr_solutions] Loading level "..full_test_level.." of level pack '"..pack.."' ...")
	lzr_gamestate.set_state(lzr_gamestate.LEVEL_TEST)
	lzr_levels.start_level(full_test_level, level_data)
	return true
end
lzr_levels.register_on_level_start(function()
	minetest.log("verbose", "[lzr_solutions] on_level_start event")
	if state == "wait_for_level" then
		local player = minetest.get_player_by_name("singleplayer")
		lzr_gui.set_play_gui(player, true)
		lzr_player.set_play_inventory(player, nil, true)
		lzr_solutions.replay_solution(current_solution)
		return
	end
	if full_test then
		test_next_pack_solution_callback()
	end
	if state == "recording" then
		lzr_solutions.stop_recording_solution()
		minetest.chat_send_player("singleplayer", S("Recording cancelled."))
	end
end)

lzr_solutions.test_pack_solutions = function(pack)
	full_test = true
	full_test_level = 0
	full_test_pack = pack
	full_test_failed_levels = {}
	local ok = test_next_pack_solution(pack)
	if not ok then
		full_test = false
	end
end

local record_action = function(solution, action)
	local new_action = table.copy(action)
	local time = math.floor(minetest.get_us_time()/1000)
	new_action.time = time - current_solution_start_time
	table.insert(solution.actions, new_action)
end

local get_inactive_nodename = function(nodename)
	local def = minetest.registered_nodes[nodename]
	if not def then
		return nodename
	end
	if not def._lzr_inactive then
		return nodename
	else
		return def._lzr_inactive
	end
end

local replay_action = function(player, action)
	player:set_pos(action.origin.pos)
	player:set_look_horizontal(action.origin.yaw)
	player:set_look_vertical(action.origin.pitch)
	if action.type == "dig" then
		-- Check if node is the expected node
		local node = minetest.get_node(action.pos)
		local action_node = action.node

		-- Use the name of the inactive node to compare for the test, instead
		-- of using the actual node name in the world.
		-- This is to work around possible timing-related issues when digging
		-- and placing nodes very fast and a node active/inactive state has
		-- not been updated fast enough yet.
		local canonical_nodename = get_inactive_nodename(node.name)
		local canonical_action_nodename = get_inactive_nodename(action_node.name)

		if canonical_nodename ~= canonical_action_nodename or node.param2 ~= action.node.param2 then
			minetest.log("error", "[lzr_solutions] Expected to dig node '"..canonical_action_nodename.."' (param2="..action.node.param2..") but found '"..canonical_nodename.."' (param2="..node.param2..")")
			return false
		end
		minetest.node_dig(action.pos, action.node, player)
		local def = minetest.registered_nodes[action.node.name]
		if def and def.sounds and def.sounds.dug then
			minetest.sound_play(def.sounds.dug, {pos=action.pos}, true)
		end
		lzr_laser.full_laser_update_if_needed()
	elseif action.type == "place" then
		-- Check if action itemstack exists in player inventory
		-- and find its inventory index
		local inv = player:get_inventory()
		local idx = nil

		-- In this check, we consider two items to be equal if both
		-- their itemname and trigger_id (from metadata) are equal.
		local ameta = action.itemstack:get_meta()
		local a_trigger_id = ameta:get_string("trigger_id")
		for i=1, inv:get_size("main") do
			local checkstack = inv:get_stack("main", i)
			local cmeta = checkstack:get_meta()
			local c_trigger_id = cmeta:get_string("trigger_id")
			if action.itemstack:get_name() == checkstack:get_name() and a_trigger_id == c_trigger_id then
				idx = i
				break
			end
		end
		if not idx then
			minetest.log("error", "[lzr_solutions] Tried to place an item not found in the player inventory: "..action.itemstack:to_string())
			return false
		end

		if not action.itemstack then
			minetest.log("error", "[lzr_solutions] action.itemstack was nil!")
		end
		local itemstack, position = minetest.item_place_node(action.itemstack, player, action.pointed_thing, action.param2, false)
		if not position then
			local itemstr = action.itemstack:to_string() or "<UNKNOWN>"
			minetest.log("error", "[lzr_solutions] Tried to place item '"..itemstr.."' as node but failed")
			return false
		end
		-- Update player inventory
		inv:set_stack("main", idx, itemstack)

		local def = minetest.registered_nodes[action.itemstack:get_name()]
		if def and def.sounds and def.sounds.place then
			minetest.sound_play(def.sounds.place, {pos=action.pointed_thing.under}, true)
		end
		lzr_laser.full_laser_update_if_needed()
	elseif action.type == "rotate" then
		local node = minetest.get_node(action.pos)
		local action_node = action.node

		-- Use the name of the inactive node to compare for the test.
		-- Same reason as for action.type == "dig".
		local canonical_nodename = get_inactive_nodename(node.name)
		local canonical_action_nodename = get_inactive_nodename(action_node.name)

		if canonical_nodename ~= canonical_action_nodename then
			minetest.log("error", "[lzr_solutions] Tried to rotate node '"..canonical_action_nodename.."' but found '"..canonical_nodename.."'")
			return false
		end
		minetest.swap_node(action.pos, action.node)
		local def = minetest.registered_nodes[action.node.name]
		if def and def.sounds and def.sounds._rotate then
			minetest.sound_play(def.sounds._rotate, {pos=action.pos}, true)
		end
		lzr_laser.full_laser_update_if_needed()
	elseif action.type == "pull_lever" then
		local node = minetest.get_node(action.pos)
		if minetest.get_item_group(node.name, "lever") == 0 then
			minetest.log("error", "[lzr_solutions] Expected lever at "..minetest.pos_to_string(action.pos).." didn't find one")
			return false
		end
		lzr_levers.pull_lever(action.pos, player)
	elseif action.type == "find_treasure" then
		local node = minetest.get_node(action.pos)
		if minetest.get_item_group(node.name, "chest") ~= 1 then
			minetest.log("error", "[lzr_solutions] Expected node with group 'chest=1' at "..minetest.pos_to_string(action.pos).." but it was something else (node="..node.name.."; param2="..node.param2..")")
			return false
		end
		local def = minetest.registered_nodes[node.name]
		if def.on_punch then
			def.on_punch(action.pos, node, player)
		else
			minetest.log("error", "[lzr_solutions] Expected node with on_punch function (for a chest) at "..minetest.pos_to_string(action.pos).." but on_punch was missing (node="..node.name.."; param2="..node.param2..")")
			return false
		end
	end
	return true
end

lzr_solutions.record_solution = function(activate_autostop)
	if activate_autostop then
		autostop = true
	else
		autostop = false
	end
	state = "recording"
	current_solution = {
		actions = {},
	}
	current_solution_start_time = math.floor(minetest.get_us_time()/1000)
end

lzr_solutions.stop_recording_solution = function()
	state = "idle"
	autostop = false
	if current_solution then
		local solution = table.copy(current_solution)
		current_solution = nil
		current_solution_start_time = nil
		return solution
	else
		return
	end
end

lzr_solutions.replay_solution = function(solution)
	current_replay_time = 0
	current_action = 1
	current_solution = table.copy(solution)
	state = "playing"
end

lzr_solutions.solution_to_csv = function(solution)
	local rows = {}
	for a=1, #solution.actions do
		local action = solution.actions[a]

		local s_pos, s_node_name, s_node_param2 = "", "", ""
		local s_itemstack = ""
		local s_pointed_thing_under = ""
		local s_pointed_thing_above = ""
		if action.pos then
			s_pos = minetest.pos_to_string(w2l(action.pos))
		end
		if action.node then
			s_node_name = action.node.name
			s_node_param2 = action.node.param2
		end
		if action.itemstack then
			s_itemstack = action.itemstack:to_string()
		end
		if action.pointed_thing and action.pointed_thing.type == "node" then
			s_pointed_thing_under = minetest.pos_to_string(w2l(action.pointed_thing.under))
			s_pointed_thing_above = minetest.pos_to_string(w2l(action.pointed_thing.above))
		end

		local row = {
			action.type,
			action.time,
			minetest.pos_to_string(w2l(action.origin.pos), 5),
			string.format("%.5g", action.origin.pitch),
			string.format("%.5g", action.origin.yaw),

			-- action-specific fields:
			-- pos
			s_pos,
			-- node (name and param2)
			s_node_name,
			s_node_param2,
			-- itemstack
			s_itemstack,
			-- pointed_thing (type node)
			s_pointed_thing_under,
			s_pointed_thing_above,
		}
		table.insert(rows, row)
	end
	return rcsv.write_csv(rows)
end

lzr_solutions.csv_to_solution = function(csv)
	local rows, csv_error = rcsv.parse_csv(csv)
	if not rows then
		return nil, csv_error
	end
	local solution = {
		actions = {},
	}
	for r=1, #rows do
		local row = rows[r]
		local function row_exists(i)
			return row[i] and row[i] ~= ""
		end
		local action = {}
		action.type = row[1]
		action.time = tonumber(row[2])
		action.origin = {
			pos = l2w(minetest.string_to_pos(row[3])),
			pitch = tonumber(row[4]),
			yaw = tonumber(row[5]),
		}
		if row_exists(6) then
			action.pos = l2w(minetest.string_to_pos(row[6]))
		end
		if row_exists(7) and row_exists(8) then
			action.node = {
				name = row[7],
				param2 = tonumber(row[8]),
			}
		end
		if row_exists(9) then
			action.itemstack = ItemStack(row[9])
		end
		if row_exists(10) and row_exists(11) then
			action.pointed_thing = {
				type = "node",
				under = l2w(minetest.string_to_pos(row[10])),
				above = l2w(minetest.string_to_pos(row[11])),
			}
		end
		table.insert(solution.actions, action)
	end
	return solution
end

minetest.register_on_placenode(function(pos, newnode, placer, oldnode, itemstack, pointed_thing)
	if state ~= "recording" then
		return
	end
	if not placer then
		return
	end
	local action = {
		type = "place",
		origin = {
			pos = placer:get_pos(),
			yaw = placer:get_look_horizontal(),
			pitch = placer:get_look_vertical(),
		},
		pos = table.copy(pos),
		param2 = newnode.param2,
		itemstack = ItemStack(itemstack),
		pointed_thing = table.copy(pointed_thing),
	}
	record_action(current_solution, action)
end)

minetest.register_on_dignode(function(pos, oldnode, digger)
	if state ~= "recording" then
		return
	end
	if not digger then
		return
	end
	local action = {
		type = "dig",
		origin = {
			pos = digger:get_pos(),
			yaw = digger:get_look_horizontal(),
			pitch = digger:get_look_vertical(),
		},
		pos = pos,
		node = oldnode,
	}
	record_action(current_solution, action)
end)

lzr_hook.register_after_rotate(function(pos, new_node, player)
	if state ~= "recording" then
		return
	end
	if not player then
		return
	end
	local action = {
		type = "rotate",
		origin = {
			pos = player:get_pos(),
			yaw = player:get_look_horizontal(),
			pitch = player:get_look_vertical(),
		},
		pos = pos,
		node = new_node,
	}
	record_action(current_solution, action)
end)

lzr_treasure.register_after_found_treasure(function(pos, player)
	if state ~= "recording" then
		return
	end
	if not player then
		return
	end
	local action = {
		type = "find_treasure",
		origin = {
			pos = player:get_pos(),
			yaw = player:get_look_horizontal(),
			pitch = player:get_look_vertical(),
		},
		pos = pos,
	}
	record_action(current_solution, action)
end)

lzr_levers.register_after_pulled_lever(function(pos, new_node, player)
	if state ~= "recording" then
		return
	end
	if not player then
		return
	end
	local action = {
		type = "pull_lever",
		origin = {
			pos = player:get_pos(),
			yaw = player:get_look_horizontal(),
			pitch = player:get_look_vertical(),
		},
		pos = pos,
		node = new_node,
	}
	record_action(current_solution, action)
end)

local function passed_message(message)
	minetest.chat_send_all(message)
	local player = minetest.get_player_by_name("singleplayer")
	if player then
		lzr_messages.show_message(player, message, 4)
		minetest.sound_play({name = "lzr_levels_level_complete", gain = 1}, {to_player=player:get_player_name()}, true)
	end
end

minetest.register_globalstep(function(dtime)
	if state ~= "playing" then
		return
	end
	current_replay_time = current_replay_time + dtime
	if not current_solution then
		minetest.log("error", "[lzr_solutions] In 'playing' state but current_solution is nil!")
		state = "idle"
		full_test = false
		return
	end
	if current_action > #current_solution.actions then
		-- Replay is finished
		if full_test then
			if not lzr_gamestate.is_loading() then
				if lzr_laser.check_level_won() then
					minetest.log("action", "[lzr_solutions] Solution for level "..full_test_level.." of level pack '"..full_test_pack.."' completed!")
				else
					table.insert(full_test_failed_levels, full_test_level)
					minetest.log("error", "[lzr_solutions] Replay for solution of level "..full_test_level.." of level pack '"..full_test_pack.."' is done but level wasn't solved")
				end

				local ok = test_next_pack_solution(full_test_pack)
				if not ok then
					if #full_test_failed_levels == 0 then
						minetest.log("action", "[lzr_solutions] Level pack solution test for '"..full_test_pack.."' successfully completed!")
						passed_message(S("Level pack solution test PASSED!"))
					else
						minetest.log("action", "[lzr_solutions] Level pack solution test for '"..full_test_pack.."' completed in failure!")
						passed_message(S("Level pack solution test FAILED!"))
					end

					full_test = false
					current_solution = nil
					state = "idle"
					lzr_levels.go_to_menu()
				end
			end
		else
			-- Go back to idle state
			current_solution = nil
			state = "idle"
			if lzr_laser.check_level_won() then
				passed_message(S("Level solution test PASSED!"))

				minetest.log("action", "[lzr_solutions] Level solution test PASSED!")
				lzr_levels.go_to_menu()
			else
				minetest.log("error", "[lzr_solutions] Level solution test FAILED!")
				lzr_gamestate.set_state(lzr_gamestate.LEVEL)
			end
		end
		return
	end
	local action = current_solution.actions[current_action]
	if current_replay_time >= action.time*(0.001*lzr_globals.LEVEL_TEST_TIME_MULTIPLIER) then
		local player = minetest.get_player_by_name("singleplayer")
		if not player then
			return
		end
		local ok = replay_action(player, action)
		if not ok then
			-- Abort replay and skip to next one if replay_action returns false (in case of error)
			current_action = math.huge
		else
			current_action = current_action + 1
		end
	end
end)

lzr_solutions.save_solution_for_current_level = function(solution)
	local csv = lzr_solutions.solution_to_csv(solution)
	local world_solutions_path = minetest.get_worldpath().."/solutions"
	local ok = minetest.mkdir(world_solutions_path)
	if not ok then
		return false, S("Could not create and/or access world solutions path.")
	end

	local level_data = lzr_levels.get_current_level_data()
	local level_id = lzr_levels.get_current_level()
	if not level_data or not level_id then
		minetest.log("error", "[lzr_solutions] save_solution_for_current_level: Missing level_data or level ID!")
		return false
	end
	local level = level_data[level_id]
	local filename_solution = string.sub(level.filename, 1, -5) .. ".sol.csv"

	local full_path = world_solutions_path.."/"..filename_solution
	local solution_file = io.open(full_path, "wb")
	if solution_file then
		solution_file:write(csv)
		solution_file:close()
		return true, S("Solution file written to: @1", full_path)
	else
		return false, S("Could not write solution file.")
	end
end

-- Automatically stop recording when entering the LEVEL_COMPLETE state
-- and save solution to file.
lzr_gamestate.register_on_enter_state(function(new_state)
	if state == "recording" then
		if new_state == lzr_gamestate.LEVEL_COMPLETE and autostop then
			local solution = lzr_solutions.stop_recording_solution()
			local msg = S("Recording finished.")
			local ok, save_msg = lzr_solutions.save_solution_for_current_level(solution)
			msg = msg .. "\n"..save_msg
			minetest.chat_send_player("singleplayer", msg)
		elseif new_state ~= lzr_gamestate.LEVEL then
			lzr_solutions.stop_recording_solution()
			minetest.chat_send_player("singleplayer", S("Recording cancelled."))
		end
	elseif state == "playing" then
		if new_state ~= lzr_gamestate.LEVEL_TEST then
			full_test = false
			state = "idle"
			current_solution = nil
		end
	end
end)

-- Show chat commands only in debug mode
if minetest.settings:get_bool("lzr_debug", false) == true then

	minetest.register_chatcommand("replay_solution", {
		privs = { debug = true, server = true },
		params = "[ start | stop ]",
		description = S("Replay saved solution for current level, or stop an active replay"),
		func = function(name, param)
			if state == "wait_for_level" then
				return false, S("Level is loading!")
			elseif param == "stop" then
				if state == "playing" then
					full_test = false
					state = "idle"
					current_solution = nil
					lzr_gamestate.set_state(lzr_gamestate.LEVEL)
					return true, S("Replay stopped.")
				elseif state == "idle" then
					return true, S("No replay active.")
				elseif state == "recording" then
					return false, S("Already recording!")
				end
			elseif param ~= "" and param ~= "start" then
				return false
			end

			if state == "playing" then
				return false, S("Already replaying a solution!")
			elseif state == "recording" then
				return false, S("Already recording!")
			end
			local gstate = lzr_gamestate.get_state()
			if gstate ~= lzr_gamestate.LEVEL and gstate ~= lzr_gamestate.LEVEL_TEST then
				return false, S("Not playing in a level!")
			end
			if lzr_gamestate.is_loading() then
				return false, S("Can’t replay solution while loading!")
			end

			local level_data = lzr_levels.get_current_level_data()
			local level_id = lzr_levels.get_current_level()
			local level = level_data[level_id]

			if not level_data.solutions_path or not level.filename_solution then
				return false, S("No solution available.")
			end

			local full_path = level_data.solutions_path.."/"..level.filename_solution
			local solution_file = io.open(full_path, "rb")
			if solution_file then
				local csv = solution_file:read("*a")
				local solution, csv_error = lzr_solutions.csv_to_solution(csv)
				if solution then
					lzr_gamestate.set_state(lzr_gamestate.LEVEL_TEST)
					state = "wait_for_level"
					current_solution = solution
					lzr_levels.restart_level()
					return true, S("Replay started.")
				else
					return false, S("CSV error in solution: @1.", csv_error)
				end
			else
				return false, S("No solution file available.")
			end
		end,
	})

	minetest.register_chatcommand("test_pack_solutions", {
		privs = { debug = true, server = true },
		params = S("[<level pack>]"),
		description = S("Test the solutions of all levels of a level pack"),
		func = function(name, param)
			if state == "wait_for_level" then
				return false, S("Level is loading!")
			elseif state == "playing" then
				return false, S("Already replaying a solution!")
			elseif state == "recording" then
				return false, S("Already recording!")
			end
			local pack = param
			if pack == "" then
				pack = "__core"
			end
			if lzr_levels.get_level_pack(pack) then
				lzr_solutions.test_pack_solutions(pack)
				return true
			else
				return false, S("This level pack doesn’t exist!")
			end
		end,
	})

	minetest.register_chatcommand("record_solution", {
		privs = {},
		params = "[ start | stop | cancel ]",
		description = S("Start or stop recording solution for current level, writing to a solution file when stopping"),
		func = function(name, param)
			local gstate = lzr_gamestate.get_state()
			if gstate ~= lzr_gamestate.LEVEL then
				return false, S("Not playing in a level!")
			end
			if state == "wait_for_level" then
				return false, S("Level is loading!")
			end

			-- Starts recording
			if param == "start" or param == "" then
				if state == "playing" then
					return false, S("Already replaying a solution!")
				elseif state == "recording" then
					return false, S("Already recording!")
				end
				lzr_solutions.record_solution(true)
				return true, S("Recording started.")
			-- Stops recording and finalizes it by writing to a file
			elseif param == "stop" then
				if state ~= "recording" then
					return false, S("Not recording!")
				end
				local solution = lzr_solutions.stop_recording_solution()
				local ok, msg = lzr_solutions.save_solution_for_current_level(solution)
				if ok then
					return true, S("Recording stopped.").."\n"..msg
				else
					return false, S("Recording stopped.").."\n"..msg
				end
			-- Cancels recording without writing to file
			elseif param == "cancel" then
				if state ~= "recording" then
					return false, S("Not recording!")
				end
				lzr_solutions.stop_recording_solution()
				return true, S("Recording cancelled.")
			else
				return false
			end
		end,
	})

end
