local MAJOR, MINOR = "LibMatic-VersionCheck-1.0", 1
assert(LibStub, MAJOR .. " requires LibStub")
local lib = LibStub:NewLibrary(MAJOR, MINOR)
if not lib then return end

-- Cross-addon update notifier for the -Matic family. Every embedding addon
-- registers its slug + version; the library speaks one shared addon-message
-- prefix for the whole family, so seven addons cost one prefix and one
-- query/report exchange instead of seven. GUILD is the channel that makes
-- this work while ungrouped; PARTY/RAID/INSTANCE_CHAT are layered on when
-- the player is actually in a group of real players.

lib.registeredAddons = lib.registeredAddons or {}   -- [slug] = { version, label, noticeText, onOutdated }
lib.outdatedNoticed = lib.outdatedNoticed or {}     -- [slug] = true once per session
lib.queryQueued = lib.queryQueued or false
lib.lastQueryAt = lib.lastQueryAt or 0
lib.groupSignature = lib.groupSignature or nil

local COMM_PREFIX = "LibMaticVC"
local KIND_QUERY = "Q"
local KIND_REPORT = "R"
local KIND_SEP = "#"
local PAIR_SEP = ";"
local SLUG_SEP = "="
local LOGIN_QUERY_DELAY = 4
local GROUP_REQUERY_DELAY = 3
local GUILD_REQUERY_DELAY = 6
local QUERY_FLOOR_SECONDS = 60
local FALLBACK_NOTICE = "Update available: version %s is out. Grab it on CurseForge."

-- ============================================================
-- Pure version logic (offline-testable, no WoW APIs)
-- ============================================================

-- Accepts "1.2.3", "v1.2.3", "1.2.3-beta.4", "1.2.3-alpha".
-- Returns { major, minor, patch, tag, tagIter } or nil when unparseable.
function lib:SplitVersion(text)
    if type(text) ~= "string" then return nil end
    local trimmed = text:match("^v(.*)$") or text
    local maj, min, pat, rest = trimmed:match("^(%d+)%.(%d+)%.(%d+)(.*)$")
    if not maj then return nil end
    local parsed = {
        major = tonumber(maj),
        minor = tonumber(min),
        patch = tonumber(pat),
    }
    if rest ~= "" then
        local tag, iter = rest:match("^%-(%a+)%.?(%d*)$")
        if tag then
            parsed.tag = tag:lower()
            parsed.tagIter = tonumber(iter) or 0
        end
    end
    return parsed
end

function lib:IsUnstable(text)
    local parsed = self:SplitVersion(text)
    if not parsed then return false end
    return parsed.tag == "alpha" or parsed.tag == "beta"
end

local NUMERIC_FIELDS = { "major", "minor", "patch" }

-- -1 when a is older than b, 0 when equal, 1 when newer; nil if either
-- side fails to parse. A tagless build outranks any tagged build of the
-- same triple; between tags, "alpha" sorts under "beta", then by iteration.
function lib:CompareVersionStrings(a, b)
    local pa, pb = self:SplitVersion(a), self:SplitVersion(b)
    if not pa or not pb then return nil end
    for i = 1, #NUMERIC_FIELDS do
        local field = NUMERIC_FIELDS[i]
        if pa[field] ~= pb[field] then
            return pa[field] < pb[field] and -1 or 1
        end
    end
    if pa.tag == pb.tag then
        local ia, ib = pa.tagIter or 0, pb.tagIter or 0
        if ia == ib then return 0 end
        return ia < ib and -1 or 1
    end
    if not pa.tag then return 1 end
    if not pb.tag then return -1 end
    return pa.tag < pb.tag and -1 or 1
end

-- A notice is warranted only for a stable incoming version strictly newer
-- than what this client runs. Prerelease sightings never nag.
function lib:ShouldNotify(incoming, current)
    if self:IsUnstable(incoming) then return false end
    return self:CompareVersionStrings(incoming, current) == 1
end

-- ============================================================
-- Wire format
-- ============================================================

