local S = minetest.get_translator("lzr_editor")
local FS = function(...) return minetest.formspec_escape(S(...)) end
local NS = function(s) return end

local F = minetest.formspec_escape

local select_item = dofile(minetest.get_modpath("lzr_editor").."/select_item.lua")

-- Text color the special autosave level will be highlighted
-- in the save/load dialog
local AUTOSAVE_HIGHLIGHT = "#FF8060"

local TXT_COLOR_ERROR = "#ff8000"
local TXT_COLOR_WARNING = "#ffff00"

-- List of items to give to player on editor entry.
-- This must be compatible with inventory:set_list().
local INITIAL_EDITOR_ITEMS = {
	-- tools
	"lzr_tools:ultra_pickaxe",
	"lzr_tools:ultra_bucket",
	"lzr_hook:hook",
	"lzr_laser:block_state_toggler",
	"lzr_laser:trigger_tool",
	"lzr_laser:color_changer",
	"lzr_tools:variant_changer",
	"lzr_laser:screw_changer",
	-- important nodes
	"lzr_laser:emitter_red_fixed_on",
	"lzr_laser:detector_fixed",
	"lzr_teleporter:teleporter_off",
	"lzr_treasure:chest_wood_locked",
	"lzr_treasure:chest_dark_locked",
	"lzr_laser:mirror_fixed",
	"lzr_laser:double_mirror_00_fixed",
	"lzr_laser:transmissive_mirror_00_fixed",
}

-- If the player is this far away out of the level bounds,
-- teleport back to level on level resize, level move
-- or level load.
local OUT_OF_BOUNDS_TOLERANCE = 1

lzr_editor = {}
-- Remember if the WorldEdit usage warning has already been shown in this session
-- (a warning is shown when a WorldEdit command is used because some commands
-- might break the triggers)
lzr_editor.worldedit_warning = false

-- The level state holds the metadata for
-- the level that is being currently edited.
-- INITIAL_LEVEL_STATE is the default state
-- and the state when the editor starts.
-- NOTE: level size and position are stored by
-- the lzr_world mod.
local INITIAL_LEVEL_STATE = {
	name = "",
	file_name = "",
	size = lzr_globals.DEFAULT_LEVEL_SIZE,
	wall = lzr_globals.DEFAULT_WALL_NODE,
	ceiling = lzr_globals.DEFAULT_CEILING_NODE,
	floor = lzr_globals.DEFAULT_FLOOR_NODE,
	ambient = lzr_ambience.DEFAULT_AMBIENCE,
	sky = lzr_globals.DEFAULT_SKY,
	npc_texts = lzr_globals.DEFAULT_NPC_TEXTS,
	weather = lzr_globals.DEFAULT_WEATHER,
	backdrop = lzr_globals.DEFAULT_BACKDROP,
	backdrop_pos = lzr_globals.DEFAULT_BACKDROP_POS,
	triggers = "",
	flags = "",
}
local level_state = table.copy(INITIAL_LEVEL_STATE)

local temp_settings_state = {}

-- Used to store the current contents for the
-- textlist contents of the custom level file list.
-- (this assumes singleplayer)
local level_file_textlist_state = {}

-- Error and warning texts for the level checker
local error_warning_texts = {
	no_teleporter = S("No teleporter"),
	too_many_teleporters = S("More than one teleporter"),
	barriers = S("Barrier or rain membrane in level area"),
	gold_block = S("Bare gold block in level area"),
	plant_on_ground = S("Rooted plant in level area"),
	too_many_parrot_spawners = S("More than one parrot spawner"),
	too_many_hidden_parrot_spawners = S("More than one hidden parrot spawner"),
	--~ param2 is an internal value used by blocks to store some state
	bad_hidden_parrot_spawner = S("Bad param2 value for hidden parrot spawner"),
	trigger_out_of_bounds = S("Trigger is out of bounds"),
	trigger_moved = S("Trigger ID does not match location"),
	laser_incompatible = S("Laser-incompatible node found"),
	no_treasures = S("No treasures to collect"),
	--~ Warning shown when a level has an incomplete door
	half_door = S("Incomplete door"),
	--~ Warning shown when a level has an invalid door. param2 is an internal value used by blocks to store some state
	incompatible_door_segments_param2 = S("Mismatching param2 value for door segments"),
	--~ Warning shown when a level has an invalid door
	incompatible_door_segments_type = S("Mismatching type for door segments"),
}
-- Add error messages from lzr_triggers as well
for id, text in pairs(lzr_triggers.CHECK_ERROR_TEXTS) do
	error_warning_texts["lzr_triggers_"..id] = text
end

-- Creates a nice-looking string listing all level warnings and errors.
-- Then returns it.
-- If everything is OK, returns the empty string.
local list_warnings_and_errors = function()
	local result_str = ""
	local check1_ok, errors = lzr_editor.check_level_errors()
	if not check1_ok then
		-- Level error found.
		local error_texts = {}
		for e=1, #errors do
			table.insert(error_texts, S("• Error: @1", error_warning_texts[errors[e]] or errors[e]))
		end
		local errors_str = table.concat(error_texts, "\n")
		result_str = result_str .. minetest.colorize(TXT_COLOR_ERROR, errors_str)
	end
	local check2_ok, warnings = lzr_editor.check_level_warnings()
	if not check2_ok then
		local warning_texts = {}
		for w=1, #warnings do
			table.insert(warning_texts, S("• Warning: @1", error_warning_texts[warnings[w]] or warnings[w]))
		end
		local warnings_str = table.concat(warning_texts, "\n")
		if not check1_ok then
			result_str = result_str .. "\n"
		end
		result_str = result_str .. minetest.colorize(TXT_COLOR_WARNING, warnings_str)
	end
	return result_str
end

-- Move player to the level if they're too far away
local function move_player_to_level_if_neccessary(player)
	local lsoffset = vector.new(OUT_OF_BOUNDS_TOLERANCE, OUT_OF_BOUNDS_TOLERANCE, OUT_OF_BOUNDS_TOLERANCE)
	local lsminpos = vector.subtract(lzr_world.get_level_pos(), lsoffset)
	local lsmaxpos = vector.add(vector.add(lzr_world.get_level_pos(), lzr_world.get_level_size()), lsoffset)
	if not vector.in_area(player:get_pos(), lsminpos, lsmaxpos) then
		player:set_pos(lzr_world.get_level_pos())
	end
end

-- Give useful starter items to player
local function give_initial_items(player)
	local inv = player:get_inventory()
	-- Set all items at once
	inv:set_list("main", INITIAL_EDITOR_ITEMS)

	-- Check if we actually have those items because we're paranoid
	for i=1, #INITIAL_EDITOR_ITEMS do
		local item = inv:get_stack("main", i)
		if item:get_name() ~= INITIAL_EDITOR_ITEMS[i] then
			minetest.log("error", "[lzr_editor] Wrong or missing initial item at position "..i..": expected "..INITIAL_EDITOR_ITEMS[i].." but got "..item:get_name())
			break
		end
	end
end

-- Returns true if pos is within the current bounds
-- of the actively edited level.
function lzr_editor.is_in_level_bounds(pos)
	local offset = table.copy(lzr_world.get_level_size())
	offset = vector.offset(offset, -1, -1, -1)
	return vector.in_area(pos, lzr_world.get_level_pos(), vector.add(lzr_world.get_level_pos(), offset))
end

