local storage = core.get_mod_storage()

playersettings = {}

playersettings.registered = {}

playersettings.settingslist = {}

-- per-player values for formspec creation
playersettings.highlighted = {}
playersettings.show_technical_names = {}

playersettings.form = "formspec_version[4]"..
	"size[15,12.5]" ..
	"button[11, 9.5;3,1;bset;Set]"..
	"button[12,11;2,1;breset;Reset]"..
	"textlist[0.5,0.5;7,11;settinglist;%s;%d;false]"..
	"checkbox[0.5,12;ctechnical;Show technical names;%s]"..
	"     box[8,1;6.5,4;#222]"..
	"textarea[8,1;6.5,4;;%s;%s]"..
	"%s"

local text_no_settings = [[
No settings currently exist.

To use the settings menu, one or more mods that provide some settings must be installed.
]]

playersettings.form_fallback = (
	"formspec_version[4]"..
	"size[10,4.5]"..
	"textarea[0.5,0.5;9,2.5;;;" .. text_no_settings .."]"..
	"button_exit[4,3;2,1;quit;Close]"
)

--- Returns: setting, def
local function get_setting_at(index)
	local settingname = playersettings.settingslist[index]
	if not settingname then
		return nil, nil
	end
	return settingname, playersettings.registered[settingname]
end

local function table_concat_escaped(list, delim)
	local esc = {}
	for i, v in ipairs(list) do
		esc[i] = core.formspec_escape(v)
	end
	return table.concat(esc, delim)
end

local setting_get_no_fallback -- (name, setting, require_existence)

function playersettings.openform(player)
	if type(player) == "string" then player = core.get_player_by_name(player) end
	local name = player:get_player_name()
	if #playersettings.settingslist < 1 then
		core.show_formspec(name,"playersettings:nosettings", playersettings.form_fallback)
		return
	end

	local settingslist
	do
		local list = {}
		local technical = playersettings.show_technical_names[name]
		for _,setting in ipairs(playersettings.settingslist) do
			local def = playersettings.registered[setting]
			local row
			if technical then
				row = ("%s (%s)"):format(setting, def.type)
			else
				row = def.shortdesc
			end
			local setvalue = setting_get_no_fallback(name, setting, false)
			if setvalue ~= nil and setvalue ~= playersettings.getdefault(setting) then
				-- 'Value is set' indicator
				row = row .. " *"
			end
			table.insert(list, row)
		end
		settingslist = table_concat_escaped(list, ",")
	end

	local settingname, def = get_setting_at(playersettings.highlighted[name])
	if not def then
		playersettings.highlighted[name] = 1
		settingname, def = get_setting_at(1)
	end
	local settingvalue = playersettings.get(name, settingname)
	-- Engine bug? Even though this code uses the correct values, when pressing
	--             "Set" and then "Reset", the shown value does not update.
	--print(def.type, settingvalue)

	local extras = ""
	if def.type == "boolean" then
		extras = (
			"checkbox[8,6.5;checkbox;Enabled;%s]" ..
			"field_close_on_enter[field;false]"
		):format(
			settingvalue and "true" or "false"
		)
	elseif def.type == "string" then
		extras = (
			"field[8,6;6,1;field;Enter value:;%s]" ..
			"textarea[8,7.5;6,2;;;Allowed values: %s\nCharacters limit: %d]" ..
			"field_close_on_enter[field;false]"
		):format(
			core.formspec_escape(settingvalue),
			core.formspec_escape(def.values or "any string"),
			def.max
		)
	elseif def.type == "number" then
		local allowed = "any number"
		if def.min and def.max then
			allowed = "numbers "..def.min.." to "..def.max
		elseif def.max then
			allowed = "numbers up to "..def.max
		elseif def.min then
			allowed = "numbers "..def.min.." and up"
		end
		if def.integer then
			allowed = allowed.." (whole numbers only)"
		end
		extras = (
			"field[8,6;6,1;field;Enter value:;%s]" ..
			"textarea[8,7.5;6,2;;;Allowed values: %s]" ..
			"field_close_on_enter[field;false]"
		):format(
			core.formspec_escape(settingvalue),
			allowed
		)
	elseif def.type == "enum" then
		local selected = table.indexof(def.values, settingvalue)
		if selected == -1 then selected = 1 end
		extras = ("dropdown[8,6;6,1;dropdown;%s;%d;true]"):format(
			table_concat_escaped(table.copy(def.values), ","),
			selected
		)
	end

	local form = playersettings.form:format(
		settingslist,
		playersettings.highlighted[name],
		playersettings.show_technical_names[name] and "true" or "false",
		core.formspec_escape(def.shortdesc),
		core.formspec_escape(def.longdesc),
		extras
	)
	core.show_formspec(name,"playersettings:settings",form)
end

