-- LUALOCALS < ---------------------------------------------------------
local core, ipairs, pairs, setmetatable, string, tonumber, type
    = core, ipairs, pairs, setmetatable, string, tonumber, type
local string_gsub
    = string.gsub
-- LUALOCALS > ---------------------------------------------------------

------------------------------------------------------------------------
-- Archipelago connection abstraction: handles a single connection to
-- archipelago through our websocket layer, dealing with login,
-- receiving, tracking and updating state, and connection keepalive.
------------------------------------------------------------------------

local env = ...
local include = env.include
local S = env.S

------------------------------------------------------------------------

local util = include("util")
local deepcopy = util.deepcopy
local create_emitter = util.create_emitter
local generate_uuid = util.generate_uuid
local websocket = include("websocket")
local parse_json = core.parse_json
local write_json = core.write_json

local timeout_ping = 5
local timeout_watchdog = 20
local protocol_version = {
	major = 0,
	minor = 5,
	build = 0,
	class = "Version"
}
local items_handling = 7 -- all events

-- nil properties document configuration we expect
-- to be set elsewhere.
local prototype = {
	-- Configure connection options
	login_slotname = nil,
	login_gamename = nil,
	login_password = "",
	-- Plus any (e.g. url_target) from websocket

	-- Track server-received state
	state = nil,
}

-- Events emitted:
-- ("disconnect", ...) - network disconnect (retryable)
-- ("rejected", {...}) - access denied by server (fatal)
-- ("cmd:" .. command, message) - archipelago message received
-- ("update") - received data from server updated

-- N.B. "update" happens on any update from server, including when the
-- received state is partial/WIP. Consumers are expected to make their
-- own determination if the information received is enough to proceed
-- with. Deltas are not provided; consumers are expected to reconcile
-- all received state with all game state as needed.

-- Reset the pinger to make sure our connection doesn't time out. This
-- exercises the full connection round trip, ensuring that server-side
-- timeout watchdogs, our own local one, and any intervening firewalls
-- are kept alive.
function prototype:reset_pinger()
	if self.ping_job then self.ping_job:cancel() end
	self.ping_job = core.after(timeout_ping, function()
			if not self.sock then return end
			local slot = self.state and self.state.slot
			if not slot then return self:disconnect("pinger no slot") end
			self:send({
					cmd = "Bounce",
					slots = {slot},
					data = {ping = core.get_us_time()}
				})
			return self:reset_pinger()
		end)
end

-- Use a robust catch-all watchdog timeout to ensure that our
-- connection not only exists but is actually working for traffic
function prototype:reset_watchdog()
	if self.watchdog_job then self.watchdog_job:cancel() end
	self.watchdog_job = core.after(timeout_watchdog, function()
			self:disconnect("timeout")
		end)
end

-- Send message packets, including proper serialization
function prototype:send(cmds, cb)
	if not self.sock then
		if cb then cb(false, "no socket") end
		return
	end

	if cmds.cmd then cmds = {cmds} end
	if #cmds < 1 then return end
	local json = write_json(cmds)

	-- Luanti's JSON serializer adds .0 after all integers, which
	-- breaks Archipelago's parser when it's expecting integers
	json = string_gsub(json, "(%d+)%.0([,%]}])", "%1%2")

	return self.sock:send(json, cb)
end

-- Open a connection
function prototype:connect()
	if self.sock then return end

	-- Reuse self as websocket config so our caller can override
	-- websocket-level options (such as proxy) also.
	self.sock = websocket(self)

	-- We retain the old cached state after a disconnection, but
	-- clear it right before we connect so we have only current
	-- data in here from now on.
	self.state = nil

	-- Connect, and broadcast disconnections
	self.sock:connect(function(ok, ...)
			if ok then
				self:reset_watchdog()
				return self:reset_pinger()
			end
			return self:disconnect(...)
		end)
	self.sock.on("disconnect", function(...)
			return self:disconnect(...)
		end)

	-- Decode and dispatch messages
	self.sock.on("message", function(raw)
			local packet = parse_json(raw)
			if #packet > 0 then
				self:reset_watchdog()
			end
			for i = 1, #packet do
				local cmd = packet[i]
				util.log("action", "recv", cmd.cmd)
				self.emit("cmd:" .. cmd.cmd, cmd)
			end
		end)
end

-- Clean up and close connection
function prototype:disconnect(...)
	if self.sock then self.sock:disconnect(...) end
	if self.watchdog_job then self.watchdog_job:cancel() end
	if self.ping_job then self.ping_job:cancel() end
	self.sock = nil
	self.emit("disconnect", ...)
end

