---@class AddonPrivate
local Private = select(2, ...)
local const = Private.constants

---@class Comm
-- Silent, realm-wide peer detection. Every user quietly joins a HIDDEN custom chat
-- channel (never shown in their chat window) and exchanges tiny addon messages so we
-- can tell who else is running Meta-Tracker. Normal users see nothing at all; only
-- someone who runs the (undocumented) /mtwho command ever sees the roster. Nothing is
-- uploaded or persisted -- the roster lives in memory for the session only.
local comm = {}
Private.Comm = comm

-- Keep these obscure so a curious user poking at /chatlist doesn't recognise them.
local PREFIX  = "MTr7c"       -- addon-message prefix (<=16 chars)
local CHANNEL = "mtx7sync"    -- hidden channel name
local VERSION = const.ADDON_VERSION or "?"
local HELLO_MIN_GAP = 120     -- don't re-broadcast our presence more than this often

local roster = {}             -- fullName -> { version, class, level, zone, lastSeen, firstSeen }
comm.roster = roster

-- Crowdsourced waypoint suggestions (fixes + route steps). Contributors auto-share
-- theirs when the author (review mode) asks; the author reviews each in a center-screen
-- popup. All in-memory for the session; approved ones merge into the author's own db.
local pending = {}            -- queue of suggestions awaiting the author's decision
local seenSug = {}            -- "F:key"/"S:key" already queued this session (no re-pop)

local frame = CreateFrame("Frame")
local channelIndex = 0
local myFull
local lastHello = 0

local function cfg() return Private.Settings and Private.Settings.db and Private.Settings.db.comm end

