-- UI/Competitive.lua
-- Competitive / Challenge mode (LootAppraiser-style) - invite + party/raid/channel comms
-- Keeps competitive logic out of MainWindow.lua.

local ADDON_NAME, ns = ...
local WF = ns.WF
local Util = WF.Util
local Tracker = WF.Tracker
local Theme = WF.UI.Theme

local function CompetitiveColorHex(r, g, b)
  r = math.floor(math.max(0, math.min(1, tonumber(r) or 1)) * 255 + 0.5)
  g = math.floor(math.max(0, math.min(1, tonumber(g) or 1)) * 255 + 0.5)
  b = math.floor(math.max(0, math.min(1, tonumber(b) or 1)) * 255 + 0.5)
  return string.format("%02x%02x%02x", r, g, b)
end

local function CompetitiveNoticeText(msg)
  msg = tostring(msg or "")
  local accent = Theme and Theme.accent or { 0.35, 0.78, 1.00, 1.00 }
  local blue = CompetitiveColorHex(accent[1], accent[2], accent[3])
  local prefix = "What Farm?:"

  if msg:sub(1, #prefix) == prefix then
    local body = msg:sub(#prefix + 1):gsub("^%s+", "")
    if body ~= "" then
      return "|cff" .. blue .. prefix .. "|r |cffffffff" .. body .. "|r"
    end
    return "|cff" .. blue .. prefix .. "|r"
  end

  return "|cff" .. blue .. "What Farm?:|r |cffffffff" .. msg .. "|r"
end

WF.UI.Main = WF.UI.Main or {}
local UI = WF.UI.Main

-- ---------------------------------------------------------
-- Constants / helpers
-- ---------------------------------------------------------
local COMP_PREFIX    = "WFARM_COMP"
local COMP_INV_POPUP = "WHATFARM_COMP_INVITE"
local COMP_JOIN_REQ_POPUP = "WHATFARM_COMP_JOIN_REQUEST"

local function CompHideTexture(tex)
  if not tex then return end
  if tex.SetTexture then pcall(tex.SetTexture, tex, nil) end
  if tex.SetAtlas then pcall(tex.SetAtlas, tex, nil) end
  if tex.SetAlpha then tex:SetAlpha(0) end
  if tex.Hide then tex:Hide() end
end

local function CompHideFrameTextures(frame, depth)
  if not frame or (tonumber(depth) or 0) > 4 then return end

  local regions = { frame:GetRegions() }
  for i = 1, #regions do
    local r = regions[i]
    if r and r.GetObjectType and r:GetObjectType() == "Texture" then
      CompHideTexture(r)
    end
  end

  local children = { frame:GetChildren() }
  for i = 1, #children do
    local child = children[i]
    if child and not child._wfCompCustomSkin then
      CompHideFrameTextures(child, (tonumber(depth) or 0) + 1)
    end
  end
end

local function CompIsElvUIEnabled()
  if _G and _G.ElvUI then return true end
  if type(C_AddOns) == "table" and type(C_AddOns.IsAddOnLoaded) == "function" then
    local ok, loaded = pcall(C_AddOns.IsAddOnLoaded, "ElvUI")
    if ok and loaded then return true end
  end
  if type(IsAddOnLoaded) == "function" then
    local ok, loaded = pcall(IsAddOnLoaded, "ElvUI")
    if ok and loaded then return true end
  end
  return false
end

local function CompPopupApplyButtonText(btn, enabled)
  if not btn then return end

  if btn.SetNormalFontObject then btn:SetNormalFontObject("WF_GameFontNormal") end
  if btn.SetHighlightFontObject then btn:SetHighlightFontObject("WF_GameFontNormal") end
  if btn.SetDisabledFontObject then btn:SetDisabledFontObject("WF_GameFontNormal") end

  local fs = btn.GetFontString and btn:GetFontString() or nil
  if fs then
    fs:SetFontObject("WF_GameFontNormal")
    if fs.SetShadowOffset then fs:SetShadowOffset(0, 0) end
    if fs.SetShadowColor then fs:SetShadowColor(0, 0, 0, 0) end
    if fs.SetTextColor then
      if enabled == false then
        fs:SetTextColor(Theme.muted[1], Theme.muted[2], Theme.muted[3], 0.85)
      else
        fs:SetTextColor(Theme.accent[1], Theme.accent[2], Theme.accent[3], 1.00)
      end
    end
  end
end

local function CompStylePopupButton(btn)
  if not btn then return end

  local usingElvUI = CompIsElvUIEnabled()

  if not btn._wfCompTextStyled then
    btn._wfCompTextStyled = true
    btn:HookScript("OnShow", function(self)
      CompPopupApplyButtonText(self, (not self.IsEnabled) or self:IsEnabled())
      if C_Timer and C_Timer.After then
        C_Timer.After(0, function()
          if self and self:IsShown() then
            CompPopupApplyButtonText(self, (not self.IsEnabled) or self:IsEnabled())
          end
        end)
      end
    end)
    btn:HookScript("OnEnable", function(self)
      CompPopupApplyButtonText(self, true)
    end)
    btn:HookScript("OnDisable", function(self)
      CompPopupApplyButtonText(self, false)
    end)
  end

  CompPopupApplyButtonText(btn, (not btn.IsEnabled) or btn:IsEnabled())

  if usingElvUI then
    return
  end

  if not btn._wfCompSkin then
    btn._wfCompSkin = true
    btn._wfCompCustomSkin = true

    CompHideFrameTextures(btn, 0)

    local bg = CreateFrame("Frame", nil, btn, "BackdropTemplate")
    bg._wfCompCustomSkin = true
    bg:SetPoint("TOPLEFT", 0, 0)
    bg:SetPoint("BOTTOMRIGHT", 0, 0)
    bg:SetFrameLevel(math.max(0, (btn:GetFrameLevel() or 1) - 1))
    bg:SetBackdrop({
      bgFile = "Interface/Buttons/WHITE8x8",
      edgeFile = "Interface/Buttons/WHITE8x8",
      edgeSize = 1,
    })
    btn._wfCompBg = bg

    local fill = bg:CreateTexture(nil, "ARTWORK")
    fill:SetAllPoints()
    fill:SetTexture("Interface/Buttons/WHITE8x8")
    btn._wfCompFill = fill

    local function apply(self, state)
      local enabled = (not self.IsEnabled) or self:IsEnabled()
      local hover = self.IsMouseOver and self:IsMouseOver()
      local down = (state == "down")

      if not enabled then
        if self._wfCompBg then
          self._wfCompBg:SetBackdropColor(0.02, 0.02, 0.02, 0.98)
          self._wfCompBg:SetBackdropBorderColor(0.00, 0.00, 0.00, 0.90)
        end
        if self._wfCompFill then
          self._wfCompFill:SetVertexColor(0.00, 0.00, 0.00, 0.00)
        end
        CompPopupApplyButtonText(self, false)
        return
      end

      local bgA = 0.98
      local fillA = 0.00
      local borderA = 0.95
      if hover then
        fillA = 0.06
        borderA = 1.00
      end
      if down then
        fillA = 0.10
        borderA = 1.00
      end

      if self._wfCompBg then
        self._wfCompBg:SetBackdropColor(0.02, 0.02, 0.02, bgA)
        self._wfCompBg:SetBackdropBorderColor(0.00, 0.00, 0.00, borderA)
      end
      if self._wfCompFill then
        self._wfCompFill:SetVertexColor(Theme.accent[1], Theme.accent[2], Theme.accent[3], fillA)
      end
      CompPopupApplyButtonText(self, true)
    end

    btn:HookScript("OnEnter", function(self) apply(self, "hover") end)
    btn:HookScript("OnLeave", function(self) apply(self) end)
    btn:HookScript("OnMouseDown", function(self) apply(self, "down") end)
    btn:HookScript("OnMouseUp", function(self) apply(self) end)
    btn:HookScript("OnShow", function(self) apply(self) end)
    btn:HookScript("OnEnable", function(self) apply(self) end)
    btn:HookScript("OnDisable", function(self) apply(self) end)
  end

  if btn._wfCompBg then
    btn._wfCompBg:SetBackdropColor(0.02, 0.02, 0.02, 0.98)
    btn._wfCompBg:SetBackdropBorderColor(0.00, 0.00, 0.00, 0.95)
  end
  if btn._wfCompFill then
    btn._wfCompFill:SetVertexColor(Theme.accent[1], Theme.accent[2], Theme.accent[3], 0.00)
  end
end

local function CompStyleStaticPopupFrame(frame)
  if not frame then return end
  local usingElvUI = CompIsElvUIEnabled()

  if not usingElvUI then
    CompHideFrameTextures(frame, 0)

    if not frame._wfCompSkin then
      frame._wfCompSkin = true

      local bg = CreateFrame("Frame", nil, frame, "BackdropTemplate")
      bg._wfCompCustomSkin = true
      bg:SetPoint("TOPLEFT", frame, "TOPLEFT", 8, -8)
      bg:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -8, 8)
      bg:SetFrameLevel(math.max(0, (frame:GetFrameLevel() or 1) - 1))
      bg:SetBackdrop({
        bgFile = "Interface/Buttons/WHITE8x8",
        edgeFile = "Interface/Buttons/WHITE8x8",
        edgeSize = 1,
      })
      frame._wfCompBg = bg
    end

    if frame._wfCompBg then
      frame._wfCompBg:SetBackdropColor(0.02, 0.02, 0.02, 0.98)
      frame._wfCompBg:SetBackdropBorderColor(0.00, 0.00, 0.00, 0.98)
    end
  end

  local txt = frame.text or (frame.GetName and _G[frame:GetName() .. "Text"])
  if txt then
    txt:SetTextColor(Theme.text[1], Theme.text[2], Theme.text[3], 1.00)
    txt:SetJustifyH("CENTER")
    if txt.SetSpacing then txt:SetSpacing(2) end
  end

  local btn1 = frame.button1 or (frame.GetName and _G[frame:GetName() .. "Button1"])
  local btn2 = frame.button2 or (frame.GetName and _G[frame:GetName() .. "Button2"])
  local btn3 = frame.button3 or (frame.GetName and _G[frame:GetName() .. "Button3"])
  local btn4 = frame.button4 or (frame.GetName and _G[frame:GetName() .. "Button4"])
  CompStylePopupButton(btn1)
  CompStylePopupButton(btn2)
  CompStylePopupButton(btn3)
  CompStylePopupButton(btn4)

  if C_Timer and C_Timer.After then
    C_Timer.After(0, function()
      if not frame or not frame:IsShown() then return end

      local laterText = frame.text or (frame.GetName and _G[frame:GetName() .. "Text"])
      if laterText and laterText.SetTextColor then
        laterText:SetTextColor(Theme.text[1], Theme.text[2], Theme.text[3], 1.00)
      end

      CompStylePopupButton(frame.button1 or (frame.GetName and _G[frame:GetName() .. "Button1"]))
      CompStylePopupButton(frame.button2 or (frame.GetName and _G[frame:GetName() .. "Button2"]))
      CompStylePopupButton(frame.button3 or (frame.GetName and _G[frame:GetName() .. "Button3"]))
      CompStylePopupButton(frame.button4 or (frame.GetName and _G[frame:GetName() .. "Button4"]))
    end)
  end
end

local function SafeNow()
  if GetServerTime then return GetServerTime() end
  if time then return time() end
  if GetTime then return GetTime() end
  return 0
end

local function Trim(s)
  s = tostring(s or "")
  s = s:gsub("^%s+", ""):gsub("%s+$", "")
  return s
end

local function Lower(s)
  s = tostring(s or "")
  return s:lower()
end

local function DisplayProfileName(name)
  name = tostring(name or "")
  if name == "" then
    return "Default"
  end
  return name
end

local function NormalizeRealm(r)
  r = tostring(r or "")
  r = r:gsub("%s+", "")
  r = r:gsub("[^%w]", "")
  return r
end

local function GetCompSetting(key, default)
  if WF and WF.DB and WF.DB.GetSetting then
    local v = WF.DB:GetSetting(key)
    if v ~= nil then
      return v
    end
  end
  return default
end

local function CompAllowInvitesDuringSession()
  return GetCompSetting("compAllowInvitesDuringSession", false) and true or false
end

local function CompAllowJoinWithoutInvite()
  return GetCompSetting("compAllowJoinWithoutInvite", false) and true or false
end

local function GetCompetitiveRuntimeDB()
  if not (WF and WF.DB and WF.DB.Get) then return nil end
  local db = WF.DB:Get()
  if type(db) ~= "table" then return nil end
  db.runtime = db.runtime or {}
  return db.runtime
end

local function GetLocalActiveFarmSession()
  if Tracker and type(Tracker.current) == "table" and Tracker.current.active then
    return Tracker.current
  end

  local rt = GetCompetitiveRuntimeDB()
  local sess = rt and rt.activeSession or nil
  if type(sess) == "table" and sess.active then
    return sess
  end

  return nil
end

local function BlockCompetitiveJoinForActiveFarm()
  local sess = GetLocalActiveFarmSession()
  if type(sess) ~= "table" then
    local rt = GetCompetitiveRuntimeDB()
    sess = rt and rt.activeSession or nil
  end
  if type(sess) ~= "table" then
    return false
  end

  local paused = (UI and UI.IsPaused and UI:IsPaused()) and true or false
  if not sess.active and not paused then
    return false
  end

  local label = Trim(
    (sess and (sess._wfCustomFarmName or sess.farmName or sess.zone))
    or (sess and sess.farm and (sess.farm.name or sess.farm.zone))
    or ""
  )
  if label == "" then
    label = "your current farm"
  end

  local state = paused and "paused" or "active"
  Notify("What Farm?: Finish or stop " .. label .. " before joining a Competitive session. You still have a " .. state .. " farm run.")
  return true
end

local function CopyCompetitivePlainTable(src, depth)
  if type(src) ~= "table" then
    return src
  end

  depth = tonumber(depth) or 0
  if depth > 8 then
    return nil
  end

  local out = {}
  for k, v in pairs(src) do
    local kt = type(k)
    local vt = type(v)
    if (kt == "string" or kt == "number") and (vt == "string" or vt == "number" or vt == "boolean" or vt == "table") then
      if vt == "table" then
        out[k] = CopyCompetitivePlainTable(v, depth + 1)
      else
        out[k] = v
      end
    end
  end
  return out
end


local function SplitInviteList(raw)
  raw = tostring(raw or "")
  raw = raw:gsub("[\r\n]", " ")
  raw = raw:gsub(";", ",")
  local out, seen = {}, {}
  for token in raw:gmatch("[^,]+") do
    token = Trim(token)
    if token ~= "" and not seen[token] then
      seen[token] = true
      out[#out+1] = token
    end
  end
  return out
end


-- ---------------------------------------------------------
-- Loot message parsing (localized-safe, same approach as Tracker)
-- ---------------------------------------------------------
local COMP_LOOT_PATTERNS = {
  Util:PatternFromFormat(LOOT_ITEM_SELF_MULTIPLE),
  Util:PatternFromFormat(LOOT_ITEM_PUSHED_SELF_MULTIPLE),
  Util:PatternFromFormat(LOOT_ITEM_SELF),
  Util:PatternFromFormat(LOOT_ITEM_PUSHED_SELF),
}

local function ExtractItemLink(s)
  if type(s) ~= "string" then
    return nil
  end

  -- Prefer a fully-colored hyperlink so rarity color is correct even before item data is cached.
  local link = s:match("(|c%x%x%x%x%x%x%x%x|Hitem:.-|h%[.-%]|h|r)")
  if link then
    return link
  end

  -- Fallback: uncolored hyperlink.
  link = s:match("(|Hitem:.-|h%[.-%]|h)")
  if link then
    return link
  end

  if s:find("|Hitem:") then
    return s
  end

  return nil
end

local function ParseLootMessage(msg)
  if type(msg) ~= "string" then
    return nil, 0
  end

  for _, pat in ipairs(COMP_LOOT_PATTERNS) do
    if pat then
      local a, b = msg:match(pat)
      if a then
        local link = ExtractItemLink(a) or ExtractItemLink(b)
        local qty = 1

        qty = tonumber(a) or tonumber(b) or 1

        -- Safety: if we matched a SINGLE pattern but %s capture contained trailing "xN",
        -- strip it and treat N as the quantity.
        if (not tonumber(a)) and (not tonumber(b)) then
          local q = a:match("x(%d+)$") or b and b:match("x(%d+)$")
          if q then
            qty = tonumber(q) or 1
          end
        end

        if link then
          qty = tonumber(qty) or 1
          if qty < 1 then qty = 1 end
          return link, qty
        end
      end
    end
  end

  return nil, 0
end

function UI:_ResetCompetitiveLocalLoot()
  self:_EnsureCompetitiveState()
  self._compLocalLootCounts   = {}
  self._compLocalLootLastSeen = {}
  self._compLastLocalTrackedPayload = ""
  self._compLastSentRawLootCounts = {}
  self._compLootDirty = true
end

-- Prune the local loot cache so it does not grow unboundedly during long sessions.
-- Keeps all items that still appear in the active tracker (counts > 0).
-- Removes stale entries that have not been seen for more than maxAge seconds
-- AND no longer appear in the current tracked counts.
function UI:_PruneCompetitiveLocalLootCache(maxAge)
  local seen = self._compLocalLootLastSeen
  local counts = self._compLocalLootCounts
  if type(seen) ~= "table" or type(counts) ~= "table" then return end

  maxAge = tonumber(maxAge) or 120
  local now = SafeNow()

  -- Get the current tracked item IDs so we never evict something still active.
  local active = {}
  local tracked = self:_GetCompetitiveTrackedCountsSinceStart(false)
  for iid in pairs(tracked) do
    active[iid] = true
  end

  for iid in pairs(seen) do
    if not active[iid] then
      local t = tonumber(seen[iid]) or 0
      if t > 0 and (now - t) >= maxAge then
        seen[iid] = nil
        counts[iid] = nil
      end
    end
  end
end

function UI:_OnCompetitiveLootEvent(msg)
  if not self._compStarted then return end
  local link, qty = ParseLootMessage(msg)
  if not link then return end
  local iid = GetItemInfoInstant and GetItemInfoInstant(link) or nil
  if not iid and type(link) == "string" then
    local uncolored = link:match("(|Hitem:.-|h%[.-%]|h)")
    if uncolored and GetItemInfoInstant then
      iid = GetItemInfoInstant(uncolored)
    end
  end
  iid = tonumber(iid)
  if not iid then return end
  qty = tonumber(qty) or 1
  if qty < 1 then qty = 1 end

  self:_EnsureCompetitiveState()
  local t = SafeNow()
  local tbl = self._compLocalLootCounts
  tbl[iid] = (tonumber(tbl[iid]) or 0) + qty
  local seen = self._compLocalLootLastSeen
  seen[iid] = t

  -- Keep the raw local loot cache for emergency fallback/debug only.
  -- Competitive scoring and the visible Loot tab must come from the tracked,
  -- profile-filtered counts so junk items do not leak into the table.
  self:_MarkCompetitiveLootDirty()
  if self.RefreshCompetitiveLootPanel then
    self:RefreshCompetitiveLootPanel(false)
  end
end

function UI:_ParseCompetitiveLootCountsString(items)
  local out = {}
  items = tostring(items or "")
  for part in items:gmatch("([^,]+)") do
    local iid, cnt = part:match("^(%d+):(%d+)$")
    iid = tonumber(iid)
    cnt = tonumber(cnt)
    if iid and cnt and cnt > 0 then
      out[iid] = cnt
    end
  end
  return out
end

function UI:_BuildCompetitiveLootStringFromCounts(counts, maxItems, maxLen)
  self:_EnsureCompetitiveState()
  counts = counts or {}

  maxItems = tonumber(maxItems) or 10
  if maxItems < 1 then maxItems = 1 end
  if maxItems > 60 then maxItems = 60 end

  maxLen = tonumber(maxLen) or 220
  if maxLen < 80 then maxLen = 80 end
  if maxLen > 230 then maxLen = 230 end

  local list = {}
  for iid, cnt in pairs(counts) do
    iid = tonumber(iid)
    cnt = tonumber(cnt)
    if iid and cnt and cnt > 0 then
      local unit = 0
      if WF and WF.Price and WF.Price.GetItemValue then
        unit = tonumber(WF.Price:GetItemValue(iid)) or 0
      end
      local val = unit * cnt
      list[#list+1] = { itemID = iid, count = cnt, value = val }
    end
  end

  table.sort(list, function(a, b)
    if a.value == b.value then
      return a.count > b.count
    end
    return a.value > b.value
  end)

  local parts = {}
  local curLen = 0
  local n = 0
  for i = 1, #list do
    local it = list[i]
    local part = tostring(it.itemID) .. ":" .. tostring(it.count) .. ":" .. tostring(math.floor(it.value))
    local add = #part
    if #parts > 0 then add = add + 1 end
    if (curLen + add) > maxLen then
      break
    end
    parts[#parts+1] = part
    curLen = curLen + add
    n = n + 1
    if n >= maxItems then break end
  end

  return table.concat(parts, ",")
end

function UI:_GetCompetitiveLocalLootCountsSummary(maxItems, maxLen)
  self:_EnsureCompetitiveState()

  maxItems = tonumber(maxItems) or 30
  if maxItems < 1 then maxItems = 1 end
  if maxItems > 120 then maxItems = 120 end

  maxLen = tonumber(maxLen) or 220
  if maxLen < 80 then maxLen = 80 end
  if maxLen > 230 then maxLen = 230 end

  local list = {}
  for iid, cnt in pairs(self._compLocalLootCounts or {}) do
    iid = tonumber(iid)
    cnt = tonumber(cnt)
    if iid and cnt and cnt > 0 then
      local seen = 0
      if self._compLocalLootLastSeen then
        seen = tonumber(self._compLocalLootLastSeen[iid]) or 0
      end
      list[#list+1] = { itemID = iid, count = cnt, seen = seen }
    end
  end

  table.sort(list, function(a, b)
    if a.seen == b.seen then
      return a.count > b.count
    end
    return a.seen > b.seen
  end)

  local parts = {}
  local curLen = 0
  local n = 0
  for i = 1, #list do
    local it = list[i]
    local part = tostring(it.itemID) .. ":" .. tostring(it.count)
    local add = #part
    if #parts > 0 then add = add + 1 end
    if (curLen + add) > maxLen then
      break
    end
    parts[#parts+1] = part
    curLen = curLen + add
    n = n + 1
    if n >= maxItems then break end
  end

  return table.concat(parts, ",")
end

local function CopyCountsTable(src)
  local out = {}
  if type(src) ~= "table" then return out end
  for iid, cnt in pairs(src) do
    iid = tonumber(iid)
    cnt = tonumber(cnt)
    if iid and cnt and cnt > 0 then
      out[iid] = cnt
    end
  end
  return out
end


local COMP_HOST_CATEGORY_KEYS = {
  "lootRecordMaterials",
  "lootRecordGear",
  "lootRecordRecipes",
  "lootRecordPets",
  "lootRecordToys",
  "lootRecordMounts",
  "lootRecordConsumables",
  "lootRecordMisc",
}

local COMP_HOST_NON_MATERIAL_CATEGORY_KEYS = {
  "lootRecordGear",
  "lootRecordRecipes",
  "lootRecordPets",
  "lootRecordToys",
  "lootRecordMounts",
  "lootRecordConsumables",
  "lootRecordMisc",
}

local function CompetitiveGetSetting(key, fallback)
  if WF and WF.DB and WF.DB.GetSetting then
    local ok, ret = pcall(function()
      return WF.DB:GetSetting(key)
    end)
    if ok and ret ~= nil then
      return ret
    end
  end
  return fallback
end

local function CompetitiveMinQualityToNumber(v)
  if type(v) == "string" then
    local map = {
      ["Poor+"] = 0, ["Any"] = 0, ["Poor"] = 0,
      ["Common+"] = 1, ["Common"] = 1,
      ["Uncommon+"] = 2, ["Uncommon"] = 2,
      ["Rare+"] = 3, ["Rare"] = 3,
      ["Epic+"] = 4, ["Epic"] = 4,
      ["Legendary+"] = 5, ["Legendary"] = 5,
    }
    return tonumber(map[v] or v) or 0
  end
  return tonumber(v) or 0
end

local function CompetitiveBuildCategorySelection()
  local categories = {}
  local hasCategoryData = false

  for i = 1, #COMP_HOST_CATEGORY_KEYS do
    local k = COMP_HOST_CATEGORY_KEYS[i]
    local v = CompetitiveGetSetting(k, nil)
    if v ~= nil then
      categories[k] = v and true or false
      hasCategoryData = true
    else
      categories[k] = false
    end
  end

  if not hasCategoryData then
    local includeMaterials = CompetitiveGetSetting("lootIncludeMaterials", true)
    local onlyMaterials = CompetitiveGetSetting("lootOnlyMaterials", false) and true or false
    local onlyGear = CompetitiveGetSetting("lootOnlyGear", false) and true or false

    for i = 1, #COMP_HOST_CATEGORY_KEYS do
      categories[COMP_HOST_CATEGORY_KEYS[i]] = false
    end

    if onlyMaterials and onlyGear then
      categories.lootRecordMaterials = true
      categories.lootRecordGear = true
    elseif onlyMaterials then
      categories.lootRecordMaterials = true
    elseif onlyGear then
      categories.lootRecordGear = true
    elseif includeMaterials == false then
      for i = 1, #COMP_HOST_NON_MATERIAL_CATEGORY_KEYS do
        categories[COMP_HOST_NON_MATERIAL_CATEGORY_KEYS[i]] = true
      end
    end
  end

  return categories
end

local function CompetitiveHasCategorySelection(categories)
  if type(categories) ~= "table" then return false end
  for i = 1, #COMP_HOST_CATEGORY_KEYS do
    if categories[COMP_HOST_CATEGORY_KEYS[i]] then
      return true
    end
  end
  return false
end

local function CompetitiveSafeCall(fn, ...)
  if type(fn) ~= "function" then return nil end
  local ok, ret = pcall(fn, ...)
  if ok then return ret end
  return nil
end

local function CompetitiveIsToyItem(itemID)
  if not itemID then return false end
  if C_ToyBox and C_ToyBox.GetToyInfo then
    local toyName = CompetitiveSafeCall(C_ToyBox.GetToyInfo, itemID)
    if toyName then return true end
  end
  if PlayerHasToy then
    return CompetitiveSafeCall(PlayerHasToy, itemID) and true or false
  end
  return false
end

local function CompetitiveIsMountItem(itemID)
  if not itemID then return false end
  if C_MountJournal and C_MountJournal.GetMountFromItem then
    local mountID = CompetitiveSafeCall(C_MountJournal.GetMountFromItem, itemID)
    if mountID and mountID ~= 0 then return true end
  end
  return false
end

local function CompetitiveClassifyRecordCategory(rec)
  if not rec then return "lootRecordMisc" end
  if rec.isToy then return "lootRecordToys" end
  if rec.isMount then return "lootRecordMounts" end
  if rec.isPet then return "lootRecordPets" end
  if rec.isRecipe then return "lootRecordRecipes" end
  if rec.isReagent then return "lootRecordMaterials" end
  if rec.isGear then return "lootRecordGear" end
  if rec.isConsumable then return "lootRecordConsumables" end
  return "lootRecordMisc"
end

local function CompetitiveBuildItemRecord(itemID)
  itemID = tonumber(itemID)
  if not itemID then return nil end

  local name, link, quality, ilvl, reqLevel, class, subclass, maxStack, equipSlot, icon,
        sellPrice, classID, subclassID, bindType, expacID, setID, isCraftingReagent = GetItemInfo(itemID)

  if not name then
    if C_Item and C_Item.RequestLoadItemDataByID then
      pcall(C_Item.RequestLoadItemDataByID, itemID)
    end
    return nil
  end

  if ((not classID) or (not subclassID) or (not equipSlot) or equipSlot == "") and GetItemInfoInstant then
    local _, _, _, iequip, iicon, iclassID, isubclassID = GetItemInfoInstant(itemID)
    if not icon and iicon then icon = iicon end
    if (not equipSlot or equipSlot == "") and iequip then equipSlot = iequip end
    if not classID and iclassID then classID = iclassID end
    if not subclassID and isubclassID then subclassID = isubclassID end
  end

  local rec = {
    itemID = itemID,
    name = name,
    link = link,
    icon = icon,
    quality = tonumber(quality) or 0,
    classID = tonumber(classID),
    subclassID = tonumber(subclassID),
    equipSlot = equipSlot or "",
    bindType = tonumber(bindType) or 0,
    vendor = tonumber(sellPrice) or 0,
  }

  rec.isGear = (rec.equipSlot ~= "" and rec.equipSlot ~= "INVTYPE_BAG" and (rec.classID == 2 or rec.classID == 4))
  rec.isReagent = (isCraftingReagent == true)
  if rec.isReagent ~= true and (rec.classID == 3 or rec.classID == 5 or rec.classID == 7 or rec.classID == 8) then
    rec.isReagent = true
  end
  rec.isRecipe = (rec.classID == 9)
  rec.isPet = (rec.classID == 17)
  rec.isConsumable = (rec.classID == 0)
  rec.isToy = CompetitiveIsToyItem(itemID)
  rec.isMount = CompetitiveIsMountItem(itemID)
  rec.recordCategory = CompetitiveClassifyRecordCategory(rec)

  return rec
end

local function CompetitiveHostItemPassesRules(itemID, count)
  itemID = tonumber(itemID)
  count = tonumber(count) or 0
  if not itemID or count <= 0 then
    return false, 0
  end

  local minQty = tonumber(CompetitiveGetSetting("lootMinQty", 1)) or 1
  if minQty < 1 then minQty = 1 end
  if count < minQty then
    return false, 0
  end

  local rec = CompetitiveBuildItemRecord(itemID)
  if not rec then
    return false, 0
  end

  local minQuality = CompetitiveMinQualityToNumber(CompetitiveGetSetting("lootMinQuality", 0))
  if minQuality < 0 then minQuality = 0 end

  local ignoreSoulbound = CompetitiveGetSetting("lootIgnoreSoulbound", true)
  if ignoreSoulbound ~= false then ignoreSoulbound = true end

  if ignoreSoulbound and rec.bindType == 1 then
    return false, 0
  end

  if (tonumber(rec.quality) or 0) < minQuality then
    return false, 0
  end

  local categories = CompetitiveBuildCategorySelection()
  if CompetitiveHasCategorySelection(categories) and not categories[rec.recordCategory or "lootRecordMisc"] then
    return false, 0
  end

  local unit = 0
  if WF and WF.Price and WF.Price.GetItemValue then
    unit = tonumber(WF.Price:GetItemValue(itemID, rec.link)) or 0
  end
  local value = unit * count

  local minGold = tonumber(CompetitiveGetSetting("lootMinGold", 0)) or 0
  if minGold < 0 then minGold = 0 end
  if minGold > 0 and value < minGold then
    return false, value
  end

  return true, value
end

function UI:_MarkCompetitiveLootDirty()
  self:_EnsureCompetitiveState()
  self._compLootDirty = true
end

function UI:_GetCompetitiveTrackedCounts(forceRefresh)
  self:_EnsureCompetitiveState()

  local out = {}
  if not (Tracker and Tracker.current) then
    return out
  end

  local now = (GetTime and GetTime()) or SafeNow()
  local shouldReprice = forceRefresh and true or false
  if not shouldReprice then
    local last = tonumber(self._compLastTrackerRepriceAt) or 0
    if last <= 0 or (now - last) >= 1 then
      shouldReprice = true
    end
  end

  if shouldReprice and Tracker.RepriceCurrent then
    pcall(function()
      Tracker:RepriceCurrent(forceRefresh and true or false)
    end)
    self._compLastTrackerRepriceAt = now
  end

  local function MergeRecordedBucket(bucket)
    if type(bucket) ~= "table" then return end
    for iid, entry in pairs(bucket) do
      iid = tonumber(iid) or tonumber(entry and entry.itemID)
      local cnt = tonumber(entry and entry.count) or 0
      if iid and cnt and cnt > 0 then
        out[iid] = (tonumber(out[iid]) or 0) + cnt
      end
    end
  end

  local sess = Tracker.current or {}

  -- Competitive loot must mirror the tracker's recorded, filter-approved items only.
  -- Do NOT merge sess.pending here. Pending entries can still be waiting on meta,
  -- minimum quantity/value gates, or final category/quality decisions, which is how
  -- gray/junk items were leaking into the competitive loot table at the end of a run.
  MergeRecordedBucket(sess.items)

  return out
end

function UI:_ResetCompetitiveTrackedSyncBase()
  self:_EnsureCompetitiveState()
  local cur = self:_GetCompetitiveTrackedCounts(true)
  self._compTrackedBaseCounts = CopyCountsTable(cur)
  self._compLastSentTrackedCounts = CopyCountsTable(cur)
end

function UI:_GetCompetitiveTrackedCountsSinceStart(forceRefresh)
  self:_EnsureCompetitiveState()
  local cur = self:_GetCompetitiveTrackedCounts(forceRefresh)
  local base = self._compTrackedBaseCounts or {}
  local out = {}

  for iid, cnt in pairs(cur) do
    local delta = (tonumber(cnt) or 0) - (tonumber(base[iid]) or 0)
    if delta > 0 then
      out[iid] = delta
    end
  end

  return out
end

function UI:_GetCompetitiveTrackedDeltaSinceLastSend(forceRefresh)
  self:_EnsureCompetitiveState()

  local cur = self:_GetCompetitiveTrackedCounts(forceRefresh)
  local last = self._compLastSentTrackedCounts or {}
  local out = {}
  local changed = false

  for iid, cnt in pairs(cur) do
    local delta = (tonumber(cnt) or 0) - (tonumber(last[iid]) or 0)
    if delta > 0 then
      out[iid] = delta
      changed = true
    end
  end

  if changed then
    self._compLastSentTrackedCounts = CopyCountsTable(cur)
  end

  return out
end


function UI:_GetCompetitiveRawCountsSinceStart()
  self:_EnsureCompetitiveState()
  return CopyCountsTable(self._compLocalLootCounts or {})
end

function UI:_GetCompetitiveRawDeltaSinceLastSend()
  self:_EnsureCompetitiveState()

  local cur = self:_GetCompetitiveRawCountsSinceStart()
  local last = self._compLastSentRawLootCounts or {}
  local out = {}
  local changed = false

  for iid, cnt in pairs(cur) do
    local delta = (tonumber(cnt) or 0) - (tonumber(last[iid]) or 0)
    if delta > 0 then
      out[iid] = delta
      changed = true
    end
  end

  if changed then
    self._compLastSentRawLootCounts = CopyCountsTable(cur)
  end

  return out
end

function UI:_BuildCompetitiveHostScoredCounts(counts)
  self:_EnsureCompetitiveState()

  local out = {}
  counts = counts or {}
  for iid, cnt in pairs(counts) do
    iid = tonumber(iid)
    cnt = tonumber(cnt)
    if iid and cnt and cnt > 0 then
      local ok = CompetitiveHostItemPassesRules(iid, cnt)
      if ok then
        out[iid] = cnt
      end
    end
  end
  return out
end

function UI:_RebuildCompetitiveAllAuthoritativeState(broadcast)
  if not self:_IsCompetitiveLeader() then return end
  self:_EnsureCompetitiveState()

  local me = self:_CanonCompName(self:_GetLocalPlayerTag())
  if me ~= "" then
    self._compLootCounts[me] = self:_GetCompetitiveRawCountsSinceStart()
  end

  local roster = {}
  if me ~= "" then roster[me] = true end
  for name in pairs(self._compPeers or {}) do
    name = self:_CanonCompName(name)
    if name ~= "" then roster[name] = true end
  end

  for name in pairs(roster) do
    self:_RebuildCompetitivePlayerFromCounts(name, broadcast)
  end
end

function UI:_SyncCompetitiveLocalLootFromTracker(forceRefresh)
  self:_EnsureCompetitiveState()

  -- In Competitive, the host owns official values. Non-hosts should not overwrite
  -- their own host-synced loot rows with locally priced data.
  if tostring(self._compSessionId or "") ~= "" and not self:_IsCompetitiveLeader() then
    return false
  end

  local me = self:_CanonCompName(self:_GetLocalPlayerTag())
  if me == "" then
    return false
  end

  local raw = self:_GetCompetitiveRawCountsSinceStart()
  local scored = self:_BuildCompetitiveHostScoredCounts(raw)
  local sigParts = {}
  for iid, cnt in pairs(scored) do
    iid = tonumber(iid)
    cnt = tonumber(cnt)
    if iid and cnt and cnt > 0 then
      sigParts[#sigParts + 1] = tostring(iid) .. ":" .. tostring(cnt)
    end
  end
  table.sort(sigParts)
  local payload = table.concat(sigParts, ",")
  local changed = forceRefresh and true or (payload ~= tostring(self._compLastLocalTrackedPayload or ""))

  self._compLootCounts[me] = CopyCountsTable(raw)
  self._compScoredLootCounts[me] = CopyCountsTable(scored)
  self._compLoot[me] = self._compLoot[me] or {}
  self._compLoot[me].items = self:_BuildCompetitiveLootItemsFromCounts(scored)
  self._compLoot[me].updatedAt = SafeNow()
  self._compLastLocalTrackedPayload = payload

  if changed then
    self:_MarkCompetitiveLootDirty()
  end

  return changed
end

function UI:_PruneCompetitiveDeltaInbox(maxAge)
  self:_EnsureCompetitiveState()

  maxAge = tonumber(maxAge) or 15
  if maxAge < 3 then maxAge = 3 end

  local now = SafeNow()
  for who, seqMap in pairs(self._compDeltaInbox or {}) do
    if type(seqMap) == "table" then
      for seq, inbox in pairs(seqMap) do
        local createdAt = tonumber(inbox and inbox.createdAt) or 0
        if createdAt <= 0 or (now - createdAt) >= maxAge then
          seqMap[seq] = nil
        end
      end
      if next(seqMap) == nil then
        self._compDeltaInbox[who] = nil
      end
    else
      self._compDeltaInbox[who] = nil
    end
  end
end

function UI:_ComputeCompetitiveValueFromCounts(counts)
  local total = 0
  counts = counts or {}
  for iid, cnt in pairs(counts) do
    iid = tonumber(iid)
    cnt = tonumber(cnt)
    if iid and cnt and cnt > 0 and WF and WF.Price and WF.Price.GetItemValue then
      total = total + ((tonumber(WF.Price:GetItemValue(iid)) or 0) * cnt)
    end
  end
  return total
end

function UI:_SplitCompetitiveCountsPayload(counts, maxLen)
  counts = counts or {}
  maxLen = tonumber(maxLen) or 180
  if maxLen < 60 then maxLen = 60 end

  local items = {}
  for iid, cnt in pairs(counts) do
    iid = tonumber(iid)
    cnt = tonumber(cnt)
    if iid and cnt and cnt > 0 then
      items[#items+1] = { itemID = iid, count = cnt }
    end
  end
  table.sort(items, function(a, b) return a.itemID < b.itemID end)

  local chunks, cur, curLen = {}, {}, 0
  for i = 1, #items do
    local part = tostring(items[i].itemID) .. ":" .. tostring(items[i].count)
    local add = #part
    if #cur > 0 then add = add + 1 end
    if (curLen + add) > maxLen and #cur > 0 then
      chunks[#chunks+1] = table.concat(cur, ",")
      cur = { part }
      curLen = #part
    else
      cur[#cur+1] = part
      curLen = curLen + add
    end
  end
  if #cur > 0 then
    chunks[#chunks+1] = table.concat(cur, ",")
  end
  return chunks
end
function UI:_BuildCompetitiveCountsString(counts, maxItems, maxLen)
  counts = counts or {}
  maxItems = tonumber(maxItems) or 60
  if maxItems < 1 then maxItems = 1 end
  if maxItems > 120 then maxItems = 120 end

  maxLen = tonumber(maxLen) or 220
  if maxLen < 80 then maxLen = 80 end
  if maxLen > 230 then maxLen = 230 end

  local items = {}
  for iid, cnt in pairs(counts) do
    iid = tonumber(iid)
    cnt = tonumber(cnt)
    if iid and cnt and cnt > 0 then
      items[#items+1] = { itemID = iid, count = cnt }
    end
  end
  table.sort(items, function(a, b)
    return a.itemID < b.itemID
  end)

  local parts = {}
  local curLen = 0
  local n = 0
  for i = 1, #items do
    local it = items[i]
    local part = tostring(it.itemID) .. ":" .. tostring(it.count)
    local add = #part
    if #parts > 0 then add = add + 1 end
    if (curLen + add) > maxLen then
      break
    end
    parts[#parts+1] = part
    curLen = curLen + add
    n = n + 1
    if n >= maxItems then break end
  end

  return table.concat(parts, ",")
end


function UI:_GetCompetitiveSessionElapsed(atTime)
  self:_EnsureCompetitiveState()
  local sess = self._compSession or {}
  local startAt = tonumber(sess.startAt) or 0
  if startAt <= 0 then return 0 end

  local now = tonumber(atTime) or SafeNow()
  local state = tostring(sess.state or "idle")
  if state == "paused" then
    now = tonumber(sess.pausedAt) or now
  elseif state == "ended" then
    now = tonumber(sess.endAt) or now
  end

  local pausedTotal = tonumber(sess.pausedTotal) or 0
  local elapsed = now - startAt - pausedTotal
  if elapsed < 0 then elapsed = 0 end
  return elapsed
end

function UI:_SetCompetitiveSessionState(state, sid, startAt, pausedAt, pausedTotal, endAt)
  self:_EnsureCompetitiveState()
  local sess = self._compSession
  if sid ~= nil and tostring(sid or "") ~= "" then
    sess.id = tostring(sid)
    self._compSessionId = sess.id
  end
  if startAt ~= nil then
    sess.startAt = tonumber(startAt) or 0
    self._compStartAt = sess.startAt
  end
  if pausedAt ~= nil then
    sess.pausedAt = tonumber(pausedAt) or 0
  end
  if pausedTotal ~= nil then
    sess.pausedTotal = tonumber(pausedTotal) or 0
  end
  if endAt ~= nil then
    sess.endAt = tonumber(endAt) or 0
  end
  sess.state = tostring(state or sess.state or "idle")
  self._compFrozen = (sess.state == "paused") or (sess.state == "ended")
  self._compStarted = (sess.state == "live") and true or false

  if self._RefreshMainSessionControlLocks then
    self:_RefreshMainSessionControlLocks()
  end
end

function UI:_BeginCompetitiveRaceState(sid, startAt, requiredProfile, hostProvider)
  self:_EnsureCompetitiveState()
  self._compSessionId = tostring(sid or "")
  self._compLeader = self:_CanonCompName(self._compLeader or self:_GetLocalPlayerTag())
  self._compRequiredProfile = tostring(requiredProfile or self._compRequiredProfile or "")
  self._compHostPriceProvider = tostring(hostProvider or self._compHostPriceProvider or "")

  self._compScores = {}
  self._compMeta = {}
  self._compLoot = {}
  self._compLootCounts = {}
  self._compScoredLootCounts = {}
  self._compPlayerState = {}
  self._compDeltaInbox = {}
  self._compLastMetaSendAt = 0
  self._compLastLootSendAt = 0
  self._compLastLootPruneAt = 0
  self._compFrozen = false
  self._compStarted = false
  self._compResetArmedUntil = 0
  self:_ResetCompetitiveLocalLoot()

  self:_SetCompetitivePlayerStatus(self:_GetLocalPlayerTag(), "active")
  for n in pairs(self._compPeers or {}) do
    self:_SetCompetitivePlayerStatus(n, "active")
  end
  for n in pairs(self._compInvited or {}) do
    if not self._compPeers[n] then
      self:_SetCompetitivePlayerStatus(n, "invited")
    end
  end

  local sess = self._compSession or {}
  sess.host = self._compLeader
  sess.revision = (tonumber(sess.revision) or 0) + 1
  self:_SetCompetitiveSessionState("countdown", sid, startAt, 0, 0, 0)
  self:_MarkCompetitiveLootDirty()
end

function UI:_RebuildCompetitivePlayerFromCounts(name, broadcast)
  name = self:_CanonCompName(name)
  if name == "" then return end
  self:_EnsureCompetitiveState()

  self:_MarkCompetitiveLootDirty()

  local rawCounts = self._compLootCounts[name] or {}
  local counts = self:_BuildCompetitiveHostScoredCounts(rawCounts)
  self._compScoredLootCounts[name] = CopyCountsTable(counts)
  local value = self:_ComputeCompetitiveValueFromCounts(counts)
  local elapsed = self:_GetCompetitiveSessionElapsed()
  local status = self:_GetCompetitivePlayerStatus(name)

  self:_EnsureCompScore(name)
  self._compScores[name].value = value
  self._compScores[name].elapsed = elapsed
  self._compScores[name].status = status
  self._compScores[name].updatedAt = SafeNow()

  local lootStr = self:_BuildCompetitiveLootStringFromCounts(counts, 12, 220)
  self._compLoot[name] = self._compLoot[name] or {}
  self._compLoot[name].items = self:_ParseCompetitiveLootString(lootStr)
  self._compLoot[name].updatedAt = SafeNow()
  self:_MarkCompetitiveLootDirty()

  if broadcast and self._compSessionId and self:_IsCompetitiveLeader() then
    local sid = tostring(self._compSessionId)
    self:_SendCompetitivePeerMessage("RUPD|" .. sid .. "|" .. name .. "|" .. tostring(value) .. "|" .. tostring(elapsed) .. "|" .. tostring(status))
    self:_SendCompetitivePeerMessage("RLOOT|" .. sid .. "|" .. name .. "|" .. lootStr)
  end
end

function UI:_RebuildCompetitiveSelfAuthoritativeState(broadcast)
  if not self:_IsCompetitiveLeader() then return end
  local me = self:_CanonCompName(self:_GetLocalPlayerTag())
  if me == "" then return end
  self._compLootCounts[me] = self:_GetCompetitiveRawCountsSinceStart()
  self:_RebuildCompetitivePlayerFromCounts(me, broadcast)
end

function UI:_SendCompetitiveFilteredCountDeltas(forceEmpty)
  self:_EnsureCompetitiveState()
  if not self._compSessionId then return end
  if self:_GetCompetitivePlayerStatus(self:_GetLocalPlayerTag()) == "quit" then return end

  local delta = self:_GetCompetitiveRawDeltaSinceLastSend()
  if (not forceEmpty) and (type(delta) ~= "table" or next(delta) == nil) then
    return
  end

  local chunks = self:_SplitCompetitiveCountsPayload(delta, 180)
  if #chunks == 0 then
    if not forceEmpty then return end
    chunks = { "" }
  end

  self._compDeltaSeq = (tonumber(self._compDeltaSeq) or 0) + 1
  local seq = tostring(self._compDeltaSeq)
  local total = tostring(#chunks)
  local sid = tostring(self._compSessionId)

  for i = 1, #chunks do
    self:_SendCompetitivePeerMessage("FD|" .. sid .. "|" .. seq .. "|" .. tostring(i) .. "/" .. total .. "|" .. tostring(chunks[i] or ""))
  end
end

local function PrettyName(tag)
  tag = tostring(tag or "")
  if Ambiguate then
    return Ambiguate(tag, "short")
  end
  return tag
end

local function TableCount(t)
  local n = 0
  if type(t) ~= "table" then return 0 end
  for _ in pairs(t) do n = n + 1 end
  return n
end

local function SplitTag(tag)
  tag = tostring(tag or "")
  local a, b = tag:match("^([^%-]+)%-(.+)$")
  if a and b then
    return a, b
  end
  return tag, nil
end

local function ShortTag(tag)
  local n = SplitTag(tag)
  return tostring(n or "")
end

-- ---------------------------------------------------------
-- Identity
-- ---------------------------------------------------------
function UI:_GetLocalPlayerTag()
  if UnitFullName then
    local n, r = UnitFullName("player")
    if n and r and r ~= "" then
      return n .. "-" .. r
    end
  end
  if UnitName then
    return UnitName("player")
  end
  return "player"
end

local function GetLocalPlayerGUID()
  if UnitGUID then
    return tostring(UnitGUID("player") or "")
  end
  return ""
end


function UI:_IsCompetitiveLeader()
  local leader = self:_CanonCompName(self._compLeader or "")
  if leader == "" then return false end
  local me = self:_CanonCompName(self:_GetLocalPlayerTag())
  if me == "" then return false end
  return leader == me
end

-- ---------------------------------------------------------
-- Competitive state helpers
-- ---------------------------------------------------------

function UI:_BuildCompetitiveRuntimeSnapshot()
  self:_EnsureCompetitiveState()

  local sid = Trim(self._compSessionId or "")
  local sess = self._compSession or {}
  local state = tostring(sess.state or "idle")
  if sid == "" or state == "idle" then
    return nil
  end

  return {
    v = 2,
    savedAt = SafeNow(),
    localPlayerTag = tostring(self:_GetLocalPlayerTag() or ""),
    localPlayerGuid = GetLocalPlayerGUID(),
    sessionId = sid,
    leader = tostring(self._compLeader or ""),
    started = self._compStarted and true or false,
    startAt = tonumber(self._compStartAt) or 0,
    baseValue = tonumber(self._compBaseValue) or 0,
    baseElapsed = tonumber(self._compBaseElapsed) or 0,
    requiredProfile = tostring(self._compRequiredProfile or ""),
    hostPriceProvider = tostring(self._compHostPriceProvider or ""),
    frozen = self._compFrozen and true or false,
    session = CopyCompetitivePlainTable(self._compSession),
    peers = CopyCompetitivePlainTable(self._compPeers),
    invited = CopyCompetitivePlainTable(self._compInvited),
    alias = CopyCompetitivePlainTable(self._compAlias),
    scores = CopyCompetitivePlainTable(self._compScores),
    meta = CopyCompetitivePlainTable(self._compMeta),
    loot = CopyCompetitivePlainTable(self._compLoot),
    lootCounts = CopyCompetitivePlainTable(self._compLootCounts),
    scoredLootCounts = CopyCompetitivePlainTable(self._compScoredLootCounts),
    playerState = CopyCompetitivePlainTable(self._compPlayerState),
    trackedBaseCounts = CopyCompetitivePlainTable(self._compTrackedBaseCounts),
    lastSentTrackedCounts = CopyCompetitivePlainTable(self._compLastSentTrackedCounts),
    lastSentRawLootCounts = CopyCompetitivePlainTable(self._compLastSentRawLootCounts),
    localLootCounts = CopyCompetitivePlainTable(self._compLocalLootCounts),
    localLootLastSeen = CopyCompetitivePlainTable(self._compLocalLootLastSeen),
  }
end

function UI:_PersistCompetitiveRuntimeState()
  local rt = GetCompetitiveRuntimeDB()
  if not rt then return end
  rt.activeCompetitive = self:_BuildCompetitiveRuntimeSnapshot()
end

function UI:_ClearPersistedCompetitiveRuntimeState()
  local rt = GetCompetitiveRuntimeDB()
  if not rt then return end
  rt.activeCompetitive = nil
end

function UI:_RestoreCompetitiveRuntimeState()
  local rt = GetCompetitiveRuntimeDB()
  if not rt then return false end

  local saved = rt.activeCompetitive
  if type(saved) ~= "table" then
    rt.activeCompetitive = nil
    return false
  end

  -- Competitive runtime state is character-local. Older snapshots were account-wide,
  -- which could make a character look grouped with another character on the same account
  -- after swapping alts. Drop legacy or mismatched snapshots instead of restoring ghosts.
  if tonumber(saved.v) ~= 2 then
    rt.activeCompetitive = nil
    return false
  end

  local savedGuid = Trim(saved.localPlayerGuid or "")
  local currentGuid = GetLocalPlayerGUID()
  if savedGuid ~= "" and currentGuid ~= "" and savedGuid ~= currentGuid then
    rt.activeCompetitive = nil
    return false
  end

  local savedPlayer = self:_CanonCompName(saved.localPlayerTag or "")
  local currentPlayer = self:_CanonCompName(self:_GetLocalPlayerTag())
  if savedPlayer ~= "" and currentPlayer ~= "" and savedPlayer ~= currentPlayer then
    rt.activeCompetitive = nil
    return false
  end

  local savedAt = tonumber(saved.savedAt) or 0
  if savedAt <= 0 then
    rt.activeCompetitive = nil
    return false
  end

  local age = SafeNow() - savedAt
  if age < 0 then age = 0 end
  if age > 900 then
    rt.activeCompetitive = nil
    return false
  end

  local sid = Trim(saved.sessionId or "")
  if sid == "" then
    rt.activeCompetitive = nil
    return false
  end

  self:_EnsureCompetitiveState()

  self._compLeader = Trim(saved.leader or "")
  self._compSessionId = sid
  self._compStarted = saved.started and true or false
  self._compStartAt = tonumber(saved.startAt) or 0
  self._compBaseValue = tonumber(saved.baseValue) or 0
  self._compBaseElapsed = tonumber(saved.baseElapsed) or 0
  self._compRequiredProfile = tostring(saved.requiredProfile or "")
  self._compHostPriceProvider = tostring(saved.hostPriceProvider or "")
  self._compFrozen = saved.frozen and true or false

  self._compSession = CopyCompetitivePlainTable(saved.session or {}) or {}
  self._compSession.id = sid
  self._compPeers = CopyCompetitivePlainTable(saved.peers or {}) or {}
  self._compInvited = CopyCompetitivePlainTable(saved.invited or {}) or {}
  self._compAlias = CopyCompetitivePlainTable(saved.alias or {}) or {}
  self._compScores = CopyCompetitivePlainTable(saved.scores or {}) or {}
  self._compMeta = CopyCompetitivePlainTable(saved.meta or {}) or {}
  self._compLoot = CopyCompetitivePlainTable(saved.loot or {}) or {}
  self._compLootCounts = CopyCompetitivePlainTable(saved.lootCounts or {}) or {}
  self._compScoredLootCounts = CopyCompetitivePlainTable(saved.scoredLootCounts or {}) or {}
  self._compPlayerState = CopyCompetitivePlainTable(saved.playerState or {}) or {}
  self._compTrackedBaseCounts = CopyCompetitivePlainTable(saved.trackedBaseCounts or {}) or {}
  self._compLastSentTrackedCounts = CopyCompetitivePlainTable(saved.lastSentTrackedCounts or {}) or {}
  self._compLastSentRawLootCounts = CopyCompetitivePlainTable(saved.lastSentRawLootCounts or {}) or {}
  self._compLocalLootCounts = CopyCompetitivePlainTable(saved.localLootCounts or {}) or {}
  self._compLocalLootLastSeen = CopyCompetitivePlainTable(saved.localLootLastSeen or {}) or {}
  self._compLootDirty = true
  self._compLastLootRenderState = ""
  self._compLastTrackerRepriceAt = 0

  local sess = self._compSession or {}
  local state = tostring(sess.state or "idle")
  if state == "live" then
    self._compStarted = true
    self._compFrozen = false
    self:_EnsureCompetitiveTicker()
  elseif state == "paused" then
    self._compStarted = false
    self._compFrozen = true
    self:_StopCompetitiveTicker()
  elseif state == "countdown" then
    self._compStarted = false
    self._compFrozen = false
    self:_EnsureCompetitiveTicker()
  elseif state == "ended" then
    self._compStarted = false
    self._compFrozen = true
    self:_StopCompetitiveTicker()
  else
    rt.activeCompetitive = nil
    return false
  end

  return true
end

function UI:_EnsureCompetitiveState()
  self._comp = self._comp or {}
  self._compScores = self._compScores or {}
  self._compPeers  = self._compPeers  or {}
  -- _compInvited is a map: [canonName] = { target = "raw whisper target" }
  self._compInvited = self._compInvited or {}
  -- Alias map to prevent duplicate short/full rows: ["Bee"] = "Bee-Area52"
  self._compAlias  = self._compAlias or {}
  -- Per-player meta for tooltip/"farm loot" (zone + top items)
  self._compMeta   = self._compMeta or {}
  self._compLastMetaSendAt = self._compLastMetaSendAt or 0
  -- Per-player loot summaries for the Loot tab
  self._compLoot   = self._compLoot or {}
  self._compLastLootSendAt = self._compLastLootSendAt or 0

  -- Host-authoritative raw loot counts per player, plus host-filtered/scored counts.
  self._compLootCounts = self._compLootCounts or {}
  self._compScoredLootCounts = self._compScoredLootCounts or {}

  -- Per-player lifecycle / row status (active, invited, quit).
  self._compPlayerState = self._compPlayerState or {}

  -- Local tracked-count baselines so a challenge can begin in the middle of an existing run
  -- without inheriting earlier loot.
  self._compTrackedBaseCounts = self._compTrackedBaseCounts or {}
  self._compLastSentTrackedCounts = self._compLastSentTrackedCounts or {}
  self._compLastSentRawLootCounts = self._compLastSentRawLootCounts or {}

  -- Chunked filtered-count delta sync.
  self._compDeltaSeq = self._compDeltaSeq or 0
  self._compDeltaInbox = self._compDeltaInbox or {}

  -- Shared session/clock state.
  self._compSession = self._compSession or {
    id = "",
    host = "",
    state = "idle", -- idle / countdown / live / paused / ended
    startAt = 0,
    pausedAt = 0,
    pausedTotal = 0,
    endAt = 0,
    revision = 0,
  }

  -- Countdown invalidation so stale timers from an older session never start a new one.
  self._compStartToken = tonumber(self._compStartToken) or 0

  -- Host scoring basis (everyone displays the host's price provider for fairness)
  self._compHostPriceProvider = self._compHostPriceProvider or ""

  -- Temporary competitive rules/profile override (restored on Reset)
  self._compRulesSaved = self._compRulesSaved or nil
  self._compRulesAppliedProfile = self._compRulesAppliedProfile or ""
  self._compSavedActiveProfile = self._compSavedActiveProfile or ""

  -- Local unfiltered loot counts for competitive sync (independent of Tracker filters/pricing)
  self._compLocalLootCounts   = self._compLocalLootCounts or {}
  self._compLocalLootLastSeen = self._compLocalLootLastSeen or {}
  self._compLastLocalTrackedPayload = self._compLastLocalTrackedPayload or ""

  -- Loot panel refresh throttles / dirty tracking.
  self._compLootDirty = self._compLootDirty == nil and true or self._compLootDirty
  self._compLastLootRenderState = self._compLastLootRenderState or ""
  self._compLastTrackerRepriceAt = tonumber(self._compLastTrackerRepriceAt) or 0
  self._compLootExpanded = self._compLootExpanded or {}
  self._comp.lootSortMode = self._comp.lootSortMode or "value"
  self._comp.lootSortDir = self._comp.lootSortDir or "desc"

  -- Local snapshot/freeze state used by the board.
  self._compFrozen = self._compFrozen or false
  self._compResetArmedUntil = self._compResetArmedUntil or 0
end

-- When we learn a full name for a short tag, collapse any old short-key entries
-- so we don't render the same person twice ("Bee" + "Bee-Area52").

-- ---------------------------------------------------------
-- Competitive "rules" enforcement
-- We enforce the REQUIRED PROFILE'S FILTERS for everyone, but we DO NOT touch each player's priceSource.
-- Scoring/value is host-authoritative in competitive mode.
-- ---------------------------------------------------------
local COMP_RULE_KEYS = {
  "lootMinGold",
  "lootMinQty",
  "lootMinQuality",
  "lootIncludeMaterials",
  "lootOnlyMaterials",
  "lootOnlyGear",
  "lootRecordMaterials",
  "lootRecordGear",
  "lootRecordRecipes",
  "lootRecordPets",
  "lootRecordToys",
  "lootRecordMounts",
  "lootRecordConsumables",
  "lootRecordMisc",
  "lootIgnoreSoulbound",
  "priceSource",
  "tsmPriceString",
  "overviewLast5Metric",
}

local function NormalizeCompetitiveProvider(provider)
  provider = Trim(provider)
  provider = provider:gsub("%s+", "")
  if provider == "" then
    return "", ""
  end

  local source, extra = provider:match("^([%a_]+)%:(.+)$")
  if source then
    source = Lower(source)
    extra = Trim(extra)
  else
    source = Lower(provider)
    extra = ""
  end

  if source ~= "vendor" and source ~= "auctionator" and source ~= "tsm" then
    return "", ""
  end

  return source, extra or ""
end

function UI:_SetCompetitiveRuntimeSetting(key, value)
  if not (WF and WF.DB) then
    return false
  end

  local db = nil
  if WF.DB.Get then
    local ok, ret = pcall(function()
      return WF.DB:Get()
    end)
    if ok then
      db = ret
    end
  end

  if type(db) == "table" then
    db.settings = db.settings or {}
    db.settings[key] = value
    return true
  end

  if WF.DB.SetSetting then
    WF.DB:SetSetting(key, value)
    return true
  end

  return false
end

function UI:_EnsureCompetitiveRulesBackup()
  self:_EnsureCompetitiveState()

  if type(self._compRulesSaved) == "table" then
    return
  end

  self._compRulesSaved = {}
  if not (WF and WF.DB and WF.DB.GetSetting) then
    return
  end

  for i = 1, #COMP_RULE_KEYS do
    local k = COMP_RULE_KEYS[i]
    self._compRulesSaved[k] = WF.DB:GetSetting(k)
  end
end

function UI:_ApplyCompetitiveHostPriceProvider(hostProvider, forceRefresh)
  self:_EnsureCompetitiveState()

  local source, extra = NormalizeCompetitiveProvider(hostProvider or self._compHostPriceProvider or "")
  if source == "" then
    return false
  end
  if not (WF and WF.DB and WF.DB.GetSetting) then
    return false
  end

  self:_EnsureCompetitiveRulesBackup()

  local changed = false
  local curSource = Lower(WF.DB:GetSetting("priceSource") or "")
  if curSource == "" then
    curSource = "vendor"
  end

  if curSource ~= source then
    if self:_SetCompetitiveRuntimeSetting("priceSource", source) then
      changed = true
    end
  end

  if source == "tsm" and extra ~= "" then
    local curExtra = Trim(WF.DB:GetSetting("tsmPriceString") or "")
    if curExtra ~= extra then
      if self:_SetCompetitiveRuntimeSetting("tsmPriceString", extra) then
        changed = true
      end
    end
  end

  if changed or forceRefresh then
    self._compLastTrackerRepriceAt = 0
    if Tracker and Tracker.RepriceCurrent then
      pcall(function()
        Tracker:RepriceCurrent(true)
      end)
    end
    if self.RefreshProfileUI then pcall(function() self:RefreshProfileUI() end) end
    if self.RefreshTitle then pcall(function() self:RefreshTitle() end) end
    if self.RefreshMetrics then pcall(function() self:RefreshMetrics() end) end
    if self.RefreshLoot then pcall(function() self:RefreshLoot(true) end) end
  end

  return true
end

function UI:_RestoreCompetitiveRules()
  self:_EnsureCompetitiveState()

  local restoredProfile = false
  local restoreName = Trim(self._compSavedActiveProfile or "")
  if restoreName ~= "" and WF and WF.DB and WF.DB.ApplyProfile then
    local ok, ret = pcall(function()
      return WF.DB:ApplyProfile(restoreName)
    end)
    restoredProfile = ok and ret and true or false
  end

  if not restoredProfile then
    if not (WF and WF.DB and WF.DB.SetSetting) then
      self._compRulesSaved = nil
      self._compRulesAppliedProfile = ""
      self._compSavedActiveProfile = ""
      return
    end
    if type(self._compRulesSaved) == "table" then
      for i = 1, #COMP_RULE_KEYS do
        local k = COMP_RULE_KEYS[i]
        if self._compRulesSaved[k] ~= nil then
          WF.DB:SetSetting(k, self._compRulesSaved[k])
        end
      end
    end
  end

  if type(self._compRulesSaved) == "table" then
    if self._compRulesSaved.priceSource ~= nil then
      self:_SetCompetitiveRuntimeSetting("priceSource", self._compRulesSaved.priceSource)
    end
    if self._compRulesSaved.tsmPriceString ~= nil then
      self:_SetCompetitiveRuntimeSetting("tsmPriceString", self._compRulesSaved.tsmPriceString)
    end
  end

  if Tracker and Tracker.RepriceCurrent then
    pcall(function()
      Tracker:RepriceCurrent(true)
    end)
  end

  self._compRulesSaved = nil
  self._compRulesAppliedProfile = ""
  self._compSavedActiveProfile = ""

  if self.RefreshProfileUI then pcall(function() self:RefreshProfileUI() end) end
  if self.RefreshTitle then pcall(function() self:RefreshTitle() end) end
  if self.RefreshMetrics then pcall(function() self:RefreshMetrics() end) end
  if self.RefreshLoot then pcall(function() self:RefreshLoot(true) end) end
end

function UI:_ApplyCompetitiveRules(profileName)
  self:_EnsureCompetitiveState()
  profileName = Trim(profileName)
  if profileName == "" then return false end
  if not (WF and WF.DB and WF.DB.GetProfile and WF.DB.GetSetting and WF.DB.SetSetting) then
    return false
  end

  local prof = WF.DB:GetProfile(profileName)
  if type(prof) ~= "table" then
    return false
  end

  if type(self._compSavedActiveProfile) ~= "string" or self._compSavedActiveProfile == "" then
    if WF.DB.GetActiveProfileName then
      self._compSavedActiveProfile = tostring(WF.DB:GetActiveProfileName() or "")
    else
      self._compSavedActiveProfile = ""
    end
  end

  -- Save current rule settings once so Reset can restore them even if profile switching is unavailable.
  self:_EnsureCompetitiveRulesBackup()

  local applied = false
  if WF.DB.ApplyProfile then
    local ok, ret = pcall(function()
      return WF.DB:ApplyProfile(profileName)
    end)
    applied = ok and ret and true or false
  end

  if not applied then
    -- Fallback: at least force the tracking rules to match the required profile.
    for i = 1, #COMP_RULE_KEYS do
      local k = COMP_RULE_KEYS[i]
      if prof[k] ~= nil then
        WF.DB:SetSetting(k, prof[k])
      end
    end
  end

  self._compRulesAppliedProfile = profileName

  -- Non-host clients use the host for Competitive scoring. Keep their own pricing
  -- addon/settings alone and prevent local min-value gates from hiding loot before
  -- the host can price it. The host still applies the profile normally.
  if not self:_IsCompetitiveLeader() then
    if type(self._compRulesSaved) == "table" then
      if self._compRulesSaved.priceSource ~= nil then
        self:_SetCompetitiveRuntimeSetting("priceSource", self._compRulesSaved.priceSource)
      end
      if self._compRulesSaved.tsmPriceString ~= nil then
        self:_SetCompetitiveRuntimeSetting("tsmPriceString", self._compRulesSaved.tsmPriceString)
      end
    end
    self:_SetCompetitiveRuntimeSetting("lootMinGold", 0)
  end

  if self.RefreshProfileUI then pcall(function() self:RefreshProfileUI() end) end
  if self.RefreshTitle then pcall(function() self:RefreshTitle() end) end
  if self.RefreshMetrics then pcall(function() self:RefreshMetrics() end) end
  if self.RefreshLoot then pcall(function() self:RefreshLoot(true) end) end
  return true
end

function UI:_MergeCompetitiveAlias(short, full)
  short = Trim(short)
  full  = Trim(full)
  if short == "" or full == "" or short == full then return end

  self:_EnsureCompetitiveState()

  -- peers
  if self._compPeers[short] then
    self._compPeers[short] = nil
    self._compPeers[full] = true
  end

  -- invited placeholders
  if self._compInvited[short] and not self._compInvited[full] then
    self._compInvited[full] = self._compInvited[short]
  end
  self._compInvited[short] = nil

  -- scores
  if self._compScores[short] and not self._compScores[full] then
    self._compScores[full] = self._compScores[short]
  end
  self._compScores[short] = nil

  -- meta
  if self._compMeta[short] and not self._compMeta[full] then
    self._compMeta[full] = self._compMeta[short]
  end
  self._compMeta[short] = nil

  -- loot summaries
  if self._compLoot and self._compLoot[short] and not (self._compLoot and self._compLoot[full]) then
    self._compLoot[full] = self._compLoot[short]
  end
  if self._compLoot then
    self._compLoot[short] = nil
  end

  -- authoritative loot counts
  if self._compLootCounts and self._compLootCounts[short] and not (self._compLootCounts and self._compLootCounts[full]) then
    self._compLootCounts[full] = self._compLootCounts[short]
  end
  if self._compLootCounts then
    self._compLootCounts[short] = nil
  end

  -- host-filtered/scored loot counts
  if self._compScoredLootCounts and self._compScoredLootCounts[short] and not (self._compScoredLootCounts and self._compScoredLootCounts[full]) then
    self._compScoredLootCounts[full] = self._compScoredLootCounts[short]
  end
  if self._compScoredLootCounts then
    self._compScoredLootCounts[short] = nil
  end

  -- lifecycle state
  if self._compPlayerState and self._compPlayerState[short] and not (self._compPlayerState and self._compPlayerState[full]) then
    self._compPlayerState[full] = self._compPlayerState[short]
  end
  if self._compPlayerState then
    self._compPlayerState[short] = nil
  end

  -- partially received delta payloads
  if self._compDeltaInbox and self._compDeltaInbox[short] and not (self._compDeltaInbox and self._compDeltaInbox[full]) then
    self._compDeltaInbox[full] = self._compDeltaInbox[short]
  end
  if self._compDeltaInbox then
    self._compDeltaInbox[short] = nil
  end
end

function UI:_GetLocalPlayerShort()
  local full = self:_GetLocalPlayerTag()
  local n = ShortTag(full)
  if n and n ~= "" then
    return n
  end
  return full
end

function UI:_CanonCompName(tag)
  tag = Trim(tag)
  if tag == "" then return "" end

  self:_EnsureCompetitiveState()

  local tagLower = Lower(tag)
  local localFull = self:_GetLocalPlayerTag()
  local localShort = self:_GetLocalPlayerShort()
  local localFullLower = Lower(localFull)
  local localShortLower = Lower(localShort)

  if tagLower == localShortLower or tagLower == localFullLower then
    self._compAlias[localShortLower] = localFull
    self._compAlias[localFullLower] = localFull
    return localFull
  end

  local short = ShortTag(tag)
  local shortLower = Lower(short)
  if shortLower ~= "" and self._compAlias[shortLower] then
    return self._compAlias[shortLower]
  end

  local a, b = SplitTag(tag)
  if a and a ~= "" and b and b ~= "" then
    self._compAlias[Lower(a)] = tag
    self._compAlias[Lower(tag)] = tag
    self:_MergeCompetitiveAlias(a, tag)
  end

  return tag
end

function UI:_EnsureCompScore(name)
  name = self:_CanonCompName(name)
  if name == "" then return end
  self:_EnsureCompetitiveState()
  if not self._compScores[name] then
    self._compScores[name] = { value = 0, elapsed = 0, updatedAt = SafeNow(), status = self._compPlayerState[name] or "active" }
  end
  if not self._compScores[name].status or self._compScores[name].status == "" then
    self._compScores[name].status = self._compPlayerState[name] or "active"
  end
end

function UI:_SetCompetitivePlayerStatus(name, status)
  name = self:_CanonCompName(name)
  if name == "" then return end
  self:_EnsureCompetitiveState()
  status = Trim(status)
  if status == "" then status = "active" end
  self._compPlayerState[name] = status
  self:_EnsureCompScore(name)
  self._compScores[name].status = status
  self._compScores[name].updatedAt = SafeNow()
end

function UI:_GetCompetitivePlayerStatus(name)
  name = self:_CanonCompName(name)
  if name == "" then return "active" end
  self:_EnsureCompetitiveState()
  local status = self._compPlayerState[name]
  if type(status) ~= "string" or status == "" then
    status = self._compScores[name] and self._compScores[name].status or "active"
  end
  if type(status) ~= "string" or status == "" then
    status = "active"
  end
  return status
end


function UI:_ClearCompetitiveInvitePlaceholder(name)
  name = Trim(name)
  if name == "" then return end

  self:_EnsureCompetitiveState()

  local canon = self:_CanonCompName(name)
  local canonLower = Lower(canon ~= "" and canon or name)
  local shortLower = Lower(ShortTag(canon ~= "" and canon or name))
  local fullLower = canonLower

  for key, rec in pairs(self._compInvited) do
    local keyCanon = self:_CanonCompName(key)
    local keyLower = Lower(keyCanon ~= "" and keyCanon or key)
    local keyShortLower = Lower(ShortTag(keyCanon ~= "" and keyCanon or key))
    local targetLower = Lower(rec and rec.target or "")
    local inputLower = Lower(rec and rec.input or "")

    if keyLower == fullLower
      or (shortLower ~= "" and keyShortLower == shortLower)
      or targetLower == fullLower
      or (shortLower ~= "" and targetLower == shortLower)
      or inputLower == fullLower
      or (shortLower ~= "" and inputLower == shortLower)
    then
      self._compInvited[key] = nil
    end
  end
end


function UI:_HasCompetitivePeers()
  self:_EnsureCompetitiveState()
  for _ in pairs(self._compPeers) do
    return true
  end
  return false
end

function UI:_HasCompetitiveInvited()
  self:_EnsureCompetitiveState()
  for _ in pairs(self._compInvited) do
    return true
  end
  return false
end

function UI:_AddCompetitivePeer(name)
  name = self:_CanonCompName(name)
  if name == "" then return end
  self:_EnsureCompetitiveState()
  self._compPeers[name] = true
  self:_ClearCompetitiveInvitePlaceholder(name)
  self._compInvited[name] = nil
  self:_EnsureCompScore(name)
  self:_SetCompetitivePlayerStatus(name, "active")
end

function UI:_RemoveCompetitivePeer(name)
  name = self:_CanonCompName(name)
  if name == "" then return end
  self:_EnsureCompetitiveState()
  self._compPeers[name] = nil
end

function UI:_ClearCompetitivePeers()
  self:_EnsureCompetitiveState()
  self._compPeers = {}
  self._compInvited = {}
  self._compAlias = {}
  self._compLeader = nil
  self._compSessionId = nil
  self._compStarted = nil
  self._compStartAt = nil
  self._compBaseValue = nil
  self._compBaseElapsed = nil
  self._compRequiredProfile = nil
  self._compScores = {}
  self._compLoot = {}
  self._compMeta = {}
  self._compLootCounts = {}
  self._compScoredLootCounts = {}
  self._compPlayerState = {}
  self._compTrackedBaseCounts = {}
  self._compLastSentTrackedCounts = {}
  self._compLastSentRawLootCounts = {}
  self._compDeltaInbox = {}
  self._compDeltaSeq = 0
  self._compLastMetaSendAt = 0
  self._compLastLootSendAt = 0
  self._compSession = { id = "", host = "", state = "idle", startAt = 0, pausedAt = 0, pausedTotal = 0, endAt = 0, revision = 0 }
  self._compLastLocalTrackedPayload = ""
  self._compLootDirty = true
  self._compLastLootRenderState = ""
  self._compLastTrackerRepriceAt = 0
  self:_StopCompetitiveTicker()
  self:_ClearPersistedCompetitiveRuntimeState()
end

-- Recipients for whisper-mode messages
function UI:_IterCompetitiveRecipients(fn)
  self:_EnsureCompetitiveState()
  local sent = {}
  for n in pairs(self._compPeers) do
    n = self:_CanonCompName(n)
    if n ~= "" and not sent[n] then
      sent[n] = true
      fn(n)
    end
  end
  for canon, rec in pairs(self._compInvited) do
    canon = self:_CanonCompName(canon)
    local target = rec and rec.target or canon
    target = Trim(target)
    if target ~= "" and not sent[target] then
      sent[target] = true
      fn(target)
    end
  end
end

function UI:_IterCompetitivePeers(fn)
  self:_EnsureCompetitiveState()
  local sent = {}
  for n in pairs(self._compPeers) do
    n = self:_CanonCompName(n)
    if n ~= "" and not sent[n] then
      sent[n] = true
      fn(n)
    end
  end
end

-- Distribution: Party/Raid or Channel fallback (non-invite mode)
function UI:_GetGroupChannel()
  if IsInRaid and IsInRaid() then
    return "RAID"
  end
  if IsInGroup and IsInGroup() then
    return "PARTY"
  end
  return nil
end

function UI:_GetCompetitiveDistribution()
  -- If we're in invite/whisper mode, no group/channel distribution is used.
  if self:_HasCompetitivePeers() or self:_HasCompetitiveInvited() then
    return nil
  end

  -- Prefer party/raid if available.
  local ch = self:_GetGroupChannel()
  if ch then
    return ch, nil
  end

  -- Optional shared channel mode (must already be joined). We DO NOT auto-join channels
  -- because that can be confusing and can accidentally broadcast to people you didn't invite.
  if not GetChannelName then
    return nil
  end

  local cname = nil
  if WF and WF.DB and WF.DB.GetSetting then
    cname = WF.DB:GetSetting("compChannelName")
  end
  cname = tostring(cname or "WFARMCOMP")

  local id = GetChannelName(cname)
  if id and id > 0 then
    return "CHANNEL", id
  end

  return nil
end

-- ---------------------------------------------------------
-- Invite popup
-- ---------------------------------------------------------
function UI:_EnsureCompetitiveInvitePopup()
  if not StaticPopupDialogs then
    return
  end

  if not StaticPopupDialogs[COMP_INV_POPUP] then
    StaticPopupDialogs[COMP_INV_POPUP] = {
      text = "What Farm?: %s invited you to a Competitive challenge.\nRequired profile: %s",
      button1 = "Switch & Accept",
      button2 = DECLINE,
      timeout = 20,
      whileDead = 1,
      hideOnEscape = 1,
      preferredIndex = 3,
      OnShow = function(self)
        CompStyleStaticPopupFrame(self)
      end,
      OnAccept = function(_, data)
        UI:AcceptCompetitiveInvite(data)
      end,
      OnCancel = function(_, data)
        UI:DeclineCompetitiveInvite(data)
      end,
    }
  end

  if not StaticPopupDialogs[COMP_JOIN_REQ_POPUP] then
    StaticPopupDialogs[COMP_JOIN_REQ_POPUP] = {
      text = "What Farm?: %s wants to join your Competitive challenge.\nTheir current profile: %s",
      button1 = ACCEPT,
      button2 = DECLINE,
      timeout = 20,
      whileDead = 1,
      hideOnEscape = 1,
      preferredIndex = 3,
      OnShow = function(self)
        CompStyleStaticPopupFrame(self)
      end,
      OnAccept = function(_, data)
        if type(data) == "table" and data.from and data.from ~= "" then
          UI:InviteChallenge(data.from)
        end
      end,
      OnCancel = function(_, data)
        if type(data) == "table" and data.from and data.from ~= "" then
          UI:_SendCompetitiveWhisper(data.from, "RQDENY|0|declined|")
        end
      end,
    }
  end
end

function UI:_EnsureCompetitiveFriendInviteMenus()
  local menuLabel = "Invite to Competitive"
  local requestLabel = "Request to Join Competitive"
  local menuTitle = "What Farm?"
  local legacyButton = "WHATFARM_COMP_INVITE"
  local legacyRequestButton = "WHATFARM_COMP_REQUEST_JOIN"

  local function AddMenuDivider(rootDescription)
    if rootDescription and rootDescription.CreateDivider then
      rootDescription:CreateDivider()
    end
  end

  local function AddMenuTitle(rootDescription)
    if rootDescription and rootDescription.CreateTitle then
      rootDescription:CreateTitle(menuTitle)
    end
  end

  local function NormalizeContextRealm(realm)
    realm = NormalizeRealm(realm)
    if realm ~= "" then
      return realm
    end
    if GetNormalizedRealmName then
      return NormalizeRealm(GetNormalizedRealmName())
    end
    return ""
  end

  local function BuildNameRealm(name, realm)
    name = Trim(name)
    if name == "" then return nil end
    if name:find("-", 1, true) then return name end

    realm = NormalizeContextRealm(realm)
    if realm ~= "" then
      return name .. "-" .. realm
    end

    return name
  end

  local function BuildGameAccountName(gameInfo)
    if type(gameInfo) ~= "table" then
      return nil
    end

    local client = tostring(gameInfo.clientProgram or gameInfo.client or gameInfo.clientName or "")
    if client ~= "" and client ~= "WoW" then
      return nil
    end

    if gameInfo.isOnline == false then
      return nil
    end

    local name = gameInfo.characterName or gameInfo.playerName or gameInfo.name
    local realm = gameInfo.realmName or gameInfo.realmDisplayName or gameInfo.realmNameNormalized or gameInfo.realm
    return BuildNameRealm(name, realm)
  end

  local function UnitTokenToName(unit)
    unit = Trim(unit)
    if unit == "" or not (UnitExists and UnitExists(unit)) then
      return nil
    end

    local name, realm
    if UnitFullName then
      name, realm = UnitFullName(unit)
    end
    if (not name or name == "") and UnitName then
      name = UnitName(unit)
    end

    return BuildNameRealm(name, realm)
  end

  local function GetBNetAccountIDFromContext(contextData)
    if type(contextData) == "table" then
      local accountInfo = contextData.accountInfo or contextData.bnetAccountInfo or contextData.battleNetAccountInfo
      local id = tonumber(contextData.bnetAccountID or contextData.bnetAccountId or contextData.bnetIDAccount or contextData.accountID or contextData.accountId)
      if id then return id end

      if type(accountInfo) == "table" then
        id = tonumber(accountInfo.bnetAccountID or accountInfo.bnetAccountId or accountInfo.bnetIDAccount or accountInfo.accountID or accountInfo.accountId or accountInfo.id)
        if id then return id end
      end
    end

    local dropdown = UIDROPDOWNMENU_INIT_MENU
    if type(dropdown) == "table" then
      local id = tonumber(dropdown.bnetIDAccount or dropdown.bnetAccountID or dropdown.bnetAccountId or dropdown.accountID or dropdown.accountId)
      if id then return id end
    end

    return nil
  end

  local function GetBattleTagFromContext(contextData)
    if type(contextData) == "table" then
      local accountInfo = contextData.accountInfo or contextData.bnetAccountInfo or contextData.battleNetAccountInfo
      local tag = tostring(contextData.battleTag or contextData.battleTagName or contextData.battletag or "")
      if tag ~= "" then return tag end

      if type(accountInfo) == "table" then
        tag = tostring(accountInfo.battleTag or accountInfo.battleTagName or accountInfo.battletag or "")
        if tag ~= "" then return tag end
      end
    end

    local dropdown = UIDROPDOWNMENU_INIT_MENU
    if type(dropdown) == "table" then
      local tag = tostring(dropdown.battleTag or dropdown.battleTagName or dropdown.battletag or "")
      if tag ~= "" then return tag end
    end

    return nil
  end

  local function GetNameFromContext(contextData)
    if type(contextData) == "table" then
      local unitName = UnitTokenToName(contextData.unit or contextData.unitToken or contextData.target)
      if unitName then return unitName end

      local accountInfo = contextData.accountInfo or contextData.bnetAccountInfo or contextData.battleNetAccountInfo
      if type(accountInfo) == "table" then
        local built = BuildGameAccountName(contextData.gameAccountInfo or contextData.gameAccount or accountInfo.gameAccountInfo or accountInfo.gameAccount)
        if built then return built end
      else
        local built = BuildGameAccountName(contextData.gameAccountInfo or contextData.gameAccount)
        if built then return built end
      end

      local built = BuildNameRealm(
        contextData.fullName or contextData.playerName or contextData.unitName or contextData.characterName or contextData.name,
        contextData.server or contextData.realm or contextData.realmName or contextData.realmDisplayName or contextData.realmNameNormalized
      )
      if built then return built end
    end

    local dropdown = UIDROPDOWNMENU_INIT_MENU
    if type(dropdown) == "table" then
      local unitName = UnitTokenToName(dropdown.unit or dropdown.unitToken or dropdown.target)
      if unitName then return unitName end

      local built = BuildGameAccountName(dropdown.gameAccountInfo or dropdown.gameAccount)
      if built then return built end

      built = BuildNameRealm(dropdown.playerName or dropdown.unitName or dropdown.fullName or dropdown.name, dropdown.server or dropdown.realm or dropdown.realmName)
      if built then return built end
    end

    return nil
  end

  local function ResolveContextToInviteToken(contextData)
    -- For right-click menus, prefer the WoW character directly attached to the menu context.
    -- Do not return a raw BattleTag here. The Competitive flow sends addon whispers to WoW characters.
    local name = GetNameFromContext(contextData)
    if name and name ~= "" then
      return name
    end

    local bnetID = GetBNetAccountIDFromContext(contextData)
    if bnetID then
      local wowName = UI:_ResolveBattleNetAccountToWoWCharacter(bnetID)
      if wowName and wowName ~= "" then
        return wowName
      end
    end

    local battleTag = GetBattleTagFromContext(contextData)
    if battleTag and battleTag ~= "" then
      local wowName = UI:_ResolveBattleTagToWoWCharacter(battleTag)
      if wowName and wowName ~= "" then
        return wowName
      end
    end

    return nil
  end

  local function MenuNotice(msg)
    local text = CompetitiveNoticeText(msg)
    if UIErrorsFrame and UIErrorsFrame.AddMessage then
      UIErrorsFrame:AddMessage(text, 1, 1, 1)
    elseif DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
      DEFAULT_CHAT_FRAME:AddMessage(text)
    end
  end

  local function InviteFromContext(contextData)
    local token = ResolveContextToInviteToken(contextData)
    token = Trim(token)
    if token == "" then
      MenuNotice("What Farm?: Couldn't resolve that menu target for a Competitive invite.")
      return
    end

    UI:InviteChallenge(token)
  end

  local function RequestJoinFromContext(contextData)
    local token = ResolveContextToInviteToken(contextData)
    token = Trim(token)
    if token == "" then
      MenuNotice("What Farm?: Couldn't resolve that menu target for a Competitive join request.")
      return
    end

    UI:RequestJoinCompetitive(token)
  end

  local function AddModernInviteButton(owner, rootDescription, contextData)
    if not (rootDescription and rootDescription.CreateButton) then
      return
    end

    -- Keep menu construction cheap. Do not resolve Battle.net / friend data while
    -- Blizzard is building every right-click menu. Resolve only when the button is clicked.
    AddMenuDivider(rootDescription)
    AddMenuTitle(rootDescription)

    rootDescription:CreateButton(menuLabel, function()
      InviteFromContext(contextData)
    end)

    rootDescription:CreateButton(requestLabel, function()
      RequestJoinFromContext(contextData)
    end)
  end

  -- LootAppraiser Challenge registers the Contacts/Battle.net menu at file load with
  -- Menu.ModifyMenu("MENU_UNIT_BN_FRIEND", ...). We do the same here and also cover
  -- normal unit/player menus so player cards and friend characters use the same entry.
  if Menu and Menu.ModifyMenu and not self._compModernInviteMenusHooked then
    self._compModernInviteMenusHooked = true

    local modernMenus = {
      "MENU_UNIT_PLAYER",
      "MENU_UNIT_TARGET",
      "MENU_UNIT_FOCUS",
      "MENU_UNIT_PARTY",
      "MENU_UNIT_RAID_PLAYER",
      "MENU_UNIT_FRIEND",
      "MENU_UNIT_BN_FRIEND",
      "MENU_UNIT_GUILD",
      "MENU_UNIT_COMMUNITIES_MEMBER",
      "MENU_UNIT_COMMUNITIES_WOW_MEMBER",
      "MENU_UNIT_CHAT_ROSTER",
      "MENU_UNIT_RAID",
    }

    for i = 1, #modernMenus do
      pcall(Menu.ModifyMenu, modernMenus[i], AddModernInviteButton)
    end
  end

  -- Legacy UnitPopup fallback for older/dropdown-based menus.
  if UnitPopupButtons and UnitPopupMenus and not self._compLegacyInviteMenusHooked then
    self._compLegacyInviteMenusHooked = true

    UnitPopupButtons[legacyButton] = UnitPopupButtons[legacyButton] or {
      text = menuLabel,
      dist = 0,
      nested = 0,
      notCheckable = 1,
    }

    UnitPopupButtons[legacyRequestButton] = UnitPopupButtons[legacyRequestButton] or {
      text = requestLabel,
      dist = 0,
      nested = 0,
      notCheckable = 1,
    }

    local function AddLegacyButton(menuName, buttonName)
      local menu = UnitPopupMenus[menuName]
      if type(menu) ~= "table" then return end

      buttonName = tostring(buttonName or "")
      if buttonName == "" then return end

      for i = 1, #menu do
        if menu[i] == buttonName then
          return
        end
      end

      local insertAt = #menu + 1
      for i = 1, #menu do
        if menu[i] == "CANCEL" then
          insertAt = i
          break
        end
      end

      table.insert(menu, insertAt, buttonName)
    end

    local legacyMenus = {
      "PLAYER",
      "TARGET",
      "FOCUS",
      "PARTY",
      "RAID_PLAYER",
      "FRIEND",
      "BN_FRIEND",
      "BN_FRIEND_OFFLINE",
      "GUILD",
      "COMMUNITIES_WOW_MEMBER",
      "CHAT_ROSTER",
    }

    for i = 1, #legacyMenus do
      AddLegacyButton(legacyMenus[i], legacyButton)
      AddLegacyButton(legacyMenus[i], legacyRequestButton)
    end
  end

  if hooksecurefunc and UnitPopup_OnClick and not self._compUnitPopupInviteClickHooked then
    self._compUnitPopupInviteClickHooked = true

    hooksecurefunc("UnitPopup_OnClick", function(dropdownButton)
      local value = dropdownButton and (dropdownButton.value or dropdownButton.valueName)
      if value == legacyButton then
        InviteFromContext(nil)
        return
      end
      if value == legacyRequestButton then
        RequestJoinFromContext(nil)
      end
    end)
  end

  -- Old FriendsFrame dropdown fallback. Modern Contacts is handled by MENU_UNIT_BN_FRIEND above.
  if hooksecurefunc and UIDropDownMenu_CreateInfo and UIDropDownMenu_AddButton and FriendsFrame_ShowDropdown and not self._compFriendsFrameInviteDropdownHooked then
    self._compFriendsFrameInviteDropdownHooked = true

    hooksecurefunc("FriendsFrame_ShowDropdown", function(name, connected, lineID, friendsList, bnetIDAccount)
      local level = tonumber(UIDROPDOWNMENU_MENU_LEVEL) or 1
      if level ~= 1 then return end

      local token = nil
      if bnetIDAccount then
        token = UI:_ResolveBattleNetAccountToWoWCharacter(bnetIDAccount)
      end
      if (not token or token == "") and name and name ~= "" then
        token = UI:_ResolveNameToWoWCharacter(name) or tostring(name)
      end
      token = Trim(token)
      if token == "" or token:find("#", 1, true) then return end

      local listFrame = _G["DropDownList" .. level]
      local numButtons = listFrame and tonumber(listFrame.numButtons) or 0
      for i = 1, numButtons do
        local btn = _G["DropDownList" .. level .. "Button" .. i]
        if btn and btn:IsShown() and btn.GetText and btn:GetText() == menuLabel then
          return
        end
      end

      local info = UIDropDownMenu_CreateInfo()
      if type(info) ~= "table" then return end
      info.text = menuLabel
      info.notCheckable = true
      info.func = function()
        UI:InviteChallenge(token)
      end
      pcall(UIDropDownMenu_AddButton, info, level)

      info = UIDropDownMenu_CreateInfo()
      if type(info) ~= "table" then return end
      info.text = requestLabel
      info.notCheckable = true
      info.func = function()
        UI:RequestJoinCompetitive(token)
      end
      pcall(UIDropDownMenu_AddButton, info, level)
    end)
  end
end

-- Register the right-click menus immediately, not only after Competitive comms start.
-- This is the part that makes the option appear in Contacts/player-card menus like LootAppraiser.
do
  local function RegisterCompetitiveInviteMenus()
    if UI and UI._EnsureCompetitiveFriendInviteMenus then
      pcall(function()
        UI:_EnsureCompetitiveFriendInviteMenus()
      end)
    end
  end

  RegisterCompetitiveInviteMenus()

  if C_Timer and C_Timer.After then
    C_Timer.After(0, RegisterCompetitiveInviteMenus)
  end

  if CreateFrame then
    local eventFrame = CreateFrame("Frame")
    eventFrame:RegisterEvent("PLAYER_LOGIN")
    eventFrame:SetScript("OnEvent", function(frame)
      frame:UnregisterEvent("PLAYER_LOGIN")
      RegisterCompetitiveInviteMenus()
    end)
  end
end

local function Notify(msg)
  local text = CompetitiveNoticeText(msg)
  if UIErrorsFrame and UIErrorsFrame.AddMessage then
    UIErrorsFrame:AddMessage(text, 1, 1, 1)
  elseif DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
    DEFAULT_CHAT_FRAME:AddMessage(text)
  end
end

-- ---------------------------------------------------------
-- Messaging
-- ---------------------------------------------------------
function UI:_SendCompetitiveWhisper(target, payload)
  if not (C_ChatInfo and C_ChatInfo.SendAddonMessage) then return end
  target = Trim(target)
  if target == "" then return end
  C_ChatInfo.SendAddonMessage(COMP_PREFIX, payload, "WHISPER", target)
end

function UI:_SendCompetitiveMessage(payload)
  self:_EnsureCompetitiveState()

  -- Whisper mode (invite / peers)
  if self:_HasCompetitivePeers() or self:_HasCompetitiveInvited() then
    local leader = self:_CanonCompName(self._compLeader or "")
    local me = self:_CanonCompName(self:_GetLocalPlayerTag())

    -- If we're not the leader, only send to the leader (hub-and-spoke for reliability).
    if leader ~= "" and me ~= "" and leader ~= me then
      self:_SendCompetitiveWhisper(leader, payload)
      return
    end

    -- Leader (or unknown leader): broadcast to recipients.
    self:_IterCompetitiveRecipients(function(t)
      self:_SendCompetitiveWhisper(t, payload)
    end)
    return
  end

  -- Party/Raid/Channel mode
  local dist, id = self:_GetCompetitiveDistribution()
  if not dist then return end

  if dist == "CHANNEL" then
    if C_ChatInfo and C_ChatInfo.SendAddonMessage then
      C_ChatInfo.SendAddonMessage(COMP_PREFIX, payload, "CHANNEL", id)
    end
    return
  end

  if C_ChatInfo and C_ChatInfo.SendAddonMessage then
    C_ChatInfo.SendAddonMessage(COMP_PREFIX, payload, dist)
  end
end

function UI:_SendCompetitivePeerMessage(payload)
  self:_EnsureCompetitiveState()

  if self:_HasCompetitivePeers() then
    local leader = self:_CanonCompName(self._compLeader or "")
    local me = self:_CanonCompName(self:_GetLocalPlayerTag())

    if leader ~= "" and me ~= "" and leader ~= me then
      self:_SendCompetitiveWhisper(leader, payload)
      return
    end

    self:_IterCompetitivePeers(function(t)
      self:_SendCompetitiveWhisper(t, payload)
    end)
    return
  end

  local dist, id = self:_GetCompetitiveDistribution()
  if not dist then return end

  if dist == "CHANNEL" then
    if C_ChatInfo and C_ChatInfo.SendAddonMessage then
      C_ChatInfo.SendAddonMessage(COMP_PREFIX, payload, "CHANNEL", id)
    end
    return
  end

  if C_ChatInfo and C_ChatInfo.SendAddonMessage then
    C_ChatInfo.SendAddonMessage(COMP_PREFIX, payload, dist)
  end
end

function UI:_BroadcastCompetitivePeers()
  if not self._compSessionId then return end
  local sid = tostring(self._compSessionId)

  local seen = {}
  local list = {}

  local leader = self:_CanonCompName(self._compLeader or self:_GetLocalPlayerTag())
  if leader ~= "" then
    seen[leader] = true
    list[#list + 1] = leader
  end

  for n in pairs(self._compPeers or {}) do
    n = self:_CanonCompName(n)
    if n ~= "" and not seen[n] then
      seen[n] = true
      list[#list+1] = n
    end
  end
  table.sort(list)

  local csv = table.concat(list, ",")
  local prof = tostring(self._compRequiredProfile or "")
  self:_SendCompetitivePeerMessage("PEERS|" .. sid .. "|" .. csv .. "|" .. prof)
end

-- ---------------------------------------------------------
-- Session start
-- ---------------------------------------------------------
function UI:_ScheduleCompetitiveStart(sid, startAt, requiredProfile)
  sid = tostring(sid or "")
  if sid == "" then return end

  self:_EnsureCompetitiveState()
  self._compStartToken = (tonumber(self._compStartToken) or 0) + 1
  local myToken = self._compStartToken

  self._compSessionId = sid
  self._compStartAt = tonumber(startAt) or (SafeNow() + 3)
  self._compStarted = false
  self._compBaseValue = nil
  self._compBaseElapsed = nil
  if requiredProfile and requiredProfile ~= "" then
    self._compRequiredProfile = requiredProfile
  end

  local function DoStart()
    if myToken ~= tonumber(self._compStartToken) then return end
    if tostring(self._compSessionId or "") ~= sid then return end

    local prof = tostring(self._compRequiredProfile or "")
    if prof ~= "" then
      local ok = self:_ApplyCompetitiveRules(prof)
      if not ok then
        Notify("What Farm?: Competitive start aborted (can't apply required rules: " .. DisplayProfileName(prof) .. ").")
        return
      end
    end

    local hostProvider = Trim(self._compHostPriceProvider or "")
    if self:_IsCompetitiveLeader() and hostProvider ~= "" then
      self:_ApplyCompetitiveHostPriceProvider(hostProvider, true)
    end

    if self.IsPaused and self:IsPaused() and self.Resume then
      self:Resume()
    end

    if Tracker and Tracker.IsActive and Tracker.Start then
      if not Tracker:IsActive() then
        local nm = Util and Util.GetZoneLabel and Util:GetZoneLabel() or "Farm"
        Tracker:Start({ name = nm })
      end
    end

    self:_ResetCompetitiveLocalLoot()
    self:_ResetCompetitiveTrackedSyncBase()

    if UI and UI.frame and UI.frame.IsShown and UI.frame:IsShown() then
      if UI.RefreshTitle then UI:RefreshTitle() end
      if UI.RefreshMetrics then UI:RefreshMetrics() end
      if UI.RefreshLoot then UI:RefreshLoot(true) end
    end

    if self.frame and self.frame.IsShown and self.frame:IsShown() then
      if self.RefreshTitle then self:RefreshTitle() end
      if self.RefreshMetrics then self:RefreshMetrics() end
      if self.RefreshLoot then self:RefreshLoot(true) end
    end

    self:_SetCompetitiveSessionState("live", sid, tonumber(startAt) or self._compStartAt or SafeNow(), 0, 0, 0)
    self:_SetCompetitivePlayerStatus(self:_GetLocalPlayerTag(), "active")
    self:_EnsureCompScore(self:_GetLocalPlayerTag())

    if self:_IsCompetitiveLeader() then
      self:_RebuildCompetitiveSelfAuthoritativeState(false)
    end

    self:_EnsureCompetitiveTicker()

    Notify("What Farm?: Competitive started.")
    if self.RefreshCompetitivePanel then
      self:RefreshCompetitivePanel(true)
    end
  end

  local delay = (tonumber(self._compStartAt) or 0) - SafeNow()
  if delay < 0 then delay = 0 end

  if C_Timer and C_Timer.After then
    C_Timer.After(delay, DoStart)
  else
    DoStart()
  end
end

function UI:_GetCompetitiveLocalDelta()
  if not self._compStarted then
    return 0, 0
  end

  local v = 0
  if Tracker and Tracker.GetTotalValue then
    v = tonumber(Tracker:GetTotalValue()) or 0
  end
  local t = 0
  if Tracker and Tracker.GetElapsed then
    t = tonumber(Tracker:GetElapsed()) or 0
  end

  v = v - (tonumber(self._compBaseValue) or 0)
  t = t - (tonumber(self._compBaseElapsed) or 0)
  if v < 0 then v = 0 end
  if t < 0 then t = 0 end
  return v, t
end

function UI:_GetCompetitiveLocalMeta()
  -- Zone + active profile + top items (by value) from the tracker snapshot used by competitive mode.
  local zone = ""
  if Util and Util.GetZoneLabel then
    zone = tostring(Util:GetZoneLabel() or "")
  end
  zone = zone:gsub("|", "/")

  -- Prefer the enforced competitive rules profile (if any), otherwise fall back to active profile.
  local prof = tostring(self._compRulesAppliedProfile or self._compRequiredProfile or "")
  if prof == "" and WF and WF.DB and WF.DB.GetActiveProfileName then
    prof = tostring(WF.DB:GetActiveProfileName() or "")
  end
  prof = prof:gsub("|", "/")

  -- Display the host's provider as the official Competitive pricing basis.
  local prov = Trim(self._compHostPriceProvider or "")
  if prov == "" and WF and WF.Price and WF.Price.GetProvider then
    prov = tostring(WF.Price:GetProvider() or "")
  end
  prov = prov:gsub("|", "/")
  prov = prov:gsub("%s+", "")
  if prov ~= "" then
    prof = prof .. "^" .. prov
  end

  local items = {}
  if self:_IsCompetitiveLeader() then
    local me = self:_CanonCompName(self:_GetLocalPlayerTag())
    local counts = (me ~= "" and self._compScoredLootCounts and self._compScoredLootCounts[me]) or self:_BuildCompetitiveHostScoredCounts(self:_GetCompetitiveRawCountsSinceStart())
    items = self:_BuildCompetitiveLootItemsFromCounts(counts)
  end
  local parts = {}
  local n = 0
  for i = 1, #items do
    local it = items[i]
    local iid = tonumber(it and it.itemID)
    local cnt = tonumber(it and it.count) or 0
    local val = tonumber(it and it.value) or 0
    if iid and cnt > 0 and val >= 0 then
      parts[#parts+1] = tostring(iid) .. ":" .. tostring(cnt) .. ":" .. tostring(math.floor(val))
      n = n + 1
      if n >= 3 then break end
    end
  end

  local itemsStr = table.concat(parts, ",")
  return zone, prof, itemsStr
end


function UI:_GetCompetitiveLocalLootSummary(maxItems, maxLen)
  -- Returns a compact string of top items for the current competitive delta snapshot: itemID:count:value,...
  -- IMPORTANT: SendAddonMessage payloads can be truncated/dropped if too long.
  -- We cap the output by BOTH item count and total string length to keep sync reliable.
  maxItems = tonumber(maxItems) or 10
  if maxItems < 1 then maxItems = 1 end
  if maxItems > 60 then maxItems = 60 end

  maxLen = tonumber(maxLen) or 220
  if maxLen < 80 then maxLen = 80 end
  if maxLen > 230 then maxLen = 230 end

  local parts = {}
  local curLen = 0
  local items = self:_BuildCompetitiveLootItemsFromCounts(self:_GetCompetitiveTrackedCountsSinceStart(false))

  local n = 0
  for i = 1, #items do
    local it = items[i]
    local iid = tonumber(it and it.itemID)
    local cnt = tonumber(it and it.count) or 0
    local val = tonumber(it and it.value) or 0
    if iid and cnt > 0 and val >= 0 then
      local part = tostring(iid) .. ":" .. tostring(cnt) .. ":" .. tostring(math.floor(val))
      local add = #part
      if #parts > 0 then add = add + 1 end
      if (curLen + add) > maxLen then
        break
      end
      parts[#parts+1] = part
      curLen = curLen + add
      n = n + 1
      if n >= maxItems then break end
    end
  end

  return table.concat(parts, ",")
end

function UI:_ParseCompetitiveLootString(items)
  local out = {}
  items = tostring(items or "")
  for part in items:gmatch("([^,]+)") do
    local iid, cnt, val = part:match("^(%d+):(%d+):(%d+)$")
    if not iid then
      iid, cnt = part:match("^(%d+):(%d+)$")
      val = "0"
    end
    iid = tonumber(iid)
    if iid then
      out[#out+1] = {
        itemID = iid,
        count  = tonumber(cnt) or 0,
        value  = tonumber(val) or 0,
      }
    end
  end
  return out
end

-- ---------------------------------------------------------
-- Invite flow
-- ---------------------------------------------------------

-- Resolve a BattleTag (Name#1234) to an online WoW character (Name-Realm) via Battle.net friends.
-- Returns: whisperTarget, bnetAccountID, err (err is non-nil on failure)
function UI:_ResolveBattleTagToWoWCharacter(btag)
  btag = Trim(btag)
  if btag == "" then
    return nil, nil, "Empty BattleTag."
  end

  local want = Lower(btag)

  -- Modern API (retail): C_BattleNet
  if C_BattleNet and C_BattleNet.GetNumFriends and C_BattleNet.GetFriendAccountInfo then
    local num = tonumber(C_BattleNet.GetNumFriends() or 0) or 0
    for i = 1, num do
      local fi = C_BattleNet.GetFriendAccountInfo(i)
      local acct = fi and (type(fi.accountInfo) == "table" and fi.accountInfo or fi) or nil
      if acct then
        local tag = tostring(acct.battleTag or acct.battleTagName or acct.battletag or "")
        if tag ~= "" and Lower(tag) == want then
          local bnetID = acct.bnetAccountID or acct.bnetAccountId or acct.id
          local function tryGame(g)
            if not g then return nil end
            local client = g.clientProgram or g.client
            local online = g.isOnline
            if client == "WoW" and online and g.characterName and g.characterName ~= "" then
              local realm = g.realmName or g.realmDisplayName or g.realmNameNormalized or g.realm or ""
              realm = NormalizeRealm(realm)
              if realm == "" and GetNormalizedRealmName then
                realm = NormalizeRealm(GetNormalizedRealmName())
              end
              if realm ~= "" then
                return tostring(g.characterName) .. "-" .. realm
              end
              return tostring(g.characterName)
            end
            return nil
          end

          -- Prefer enumerating all game accounts if available.
          if C_BattleNet.GetFriendNumGameAccounts and C_BattleNet.GetFriendGameAccountInfo then
            local ng = tonumber(C_BattleNet.GetFriendNumGameAccounts(i) or 0) or 0
            for j = 1, ng do
              local gi = C_BattleNet.GetFriendGameAccountInfo(i, j)
              local t = tryGame(gi)
              if t then
                return t, bnetID, nil
              end
            end
          end

          local t = tryGame(fi.gameAccountInfo) or tryGame(acct.gameAccountInfo)
          if t then
            return t, bnetID, nil
          end
          return nil, bnetID, "They are not currently online on WoW."
        end
      end
    end
    return nil, nil, "BattleTag not found in your Battle.net friends list."
  end

  -- Legacy BN API fallback
  if BNGetNumFriends and BNGetFriendInfo then
    local total = BNGetNumFriends()
    total = tonumber(total or 0) or 0
    for i = 1, total do
      local bnetAccountID, _, battleTag, _, characterName, bnetGameAccountID, client, isOnline = BNGetFriendInfo(i)
      if battleTag and Lower(battleTag) == want then
        if not isOnline or client ~= "WoW" then
          return nil, bnetAccountID, "They are not currently online on WoW."
        end
        local realm = ""
        if BNGetGameAccountInfo and bnetGameAccountID then
          realm = select(4, BNGetGameAccountInfo(bnetGameAccountID)) or ""
        end
        realm = NormalizeRealm(realm)
        if realm ~= "" then
          return tostring(characterName) .. "-" .. realm, bnetAccountID, nil
        end
        return tostring(characterName), bnetAccountID, nil
      end
    end
    return nil, nil, "BattleTag not found in your Battle.net friends list."
  end

  return nil, nil, "Battle.net friend APIs unavailable."
end

-- Resolve an invite token to a whisper target. Supports Name-Realm or BattleTag.
-- Returns: whisperTarget, bnetAccountID, displayNote, err


function UI:_GetBattleTagForAccountID(accountID)
  accountID = tonumber(accountID)
  if not accountID then return nil end

  if C_BattleNet and C_BattleNet.GetAccountInfoByID then
    local ok, acct = pcall(C_BattleNet.GetAccountInfoByID, accountID)
    if ok and type(acct) == "table" then
      local tag = tostring(acct.battleTag or acct.battleTagName or acct.battletag or "")
      if tag ~= "" then
        return tag
      end
    end
  end

  if C_BattleNet and C_BattleNet.GetNumFriends and C_BattleNet.GetFriendAccountInfo then
    local num = tonumber(C_BattleNet.GetNumFriends() or 0) or 0
    for i = 1, num do
      local fi = C_BattleNet.GetFriendAccountInfo(i)
      local acct = fi and (type(fi.accountInfo) == "table" and fi.accountInfo or fi) or nil
      if acct then
        local id = tonumber(acct.bnetAccountID or acct.bnetAccountId or acct.id)
        if id and id == accountID then
          local tag = tostring(acct.battleTag or acct.battleTagName or acct.battletag or "")
          if tag ~= "" then
            return tag
          end
        end
      end
    end
  end

  if BNGetNumFriends and BNGetFriendInfo then
    local total = tonumber(BNGetNumFriends() or 0) or 0
    for i = 1, total do
      local bnetAccountID, _, battleTag = BNGetFriendInfo(i)
      if tonumber(bnetAccountID) == accountID and battleTag and battleTag ~= "" then
        return tostring(battleTag)
      end
    end
  end

  return nil
end

function UI:_ResolveBattleNetAccountToWoWCharacter(accountID)
  accountID = tonumber(accountID)
  if not accountID then return nil end

  local function tryDirectGame(g)
    if not g then return nil end
    local client = g.clientProgram or g.client
    local online = g.isOnline
    if client == "WoW" and online and g.characterName and g.characterName ~= "" then
      local realm = NormalizeRealm(g.realmName or g.realmDisplayName or g.realmNameNormalized or g.realm or "")
      if realm == "" and GetNormalizedRealmName then
        realm = NormalizeRealm(GetNormalizedRealmName())
      end
      if realm ~= "" then
        return tostring(g.characterName) .. "-" .. realm
      end
      return tostring(g.characterName)
    end
    return nil
  end

  if C_BattleNet and C_BattleNet.GetAccountInfoByID then
    local ok, acct = pcall(C_BattleNet.GetAccountInfoByID, accountID)
    if ok and type(acct) == "table" then
      local t = tryDirectGame(acct.gameAccountInfo or acct.gameAccount)
      if t then
        return t
      end
    end
  end

  if C_BattleNet and C_BattleNet.GetNumFriends and C_BattleNet.GetFriendAccountInfo then
    local num = tonumber(C_BattleNet.GetNumFriends() or 0) or 0
    for i = 1, num do
      local fi = C_BattleNet.GetFriendAccountInfo(i)
      local acct = fi and (type(fi.accountInfo) == "table" and fi.accountInfo or fi) or nil
      if acct then
        local id = tonumber(acct.bnetAccountID or acct.bnetAccountId or acct.id)
        if id and id == accountID then
          local function tryGame(g)
            if not g then return nil end
            local client = g.clientProgram or g.client
            local online = g.isOnline
            if client == "WoW" and online and g.characterName and g.characterName ~= "" then
              local realm = NormalizeRealm(g.realmName or g.realmDisplayName or g.realmNameNormalized or g.realm or "")
              if realm == "" and GetNormalizedRealmName then
                realm = NormalizeRealm(GetNormalizedRealmName())
              end
              if realm ~= "" then
                return tostring(g.characterName) .. "-" .. realm
              end
              return tostring(g.characterName)
            end
            return nil
          end

          if C_BattleNet.GetFriendNumGameAccounts and C_BattleNet.GetFriendGameAccountInfo then
            local ng = tonumber(C_BattleNet.GetFriendNumGameAccounts(i) or 0) or 0
            for j = 1, ng do
              local gi = C_BattleNet.GetFriendGameAccountInfo(i, j)
              local t = tryGame(gi)
              if t then
                return t
              end
            end
          end

          return tryGame(fi.gameAccountInfo) or tryGame(acct.gameAccountInfo)
        end
      end
    end
  end

  if BNGetNumFriends and BNGetFriendInfo then
    local total = tonumber(BNGetNumFriends() or 0) or 0
    for i = 1, total do
      local bnetAccountID, _, _, _, characterName, bnetGameAccountID, client, isOnline = BNGetFriendInfo(i)
      if tonumber(bnetAccountID) == accountID and isOnline and client == "WoW" and characterName and characterName ~= "" then
        local realm = ""
        if BNGetGameAccountInfo and bnetGameAccountID then
          realm = select(4, BNGetGameAccountInfo(bnetGameAccountID)) or ""
        end
        realm = NormalizeRealm(realm)
        if realm ~= "" then
          return tostring(characterName) .. "-" .. realm
        end
        return tostring(characterName)
      end
    end
  end

  return nil
end

function UI:_ResolveNameToWoWCharacter(token)
  token = Trim(token)
  if token == "" then return nil end
  if token:find("-", 1, true) then return token end

  local want = Lower(token)
  local localRealm = ""
  if GetNormalizedRealmName then
    local localRealmName = GetNormalizedRealmName()
    if localRealmName then
      localRealm = NormalizeRealm(localRealmName)
    end
  elseif UnitFullName then
    local _, r = UnitFullName("player")
    localRealm = NormalizeRealm(r)
  end

  local function mk(name, realm)
    name = tostring(name or "")
    if name == "" then return nil end
    realm = NormalizeRealm(realm)
    if realm == "" then realm = localRealm end
    if realm ~= "" then
      return name .. "-" .. realm
    end
    return name
  end

  local function matchName(name)
    name = tostring(name or "")
    if name == "" then return false end
    if Lower(name) == want then return true end
    if Lower(ShortTag(name)) == want then return true end
    return false
  end

  local function checkUnit(unit)
    if not (unit and UnitExists and UnitExists(unit)) then return nil end
    local n, r = nil, nil
    if UnitFullName then
      n, r = UnitFullName(unit)
    end
    if (not n or n == "") and UnitName then
      n = UnitName(unit)
    end
    if matchName(n) then
      return mk(n, r)
    end
    return nil
  end

  if GetNumGroupMembers then
    local total = tonumber(GetNumGroupMembers() or 0) or 0
    if total > 0 then
      if IsInRaid and IsInRaid() then
        for i = 1, total do
          local t = checkUnit("raid" .. i)
          if t then return t end
        end
      else
        for i = 1, math.max(total - 1, 0) do
          local t = checkUnit("party" .. i)
          if t then return t end
        end
      end
    end
  end

  if GetNumFriends and GetFriendInfo then
    local total = tonumber(GetNumFriends() or 0) or 0
    for i = 1, total do
      local name, _, _, _, connected = GetFriendInfo(i)
      if connected and matchName(name) then
        local short, realm = SplitTag(name)
        return mk(short, realm) or name
      end
    end
  end

  if GetNumGuildMembers and GetGuildRosterInfo then
    local total = tonumber(GetNumGuildMembers() or 0) or 0
    for i = 1, total do
      local name, _, _, _, _, _, _, _, online = GetGuildRosterInfo(i)
      if online and matchName(name) then
        local short, realm = SplitTag(name)
        return mk(short, realm) or name
      end
    end
  end

  if C_BattleNet and C_BattleNet.GetNumFriends and C_BattleNet.GetFriendAccountInfo then
    local num = tonumber(C_BattleNet.GetNumFriends() or 0) or 0
    for i = 1, num do
      local fi = C_BattleNet.GetFriendAccountInfo(i)
      local acct = fi and (type(fi.accountInfo) == "table" and fi.accountInfo or fi) or nil
      local function tryGame(g)
        if not g then return nil end
        local client = g.clientProgram or g.client
        local online = g.isOnline
        local charName = tostring(g.characterName or "")
        if client == "WoW" and online and matchName(charName) then
          return mk(charName, g.realmName or g.realmDisplayName or g.realmNameNormalized or g.realm)
        end
        return nil
      end
      if acct then
        if C_BattleNet.GetFriendNumGameAccounts and C_BattleNet.GetFriendGameAccountInfo then
          local ng = tonumber(C_BattleNet.GetFriendNumGameAccounts(i) or 0) or 0
          for j = 1, ng do
            local t = tryGame(C_BattleNet.GetFriendGameAccountInfo(i, j))
            if t then return t end
          end
        end
        local t = tryGame(fi and fi.gameAccountInfo or nil) or tryGame(acct.gameAccountInfo)
        if t then return t end
      end
    end
  end

  return nil
end
function UI:_ResolveCompetitiveInviteTarget(token)
  token = Trim(token)
  if token == "" then
    return nil, nil, nil, "Empty target."
  end

  local raw = token
  token = token:gsub("^[Bb][Tt][Aa][Gg]%s*:%s*", "")

  if token:find("#", 1, true) then
    local target, bnetID, err = self:_ResolveBattleTagToWoWCharacter(token)
    if not target then
      return nil, bnetID, nil, err or "Unable to resolve BattleTag."
    end
    return target, bnetID, ("via " .. token), nil
  end

  local resolved = self:_ResolveNameToWoWCharacter(token)
  if resolved and resolved ~= "" then
    return resolved, nil, (resolved ~= raw and ("resolved from " .. raw) or nil), nil
  end

  -- Last-resort same-realm whisper target. Visible whispers may still reach this target,
  -- but addon popup delivery is less reliable without an exact Name-Realm or BattleTag.
  if not token:find("-", 1, true) then
    return token, nil, "unresolved-short", nil
  end

  return token, nil, nil, nil
end

function UI:_FindCompetitiveInviteRecord(name)
  name = Trim(name)
  if name == "" then return nil, nil end

  self:_EnsureCompetitiveState()

  local canon = self:_CanonCompName(name)
  local fullLower = Lower(canon ~= "" and canon or name)
  local shortLower = Lower(ShortTag(canon ~= "" and canon or name))

  for key, rec in pairs(self._compInvited or {}) do
    local keyCanon = self:_CanonCompName(key)
    local keyLower = Lower(keyCanon ~= "" and keyCanon or key)
    local keyShortLower = Lower(ShortTag(keyCanon ~= "" and keyCanon or key))
    local targetLower = Lower(rec and rec.target or "")
    local inputLower = Lower(rec and rec.input or "")

    if keyLower == fullLower
      or (shortLower ~= "" and keyShortLower == shortLower)
      or targetLower == fullLower
      or (shortLower ~= "" and targetLower == shortLower)
      or inputLower == fullLower
      or (shortLower ~= "" and inputLower == shortLower)
    then
      return key, rec
    end
  end

  return nil, nil
end

function UI:_MarkCompetitiveInviteDelivered(name)
  local key, rec = self:_FindCompetitiveInviteRecord(name)
  if not key or type(rec) ~= "table" then
    return false, nil, nil
  end

  if tonumber(rec.deliveredAt or 0) > 0 then
    return false, key, rec
  end

  rec.deliveredAt = SafeNow()
  rec.deliveryState = "delivered"
  self._compInvited[key] = rec
  return true, key, rec
end



function UI:_ClearCompetitiveParticipantData(name)
  name = self:_CanonCompName(name)
  if name == "" then return false end

  self:_EnsureCompetitiveState()

  local short = ShortTag(name)
  local fullLower = Lower(name)
  local shortLower = Lower(short)

  self:_ClearCompetitiveInvitePlaceholder(name)

  self._compPeers[name] = nil
  self._compInvited[name] = nil
  self._compScores[name] = nil
  self._compMeta[name] = nil
  self._compLoot[name] = nil
  self._compLootCounts[name] = nil
  if self._compScoredLootCounts then self._compScoredLootCounts[name] = nil end
  self._compPlayerState[name] = nil
  self._compDeltaInbox[name] = nil
  self:_MarkCompetitiveLootDirty()

  if shortLower ~= "" then
    self._compAlias[shortLower] = nil
  end
  if fullLower ~= "" then
    self._compAlias[fullLower] = nil
  end

  return true
end

function UI:CancelCompetitiveInvite(name, silent)
  self:_EnsureCompetitiveComm()
  self:_EnsureCompetitiveState()

  if not self:_IsCompetitiveLeader() then
    if not silent then
      Notify("What Farm?: Only the host can cancel a pending invite.")
    end
    return false
  end

  local key, rec = self:_FindCompetitiveInviteRecord(name)
  if not key or type(rec) ~= "table" then
    if not silent then
      Notify("What Farm?: No pending Competitive invite found for " .. PrettyName(name) .. ".")
    end
    return false
  end

  local sid = tostring(self._compSessionId or "")
  local target = Trim(rec.target or key or "")
  if sid ~= "" and target ~= "" then
    self:_SendCompetitiveWhisper(target, "CINV|" .. sid .. "||")
  end

  self:_ClearCompetitiveParticipantData(key)

  if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(true) end
  if self.RefreshCompetitiveLootPanel then self:RefreshCompetitiveLootPanel(true) end

  if not silent then
    Notify("What Farm?: Canceled pending invite for " .. PrettyName(key) .. ".")
  end
  return true
end

function UI:ReinviteCompetitivePlayer(name)
  self:_EnsureCompetitiveComm()
  self:_EnsureCompetitiveState()

  if not self:_IsCompetitiveLeader() then
    Notify("What Farm?: Only the host can reinvite players.")
    return false
  end

  local token = Trim(name)
  local key, rec = self:_FindCompetitiveInviteRecord(name)
  if type(rec) == "table" then
    token = Trim(rec.input or rec.target or key or name)
    local sid = tostring(self._compSessionId or "")
    local target = Trim(rec.target or key or "")
    if sid ~= "" and target ~= "" then
      self:_SendCompetitiveWhisper(target, "CINV|" .. sid .. "||")
    end
    self:_ClearCompetitiveParticipantData(key)
  end

  if token == "" then
    token = Trim(name)
  end
  if token == "" then
    return false
  end

  self:InviteChallenge(token)
  return true
end

function UI:RemoveCompetitiveParticipant(name)
  self:_EnsureCompetitiveComm()
  self:_EnsureCompetitiveState()

  if not self:_IsCompetitiveLeader() then
    Notify("What Farm?: Only the host can remove players from the match.")
    return false
  end

  local canon = self:_CanonCompName(name)
  local me = self:_CanonCompName(self:_GetLocalPlayerTag())
  if canon == "" or canon == me then
    return false
  end

  local _, rec = self:_FindCompetitiveInviteRecord(canon)
  if type(rec) == "table" then
    return self:CancelCompetitiveInvite(canon, false)
  end

  local hadData = not not (self._compPeers[canon] or self._compScores[canon] or self._compMeta[canon] or self._compLoot[canon] or self._compPlayerState[canon])
  if not hadData then
    Notify("What Farm?: Couldn't find " .. PrettyName(canon) .. " in this Competitive match.")
    return false
  end

  local sid = tostring(self._compSessionId or "")
  if sid ~= "" then
    self:_SendCompetitiveWhisper(canon, "KICK|" .. sid .. "||")
  end

  self:_ClearCompetitiveParticipantData(canon)
  self:_BroadcastCompetitivePeers()

  if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(true) end
  if self.RefreshCompetitiveLootPanel then self:RefreshCompetitiveLootPanel(true) end

  Notify("What Farm?: Removed " .. PrettyName(canon) .. " from the match.")
  return true
end


function UI:CloseCompetitiveRosterContextMenu()
  if self._compRosterStyledMenu then
    self._compRosterStyledMenu:Hide()
  end
  if self._compRosterStyledBlocker then
    self._compRosterStyledBlocker:Hide()
  end
end

function UI:_EnsureCompetitiveRosterStyledMenu()
  if self._compRosterStyledMenu then
    return
  end
  if not CreateFrame or not UIParent then
    return
  end

  local blocker = CreateFrame("Frame", nil, UIParent)
  blocker:SetAllPoints(UIParent)
  blocker:EnableMouse(true)
  blocker:Hide()
  blocker:SetFrameStrata("FULLSCREEN_DIALOG")
  blocker:SetFrameLevel(398)
  blocker:SetScript("OnMouseDown", function()
    if UI and UI.CloseCompetitiveRosterContextMenu then
      UI:CloseCompetitiveRosterContextMenu()
    end
  end)

  local menu = CreateFrame("Frame", "WhatFarmCompetitiveRosterStyledContextMenu", UIParent, "BackdropTemplate")
  menu:SetFrameStrata("FULLSCREEN_DIALOG")
  menu:SetFrameLevel(399)
  menu:SetSize(220, 88)
  menu:EnableMouse(true)
  menu:Hide()
  menu:SetBackdrop({
    bgFile = "Interface/Buttons/WHITE8x8",
    edgeFile = "Interface/Buttons/WHITE8x8",
    edgeSize = 1,
    insets = { left = 1, right = 1, top = 1, bottom = 1 },
  })
  menu:SetBackdropColor(0.035, 0.035, 0.040, 0.98)
  menu:SetBackdropBorderColor(0.35, 0.78, 1.00, 0.70)

  local bg = menu:CreateTexture(nil, "ARTWORK")
  bg:SetAllPoints(menu)
  bg:SetTexture("Interface/Buttons/WHITE8x8")
  bg:SetVertexColor(0.035, 0.035, 0.040, 0.98)
  menu._wfCompMenuBG = bg
  menu._wfCompRows = {}

  menu:SetScript("OnHide", function(self)
    for i = 1, #(self._wfCompRows or {}) do
      local row = self._wfCompRows[i]
      if row then row:Hide() end
    end
  end)

  self._compRosterStyledBlocker = blocker
  self._compRosterStyledMenu = menu
end

function UI:OpenCompetitiveRosterContextMenu(items, width)
  self:_EnsureCompetitiveRosterStyledMenu()
  local menu = self._compRosterStyledMenu
  local blocker = self._compRosterStyledBlocker
  if not menu or not blocker then
    return false
  end

  if CloseDropDownMenus then
    pcall(CloseDropDownMenus)
  end
  if self.CloseContextMenu then
    self:CloseContextMenu()
  end
  if self.CloseDropdown then
    self:CloseDropdown()
  end

  local rows = menu._wfCompRows or {}
  menu._wfCompRows = rows
  for i = 1, #rows do
    rows[i]:Hide()
  end

  local accent = { 0.35, 0.78, 1.00, 1.00 }
  local textColor = { 0.92, 0.92, 0.92, 1.00 }
  local mutedColor = { 0.62, 0.62, 0.62, 1.00 }
  local hoverColor = { 0.35, 0.78, 1.00, 0.12 }
  local sepColor = { 0.35, 0.78, 1.00, 0.18 }

  local rowH = 22
  local yOff = 6
  local shownRows = 0
  local maxW = tonumber(width) or 220
  if maxW < 160 then maxW = 160 end

  local function EnsureRow(index)
    local row = rows[index]
    if row then return row end

    row = CreateFrame("Button", nil, menu)
    row:SetHeight(rowH)
    row:SetPoint("LEFT", menu, "LEFT", 6, 0)
    row:SetPoint("RIGHT", menu, "RIGHT", -6, 0)
    row:EnableMouse(true)

    row.bg = row:CreateTexture(nil, "BACKGROUND")
    row.bg:SetAllPoints(row)
    row.bg:SetTexture("Interface/Buttons/WHITE8x8")
    row.bg:SetVertexColor(hoverColor[1], hoverColor[2], hoverColor[3], 0)

    row.txt = row:CreateFontString(nil, "OVERLAY", "WF_GameFontHighlight")
    row.txt:SetPoint("LEFT", row, "LEFT", 8, 0)
    row.txt:SetPoint("RIGHT", row, "RIGHT", -8, 0)
    row.txt:SetJustifyH("LEFT")
    row.txt:SetTextColor(textColor[1], textColor[2], textColor[3], textColor[4])

    row:SetScript("OnEnter", function(self)
      if self._wfDisabled or self._wfPassive then return end
      self.bg:SetVertexColor(hoverColor[1], hoverColor[2], hoverColor[3], hoverColor[4])
    end)
    row:SetScript("OnLeave", function(self)
      self.bg:SetVertexColor(hoverColor[1], hoverColor[2], hoverColor[3], 0)
    end)

    rows[index] = row
    return row
  end

  local function ConfigureSeparator(row)
    row:SetHeight(10)
    row:EnableMouse(false)
    row._wfDisabled = true
    row._wfPassive = true
    row:SetScript("OnClick", nil)
    row.txt:SetText("")
    row.bg:SetVertexColor(hoverColor[1], hoverColor[2], hoverColor[3], 0)

    if not row.sep then
      row.sep = row:CreateTexture(nil, "BORDER")
      row.sep:SetTexture("Interface/Buttons/WHITE8x8")
      row.sep:SetPoint("LEFT", row, "LEFT", 8, 0)
      row.sep:SetPoint("RIGHT", row, "RIGHT", -8, 0)
      row.sep:SetHeight(1)
    end
    row.sep:SetVertexColor(sepColor[1], sepColor[2], sepColor[3], sepColor[4])
    row.sep:Show()
  end

  local function ConfigureItem(row, item)
    row:SetHeight(rowH)
    row:EnableMouse(not item.disabled and not item.passive)
    row._wfDisabled = item.disabled and true or false
    row._wfPassive = item.passive and true or false
    row.bg:SetVertexColor(hoverColor[1], hoverColor[2], hoverColor[3], 0)
    if row.sep then row.sep:Hide() end

    row.txt:SetText(tostring(item.text or ""))
    if row._wfDisabled then
      row.txt:SetTextColor(mutedColor[1], mutedColor[2], mutedColor[3], mutedColor[4])
    elseif item.passive then
      row.txt:SetTextColor(accent[1], accent[2], accent[3], accent[4])
    else
      row.txt:SetTextColor(textColor[1], textColor[2], textColor[3], textColor[4])
    end

    if row._wfDisabled or row._wfPassive then
      row:SetScript("OnClick", nil)
    else
      row:SetScript("OnClick", function()
        if UI and UI.CloseCompetitiveRosterContextMenu then
          UI:CloseCompetitiveRosterContextMenu()
        end
        if item.fn then item.fn() end
      end)
    end
  end

  for i = 1, #(items or {}) do
    local item = items[i]
    if item then
      shownRows = shownRows + 1
      local row = EnsureRow(shownRows)
      row:ClearAllPoints()
      row:SetPoint("TOPLEFT", menu, "TOPLEFT", 6, -yOff)
      row:SetPoint("TOPRIGHT", menu, "TOPRIGHT", -6, -yOff)

      if item.sep then
        ConfigureSeparator(row)
        yOff = yOff + 10
      else
        ConfigureItem(row, item)
        yOff = yOff + rowH
      end
      row:Show()
    end
  end

  if shownRows == 0 then
    return false
  end

  local totalH = yOff + 6
  menu:SetSize(maxW, totalH)
  menu:SetBackdropColor(0.035, 0.035, 0.040, 0.98)
  menu:SetBackdropBorderColor(accent[1], accent[2], accent[3], 0.70)
  if menu._wfCompMenuBG then
    menu._wfCompMenuBG:SetVertexColor(0.035, 0.035, 0.040, 0.98)
  end

  local x, y = 0, 0
  if GetCursorPosition and UIParent.GetScale then
    local scale = UIParent:GetScale() or 1
    x, y = GetCursorPosition()
    x = (x or 0) / scale
    y = (y or 0) / scale
  end

  local parentW = UIParent.GetWidth and UIParent:GetWidth() or 0
  local parentH = UIParent.GetHeight and UIParent:GetHeight() or 0
  if parentW > 0 and x + maxW > parentW - 6 then
    x = parentW - maxW - 6
  end
  if x < 6 then x = 6 end
  if parentH > 0 and y > parentH - 6 then
    y = parentH - 6
  end
  if y - totalH < 6 then
    y = totalH + 6
  end

  menu:ClearAllPoints()
  menu:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", x, y)

  blocker:Show()
  menu:Show()
  blocker:SetFrameLevel(398)
  blocker:Raise()
  menu:SetFrameLevel(399)
  menu:Raise()
  return true
end

function UI:_EnsureCompetitiveRosterContextMenu()
  if self._compRosterMenu then
    return
  end
  if not CreateFrame then
    return
  end

  self._compRosterMenu = CreateFrame("Frame", "WhatFarmCompetitiveRosterContextMenu", UIParent, "UIDropDownMenuTemplate")
end

function UI:_CanManageCompetitiveParticipant(name)
  self:_EnsureCompetitiveState()

  local canon = self:_CanonCompName(name)
  local me = self:_CanonCompName(self:_GetLocalPlayerTag())
  if canon == "" or canon == me then
    return false
  end
  if not self:_IsCompetitiveLeader() then
    return false
  end

  local _, rec = self:_FindCompetitiveInviteRecord(canon)
  if type(rec) == "table" then
    return true
  end

  if self._compPeers[canon] or self._compScores[canon] or self._compMeta[canon] or self._compLoot[canon] or self._compPlayerState[canon] then
    return true
  end

  return false
end

function UI:_BindCompetitiveRowContextMenu(rowFrame)
  if not rowFrame or rowFrame._wfCompContextBound or not rowFrame.HookScript then
    return
  end

  rowFrame._wfCompContextBound = true
  rowFrame:HookScript("OnMouseUp", function(frame, button)
    if button ~= "RightButton" then
      return
    end
    if GameTooltip then
      GameTooltip:Hide()
    end
    if UI and UI.ShowCompetitiveRowContextMenu then
      UI:ShowCompetitiveRowContextMenu(frame)
    end
  end)
end

function UI:ShowCompetitiveRowContextMenu(rowFrame)
  if not rowFrame then return end

  self:_EnsureCompetitiveState()

  local nameKey = self:_CanonCompName(rowFrame._wfCompName)
  if not self:_CanManageCompetitiveParticipant(nameKey) then
    return
  end

  local _, rec = self:_FindCompetitiveInviteRecord(nameKey)
  local isPending = type(rec) == "table"

  local items = {
    {
      text = PrettyName(nameKey),
      passive = true,
    },
    { sep = true },
  }

  if isPending then
    items[#items + 1] = {
      text = "Reinvite",
      fn = function()
        UI:ReinviteCompetitivePlayer(nameKey)
      end,
    }
    items[#items + 1] = {
      text = "Cancel Invite",
      fn = function()
        UI:CancelCompetitiveInvite(nameKey)
      end,
    }
  else
    items[#items + 1] = {
      text = "Remove from Match",
      fn = function()
        UI:RemoveCompetitiveParticipant(nameKey)
      end,
    }
  end

  items[#items + 1] = { sep = true }
  items[#items + 1] = {
    text = CANCEL or "Cancel",
    fn = function()
      if UI and UI.CloseCompetitiveRosterContextMenu then
        UI:CloseCompetitiveRosterContextMenu()
      end
    end,
  }

  self:OpenCompetitiveRosterContextMenu(items, 220)
end

function UI:RequestJoinCompetitive(rawHostList)
  self:_EnsureCompetitiveComm()
  self:_EnsureCompetitiveState()

  local tokens = SplitInviteList(rawHostList)
  if #tokens == 0 then
    Notify("What Farm?: Pick a player to request to join their Competitive challenge.")
    return
  end

  local prof = ""
  if WF and WF.DB and WF.DB.GetActiveProfileName then
    prof = tostring(WF.DB:GetActiveProfileName() or "")
  end

  local sent = {}
  for _, token in ipairs(tokens) do
    local tokenTrim = Trim(token)
    local target, bnetID, note, err = self:_ResolveCompetitiveInviteTarget(tokenTrim)
    if err then
      Notify("What Farm?: Can't request to join " .. tokenTrim .. " — " .. tostring(err))
    else
      local meCanon = self:_CanonCompName(self:_GetLocalPlayerTag())
      local targetCanon = self:_CanonCompName(target)
      if targetCanon ~= "" and meCanon ~= "" and Lower(targetCanon) == Lower(meCanon) then
        Notify("What Farm?: You can't request to join yourself.")
      else
        self:_SendCompetitiveWhisper(target, "RQJOIN|0|" .. prof .. "|")

        local txt = "What Farm?: I requested to join your Competitive challenge. Open WhatFarm to accept or decline."
        if bnetID and BNSendWhisper then
          BNSendWhisper(bnetID, txt)
        elseif SendChatMessage then
          SendChatMessage(txt, "WHISPER", nil, target)
        end

        if note then
          sent[#sent + 1] = tokenTrim .. " → " .. target
        else
          sent[#sent + 1] = target
        end
      end
    end
  end

  if #sent == 0 then
    Notify("What Farm?: No join requests were sent.")
    return
  end

  Notify("What Farm?: Sent Competitive join request to " .. table.concat(sent, ", ") .. ".")
end

function UI:InviteChallenge(rawList)
  self:_EnsureCompetitiveComm()
  self:_EnsureCompetitiveState()

  if tostring(self._compSessionId or "") ~= "" and self._compLeader and not self:_IsCompetitiveLeader() then
    Notify("What Farm?: Only the host can invite players to this match.")
    return
  end

  local sessState = tostring((self._compSession and self._compSession.state) or "idle")
  if (sessState == "live" or sessState == "paused") and not CompAllowInvitesDuringSession() then
    Notify("What Farm?: Session active, please wait until it's over to invite someone.")
    return
  end

  local tokens = SplitInviteList(rawList)
  if #tokens == 0 then
    Notify("What Farm?: Enter one or more player names (comma-separated). You can also invite by BattleTag (Name#1234) if they're a Battle.net friend and online on WoW.")
    return
  end

  local prof = ""
  if WF and WF.DB and WF.DB.GetActiveProfileName then
    prof = tostring(WF.DB:GetActiveProfileName() or "")
  end
  self._compRequiredProfile = prof

  local sid = tostring(self._compSessionId or "")
  if sid == "" then
    sid = tostring(math.floor(SafeNow() * 1000 + math.random(1000, 9999)))
    self._compSessionId = sid
  end

  self._compLeader = self:_GetLocalPlayerTag()

  self:_EnsureCompetitiveState()

  local invited = {}
  local hostTag = self:_GetLocalPlayerTag()
  local joinHint = "/wf comp join " .. sid .. " " .. hostTag

  for _, token in ipairs(tokens) do
    local tokenTrim = Trim(token)
    local tokenCanon = self:_CanonCompName(tokenTrim)
    local meCanon = self:_CanonCompName(self:_GetLocalPlayerTag())

    if tokenTrim ~= "" and (Lower(tokenTrim) == Lower(meCanon) or Lower(tokenTrim) == Lower(ShortTag(meCanon)) or (tokenCanon ~= "" and Lower(tokenCanon) == Lower(meCanon))) then
      Notify("What Farm?: You can't invite yourself.")
    else
      local target, bnetID, note, err = self:_ResolveCompetitiveInviteTarget(token)
    if err then
      local msg = "What Farm?: Can't invite " .. token .. " — " .. tostring(err)
      Notify(msg)
      if bnetID and BNSendWhisper then
        BNSendWhisper(bnetID, "What Farm?: I tried to invite you to a friendly Competitive run, but you're not reachable on a WoW character right now. (Log into WoW, then I can invite again.)")
      end
    else
      local targetHasRealm = tostring(target):find("-", 1, true) ~= nil
      local unresolvedShort = (not targetHasRealm) and (note == "unresolved-short")
      local canon = self:_CanonCompName(target)
      self._compInvited[canon] = {
        target = target,
        input = token,
        bnetID = bnetID,
        note = note,
        unresolvedShort = unresolvedShort,
        sentAt = SafeNow(),
        deliveredAt = 0,
        deliveryState = "pending",
      }
      self:_EnsureCompScore(canon)
      self:_SetCompetitivePlayerStatus(canon, "invited")

      self:_SendCompetitiveWhisper(target, "INV|" .. sid .. "|0|" .. prof)

      local txt = "What Farm?: Wanna have a friendly competition? Open WhatFarm and accept the Competitive invite popup."
      if unresolvedShort then
        txt = txt .. " If no popup appears, use " .. joinHint
      end
      if bnetID and BNSendWhisper then
        BNSendWhisper(bnetID, txt)
      elseif SendChatMessage then
        SendChatMessage(txt, "WHISPER", nil, target)
      end

      if unresolvedShort then
        invited[#invited+1] = token .. " (short-name)"
      elseif note then
        invited[#invited+1] = token .. " → " .. target
      else
        invited[#invited+1] = target
      end
    end
    end
  end

  if #invited == 0 then
    Notify("What Farm?: No invites were sent.")
    return
  end

  local msg = "What Farm?: Sent Competitive invite(s) to " .. table.concat(invited, ", ") ..
              " • Session " .. sid ..
              (prof ~= "" and (" • Profile " .. prof) or "")
  Notify(msg)

  self:_EnsureCompetitiveTicker()
  if self.RefreshCompetitivePanel then
    self:RefreshCompetitivePanel(true)
  end
end

function UI:AcceptCompetitiveInvite(data)
  self:_EnsureCompetitiveComm()

  if type(data) ~= "table" then return end
  if BlockCompetitiveJoinForActiveFarm() then
    self:DeclineCompetitiveInvite(data)
    return
  end
  local from = self:_CanonCompName(data.from)
  local sid  = Trim(data.sid)
  local prof = Trim(data.profile)

  if sid == "" or from == "" then return end

  -- Enforce required profile (if present).
  if prof ~= "" then
    local ok = self:_ApplyCompetitiveRules(prof)
    if not ok then
      Notify("What Farm?: Can't accept — unable to apply required Competitive rules: " .. DisplayProfileName(prof))
      self:DeclineCompetitiveInvite(data)
      return
    end
  end

  self._compSessionId = sid
  self._compLeader = from
  self._compRequiredProfile = prof ~= "" and prof or self._compRequiredProfile
  self._compInvited = {}
  self._compScores = {}
  self._compPeers = {}

  self:_AddCompetitivePeer(from)
  self:_EnsureCompScore(self:_GetLocalPlayerTag())
  self:_SetCompetitivePlayerStatus(self:_GetLocalPlayerTag(), "active")
  self:_SetCompetitivePlayerStatus(from, "active")

  self:_SendCompetitiveWhisper(from, "ACK|" .. sid .. "|" .. prof .. "|")
  Notify("What Farm?: Accepted Competitive invite from " .. PrettyName(from) .. (prof ~= "" and (" (Profile: " .. DisplayProfileName(prof) .. ")") or "") .. ".")

  if self._RefreshMainSessionControlLocks then
    self:_RefreshMainSessionControlLocks()
  end
  self:_EnsureCompetitiveTicker()
  if self.RefreshCompetitivePanel then
    self:RefreshCompetitivePanel(true)
  end
end

function UI:JoinCompetitiveSession(sessionID, hostToken)
  self:_EnsureCompetitiveComm()
  self:_EnsureCompetitiveState()

  if BlockCompetitiveJoinForActiveFarm() then
    return false
  end

  sessionID = Trim(sessionID)
  hostToken = Trim(hostToken)

  if sessionID == "" then
    Notify("What Farm?: Usage: /wf comp join <sessionID> [hostName-Realm or BattleTag]")
    return false
  end

  if tostring(self._compSessionId or "") ~= "" and self._compLeader and not self:_IsCompetitiveLeader() then
    Notify("What Farm?: Leave your current Competitive session before joining another one.")
    return false
  end

  local prof = ""
  if WF and WF.DB and WF.DB.GetActiveProfileName then
    prof = tostring(WF.DB:GetActiveProfileName() or "")
  end

  if hostToken == "" then
    local dist = self:_GetCompetitiveDistribution()
    if not dist then
      Notify("What Farm?: Joining by session ID alone only works in party, raid, or a shared Competitive channel. For whisper sessions, add the host name or BattleTag.")
      return false
    end
    self:_SendCompetitiveMessage("JREQ|" .. sessionID .. "|" .. prof .. "|")
    Notify("What Farm?: Sent join request for session " .. sessionID .. ".")
    return true
  end

  local hostTarget, _, _, err = self:_ResolveCompetitiveInviteTarget(hostToken)
  if not hostTarget then
    Notify("What Farm?: Can't join session " .. sessionID .. " — " .. tostring(err or "invalid host target"))
    return false
  end

  self:_SendCompetitiveWhisper(hostTarget, "JREQ|" .. sessionID .. "|" .. prof .. "|")
  Notify("What Farm?: Sent join request to " .. hostTarget .. " • Session " .. sessionID .. ".")
  return true
end

function UI:DeclineCompetitiveInvite(data)
  self:_EnsureCompetitiveComm()
  if type(data) ~= "table" then return end
  local from = self:_CanonCompName(data.from)
  local sid  = Trim(data.sid)
  if from == "" or sid == "" then return end
  self:_SendCompetitiveWhisper(from, "DEC|" .. sid .. "||")
  Notify("What Farm?: Declined Competitive invite from " .. PrettyName(from) .. ".")
end

-- ---------------------------------------------------------
-- Comms / event handling
-- ---------------------------------------------------------
function UI:_EnsureCompetitiveComm()
  if self._compCommReady then
    return
  end
  self._compCommReady = true
  self:_EnsureCompetitiveState()

  if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then
    C_ChatInfo.RegisterAddonMessagePrefix(COMP_PREFIX)
  end

  self:_EnsureCompetitiveInvitePopup()
  self:_EnsureCompetitiveFriendInviteMenus()

  local ef = CreateFrame("Frame")
  self._compEventFrame = ef
  ef:RegisterEvent("CHAT_MSG_ADDON")
  ef:RegisterEvent("CHAT_MSG_LOOT")
  ef:SetScript("OnEvent", function(_, event, prefix, msg, channel, sender)
    if event == "CHAT_MSG_LOOT" then
      self:_OnCompetitiveLootEvent(prefix)
      return
    end

    if event ~= "CHAT_MSG_ADDON" then return end
    if prefix ~= COMP_PREFIX then return end
    if not msg or msg == "" then return end

    local a, b, c, d, e, f = strsplit("|", msg)
    a = tostring(a or "")
    local sid = Trim(b)
    local senderTag = self:_CanonCompName(sender)
    c = tostring(c or "")
    d = tostring(d or "")
    e = tostring(e or "")
    f = tostring(f or "")

    local iAmLeader = self:_IsCompetitiveLeader()

    -- INV|sid|startAt|profile
    if a == "INV" then
      local startAt = tonumber(c) or 0
      if startAt <= 0 then
        startAt = SafeNow() + 3
      end
      local prof = Trim(d)
      self._compPendingInvite = { from = senderTag, sid = sid, startAt = startAt, profile = prof }
      if senderTag ~= "" and sid ~= "" then
        self:_SendCompetitiveWhisper(senderTag, "IACK|" .. sid .. "||")
      end
      StaticPopup_Show(COMP_INV_POPUP, PrettyName(senderTag), (prof ~= "" and DisplayProfileName(prof) or "—"), self._compPendingInvite)
      return
    end

    -- CINV|sid||
    if a == "CINV" then
      if sid == "" then return end
      local pending = self._compPendingInvite
      if type(pending) == "table" and tostring(pending.sid or "") == sid then
        local pendingFrom = self:_CanonCompName(pending.from or "")
        if pendingFrom == senderTag then
          self._compPendingInvite = nil
          if StaticPopup_Hide then
            StaticPopup_Hide(COMP_INV_POPUP)
          end
          Notify("What Farm?: " .. PrettyName(senderTag) .. " canceled the Competitive invite.")
        end
      end
      return
    end

    -- KICK|sid||
    if a == "KICK" then
      if sid == "" or tostring(self._compSessionId or "") ~= sid then
        return
      end

      local leaderName = self:_CanonCompName(self._compLeader or "")
      if leaderName ~= "" and senderTag ~= leaderName then
        return
      end

      self:ResetCompetitiveSession()
      Notify("What Farm?: " .. PrettyName(senderTag) .. " removed you from the Competitive match.")
      return
    end

    -- JREQ|sid|profile|
    if a == "JREQ" then
      if not iAmLeader then return end
      if sid == "" or tostring(self._compSessionId or "") ~= sid then return end

      local joiner = self:_CanonCompName(senderTag)
      if joiner == "" then return end

      local allowJoin = CompAllowJoinWithoutInvite()
      local invitedKey = self:_FindCompetitiveInviteRecord(joiner)
      local alreadyKnown = self._compPeers[joiner] or (self:_CanonCompName(self._compLeader or "") == joiner)
      if (not allowJoin) and (not invitedKey) and (not alreadyKnown) then
        self:_SendCompetitiveWhisper(senderTag, "JDENY|" .. sid .. "|invite-only||")
        Notify("What Farm?: Blocked unsolicited join request from " .. PrettyName(joiner) .. ".")
        return
      end

      self:_AddCompetitivePeer(joiner)
      self:_EnsureCompScore(joiner)
      self:_SetCompetitivePlayerStatus(joiner, "active")
      self:_ClearCompetitiveInvitePlaceholder(joiner)

      local reqProf = Trim(self._compRequiredProfile or c or "")
      local hostProv = Trim(self._compHostPriceProvider or "")
      local hostName = self:_GetLocalPlayerTag()
      self:_SendCompetitiveWhisper(senderTag, "JACK|" .. sid .. "|" .. reqProf .. "|" .. hostProv .. "|" .. hostName)
      self:_BroadcastCompetitivePeers()
      self:_RebuildCompetitiveSelfAuthoritativeState(true)

      local sess = self._compSession or {}
      local state = tostring(sess.state or "idle")
      if state == "countdown" or state == "live" or state == "paused" then
        local startAt = tonumber(sess.startAt) or tonumber(self._compStartAt) or (SafeNow() + 3)
        self:_SendCompetitiveWhisper(senderTag, "START|" .. sid .. "|" .. tostring(startAt) .. "|" .. reqProf .. "|" .. hostProv)
        if state == "paused" then
          local pausedAt = tonumber(sess.pausedAt) or SafeNow()
          local pausedTotal = tonumber(sess.pausedTotal) or 0
          self:_SendCompetitiveWhisper(senderTag, "PAUSE|" .. sid .. "|" .. tostring(pausedAt) .. "|" .. tostring(pausedTotal) .. "|")
        end
      elseif state == "ended" then
        local endedAt = tonumber(sess.endAt) or SafeNow()
        local pausedTotal = tonumber(sess.pausedTotal) or 0
        self:_SendCompetitiveWhisper(senderTag, "END|" .. sid .. "|" .. tostring(endedAt) .. "|" .. tostring(pausedTotal) .. "|")
      end

      local timerSeconds = 0
      if self._GetStoredRaceTimerSeconds then
        timerSeconds = self:_GetStoredRaceTimerSeconds()
      elseif WF and WF.DB and WF.DB.GetSetting then
        timerSeconds = tonumber(WF.DB:GetSetting("mainWindowRaceTimerSeconds")) or 0
      end
      timerSeconds = math.floor(math.max(0, math.min(86400, tonumber(timerSeconds) or 0)) + 0.0001)
      self:_SendCompetitiveWhisper(senderTag, "RTIMER|" .. sid .. "|" .. tostring(timerSeconds) .. "||")

      Notify("What Farm?: " .. PrettyName(joiner) .. " joined Competitive session " .. sid .. ".")
      if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(true) end
      if self.RefreshCompetitiveLootPanel then self:RefreshCompetitiveLootPanel(true) end
      return
    end

    -- JACK|sid|profile|hostProvider|leader
    if a == "JACK" then
      local prof = Trim(c)
      local hostProv = Trim(d)
      local leaderName = self:_CanonCompName(Trim(e) ~= "" and e or senderTag)

      self._compSessionId = sid
      self._compLeader = leaderName
      self._compRequiredProfile = prof ~= "" and prof or self._compRequiredProfile
      self._compHostPriceProvider = hostProv ~= "" and hostProv or self._compHostPriceProvider

      if prof ~= "" then
        local ok = self:_ApplyCompetitiveRules(prof)
        if not ok then
          Notify("What Farm?: Can't join — unable to apply required Competitive profile: " .. DisplayProfileName(prof))
          return
        end
      end

      -- The host is the price authority. Non-hosts keep their own local price addon
      -- settings and receive official values from the host.

      self._compInvited = {}
      self:_AddCompetitivePeer(leaderName)
      self:_EnsureCompScore(self:_GetLocalPlayerTag())
      self:_SetCompetitivePlayerStatus(self:_GetLocalPlayerTag(), "active")

      Notify("What Farm?: Joined Competitive session " .. sid .. " (host: " .. PrettyName(leaderName) .. ").")
      if self._RefreshMainSessionControlLocks then
        self:_RefreshMainSessionControlLocks()
      end
      if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(true) end
      return
    end

    -- JDENY|sid|reason|
    if a == "JDENY" then
      local reason = Trim(c)
      if reason == "invite-only" then
        Notify("What Farm?: That Competitive session is invite-only.")
      else
        Notify("What Farm?: Join request denied.")
      end
      return
    end

    -- RQJOIN|0|profile|
    if a == "RQJOIN" then
      local requester = self:_CanonCompName(senderTag)
      local reqProf = Trim(c)
      if requester == "" then return end

      local state = tostring((self._compSession and self._compSession.state) or "idle")
      local hasSession = tostring(self._compSessionId or "") ~= "" and state ~= "idle"
      if (not self:_IsCompetitiveLeader()) or (not hasSession) then
        self:_SendCompetitiveWhisper(requester, "RQDENY|0|not-hosting|")
        return
      end

      if (state == "live" or state == "paused") and not CompAllowInvitesDuringSession() then
        self:_SendCompetitiveWhisper(requester, "RQDENY|0|session-active|")
        Notify("What Farm?: " .. PrettyName(requester) .. " requested to join, but invites are disabled during active sessions.")
        return
      end

      self._compPendingJoinRequest = { from = requester, profile = reqProf, requestedAt = SafeNow() }
      StaticPopup_Show(COMP_JOIN_REQ_POPUP, PrettyName(requester), (reqProf ~= "" and DisplayProfileName(reqProf) or "—"), self._compPendingJoinRequest)
      return
    end

    -- RQDENY|0|reason|
    if a == "RQDENY" then
      local reason = Trim(c)
      if reason == "not-hosting" then
        Notify("What Farm?: " .. PrettyName(senderTag) .. " is not hosting a Competitive challenge right now.")
      elseif reason == "session-active" then
        Notify("What Farm?: " .. PrettyName(senderTag) .. " can't accept join requests during an active session.")
      else
        Notify("What Farm?: " .. PrettyName(senderTag) .. " declined your Competitive join request.")
      end
      return
    end

    -- Ignore messages for other sessions (except INV/JREQ/JACK/RQJOIN/RQDENY)
    if self._compSessionId and sid ~= "" and tostring(self._compSessionId) ~= sid then
      return
    end

    -- If the board is frozen/paused, ignore live updates so the snapshot stays stable.
    if self._compFrozen then
      if a == "UPD" or a == "RUPD" or a == "META" or a == "RMETA" or a == "LOOT" or a == "LC" or a == "FD" or a == "RLOOT" then
        return
      end
    end

    -- IACK|sid||
    if a == "IACK" then
      if sid == "" or tostring(self._compSessionId or "") ~= sid then
        return
      end

      local marked = self:_MarkCompetitiveInviteDelivered(senderTag)
      if marked then
        Notify("What Farm?: Competitive invite delivered to " .. PrettyName(senderTag) .. ".")
        if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(true) end
      end
      return
    end

    -- ACK|sid|profile|
    if a == "ACK" then
      local prof = Trim(c)
      if prof ~= "" then
        self._compRequiredProfile = prof
      end
      self._compSessionId = sid
      self._compLeader = self._compLeader or self:_GetLocalPlayerTag()
      self:_AddCompetitivePeer(senderTag)

      -- Remove any "invited" placeholders for this player (short or full) to prevent duplicate rows.
      self._compInvited[senderTag] = nil
      self._compInvited[ShortTag(senderTag)] = nil
      Notify("What Farm?: " .. PrettyName(senderTag) .. " accepted your Competitive invite.")
      self:_BroadcastCompetitivePeers()
      if self._BroadcastCompetitiveRaceTimer then
        self:_BroadcastCompetitiveRaceTimer(nil, true)
      end
      if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(true) end
      return
    end

    -- DEC|sid||
    if a == "DEC" then
      self._compInvited[senderTag] = nil
      self._compInvited[ShortTag(senderTag)] = nil
      Notify("What Farm?: " .. PrettyName(senderTag) .. " declined your Competitive invite.")
      if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(true) end
      return
    end

    -- PEERS|sid|csv|profile
    if a == "PEERS" then
      local csv = tostring(c or "")
      local prof = Trim(d)
      if prof ~= "" then
        self._compRequiredProfile = prof
      end

      local newPeers = {}
      local leaderName = self:_CanonCompName(self._compLeader or senderTag)
      local meNow = self:_CanonCompName(self:_GetLocalPlayerTag())
      if leaderName ~= "" and leaderName ~= meNow then
        newPeers[leaderName] = true
      end

      if csv and csv ~= "" then
        for name in csv:gmatch("([^,]+)") do
          name = self:_CanonCompName(name)
          if name ~= "" and name ~= self:_GetLocalPlayerTag() then
            newPeers[name] = true
          end
        end
      end

      local oldPeers = self._compPeers or {}
      self._compPeers = {}
      for name in pairs(newPeers) do
        self:_AddCompetitivePeer(name)
      end
      for name in pairs(oldPeers) do
        name = self:_CanonCompName(name)
        if name ~= "" and (not newPeers[name]) and name ~= meNow and name ~= leaderName then
          self:_ClearCompetitiveParticipantData(name)
        end
      end

      if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(true) end
      if self.RefreshCompetitiveLootPanel then self:RefreshCompetitiveLootPanel(true) end
      return
    end

    -- RTIMER|sid|seconds
    if a == "RTIMER" then
      if sid == "" or tostring(self._compSessionId or "") ~= sid then
        return
      end

      local leaderName = self:_CanonCompName(self._compLeader or "")
      if leaderName ~= "" and senderTag ~= leaderName then
        return
      end

      local seconds = tonumber(c) or 0
      if self._ApplyCompetitiveRaceTimerSync then
        self:_ApplyCompetitiveRaceTimerSync(seconds)
      else
        if seconds < 0 then seconds = 0 end
        if seconds > 86400 then seconds = 86400 end
        self._compRaceTimerSeconds = math.floor(seconds + 0.0001)
      end
      return
    end

    -- START|sid|startAt|profile|hostProvider
    if a == "START" then
      local startAt = tonumber(c) or (SafeNow() + 3)
      local prof = Trim(d)
      local hostProv = Trim(e)
      if hostProv ~= "" then
        self._compHostPriceProvider = hostProv
      end
      if prof ~= "" then
        self._compRequiredProfile = prof
      end
      self._compSessionId = sid
      self._compLeader = self:_CanonCompName(self._compLeader or senderTag)
      self:_BeginCompetitiveRaceState(sid, startAt, prof, hostProv)
      Notify("What Farm?: Competitive starts in 3s (host: " .. PrettyName(senderTag) .. ").")
      self:_ScheduleCompetitiveStart(sid, startAt, prof)
      return
    end

    -- PAUSE|sid|pausedAt|pausedTotal
    if a == "PAUSE" then
      local pausedAt = tonumber(c) or SafeNow()
      local pausedTotal = tonumber(d) or (self._compSession and self._compSession.pausedTotal) or 0
      self:_SetCompetitiveSessionState("paused", sid, self._compStartAt, pausedAt, pausedTotal, 0)
      if self.Pause and not (self.IsPaused and self:IsPaused()) then
        pcall(function() self:Pause() end)
      end
      self:_StopCompetitiveTicker()
      if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(true) end
      if self.RefreshCompetitiveLootPanel then self:RefreshCompetitiveLootPanel(true) end
      return
    end

    -- RESUME|sid|resumeAt|pausedTotal
    if a == "RESUME" then
      local pausedTotal = tonumber(d) or 0
      self:_SetCompetitiveSessionState("live", sid, self._compStartAt, 0, pausedTotal, 0)
      if self.Resume and self.IsPaused and self:IsPaused() then
        pcall(function() self:Resume() end)
      end
      self:_EnsureCompetitiveTicker()
      if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(true) end
      if self.RefreshCompetitiveLootPanel then self:RefreshCompetitiveLootPanel(true) end
      return
    end

    -- END|sid|endedAt|pausedTotal
    if a == "END" then
      local endedAt = tonumber(c) or SafeNow()
      local pausedTotal = tonumber(d) or (self._compSession and self._compSession.pausedTotal) or 0
      self:_SetCompetitiveSessionState("ended", sid, self._compStartAt, 0, pausedTotal, endedAt)
      self:_StopCompetitiveTicker()
      self:_RestoreCompetitiveRules()
      if self.IsPaused and self:IsPaused() and self.FinalizePauseAccounting then
        pcall(function() self:FinalizePauseAccounting() end)
      end
      if Tracker and Tracker.IsActive and Tracker.Stop and (Tracker:IsActive() or (self.IsPaused and self:IsPaused())) then
        pcall(function() Tracker:Stop(true) end)
      end
      self:_ResetFarmWindowAfterCompetitiveEnd()
      if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(true) end
      if self.RefreshCompetitiveLootPanel then self:RefreshCompetitiveLootPanel(true) end
      Notify("What Farm?: Competitive match ended.")
      return
    end

    -- LEAVE|sid|player
    if a == "LEAVE" then
      if not iAmLeader then return end
      local who = self:_CanonCompName(Trim(c) ~= "" and c or senderTag)
      if who == "" then return end
      self:_ClearCompetitiveParticipantData(who)
      self:_BroadcastCompetitivePeers()
      if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(true) end
      if self.RefreshCompetitiveLootPanel then self:RefreshCompetitiveLootPanel(true) end
      Notify("What Farm?: " .. PrettyName(who) .. " left the match.")
      return
    end

    -- META|sid|zone|profile|items
    -- items format: itemID:count:value,itemID:count:value,... (top items only)
    if a == "META" then
      local zone = Trim(c)
      local prof = Trim(d)
      local items = Trim(e)

      -- prof can optionally pack the price provider as: "ProfileName^provider"
      local profName, prov = prof:match("^([^%^]+)%^(.-)$")
      if profName then
        profName = Trim(profName)
        prov = Trim(prov)
      else
        profName = prof
        prov = ""
      end

      local key = self:_CanonCompName(senderTag)
      self:_EnsureCompetitiveState()
      self._compMeta[key] = self._compMeta[key] or {}
      local m = self._compMeta[key]
      m.zone = zone
      m.profile = profName
      m.priceProvider = prov
      m.items = {}
      m.updatedAt = SafeNow()

      if iAmLeader then
        local officialItems = self:_BuildCompetitiveLootStringFromCounts((self._compScoredLootCounts and self._compScoredLootCounts[key]) or {}, 3, 220)
        items = officialItems
      end

      if items ~= "" then
        for part in items:gmatch("([^,]+)") do
          local iid, cnt, val = part:match("^(%d+):(%d+):(%d+)$")
          iid = tonumber(iid)
          if iid then
            m.items[#m.items + 1] = {
              itemID = iid,
              count = tonumber(cnt) or 0,
              value = tonumber(val) or 0,
            }
          end
        end
      end

      if iAmLeader and key ~= self:_CanonCompName(self:_GetLocalPlayerTag()) then
        self:_SendCompetitivePeerMessage("RMETA|" .. sid .. "|" .. key .. "|" .. zone .. "|" .. prof .. "|" .. items)
      end

      return
    end


    -- LOOT|sid|items
    -- items format: itemID:count:value,itemID:count:value,... (top items; used for Overall Loot tab)
    if a == "LOOT" then
      local items = Trim(c)
      local key = self:_CanonCompName(senderTag)
      self:_EnsureCompetitiveState()
      self._compLoot[key] = self._compLoot[key] or {}
      local l = self._compLoot[key]
      l.items = self:_ParseCompetitiveLootString(items)
      l.updatedAt = SafeNow()
      self:_MarkCompetitiveLootDirty()
      if self.RefreshCompetitiveLootPanel then self:RefreshCompetitiveLootPanel(false) end
      return
    end

    -- LC|sid|counts
    -- counts format: itemID:count,itemID:count,... (full filtered snapshot; leader replaces authoritative counts)
    if a == "LC" then
      if not iAmLeader then return end
      local who = self:_CanonCompName(senderTag)
      local payload = Trim(c)
      if who == "" then return end
      self._compLootCounts[who] = self:_ParseCompetitiveLootCountsString(payload)
      self:_RebuildCompetitivePlayerFromCounts(who, true)
      if self.RefreshCompetitiveLootPanel then self:RefreshCompetitiveLootPanel(false) end
      if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(false) end
      return
    end

    -- FD|sid|seq|part/total|counts
    -- counts format: itemID:count,itemID:count,... (filtered deltas; leader prices+rebroadcasts)
    if a == "FD" then
      if not iAmLeader then return end
      local who = self:_CanonCompName(senderTag)
      local seq = tostring(c or "")
      local partInfo = Trim(d)
      local payload = Trim(e)
      local part, total = partInfo:match("^(%d+)%/(%d+)$")
      part = tonumber(part)
      total = tonumber(total)
      if who == "" or seq == "" or not part or not total or total < 1 then return end

      self._compDeltaInbox[who] = self._compDeltaInbox[who] or {}
      local inbox = self._compDeltaInbox[who][seq]
      if not inbox then
        inbox = { total = total, parts = {}, createdAt = SafeNow() }
        self._compDeltaInbox[who][seq] = inbox
      end
      inbox.total = total
      inbox.parts[part] = payload

      local ready = true
      for idx = 1, total do
        if inbox.parts[idx] == nil then
          ready = false
          break
        end
      end
      if not ready then return end

      self._compLootCounts[who] = self._compLootCounts[who] or {}
      for idx = 1, total do
        local cnts = self:_ParseCompetitiveLootCountsString(inbox.parts[idx])
        for iid, cnt in pairs(cnts) do
          self._compLootCounts[who][iid] = (tonumber(self._compLootCounts[who][iid]) or 0) + (tonumber(cnt) or 0)
        end
      end
      self._compDeltaInbox[who][seq] = nil

      self:_RebuildCompetitivePlayerFromCounts(who, true)
      if self.RefreshCompetitiveLootPanel then self:RefreshCompetitiveLootPanel(false) end
      if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(false) end
      return
    end

    -- RLOOT|sid|player|items
    if a == "RLOOT" then
      local who = self:_CanonCompName(Trim(c))
      local items = Trim(d)
      if who == "" then return end
      self:_EnsureCompetitiveState()
      self._compLoot[who] = self._compLoot[who] or {}
      self._compLoot[who].items = self:_ParseCompetitiveLootString(items)
      self._compLoot[who].updatedAt = SafeNow()
      self:_MarkCompetitiveLootDirty()
      if self.RefreshCompetitiveLootPanel then self:RefreshCompetitiveLootPanel(false) end
      return
    end

    -- RUPD|sid|player|value|elapsed|status
    if a == "RUPD" then
      local who = self:_CanonCompName(Trim(c))
      local v = tonumber(d) or 0
      local t = tonumber(e) or 0
      local status = Trim(f)
      if who == "" then return end
      self:_EnsureCompScore(who)
      if status ~= "" then
        self:_SetCompetitivePlayerStatus(who, status)
      end
      self._compScores[who].value = v
      self._compScores[who].elapsed = t
      self._compScores[who].status = self:_GetCompetitivePlayerStatus(who)
      self._compScores[who].updatedAt = SafeNow()
      if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(false) end
      return
    end

    -- RMETA|sid|player|zone|profile|items
    if a == "RMETA" then
      local who = self:_CanonCompName(Trim(c))
      local zone = Trim(d):gsub("|", "/")
      local prof = Trim(e):gsub("|", "/")
      local items = Trim(f)
      if who == "" then return end
      self:_EnsureCompetitiveState()
      self._compMeta[who] = self._compMeta[who] or {}
      self._compMeta[who].zone = zone
      local profName, prov = prof:match("^([^%^]+)%^(.-)$")
      if profName then
        self._compMeta[who].profile = Trim(profName)
        self._compMeta[who].priceProvider = Trim(prov)
      else
        self._compMeta[who].profile = prof
        self._compMeta[who].priceProvider = ""
      end
      self._compMeta[who].items = self:_ParseCompetitiveLootString(items)
      self._compMeta[who].updatedAt = SafeNow()
      if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(false) end
      return
    end

    -- UPD|sid|value|elapsed
    -- Legacy client-auth value updates are ignored during host-priced Competitive
    -- sessions. Raw LC/FD loot counts are the only source the host scores from.
    if a == "UPD" then
      return
    end

  end)

  self._compEventFrame = ef
end

-- ---------------------------------------------------------
-- Start / Update
-- ---------------------------------------------------------
function UI:StartChallenge()
  self:_EnsureCompetitiveComm()
  self:_EnsureCompetitiveState()

  if tostring(self._compSessionId or "") ~= "" and self._compLeader and not self:_IsCompetitiveLeader() then
    Notify("What Farm?: Only the host can start the match.")
    return
  end

  local accepted = TableCount(self._compPeers)
  local pending = TableCount(self._compInvited)
  if accepted <= 0 and pending > 0 then
    Notify("What Farm?: Wait for at least one invite to accept before starting.")
    return
  end

  local sid = tostring(self._compSessionId or "")
  local keepExistingSid = (sid ~= "") and (self:_HasCompetitivePeers() or self:_HasCompetitiveInvited())
  if not keepExistingSid then
    sid = tostring(math.floor(SafeNow() * 1000 + math.random(1000, 9999)))
  end
  self._compSessionId = sid

  local prof = ""
  if WF and WF.DB and WF.DB.GetActiveProfileName then
    prof = tostring(WF.DB:GetActiveProfileName() or "")
  end
  self._compRequiredProfile = prof

  local leader = self:_GetLocalPlayerTag()
  self._compLeader = leader

  local startAt = SafeNow() + 3

  local hostProv = ""
  if WF and WF.Price and WF.Price.GetProvider then
    hostProv = tostring(WF.Price:GetProvider() or "")
  end
  hostProv = hostProv:gsub("|", "/")
  hostProv = hostProv:gsub("%s+", "")
  self._compHostPriceProvider = hostProv

  self:_BeginCompetitiveRaceState(sid, startAt, prof, hostProv)
  self:_BroadcastCompetitivePeers()
  self:_SendCompetitivePeerMessage("START|" .. sid .. "|" .. tostring(startAt) .. "|" .. prof .. "|" .. hostProv)
  if self._BroadcastCompetitiveRaceTimer then
    self:_BroadcastCompetitiveRaceTimer(nil, true)
  end
  Notify("What Farm?: Competitive starts in 3 seconds.")
  self:_ScheduleCompetitiveStart(sid, startAt, prof)

  self:_EnsureCompetitiveTicker()
  if self.RefreshCompetitivePanel then
    self:RefreshCompetitivePanel(true)
  end
end

function UI:_SendCompetitiveUpdate()
  if not self._compSessionId then return end
  self:_EnsureCompetitiveState()

  local sess = self._compSession or {}
  if tostring(sess.state or "idle") ~= "live" then return end
  if self:_GetCompetitivePlayerStatus(self:_GetLocalPlayerTag()) == "quit" then return end

  local sid = tostring(self._compSessionId)
  local me = self:_GetLocalPlayerTag()
  local iAmLeader = self:_IsCompetitiveLeader()

  local hostProvider = Trim(self._compHostPriceProvider or "")
  if iAmLeader and hostProvider ~= "" then
    self:_ApplyCompetitiveHostPriceProvider(hostProvider, false)
  end

  self:_PruneCompetitiveDeltaInbox(15)

  self:_EnsureCompScore(me)

  if iAmLeader then
    self:_RebuildCompetitiveAllAuthoritativeState(true)
  else
    self:_SendCompetitiveFilteredCountDeltas(false)

    local lastSnapshot = tonumber(self._compLastSnapshotSendAt) or 0
    local nowSnapshot = SafeNow()
    local fullCounts = self:_BuildCompetitiveCountsString(self:_GetCompetitiveRawCountsSinceStart(), 60, 220)
    if (nowSnapshot - lastSnapshot) >= 4 and fullCounts ~= tostring(self._compLastSnapshotPayload or "") then
      self._compLastSnapshotSendAt = nowSnapshot
      self._compLastSnapshotPayload = fullCounts
      self:_SendCompetitivePeerMessage("LC|" .. sid .. "|" .. fullCounts)
    end
  end

  if iAmLeader and self._BroadcastCompetitiveRaceTimer then
    self:_BroadcastCompetitiveRaceTimer(nil, false)
  end

  if self._CheckRaceTimerAutoStop then
    self:_CheckRaceTimerAutoStop()
  end

  local now = SafeNow()

  -- Prune stale addon-owned tables periodically.

  -- Prune stale local loot cache entries periodically so memory
  -- does not climb during long host sessions.
  if (now - (tonumber(self._compLastLootPruneAt) or 0)) >= 30 then
    self._compLastLootPruneAt = now
    self:_PruneCompetitiveLocalLootCache(120)
  end
  local last = tonumber(self._compLastMetaSendAt) or 0
  if (now - last) >= 5 then
    self._compLastMetaSendAt = now
    local zone, prof, items = self:_GetCompetitiveLocalMeta()

    local meKey = self:_CanonCompName(self:_GetLocalPlayerTag())
    self._compMeta[meKey] = self._compMeta[meKey] or {}
    self._compMeta[meKey].zone = zone
    local profName, prov = prof:match("^([^%^]+)%^(.-)$")
    if profName then
      self._compMeta[meKey].profile = Trim(profName)
      self._compMeta[meKey].priceProvider = Trim(prov)
    else
      self._compMeta[meKey].profile = prof
      self._compMeta[meKey].priceProvider = ""
    end
    self._compMeta[meKey].items = {}
    self._compMeta[meKey].updatedAt = now
    if items ~= "" then
      for part in items:gmatch("([^,]+)") do
        local iid, cnt, val = part:match("^(%d+):(%d+):(%d+)$")
        iid = tonumber(iid)
        if iid then
          self._compMeta[meKey].items[#self._compMeta[meKey].items+1] = { itemID = iid, count = tonumber(cnt) or 0, value = tonumber(val) or 0 }
        end
      end
    end

    if zone ~= "" or items ~= "" then
      if iAmLeader then
        self:_SendCompetitivePeerMessage("RMETA|" .. sid .. "|" .. meKey .. "|" .. zone .. "|" .. prof .. "|" .. items)
      else
        self:_SendCompetitivePeerMessage("META|" .. sid .. "|" .. zone .. "|" .. prof .. "|" .. items)
      end
    end
  end
end

-- Dedicated competitive ticker (keeps updates flowing even if main window is closed)
function UI:_EnsureCompetitiveTicker()
  if self._compTicker then
    return
  end
  if not (C_Timer and C_Timer.NewTicker) then
    return
  end

  self._compTicker = C_Timer.NewTicker(1, function()
    if not self._compSessionId then
      return
    end
    self:_SendCompetitiveUpdate()
    local compVisible = false
    if self.compPanel and self.compPanel.IsShown and self.compPanel:IsShown() then
      compVisible = true
    elseif self.compLootPanel and self.compLootPanel.IsShown and self.compLootPanel:IsShown() then
      compVisible = true
    end
    if compVisible then
      if self.RefreshCompetitivePanel then
        self:RefreshCompetitivePanel(false)
      end
      if self.RefreshCompetitiveLootPanel then
        self:RefreshCompetitiveLootPanel(false)
      end
    end
  end)
end

function UI:_StopCompetitiveTicker()
  if self._compTicker and self._compTicker.Cancel then
    self._compTicker:Cancel()
  end
  self._compTicker = nil
end

-- ---------------------------------------------------------
-- UI refresh (separate Competitive window)
-- ---------------------------------------------------------
function UI:RefreshCompetitivePanel(forceMeta)
  if not (self._comp and self._comp.rows) then return end
  if not (self.compPanel and self.compPanel.IsShown and self.compPanel:IsShown()) then return end

  self:_EnsureCompetitiveComm()
  self:_EnsureCompetitiveState()

  local sid = tostring(self._compSessionId or "—")
  local prof = tostring(self._compRequiredProfile or "")
  local dist, id = self:_GetCompetitiveDistribution()
  local distTxt = "Invite"
  if dist == "RAID" then distTxt = "Raid"
  elseif dist == "PARTY" then distTxt = "Party"
  elseif dist == "CHANNEL" then distTxt = "Channel"
  elseif dist == nil and not (self:_HasCompetitivePeers() or self:_HasCompetitiveInvited()) then
    distTxt = "Solo"
  end
  if dist == "CHANNEL" and id then
    distTxt = distTxt .. " #" .. tostring(id)
  end

  local leader = tostring(self:_CanonCompName(self._compLeader or "—"))
  local joined = TableCount(self._compPeers)
  local invited = TableCount(self._compInvited)
  local hostProv = tostring(self._compHostPriceProvider or "")
  local scoringTxt = hostProv ~= "" and hostProv or "Host"
  local sessState = tostring((self._compSession and self._compSession.state) or "idle")
  local stateTxt = "Waiting"
  if sessState == "countdown" then stateTxt = "Countdown"
  elseif sessState == "live" then stateTxt = "Live"
  elseif sessState == "paused" then stateTxt = "Paused"
  elseif sessState == "ended" then stateTxt = "Ended" end

  if self:_IsCompetitiveLeader() and (sessState == "live" or sessState == "paused") then
    self:_RebuildCompetitiveSelfAuthoritativeState(false)
  end

  if self._comp and self._comp.refreshButtons then
    self._comp.refreshButtons()
  end

  local me = self:_GetLocalPlayerTag()
  self:_EnsureCompScore(me)
  if leader ~= "—" and leader ~= "" then self:_EnsureCompScore(leader) end
  for n in pairs(self._compPeers) do self:_EnsureCompScore(n) end
  for n in pairs(self._compInvited) do self:_EnsureCompScore(n) end

  local roster = {}
  roster[me] = true
  if leader ~= "—" and leader ~= "" then roster[leader] = true end
  for n in pairs(self._compPeers) do roster[n] = true end
  for n in pairs(self._compInvited) do roster[n] = true end

  local acceptedCount = 0
  local rosterCount = 0
  for n in pairs(roster) do
    rosterCount = rosterCount + 1
    if self:_GetCompetitivePlayerStatus(n) ~= "invited" then
      acceptedCount = acceptedCount + 1
    end
  end

  local function SetChip(chip, value, color)
    if not (chip and chip.value and chip.value.SetText) then return end
    chip.value:SetText(value or "—")
    if color and chip.value.SetTextColor then
      chip.value:SetTextColor(color[1] or 1, color[2] or 1, color[3] or 1, color[4] or 1)
    elseif chip.value.SetTextColor then
      chip.value:SetTextColor(1, 1, 1, 1)
    end
  end

  local accent = (Theme and Theme.accent) or { 0.35, 0.78, 1.00, 1.00 }
  local muted = (Theme and Theme.muted) or { 0.65, 0.65, 0.65, 1.00 }
  local pausedColor = (Theme and Theme.pause) or accent
  local statusColor = accent
  if sessState == "paused" then
    statusColor = pausedColor
  elseif sessState == "ended" then
    statusColor = muted
  elseif sessState == "idle" then
    statusColor = muted
  end

  local profileText = "Any"
  if prof ~= "" then
    profileText = DisplayProfileName(prof)
  end

  SetChip(self._comp.summarySession, sid, nil)
  SetChip(self._comp.summaryStatus, stateTxt, statusColor)
  SetChip(self._comp.summaryMode, distTxt, nil)
  SetChip(self._comp.summaryHost, PrettyName(leader), accent)
  SetChip(self._comp.summaryProfile, profileText, nil)
  SetChip(self._comp.summaryScoring, scoringTxt, nil)

  if self._comp.meta and self._comp.meta.SetText then
    self._comp.meta:SetText("Accepted: " .. tostring(acceptedCount) .. " • Pending: " .. tostring(invited) .. " • Total roster: " .. tostring(rosterCount) .. " • Host: " .. PrettyName(leader))
  end
  if self._comp.meta2 and self._comp.meta2.SetText then
    local helper = "Right-click a player row for host actions."
    if not self:_IsCompetitiveLeader() then
      helper = "Hover players for details. Host-only row controls stay on the leader."
    elseif invited > 0 then
      helper = helper .. " Pending invites can be reinvited or canceled."
    end
    self._comp.meta2:SetText(helper)
  elseif forceMeta and self._comp.meta and self._comp.meta.SetText then
    local meta = "Session: " .. sid .. " • Mode: " .. distTxt .. " • Status: " .. stateTxt
    meta = meta .. " • Scoring: " .. scoringTxt
    if prof ~= "" then meta = meta .. " • Profile: " .. DisplayProfileName(prof) end
    meta = meta .. " • Host: " .. PrettyName(leader)
    if invited > 0 then meta = meta .. " • Invited: " .. tostring(invited) end
    if joined > 0 then meta = meta .. " • Joined: " .. tostring(joined) end
    self._comp.meta:SetText(meta)
  end

  local list = {}
  for n, s in pairs(self._compScores) do
    if roster[n] then
      list[#list+1] = {
        name = n,
        value = tonumber(s.value) or 0,
        elapsed = tonumber(s.elapsed) or 0,
        status = self:_GetCompetitivePlayerStatus(n),
      }
    end
  end

  table.sort(list, function(a, b)
    local aq = (a.status == "quit") and 1 or 0
    local bq = (b.status == "quit") and 1 or 0
    if aq ~= bq then return aq < bq end
    if a.value ~= b.value then return a.value > b.value end
    if a.elapsed ~= b.elapsed then return a.elapsed < b.elapsed end
    return tostring(a.name or ""):lower() < tostring(b.name or ""):lower()
  end)

  if self._comp then
    self._comp.visibleRowCount = #list
    if self._comp.updateLeaderboardScrollLayout then
      self._comp.updateLeaderboardScrollLayout()
    end
  end

  for i, row in ipairs(self._comp.rows) do
    local e = list[i]
    if e then
      if row.frame then
        self:_BindCompetitiveRowContextMenu(row.frame)
        row.frame._wfCompName = e.name
        row.frame._wfCompStatus = e.status
      end

      local rowStatus = "Joined"
      if e.name == leader then
        rowStatus = "Host"
      end
      if e.status == "invited" then
        rowStatus = "Pending"
      elseif e.status == "quit" then
        rowStatus = "Quit"
      end

      row.rank:SetText(tostring(i))
      row.name:SetText(PrettyName(e.name))
      if row.status then row.status:SetText(rowStatus) end
      row.value:SetText(Util:FormatMoney(e.value))

      local gph = 0
      if e.elapsed and e.elapsed > 0 then gph = (e.value / e.elapsed) * 3600 end
      row.gph:SetText(Util:FormatMoney(gph))

      if row.rank and row.rank.SetTextColor then
        row.rank:SetTextColor(0.72, 0.72, 0.72, 1)
      end

      if row.name and row.name.SetTextColor then
        if e.status == "invited" then
          row.name:SetTextColor(0.64, 0.64, 0.64, 1)
        elseif e.status == "quit" then
          row.name:SetTextColor(muted[1], muted[2], muted[3], muted[4] or 1)
        elseif e.name == leader then
          row.name:SetTextColor(accent[1], accent[2], accent[3], 1)
        else
          row.name:SetTextColor(1, 1, 1, 1)
        end
      end

      if row.status and row.status.SetTextColor then
        if e.status == "invited" then
          row.status:SetTextColor(0.62, 0.62, 0.62, 1)
        elseif e.status == "quit" then
          row.status:SetTextColor(muted[1], muted[2], muted[3], muted[4] or 1)
        elseif e.name == leader then
          row.status:SetTextColor(accent[1], accent[2], accent[3], 1)
        else
          row.status:SetTextColor(0.78, 0.78, 0.78, 1)
        end
      end

      if row.value and row.value.SetTextColor then
        if e.status == "quit" then
          row.value:SetTextColor(0.82, 0.82, 0.82, 1)
        else
          row.value:SetTextColor(1, 1, 1, 1)
        end
      end
      if row.gph and row.gph.SetTextColor then row.gph:SetTextColor(0.75, 0.75, 0.75, 1) end
      if row.bg and row.bg.SetColorTexture then
        if e.status == "quit" then
          row.bg:SetColorTexture(accent[1], accent[2], accent[3], 0.045)
        elseif e.status == "invited" then
          row.bg:SetColorTexture(1, 1, 1, 0.015)
        elseif (i % 2) == 1 then
          row.bg:SetColorTexture(1, 1, 1, 0.02)
        else
          row.bg:SetColorTexture(1, 1, 1, 0.00)
        end
      end

      if row.frame and row.frame.Show then row.frame:Show() end
      row.rank:Show(); row.name:Show(); row.value:Show(); row.gph:Show()
      if row.status then row.status:Show() end
    else
      if row.frame then
        self:_BindCompetitiveRowContextMenu(row.frame)
        row.frame._wfCompName = nil
        row.frame._wfCompStatus = nil
        if row.frame.Hide then row.frame:Hide() end
      end
      row.rank:SetText("")
      row.name:SetText("")
      if row.status then row.status:SetText("") end
      row.value:SetText("")
      row.gph:SetText("")
      if row.rank and row.rank.Hide then row.rank:Hide() end
      if row.name and row.name.Hide then row.name:Hide() end
      if row.value and row.value.Hide then row.value:Hide() end
      if row.gph and row.gph.Hide then row.gph:Hide() end
      if row.status and row.status.Hide then row.status:Hide() end
    end
  end
end

function UI:_BuildCompetitiveLootItemsFromCounts(counts)
  local out = {}
  counts = counts or {}

  for iid, cnt in pairs(counts) do
    iid = tonumber(iid)
    cnt = tonumber(cnt)
    if iid and cnt and cnt > 0 then
      local unit = 0
      if WF and WF.Price and WF.Price.GetItemValue then
        unit = tonumber(WF.Price:GetItemValue(iid)) or 0
      end

      out[#out + 1] = {
        itemID = iid,
        count = cnt,
        value = unit * cnt,
      }
    end
  end

  table.sort(out, function(a, b)
    if (a.value or 0) == (b.value or 0) then
      if (a.count or 0) == (b.count or 0) then
        return (a.itemID or 0) < (b.itemID or 0)
      end
      return (a.count or 0) > (b.count or 0)
    end
    return (a.value or 0) > (b.value or 0)
  end)

  return out
end

function UI:_GetCompetitivePlayerLootItems(name)
  self:_EnsureCompetitiveState()

  local key = self:_CanonCompName(name)
  if key == "" then
    return nil
  end

  local me = self:_CanonCompName(self:_GetLocalPlayerTag())
  if key == me and self:_IsCompetitiveLeader() then
    self:_SyncCompetitiveLocalLootFromTracker(false)
  end

  local loot = self._compLoot and self._compLoot[key]
  if type(loot) == "table" and type(loot.items) == "table" and #loot.items > 0 then
    return loot.items
  end

  local scored = self._compScoredLootCounts and self._compScoredLootCounts[key]
  if type(scored) == "table" and next(scored) ~= nil then
    return self:_BuildCompetitiveLootItemsFromCounts(scored)
  end

  -- During a host-priced Competitive session, wait for official host-scored data
  -- instead of showing raw/unfiltered local-priced fallbacks.
  if tostring(self._compSessionId or "") ~= "" then
    return nil
  end

  local counts = self._compLootCounts and self._compLootCounts[key]
  if type(counts) == "table" and next(counts) ~= nil then
    return self:_BuildCompetitiveLootItemsFromCounts(counts)
  end

  if key == me and self:_IsCompetitiveLeader() then
    local scored = self._compScoredLootCounts and self._compScoredLootCounts[key]
    if type(scored) == "table" and next(scored) ~= nil then
      return self:_BuildCompetitiveLootItemsFromCounts(scored)
    end

    -- Keep the raw local loot cache out of the visible loot table.
    -- It is intentionally unfiltered and only exists as the host pricing input.
  end

  return nil
end

local function CompetitiveLootSafeEach(totalValue, itemCount)
  totalValue = tonumber(totalValue) or 0
  itemCount = tonumber(itemCount) or 0
  if itemCount <= 0 then
    return 0
  end
  return math.floor(totalValue / itemCount)
end

local function CompetitiveLootIsMaterial(itemID, itemLink)
  itemID = tonumber(itemID)
  if not itemID then
    return false
  end

  local classID = select(6, GetItemInfoInstant(itemID))
  if tonumber(classID) == 7 then
    return true
  end

  if C_Item and C_Item.IsCraftingReagent then
    local ok, isReagent = pcall(C_Item.IsCraftingReagent, itemLink or ("item:" .. tostring(itemID)))
    if ok and isReagent then
      return true
    end
  end

  return false
end

function UI:ToggleCompetitiveLootItemExpansion(itemID)
  self:_EnsureCompetitiveState()

  itemID = tonumber(itemID)
  if not itemID then
    return
  end

  local expanded = self._compLootExpanded or {}
  local willExpand = not not (not expanded[itemID])

  for k in pairs(expanded) do
    expanded[k] = nil
  end

  if willExpand then
    expanded[itemID] = true
  end

  self._compLootExpanded = expanded
  self:_MarkCompetitiveLootDirty()
  if self.RefreshCompetitiveLootPanel then
    self:RefreshCompetitiveLootPanel(true)
  end
end

function UI:_GetCompetitiveOverallLootList()
  self:_EnsureCompetitiveState()

  local activeRoster = {}
  local me = self:_CanonCompName(self:_GetLocalPlayerTag())
  if me ~= "" then
    activeRoster[me] = true
  end
  local leader = self:_CanonCompName(self._compLeader or "")
  if leader ~= "" then
    activeRoster[leader] = true
  end
  for name in pairs(self._compPeers or {}) do
    name = self:_CanonCompName(name)
    if name ~= "" then
      activeRoster[name] = true
    end
  end
  for name in pairs(self._compInvited or {}) do
    name = self:_CanonCompName(name)
    if name ~= "" then
      activeRoster[name] = true
    end
  end
  for name in pairs(self._compLoot or {}) do
    name = self:_CanonCompName(name)
    if name ~= "" then
      activeRoster[name] = true
    end
  end
  for name in pairs(self._compLootCounts or {}) do
    name = self:_CanonCompName(name)
    if name ~= "" then
      activeRoster[name] = true
    end
  end

  local itemBuckets = {}
  local itemInfoCache = {}

  local function GetItemMeta(iid)
    iid = tonumber(iid)
    if not iid then
      return {
        itemID = 0,
        name = "Unknown Item",
        nameLower = "unknown item",
        link = nil,
        icon = nil,
        isMaterial = false,
      }
    end

    local cached = itemInfoCache[iid]
    if cached then
      return cached
    end

    local name, link, _, _, _, _, _, _, _, icon = GetItemInfo(iid)
    if (not icon) and C_Item and C_Item.GetItemIconByID then
      icon = C_Item.GetItemIconByID(iid)
    end

    cached = {
      itemID = iid,
      name = tostring(name or ("Item " .. tostring(iid))),
      nameLower = Lower(name or ("Item " .. tostring(iid))),
      link = link,
      icon = icon,
      isMaterial = CompetitiveLootIsMaterial(iid, link),
    }
    itemInfoCache[iid] = cached
    return cached
  end

  for ownerName in pairs(activeRoster) do
    local items = self:_GetCompetitivePlayerLootItems(ownerName)
    if type(items) == "table" then
      for _, it in ipairs(items) do
        local iid = tonumber(it and it.itemID)
        local itemCount = tonumber(it and it.count) or 0
        if iid and itemCount > 0 then
          local itemValue = tonumber(it and it.value) or 0
          local hostPricedSession = tostring(self._compSessionId or "") ~= ""
          if itemValue <= 0 and (not hostPricedSession) and WF and WF.Price and WF.Price.GetItemValue then
            itemValue = (tonumber(WF.Price:GetItemValue(iid)) or 0) * itemCount
          end

          local meta = GetItemMeta(iid)
          local bucket = itemBuckets[iid]
          if not bucket then
            bucket = {
              itemID = iid,
              itemName = meta.name,
              itemNameLower = meta.nameLower,
              itemLink = meta.link,
              itemIcon = meta.icon,
              isMaterial = meta.isMaterial,
              count = 0,
              value = 0,
              owners = {},
              ownerList = {},
              flatRows = {},
            }
            itemBuckets[iid] = bucket
          end

          bucket.count = bucket.count + itemCount
          bucket.value = bucket.value + itemValue

          local owner = bucket.owners[ownerName]
          if not owner then
            owner = {
              name = ownerName,
              pretty = PrettyName(ownerName),
              prettyLower = Lower(PrettyName(ownerName)),
              count = 0,
              value = 0,
            }
            bucket.owners[ownerName] = owner
            bucket.ownerList[#bucket.ownerList + 1] = owner
          end

          owner.count = owner.count + itemCount
          owner.value = owner.value + itemValue

          bucket.flatRows[#bucket.flatRows + 1] = {
            rowType = "flat",
            itemID = iid,
            itemName = meta.name,
            itemNameLower = meta.nameLower,
            itemLink = meta.link,
            itemIcon = meta.icon,
            owner = ownerName,
            ownerPretty = owner.pretty,
            ownerLower = owner.prettyLower,
            sortOwnerLower = owner.prettyLower,
            count = itemCount,
            value = itemValue,
            eachValue = CompetitiveLootSafeEach(itemValue, itemCount),
            isMaterial = meta.isMaterial,
          }
        end
      end
    end
  end

  local topEntries = {}

  for _, bucket in pairs(itemBuckets) do
    table.sort(bucket.ownerList, function(a, b)
      if (a.count or 0) == (b.count or 0) then
        if (a.value or 0) == (b.value or 0) then
          return tostring(a.pretty or a.name or "") < tostring(b.pretty or b.name or "")
        end
        return (a.value or 0) > (b.value or 0)
      end
      return (a.count or 0) > (b.count or 0)
    end)

    local holdersCount = #bucket.ownerList
    if bucket.isMaterial and holdersCount > 1 then
      local ownerNames = {}
      local childRows = {}
      for _, owner in ipairs(bucket.ownerList) do
        ownerNames[#ownerNames + 1] = tostring(owner.pretty or owner.name or "")
        childRows[#childRows + 1] = {
          rowType = "group_child",
          parentItemID = bucket.itemID,
          itemID = bucket.itemID,
          itemName = bucket.itemName,
          itemNameLower = bucket.itemNameLower,
          itemLink = bucket.itemLink,
          itemIcon = bucket.itemIcon,
          owner = owner.name,
          ownerPretty = owner.pretty,
          ownerLower = owner.prettyLower,
          sortOwnerLower = owner.prettyLower,
          count = tonumber(owner.count) or 0,
          value = tonumber(owner.value) or 0,
          eachValue = CompetitiveLootSafeEach(owner.value, owner.count),
          isMaterial = true,
        }
      end

      local holdersLabel = tostring(holdersCount) .. " players"
      local leadOwner = bucket.ownerList[1]
      topEntries[#topEntries + 1] = {
        rowType = "group_parent",
        itemID = bucket.itemID,
        itemName = bucket.itemName,
        itemNameLower = bucket.itemNameLower,
        itemLink = bucket.itemLink,
        itemIcon = bucket.itemIcon,
        ownerPretty = holdersLabel,
        ownerLower = Lower(holdersLabel),
        ownerSearchLower = Lower(table.concat(ownerNames, " ")),
        sortOwnerLower = leadOwner and tostring(leadOwner.prettyLower or "") or Lower(holdersLabel),
        count = tonumber(bucket.count) or 0,
        value = tonumber(bucket.value) or 0,
        eachValue = CompetitiveLootSafeEach(bucket.value, bucket.count),
        holdersCount = holdersCount,
        childRows = childRows,
        expandable = true,
        isExpanded = not not (self._compLootExpanded and self._compLootExpanded[bucket.itemID]),
        isMaterial = true,
      }
    else
      for _, row in ipairs(bucket.flatRows) do
        topEntries[#topEntries + 1] = row
      end
    end
  end

  local filter = ""
  local sortMode = "value"
  local sortDir = "desc"
  if self._comp then
    if type(self._comp.lootFilter) == "string" then
      filter = Trim(self._comp.lootFilter):lower()
    end
    if type(self._comp.lootSortMode) == "string" and self._comp.lootSortMode ~= "" then
      sortMode = self._comp.lootSortMode
    end
    if type(self._comp.lootSortDir) == "string" and self._comp.lootSortDir ~= "" then
      sortDir = self._comp.lootSortDir
    end
  end
  if sortDir ~= "asc" then
    sortDir = "desc"
  end

  local wantNum = filter ~= "" and filter:match("^%d+$")

  local function EntryMatchesFilter(entry)
    if filter == "" then
      return true
    end
    if wantNum then
      return tostring(entry.itemID or ""):find(filter, 1, true) ~= nil
    end

    if tostring(entry.itemNameLower or ""):find(filter, 1, true) ~= nil then
      return true
    end
    if tostring(entry.ownerLower or ""):find(filter, 1, true) ~= nil then
      return true
    end
    if tostring(entry.ownerSearchLower or ""):find(filter, 1, true) ~= nil then
      return true
    end

    return false
  end

  local filteredEntries = {}
  for _, entry in ipairs(topEntries) do
    if EntryMatchesFilter(entry) then
      filteredEntries[#filteredEntries + 1] = entry
    end
  end
  topEntries = filteredEntries

  local function SortOwnerKey(row)
    return tostring(row.sortOwnerLower or row.ownerLower or "")
  end

  local function SortItemKey(row)
    return tostring(row.itemNameLower or "")
  end

  local function CmpNumber(a, b)
    a = tonumber(a) or 0
    b = tonumber(b) or 0
    if a == b then return nil end
    if sortDir == "asc" then
      return a < b
    end
    return a > b
  end

  local function CmpString(a, b)
    a = tostring(a or "")
    b = tostring(b or "")
    if a == b then return nil end
    if sortDir == "asc" then
      return a < b
    end
    return a > b
  end

  local function TieBreak(a, b)
    local av = tonumber(a.value) or 0
    local bv = tonumber(b.value) or 0
    if av ~= bv then return av > bv end

    local ac = tonumber(a.count) or 0
    local bc = tonumber(b.count) or 0
    if ac ~= bc then return ac > bc end

    local an = SortItemKey(a)
    local bn = SortItemKey(b)
    if an ~= bn then return an < bn end

    local ao = SortOwnerKey(a)
    local bo = SortOwnerKey(b)
    if ao ~= bo then return ao < bo end

    return (tonumber(a.itemID) or 0) < (tonumber(b.itemID) or 0)
  end

  table.sort(topEntries, function(a, b)
    local primary

    if sortMode == "name" then
      primary = CmpString(SortItemKey(a), SortItemKey(b))
      if primary ~= nil then return primary end
      local owner = CmpString(SortOwnerKey(a), SortOwnerKey(b))
      if owner ~= nil then return owner end
      return TieBreak(a, b)
    end

    if sortMode == "owner" then
      primary = CmpString(SortOwnerKey(a), SortOwnerKey(b))
      if primary ~= nil then return primary end
      local item = CmpString(SortItemKey(a), SortItemKey(b))
      if item ~= nil then return item end
      return TieBreak(a, b)
    end

    if sortMode == "count" then
      primary = CmpNumber(a.count, b.count)
      if primary ~= nil then return primary end
      return TieBreak(a, b)
    end

    if sortMode == "each" then
      primary = CmpNumber(a.eachValue, b.eachValue)
      if primary ~= nil then return primary end
      return TieBreak(a, b)
    end

    primary = CmpNumber(a.value, b.value)
    if primary ~= nil then return primary end
    return TieBreak(a, b)
  end)

  local out = {}
  for _, entry in ipairs(topEntries) do
    out[#out + 1] = entry
    if entry.rowType == "group_parent" and entry.isExpanded and type(entry.childRows) == "table" then
      local showAllChildren = EntryMatchesFilter(entry)
      for _, child in ipairs(entry.childRows) do
        if showAllChildren or EntryMatchesFilter(child) then
          out[#out + 1] = child
        end
      end
    end
  end

  return out
end

function UI:_PrimeCompetitiveComm()
  if self._compCommPrimed then
    return
  end
  self._compCommPrimed = true

  local function boot()
    if UI and UI._EnsureCompetitiveComm then
      UI:_EnsureCompetitiveComm()
    end
  end

  boot()
  if C_Timer and C_Timer.After then
    C_Timer.After(1, boot)
  end
end

if WF and WF.Events and WF.Events.On then
  WF.Events:On("PLAYER_LOGIN", function()
    local restored = false
    if UI and UI._PrimeCompetitiveComm then
      UI:_PrimeCompetitiveComm()
    elseif UI and UI._EnsureCompetitiveComm then
      UI:_EnsureCompetitiveComm()
    end

    if UI and UI._RestoreCompetitiveRuntimeState then
      restored = UI:_RestoreCompetitiveRuntimeState() and true or false
    end

    if restored and C_Timer and C_Timer.After then
      C_Timer.After(1, function()
        if UI and UI.RefreshCompetitivePanel then
          UI:RefreshCompetitivePanel(true)
        end
        if UI and UI.RefreshCompetitiveLootPanel then
          UI:RefreshCompetitiveLootPanel(true)
        end
      end)
    end
  end)

  WF.Events:On("PLAYER_LOGOUT", function()
    if UI and UI._PersistCompetitiveRuntimeState then
      UI:_PersistCompetitiveRuntimeState()
    end
  end)
end

function UI:RefreshCompetitiveLootPanel(force)
  if not (self._comp and self._comp.lootRows) then return end
  if not (self.compLootPanel and self.compLootPanel.IsShown and self.compLootPanel:IsShown()) then return end

  self:_EnsureCompetitiveComm()
  self:_EnsureCompetitiveState()
  self:_SyncCompetitiveLocalLootFromTracker(false)
  if self._compSyncLootLayout then
    self._compSyncLootLayout()
  end

  local expandedKeys = {}
  for iid in pairs(self._compLootExpanded or {}) do
    expandedKeys[#expandedKeys + 1] = tonumber(iid) or 0
  end
  table.sort(expandedKeys)
  local expandedState = table.concat(expandedKeys, ",")

  local renderState = tostring(self._comp.lootFilter or "") .. "" .. tostring(self._comp.lootSortMode or "value") .. "" .. tostring(self._comp.lootSortDir or "desc") .. "" .. expandedState
  if not force and not self._compLootDirty and renderState == tostring(self._compLastLootRenderState or "") then
    return
  end

  local meKey = self:_CanonCompName(self:_GetLocalPlayerTag())
  local allowLocalFallback = (not self:_HasCompetitivePeers()) or self:_IsCompetitiveLeader() or not self._compSessionId
  if allowLocalFallback and not (self._compLoot[meKey] and self._compLoot[meKey].items and #self._compLoot[meKey].items > 0) then
    local lootStr = self:_GetCompetitiveLocalLootSummary(12, 220)
    self._compLoot[meKey] = self._compLoot[meKey] or {}
    self._compLoot[meKey].items = self:_ParseCompetitiveLootString(lootStr)
    self._compLoot[meKey].updatedAt = SafeNow()
  end

  local list = self:_GetCompetitiveOverallLootList()
  if self._compEnsureLootRows then
    self._compEnsureLootRows(math.max(#list, tonumber(self._comp.lootMinRows) or 40))
  end

  local rows = self._comp.lootRows or {}
  local rowPitch = tonumber(self._comp.lootRowPitch) or 24
  local needH = (#list * rowPitch) + 2
  if self._comp.lootChild and self._comp.lootChild.SetHeight then
    self._comp.lootChild:SetHeight(math.max(needH, 1))
  end

  for i = 1, #rows do
    local r = rows[i]
    local e = list[i]
    if e then
      local link = e.itemLink or select(2, GetItemInfo(e.itemID))
      local baseName = tostring(e.itemName or "") ~= "" and tostring(e.itemName)
        or (type(link) == "string" and link:match("%[(.-)%]"))
        or ("Item " .. tostring(e.itemID))
      local count = tonumber(e.count) or 0
      local totalValue = tonumber(e.value) or 0
      local eachValue = tonumber(e.eachValue) or 0
      local icon = e.itemIcon or select(10, GetItemInfo(e.itemID))
      local rowType = tostring(e.rowType or "flat")
      local isParent = rowType == "group_parent"
      local isChild = rowType == "group_child"
      local displayName = baseName
      local displayWho = e.ownerPretty or "—"
      local ownersTooltipTitle = nil
      local ownersTooltipText = nil

      if isParent then
        displayName = "|cff55b8ff*|r " .. baseName
        ownersTooltipTitle = "Grouped loot"
        ownersTooltipText = "Multiple players have obtained this item.\nTo keep the loot window clean, click the blue asterisk to see who owns what."
      elseif isChild then
        displayName = ">"
      end

      r.frame._wfLootItemID = e.itemID
      r.frame._wfLootOwnersText = tostring(e.ownerPretty or e.owner or "")
      r.frame._wfLootOwnersTooltipText = ownersTooltipText
      r.frame._wfLootOwnersTooltipTitle = ownersTooltipTitle
      r.frame._wfLootCount = count
      r.frame._wfLootEachValue = eachValue
      r.frame._wfLootTotalValue = totalValue
      r.frame._wfLootCompetitionCount = nil
      r.frame._wfLootCompetitionTotalValue = nil
      r.frame._wfLootExpandable = isParent and true or nil
      r.frame._wfLootExpanded = isParent and (e.isExpanded and true or false) or nil
      r.frame._wfLootRowType = rowType
      r.frame._wfLootParentItemID = tonumber(e.parentItemID) or nil

      if r.icon then
        if isChild then
          r.icon:SetTexture(nil)
          r.icon:Hide()
        elseif icon then
          r.icon:SetTexture(icon)
          r.icon:Show()
        else
          r.icon:SetTexture(nil)
          r.icon:Hide()
        end
      end

      if r.bg then
        if Theme and Theme.panel2 then
          if isChild then
            r.bg:SetColorTexture(Theme.panel2[1], Theme.panel2[2], Theme.panel2[3], 0.12)
          elseif isParent then
            r.bg:SetColorTexture(Theme.panel2[1], Theme.panel2[2], Theme.panel2[3], 0.30)
          else
            local baseA = (i % 2 == 0) and 0.40 or 0.22
            r.bg:SetColorTexture(Theme.panel2[1], Theme.panel2[2], Theme.panel2[3], baseA)
          end
        else
          if isChild then
            r.bg:SetColorTexture(1, 1, 1, 0.02)
          elseif isParent then
            r.bg:SetColorTexture(1, 1, 1, 0.08)
          else
            r.bg:SetColorTexture(1, 1, 1, (i % 2 == 0) and 0.06 or 0.03)
          end
        end
      end

      if r.name and r.name.ClearAllPoints then
        r.name:ClearAllPoints()
        if isChild and r.who then
          r.name:SetPoint("RIGHT", r.who, "LEFT", -28, 0)
          r.name:SetWidth(22)
          if r.name.SetJustifyH then
            r.name:SetJustifyH("CENTER")
          end
        else
          if r.icon then
            r.name:SetPoint("LEFT", r.icon, "RIGHT", 8, 0)
          end
          if r.who then
            r.name:SetPoint("RIGHT", r.who, "LEFT", -10, 0)
          end
          if r.name.SetJustifyH then
            r.name:SetJustifyH("LEFT")
          end
        end
      elseif r.name and r.name.SetJustifyH then
        r.name:SetJustifyH(isChild and "CENTER" or "LEFT")
      end
      r.name:SetText(displayName)
      if isChild then
        r.name:SetTextColor(1, 1, 1, 1)
      elseif WF and WF.DB and WF.DB.GetSetting and WF.DB:GetSetting("colorByQuality") then
        local rr, gg, bb = Util:GetQualityColor(link or e.itemID)
        r.name:SetTextColor(rr, gg, bb)
      elseif Theme and Theme.text then
        r.name:SetTextColor(Theme.text[1], Theme.text[2], Theme.text[3], Theme.text[4] or 1)
      else
        r.name:SetTextColor(1, 1, 1, 1)
      end

      if r.who then
        r.who:SetText(displayWho)
        if r.who.SetTextColor then
          if Theme and Theme.muted then
            r.who:SetTextColor(Theme.muted[1], Theme.muted[2], Theme.muted[3], Theme.muted[4] or 1)
          else
            r.who:SetTextColor(0.65, 0.65, 0.65, 1)
          end
        end
      end
      if r.count then r.count:SetText(tostring(count)) end
      if r.each then r.each:SetText(Util:FormatMoney(eachValue)) end
      if r.total then r.total:SetText(Util:FormatMoney(totalValue)) end
      if r.frame.Show then r.frame:Show() end
    else
      if r.icon then
        r.icon:SetTexture(nil)
        r.icon:Hide()
      end
      r.frame._wfLootItemID = nil
      r.frame._wfLootOwnersText = nil
      r.frame._wfLootOwnersTooltipText = nil
      r.frame._wfLootOwnersTooltipTitle = nil
      r.frame._wfLootCount = nil
      r.frame._wfLootEachValue = nil
      r.frame._wfLootTotalValue = nil
      r.frame._wfLootCompetitionCount = nil
      r.frame._wfLootCompetitionTotalValue = nil
      r.frame._wfLootExpandable = nil
      r.frame._wfLootExpanded = nil
      r.frame._wfLootRowType = nil
      r.frame._wfLootParentItemID = nil
      if r.bg then
        if Theme and Theme.panel2 then
          local baseA = (i % 2 == 0) and 0.40 or 0.22
          r.bg:SetColorTexture(Theme.panel2[1], Theme.panel2[2], Theme.panel2[3], baseA)
        else
          r.bg:SetColorTexture(1, 1, 1, (i % 2 == 0) and 0.06 or 0.03)
        end
      end
      r.name:SetText("")
      if r.who then r.who:SetText("") end
      if r.count then r.count:SetText("") end
      if r.each then r.each:SetText("") end
      if r.total then r.total:SetText("") end
      if r.frame.Hide then r.frame:Hide() end
    end
  end

  if self._comp and self._comp.refreshLootHeaderState then
    self._comp.refreshLootHeaderState()
  end

  self._compLastLootRenderState = renderState
  self._compLootDirty = false
end

function UI:ShowCompetitiveTooltip(rowFrame)
  if not (rowFrame and rowFrame._wfCompName) then return end
  if not GameTooltip then return end

  self:_EnsureCompetitiveState()

  local nameKey = self:_CanonCompName(rowFrame._wfCompName)
  if nameKey == "" then return end

  if Widgets and Widgets.EnsureTooltipSkin then Widgets:EnsureTooltipSkin() end
  GameTooltip:SetOwner(rowFrame, "ANCHOR_RIGHT")
  GameTooltip:ClearLines()

  local pal = (Widgets and Widgets.GetTooltipPalette and Widgets:GetTooltipPalette()) or {
    title = { 0.35, 0.78, 1.00, 1.00 },
    body = { 0.92, 0.92, 0.92, 1.00 },
    muted = { 0.65, 0.65, 0.65, 1.00 },
  }

  GameTooltip:AddLine("Competitive", pal.title[1], pal.title[2], pal.title[3], true)
  GameTooltip:AddLine(PrettyName(nameKey), pal.body[1], pal.body[2], pal.body[3], true)

  local m = self._compMeta and self._compMeta[nameKey]
  if m then
    if m.zone and m.zone ~= "" then
      GameTooltip:AddDoubleLine("Zone", tostring(m.zone), pal.muted[1], pal.muted[2], pal.muted[3], pal.body[1], pal.body[2], pal.body[3])
    end
    if m.profile and m.profile ~= "" then
      GameTooltip:AddDoubleLine("Profile", DisplayProfileName(m.profile), pal.muted[1], pal.muted[2], pal.muted[3], pal.body[1], pal.body[2], pal.body[3])
    end
    if m.priceProvider and m.priceProvider ~= "" then
      GameTooltip:AddDoubleLine("Price", tostring(m.priceProvider), pal.muted[1], pal.muted[2], pal.muted[3], pal.body[1], pal.body[2], pal.body[3])
    end

    local age = 0
    if m.updatedAt then
      age = SafeNow() - (tonumber(m.updatedAt) or 0)
      if age < 0 then age = 0 end
    end
    if age > 0 then
      GameTooltip:AddDoubleLine("Updated", string.format("%.0fs ago", age), pal.muted[1], pal.muted[2], pal.muted[3], pal.body[1], pal.body[2], pal.body[3])
    end

    if m.items and type(m.items) == "table" and #m.items > 0 then
      GameTooltip:AddLine(" ")
      GameTooltip:AddLine("Top Loot", pal.title[1], pal.title[2], pal.title[3], true)
      for _, it in ipairs(m.items) do
        local iid = tonumber(it.itemID)
        if iid then
          local link = select(2, GetItemInfo(iid))
          local left = link or ("Item " .. tostring(iid))
          local cnt = tonumber(it.count) or 0
          local val = tonumber(it.value) or 0
          GameTooltip:AddDoubleLine(left, "x" .. tostring(cnt) .. "  •  " .. Util:FormatMoney(val), pal.body[1], pal.body[2], pal.body[3], pal.muted[1], pal.muted[2], pal.muted[3])
        end
      end
    end
  else
    GameTooltip:AddLine("No loot data yet.", pal.muted[1], pal.muted[2], pal.muted[3], true)
  end

  GameTooltip:Show()
end


-- ---------------------------------------------------------
-- Competitive controls
--   • Host can pause/resume the match for everyone.
--   • Any player can quit their own participation.
--   • Host can end the match for everyone while keeping the final board visible.
-- ---------------------------------------------------------

function UI:_EndCompetitiveFarmRun()
  if self.IsPaused and self:IsPaused() and self.FinalizePauseAccounting then
    pcall(function() self:FinalizePauseAccounting() end)
  end

  local active = false
  if Tracker and Tracker.IsActive and Tracker:IsActive() then
    active = true
  elseif self.IsPaused and self:IsPaused() then
    active = true
  end

  if active and Tracker and Tracker.Stop then
    pcall(function() Tracker:Stop(true) end)
  elseif active and Tracker and Tracker.End then
    pcall(function() Tracker:End() end)
  elseif active and Tracker and Tracker.Finish then
    pcall(function() Tracker:Finish() end)
  end

  if type(self.RefreshTitle) == "function" then pcall(function() self:RefreshTitle() end) end
  if type(self.RefreshMetrics) == "function" then pcall(function() self:RefreshMetrics() end) end
  if type(self.RefreshLoot) == "function" then pcall(function() self:RefreshLoot(true) end) end
end

function UI:_ResetFarmWindowAfterCompetitiveEnd()
  if self.ClearPauseState then
    pcall(function() self:ClearPauseState(true) end)
  end

  self._pendingFarm = nil
  self._lastStoppedSession = nil

  if Tracker and Tracker.Reset then
    pcall(function() Tracker:Reset() end)
  elseif Tracker then
    Tracker.current = nil
    if Tracker.MarkDirty then
      pcall(function() Tracker:MarkDirty() end)
    end
  end

  if self.IsCompareOpen and self:IsCompareOpen() and self.RefreshComparePanel then
    pcall(function() self:RefreshComparePanel() end)
  end

  if type(self.RefreshTitle) == "function" then pcall(function() self:RefreshTitle() end) end
  if type(self.RefreshMetrics) == "function" then pcall(function() self:RefreshMetrics() end) end
  if type(self.RefreshLoot) == "function" then pcall(function() self:RefreshLoot(true) end) end
end

function UI:ToggleCompetitivePause()
  self:_EnsureCompetitiveState()
  if not self:_IsCompetitiveLeader() then
    Notify("What Farm?: Only the host can pause or resume the match.")
    return
  end

  local sid = tostring(self._compSessionId or "")
  if sid == "" then return end

  local sess = self._compSession or {}
  local state = tostring(sess.state or "idle")

  if state == "live" then
    local now = SafeNow()
    local pausedTotal = tonumber(sess.pausedTotal) or 0
    self:_SetCompetitiveSessionState("paused", sid, self._compStartAt, now, pausedTotal, 0)
    if self.Pause and not (self.IsPaused and self:IsPaused()) then
      pcall(function() self:Pause() end)
    end
    self:_StopCompetitiveTicker()
    self:_SendCompetitivePeerMessage("PAUSE|" .. sid .. "|" .. tostring(now) .. "|" .. tostring(pausedTotal) .. "|")
    Notify("What Farm?: Competitive paused.")
  elseif state == "paused" then
    local now = SafeNow()
    local pausedAt = tonumber(sess.pausedAt) or now
    local pausedTotal = (tonumber(sess.pausedTotal) or 0) + math.max(0, now - pausedAt)
    self:_SetCompetitiveSessionState("live", sid, self._compStartAt, 0, pausedTotal, 0)
    if self.Resume and self.IsPaused and self:IsPaused() then
      pcall(function() self:Resume() end)
    end
    self:_EnsureCompetitiveTicker()
    self:_SendCompetitivePeerMessage("RESUME|" .. sid .. "|" .. tostring(now) .. "|" .. tostring(pausedTotal) .. "|")
    Notify("What Farm?: Competitive resumed.")
  else
    Notify("What Farm?: The match is not in a pausable state.")
    return
  end

  if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(true) end
  if self.RefreshCompetitiveLootPanel then self:RefreshCompetitiveLootPanel(true) end
end

function UI:QuitCompetitiveMatch()
  self:_EnsureCompetitiveState()
  local sid = tostring(self._compSessionId or "")
  if sid == "" then return end

  local me = self:_CanonCompName(self:_GetLocalPlayerTag())
  local isLeader = self:_IsCompetitiveLeader()

  if tostring((self._compSession and self._compSession.state) or "idle") == "live" then
    if isLeader then
      self:_RebuildCompetitiveSelfAuthoritativeState(true)
    else
      self:_SendCompetitiveFilteredCountDeltas(false)
    end
  end

  self:_EndCompetitiveFarmRun()
  self:_RestoreCompetitiveRules()

  if isLeader then
    local endedAt = SafeNow()
    local pausedTotal = tonumber(self._compSession and self._compSession.pausedTotal) or 0
    self:_SetCompetitiveSessionState("ended", sid, self._compStartAt, 0, pausedTotal, endedAt)
    self:_StopCompetitiveTicker()
    self:_SendCompetitivePeerMessage("END|" .. sid .. "|" .. tostring(endedAt) .. "|" .. tostring(pausedTotal) .. "|")
    self:_ResetFarmWindowAfterCompetitiveEnd()
    Notify("What Farm?: Match ended.")
  else
    self:_SendCompetitivePeerMessage("LEAVE|" .. sid .. "|" .. me .. "||")
    self:_ResetFarmWindowAfterCompetitiveEnd()
    self:ResetCompetitiveSession()
    Notify("What Farm?: You left the match.")
  end

  if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(true) end
  if self.RefreshCompetitiveLootPanel then self:RefreshCompetitiveLootPanel(true) end
end

function UI:_ClearCompetitiveSessionNow()
  self:_EnsureCompetitiveState()
  self:_RestoreCompetitiveRules()

  self._compResetArmedUntil = 0
  self._compFrozen = false
  self._compSessionId = nil
  self._compLeader = nil
  self._compStarted = false
  self._compStartAt = nil
  self._compBaseValue = nil
  self._compBaseElapsed = nil
  self._compRequiredProfile = nil
  self._compHostPriceProvider = ""
  self._compSavedActiveProfile = ""
  self._compRaceTimerSeconds = nil
  self._compLastRaceTimerSentSeconds = nil
  self._compLastRaceTimerSentAt = nil
  self._compPeers = {}
  self._compInvited = {}
  self._compAlias = {}
  self._compScores = {}
  self._compMeta = {}
  self._compLoot = {}
  self._compLootCounts = {}
  self._compScoredLootCounts = {}
  self._compPlayerState = {}
  self._compTrackedBaseCounts = {}
  self._compLastSentTrackedCounts = {}
  self._compDeltaInbox = {}
  self._compDeltaSeq = 0
  self._compLastSnapshotSendAt = 0
  self._compLastSnapshotPayload = ""
  self._compLastMetaSendAt = 0
  self._compLastGcStepAt = 0
  self._compSession = { id = "", host = "", state = "idle", startAt = 0, pausedAt = 0, pausedTotal = 0, endAt = 0, revision = 0 }
  self._compLastLocalTrackedPayload = ""
  self._compLootDirty = true
  self._compLastLootRenderState = ""
  self._compLastTrackerRepriceAt = 0

  self:_ResetCompetitiveLocalLoot()
  self:_StopCompetitiveTicker()
  self:_ClearPersistedCompetitiveRuntimeState()

  if self._RefreshMainSessionControlLocks then
    self:_RefreshMainSessionControlLocks()
  end
end

function UI:ResetCompetitiveSession()
  self:_ClearCompetitiveSessionNow()
  if self.RefreshCompetitivePanel then self:RefreshCompetitivePanel(true) end
  if self.RefreshCompetitiveLootPanel then self:RefreshCompetitiveLootPanel(true) end
end
