-- UI/WarBoard_ShardMatcher.lua
-- Live, short-lived shard matches where Alliance and Horde are both present.

local addonName, WB = ...

WB.ShardMatcher = WB.ShardMatcher or {}
local Matcher = WB.ShardMatcher

local WINDOW_WIDTH  = 660
local WINDOW_HEIGHT = 520
local REPORT_WINDOW = 3 * 60
local HEARTBEAT     = 60
local ROW_HEIGHT    = 82

local GOLD      = { 0.95, 0.78, 0.35 }
local ALLIANCE  = { 0.32, 0.58, 1.00 }
local HORDE     = { 0.95, 0.25, 0.20 }
local MUTED     = { 0.66, 0.61, 0.52 }

local NEUTRAL_ART = {
    corner     = "Neutral-NineSlice-Corner",
    edgeTop    = "_Neutral-NineSlice-EdgeTop",
    edgeBottom = "_Neutral-NineSlice-EdgeBottom",
    edgeLeft   = "!Neutral-NineSlice-EdgeLeft",
    edgeRight  = "!Neutral-NineSlice-EdgeRight",
    background = "UI-Frame-Neutral-BackgroundTile",
}

local window
local reports = {} -- [shard][faction][guid/name] = report
local lastSequence = {} -- [guid/name] = newest presence/clear sequence seen
local localPresence
local localSequence = 0
local lastBroadcastAt = 0
local receiveOrder = 0

local function SafeText(value, maxLen)
    value = tostring(value or "")
    return (WB.SanitizeDisplay and WB.SanitizeDisplay(value, maxLen)) or value
end

local function FullName(report)
    local name = SafeText(report and report.name, 32)
    local realm = SafeText(report and report.realm, 32)
    if realm ~= "" then return name .. "-" .. realm end
    return name
end

local function SetAtlasTexture(texture, atlasName, flipH, flipV)
    local info = C_Texture and C_Texture.GetAtlasInfo and C_Texture.GetAtlasInfo(atlasName)
    if not info then return false end
    texture:SetTexture(info.file)
    local left, right = info.leftTexCoord, info.rightTexCoord
    local top, bottom = info.topTexCoord, info.bottomTexCoord
    if flipH then left, right = right, left end
    if flipV then top, bottom = bottom, top end
    texture:SetTexCoord(left, right, top, bottom)
    texture:SetSize(info.width, info.height)
    return true
end

local function ApplyNeutralNineSlice(frame)
    local topLeft = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(topLeft, NEUTRAL_ART.corner)
    topLeft:SetPoint("TOPLEFT")

    local topRight = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(topRight, NEUTRAL_ART.corner, true, false)
    topRight:SetPoint("TOPRIGHT")

    local bottomLeft = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(bottomLeft, NEUTRAL_ART.corner, false, true)
    bottomLeft:SetPoint("BOTTOMLEFT")

    local bottomRight = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(bottomRight, NEUTRAL_ART.corner, true, true)
    bottomRight:SetPoint("BOTTOMRIGHT")

    local cornerWidth = topLeft:GetWidth()
    local cornerHeight = topLeft:GetHeight()
    if cornerWidth == 0 then cornerWidth = 32 end
    if cornerHeight == 0 then cornerHeight = 32 end

    local topEdge = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(topEdge, NEUTRAL_ART.edgeTop)
    topEdge:SetHorizTile(true)
    topEdge:SetPoint("TOPLEFT", frame, "TOPLEFT", cornerWidth, 0)
    topEdge:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -cornerWidth, 0)

    local bottomEdge = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(bottomEdge, NEUTRAL_ART.edgeBottom)
    bottomEdge:SetHorizTile(true)
    bottomEdge:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", cornerWidth, 0)
    bottomEdge:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -cornerWidth, 0)

    local leftEdge = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(leftEdge, NEUTRAL_ART.edgeLeft)
    leftEdge:SetVertTile(true)
    leftEdge:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, -cornerHeight)
    leftEdge:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, cornerHeight)

    local rightEdge = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(rightEdge, NEUTRAL_ART.edgeRight)
    rightEdge:SetVertTile(true)
    rightEdge:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, -cornerHeight)
    rightEdge:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, cornerHeight)
end

local function Newest(bucket)
    local newest
    for _, report in pairs(bucket or {}) do
        if not newest or (report.seenAt or 0) > (newest.seenAt or 0) then newest = report end
    end
    return newest