function playersettings.handleform(player,form,fields)
	if form ~= "playersettings:settings" then return end

	local name = player:get_player_name()

	if fields.ctechnical then
		playersettings.show_technical_names[name] = fields.ctechnical == "true"
		playersettings.openform(player)
		return
	end

	local settingname, def = get_setting_at(playersettings.highlighted[name])
	if def then
		if fields.breset then
			playersettings.reset(name, settingname)
			playersettings.openform(player)
			return
		end

		-- May also be triggered by `fields.bset`
		if def.type == "boolean" and fields.checkbox then
			playersettings.set(name,settingname,fields.checkbox == "true")
			if not fields.quit then playersettings.openform(player) end
		elseif def.type == "enum" and fields.dropdown then
			local value = def.values[tonumber(fields.dropdown)]
			if value then
				playersettings.set(name, settingname, value)
				if not fields.quit then playersettings.openform(player) end
			end
		elseif def.type == "number" and fields.field then
			local value = tonumber(fields.field)
			if value
				and ((not def.max) or (value <= def.max))
				and ((not def.min) or (value >= def.min)) then
				if def.integer then value = math.floor(value) end
				playersettings.set(name,settingname,value)
				if not fields.quit then playersettings.openform(player) end
			end
		elseif def.type == "string" and fields.field then
			local value = fields.field:sub(1, def.max)
			playersettings.set(name,settingname,value)
			if not fields.quit then playersettings.openform(player) end
		end
	end
	if fields.settinglist then
		local exp = core.explode_textlist_event(fields.settinglist)
		if exp.type == "CHG" then
			playersettings.highlighted[name] = exp.index
			playersettings.openform(player)
		end
	end
end

local function CHECK_TYPE(typename, value, readable)
	local got = type(value)
	assert(got == typename, ("Invalid %s (expected %s, got %s)"):format(readable, typename, got))
end

function playersettings.getdefault(setting)
	local def = playersettings.registered[setting]
	if def.default ~= nil then return def.default end
	if def.type == "boolean" then
		return false
	elseif def.type == "number" then
		return def.min or 0
	elseif def.type == "string" then
		return ""
	end
end

--- Return #1: Value or `nil` if no value was found
--- Return #2: Database key
setting_get_no_fallback = function(name, setting, require_existence)
	CHECK_TYPE("string", name, "player name")
	CHECK_TYPE("string", setting, "setting name")
	local key = ("%s|%s"):format(name, setting)
	if require_existence then
		assert(playersettings.registered[setting],"No such setting: "..setting)
	end
	return core.deserialize(storage:get_string(key)), key
end

function playersettings.get(name,setting)
	local value = setting_get_no_fallback(name, setting, true)
	if value ~= nil then
		return value
	else
		return playersettings.getdefault(setting)
	end
end

function playersettings.set(name,setting,value)
	local old, key = setting_get_no_fallback(name, setting, true)

	-- Compare value without the .get(...) default fallback
	local is_different = old ~= value
	if old == nil then
		local default = playersettings.getdefault(setting)
		-- Allow using the default value but do not fire callbacks if they are equal
		is_different = value ~= default
	end

	local def = playersettings.registered[setting]
	if def.type == "enum" then
		-- Prohibit unknown values
		if table.indexof(def.values, value) == -1 then
			return
		end
	end
	if is_different and def.onchange then
		if not def.onchange(name,old,value) then return end
	end
	storage:set_string(key,core.serialize(value))
	if is_different and def.afterchange then
		def.afterchange(name,old,value)
	end
end

function playersettings.reset(name, setting)
	local old, key = setting_get_no_fallback(name, setting, false)
	-- ^ 'false': Do not require 'setting' to be defined to allow removing unused settings.

	local def = playersettings.registered[setting]
	local default      = def and playersettings.getdefault(setting)
	local is_different = def and old ~= default

	if is_different and def.onchange then
		if not def.onchange(name,old,default) then return end
	end
	storage:set_string(key, "")
	if is_different and def.afterchange then
		def.afterchange(name,old,default)
	end
end

function playersettings.register(setting,def)
	CHECK_TYPE("string", setting, "setting name")
	CHECK_TYPE("table", def, "setting definition")
	assert(not playersettings.registered[setting],string.format("Setting %s is already defined",setting))

	assert(type(def.type) == "string")
	assert(type(def.shortdesc) == "string")
	assert(type(def.longdesc)  == "string")
	assert(not def.onjoin      or type(def.onjoin)      == "function")
	assert(not def.onchange    or type(def.onchange)    == "function")
	assert(not def.afterchange or type(def.afterchange) == "function")

	-- Type-specific checks
	if def.type == "string" then
		assert(type(def.values) == "string")
		def.max = def.max or 200
	elseif def.type == "enum" then
		assert(type(def.values) == "table")
	end
	local default_type = (def.type == "enum") and "string" or def.type
	assert(def.default == nil or type(def.default) == default_type)

	table.insert(playersettings.settingslist,setting)
	table.sort(playersettings.settingslist)
	playersettings.registered[setting] = def
end

function playersettings.onjoin(player)
	local name = player:get_player_name()
	for setting,def in pairs(playersettings.registered) do
		if def.onjoin then
			def.onjoin(name,playersettings.get(name,setting))
		end
	end
end

local function on_leaveplayer(player)
	-- Clean up memory
	local name = player:get_player_name()
	playersettings.highlighted[name] = nil
	playersettings.show_technical_names[name] = nil
end

if core.global_exists("unified_inventory") then
	unified_inventory.register_button("playersettings", {
		action = playersettings.openform,
		tooltip = "Settings",
		type = "image",
		image = "playersettings_settings_button.png"
	})
end

core.register_chatcommand("settings",{
	description = "Open player settings menu",
	func = playersettings.openform,
})

core.register_on_joinplayer(playersettings.onjoin)
core.register_on_leaveplayer(on_leaveplayer)
core.register_on_player_receive_fields(playersettings.handleform)

-- For testing purposes only!
--dofile(core.get_modpath(core.get_current_modname()) .. "/examples.lua")
