-- Portal Pro :: Modules/Comms.lua
--
-- Peer version checking.
--
-- Addons have no internet access, so there is no way to ask CurseForge whether a newer
-- build exists. The only mechanism available is gossip: broadcast our own version to other
-- players running Portal Pro, listen for theirs, and notice when someone reports higher.
--
-- These are addon messages, not chat. They are invisible to every player, including those
-- without the addon — the client drops messages whose prefix the receiver hasn't
-- registered, before they ever reach Lua.
--
-- Classic Era has no CHANNEL distribution for addon messages, so Trade chat is unreachable.
-- Our reach is GUILD, the current group, and SAY range. For a mage parked at a bank all
-- evening, SAY is actually the channel that finds other mages.

local ADDON_NAME, PP = ...
local U, C = PP.U, PP.C

local Comms = PP:NewModule("Comms")
PP.Comms = Comms

local PREFIX      = "PortalPro"   -- max 16 characters
local MSG_VERSION = "V"

-- The server allows a burst of 10 addon messages per prefix, recovering at 1/second. We
-- send single short messages minutes apart, so no queueing library is needed.
local SAY_INTERVAL   = 600   -- periodic local broadcast
local SEND_COOLDOWN  = 60    -- minimum gap between sends on any one channel
local REPLY_COOLDOWN = 300   -- per-player, so two addons can't ping-pong

local sessionAnnounced = false
local lastSend  = {}
local lastReply = {}

Comms.newestSeen = nil        -- highest version string observed this session
Comms.newestFrom = nil        -- who reported it

--------------------------------------------------------------------------------
-- Version comparison
--------------------------------------------------------------------------------