-- Enter editor state (if not already in it).
-- Returns true if state changed, false if already in editor.
function lzr_editor.enter_editor(player)
	local state = lzr_gamestate.get_state()
	if state == lzr_gamestate.EDITOR then
		return false
	else
		level_state = table.copy(INITIAL_LEVEL_STATE)

		local pos = lzr_world.get_level_pos()
		local size = lzr_world.get_level_size()
		lzr_levels.reset_level_area(false, pos, size)

		lzr_world.set_level_pos(lzr_globals.DEFAULT_LEVEL_POS)
		lzr_world.set_level_size(lzr_globals.DEFAULT_LEVEL_SIZE)
		pos = lzr_world.get_level_pos()
		size = lzr_world.get_level_size()
		lzr_triggers.reset_triggers()

		lzr_gamestate.set_state(lzr_gamestate.EDITOR)

		lzr_ambience.set_ambience(lzr_ambience.DEFAULT_AMBIENCE)
		lzr_sky.set_sky(lzr_globals.DEFAULT_SKY)
		lzr_weather.set_weather(lzr_globals.DEFAULT_WEATHER)


		lzr_gui.set_loading_gui(player)
		lzr_player.set_editor_inventory(player, true)
		lzr_player.set_loading_inventory_formspec(player)
		lzr_gamestate.set_loading(true)

		-- Stuff to do after the editor was loaded
		local done = function()

			lzr_player.set_editor_inventory(player)

			give_initial_items(player)

			lzr_privs.grant_edit_privs(player)

			lzr_gui.set_editor_gui(player)

			lzr_gui.show_level_bounds(player, pos, size)

			lzr_gamestate.set_loading(false)

			minetest.log("action", "[lzr_editor] Entered and initialized level editor")
		end

		lzr_levels.build_room({pos=pos, size=size, spawn_pos=pos, yaw=0, clear=true, callback_done=done})
		minetest.log("action", "[lzr_editor] Entering level editor ...")

		return true
	end
end

local check_for_slash = function(str)
	if string.match(str, "[/\\]") then
		return true
	else
		return false
	end
end

local save_level = function(level_name, is_autosave)
	if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
		return false
	end
	if check_for_slash(level_name) then
		return false
	end

	-- Show level errors and warnings when saving manually
	if not is_autosave then
		local result_str = list_warnings_and_errors()
		if result_str ~= "" then
			minetest.chat_send_player("singleplayer", S("The following problems were found in this level:") .. "\n" .. result_str)
		end
	end

	minetest.mkdir(minetest.get_worldpath().."/levels")

	local filename = minetest.get_worldpath().."/levels/"..level_name..".mts"
	local size = vector.subtract(lzr_world.get_level_size(), vector.new(1, 1, 1))

	--[[ <<<The file is saved here >>> ]]
	local ok = minetest.create_schematic(lzr_world.get_level_pos(), vector.add(lzr_world.get_level_pos(), size), {}, filename, {})
	--[[ ^ Note: This saves the level exactly as-is, including the current state with
	all active lasers and active laser blocks. This is intended. The game will
	automatically clear lasers, reset laser blocks and recalculate the lasers on each level
	start anyway, so it shouldn't matter in which 'state' the level is stored.
	Saving the level with the active lasers pre-calculated tho has the slight benefit that
	the level instantly loads with the correct lasers. ]]

	-- Save level data into CSV
	local csv_filename = minetest.get_worldpath().."/levels/"..level_name..".csv"
	local npc_texts_csv = ""
	if level_state.npc_texts and level_state.npc_texts.goldie then
		npc_texts_csv = level_state.npc_texts.goldie or ""
	end
	local csv_contents = lzr_csv.write_csv({{
		level_name..".mts",
		level_state.name,
		-- level boundaries syntax: wall|<unused>|floor|ceiling
		-- (the <unused> used to be a node, but it has been removed from the game)
		level_state.wall .. "||" .. level_state.floor .. "|" .. level_state.ceiling,
		level_state.ambient,
		level_state.sky,
		npc_texts_csv,
		level_state.weather,
		level_state.backdrop,
		minetest.pos_to_string(level_state.backdrop_pos),
		lzr_levels.serialize_triggers(),
		level_state.flags,
	}})
	local ok_csv = minetest.safe_file_write(csv_filename, csv_contents)

	if ok and ok_csv then
		minetest.log("action", "[lzr_editor] Level written to "..filename.." and "..csv_filename)
		if not is_autosave then
			level_state.file_name = level_name
		end
		return true, filename, csv_filename
	elseif ok then
		minetest.log("error", "[lzr_editor] Level written to "..filename..", but failed to write CSV!")
		return false, filename, csv_filename
	else
		minetest.log("error", "[lzr_editor] Failed to write level to "..filename)
		return false, filename, csv_filename
	end
end

local autosave_level = function()
	save_level(lzr_globals.AUTOSAVE_NAME, true)
end

-- Exit editor state (if not already outside of editor).
-- Returns true if state changed, false if could not exit
local function exit_editor(player)
	if lzr_gamestate.is_loading() then
		return false
	end
	level_state.file_name = ""
	local state = lzr_gamestate.get_state()
	if state ~= lzr_gamestate.EDITOR then
		return false
	else
		lzr_levels.go_to_menu()
		local pos = lzr_world.get_level_pos()
		local size = lzr_world.get_level_size()
		lzr_levels.reset_level_area(false, pos, size)
		return true
	end
end

minetest.register_chatcommand("editor_save", {
	description = S("Save current level"),
	params = S("<level name>"),
	func = function(name, param)
		if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
			return false, S("Not in editor mode!")
		elseif lzr_gamestate.is_loading() then
			return false, S("Can’t do this while loading!")
		end
		if param == "" then
			return false, S("No level name provided.")
		end
		local level_name = param
		if check_for_slash(level_name) then
			return false, S("Level name must not contain slash or backslash!")
		end
		local ok, filename, filename2 = save_level(level_name)
		if ok and filename and filename2 then
			--~ @1 and @2 are file locations
			return true, S("Level saved to @1 and @2.", filename, filename2)
		elseif of and filename then
			--~ @1 and @2 are file locations
			return true, S("Level saved to @1, but could not write metadata to @2.", filename, filename2)
		else
			return false, S("Error writing level file!")
		end
	end,
})

