nm = {}
nm.playerdb = {}
nm.inspect_mode = {}
nm.inspect_query = {}
nm.write_cache = {}
nm.write_cache_thresh = 20
nm.write_cache_timeout = 30 -- seconds

local function try_load(libname)
	local success, lib
	success, lib = pcall(require, libname)
	if success then
		return lib
	else
		return nil
	end
end

local bit32, bit
bit32 = try_load("bit32")
bit = try_load("bit")

-- Test whether libraries are usable for our purpose

local bit32_big = true
if bit32 then
	local t = 67
	if bit32.lshift(t + 32768, 32) ~= 141025251164160 then
		bit32_big = false -- broken
	end
end

local bit_big = true
if bit then
	local t = 67
	if bit.lshift(t + 32768, 32) ~= 141025251164160 then
		bit_big = false -- broken
	end
end

local function write_u16(f, n)
	local s
	if bit32 then
		s = string.char(bit32.band(n, 255), bit32.rshift(n, 8))
	elseif bit then
		s = string.char(bit.band(n, 255), bit.rshift(n, 8))
	else
		s = string.char(n % 256, math.floor(n / (2 ^ 8)))
	end
	f:write(s)
end

local function read_u16(f)
	local s = f:read(2)
	if not s then
		return nil
	end
	local a, b = string.byte(s, 1, 2)
	if bit32 then
		return a + bit32.lshift(b, 8)
	elseif bit then
		return a + bit.lshift(b, 8)
	else
		return a + b * (2 ^ 8)
	end
end

local function write_s16(f, n)
	write_u16(f, n + 32768)
end

local function read_s16(f)
	local v = read_u16(f)
	if v then
		return v - 32768
	else
		return nil
	end
end

local function hash_node_pos(x, y, z)
	if bit32 and bit32_big then
		return (x + 32768) + bit32.lshift(y + 32768, 16) + bit32.lshift(z + 32768, 32)
	elseif bit and bit_big then
		return (x + 32768) + bit.lshift(y + 32768, 16) + bit.lshift(z + 32768, 32)
	else
		return (x + 32768) + ((y + 32768) * (2 ^ 16)) + ((z + 32768) * (2 ^ 32))
	end

end

local function player_db_load()
	local f = io.open(minetest.get_worldpath().."/nm_players.db", "r")
	if not f then
		nm.player_nid = 0
		return
	end
	local id = 0
	for ent in f:lines() do
		nm.playerdb[ent] = id
		id = id + 1
	end
	nm.player_nid = id
	f:close()
end

local function player_db_add(name)
	local f = io.open(minetest.get_worldpath().."/nm_players.db", "a")
	if not f then
		minetest.log("error", "[NM] Failed to open player database")
		error()
	end
	f:write(name .. "\n")
	f:close()
end

local function player_db_lookup(name, no_new_entry)
	local id = nm.playerdb[name]
	if not id and not no_new_entry then
		id = nm.player_nid
		nm.playerdb[name] = id
		nm.player_nid = nm.player_nid + 1
		player_db_add(name)
	end
	return id
end

local function player_db_lookup_pid(spid)
	local sname
	for name, pid in pairs(nm.playerdb) do
		if pid == spid then
			sname = name
			break
		end
	end
	return sname
end