end

-- Preserve the original oldest-first choice unless a group leader is present.
local function PreferredReporter(bucket)
    local best
    for _, report in pairs(bucket or {}) do
        if not best
        or (report.isGroupLeader and not best.isGroupLeader)
        or (report.isGroupLeader == best.isGroupLeader
            and (report.firstOrder or 0) < (best.firstOrder or 0)) then
            best = report
        end
    end
    return best
end

local function PruneReports()
    local cutoff = time() - REPORT_WINDOW
    for shard, factions in pairs(reports) do
        for _, faction in ipairs({ "Alliance", "Horde" }) do
            local bucket = factions[faction]
            if bucket then
                for key, report in pairs(bucket) do
                    if (report.seenAt or 0) < cutoff then bucket[key] = nil end
                end
            end
        end
        if not Newest(factions.Alliance) and not Newest(factions.Horde) then reports[shard] = nil end
    end
end

local function BuildMatches()
    PruneReports()
    local matches = {}
    for shard, factions in pairs(reports) do
        local alliance = PreferredReporter(factions.Alliance)
        local horde = PreferredReporter(factions.Horde)
        if alliance and horde then
            local freshest = ((alliance.seenAt or 0) >= (horde.seenAt or 0)) and alliance or horde
            matches[#matches + 1] = {
                shard = shard,
                alliance = alliance,
                horde = horde,
                zone = freshest.zone,
                lastSeen = math.min(alliance.seenAt or 0, horde.seenAt or 0),
            }
        end
    end
    table.sort(matches, function(a, b)
        if a.lastSeen == b.lastSeen then return tostring(a.shard) < tostring(b.shard) end
        return a.lastSeen > b.lastSeen
    end)
    return matches
end

local function Ago(timestamp)
    local age = math.max(0, time() - (timestamp or 0))
    if age < 5 then return "just now" end
    if age < 60 then return math.floor(age) .. "s ago" end
    return math.floor(age / 60) .. "m ago"
end

local function Whisper(report)
    local full = FullName(report)
    if full ~= "" and ChatFrame_SendTell then ChatFrame_SendTell(full) end
end

local function RequestInvite(report)
    local full = FullName(report)
    if full == "" or not (C_PartyInfo and C_PartyInfo.RequestInviteFromUnit) then return end
    if IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then
        C_PartyInfo.RequestInviteFromUnit(full)
    elseif IsInGroup() then
        C_PartyInfo.LeaveParty()
        C_Timer.After(0.5, function() C_PartyInfo.RequestInviteFromUnit(full) end)
    else
        C_PartyInfo.RequestInviteFromUnit(full)
    end
end

local function ShowPlayerMenu(owner, report)
    if not owner or not report then return end
    local full = FullName(report)
    if MenuUtil and MenuUtil.CreateContextMenu then
        MenuUtil.CreateContextMenu(owner, function(_, root)
            root:CreateTitle(full)
            root:CreateButton("Whisper", function() Whisper(report) end)
            root:CreateButton("Request an Invite", function() RequestInvite(report) end)
        end)
    elseif EasyMenu then
        Matcher._legacyMenu = Matcher._legacyMenu
            or CreateFrame("Frame", "WarBoardShardMatcherMenu", UIParent, "UIDropDownMenuTemplate")
        EasyMenu({
            { text = full, isTitle = true, notCheckable = true },
            { text = "Whisper", notCheckable = true, func = function() Whisper(report) end },
            { text = "Request an Invite", notCheckable = true, func = function() RequestInvite(report) end },
        }, Matcher._legacyMenu, "cursor", 0, 0, "MENU")
    end
end