local load_level = function(level_name, player)
	if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
		return false
	end

	if check_for_slash(level_name) then
		return false
	end
	local filename = level_name..".mts"
	local ok = lzr_util.file_exists(minetest.get_worldpath().."/levels", filename)
	if not ok then
		return false
	end
	local schem = minetest.read_schematic(minetest.get_worldpath().."/levels/"..filename, {write_yslice_prob="none"})
	if schem then
		local csv_ok = false
		local csv_file = io.open(minetest.get_worldpath().."/levels/"..level_name..".csv", "r")
		local triggers_str = ""
		local flags_str = ""
		if csv_file then
			local csv_string = csv_file:read("*a")
			csv_file:close()
			local csv_parsed, csv_error = lzr_csv.parse_csv(csv_string)
			if csv_parsed and #csv_parsed >= 1 then
				csv_ok = true
				level_state.name = csv_parsed[1][2]
				local bounds = csv_parsed[1][3]
				local exploded_bounds = string.split(bounds, "|", true)
				if exploded_bounds then
					level_state.wall = exploded_bounds[1]
					local legacy_window = exploded_bounds[2]
					level_state.floor = exploded_bounds[3]
					-- Force ceiling to be barrier if legacy window
					-- node is used so that the level still gets
					-- sunlight
					if legacy_window == "lzr_decor:woodframed_glass" then
						level_state.ceiling = "lzr_core:barrier"
						--~ The "window boundary" refers to a special block used for the boundaries of the level to create windows in the walls. It has been removed in later versions of the game.
						minetest.chat_send_player(player:get_player_name(), S("Note: This level uses a legacy window boundary, which is no longer supported."))
					else
						level_state.ceiling = exploded_bounds[4]
					end
				end
				level_state.ambient = csv_parsed[1][4]
				level_state.sky = csv_parsed[1][5] or lzr_globals.DEFAULT_SKY
				level_state.npc_texts = csv_parsed[1][6]
				if level_state.npc_texts then
					level_state.npc_texts = { goldie = level_state.npc_texts }
				else
					level_state.npc_texts = lzr_globals.DEFAULT_NPC_TEXTS
				end
				level_state.weather = csv_parsed[1][7] or lzr_globals.DEFAULT_WEATHER
				level_state.backdrop = csv_parsed[1][8] or lzr_globals.DEFAULT_BACKDROP
				local parsed_backdrop_pos = csv_parsed[1][9]
				local backdrop_pos
				if parsed_backdrop_pos then
					backdrop_pos = minetest.string_to_pos(parsed_backdrop_pos)
				end
				if backdrop_pos then
					level_state.backdrop_pos = backdrop_pos
				else
					level_state.backdrop_pos = lzr_globals.DEFAULT_BACKDROP_POS
				end
				triggers_str = csv_parsed[1][10] or ""
				flags_str = csv_parsed[1][11] or ""
				level_state.triggers = triggers_str or ""
				level_state.flags = flags_str or ""
			else
				csv_ok = false
				minetest.chat_send_player(player:get_player_name(), minetest.colorize(TXT_COLOR_ERROR, S("The CSV file for level ‘@1’ couldn't be parsed. Error message: @2", level_name, csv_error)))
				minetest.log("action", "[lzr_editor] Error parsing CSV file for "..level_name..": "..csv_error)
			end
		else
			csv_ok = false
			minetest.chat_send_player(player:get_player_name(), minetest.colorize(TXT_COLOR_WARNING, S("Level ‘@1’ doesn’t have a CSV file.", level_name)))
			minetest.log("action", "[lzr_editor] No CSV file found for "..level_name)
		end
		if not csv_ok then
			level_state.name = ""
			level_state.wall = lzr_globals.DEFAULT_WALL_NODE
			level_state.ceiling = lzr_globals.DEFAULT_CEILING_NODE
			level_state.floor = lzr_globals.DEFAULT_FLOOR_NODE
			level_state.ambient = lzr_globals.DEFAULT_AMBIENCE
			level_state.sky = lzr_globals.DEFAULT_SKY
			level_state.npc_texts = lzr_globals.DEFAULT_NPC_TEXTS
			level_state.weather = lzr_globals.DEFAULT_WEATHER
			level_state.backdrop = lzr_globals.DEFAULT_BACKDROP
			level_state.backdrop_pos = lzr_globals.DEFAULT_BACKDROP_POS
			level_state.triggers = ""
			level_state.flags = ""
			minetest.chat_send_player(player:get_player_name(), minetest.colorize(TXT_COLOR_WARNING, S("The level was initialized with fallback level settings.")))
		end

		lzr_world.set_level_size(table.copy(schem.size))
		level_state.size = lzr_world.get_level_size()
		level_state.name = level_state.name or ""
		level_state.wall = level_state.wall or lzr_globals.DEFAULT_WALL_NODE
		level_state.ceiling = level_state.ceiling or lzr_globals.DEFAULT_CEILING_NODE
		level_state.floor = level_state.floor or lzr_globals.DEFAULT_FLOOR_NODE
		level_state.ambient = level_state.ambient or lzr_ambience.DEFAULT_AMBIENCE
		level_state.sky = level_state.sky or lzr_globals.DEFAULT_SKY
		level_state.npc_texts = level_state.npc_texts or lzr_globals.DEFAULT_NPC_TEXTS
		level_state.weather = level_state.weather or lzr_globals.DEFAULT_WEATHER
		level_state.backdrop = level_state.backdrop or lzr_globals.DEFAULT_BACKDROP
		level_state.backdrop_pos = level_state.backdrop_pos or lzr_globals.DEFAULT_BACKDROP_POS
		level_state.triggers = level_state.triggers or ""
		level_state.flags = level_state.flags or ""

		if level_state.backdrop == "ocean" then
			lzr_world.set_level_pos(table.copy(lzr_globals.BACKDROP_POS_OCEAN))
		elseif level_state.backdrop == "islands" then
			lzr_world.set_level_pos(table.copy(level_state.backdrop_pos))
		elseif level_state.backdrop == "underground" then
			lzr_world.set_level_pos(table.copy(lzr_globals.BACKDROP_POS_UNDERGROUND))
		elseif level_state.backdrop == "sky" then
			lzr_world.set_level_pos(table.copy(lzr_globals.BACKDROP_POS_SKY))
		end

		local bounding_nodes = {
			node_wall = level_state.wall,
			node_ceiling = level_state.ceiling,
			node_floor = level_state.floor,
		}

		lzr_gamestate.set_loading(true)
		lzr_gui.set_loading_gui(player)
		lzr_player.set_loading_inventory_formspec(player)
		lzr_player.set_editor_inventory(player, true)
		local done = function()
			lzr_gui.show_level_bounds(player, lzr_world.get_level_pos(), schem.size)

			if lzr_ambience.ambience_exists(level_state.ambient) then
				lzr_ambience.set_ambience(level_state.ambient)
			else
				level_state.ambient = "none"
				lzr_ambience.set_ambience("none")
			end
			if lzr_sky.sky_exists(level_state.sky) then
				lzr_sky.set_sky(level_state.sky)
			else
				lzr_sky.set_sky(lzr_globals.DEFAULT_SKY)
			end
			if lzr_weather.weather_exists(level_state.weather) then
				lzr_weather.set_weather(level_state.weather)
			else
				lzr_weather.set_weather(lzr_globals.DEFAULT_WEATHER)
			end

			lzr_player.set_editor_inventory(player)
			lzr_gui.set_editor_gui(player)

			if level_state.flags and level_state.flags ~= "" then
				minetest.chat_send_player(player:get_player_name(), S("This level contains level flags: @1", tostring(level_state.flags)))
			end

			lzr_gamestate.set_loading(false)
			minetest.log("action", "[lzr_editor] Loaded level "..filename)
		end
		lzr_levels.prepare_and_build_custom_level(lzr_world.get_level_pos(), schem, "start", nil, bounding_nodes, triggers_str, flags_str, done)

		level_state.file_name = level_name
		minetest.log("action", "[lzr_editor] Started loading level from "..filename.." ...")
		return true
	else
		minetest.log("error", "[lzr_editor] Failed to read level from "..filename)
		return false
	end
end


minetest.register_chatcommand("editor_load", {
	description = S("Load level"),
	params = S("<level name>"),
	func = function(name, param)
		local player = minetest.get_player_by_name(name)
		if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
			return false, S("Not in editor mode!")
		elseif lzr_gamestate.is_loading() then
			return false, S("The editor is already loading a level!")
		end
		if param == "" then
			return false, S("No level name provided.")
		end
		local level_name = param
		if check_for_slash(level_name) then
			return false, S("Level name must not contain slash or backslash!")
		end
		local ok = lzr_util.file_exists(minetest.get_worldpath().."/levels", level_name..".mts")
		if not ok then
			return false, S("Level file does not exist!")
		end

		local ok = load_level(level_name, player)
		if ok then
			return true, S("Level loaded.")
		else
			return false, S("Error reading level file!")
		end
	end,
})

minetest.register_chatcommand("editor", {
	description = S("Start the level editor"),
	params = "",
	func = function(name, param)
		local player = minetest.get_player_by_name(name)
		if lzr_gamestate.get_state() == lzr_gamestate.EDITOR then
			return false, S("Already in level editor!")
		elseif lzr_gamestate.is_loading() then
			return false, S("Can’t do this while loading!")
		end
		local ok = lzr_editor.enter_editor(player)
		if ok then
			return true
		else
			minetest.log("error", "[lzr_editor] Can't enter editor for unknown reason")
			return false
		end
	end,
})

minetest.register_chatcommand("reset_triggers", {
	description = S("Remove all triggers and reset them to their initial state"),
	params = "",
	func = function(name, param)
		local state = lzr_gamestate.get_state()
		if state ~= lzr_gamestate.EDITOR then
			return false, S("Not in editor mode!")
		elseif lzr_gamestate.is_loading() then
			return false, S("Can’t do this while loading!")
		end
		lzr_levels.init_triggers()
		return true, S("Triggers have been reset.")
	end
})

-- Unlimited node placement in editor mode
minetest.register_on_placenode(function(pos, newnode, placer, oldnode, itemstack)
	if placer and placer:is_player() then
		return lzr_gamestate.get_state() == lzr_gamestate.EDITOR
	end
end)