-- ---------------------------------------------------------------------------
-- Names: normalise everything to "Name-Realm" so channel senders (which carry the
-- realm) and mouseover units (which don't, on your own realm) compare equal.
-- ---------------------------------------------------------------------------
local function fullName(name, realm)
    if not name or name == "" then return nil end
    if name:find("-", 1, true) then return name end
    realm = (realm and realm ~= "") and realm:gsub("%s+", "") or (GetNormalizedRealmName() or "")
    if realm == "" then return name end
    return name .. "-" .. realm
end

-- ---------------------------------------------------------------------------
-- Hide the channel completely: suppress every join/leave/list/notice line that
-- mentions it, and drop it from all chat windows so its traffic never displays.
-- (Addon messages themselves are already invisible -- this hides the channel shell.)
-- ---------------------------------------------------------------------------
local function isOurs(s)
    return type(s) == "string" and s:lower():find(CHANNEL, 1, true) ~= nil
end
local function noticeFilter(_, _, msg, _, _, channelString, _, _, _, _, channelBaseName)
    if isOurs(channelString) or isOurs(channelBaseName) or isOurs(msg) then return true end
    return false
end
local function installFilters()
    for _, e in ipairs({
        "CHAT_MSG_CHANNEL_NOTICE", "CHAT_MSG_CHANNEL_NOTICE_USER",
        "CHAT_MSG_CHANNEL_JOIN", "CHAT_MSG_CHANNEL_LEAVE", "CHAT_MSG_CHANNEL_LIST",
    }) do
        ChatFrame_AddMessageEventFilter(e, noticeFilter)
    end
end

local function removeFromWindows()
    for i = 1, NUM_CHAT_WINDOWS do
        local cf = _G["ChatFrame" .. i]
        if cf then ChatFrame_RemoveChannel(cf, CHANNEL) end
    end
end

local removedOnce = false
local function ensureChannel()
    if not (cfg() and cfg().enabled) then return false end
    channelIndex = GetChannelName(CHANNEL)
    if not (channelIndex and channelIndex > 0) then
        JoinTemporaryChannel(CHANNEL)
        channelIndex = GetChannelName(CHANNEL)
        removedOnce = false -- a fresh join re-adds it to the chat windows
    end
    if channelIndex and channelIndex > 0 then
        if not removedOnce then removeFromWindows(); removedOnce = true end
        return true
    end
    return false
end

-- ---------------------------------------------------------------------------
-- Sending: a tiny throttle so a burst of replies never trips the server's
-- addon-message rate limit.
-- ---------------------------------------------------------------------------
local sendQueue, sending = {}, false
local function pump()
    local m = table.remove(sendQueue, 1)
    if not m then sending = false; return end
    sending = true
    if C_ChatInfo and C_ChatInfo.SendAddonMessage then
        C_ChatInfo.SendAddonMessage(PREFIX, m.msg, m.chan, m.target)
    end
    C_Timer.After(0.35, pump)
end
local function queueSend(msg, chan, target)
    sendQueue[#sendQueue + 1] = { msg = msg, chan = chan, target = target }
    if not sending then pump() end
end

local function myPayload(tag)
    local _, class = UnitClass("player")
    local level = UnitLevel("player") or 0
    local zone = GetRealZoneText() or ""
    return string.format("%s|%s|%s|%d|%s", tag, VERSION, class or "", level, zone)
end

-- Broadcast our presence (throttled). force=true for on-demand replies.
local function broadcastHello(force)
    if not ensureChannel() then return end
    local now = GetTime()
    if not force and (now - lastHello) < HELLO_MIN_GAP then return end
    lastHello = now
    queueSend(myPayload("H"), "CHANNEL", channelIndex)
end

-- Ask everyone to announce themselves (used by /mtwho to refresh the roster).
local function sendWho()
    if not ensureChannel() then return end
    queueSend("W|" .. VERSION, "CHANNEL", channelIndex)
end

-- ---------------------------------------------------------------------------
-- Roster
-- ---------------------------------------------------------------------------
local function printDetected(name, e)
    print(string.format("|cff33ccff[MT]|r detected |cffffd700%s|r  v%s%s",
        name, e.version or "?", (e.zone and e.zone ~= "") and ("  in " .. e.zone) or ""))
end

local function record(sender, ver, class, level, zone)
    local full = fullName(sender)
    if not full or full == myFull then return end
    local e = roster[full] or {}
    local isNew = not roster[full]
    e.version, e.class, e.level, e.zone, e.lastSeen = ver, class, tonumber(level), zone, GetTime()
    e.firstSeen = e.firstSeen or GetTime()
    roster[full] = e
    if isNew and cfg() and cfg().verbose then printDetected(full, e) end
end

-- ---------------------------------------------------------------------------
-- Crowdsourced suggestions
-- ---------------------------------------------------------------------------
-- Compact wire form: only IDs/coords travel; the author re-derives names locally.
local function packFix(m)
    return string.format("F|%d:%d:%d:%.1f:%.1f:%.1f:%.1f|%s",
        m.achievementID, m.criteriaIndex or 0, m.mapID or 0,
        m.origX or 0, m.origY or 0, m.x or 0, m.y or 0, m.label or "")
end
local function packStep(m)
    return string.format("S|%d:%d:%d:%.1f:%.1f:%d:%d",
        m.achievementID, m.criteriaIndex or 0, m.mapID or 0,
        m.x or 0, m.y or 0, m.object or 1, m.step or 1)
end

-- Author side: resolve an achievement/criterion to readable text from LOADED data.
local function deriveContext(aid, cidx)
    local eu = Private.ExpansionUtil
    local ach = eu and eu.GetAchievement and eu:GetAchievement(aid)
    local achName = (ach and ach.name)
        or (GetAchievementInfo and select(2, GetAchievementInfo(aid))) or ("Achievement " .. tostring(aid))
    local desc
    if ach and ach.criteria then
        for _, c in ipairs(ach.criteria) do
            if (c.criteriaIndex or 0) == cidx then desc = c.description; break end
        end
    end
    local exp = eu and eu:GetExpansionNameForAchievement(aid)
    return achName, desc, exp
end

-- Reply to a request by whispering all our authored fixes + steps to the asker.
local function sendMySuggestions(target)
    local O = Private.Objectives
    if not (O and target) then return end
    local n = 0
    if O.GetAllWaypointFixes then
        for _, m in ipairs(O:GetAllWaypointFixes()) do
            queueSend(packFix(m), "WHISPER", target); n = n + 1; if n >= 60 then return end
        end
    end
    if O.GetAllWaypointStepMeta then
        for _, m in ipairs(O:GetAllWaypointStepMeta()) do
            queueSend(packStep(m), "WHISPER", target); n = n + 1; if n >= 60 then return end
        end
    end
end

-- Author side: ask everyone to share their suggestions (only meaningful in review mode).
local function requestSuggestions()
    if not (cfg() and cfg().review) then return end
    if not ensureChannel() then return end
    queueSend("RS|" .. VERSION, "CHANNEL", channelIndex)
end

-- Center-screen review popup (built lazily).
local popup
local function buildPopup()
    local f = CreateFrame("Frame", "MetaTrackerCommPopup", UIParent, "BackdropTemplate")
    f:SetSize(430, 230)
    f:SetPoint("CENTER")
    f:SetFrameStrata("FULLSCREEN_DIALOG")
    f:SetToplevel(true)
    f:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
        edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Gold-Border",
        edgeSize = 20, insets = { left = 6, right = 6, top = 6, bottom = 6 },
    })
    f:EnableMouse(true); f:SetMovable(true); f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving); f:SetScript("OnDragStop", f.StopMovingOrSizing)

    f.title = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    f.title:SetPoint("TOP", 0, -16)
    f.body = f:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    f.body:SetPoint("TOPLEFT", 22, -46); f.body:SetPoint("TOPRIGHT", -22, -46)
    f.body:SetJustifyH("LEFT"); f.body:SetJustifyV("TOP"); f.body:SetSpacing(3)

    local approve = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    approve:SetSize(115, 24); approve:SetPoint("BOTTOMLEFT", 18, 16)
    approve:SetText("|cff40ff40Approve|r")
    approve:SetScript("OnClick", function() comm:ResolveCurrent("approve") end)
    local reject = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    reject:SetSize(115, 24); reject:SetPoint("BOTTOM", 0, 16)
    reject:SetText("|cffff6060Reject|r")
    reject:SetScript("OnClick", function() comm:ResolveCurrent("reject") end)
    local skip = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    skip:SetSize(115, 24); skip:SetPoint("BOTTOMRIGHT", -18, 16)
    skip:SetText("Skip")
    skip:SetScript("OnClick", function() comm:ResolveCurrent("skip") end)

    local close = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    close:SetPoint("TOPRIGHT", 2, 2)
    close:SetScript("OnClick", function() f:Hide() end)
    return f