local function CreateRow(parent, index)
    local row = CreateFrame("Button", nil, parent, "BackdropTemplate")
    row:SetHeight(ROW_HEIGHT - 6)
    row:SetPoint("TOPLEFT", parent, "TOPLEFT", 2, -((index - 1) * ROW_HEIGHT) - 3)
    row:SetPoint("RIGHT", parent, "RIGHT", -2, 0)
    row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    row:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 12,
        insets = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    row:SetBackdropColor(0.025, 0.022, 0.018, 0.82)
    row:SetBackdropBorderColor(0.44, 0.35, 0.20, 0.82)

    row.alliance = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row.alliance:SetPoint("TOPLEFT", row, "TOPLEFT", 14, -15)
    row.alliance:SetWidth(190)
    row.alliance:SetJustifyH("LEFT")
    row.alliance:SetWordWrap(false)
    row.alliance:SetTextColor(ALLIANCE[1], ALLIANCE[2], ALLIANCE[3])

    row.shard = row:CreateFontString(nil, "OVERLAY")
    row.shard:SetFont("Fonts\\MORPHEUS.TTF", 22)
    row.shard:SetPoint("TOP", row, "TOP", 0, -9)
    row.shard:SetWidth(130)
    row.shard:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    row.zone = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    row.zone:SetPoint("TOP", row.shard, "BOTTOM", 0, -1)
    row.zone:SetWidth(150)
    row.zone:SetWordWrap(false)
    row.zone:SetTextColor(0.76, 0.68, 0.54)

    row.horde = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    row.horde:SetPoint("TOPRIGHT", row, "TOPRIGHT", -14, -15)
    row.horde:SetWidth(190)
    row.horde:SetJustifyH("RIGHT")
    row.horde:SetWordWrap(false)
    row.horde:SetTextColor(HORDE[1], HORDE[2], HORDE[3])

    row.status = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    row.status:SetPoint("BOTTOM", row, "BOTTOM", 0, 8)
    row.status:SetTextColor(MUTED[1], MUTED[2], MUTED[3])

    row:SetScript("OnEnter", function(self)
        self:SetBackdropColor(0.10, 0.08, 0.045, 0.92)
        self:SetBackdropBorderColor(0.72, 0.57, 0.28, 1.00)
        if not self.match then return end
        local mine = (WB.faction or UnitFactionGroup("player")) == "Alliance"
            and self.match.alliance or self.match.horde
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText("Whisper " .. FullName(mine), 1.00, 0.82, 0.38)
        GameTooltip:AddLine("Ask for an invite to Shard " .. tostring(self.match.shard) .. ".", 0.82, 0.80, 0.74)
        GameTooltip:AddLine("Right-click for player options.", 0.62, 0.78, 0.95)
        GameTooltip:Show()
    end)
    row:SetScript("OnLeave", function(self)
        self:SetBackdropColor(0.025, 0.022, 0.018, 0.82)
        self:SetBackdropBorderColor(0.44, 0.35, 0.20, 0.82)
        GameTooltip:Hide()
    end)
    row:SetScript("OnClick", function(self, button)
        if not self.match then return end
        local mine = (WB.faction or UnitFactionGroup("player")) == "Alliance"
            and self.match.alliance or self.match.horde
        if button == "RightButton" then ShowPlayerMenu(self, mine)
        else Whisper(mine) end
    end)
    return row
end