-- Don't pick node up if the item is already in the inventory
local old_handle_node_drops = minetest.handle_node_drops
function minetest.handle_node_drops(pos, drops, digger)
	if not digger or not digger:is_player() or
		lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
		return old_handle_node_drops(pos, drops, digger)
	end
	local inv = digger:get_inventory()
	if inv then
		for _, item in ipairs(drops) do
			if not inv:contains_item("main", item, true) then
				inv:add_item("main", item)
			end
		end
	end
end

lzr_gamestate.register_on_enter_state(function(state)
	if state == lzr_gamestate.EDITOR then
		local player = minetest.get_player_by_name("singleplayer")

		local inv = player:get_inventory()
		inv:set_list("main", {})
	end
end)

lzr_gamestate.register_on_exit_state(function(state)
	if state == lzr_gamestate.EDITOR then
		minetest.log("action", "[lzr_editor] Autosaving level on editor exit")
		autosave_level()
		local player = minetest.get_player_by_name("singleplayer")
		if player then
			lzr_privs.revoke_edit_privs(player)
		end
	end
end)

local show_settings_dialog = function(player, settings_state)
	if not settings_state then
		settings_state = level_state
	end

	-- Shorthand function to get the current dropdown index of
	-- a multiple-choice item (ambience, sky, weather) as well
	-- as a string to insert in the dropdown[] formspec element
	local get_current_thing = function(thing_type, thing_getter, description_getter)
		local thing_list = ""
		local current_thing = 1
		local things = thing_getter()
		for t=1, #things do
			local description
			if description_getter then
				description = description_getter(things[t])
			else
				description = things[t]
			end
			-- Construct string for dropdown[]
			thing_list = thing_list .. F(description)
			-- Current thing found!
			if things[t] == settings_state[thing_type] then
				current_thing = t
			end
			-- Append comma except at end
			if t < #things then
				thing_list = thing_list .. ","
			end
		end
		return thing_list, current_thing
	end

	local ambient_list, current_ambient = get_current_thing("ambient", lzr_ambience.get_ambiences, lzr_ambience.get_ambience_description)
	local sky_list, current_sky = get_current_thing("sky", lzr_sky.get_skies, lzr_sky.get_sky_description)
	local weather_list, current_weather = get_current_thing("weather", lzr_weather.get_weathers, lzr_weather.get_weather_description)
	local backdrop_list, current_backdrop = get_current_thing("backdrop", lzr_mapgen.get_backdrops, lzr_mapgen.get_backdrop_description)

	local level_size = lzr_world.get_level_size()

	local boundary_button = function(boundaryname, nodename, y)
		if minetest.registered_nodes[nodename] then
			return "item_image_button[7.75,"..y..";0.6,0.6;"..F(nodename)..";level_"..boundaryname.."_select;]"
		else
			local btn = "image_button[7.75,"..y..";0.6,0.6;"..F("[inventorycube{unknown_node.png{unknown_node.png{unknown_node.png")..";level_"..boundaryname.."_select;]"
			if type(nodename) == "string" then
				btn = btn .. "tooltip[level_"..boundaryname.."_select;"..F(nodename).."]"
			end
			return btn
		end
	end

	local form = "formspec_version[7]"..
		"size[9,11.7]"..
		"box[0,0;9,0.8;#00000080]"..
		"label[0.4,0.4;"..FS("Level settings").."]"..
		"field[0.5,1.3;8,0.6;level_name;"..FS("Name")..";"..F(settings_state.name).."]"..
		"label[0.5,2.3;"..FS("Size").."]"..
		"field[1.6,2.3;1,0.6;level_size_x;"..FS("X")..";"..F(tostring(settings_state.size.x)).."]"..
		"field[2.62,2.3;1,0.6;level_size_y;"..FS("Y")..";"..F(tostring(settings_state.size.y)).."]"..
		"field[3.63,2.3;1,0.6;level_size_z;"..FS("Z")..";"..F(tostring(settings_state.size.z)).."]"..
		"field[0.5,3.3;7,0.6;level_wall;"..FS("Wall node")..";"..F(settings_state.wall).."]"..
		"field[0.5,4.3;7,0.6;level_floor;"..FS("Floor node")..";"..F(settings_state.floor).."]"..
		"field[0.5,5.3;7,0.6;level_ceiling;"..FS("Ceiling node")..";"..F(settings_state.ceiling).."]"..
		boundary_button("wall", settings_state.wall, 3.3)..
		boundary_button("floor", settings_state.floor, 4.3)..
		boundary_button("ceiling", settings_state.ceiling, 5.3)..

		"field[0.5,6.3;8,0.6;level_npc_goldie;"..FS("Goldie speech")..";"..F(settings_state.npc_texts.goldie).."]"..

		"label[0.5,7.1;"..FS("Music").."]"..
		"dropdown[0.5,7.3;8,0.6;level_ambient;"..ambient_list..";"..current_ambient..";true]"..

		"label[0.5,8.2;"..FS("Sky").."]"..
		"dropdown[0.5,8.4;3.5,0.6;level_sky;"..sky_list..";"..current_sky..";true]"..

		"label[5,8.2;"..FS("Weather").."]"..
		"dropdown[5,8.4;3.5,0.6;level_weather;"..weather_list..";"..current_weather..";true]"..

		"label[0.5,9.3;"..FS("Backdrop").."]"..
		"dropdown[0.5,9.5;3.5,0.6;level_backdrop;"..backdrop_list..";"..current_backdrop..";true]"

		-- backdrop_pos is only relevant for islands backdrop
		if settings_state.backdrop == "islands" then
			form = form .. "field[5,9.5;1,0.6;level_backdrop_pos_x;"..FS("X")..";"..F(tostring(settings_state.backdrop_pos.x)).."]"..
			"field[6.02,9.5;1,0.6;level_backdrop_pos_y;"..FS("Y")..";"..F(tostring(settings_state.backdrop_pos.y)).."]"..
			"field[7.04,9.5;1,0.6;level_backdrop_pos_z;"..FS("Z")..";"..F(tostring(settings_state.backdrop_pos.z)).."]"..
			"tooltip[level_backdrop_pos_x;"..FS("X coordinate of backdrop position").."]"..
			"tooltip[level_backdrop_pos_y;"..FS("Y coordinate of backdrop position").."]"..
			"tooltip[level_backdrop_pos_z;"..FS("Z coordinate of backdrop position").."]"
		end

		form = form .. "tooltip[level_name;"..FS("Level name as shown to the player").."]"..
		"tooltip[level_size_x;"..FS("Level size along the X axis").."]"..
		"tooltip[level_size_y;"..FS("Level size along the Y axis").."]"..
		"tooltip[level_size_z;"..FS("Level size along the Z axis").."]"..
		"tooltip[level_wall;"..FS("Itemstring of node to be placed on the left, front, back and right level borders").."]"..
		"tooltip[level_floor;"..FS("Itemstring of node to be placed at the bottom of the level").."]"..
		"tooltip[level_ceiling;"..FS("Itemstring of node to be placed at the top of the level").."]"..
		"tooltip[level_npc_goldie;"..FS("Text to be shown when player interacts with Goldie the Parrot").."]"..
		"tooltip[level_ambient;"..FS("Which audio ambience to play").."]"..
		"tooltip[level_sky;"..FS("How the sky looks like. Affects color, sun, moon, stars, clouds and the time of day").."]"..
		"tooltip[level_weather;"..FS("Visual weather effects (no audio)").."]"..
		"tooltip[level_backdrop;"..FS("The world that surrounds the level").."]"..

		"button_exit[0.5,10.5;3.5,0.7;level_ok;"..FS("OK").."]"..
		"button_exit[5,10.5;3.5,0.7;level_cancel;"..FS("Cancel").."]"..
		"field_close_on_enter[level_ok;true]"
	minetest.show_formspec(player:get_player_name(), "lzr_editor:level_settings", form)
end