-- Reconcile checked_locations, missing_locations, and location_index.
-- The index is location_id => checked. Checked is false if the location
-- is known and missing (in missing_locations), nil if unknown.
local function reindex_locations(state, checked_saved)
	local idx = {}
	local newcl = {}
	for _, list in pairs({checked_saved, state.checked_locations}) do
		for _, id in ipairs(list or {}) do
			if not idx[id] then newcl[#newcl + 1] = id end
			idx[id] = true
		end
	end
	state.checked_locations = newcl
	local newml = {}
	for _, id in ipairs(state.missing_locations or {}) do
		if idx[id] == nil then newml[#newml + 1] = id end
		idx[id] = idx[id] or false
	end
	state.missing_locations = newml
	state.checked_location_index = idx
	util.log("action", "locations checked", #newcl, "missing", #newml)
end

-- If a table's keys can be parsed as numbers, make
-- actual-number-typed key aliases.
local function number_keys(t)
	local add = {}
	for k, v in pairs(t) do
		local n = tonumber(k)
		if n then add[n] = v end
	end
	-- separate commit pass; don't mutate while enumerating
	for k, v in pairs(add) do
		t[k] = v
	end
end

local function wire_connection_events(self)
	-- Close the connection if rejected
	self.on("cmd:ConnectionRefused", function(cmd)
			local errors = cmd.errors or {"unknown error"}
			self.emit("rejected", errors)
			return self:disconnect(cmd)
		end)

	-- Helper to abort connection if we fail to send an
	-- automated response message
	local function fatalonfail(ok, ...)
		if ok then return end
		return self:disconnect(...)
	end

	-- Helper to update state values
	local function setstate(key, val, expand)
		self.state = self.state or {}
		self.state[key] = val
		if val.slot_info then number_keys(val.slot_info) end
		local locations_dirty
		local checked_saved = self.state.checked_locations
		if expand and type(val) == "table" then
			for k, v in pairs(val) do
				if k ~= "cmd" and k ~= key then
					self.state[k] = v
					locations_dirty = locations_dirty
					or k == "checked_locations" or k == "missing_locations"
				end
			end
		end
		if locations_dirty then
			reindex_locations(self.state, checked_saved)
		end
		self.emit("update")
	end

	-- Server will send RoomInfo automatically on connect,
	-- proceed with normal handshake and initial state load
	self.on("cmd:RoomInfo", function(cmd)
			setstate("room_info", cmd, true)
			self:send({
					{cmd = "GetDataPackage"},
					{
						cmd = "Connect",
						password = self.login_password,
						game = self.login_gamename,
						name = self.login_slotname,
						uuid = generate_uuid(),
						version = protocol_version,
						items_handling = items_handling,
						-- Cannot send empty tables w/ Luanti, so we must
						-- add at least one flag here
						tags = {"Luanti/arclib"},
						slot_data = true
					}
				}, fatalonfail)
		end)
	self.on("cmd:DataPackage", function(cmd)
			for _, game in pairs(cmd.data and cmd.data.games or {}) do
				game.item_id_to_name = util.table_invert(game.item_name_to_id)
				game.location_id_to_name = util.table_invert(game.location_name_to_id)
			end
			return setstate("data_package", cmd.data)
		end)
	self.on("cmd:Connected", function(cmd)
			setstate("connected", cmd, true)
			-- Fetch and track hint data
			local keys = {}
			local players = self.state and self.state.players
			for _, p in ipairs(players or {}) do
				if p.team == cmd.team then
					keys[#keys + 1] = "_read_hints_" .. p.team .. "_" .. p.slot
				end
			end
			local sendcmds = {
				{cmd = "SetNotify", keys = keys},
				{cmd = "Get", keys = keys}
			}
			-- Scout all known locations once
			local idx = self.state and self.state.checked_location_index or {}
			local all_locations = {}
			for id in pairs(idx) do
				all_locations[#all_locations + 1] = id
			end
			if #all_locations > 0 then
				sendcmds[#sendcmds + 1] = {
					cmd = "LocationScouts",
					locations = all_locations
				}
			end
			self:send(sendcmds)
		end)
	self.on("cmd:RoomUpdate", function(cmd)
			return setstate("room_update", cmd, true)
		end)

	-- Update items received
	self.on("cmd:ReceivedItems", function(cmd)
			local index = cmd.index
			local items = cmd.items or {}
			local exist = self.state and self.state.items_received

			if index == 0 then -- replace entire list
				setstate("items_received", items)
				self.emit("update")
			elseif index == (exist and #exist or 0) then -- append to list
				for i = 1, #items do
					exist[#exist + 1] = items[i]
				end
				self.emit("update")
			else -- desync detected, request a full resync
				self:send({cmd = "Sync"}, fatalonfail)
			end
		end)

	-- Maintain list of all hints received
	local function sethints(tbl)
		self.state = self.state or {}
		self.state.hintdata = self.state.hintdata or {}
		for k, v in pairs(tbl) do
			self.state.hintdata[k] = v
		end
		local newlist = {}
		for _, list in pairs(self.state.hintdata) do
			for i = 1, #list do
				newlist[#newlist + 1] = list[i]
			end
		end
		-- Flat list of all hints across all slots
		self.state.hints = newlist
		self.emit("update")
	end
	self.on("cmd:Retrieved", function(cmd)
			return sethints(cmd.keys or {})
		end)
	self.on("cmd:SetReply", function(cmd)
			return sethints({[cmd.key] = cmd.value})
		end)

	-- Keep track of scouted location info
	self.on("cmd:LocationInfo", function(cmd)
			self.state = self.state or {}
			local locinfo = self.state.location_info or {}
			self.state.location_info = locinfo
			for _, loc in ipairs(cmd.locations or {}) do
				locinfo[loc.location] = loc
			end
			self.emit("update")
		end)

	-- Disconnect on having a packet rejected by the server
	-- (we cannot guarantee internal state is consistent anymore)
	self.on("cmd:InvalidPacket", function(cmd)
			util.log("error", "invalid packet", cmd)
			self:disconnect("invalid packet")
		end)
end

-- Constructor
local function connection(options)
	local self = deepcopy(options)
	setmetatable(self, {__index = prototype})
	self.on, self.emit = create_emitter()
	wire_connection_events(self)
	return self
end

------------------------------------------------------------------------

return connection