local function Render()
    if not window then return end
    local matches = BuildMatches()
    for _, row in ipairs(window.rows) do row:Hide() end

    window.count:SetText(('%d Active %s'):format(#matches, #matches == 1 and "Match" or "Matches"))
    window.empty:SetShown(#matches == 0)
    for index, match in ipairs(matches) do
        local row = window.rows[index]
        if not row then
            row = CreateRow(window.content, index)
            window.rows[index] = row
        end
        row.match = match
        row.alliance:SetText(FullName(match.alliance))
        row.shard:SetText(tostring(match.shard))
        row.zone:SetText(SafeText(match.zone ~= "" and match.zone or "Unknown Zone", 32))
        row.horde:SetText(FullName(match.horde))
        row.status:SetText("Both factions detected  \226\128\162  Last seen " .. Ago(match.lastSeen))
        row:Show()
    end
    window.content:SetHeight(math.max(1, #matches * ROW_HEIGHT + 4))
end

local function CreateMatcherWindow()
    local frame = CreateFrame("Frame", "WarBoardShardMatcher", UIParent)
    frame:SetSize(WINDOW_WIDTH, WINDOW_HEIGHT)
    frame:SetPoint("CENTER")
    frame:SetFrameStrata("DIALOG")
    frame:SetToplevel(true)
    frame:SetClampedToScreen(true)
    frame:SetClipsChildren(false)
    frame:EnableMouse(true)
    frame:SetMovable(true)
    frame:RegisterForDrag("LeftButton")
    frame:SetScript("OnDragStart", frame.StartMoving)
    frame:SetScript("OnDragStop", frame.StopMovingOrSizing)

    local background = frame:CreateTexture(nil, "BACKGROUND", nil, -1)
    background:SetAtlas(NEUTRAL_ART.background)
    background:SetPoint("TOPLEFT", frame, "TOPLEFT", 6, -6)
    background:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -6, 6)
    background:SetHorizTile(true)
    background:SetVertTile(true)
    background:SetAlpha(1)
    ApplyNeutralNineSlice(frame)

    local title = frame:CreateFontString(nil, "OVERLAY")
    title:SetFont("Fonts\\MORPHEUS.TTF", 26)
    title:SetPoint("TOP", frame, "TOP", 0, -27)
    title:SetText("SHARD MATCHER")
    title:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    local divider = frame:CreateTexture(nil, "ARTWORK")
    divider:SetColorTexture(0.72, 0.60, 0.38, 0.58)
    divider:SetHeight(1)
    divider:SetPoint("TOPLEFT", frame, "TOPLEFT", 36, -63)
    divider:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -36, -63)

    local subtitle = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    subtitle:SetPoint("TOP", divider, "BOTTOM", 0, -8)
    subtitle:SetWidth(WINDOW_WIDTH - 80)
    subtitle:SetText("Matched shards where both factions are present. Left-click your faction's player to whisper, or right-click to request an invite.")
    subtitle:SetTextColor(0.78, 0.72, 0.62)

    local closeButton = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
    closeButton:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -5, -5)
    closeButton:SetFrameLevel(frame:GetFrameLevel() + 10)
    closeButton:SetScript("OnClick", function() frame:Hide() end)

    local list = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    list:SetPoint("TOPLEFT", frame, "TOPLEFT", 28, -106)
    list:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -28, 48)
    list:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 14,
        insets = { left = 4, right = 4, top = 4, bottom = 4 },
    })
    list:SetBackdropColor(0.018, 0.016, 0.013, 0.80)
    list:SetBackdropBorderColor(0.48, 0.39, 0.23, 0.95)

    local allianceHeader = list:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    allianceHeader:SetPoint("TOPLEFT", list, "TOPLEFT", 18, -10)
    allianceHeader:SetText("ALLIANCE")
    allianceHeader:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    local shardHeader = list:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    shardHeader:SetPoint("TOP", list, "TOP", 0, -10)
    shardHeader:SetText("SHARD")
    shardHeader:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    local hordeHeader = list:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    hordeHeader:SetPoint("TOPRIGHT", list, "TOPRIGHT", -34, -10)
    hordeHeader:SetText("HORDE")
    hordeHeader:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    local headerLine = list:CreateTexture(nil, "ARTWORK")
    headerLine:SetColorTexture(0.55, 0.44, 0.25, 0.55)
    headerLine:SetHeight(1)
    headerLine:SetPoint("TOPLEFT", list, "TOPLEFT", 10, -31)
    headerLine:SetPoint("TOPRIGHT", list, "TOPRIGHT", -10, -31)

    local scroll = CreateFrame("ScrollFrame", nil, list, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", list, "TOPLEFT", 8, -35)
    scroll:SetPoint("BOTTOMRIGHT", list, "BOTTOMRIGHT", -28, 8)
    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(1, 1)
    scroll:SetScrollChild(content)
    scroll:SetScript("OnSizeChanged", function(self, width) content:SetWidth(width) end)
    scroll:EnableMouseWheel(true)
    scroll:SetScript("OnMouseWheel", function(self, delta)
        local nextScroll = self:GetVerticalScroll() - (delta * ROW_HEIGHT)
        local maxScroll = self:GetVerticalScrollRange() or 0
        self:SetVerticalScroll(math.max(0, math.min(nextScroll, maxScroll)))
    end)

    local empty = list:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    empty:SetPoint("CENTER", scroll, "CENTER", -8, 4)
    empty:SetWidth(420)
    empty:SetText("No matched shards detected yet.")
    empty:SetTextColor(MUTED[1], MUTED[2], MUTED[3])

    local count = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    count:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 36, 25)
    count:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    local expiry = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    expiry:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -36, 25)
    expiry:SetText("Reports fade after 3m")
    expiry:SetTextColor(MUTED[1], MUTED[2], MUTED[3])

    frame.rows = {}
    frame.content = content
    frame.empty = empty
    frame.count = count
    frame.elapsed = 0
    frame:SetScript("OnUpdate", function(self, elapsed)
        self.elapsed = self.elapsed + elapsed
        if self.elapsed >= 1 then self.elapsed = 0; Render() end
    end)
    frame:Hide()
    tinsert(UISpecialFrames, "WarBoardShardMatcher")
    return frame