local show_save_load_dialog = function(player, save, level_name)
	if not level_name then
		level_name = ""
	end
	local txt_caption
	local txt_action
	local action
	if save then
		txt_caption = S("Save level as …")
		txt_action = S("Save")
		action = "save"
	else
		txt_caption = S("Load level …")
		txt_action = S("Load")
		action = "load"
	end

	local levels_files = minetest.get_dir_list(minetest.get_worldpath().."/levels", false)
	local levels = {}
	for l=1, #levels_files do
		if string.lower(string.sub(levels_files[l], -4, -1)) == ".mts" then
			local name = string.sub(levels_files[l], 1, -5)
			table.insert(levels, name)
		end
	end
	table.sort(levels)
	level_file_textlist_state = levels
	local levels_str = ""
	for l=1, #levels do
		local entry = levels[l]
		if entry == lzr_globals.AUTOSAVE_NAME then
			entry = AUTOSAVE_HIGHLIGHT .. entry
		end
		levels_str = levels_str .. entry
		if l < #levels then
			levels_str = levels_str .. ","
		end
	end

	local level_list_elem = ""
	if #levels > 0 then
		level_list_elem = "label[0.5,1.5;"..FS("File list:").."]" ..
			"textlist[0.5,2;8,4;level_list;"..levels_str.."]"
	end

	local form = "formspec_version[7]"..
		"size[9,9]"..
		"box[0,0;9,0.8;#00000080]"..
		"label[0.4,0.4;"..F(txt_caption).."]"..
		level_list_elem..
		"field[0.5,6.6;7,0.6;file_name;"..FS("File name")..";"..minetest.formspec_escape(level_name).."]"..
		"label[7.7,6.9;.mts]"..
		"button_exit[0.5,7.7;3.5,0.7;"..action..";"..F(txt_action).."]"..
		"button_exit[5,7.7;3.5,0.7;cancel;"..FS("Cancel").."]"..
		"field_close_on_enter["..action..";true]"

	local formname
	if save then
		formname = "lzr_editor:level_save"
	else
		formname = "lzr_editor:level_load"
	end
	minetest.show_formspec(player:get_player_name(), formname, form)
end

-- Remove all triggers that are out of level bounds
local clear_out_of_bounds_triggers = function()
	local cleared = 0
	local minpos, maxpos = lzr_world.get_level_bounds()
	for trigger_id, trigger in pairs(lzr_triggers.get_triggers()) do
		local trigger_pos = minetest.string_to_pos(trigger_id)
		if not vector.in_area(trigger_pos, minpos, maxpos) then
			lzr_triggers.remove_trigger(trigger_id)
			cleared = cleared + 1
		end
	end
	if cleared > 0 then
		minetest.log("info", "[lzr_editor] Removed "..cleared.." triggers that were out of bounds")
	end
end