function lib:PackReport(versionsBySlug)
    local parts = {}
    for slug, version in pairs(versionsBySlug) do
        parts[#parts + 1] = slug .. SLUG_SEP .. version
    end
    table.sort(parts)
    return table.concat(parts, PAIR_SEP)
end

function lib:UnpackReport(payload)
    local out = {}
    if type(payload) ~= "string" then return out end
    for pair in payload:gmatch("[^" .. PAIR_SEP .. "]+") do
        local slug, version = pair:match("^([^" .. SLUG_SEP .. "]+)" .. SLUG_SEP .. "(.+)$")
        if slug and version then
            out[slug] = version
        end
    end
    return out
end

-- ============================================================
-- Channel availability
-- ============================================================

local function homeGroupHasRealPlayers()
    local homeCategory = _G.LE_PARTY_CATEGORY_HOME
    if not IsInGroup(homeCategory) then return false end
    if IsInRaid(homeCategory) then
        for i = 1, GetNumGroupMembers() do
            local unit = "raid" .. i
            if UnitExists(unit) and UnitIsPlayer(unit) and not UnitIsUnit(unit, "player") then
                return true
            end
        end
        return false
    end
    for i = 1, 4 do
        local unit = "party" .. i
        if UnitExists(unit) and UnitIsPlayer(unit) then
            return true
        end
    end
    return false
end

-- Every send re-checks this at send time, so a channel that stopped being
-- valid between scheduling and firing (left group, zoned) is silently
-- skipped and the client never spams "You aren't in a party/raid."
function lib:CanUseChannel(channel)
    local homeCategory = _G.LE_PARTY_CATEGORY_HOME
    local instanceCategory = _G.LE_PARTY_CATEGORY_INSTANCE
    if channel == "GUILD" then
        return IsInGuild() and true or false
    elseif channel == "INSTANCE_CHAT" then
        return (IsInGroup(instanceCategory) and IsInInstance()) and true or false
    elseif channel == "RAID" then
        return (IsInRaid(homeCategory) and homeGroupHasRealPlayers()) and true or false
    elseif channel == "PARTY" then
        return (IsInGroup(homeCategory) and not IsInRaid(homeCategory) and homeGroupHasRealPlayers()) and true or false
    end
    return false
end

-- GUILD plus at most one group channel; instance chat already reaches the
-- whole instance group, so home RAID/PARTY are only used outside instances.
function lib:ActiveChannels()
    local channels = {}
    if self:CanUseChannel("GUILD") then channels[#channels + 1] = "GUILD" end
    if self:CanUseChannel("INSTANCE_CHAT") then
        channels[#channels + 1] = "INSTANCE_CHAT"
    elseif self:CanUseChannel("RAID") then
        channels[#channels + 1] = "RAID"
    elseif self:CanUseChannel("PARTY") then
        channels[#channels + 1] = "PARTY"
    end
    return channels
end

-- ============================================================
-- Comms
-- ============================================================

local function myFullName()
    if not lib.selfFullName then
        local name = UnitName("player")
        local realm = (GetNormalizedRealmName and GetNormalizedRealmName())
                or (GetRealmName and GetRealmName():gsub("%s", ""))
        if name and realm then
            lib.selfFullName = name .. "-" .. realm
        end
    end
    return lib.selfFullName
end

function lib:Transmit(kind, payload, channel)
    if not self:CanUseChannel(channel) then return end
    local body = payload and (kind .. KIND_SEP .. payload) or kind
    C_ChatInfo.SendAddonMessage(COMM_PREFIX, body, channel)
end

-- Advertise only stable versions; a prerelease client stays quiet about
-- that addon so it can never nag stable users into "upgrading" to a beta.
local function stableVersionsBySlug()
    local versions = {}
    for slug, entry in pairs(lib.registeredAddons) do
        if not lib:IsUnstable(entry.version) then
            versions[slug] = entry.version
        end
    end
    return versions
end

function lib:BroadcastReport(channel)
    local versions = stableVersionsBySlug()
    if not next(versions) then return end
    self:Transmit(KIND_REPORT, self:PackReport(versions), channel)
end

function lib:BroadcastQuery()
    local now = GetTime and GetTime() or 0
    if now > 0 and (now - self.lastQueryAt) < QUERY_FLOOR_SECONDS then return end
    self.lastQueryAt = now
    for _, channel in ipairs(self:ActiveChannels()) do
        self:Transmit(KIND_QUERY, nil, channel)
    end
end

function lib:QueueQuery(delay)
    if self.queryQueued then return end
    self.queryQueued = true
    C_Timer.After(delay, function()
        lib.queryQueued = false
        lib:BroadcastQuery()
    end)
end

local function handleIncomingReport(payload)
    local reported = lib:UnpackReport(payload)
    for slug, version in pairs(reported) do
        local entry = lib.registeredAddons[slug]
        if entry and not lib.outdatedNoticed[slug] and lib:ShouldNotify(version, entry.version) then
            lib.outdatedNoticed[slug] = true
            if type(entry.onOutdated) == "function" then
                entry.onOutdated(version)
            else
                local text = entry.noticeText or FALLBACK_NOTICE
                print((entry.label or slug) .. ": " .. text:format(version))
            end
        end
    end
end

local function handleIncomingQuery(channel)
    -- Small random delay so a busy guild does not answer in one burst.
    C_Timer.After(1 + math.random() * 2, function()
        lib:BroadcastReport(channel)
    end)
end

local function currentGroupSignature()
    if not IsInGroup() then return "" end
    local names = {}
    for i = 1, GetNumGroupMembers() do
        local unit = (IsInRaid() and "raid" or "party") .. i
        local fullName = GetUnitName and GetUnitName(unit, true)
        if fullName then names[#names + 1] = fullName end
    end
    table.sort(names)
    return table.concat(names, ",")
end

local function onCommEvent(_, event, prefix, message, channel, sender)
    if event == "CHAT_MSG_ADDON" then
        if prefix ~= COMM_PREFIX or type(message) ~= "string" then return end
        if sender == myFullName() then return end
        local kind, payload = message:match("^([^" .. KIND_SEP .. "]+)" .. KIND_SEP .. "?(.*)$")
        if kind == KIND_QUERY then
            handleIncomingQuery(channel)
        elseif kind == KIND_REPORT then
            handleIncomingReport(payload)
        end
    elseif event == "GROUP_ROSTER_UPDATE" then
        local signature = currentGroupSignature()
        if signature ~= lib.groupSignature then
            lib.groupSignature = signature
            lib:QueueQuery(GROUP_REQUERY_DELAY)
        end
    elseif event == "GUILD_ROSTER_UPDATE" then
        lib:QueueQuery(GUILD_REQUERY_DELAY)
    end
end

local function ensureCommsActive()
    if lib.commFrame then return end
    if not (C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix and C_ChatInfo.SendAddonMessage) then return end
    C_ChatInfo.RegisterAddonMessagePrefix(COMM_PREFIX)
    local frame = CreateFrame("Frame")
    frame:RegisterEvent("CHAT_MSG_ADDON")
    frame:RegisterEvent("GROUP_ROSTER_UPDATE")
    frame:RegisterEvent("GUILD_ROSTER_UPDATE")
    frame:SetScript("OnEvent", onCommEvent)
    lib.commFrame = frame
    lib.groupSignature = currentGroupSignature()
    C_Timer.After(LOGIN_QUERY_DELAY, function()
        lib:BroadcastQuery()
    end)
end

-- ============================================================
-- Public registration
-- ============================================================

-- opts (all optional):
--   label      colored display name used as the chat-line prefix
--   noticeText fully localized format string; %s receives the newer version
--   onOutdated function(newVersion) replacing the default chat print
function lib:RegisterAddon(slug, version, opts)
    if type(slug) ~= "string" or slug == "" or type(version) ~= "string" then return end
    opts = opts or {}
    self.registeredAddons[slug] = {
        version = version,
        label = opts.label,
        noticeText = opts.noticeText,
        onOutdated = opts.onOutdated,
    }
    ensureCommsActive()
end