end

function Matcher.RememberReport(report)
    if type(report) ~= "table" then return false end
    local faction = report.faction
    if faction ~= "Alliance" and faction ~= "Horde" then return false end
    if type(report.name) ~= "string" or report.name == "" then return false end

    local name = SafeText(report.name, 32)
    local realm = SafeText(report.realm, 32)
    local guid = type(report.guid) == "string" and report.guid or nil
    local key = guid or (name .. "-" .. realm):lower()
    local sequence = tonumber(report.sentAt) or 0

    if report.clear then
        if sequence <= 0 or sequence <= (lastSequence[key] or 0) then return false end
        lastSequence[key] = sequence
        for _, oldFactions in pairs(reports) do
            oldFactions.Alliance[key] = nil
            oldFactions.Horde[key] = nil
        end
        if window and window:IsShown() then Render() end
        return true
    end

    if not Matcher.IsEligibleLocation(report.mapID, report.zone, report.objId) then return false end
    local shard = tostring(report.shard or "")
    if not shard:match("^%d+$") or #shard > 12 then return false end
    if sequence <= 0 or sequence <= (lastSequence[key] or 0) then return false end
    lastSequence[key] = sequence
    local clean = {
        shard = shard,
        faction = faction,
        guid = guid,
        name = name,
        realm = realm,
        zone = SafeText(report.zone, 40),
        mapID = tonumber(report.mapID),
        sentAt = sequence,
        seenAt = time(),
        isGroupLeader = report.isGroupLeader == true,
    }
    local factions = reports[shard]
    if not factions then
        factions = { Alliance = {}, Horde = {} }
        reports[shard] = factions
    end
    local current = factions[faction][key]
    clean.firstSeen = (current and current.firstSeen) or clean.seenAt
    if current then
        clean.firstOrder = current.firstOrder
    else
        receiveOrder = receiveOrder + 1
        clean.firstOrder = receiveOrder
    end

    -- A new report from this character supersedes their older shard immediately.
    for oldShard, oldFactions in pairs(reports) do
        if oldShard ~= shard then
            oldFactions.Alliance[key] = nil
            oldFactions.Horde[key] = nil
        end
    end
    factions[faction][key] = clean
    if window and window:IsShown() then Render() end
    return true
end

local function NextSequence()
    localSequence = math.max(localSequence + 1, time() * 1000 + math.floor((GetTime() % 1) * 1000))
    return localSequence
end

local function NormalizeZone(zone)
    if type(zone) ~= "string" then return nil end
    zone = zone:lower():gsub("^%s+", ""):gsub("%s+$", ""):gsub("^the%s+", "")
    return zone ~= "" and zone or nil
end

local function ObjectiveActive(obj)
    if type(obj) ~= "table" or obj.retired then return false end
    return not (WB.WarEffort and WB.WarEffort.IsFlipZoneActive) or WB.WarEffort.IsFlipZoneActive(obj)
end

function Matcher.IsEligibleLocation(mapID, zoneName, objId)
    local wantedZone = NormalizeZone(zoneName)
    for _, obj in ipairs((WB.WarEffort and WB.WarEffort.Objectives) or {}) do
        if ObjectiveActive(obj) and (not objId or obj.id == objId) then
            local objectiveMap = obj.mapID or (obj.marker and obj.marker.mapID)
            if mapID and objectiveMap and tonumber(mapID) == tonumber(objectiveMap) then return true end
            if wantedZone then
                if NormalizeZone(obj.zoneLabel) == wantedZone
                or NormalizeZone(obj.alertZone) == wantedZone
                or NormalizeZone(WB.WarEffort.ZoneFromTexture and WB.WarEffort.ZoneFromTexture(obj.texture)) == wantedZone then
                    return true
                end
                if type(obj.zones) == "table" then
                    for _, zone in ipairs(obj.zones) do
                        if NormalizeZone(zone) == wantedZone then return true end
                    end
                end
            end
            if objId and obj.id == objId then return true end
        end
    end
    return false
end

local function CurrentEligible()
    if WB.InInstance and WB.InInstance() then return false end
    local mapID = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player")
    return Matcher.IsEligibleLocation(mapID, GetZoneText())
end