local function nm_db_index()
	nm.overwrite_cache = {}
	local f = io.open(minetest.get_worldpath().."/nm.db", "rb")
	if not f then
		f = io.open(minetest.get_worldpath().."/nm.db", "wb")
		f:write("NMDB") -- char[4] magic value
		f:write(string.char(1)) -- u8 version
		f:close()
		nm.info = "0s, fresh database was created"
		return
	end
	local m = f:read(4)
	if m ~= "NMDB" then
		error("Wrong magic value '" .. m .. "', expected 'NMDB'")
	end
	local v = string.byte(f:read(1))
	if v == 1 then
		-- ok
	else
		error("Unsupported version " .. v)
	end
	local seen = {}
	local i = 0
	local x, y, z, ph
	local stime = os.time()
	local sclock = os.clock()
	while true do
		x = read_s16(f) -- s16 x
		if not x then
			break
		end
		z = read_s16(f) -- s16 z
		y = read_s16(f) -- s16 y
		f:read(2)       -- u16 pid
		ph = hash_node_pos(x, y, z)
		if seen[ph] ~= nil then
			table.insert(nm.overwrite_cache, seen[ph])
		end
		seen[ph] = i
		i = i + 1
	end
	f:close()
	seen = nil
	local eclock = os.clock()
	local etime = os.time()
	nm.info = string.format("%ds(%.2fs CPU time), %d(%.2f%%) outdated entries, %d total entries",
		os.difftime(etime, stime), eclock - sclock, #nm.overwrite_cache, (#nm.overwrite_cache / i) * 100, i)
	minetest.log("action", "[NM] Read NM database in " .. nm.info)
end

local function nm_db_lookup(sx, sy, sz)
	local f = io.open(minetest.get_worldpath().."/nm.db", "rb")
	if not f then
		minetest.log("error", "[NM] Failed to open database")
		error()
	end
	local m = f:read(4)
	if m ~= "NMDB" then
		error("Wrong magic value '" .. m .. "', expected 'NMDB'")
	end
	local v = string.byte(f:read(1))
	if v == 1 then
		-- ok
	else
		error("Unsupported version " .. v)
	end
	local x, y, z
	while true do
		x = read_s16(f) -- x
		if x == nil then
			break
		end
		if x ~= sx then
			f:read(6) -- z, y, pid
		else
			z = read_s16(f) -- z
			if z ~= sz then
				f:read(4) -- y, pid
			else
				y = read_s16(f) -- y
				if y ~= sy then
					f:read(2) -- pid
				else
					local pid = read_u16(f)
					f:close()
					return pid
				end
			end
		end
	end
	f:close()
	return nil
end

local function nm_db_lookup_multiple(poslist)
	local res = {}
	for i = 1, #poslist do
		res[i] = -1
	end
	local f = io.open(minetest.get_worldpath().."/nm.db", "rb")
	if not f then
		minetest.log("error", "[NM] Failed to open database")
		error()
	end
	local m = f:read(4)
	if m ~= "NMDB" then
		error("Wrong magic value '" .. m .. "', expected 'NMDB'")
	end
	local v = string.byte(f:read(1))
	if v == 1 then
		-- ok
	else
		error("Unsupported version " .. v)
	end
	local x, y, z, found
	while true do
		x = read_s16(f) -- x
		if x == nil then
			break
		end
		found = false
		for _, spos in ipairs(poslist) do
			if x == spos.x then
				found = true
			end
		end
		if not found then
			f:read(6) -- z, y, pid
		else
			z = read_s16(f) -- z
			found = false
			for _, spos in ipairs(poslist) do
				if x == spos.x and z == spos.z then
					found = true
				end
			end
			if not found then
				f:read(4) -- y, pid
			else
				y = read_s16(f) -- y
				found = 0
				for i, spos in ipairs(poslist) do
					if x == spos.x and z == spos.z and y == spos.y then
						found = i
					end
				end
				if found == 0 then
					f:read(2)
				else
					res[found] = read_u16(f)
				end
			end
		end
	end
	f:close()
	return res
end

local function nm_db_add(x, y, z, pid, fidx)
	local f
	if fidx then
		f = io.open(minetest.get_worldpath().."/nm.db", "r+b")
	else
		f = io.open(minetest.get_worldpath().."/nm.db", "ab")
	end
	if not f then
		minetest.log("error", "[NM] Failed to open database")
		error()
	end
	if fidx then
		f:seek("set", 4 + 1 + (8 * fidx))
	end
	write_s16(f, x)
	write_s16(f, z)
	write_s16(f, y)
	write_u16(f, pid)
	f:close()
end

local function fire_node_modify_raw(name, pos)
	local pid = player_db_lookup(name)
	if #nm.overwrite_cache > 0 then
		local fidx = table.remove(nm.overwrite_cache)
		nm_db_add(pos.x, pos.y, pos.z, pid, fidx)
	else
		nm_db_add(pos.x, pos.y, pos.z, pid)
	end
end

local function clean_write_cache()
	if #nm.write_cache <= 0 then return end
	for _, e in ipairs(nm.write_cache) do
		fire_node_modify_raw(e.name, e.pos)
	end
	nm.write_cache = {}
end

local write_cache_timer = 0
minetest.register_globalstep(function(dtime)
	write_cache_timer = write_cache_timer + dtime
	if write_cache_timer > nm.write_cache_timeout then
		clean_write_cache()
		write_cache_timer = 0
	end
end)

local function fire_node_modify(name, pos)
	if nm.write_cache_thresh > 0 then
		table.insert(nm.write_cache, {name=name, pos=pos})
		if #nm.write_cache > nm.write_cache_thresh then
			clean_write_cache()
		end
	else
		fire_node_modify_raw(name, pos)
	end
end

local function get_node_modify(pos)
	clean_write_cache()
	local pid = nm_db_lookup(pos.x, pos.y, pos.z)
	if pid == nil then
		return ""
	end
	local pname = player_db_lookup_pid(pid)
	if pname == nil then
		return -1
	end
	return pname
end

local function get_node_modify_multiple(poslist)
	clean_write_cache()
	local pids = nm_db_lookup_multiple(poslist)
	local res = {}
	for _, pid in ipairs(pids) do
		if pid == -1 then
			table.insert(res, "")
		else
			local pname = player_db_lookup_pid(pid)
			if pname == nil then
				table.insert(res, -1)
			else
				table.insert(res, pname)
			end
		end
	end
	return res
end

local function get_node_ret2human(v, pos)
	if v == "" then
		return "No information available for " .. pos .. "."
	elseif v == -1 then
		return "Found an entry for " .. pos .. " but no matching player name mapping, the database might be corrupted."
	else
		return pos .. " was last touched by " .. v .. "."
	end
end

minetest.register_on_placenode(function(pos, newnode, placer, oldnode, itemstack, pointed_thing)
	if not placer or not placer:is_player() then
		return
	end
	fire_node_modify(placer:get_player_name(), pos)
end)

minetest.register_on_dignode(function(pos, oldnode, digger)
	if not digger or not digger:is_player() then
		return
	end
	fire_node_modify(digger:get_player_name(), pos)
end)

minetest.register_chatcommand("nm", {
	params = "<x> <y> <z>",
	description = "Check who modified a node last",
	privs = {basic_privs=true},
	func = function(name, param)
		local x, y, z = param:match('(.-) (.-) (.*)')
		x = tonumber(x)
		y = tonumber(y)
		z = tonumber(z)
		if x == nil or y == nil or z == nil then
			return false, "Invalid coordinates."
		end
		local pname = get_node_modify({x=x, y=y, z=z})
		local ps = minetest.pos_to_string({x=x, y=y, z=z})
		return true, get_node_ret2human(pname, ps)
	end,
})

minetest.register_chatcommand("nm_inspect", {
	params = "",
	description = "Check who modified a node last (inspection mode)",
	privs = {basic_privs=true},
	func = function(name, param)
		if nm.inspect_mode[name] then
			nm.inspect_mode[name] = false
			if #nm.inspect_query[name] == 0 then
				return true, "Inspection mode disabled."
			end
			minetest.chat_send_player(name, "Inspection mode disabled, executing search.")
			local res = get_node_modify_multiple(nm.inspect_query[name])
			for i, pos in ipairs(nm.inspect_query[name]) do
				local pname = res[i]
				local ps = minetest.pos_to_string(pos)
				minetest.chat_send_player(name, get_node_ret2human(pname, ps))
			end
			nm.inspect_query[name] = {}
		else
			nm.inspect_mode[name] = true
			nm.inspect_query[name] = {}
			return true, "Inspection mode enabled."
		end
	end,
})

minetest.register_on_punchnode(function(pos, node, puncher, pointed_thing)
	if not puncher:is_player() then return end
	local name = puncher:get_player_name()
	if nm.inspect_mode[name] then
		table.insert(nm.inspect_query[name], pointed_thing.under)
	end
end)

minetest.register_chatcommand("nm_info", {
	params = "",
	description = "Get information about NM database & configuration",
	privs = {basic_privs=true},
	func = function(name, param)
		local a = string.format("Info: Database was read in %s, "
			.. "%d outdated entries queued for overwriting, %d unsaved changes", nm.info, #nm.overwrite_cache, #nm.write_cache)
		local cfg = a .. "\n" .. string.format("Configuration: Write cache size: %d, Write cache timeout: %ds, Libraries -> bit: ",
			nm.write_cache_thresh, nm.write_cache_timeout)
		if bit then
			cfg = cfg .. "available (big numbers: "
			if bit_big then
				cfg = cfg .. "working"
			else
				cfg = cfg .. "broken"
			end
			cfg = cfg .. "), "
		else
			cfg = cfg .. "unavailable, "
		end
		cfg = cfg .. "bit32: "
		if bit32 then
			cfg = cfg .. "available (big numbers: "
			if bit32_big then
				cfg = cfg .. "working"
			else
				cfg = cfg .. "broken"
			end
			cfg = cfg .. ")"
		else
			cfg = cfg .. "unavailable"
		end
		return true, cfg
	end,
})

minetest.register_chatcommand("nm_reindex", {
	params = "",
	description = "Reindex the NM database",
	privs = {basic_privs=true},
	func = function(name, param)
		clean_write_cache()
		nm_db_index()
		return true, "Read database in " .. nm.info
	end,
})

player_db_load()
nm_db_index()