end

local function enqueue(s)
    pending[#pending + 1] = s
    comm:ShowNextSuggestion()
end

-- Parse an incoming F/S suggestion, drop dupes/rejected/already-owned, and queue it.
local function handleSuggestion(tag, rest, sender)
    local db = Private.Settings and Private.Settings.db
    if not db then return end
    local c = cfg(); local rej = (c and c.rejected) or {}
    if tag == "F" then
        local nums, label = rest:match("^([^|]*)|?(.*)$")
        local aid, cidx, mid, ox, oy, nx, ny = strsplit(":", nums or "")
        aid, cidx, mid = tonumber(aid), tonumber(cidx), tonumber(mid)
        ox, oy, nx, ny = tonumber(ox), tonumber(oy), tonumber(nx), tonumber(ny)
        if not (aid and nx and ny) then return end
        local key = string.format("%d:%d:%d:%.1f:%.1f", aid, cidx or 0, mid or 0, ox or 0, oy or 0)
        if (db.waypointFixes and db.waypointFixes[key]) or rej["F:" .. key] or seenSug["F:" .. key] then return end
        seenSug["F:" .. key] = true
        local achName, desc, exp = deriveContext(aid, cidx or 0)
        enqueue({ type = "F", key = key, aid = aid, cidx = cidx or 0, mapID = mid or 0,
            ox = ox or 0, oy = oy or 0, nx = nx, ny = ny, label = (label ~= "" and label) or nil,
            achName = achName, desc = desc, exp = exp, from = sender })
    elseif tag == "S" then
        local aid, cidx, mid, x, y, obj, step = strsplit(":", rest)
        aid, cidx, mid = tonumber(aid), tonumber(cidx), tonumber(mid)
        x, y, obj, step = tonumber(x), tonumber(y), tonumber(obj), tonumber(step)
        if not (aid and x and y) then return end
        local key = string.format("%d:%d:%d:%.1f:%.1f", aid, cidx or 0, mid or 0, x, y)
        if (db.waypointSteps and db.waypointSteps[key]) or rej["S:" .. key] or seenSug["S:" .. key] then return end
        seenSug["S:" .. key] = true
        local achName, desc, exp = deriveContext(aid, cidx or 0)
        enqueue({ type = "S", key = key, aid = aid, cidx = cidx or 0, mapID = mid or 0,
            x = x, y = y, object = obj or 1, step = step or 1,
            achName = achName, desc = desc, exp = exp, from = sender })
    end
end

-- ---------------------------------------------------------------------------
-- Events
-- ---------------------------------------------------------------------------
frame:SetScript("OnEvent", function(_, event, ...)
    if event == "CHAT_MSG_ADDON" then
        local prefix, msg, _, sender = ...
        if prefix ~= PREFIX then return end
        local tag, rest = msg:match("^(%a+)|(.*)$")
        if tag == "H" then
            local ver, class, level, zone = strsplit("|", rest, 4)
            record(sender, ver, class, level, zone)
        elseif tag == "W" then
            record(sender, rest) -- rest = their version
            -- reply straight to the asker (whisper = no channel noise), jittered so
            -- many users don't answer in the same instant.
            C_Timer.After(math.random() * 3, function()
                if ensureChannel() then queueSend(myPayload("H"), "WHISPER", sender) end
            end)
        elseif tag == "RS" then
            -- the author is requesting waypoint suggestions -> share ours (jittered).
            C_Timer.After(math.random() * 2, function() sendMySuggestions(sender) end)
        elseif tag == "F" or tag == "S" then
            -- incoming waypoint suggestion -- only the author (review mode) collects them.
            if cfg() and cfg().review then handleSuggestion(tag, rest, sender) end
        end

    elseif event == "PLAYER_ENTERING_WORLD" then
        C_Timer.After(5, function()
            broadcastHello()
            if cfg() and cfg().verbose then C_Timer.After(1.5, sendWho) end -- author auto-refresh
            if cfg() and cfg().review then C_Timer.After(3, requestSuggestions) end -- pull suggestions
        end)

    elseif event == "UPDATE_MOUSEOVER_UNIT" then
        if not (cfg() and cfg().verbose) then return end
        if not UnitIsPlayer("mouseover") then return end
        local name, realm = UnitName("mouseover")
        local e = name and roster[fullName(name, realm)]
        if e then
            print(string.format("|cff33ccff[MT]|r |cffffd700%s|r is a Meta-Tracker user |cff888888(v%s)|r",
                fullName(name, realm), e.version or "?"))
        end
    end
end)

-- ---------------------------------------------------------------------------
-- Public API (driven by the /mtwho command)
-- ---------------------------------------------------------------------------

-- Refresh, then print the realm-wide roster. hereOnly = limit to your current zone.
function comm:PrintRoster(hereOnly)
    sendWho()
    print("|cff33ccff[Meta-Tracker]|r scanning the realm\226\128\166 (results in a moment)")
    C_Timer.After(3.5, function()
        local myZone = GetRealZoneText()
        local list = {}
        for name, e in pairs(roster) do
            if not hereOnly or e.zone == myZone then list[#list + 1] = { name = name, e = e } end
        end
        table.sort(list, function(a, b) return (a.e.lastSeen or 0) > (b.e.lastSeen or 0) end)
        print(string.format("|cff33ccff[Meta-Tracker]|r |cff00ff00%d|r user(s)%s:",
            #list, hereOnly and " in your zone" or " on your realm"))
        for _, item in ipairs(list) do
            local e = item.e
            print(string.format("  |cffffd700%s|r  |cff888888v%s  %s%s|r",
                item.name, e.version or "?", e.class or "",
                (e.zone and e.zone ~= "") and ("  \226\128\162 " .. e.zone) or ""))
        end
        if #list == 0 then
            print("  |cff888888(no one else detected yet -- others must be online & running the addon)|r")
        end
    end)
end

-- Toggle the live detection prints (mouseover a player / auto-detect on login).
function comm:ToggleVerbose()
    local c = cfg(); if not c then return end
    c.verbose = not c.verbose
    if c.verbose then
        print("|cff33ccff[Meta-Tracker]|r live detection |cff00ff00ON|r -- mouseover players to see if they run it; new logins print automatically.")
        C_Timer.After(0.5, sendWho)
    else
        print("|cff33ccff[Meta-Tracker]|r live detection |cffff0000OFF|r.")
    end
end

-- Fully opt out of / back into the comm channel (stops all broadcasting too).
function comm:ToggleEnabled()
    local c = cfg(); if not c then return end
    c.enabled = not c.enabled
    if c.enabled then
        broadcastHello(true)
        print("|cff33ccff[Meta-Tracker]|r peer detection |cff00ff00enabled|r.")
    else
        if LeaveChannelByName then LeaveChannelByName(CHANNEL) end
        channelIndex = 0
        print("|cff33ccff[Meta-Tracker]|r peer detection |cffff0000disabled|r (left the channel).")
    end
end

-- Show the next queued suggestion in the center-screen popup (or hide if none).
function comm:ShowNextSuggestion()
    if #pending == 0 then if popup then popup:Hide() end; return end
    local s = pending[1]
    popup = popup or buildPopup()
    popup.title:SetText(string.format("Meta-Tracker \226\128\148 waypoint suggestion (%d)", #pending))
    local info = C_Map.GetMapInfo(s.mapID)
    local zone = (info and info.name) or ("map " .. tostring(s.mapID or 0))
    local text
    if s.type == "F" then
        text = string.format(
            "|cffffd700%s|r\n%s\n\nMove pin:  |cffff8888%.1f, %.1f|r  \226\134\146  |cff88ff88%.1f, %.1f|r\nZone: %s%s\n\n|cff888888suggested by %s|r",
            s.achName or "?", s.desc or "", s.ox, s.oy, s.nx, s.ny, zone,
            (s.label and s.label ~= "") and ("\nNote: " .. s.label) or "", s.from or "?")
    else
        text = string.format(
            "|cffffd700%s|r\n%s\n\nRoute order:  object |cffffd700%d|r, step |cffffd700%d|r   @ (%.1f, %.1f)\nZone: %s\n\n|cff888888suggested by %s|r",
            s.achName or "?", s.desc or "", s.object, s.step, s.x, s.y, zone, s.from or "?")
    end
    popup.body:SetText(text)
    popup:Show()
end

-- Approve (merge into the author's own db so their normal export includes it),
-- reject (never show again), or skip (send to the back of the queue).
function comm:ResolveCurrent(action)
    local s = table.remove(pending, 1)
    if not s then if popup then popup:Hide() end; return end
    local db = Private.Settings and Private.Settings.db
    if action == "approve" and db then
        if s.type == "F" then
            db.waypointFixes = db.waypointFixes or {}
            db.waypointFixes[s.key] = {
                achievementID = s.aid, achName = s.achName, criteriaIndex = s.cidx, description = s.desc,
                mapID = s.mapID, origX = s.ox, origY = s.oy, x = s.nx, y = s.ny, label = s.label, expansion = s.exp,
            }
        else
            db.waypointSteps = db.waypointSteps or {}
            db.waypointSteps[s.key] = { object = s.object, step = s.step }
            db.waypointStepMeta = db.waypointStepMeta or {}
            db.waypointStepMeta[s.key] = {
                achievementID = s.aid, achName = s.achName, criteriaIndex = s.cidx, description = s.desc,
                mapID = s.mapID, x = s.x, y = s.y, object = s.object, step = s.step, expansion = s.exp,
            }
        end
        print("|cff33ccff[Meta-Tracker]|r suggestion |cff00ff00approved|r \226\128\148 it's in your next /drustexport" .. (s.type == "F" and "fixes" or "steps") .. ".")
    elseif action == "reject" then
        local c = cfg(); if c then c.rejected = c.rejected or {}; c.rejected[s.type .. ":" .. s.key] = true end
    elseif action == "skip" then
        pending[#pending + 1] = s
    end
    self:ShowNextSuggestion()
end

-- Turn the (author-only) review pipeline on/off. When on, pull suggestions right away.
function comm:ToggleReview()
    local c = cfg(); if not c then return end
    c.review = not c.review
    if c.review then
        print("|cff33ccff[Meta-Tracker]|r suggestion review |cff00ff00ON|r \226\128\148 fixes/steps from other users will pop up here for you to approve.")
        C_Timer.After(1, requestSuggestions)
    else
        print("|cff33ccff[Meta-Tracker]|r suggestion review |cffff0000OFF|r.")
    end
end

-- Manually re-request suggestions from everyone online.
function comm:Pull()
    if not (cfg() and cfg().review) then
        print("|cff33ccff[Meta-Tracker]|r turn review on first: |cffffd700/mtwho review|r")
        return
    end
    requestSuggestions()
    print("|cff33ccff[Meta-Tracker]|r requesting suggestions from users online\226\128\166")
end

function comm:Initialize()
    myFull = fullName(UnitName("player"))
    if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then
        C_ChatInfo.RegisterAddonMessagePrefix(PREFIX)
    end
    installFilters()
    frame:RegisterEvent("CHAT_MSG_ADDON")
    frame:RegisterEvent("PLAYER_ENTERING_WORLD")
    frame:RegisterEvent("UPDATE_MOUSEOVER_UNIT")
    -- Announce once shortly after load (covers the case where PLAYER_ENTERING_WORLD
    -- already fired before we registered), and re-hide the channel a moment later.
    C_Timer.After(8, function()
        broadcastHello()
        C_Timer.After(2, removeFromWindows)
        if cfg() and cfg().verbose then C_Timer.After(2.5, sendWho) end
    end)
end