local function MyIdentity(sequence)
    return {
        faction = WB.faction or UnitFactionGroup("player") or "Horde",
        guid = UnitGUID("player"),
        name = UnitNameUnmodified("player"),
        realm = GetNormalizedRealmName() or "",
        sentAt = sequence or NextSequence(),
        isGroupLeader = IsInGroup() and UnitIsGroupLeader("player") or false,
    }
end

function Matcher.ClearLocalPresence(broadcast)
    if not localPresence then return end
    local clear = MyIdentity()
    clear.clear = true
    Matcher.RememberReport(clear)
    localPresence = nil
    lastBroadcastAt = 0
    if broadcast and WB.Comm and WB.Comm.BroadcastShardPresence then
        WB.Comm.BroadcastShardPresence(clear)
    end
end

function Matcher.AnnouncePresence(force)
    if not CurrentEligible() then Matcher.ClearLocalPresence(true); return end
    local shard = WB.ShardDetector and WB.ShardDetector.GetShardID and WB.ShardDetector:GetShardID()
    if not shard then return end
    local report = MyIdentity()
    report.shard = shard
    report.zone = GetZoneText() or ""
    report.mapID = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player") or nil
    if not report.name then return end
    Matcher.RememberReport(report)
    local changed = not localPresence
                 or tostring(localPresence.shard) ~= tostring(shard)
                 or localPresence.isGroupLeader ~= report.isGroupLeader
    localPresence = report
    local forceReady = force and (GetTime() - lastBroadcastAt) >= 5
    if forceReady or changed or (GetTime() - lastBroadcastAt) >= HEARTBEAT then
        lastBroadcastAt = GetTime()
        if WB.Comm and WB.Comm.BroadcastShardPresence then WB.Comm.BroadcastShardPresence(report) end
    end
end

function Matcher.OnLocalShardChanged(shard)
    if not shard then
        Matcher.ClearLocalPresence(true)
    elseif C_Timer then
        C_Timer.After(0.5, function() Matcher.AnnouncePresence(true) end)
    end
end

local function SenderIdentity(sender)
    if type(sender) ~= "string" or sender == "" then return nil end
    local name, realm = sender:match("^([^%-]+)%-(.+)$")
    return name or sender, realm or ""
end

function Matcher.RememberAlert(alert, sender)
    if type(alert) ~= "table" or not alert.shardId then return false end
    if not Matcher.IsEligibleLocation(nil, alert.zone, alert.objId) then return false end
    local faction = alert.attackerFaction or alert.runnerFaction
    if faction ~= "Alliance" and faction ~= "Horde" then return false end
    local name, realm = SenderIdentity(sender)
    if not name then return false end
    return Matcher.RememberReport({
        shard = alert.shardId,
        faction = faction,
        guid = alert.attackerGuid or alert.runnerGuid,
        name = name,
        realm = realm,
        zone = alert.zone,
        sentAt = (tonumber(alert.t) or time()) * 1000,
    })
end

function Matcher.RememberLocalAlert(alert)
    if type(alert) ~= "table" or not alert.shardId or not CurrentEligible() then return end
    local report = MyIdentity()
    report.shard = alert.shardId
    report.zone = alert.zone or GetZoneText() or ""
    report.mapID = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player") or nil
    Matcher.RememberReport(report)
    localPresence = report
end

function Matcher.Show()
    if not window then window = CreateMatcherWindow() end
    Matcher.AnnouncePresence(true)
    Render()
    window:Show()
    window:Raise()
end

function Matcher.Hide()
    if window then window:Hide() end
end

function Matcher.Toggle()
    if not window then window = CreateMatcherWindow() end
    if window:IsShown() then Matcher.Hide() else Matcher.Show() end
end

do
    local frame = CreateFrame("Frame")
    frame:RegisterEvent("PLAYER_LOGIN")
    frame:RegisterEvent("PLAYER_ENTERING_WORLD")
    frame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
    frame:RegisterEvent("ZONE_CHANGED")
    frame:RegisterEvent("GROUP_ROSTER_UPDATE")
    frame:SetScript("OnEvent", function(_, event)
        if event == "PLAYER_LOGIN" then
            C_Timer.After(10, function() Matcher.AnnouncePresence(false) end)
            C_Timer.NewTicker(HEARTBEAT, function() Matcher.AnnouncePresence(false) end)
            C_Timer.NewTicker(30, PruneReports)
        else
            C_Timer.After(1, function()
                if CurrentEligible() then Matcher.AnnouncePresence(false)
                else Matcher.ClearLocalPresence(true) end
            end)
        end
    end)
end
