-- LUALOCALS < ---------------------------------------------------------
local ItemStack, core, math, nc, next, pairs, string, table, vector
    = ItemStack, core, math, nc, next, pairs, string, table, vector
local math_floor, math_pi, math_random, string_format, table_concat
    = math.floor, math.pi, math.random, string.format, table.concat
-- LUALOCALS > ---------------------------------------------------------

nc.amcoremod()

local hidden = nc.setting_bool(
	"nc_yctiwy_hide",
	false,
	"Offline Players - hide entities",
	[[By default, players' offline "ghosts" and inventories are
	displayed as entities. Enabling this option hides
	those, reducing resource impact on both client and server,
	but also preventing players from accessing offline player
	inventories. The offline database is still maintained, in
	case the entities are later reenabled.]]
)

local notime = nc.setting_bool(
	"nc_yctiwy_notime",
	false,
	"Offline Players - hide offline time",
	[[By default, players' offline "ghosts" display information
	about how long the player has been offline (in in-game time).
	Enabling this option hides this information.]]
)

local halflife = nc.setting_float(
	"nc_yctiwy_halflife",
	30,
	"Offline Players - marker decay half-life",
	[[Offline player markers show visible "decay" as players are
	continuously offline for long periods of time. This is
	the number of days that it takes for half of the
	remaining color to bleed out of the marker. Setting it
	to <= 0 disables decay.]]
) * 86400

------------------------------------------------------------------------
-- DATABASE

local modname = core.get_current_modname()
local modstore = core.get_mod_storage()

local db = modstore:get_string("db")
if db == "" then db = nil end
db = db and core.deserialize(db)
db = db or {}

local drop = modstore:get_string("drop")
if drop == "" then drop = nil end
drop = drop and core.deserialize(drop)
drop = drop or {}

local savedb
do
	local saved = {}
	local function sersave(key, t)
		local str = next(t) and core.serialize(t) or ""
		if saved[key] == str then return end
		modstore:set_string(key, str)
		saved[key] = str
	end
	savedb = function()
		sersave("db", db)
		return sersave("drop", drop)
	end
end

if not (next(db) or next(drop)) then
	function nc.yctiwy_import(newdb, newdrop)
		db = newdb
		drop = newdrop
		savedb()
		nc.yctiwy_import = nil
	end
end

local function savestate(player)
	if not nc.player_visible(player) then
		db[player:get_player_name()] = nil
		return
	end
	local ent = {
		pos = player:get_pos(),
		inv = {}
	}
	ent.pos.y = ent.pos.y + 1
	local inv = player:get_inventory()
	for i = 1, inv:get_size("main") do
		local stack = inv:get_stack("main", i)
		ent.inv[i] = stack:to_string()
	end
	ent.seen = core.get_gametime()
	db[player:get_player_name()] = ent
end

------------------------------------------------------------------------
-- TELEPORT COMMAND

local teleport = core.registered_chatcommands.teleport
if teleport and teleport.func then
	local cannot = nc.translate("Cannot teleport an offline player.")
	local oldfunc = teleport.func
	teleport.func = function(caller, ...)
		local oldget = core.get_player_by_name
		local function helper(...)
			core.get_player_by_name = oldget
			return ...
		end
		function core.get_player_by_name(name, ...)
			local player = oldget(name, ...)
			if player then return player end
			local s = db[name]
			if s and s.pos then
				return {
					get_pos = function() return s.pos end,
					set_pos = function()
						core.after(0, function()
								return core.chat_send_player(caller, cannot)
							end)
					end,
					get_attach = function() end,
				}
			end
		end
		return helper(oldfunc(caller, ...))
	end
end

------------------------------------------------------------------------
-- MISC/UTILITY/COMMON

local function areaunloaded(pos)
	return nc.near_inactive(pos, nil, 1)
end

local function box(n) return {-n, -n, -n, n, n, n} end

local descs = {
	unk = "Offline Player",
	min = "Offline @1 minute(s)",
	hr = "Offline @1 hour(s)",
	day = "Offline @1 day(s)",
	week = "Offline @1 week(s)",
	year = "Offline @1 year(s)",
}
for _, v in pairs(descs) do nc.translate_inform(v) end

local function agedesc(t)
	if notime or (not t) or t < 0 then return nc.translate(descs.unk) end
	t = math_floor(t / 60)
	if t < 120 then return nc.translate(descs.min, t) end
	t = math_floor(t / 60)
	if t < 72 then return nc.translate(descs.hr, t) end
	t = math_floor(t / 24)
	if t < 28 then return nc.translate(descs.day, t) end
	t = math_floor(t / 7)
	if t < 52 then return nc.translate(descs.week, t) end
	t = math_floor(t / 52.1429)
	return nc.translate(descs.year, t)
end

local function getdesc(pname, dbentry, stack)
	local d = agedesc(dbentry.seen and nc.gametime - dbentry.seen)
	.. "\n" .. nc.notranslate(pname)
	if stack then return d .. "\n" .. nc.touchtip_stack(stack) end
	return d