--- Split a dotted numeric version into parts. Anything non-numeric ("dev", "1.2.0-beta")
--- is deliberately uncomparable rather than being coerced to zero — a build that can't say
--- what it is shouldn't be able to claim it's newer or older than anything.
local function parseVersion(str)
	if type(str) ~= "string" then return nil end
	if not str:match("^%d+[%d%.]*$") then return nil end
	local parts = {}
	for n in str:gmatch("%d+") do parts[#parts + 1] = tonumber(n) end
	return (#parts > 0) and parts or nil
end

--- @return number|nil 1 if a > b, -1 if a < b, 0 if equal, nil if either is uncomparable
function Comms:Compare(a, b)
	local pa, pb = parseVersion(a), parseVersion(b)
	if not pa or not pb then return nil end
	for i = 1, math.max(#pa, #pb) do
		-- Compare numerically, so 1.10.0 correctly beats 1.9.0 where a string compare
		-- would get it backwards.
		local x, y = pa[i] or 0, pb[i] or 0
		if x > y then return 1 end
		if x < y then return -1 end
	end
	return 0
end

function Comms:IsComparable(version)
	return parseVersion(version) ~= nil
end

--------------------------------------------------------------------------------
-- Sending
--------------------------------------------------------------------------------

local function canSend()
	-- A working copy reports "dev". Broadcasting that tells nobody anything actionable,
	-- so development builds listen but stay quiet.
	return C_ChatInfo and C_ChatInfo.SendAddonMessage and Comms:IsComparable(PP.version)
end

local function send(channel, target)
	if not canSend() then return false end

	local now = GetTime()
	local key = channel .. (target or "")
	if lastSend[key] and (now - lastSend[key]) < SEND_COOLDOWN then return false end
	lastSend[key] = now

	local ok = pcall(C_ChatInfo.SendAddonMessage, PREFIX, MSG_VERSION .. ":" .. PP.version,
		channel, target)
	if ok then PP:Debug("comms sent version to", channel, target or "") end
	return ok
end

--- Announce to everywhere currently reachable.
function Comms:Broadcast()
	if IsInGuild and IsInGuild() then send("GUILD") end
	if IsInRaid and IsInRaid() then
		send("RAID")
	elseif IsInGroup and IsInGroup() then
		send("PARTY")
	end
end

--- Local-range broadcast. The only way to reach unrelated mages standing in the same city.
function Comms:BroadcastLocal()
	local inInstance = IsInInstance and IsInInstance()
	if inInstance then return end
	send("SAY")
end

--------------------------------------------------------------------------------
-- Receiving
--------------------------------------------------------------------------------

function Comms:OnVersionReceived(version, sender, channel)
	if not self:IsComparable(version) then return end

	-- Track the highest version anyone has reported, whoever they are.
	if not self.newestSeen or (self:Compare(version, self.newestSeen) == 1) then
		self.newestSeen = version
		self.newestFrom = sender
		PP:Fire("VERSION_SEEN", version, sender)
	end

	local comparison = self:Compare(version, PP.version)
	if comparison == 1 then
		self:AnnounceUpdate()
	elseif comparison == nil then
		-- Our own version is unreadable while theirs isn't. We can't prove we're behind —
		-- but we can't prove we're current either, and this is exactly the person most
		-- likely to be running a build that shipped with its version token unsubstituted.
		-- Staying silent would strand them on it forever.
		self:AnnounceUnversioned()
	end

	-- Reply so they learn our version too, but never to a whisper: that's already a reply,
	-- and answering it would bounce a message back and forth indefinitely.
	if channel ~= "WHISPER" and self:Compare(PP.version, version) == 1 then
		local now = GetTime()
		if not lastReply[sender] or (now - lastReply[sender]) > REPLY_COOLDOWN then
			lastReply[sender] = now
			send("WHISPER", sender)
		end
	end
end

--- One quiet line, once per session. Nagging every login is how addons get uninstalled.
function Comms:AnnounceUpdate()
	if sessionAnnounced then return end
	sessionAnnounced = true

	PP:Print(("version |cff34D399%s|r is available - you're running %s."):format(
		self.newestSeen, PP.version))
end

--- For builds that can't say what version they are. Worded to be true whether you're a
--- developer running a working copy or a user whose download shipped broken.
function Comms:AnnounceUnversioned()
	if sessionAnnounced then return end
	sessionAnnounced = true

	PP:Print(("this copy doesn't report a version number. Other players are running " ..
		"|cff34D399%s|r - if you installed from CurseForge, reinstalling will give you a " ..
		"properly versioned build."):format(self.newestSeen))
end

--- Is a newer build known to exist?
--- @return boolean outdated, string|nil newestVersion
function Comms:IsOutdated()
	if not self.newestSeen then return false, nil end
	if self:Compare(self.newestSeen, PP.version) == 1 then
		return true, self.newestSeen
	end
	return false, nil
end

--- Human-readable state for the Settings screen.
--- @return string text, string colourToken
function Comms:StatusText()
	if not self:IsComparable(PP.version) then
		-- Don't claim checks are "disabled": we still listen, we just can't place
		-- ourselves against what we hear.
		if self.newestSeen then
			return ("Unversioned build - other players are running %s"):format(self.newestSeen),
				"warning"
		end
		return "This build doesn't report a version number", "textMuted"
	end
	local outdated, newest = self:IsOutdated()
	if outdated then
		return ("Version %s is available"):format(newest), "warning"
	end
	if self.newestSeen then
		return "Up to date", "positive"
	end
	return "No other players seen yet", "textMuted"
end

--------------------------------------------------------------------------------
-- Wiring
--------------------------------------------------------------------------------

function Comms:OnEnable()
	if not C_ChatInfo or not C_ChatInfo.RegisterAddonMessagePrefix then
		PP:Debug("addon messaging unavailable on this client")
		return
	end

	-- Without registering, incoming messages with this prefix are discarded by the client
	-- before Lua ever sees them.
	pcall(C_ChatInfo.RegisterAddonMessagePrefix, PREFIX)

	PP:RegisterEvent("CHAT_MSG_ADDON", function(_, prefix, message, channel, sender)
		if prefix ~= PREFIX then return end

		local from = C.ShortName(sender)
		if not from or from == PP.playerName then return end

		local kind, payload = tostring(message):match("^(%a+):(.*)$")
		if kind ~= MSG_VERSION then return end

		Comms:OnVersionReceived(payload, from, channel)
	end)

	-- Give the guild roster a moment to populate before the first broadcast.
	C_Timer.After(8, function()
		Comms:Broadcast()
		Comms:BroadcastLocal()
	end)

	-- Joining a group is a fresh set of people to compare against.
	PP:RegisterEvent("GROUP_ROSTER_UPDATE", function()
		Comms:Broadcast()
	end)

	C_Timer.NewTicker(SAY_INTERVAL, function() Comms:BroadcastLocal() end)
end

--- Test hook: pretend a peer reported this version.
function Comms:SimulatePeer(version, sender)
	self:OnVersionReceived(version, sender or "Testmage", "GUILD")
end