-- Check the level area for errors
lzr_editor.check_level_errors = function()
	local minpos, maxpos = lzr_world.get_level_bounds()
	local errors = {}

	-- Test: Count teleporters (must have exactly 1)
	local teleporters = minetest.find_nodes_in_area(minpos, maxpos, "lzr_teleporter:teleporter_off")
	if #teleporters == 0 then
		table.insert(errors, "no_teleporter")
	elseif #teleporters > 1 then
		table.insert(errors, "too_many_teleporters")
	end

	-- Test: Barriers in level area (none allowed, they're only allowed for the level bounds)
	local barriers = minetest.find_nodes_in_area(minpos, maxpos, "group:barrier")
	if #barriers > 0 then
		table.insert(errors, "barriers")
	end

	-- Test: Bare gold block (none allowed)
	local gold_block = minetest.find_nodes_in_area(minpos, maxpos, "lzr_treasure:gold_block")
	if #gold_block > 0 then
		table.insert(errors, "gold_block")
	end

	-- Test: Rooted plants (none allowed, should use normal plants instead)
	local plants_on_ground = minetest.find_nodes_in_area(minpos, maxpos, "group:plant_on_ground")
	if #plants_on_ground > 0 then
		table.insert(errors, "plant_on_ground")
	end

	-- Test: More than one parrot spawner (max. 1 allowed)
	local parrot_spawners = minetest.find_nodes_in_area(minpos, maxpos, "lzr_parrot_npc:parrot_spawner")
	if #parrot_spawners > 1 then
		table.insert(errors, "too_many_parrot_spawners")
	end

	local hidden_parrot_spawners = minetest.find_nodes_in_area(minpos, maxpos, "lzr_parrot_npc:hidden_parrot_spawner")
	if #hidden_parrot_spawners > 1 then
		table.insert(errors, "too_many_hidden_parrot_spawners")
	end
	for h=1, #hidden_parrot_spawners do
		local node = minetest.get_node(hidden_parrot_spawners[h])
		local num = (node.param2 % 4) + 1
		local parrot_name = lzr_parrot_npc.get_hidden_parrot_name(num)
		if not parrot_name then
			table.insert(errors, "bad_hidden_parrot_spawner")
			break
		end
	end

	local hidden_parrot_spawners = minetest.find_nodes_in_area(minpos, maxpos, "lzr_parrot_npc:hidden_parrot_spawner")
	if #hidden_parrot_spawners > 1 then
		table.insert(errors, "too_many_hidden_parrot_spawners")
	end
	for h=1, #hidden_parrot_spawners do
		local node = minetest.get_node(hidden_parrot_spawners[h])
		local num = (node.param2 % 4) + 1
		local parrot_name = lzr_parrot_npc.get_hidden_parrot_name(num)
		if not parrot_name then
			table.insert(errors, "bad_hidden_parrot_spawner")
			break
		end
	end

	-- Test: Trigger validity check from lzr_triggers
	local trigger_check_ok, trigger_errors = lzr_triggers.check_triggers(true)
	if not trigger_check_ok then
		for e=1, #trigger_errors do
			table.insert(errors, "lzr_triggers_"..trigger_errors[e])
		end
	end

	-- Test: Additional editor-specific trigger checks
	for trigger_id, trigger in pairs(lzr_triggers.get_triggers()) do
		local trigger_pos = minetest.string_to_pos(trigger_id)
		-- Out of level bounds
		if not vector.in_area(trigger_pos, minpos, maxpos) then
			table.insert(errors, "trigger_out_of_bounds")
			break
		end
		-- Trigger is not at its expected location
		-- (in the editor, all triggers must have their ID
		-- match the location)
		if not vector.equals(trigger_pos, trigger.location) then
			table.insert(errors, "trigger_moved")
			break
		end
	end

	if #errors > 0 then
		return false, errors
	else
		return true
	end
end

-- Check the level area for non-serious problems
lzr_editor.check_level_warnings = function()
	local warnings = {}
	local minpos, maxpos = lzr_world.get_level_bounds()

	-- Test: Laser-incompatible nodes (should have none).
	-- Will look a bit ugly if a laser touches those.
	local incompatible = minetest.find_nodes_in_area(minpos, maxpos, "group:laser_incompatible")
	if #incompatible > 0 then
		table.insert(warnings, "laser_incompatible")
	end

	-- Test: Incompatible door segments
	local doors = minetest.find_nodes_in_area(minpos, maxpos, "group:door")
	if #doors > 0 then
		local half_door, inc_p2, inc_type = false, false, false
		for d=1, #doors do
			local dpos = doors[d]
			local dnode = minetest.get_node(dpos)
			local ddir = minetest.facedir_to_dir(dnode.param2)
			-- Only test upright door segments. Door segments that lie flat
			-- don't trigger warnings.
			if ddir.y == 0 then
				local altpos
				local g = minetest.get_item_group(dnode.name, "door")
				if g == 1 then
					altpos = vector.offset(dpos, 0, 1, 0)
				elseif g == 2 then
					altpos = vector.offset(dpos, 0, -1, 0)
				else
					minetest.log("error", "[lzr_editor] Door node '"..dnode.name.."' has bad door group rating: "..g)
					break
				end
				local altnode = minetest.get_node(altpos)
				local ga = minetest.get_item_group(altnode.name, "door")
				if ga == 0 then
					half_door = true
				elseif altnode.param2 ~= dnode.param2 then
					inc_p2 = true
				else
					local g_exit = minetest.get_item_group(dnode.name, "door_exit")
					local ga_exit = minetest.get_item_group(altnode.name, "door_exit")
					local g_locked = minetest.get_item_group(dnode.name, "door_locked")
					local ga_locked = minetest.get_item_group(altnode.name, "door_locked")
					if g_exit ~= ga_exit or g_locked ~= ga_locked then
						inc_type = true
					end
				end
			end
		end
		if half_door then
			table.insert(warnings, "half_door")
		end
		if inc_p2 then
			table.insert(warnings, "incompatible_door_segments_param2")
		end
		if inc_type then
			table.insert(warnings, "incompatible_door_segments_type")
		end
	end

	-- Test: No treasures to collect (should have at least 1).
	-- The level is still playable, but it will be an instant win.
	local no_treasures = true
	local chests = minetest.find_nodes_in_area(minpos, maxpos, "group:chest")
	for c=1, #chests do
		local node = minetest.get_node(chests[c])
		local g = minetest.get_item_group(node.name, "chest")
		-- Count locked and unlocked chests
		-- Opened chests with gold blocks are not counted here,
		-- they’re immediately counted as collected when the
		-- level starts.
		if g == 1 or g == 2 then
			no_treasures = false
		end
	end
	if no_treasures then
		table.insert(warnings, "no_treasures")
	end

	if #warnings > 0 then
		return false, warnings
	else
		return true
	end
end

minetest.register_chatcommand("editor_check", {
	description = S("Check current level for problems"),
	params = "",
	func = function(name, param)
		if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
			return false, S("Not in editor mode!")
		elseif lzr_gamestate.is_loading() then
			return false, S("Can’t do this while loading!")
		end
		local check1_ok, errors = lzr_editor.check_level_errors()
		local result_str = list_warnings_and_errors()
		if result_str == "" then
			return true, S("No problems found.")
		else
			-- Note: We return true because the *command* itself
			-- succeeded, it's the level that has errors.
			return true, S("The following problems were found:").."\n"..result_str
		end
	end,
})

minetest.register_on_player_receive_fields(function(player, formname, fields)
	if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
		return
	elseif lzr_gamestate.is_loading() then
		minetest.chat_send_player(player:get_player_name(), S("Can’t do this while loading!"))
		return
	end

	if formname ~= "lzr_editor:level_settings" and string.sub(formname, 1, 27) ~= "lzr_editor:select_item_page" then
		temp_settings_state = {}
	end

	local get_current_thing = function(thing_type, selected_thing, thing_getter)
		local thing_list = ""
		local current_thing = 1
		local things = thing_getter()
		selected_thing = tonumber(selected_thing)
		if not selected_thing then
			return nil
		end
		for t=1, #things do
			if t == selected_thing then
				return things[t]
			end
		end
		return nil
	end

	local pname = player:get_player_name()

	-- Read settings fields into target table (without validation)
	local parse_settings_fields = function(target_table, fields)
		if fields.level_name then
			target_table.name = fields.level_name
		end

		if not target_table.size then
			target_table.size = {}
		end
		target_table.size.x = fields.level_size_x or target_table.size.x
		target_table.size.y = fields.level_size_y or target_table.size.y
		target_table.size.z = fields.level_size_z or target_table.size.z

		target_table.floor = fields.level_floor or target_table.floor
		target_table.ceiling = fields.level_ceiling or target_table.ceiling
		target_table.wall = fields.level_wall or target_table.wall
		if fields.level_npc_goldie then
			target_table.npc_texts = {}
			target_table.npc_texts.goldie = fields.level_npc_goldie
		end

		target_table.ambient = get_current_thing("ambience", fields.level_ambient, lzr_ambience.get_ambiences) or target_table.ambient
		target_table.sky = get_current_thing("sky", fields.level_sky, lzr_sky.get_skies) or target_table.sky
		target_table.weather = get_current_thing("weather", fields.level_weather, lzr_weather.get_weathers) or target_table.weather
		target_table.backdrop = get_current_thing("backdrop", fields.level_backdrop, lzr_mapgen.get_backdrops) or target_table.backdrop

		if not target_table.backdrop_pos then
			target_table.backdrop_pos = {}
		end
		target_table.backdrop_pos.x = fields.level_backdrop_pos_x or target_table.backdrop_pos.x or "0"
		target_table.backdrop_pos.y = fields.level_backdrop_pos_y or target_table.backdrop_pos.y or "0"
		target_table.backdrop_pos.z = fields.level_backdrop_pos_z or target_table.backdrop_pos.z or "0"

	end

	local fix_settings_fields = function(target_table)
		-- Enforce size bounds
		for _, axis in pairs({"x","y","z"}) do
			target_table.size[axis] = math.floor(tonumber(target_table.size[axis]) or lzr_globals.DEFAULT_LEVEL_SIZE[axis])
			if target_table.size[axis] > lzr_globals.MAX_LEVEL_SIZE[axis] then
				target_table.size[axis] = lzr_globals.MAX_LEVEL_SIZE[axis]
			end
			if target_table.size[axis] < lzr_globals.MIN_LEVEL_SIZE[axis] then
				target_table.size[axis] = lzr_globals.MIN_LEVEL_SIZE[axis]
			end
		end

		-- Enforce backdrop limits
		if level_state.backdrop == "islands" then
			local mg_edge_min, mg_edge_max = minetest.get_mapgen_edges()
			mg_edge_max = vector.subtract(mg_edge_max, lzr_globals.MAX_LEVEL_SIZE)
			local EDGE_BUFFER = 1000 -- make sure the level is far away from the zone boundaries
			-- enforce we're in the map
			for _, axis in pairs({"x","y","z"}) do
				-- Non-numeric values get converted to 0
				target_table.backdrop_pos[axis] = math.floor(tonumber(fields["level_backdrop_pos_"..axis]) or 0)
				target_table.backdrop_pos[axis] = math.min(mg_edge_max[axis]-EDGE_BUFFER-lzr_globals.MAX_LEVEL_SIZE[axis], math.max(mg_edge_min[axis]+EDGE_BUFFER, target_table.backdrop_pos[axis]))
			end
			-- enforce that we're in the islands zone
			if target_table.backdrop_pos.z < lzr_globals.DEEP_OCEAN_Z + EDGE_BUFFER + lzr_globals.MAX_LEVEL_SIZE.z then
				target_table.backdrop_pos.z = lzr_globals.DEEP_OCEAN_Z + EDGE_BUFFER + lzr_globals.MAX_LEVEL_SIZE.z
			end
		end
	end

	local merge_settings = function(target_table, merge_table)
		for k,v in pairs(merge_table) do
			if type(v) == "table" then
				target_table[k] = table.copy(v)
			else
				target_table[k] = v
			end
		end
	end

	if formname == "lzr_editor:level_settings" then

		if not fields.level_cancel and not fields.quit and not fields.level_ok then
			-- select_item button clicked
			if fields.level_wall_select or fields.level_ceiling_select or fields.level_floor_select then
				parse_settings_fields(temp_settings_state, fields)
				local state = table.copy(level_state)
				merge_settings(state, temp_settings_state)
				fix_settings_fields(state)
				temp_settings_state = state

				local bname
				if fields.level_wall_select then
					bname = "wall"
				elseif fields.level_ceiling_select then
					bname = "ceiling"
				elseif fields.level_floor_select then
					bname = "floor"
				end
				select_item.show_dialog(pname, bname)
				return
			end
			-- Dropdown changed
			if (fields.level_sky or fields.level_backdrop or fields.level_ambient or fields.level_weather) then
				parse_settings_fields(temp_settings_state, fields)
				local state = table.copy(level_state)
				merge_settings(state, temp_settings_state)
				fix_settings_fields(level_state)

				show_settings_dialog(player, state)
				return
			end

		end

		if fields.level_cancel or (fields.quit and not fields.level_ok) then
			temp_settings_state = {}
			return
		end

		if fields.level_ok then
			local old_level_pos = table.copy(lzr_world.get_level_pos())
			local old_level_size = table.copy(lzr_world.get_level_size())
			local old_floor = level_state.floor
			local old_ceiling = level_state.ceiling
			local old_wall = level_state.wall

			parse_settings_fields(temp_settings_state, fields)
			parse_settings_fields(level_state, fields)
			if temp_settings_state then
				merge_settings(level_state, temp_settings_state)
			end
			fix_settings_fields(level_state)

			lzr_world.set_level_size(level_state.size)

			-- Some trigger nodes might have been removed if the level
			-- size was reduced, so we must clear some triggers
			clear_out_of_bounds_triggers()

			if level_state.backdrop == "ocean" then
				lzr_world.set_level_pos(table.copy(lzr_globals.BACKDROP_POS_OCEAN))
			elseif level_state.backdrop == "islands" then
				lzr_world.set_level_pos(table.copy(level_state.backdrop_pos))
			elseif level_state.backdrop == "underground" then
				lzr_world.set_level_pos(table.copy(lzr_globals.BACKDROP_POS_UNDERGROUND))
			elseif level_state.backdrop == "sky" then
				lzr_world.set_level_pos(table.copy(lzr_globals.BACKDROP_POS_SKY))
			end

			local rebuild_room, relocate_room = false, false
			local nodes = {}
			nodes.node_floor = level_state.floor
			nodes.node_ceiling = level_state.ceiling
			nodes.node_wall = level_state.wall
			if old_floor ~= nodes.node_floor or old_ceiling ~= nodes.node_ceiling or old_wall ~= nodes.node_wall then
				for k,v in pairs(nodes) do
					if not minetest.registered_nodes[v] then
						nodes[k] = lzr_globals.DEFAULT_WALL_NODE
						level_state[string.sub(k, 6)] = lzr_globals.DEFAULT_WALL_NODE
					end
				end
				rebuild_room = true
			elseif not vector.equals(lzr_world.get_level_size(), old_level_size) then
				rebuild_room = true
			end
			if not vector.equals(lzr_world.get_level_pos(), old_level_pos) then
				relocate_room = true
				rebuild_room = true
			end

			if relocate_room or rebuild_room then
				minetest.log("action", "[lzr_editor] Autosaving level on level rebuild")
				save_level(lzr_globals.AUTOSAVE_NAME, true)
			end

			local ca_nodes, ca_param2s, relocate_size
			if relocate_room then
				local new_size = lzr_world.get_level_size()
				relocate_size = table.copy(new_size)
				if relocate_size.x > old_level_size.x then relocate_size.x = old_level_size.x end
				if relocate_size.y > old_level_size.y then relocate_size.y = old_level_size.y end
				if relocate_size.z > old_level_size.z then relocate_size.z = old_level_size.z end
				-- Copy the room and reset the old level area
				-- Area will be pasted after the room resize
				ca_nodes, ca_param2s = lzr_util.copy_area(old_level_pos, relocate_size)
				lzr_levels.reset_level_area(false, old_level_pos, old_level_size)
			end

			local after_rebuild = function(emerge_success)
				if emerge_success == false then
					minetest.log("error", "[lzr_editor] Could not rebuild level, the emerging has failed")
					return
				end
				minetest.log("action", "[lzr_editor] after_rebuild() called")
				if relocate_room then
					-- Relocate all triggers
					lzr_triggers.relocate_triggers(old_level_pos, lzr_world.get_level_pos())

					-- Paste the room we copied before
					lzr_util.paste_area(lzr_world.get_level_pos(), relocate_size, ca_nodes, ca_param2s)

					-- Initialize trigger node meta
					local minpos, maxpos = lzr_world.get_level_bounds()
					local trigger_nodes = minetest.find_nodes_in_area(minpos, maxpos, {"group:sender", "group:receiver"})
					for t=1, #trigger_nodes do
						local tpos = trigger_nodes[t]
						local trigger_id = minetest.pos_to_string(tpos)
						local meta = minetest.get_meta(tpos)
						meta:set_string("trigger_id", trigger_id)
					end
					minetest.log("action", "[lzr_editor] Level area relocated")
				end

				-- Move player if too far out of new level area
				move_player_to_level_if_neccessary(player)

				if lzr_ambience.ambience_exists(level_state.ambient) then
					lzr_ambience.set_ambience(level_state.ambient)
				else
					lzr_ambience.set_ambience("none")
				end
				if lzr_sky.sky_exists(level_state.sky) then
					lzr_sky.set_sky(level_state.sky)
				else
					lzr_sky.set_sky(lzr_globals.DEFAULT_SKY)
				end
				if lzr_weather.weather_exists(level_state.weather) then
					lzr_weather.set_weather(level_state.weather)
				else
					lzr_weather.set_weather(lzr_globals.DEFAULT_WEATHER)
				end

				if rebuild_room then
					lzr_gui.set_editor_gui(player)
					lzr_player.set_editor_inventory(player)
					lzr_gamestate.set_loading(false)
				end

				lzr_gui.show_level_bounds(player, lzr_world.get_level_pos(), lzr_world.get_level_size())

				minetest.log("action", "[lzr_editor] Finished setting up new level area")
			end

			if rebuild_room then
				minetest.log("action", "[lzr_editor] Resizing level area and/or updating level boundaries ...")
				if relocate_room then
					lzr_levels.reset_level_area(true, lzr_world.get_level_pos(), lzr_world.get_level_size())
				end
				-- Resize room. This action is asynchronous

				lzr_gui.set_loading_gui(player)
				lzr_player.set_editor_inventory(player, true)
				lzr_player.set_loading_inventory_formspec(player)
				lzr_gamestate.set_loading(true)

				lzr_levels.resize_room(old_level_size, lzr_world.get_level_size(), nodes, after_rebuild)
				minetest.log("action", "[lzr_editor] Finished resizing level area and/or updating level boundaries")
			else
				-- If room does not need to be rebuilt, we can call after_rebuild with success
				-- immediately
				after_rebuild(true)
			end

		end
		return
	-- Select level boundary
	elseif string.sub(formname, 1, 27) == "lzr_editor:select_item_page" then
		local rest = string.sub(formname, 28, string.len(formname))
		local split = string.split(rest, "%%", true, 2)
		local page = tonumber(split[1])
		local boundaryname = split[2]

		-- Change page in select_item dialog
		if page ~= nil then
			if fields.previous and page > 1 then
				select_item.show_dialog_page(player:get_player_name(), boundaryname, page - 1)
				return
			elseif fields.next then
				select_item.show_dialog_page(pname, boundaryname, page + 1)
				return
			end
		end

		local item
		for field,_ in pairs(fields) do
			if string.sub(field, 1, 5) == "item_" then
				item = string.sub(field, 6, string.len(field))
				break
			end
		end
		if item then
			temp_settings_state[boundaryname] = item
			local state = table.copy(level_state)
			merge_settings(state, temp_settings_state)
			show_settings_dialog(player, state)
			return
		elseif fields.quit or fields.cancel then
			select_item.reset_player_info(pname)

			local state = table.copy(level_state)
			merge_settings(state, temp_settings_state)
			show_settings_dialog(player, state)
		end

	elseif formname == "lzr_editor:level_save" or formname == "lzr_editor:level_load" then
		if fields.level_list then
			local evnt = minetest.explode_textlist_event(fields.level_list)
			if evnt.type == "CHG" or evnt.type == "DBL" then
				local file = level_file_textlist_state[evnt.index]
				if file then
					if formname == "lzr_editor:level_save" then
						show_save_load_dialog(player, true, file)
					else
						show_save_load_dialog(player, false, file)
					end
					return
				end
			end
		elseif (fields.load or (formname == "lzr_editor:level_load" and fields.key_enter)) and fields.file_name then
			if fields.file_name == "" then
				minetest.chat_send_player(pname, S("No level name provided."))
				return
			end
			if check_for_slash(fields.file_name) then
				minetest.chat_send_player(pname, S("File name must not contain slash or backslash!"))
				return false
			end
			local exists = lzr_util.file_exists(minetest.get_worldpath().."/levels", fields.file_name..".mts")
			if not exists then
				minetest.chat_send_player(pname, S("Level file does not exist!"))
				return
			end
			local ok, filename = load_level(fields.file_name, player)
			if ok then
				minetest.chat_send_player(pname, S("Level loaded."))
			else
				minetest.chat_send_player(pname, S("Error reading level file!"))
			end
		elseif (fields.save or (formname == "lzr_editor:level_save" and fields.key_enter)) and fields.file_name then
			if fields.file_name == "" then
				minetest.chat_send_player(pname, S("No level name provided."))
				return
			end
			if check_for_slash(fields.file_name) then
				minetest.chat_send_player(pname, S("File name must not contain slash or backslash!"))
				return false
			end
			local ok, filename, filename2 = save_level(fields.file_name)
			if ok and filename and filename2 then
				minetest.chat_send_player(pname, S("Level saved to @1 and @2.", filename, filename2))
			elseif ok and filename then
				minetest.chat_send_player(pname, S("Level saved to @1, but could not write metadata to @2.", filename, filename2))
			else
				minetest.chat_send_player(pname, S("Error writing level file!"))
			end
		end
		return
	end

	if fields.__lzr_level_editor_exit then
		exit_editor()
		return
	elseif fields.__lzr_level_editor_settings then
		show_settings_dialog(player)
		return
	elseif fields.__lzr_level_editor_save then
		show_save_load_dialog(player, true, level_state.file_name)
		return
	elseif fields.__lzr_level_editor_load then
		show_save_load_dialog(player, false, level_state.file_name)
		return
	elseif fields.__lzr_level_editor_get_item then
		lzr_getitem.show_formspec(pname)
		return
	end
end)

-- Returns list of all custom level file names, sorted.
-- The file name suffix is omitted.
lzr_editor.get_custom_levels = function()
	local time1 = minetest.get_us_time()
	local levels_files = minetest.get_dir_list(minetest.get_worldpath().."/levels", false)
	local levels = {}
	for l=1, #levels_files do
		if string.lower(string.sub(levels_files[l], -4, -1)) == ".mts" then
			local name = string.sub(levels_files[l], 1, -5)
			table.insert(levels, name)
		end
	end
	table.sort(levels)
	local time2 = minetest.get_us_time()
	minetest.log("verbose", "[lzr_editor] lzr_editor.get_custom_levels() took "..time2-time1.." µs")
	return levels
end

-- Returns proper level name of a custom level.
-- The proper level name is read from a matching CSV file.
-- If no such file is found, the empty string is returned.
-- * level_name: Level file name without suffix
-- * with_fallback: If true, will return "Untitled (<file name>)"
--   instead of the empty string if the level name is empty or undefined
lzr_editor.get_custom_level_name = function(level_name, with_fallback)
	local csv_file = io.open(minetest.get_worldpath().."/levels/"..level_name..".csv", "r")
	local pname
	if csv_file then
		local csv_string = csv_file:read("*a")
		csv_file:close()
		local csv_parsed = lzr_csv.parse_csv(csv_string)
		if csv_parsed and #csv_parsed >= 1 then
			pname = csv_parsed[1][2]
		end
	end
	if not pname then
		pname = ""
	end
	if pname == "" and with_fallback then
		return S("Untitled (@1)", level_name)
	end
	return pname
end

-- Expose invisible blocks in editor mode
local invis_display_timer = 0
local INVIS_DISPLAY_UPDATE_TIME = 1
local INVIS_DISPLAY_RANGE = 8
minetest.register_globalstep(function(dtime)
	local state = lzr_gamestate.get_state()
	if state ~= lzr_gamestate.EDITOR then
		return
	end
	invis_display_timer = invis_display_timer + dtime
	if invis_display_timer < INVIS_DISPLAY_UPDATE_TIME then
		return
	end
	invis_display_timer = 0

	local player = minetest.get_player_by_name("singleplayer")
	if not player then
		return
	end
	local pos = vector.round(player:get_pos())
	local r = INVIS_DISPLAY_RANGE
	local vm = minetest.get_voxel_manip()
	local emin, emax = vm:read_from_map(vector.offset(pos, -r, -r, -r), vector.offset(pos, r, r, r))
	local area = VoxelArea:new({MinEdge = emin, MaxEdge = emax})
	local data = vm:get_data()
	for x=pos.x-r, pos.x+r do
	for y=pos.y-r, pos.y+r do
	for z=pos.z-r, pos.z+r do
		local vi = area:index(x,y,z)
		local nodename = minetest.get_name_from_content_id(data[vi])
		local texture
		-- TODO: Find a way to mark water source barriers.
		-- (as of MT 5.9.1, particles are invisible when underwater)
		if minetest.get_item_group(nodename, "rain_membrane") > 0 then
			texture = "lzr_core_rain_membrane.png"
		elseif minetest.get_item_group(nodename, "barrier") > 0 then
			texture = "lzr_core_barrier.png"
		end
		if texture then
			minetest.add_particle({
				pos = {x=x, y=y, z=z},
				texture = texture,
				glow = minetest.LIGHT_MAX,
				size = 8,
				expirationtime = 1.1,
			})
		end
	end
	end
	end
end)

minetest.register_chatcommand("editor_clear", {
	privs = {},
	params = "[ clear | regenerate ]",
	description = S("Remove all blocks in the current level area or regenerate the map"),
	func = function(name, param)
		local state = lzr_gamestate.get_state()
		if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
			return false, S("Not in editor mode!")
		elseif lzr_gamestate.is_loading() then
			return false, S("Can’t do this while loading!")
		end
		local pos = lzr_world.get_level_pos()
		local size = lzr_world.get_level_size()
		local bounding_nodes = {
			node_wall = level_state.wall,
			node_ceiling = level_state.ceiling,
			node_floor = level_state.floor,
		}
		local done = function()
			lzr_gamestate.set_loading(false)
			minetest.chat_send_player(name, S("Level cleared."))
		end
		-- Clear level by removing all blocks
		if param == "" or param == "clear" then
			minetest.log("action", "[lzr_editor] Autosaving level before clearing it")
			autosave_level()
			minetest.log("action", "[lzr_editor] Clearing level ...")

			-- Clearing the level counts as "loading". This blocks all
			-- other commands until the operation is complete.
			lzr_gamestate.set_loading(true)

			lzr_triggers.reset_triggers()
			lzr_levels.build_room({pos=pos, size=size, nodes=bounding_nodes, clear=true, clear_border=false, callback_done=done})
			return true, S("Clearing level …")
		-- Clear level by regenerating the map
		elseif param == "regenerate" then
			minetest.log("action", "[lzr_editor] Autosaving level before clearing it")
			autosave_level()
			minetest.log("action", "[lzr_editor] Clearing level ...")

			lzr_gamestate.set_loading(true)

			lzr_triggers.reset_triggers()
			lzr_levels.reset_level_area(true, pos, size)
			lzr_levels.build_room({pos=pos, size=size, nodes=bounding_nodes, callback_done=done})
			return true, S("Clearing level …")
		else
			-- Invalid parameter
			return false
		end
	end,
})

-- Show a warning when issuing a WorldEdit command in the editor.
-- Using WorldEdit to change blocks can lead to an invalid level state.
-- In particular, it might break the trigger definitions
minetest.register_on_chatcommand(function(name, command, params)
	local cdef = minetest.registered_chatcommands[command]
	if not cdef then
		return
	end
	if lzr_editor.worldedit_warning then
		return
	end
	if lzr_gamestate.get_state() ~= lzr_gamestate.EDITOR then
		return
	end
	local origin = cdef.mod_origin
	if cdef.mod_origin == "worldedit" or cdef.mod_origin == "worldedit_commands" or cdef.mod_origin == "worldedit_shortcommands" then
		local message = S("WARNING: Changing a trigger block with a WorldEdit command may break the triggers. You may need to call /reset_triggers after doing so.")
		minetest.chat_send_player(name, minetest.colorize(TXT_COLOR_WARNING, message))
		lzr_editor.worldedit_warning = true
	end
end)