end

local function spawnentity(pos, subname, values)
	if areaunloaded(pos) then return end
	local o = core.add_entity(pos, modname .. ":" .. subname)
	o = o and o:get_luaentity()
	if not o then return end
	nc.log("info", modname .. ": spawned " .. subname .. " at "
		.. core.pos_to_string(pos, 2) .. (values.pname
			and (" for " .. values.pname) or ""))
	for k, v in pairs(values) do o[k] = v end
	return o:yctiwy_check()
end

local function entrystack(entry, slot)
	if not entry then return end
	if entry.taken and entry.taken[slot] then return end
	local stack = entry.inv and entry.inv[slot]
	if (not stack) or (stack == "") then return end
	stack = ItemStack(stack)
	local def = core.registered_items[stack:get_name()]
	if (not def) or def.virtual_item then return end
	return stack
end

------------------------------------------------------------------------
-- INVENTORY ENTITIES

local thiefdata = {}

core.register_entity(modname .. ":slotent", {
		initial_properties = nc.stackentprops(),
		is_yctiwy = true,
		slot = 1,
		loose = 0,
		yctiwy_check = function(self)
			local ent = db[self.pname]
			if core.get_player_by_name(self.pname) then
				return self.object:remove()
			end
			local stack = entrystack(ent, self.slot)
			if not stack then return self.object:remove() end
			local props = nc.stackentprops(stack)
			props.visual_size.x = props.visual_size.x / 2
			props.visual_size.y = props.visual_size.y / 2
			if props.is_visible then
				props.collisionbox = box(0.2)
			end
			if not self.yawed then
				self.yawed = true
				self.object:set_yaw(math_random() * math_pi * 2)
			end
			self.rotating = self.rotating or (math_random(1, 2) == 1) and 0.1 or -0.1
			props.automatic_rotate = self.rotating
			self.object:set_properties(props)
			self.description = getdesc(self.pname, ent, stack)
		end,
		on_punch = function(self, puncher)
			local ent = db[self.pname]

			local punchname = puncher and puncher:get_player_name()
			if not punchname then return end
			local state = thiefdata[punchname]
			if state and (state.target ~= self.pname or state.slot ~= self.slot
				or state.exp < nc.gametime) then state = nil end
			state = state or {
				target = self.pname,
				slot = self.slot,
				final = nc.gametime + 2
			}
			state.exp = nc.gametime + 2
			thiefdata[punchname] = state
			if nc.gametime < state.final then return end

			local stack = entrystack(ent, self.slot)
			if not stack then return end

			local function steallog(desc)
				nc.log("action", string_format(
						"%s: %q %s %q slot %d item %q at %s",
						modname, puncher:get_player_name(), desc,
						self.pname, self.slot, stack:to_string(),
						core.pos_to_string(ent.pos, 0)))
			end
			local rp = vector.round(ent.pos)
			if core.is_protected(rp, punchname) and
			not core.is_protected(rp, self.pname) then
				steallog("attempts to steal")
				core.record_protection_violation(rp, punchname)
				return
			end
			steallog("steals")

			ent.taken = ent.taken or {}
			ent.taken[self.slot] = true
			savedb()
			stack = puncher:get_inventory():add_item("main", stack)
			if not stack:is_empty() then
				nc.item_eject(self.object:get_pos(), stack)
			end
			return self.object:remove()
		end
	})

local s = 0.25
local offsets = {
	{x = -s, y = -s, z = -s},
	{x = s, y = -s, z = -s},
	{x = -s, y = s, z = -s},
	{x = s, y = s, z = -s},
	{x = -s, y = -s, z = s},
	{x = s, y = s, z = s},
	{x = -s, y = s, z = s},
	{x = s, y = -s, z = s}
}

local function spawninv(name, entry, existdb)
	entry = entry or db[name]
	if not entry then return end

	for i = 1, #offsets do
		if not existdb[name .. ":" .. i] and entrystack(entry, i) then
			local p = vector.add(offsets[i], entry.pos)
			spawnentity(p, "slotent", {
					pname = name,
					slot = i
				})
		end
	end
end

------------------------------------------------------------------------
-- MARKER ENTITY

local function markertexture(pname)
	local colors = {nc.player_model_colors(pname)}
	while #colors > 3 do colors[#colors] = nil end
	for i = 1, #colors do
		colors[i] = string_format("(%s_marker_%d.png^[multiply:%s)",
			modname, i, colors[i])
	end
	local age = core.get_gametime() - (db[pname] or {seen = 0}).seen
	local decay = (halflife <= 0) and 0 or math_floor(240 * (1 - 0.5 ^ (age / halflife)))
	return string_format("%s^(%s_marker_1.png^%s_marker_2.png^%s_marker_3.png"
		.. "^[multiply:#a0a0a0^[opacity:%d)",
		table_concat(colors, "^"), modname, modname, modname, decay)
end

core.register_entity(modname .. ":marker", {
		initial_properties = {
			visual = "sprite",
			textures = {"nc_player_wield_slot.png"},
			collisionbox = box(0.1),
			visual_size = {x = 0.2, y = 0.2},
			is_visible = true,
			static_save = false
		},
		is_yctiwy = true,
		yctiwy_check = function(self)
			if (not self.pname) or core.get_player_by_name(self.pname)
			or (not core.player_exists(self.pname)) or (not db[self.pname]) then
				return self.object:remove()
			end
			self.object:set_properties({textures = {markertexture(self.pname)}})
			self.description = getdesc(self.pname, db[self.pname])
		end,
		on_punch = function(self)
			self.object:set_properties({pointable = false})
			self.pointtime = 2
			self.on_step = function(me, dtime)
				local pt = me.pointtime or 0
				pt = pt - dtime
				if pt > 0 then
					me.pointtime = pt
				else
					me.object:set_properties({pointable = true})
					me.pointtime = nil
					me.on_step = nil
				end
			end
		end
	})

local function spawnmarker(name, entry, existdb)
	if existdb[name .. ":"] then return end

	entry = entry or db[name]
	if not entry then return end

	spawnentity(entry.pos, "marker", {
			description = getdesc(name, entry),
			pname = name
		})
end

------------------------------------------------------------------------
-- STOLEN ITEMS

local takenitem = modname .. ":taken"
nc.register_virtual_item(takenitem, {
		description = "",
		inventory_image = "[combine:1x1",
		hotbar_type = "yctiwy_taken",
	})

nc.register_aism({
		itemnames = takenitem,
		interval = 1,
		chance = 1,
		action = function(stack)
			local exp = stack:get_meta():get_float("exp") or 0
			if exp < nc.gametime then return "" end
		end
	})

------------------------------------------------------------------------
-- PLAYER HOOKS

local recheck_timer = 0

core.register_on_leaveplayer(function(player)
		recheck_timer = 0
		savestate(player)
		return savedb()
	end)

core.register_on_shutdown(function()
		for _, pl in pairs(core.get_connected_players()) do
			savestate(pl)
		end
		return savedb()
	end)

core.register_on_joinplayer(function(player)
		recheck_timer = 0
		local name = player:get_player_name()
		local ent = db[name]
		if (not ent) or (not ent.taken) then return end
		local inv = player:get_inventory()
		local takestack = ItemStack(takenitem)
		takestack:get_meta():set_float("exp", nc.gametime + 4)
		for k in pairs(ent.taken) do
			inv:set_stack("main", k, takestack)
		end
		for _, o in pairs(core.luaentities) do
			if o and o.is_yctiwy and o.pname == name then
				o.object:remove()
			end
		end
	end)

------------------------------------------------------------------------
-- TIMER

local function dumpinv(ent)
	if nc.stasis then return end
	if areaunloaded(ent.pos) then return end
	for k, v in pairs(ent.inv) do
		if not (ent.taken and ent.taken[k]) then
			local stack = ItemStack(v)
			if not stack:is_empty() then
				if core.add_item(ent.pos, stack) then
					core.log(string_format("%s: deleted player %q"
							.. " drops slot %d item %q at %s",
							modname, ent.pname, k, stack:to_string(), core.pos_to_string(ent.pos, 0)))
					ent.inv[k] = ""
				else
					return
				end
			end
		end
	end
	return true
end

local function dropplayer(name, ent, skipsave)
	if nc.stasis then return end
	ent = ent or db[name]
	if not ent then return end
	ent.pname = name
	drop[#drop + 1] = ent
	db[name] = nil
	return skipsave or savedb()
end

local oldremove = core.remove_player
function core.remove_player(name, ...)
	local function helper(...)
		dropplayer(name)
		return ...
	end
	return helper(oldremove(name, ...))
end

core.register_globalstep(function(dtime)
		recheck_timer = recheck_timer - dtime
		if recheck_timer > 0 then return end
		recheck_timer = 3 + math_random() * 2

		if not hidden then
			for _, ent in pairs(core.luaentities) do
				if ent.yctiwy_check then
					ent:yctiwy_check()
				end
			end
		end

		local rollcall = {}
		for _, pl in pairs(core.get_connected_players()) do
			rollcall[pl:get_player_name()] = true
			savestate(pl)
		end

		local existdb = {}
		if not hidden then
			for _, ent in pairs(core.luaentities) do
				if ent.is_yctiwy then
					existdb[(ent.pname or "") .. ":" .. (ent.slot or "")] = true
				end
			end
		end
		for name, ent in pairs(db) do
			if not core.player_exists(name) then
				dropplayer(name, ent, true)
			elseif not (hidden or rollcall[name]) then
				spawnmarker(name, ent, existdb)
				spawninv(name, ent, existdb)
			end
		end

		local batch = drop
		drop = {}
		for _, ent in pairs(batch) do
			if not dumpinv(ent) then
				drop[#drop + 1] = ent
			end
		end

		return savedb()
	end)
