-------------------------------------------------------------------------------
-- RealNibba's Raid Tools -- MS_Changes.lua
-- Main Spec Change tracker.  UI built identically to AutoInvite.lua.
-------------------------------------------------------------------------------

local AceGUI = LibStub("AceGUI-3.0")

local msIsOpen  = false
msChanges = {}   -- [name] = "Spec" (global for Mini RM display)

-- Spec word aliases: maps every abbreviation to a canonical spec name.
-- Multi-word keys (2 words) are checked first; single-word after.
-- "Tank" and "Healer" are generic roles resolved per-class in the whisper handler.
local SPEC_WORD_MAP = {
    -- ── Paladin ──────────────────────────────────────────────────────────────
    ["holy"]           = "Holy",
    ["ret"]            = "Retribution",
    ["retri"]          = "Retribution",
    ["retribution"]    = "Retribution",
    ["prot"]           = "Protection",
    ["protection"]     = "Protection",
    -- ── Warrior ──────────────────────────────────────────────────────────────
    ["fury"]           = "Fury",
    ["arms"]           = "Arms",
    -- ── Hunter ───────────────────────────────────────────────────────────────
    ["mm"]             = "Marksmanship",
    ["marks"]          = "Marksmanship",
    ["marksmanship"]   = "Marksmanship",
    ["survival"]       = "Survival",
    ["surv"]           = "Survival",
    ["sv"]             = "Survival",
    ["bm"]             = "Beast Mastery",
    ["beast"]          = "Beast Mastery",
    ["beastmastery"]   = "Beast Mastery",
    ["beast mastery"]  = "Beast Mastery",
    -- ── Shaman ───────────────────────────────────────────────────────────────
    ["ele"]            = "Elemental",
    ["elem"]           = "Elemental",
    ["elemental"]      = "Elemental",
    ["enh"]            = "Enhancement",
    ["enha"]           = "Enhancement",
    ["encha"]          = "Enhancement",
    ["enhance"]        = "Enhancement",
    ["enhancement"]    = "Enhancement",
    ["spellhancement"] = "Enhancement",
    -- ── Warlock ──────────────────────────────────────────────────────────────
    ["affli"]          = "Affliction",
    ["afflic"]         = "Affliction",
    ["affliction"]     = "Affliction",
    ["demo"]           = "Demonology",
    ["demonology"]     = "Demonology",
    ["destro"]         = "Destruction",
    ["destruction"]    = "Destruction",
    -- ── Druid ────────────────────────────────────────────────────────────────
    ["feral"]          = "Feral Cat",
    ["cat"]            = "Feral Cat",
    ["feral cat"]      = "Feral Cat",
    ["kitty"]          = "Feral Cat",
    ["bear"]           = "Feral Bear",
    ["bear tank"]      = "Feral Bear",
    ["feral bear"]     = "Feral Bear",
    ["balance"]        = "Balance",
    ["boomie"]         = "Balance",
    ["boomkin"]        = "Balance",
    ["boom"]           = "Balance",
    ["moonkin"]        = "Balance",
    ["tree"]           = "Restoration",
    -- ── Rogue ────────────────────────────────────────────────────────────────
    ["combat"]         = "Combat",
    ["assassination"]  = "Assassination",
    ["assa"]           = "Assassination",
    ["sin"]            = "Assassination",
    ["mut"]            = "Assassination",
    ["mutilate"]       = "Assassination",
    ["sub"]            = "Subtlety",
    ["subtlety"]       = "Subtlety",
    -- ── Priest ───────────────────────────────────────────────────────────────
    ["disc"]           = "Discipline",
    ["disco"]          = "Discipline",
    ["discipline"]     = "Discipline",
    ["shadow"]         = "Shadow",
    ["sp"]             = "Shadow",
    ["shp"]            = "Shadow",
    ["spriest"]        = "Shadow",
    -- ── Death Knight ─────────────────────────────────────────────────────────
    ["blood dps"]      = "Blood DPS",
    ["blood tank"]     = "Blood Tank",
    ["bdk"]            = "Blood Tank",
    ["unholy"]         = "Unholy",
    ["unholy dk"]      = "Unholy",
    ["udk"]            = "Unholy",
    ["frost"]          = "Frost",
    ["frost dk"]       = "Frost",
    ["frost dw"]       = "Frost",
    ["dw frost"]       = "Frost",
    ["fdk"]            = "Frost",
    -- ── Mage ─────────────────────────────────────────────────────────────────
    ["arcane"]         = "Arcane",
    ["fire"]           = "Fire",
    ["frostfire"]      = "Fire",
    -- ── Shared / Restoration (Druid+Shaman+Paladin+Priest) ───────────────────
    ["resto"]          = "Restoration",
    ["restro"]         = "Restoration",
    ["restoration"]    = "Restoration",
    -- ── Generic roles (resolved per-class in whisper handler) ─────────────────
    ["tank"]           = "Tank",
    ["healer"]         = "Healer",
    ["heal"]           = "Healer",
    ["dps"]            = "DPS",
}

-- Valid canonical specs per class
local CLASS_VALID_SPECS = {
    ["WARRIOR"]     = { "Arms", "Fury", "Protection" },
    ["PALADIN"]     = { "Holy", "Protection", "Retribution" },
    ["HUNTER"]      = { "Beast Mastery", "Marksmanship", "Survival" },
    ["ROGUE"]       = { "Assassination", "Combat", "Subtlety" },
    ["PRIEST"]      = { "Discipline", "Holy", "Shadow" },
    ["DEATHKNIGHT"] = { "Blood Tank", "Blood DPS", "Frost", "Unholy" },
    ["SHAMAN"]      = { "Elemental", "Enhancement", "Restoration" },
    ["MAGE"]        = { "Arcane", "Fire", "Frost" },
    ["WARLOCK"]     = { "Affliction", "Demonology", "Destruction" },
    ["DRUID"]       = { "Balance", "Feral Cat", "Feral Bear", "Restoration" },
}

-- "tank" auto-resolves to the class's tank spec
local CLASS_TANK_SPEC = {
    ["WARRIOR"]     = "Protection",
    ["PALADIN"]     = "Protection",
    ["DEATHKNIGHT"] = "Blood Tank",
    ["DRUID"]       = "Feral Bear",
}

-- "heal"/"healer" auto-resolves to the class's healer spec
local CLASS_HEALER_SPEC = {
    ["PALADIN"]  = "Holy",
    ["SHAMAN"]   = "Restoration",
    ["DRUID"]    = "Restoration",
    ["PRIEST"]   = "Holy",
}

-- "dps" auto-resolves only for classes where it's unambiguous
local CLASS_DPS_SPEC = {
    ["DRUID"] = "Feral Cat",
}

-- Get class for a player name from the raid roster (no range requirement)
local function GetRaidMemberClass(name)
    local n = GetNumRaidMembers()
    for i = 1, n do
        local rName, _, _, _, _, rClass = GetRaidRosterInfo(i)
        if rName and rName:lower() == name:lower() then
            return rClass  -- e.g. "WARRIOR", "PALADIN"
        end
    end
    return nil
end

-- Check if a normalized spec is valid for a given class
-- Returns true/false, and a display string of valid specs for that class
local function IsSpecValidForClass(spec, class)
    local valid = CLASS_VALID_SPECS[class]
    if not valid then return true, nil end  -- unknown class, allow anything
    local specLo = spec:lower()
    for _, v in ipairs(valid) do
        if v:lower() == specLo then
            return true, nil
        end
    end
    return false, table.concat(valid, ", ")
end

local function NormalizeSpec(raw)
    if not raw then return nil end
    raw = raw:match("^%s*(.-)%s*$")
    if raw == "" then return nil end
    local lo = string.lower(raw)

    -- Strip MS/OS/FS prefixes: "MS Resto Sham" -> "Resto Sham"
    lo = lo:gsub("^ms%s+", ""):gsub("^os%s+", ""):gsub("^fs%s+", "")
    lo = lo:match("^%s*(.-)%s*$")

    local words = {}
    for w in lo:gmatch("%S+") do words[#words+1] = w end

    if #words >= 2 then
        -- Multi-word input: ONLY accept known 2-word combos, nothing else.
        -- This prevents "blood frost", "blood frost unholy", etc. from
        -- falling through to the first-word lookup and silently matching.
        local twoWord = words[1].." "..words[2]
        if SPEC_WORD_MAP[twoWord] then return SPEC_WORD_MAP[twoWord] end
        return nil
    end

    -- Single word: must be an exact known alias
    return SPEC_WORD_MAP[words[1]] or nil
end

function RNTools_IsMSChangesOpen()
    return msIsOpen
end

-------------------------------------------------------------------------------
-- Colour palette and helpers  (identical to AutoInvite)
-------------------------------------------------------------------------------
local C = {
    bg       = { 0.08, 0.08, 0.08, 0.00 },
    header   = { 0.16, 0.14, 0.11, 1    },
    border   = { 0.55, 0.50, 0.42, 0.8  },
    title    = { 1.00, 0.82, 0.00, 1    },
    accent   = { 0.20, 1.00, 0.60, 1    },
    text     = { 0.90, 0.90, 0.90, 1    },
    dim      = { 0.60, 0.60, 0.60, 1    },
    red      = { 1.00, 0.30, 0.30, 1    },
    btn_n    = { 0.16, 0.14, 0.11, 1    },
    btn_h    = { 0.28, 0.24, 0.18, 1    },
    btn_p    = { 0.08, 0.08, 0.08, 1    },
    row_odd  = { 0.10, 0.10, 0.10, 0.90 },
    row_even = { 0.13, 0.13, 0.13, 0.90 },
    green_bg = { 0.04, 0.20, 0.08, 1    },
    red_bg   = { 0.22, 0.04, 0.04, 1    },
}

local function MSSetBG(f, r, g, b, a)
    if not f._bgTex then
        f._bgTex = f:CreateTexture(nil, "BACKGROUND")
        f._bgTex:SetAllPoints(f)
    end
    f._bgTex:SetTexture(r, g, b, a)
end

local function MSBorder(f, r, g, b, a)
    for _, v in ipairs({
        {"TOPLEFT","TOPRIGHT","SetHeight",1},
        {"BOTTOMLEFT","BOTTOMRIGHT","SetHeight",1},
        {"TOPLEFT","BOTTOMLEFT","SetWidth",1},
        {"TOPRIGHT","BOTTOMRIGHT","SetWidth",1},
    }) do
        local t = f:CreateTexture(nil, "OVERLAY")
        t:SetPoint(v[1]); t:SetPoint(v[2])
        t[v[3]](t, v[4])
        t:SetTexture(r, g, b, a)
    end
end

local function MSMakeBtn(par, lbl, w, h)
    local btn = CreateFrame("Button", nil, par)
    btn:SetSize(w or 120, h or 24)
    MSSetBG(btn, unpack(C.btn_n))
    MSBorder(btn, unpack(C.border))
    local fs = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    fs:SetAllPoints(btn); fs:SetText(lbl); fs:SetTextColor(unpack(C.title))
    btn:SetScript("OnEnter",     function() MSSetBG(btn, unpack(C.btn_h)) end)
    btn:SetScript("OnLeave",     function() MSSetBG(btn, unpack(C.btn_n)) end)
    btn:SetScript("OnMouseDown", function() MSSetBG(btn, unpack(C.btn_p)) end)
    btn:SetScript("OnMouseUp",   function() MSSetBG(btn, unpack(C.btn_h)) end)
    btn._label = fs
    return btn
end

local function MSHeading(text)
    local h = AceGUI:Create("Heading")
    h:SetText(text)
    h:SetFullWidth(true)
    return h
end

-------------------------------------------------------------------------------
-- Autocomplete dropdown helper
-- Attaches a small suggestion list under an AceGUI EditBox, refreshed on
-- OnTextChanged via getSuggestions(text) -> array of strings.  Clicking a
-- suggestion sets the EditBox text and hides the list.
-------------------------------------------------------------------------------
local function MSCreateAutocomplete(editBoxWidget, parentFrame, getSuggestions)
    -- Parented OUTSIDE the scrollframe (to the module's top-level frame, not
    -- editBoxWidget.frame) so it isn't clipped by the ScrollFrame's scissor
    -- rect - ScrollFrame clipping applies to all descendants of the scroll
    -- child regardless of frame strata. SetPoint anchoring to
    -- editBoxWidget.frame below still works fine across different parents.
    local dd = CreateFrame("Frame", nil, parentFrame)
    dd:SetFrameStrata("TOOLTIP")
    MSSetBG(dd, unpack(C.btn_n))
    MSBorder(dd, unpack(C.border))
    dd:Hide()
    dd.buttons = {}

    local ROW_H = 18
    local MAX_ROWS = 6

    local function UpdateList(text)
        text = text or ""
        local items = (text ~= "") and getSuggestions(text) or nil
        if not items or #items == 0 then
            dd:Hide()
            return
        end
        local n = math.min(#items, MAX_ROWS)
        dd:ClearAllPoints()
        dd:SetPoint("TOPLEFT", editBoxWidget.frame, "BOTTOMLEFT", 0, -2)
        dd:SetPoint("TOPRIGHT", editBoxWidget.frame, "BOTTOMRIGHT", 0, -2)
        dd:SetHeight(n * ROW_H + 4)

        for i = 1, n do
            local b = dd.buttons[i]
            if not b then
                b = CreateFrame("Button", nil, dd)
                b:SetHeight(ROW_H)
                local fs = b:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
                fs:SetPoint("LEFT", 4, 0)
                fs:SetPoint("RIGHT", -4, 0)
                fs:SetJustifyH("LEFT")
                fs:SetTextColor(unpack(C.text))
                b._label = fs
                local hl = b:CreateTexture(nil, "HIGHLIGHT")
                hl:SetAllPoints(b)
                hl:SetTexture(1, 1, 1, 0.15)
                b:SetScript("OnClick", function(self)
                    editBoxWidget:SetText(self._value)
                    dd:Hide()
                end)
                dd.buttons[i] = b
            end
            b:ClearAllPoints()
            b:SetPoint("TOPLEFT", dd, "TOPLEFT", 2, -2 - (i - 1) * ROW_H)
            b:SetPoint("TOPRIGHT", dd, "TOPRIGHT", -2, -2 - (i - 1) * ROW_H)
            b._label:SetText(items[i])
            b._value = items[i]
            b:Show()
        end
        for i = n + 1, #dd.buttons do
            dd.buttons[i]:Hide()
        end
        dd:Show()
    end

    editBoxWidget:SetCallback("OnTextChanged", function(widget, event, text)
        UpdateList(text)
    end)
    editBoxWidget.editbox:HookScript("OnEditFocusLost", function()
        -- delay so a click on a suggestion button registers before we hide
        C_Timer_fake(0.15, function() dd:Hide() end)
    end)
    editBoxWidget.editbox:HookScript("OnEscapePressed", function() dd:Hide() end)

    return dd
end

-- Raid/party roster names (realm-stripped), for the "Player Name" autocomplete
local function MSGetRosterNames()
    local names = {}
    local n = GetNumRaidMembers()
    if n > 0 then
        for i = 1, n do
            local name = GetRaidRosterInfo(i)
            if name then
                names[#names+1] = (name:match("^([^%-]+)") or name)
            end
        end
    else
        for i = 1, GetNumPartyMembers() do
            local name = UnitName("party"..i)
            if name then names[#names+1] = name end
        end
        names[#names+1] = UnitName("player")
    end
    return names
end

local function MSFilterRosterNames(text)
    local lo = text:lower()
    local out = {}
    for _, name in ipairs(MSGetRosterNames()) do
        if name:sub(1, #text):lower() == lo then
            out[#out+1] = name
        end
    end
    table.sort(out)
    return out
end

-- Canonical spec names, for the "Main Spec" autocomplete
local CANONICAL_SPECS = {
    "Tank", "DPS", "Healer", "Melee", "Ranged", "Caster",
    "Holy", "Discipline", "Restoration",
    "Retribution", "Protection", "Fury", "Arms", "Combat", "Assassination", "Subtlety",
    "Feral", "Balance",
    "Frost", "Unholy", "Blood",
    "Arcane", "Fire",
    "Enhancement", "Elemental",
    "Affliction", "Demonology", "Destruction",
    "Marksmanship", "Survival", "Beast Mastery",
}

local function MSFilterSpecs(text)
    local lo = text:lower()
    local out = {}
    for _, spec in ipairs(CANONICAL_SPECS) do
        if spec:sub(1, #text):lower() == lo then
            out[#out+1] = spec
        end
    end
    return out
end

-------------------------------------------------------------------------------
-- Row data
-------------------------------------------------------------------------------
local msRows       = {}
local msRowCont    = nil   -- AceGUI SimpleGroup (List layout) holding row widgets
local msLeftScroll = nil   -- AceGUI ScrollFrame; re-laid-out after row rebuilds

local msStatusFS  = nil   -- updated by whisper listener

local function RefreshMSCount()
    if not msStatusFS then return end
    local n = 0
    for _ in pairs(msChanges) do n = n + 1 end
    msStatusFS:SetText(n .. " change" .. (n == 1 and "" or "s") .. " recorded")
end

local function RebuildMSRows()
    if not msRowCont then return end
    msRowCont:ReleaseChildren()
    msRows = {}

    -- Sorted list
    local sorted = {}
    for name, spec in pairs(msChanges) do
        sorted[#sorted+1] = {name=name, spec=spec}
    end
    table.sort(sorted, function(a,b) return a.name < b.name end)

    for i, entry in ipairs(sorted) do
        local row = AceGUI:Create("SimpleGroup")
        row:SetLayout("Flow")
        row:SetFullWidth(true)

        local nameLbl = AceGUI:Create("Label")
        nameLbl:SetText(entry.name)
        nameLbl:SetRelativeWidth(0.40)
        row:AddChild(nameLbl)

        local specLbl = AceGUI:Create("Label")
        specLbl:SetText(entry.spec)
        specLbl:SetColor(unpack(C.accent))
        specLbl:SetRelativeWidth(0.35)
        row:AddChild(specLbl)

        local capName = entry.name
        local delBtn = AceGUI:Create("Button")
        delBtn:SetText("|cffff4d4dRemove|r")
        delBtn:SetRelativeWidth(0.25)
        delBtn:SetCallback("OnClick", function()
            msChanges[capName] = nil
            RebuildMSRows()
            RefreshMSCount()
        end)
        row:AddChild(delBtn)

        msRowCont:AddChild(row)
        msRows[#msRows+1] = row
    end

    if msLeftScroll then msLeftScroll:DoLayout() end
end

-------------------------------------------------------------------------------
-- Session popup: shown when opening MS Changes with existing data
-- Uses native StaticPopupDialogs — same style as WoW's built-in confirmations
-------------------------------------------------------------------------------
local RefreshDemandBtn  -- forward declaration (defined below)

local MS_pendingConfirm = nil

StaticPopupDialogs["RNTOOLS_MS_SESSION"] = {
    text         = "MS Changes\n%s",
    button1      = "New Session",
    button2      = "Add to Current",
    button3      = "Cancel",
    OnAccept = function()
        msChanges = {}
        RebuildMSRows()
        if MS_pendingConfirm then MS_pendingConfirm() end
        MS_pendingConfirm = nil
    end,
    OnCancel = function()
        if MS_pendingConfirm then MS_pendingConfirm() end
        MS_pendingConfirm = nil
    end,
    OnAlt = function()
        MS_pendingConfirm = nil
    end,
    timeout      = 0,
    whileDead    = true,
    hideOnEscape = true,
    preferredIndex = 3,
}

local function MSOpenListening()
    msIsOpen = true
    RefreshDemandBtn()
    if GetNumRaidMembers() > 0 then
        SendChatMessage(
            "[RealNibba's Raid Tools] Main Spec Changes OPEN! " ..
            "Whisper me your Main Spec change. (Whisper ONLY - not in raid chat!)",
            "RAID_WARNING")
    end
    DEFAULT_CHAT_FRAME:AddMessage("|cff20ff60[MS Changes]:|r Now listening for whispers.")
    RefreshMSCount()
end

local function MSShowSessionPopup(onConfirm)
    local existing = 0
    for _ in pairs(msChanges) do existing = existing + 1 end
    MS_pendingConfirm = onConfirm
    local countText = existing == 1 and "1 change already recorded." or (existing .. " changes already recorded.")
    local infoText  = (existing > 0 and countText .. "\n" or "") .. "Start a new session or add to the current one?"
    StaticPopup_Show("RNTOOLS_MS_SESSION", infoText)
end

-------------------------------------------------------------------------------
-- Main demand/close status button
-------------------------------------------------------------------------------
local msDemandBtn = nil

RefreshDemandBtn = function()
    if not msDemandBtn then return end
    if msIsOpen then
        msDemandBtn._label:SetText("|cff20ff60OPEN|r -- Listening for whispers -- click to CLOSE")
        MSSetBG(msDemandBtn, unpack(C.green_bg))
        MSBorder(msDemandBtn, 0.10, 0.80, 0.30, 1)
    else
        msDemandBtn._label:SetText("|cffff4444CLOSED|r -- Click to DEMAND Main Spec Changes")
        MSSetBG(msDemandBtn, unpack(C.red_bg))
        MSBorder(msDemandBtn, 0.80, 0.20, 0.10, 1)
    end
end

-------------------------------------------------------------------------------
-- Tab content creator  -- same structure as CreateAutoInviteTabContent
-------------------------------------------------------------------------------
function CreateMSChangesTabContent(parent, onClose)
    local f = CreateFrame("Frame", nil, parent)
    f:SetAllPoints(parent)
    f:Hide()
    MSSetBG(f, unpack(C.bg))

    -- Symmetrical 50/50 columns: f is ~1006px wide (1018 - 12px margins on
    -- each side), with a 24px gap (12px either side of the divider) between
    -- the two columns -> (1006 - 24) / 2 = 491 per column.
    local COL_W = 491

    -- Left column: MS Changes
    local leftScroll = AceGUI:Create("ScrollFrame")
    leftScroll:SetLayout("List")
    leftScroll.frame:SetParent(f)
    leftScroll.frame:ClearAllPoints()
    leftScroll.frame:SetPoint("TOPLEFT", f, "TOPLEFT", 12, -10)
    leftScroll.frame:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 12, 36)
    leftScroll.frame:SetWidth(COL_W)
    leftScroll.frame:Show()
    msLeftScroll = leftScroll

    -- Vertical divider between MS Changes and Buff Order
    local dividerX = 12 + COL_W + 12
    local divider = f:CreateTexture(nil, "OVERLAY")
    divider:SetWidth(1)
    divider:SetPoint("TOP", f, "TOPLEFT", dividerX, -10)
    divider:SetPoint("BOTTOM", f, "BOTTOMLEFT", dividerX, 36)
    divider:SetTexture(0.55, 0.50, 0.42, 0.5)

    -- Right column: Buff Order
    local rightScroll = AceGUI:Create("ScrollFrame")
    rightScroll:SetLayout("List")
    rightScroll.frame:SetParent(f)
    rightScroll.frame:ClearAllPoints()
    rightScroll.frame:SetPoint("TOPLEFT", f, "TOPLEFT", dividerX + 12, -10)
    rightScroll.frame:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -12, 36)
    rightScroll.frame:SetWidth(COL_W)
    rightScroll.frame:Show()

    -- Title / subtitle
    leftScroll:AddChild(MSHeading("Main Spec Changes"))

    local subLbl = AceGUI:Create("Label")
    subLbl:SetFullWidth(true)
    subLbl:SetJustifyH("CENTER")
    subLbl:SetColor(unpack(C.dim))
    subLbl:SetText("Track raid member spec changes via whisper")
    leftScroll:AddChild(subLbl)

    -- ── Section: Status / Demand ──────────────────────────────────────
    leftScroll:AddChild(MSHeading("Status"))

    local demandSlot = AceGUI:Create("SimpleGroup")
    demandSlot:SetFullWidth(true)
    demandSlot.noAutoHeight = true
    demandSlot:SetHeight(30)
    leftScroll:AddChild(demandSlot)

    msDemandBtn = MSMakeBtn(demandSlot.content, "", 100, 26)
    msDemandBtn:SetPoint("TOPLEFT", demandSlot.content, "TOPLEFT", 0, 0)
    msDemandBtn:SetPoint("TOPRIGHT", demandSlot.content, "TOPRIGHT", 0, 0)
    msDemandBtn:SetHeight(26)
    msDemandBtn:SetScript("OnClick", function()
        if not (UnitIsPartyLeader("player") or UnitIsRaidOfficer("player")) then
            DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[MS Changes]:|r You need Raid Leader or Assist rank to use this.")
            return
        end
        if not msIsOpen then
            -- DEMAND: show session popup, then open
            MSShowSessionPopup(MSOpenListening)
        else
            -- CLOSE: stop and announce count
            msIsOpen = false
            RefreshDemandBtn()
            local n = 0
            for _ in pairs(msChanges) do n = n + 1 end
            if GetNumRaidMembers() > 0 then
                SendChatMessage(
                    "[RealNibba's Raid Tools] Main Spec Changes CLOSED! " ..
                    n .. " change" .. (n==1 and "" or "s") .. " received.",
                    "RAID_WARNING")
            end
            DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[MS Changes]:|r Closed. " .. n .. " changes recorded.")
            if RNToolsAddon and RNToolsAddon.db and RNToolsAddon.db.profile.ms_autoshare then
                RNTools_ShareMSData()
            end
        end
    end)
    RefreshDemandBtn()

    -- Count label
    msStatusFS = AceGUI:Create("Label")
    msStatusFS:SetFullWidth(true)
    msStatusFS:SetColor(unpack(C.dim))
    msStatusFS:SetText("0 changes recorded")
    leftScroll:AddChild(msStatusFS)

    -- ── Section: Announce ─────────────────────────────────────────────
    leftScroll:AddChild(MSHeading("Announce"))

    local announceRow = AceGUI:Create("SimpleGroup")
    announceRow:SetLayout("Flow")
    announceRow:SetFullWidth(true)

    local announceBtn = AceGUI:Create("Button")
    announceBtn:SetText("Announce All Changes to /rw")
    announceBtn:SetRelativeWidth(0.7)
    announceBtn:SetCallback("OnClick", function()
        if not (RM_HasPermission and RM_HasPermission()) then
            DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[MS Changes]:|r You need RL/ML to announce.")
            return
        end
        local n = 0
        for _ in pairs(msChanges) do n = n + 1 end
        if n == 0 then
            DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[MS Changes]:|r No changes to announce.")
            return
        end
        local sorted = {}
        for name, spec in pairs(msChanges) do
            sorted[#sorted+1] = name .. " -> " .. spec
        end
        table.sort(sorted)
        local inRaid = GetNumRaidMembers() > 0
        -- Build one single message: "MS Changes 8: Name -> Spec, Name -> Spec, ..."
        -- Split only if it exceeds 255 chars (WoW chat limit)
        local prefix = "MS Changes " .. n .. ": "
        local line = prefix
        local msgs = {}
        for i, entry in ipairs(sorted) do
            local add = (i == 1 and "" or ", ") .. entry
            if string.len(line) + string.len(add) > 250 then
                -- flush current line and start a new one with continuation marker
                msgs[#msgs+1] = line
                line = "MS Changes (cont): " .. entry
            else
                line = line .. add
            end
        end
        if line ~= "" then msgs[#msgs+1] = line end
        -- Send each message as RAID_WARNING (/rw) directly, 1.5s apart
        for i, msg in ipairs(msgs) do
            if inRaid then
                if i == 1 then
                    SendChatMessage(msg, "RAID_WARNING")
                else
                    -- Delay subsequent messages so they don't flood
                    local delay = (i-1) * 1.5
                    local t = 0
                    local capturedMsg = msg
                    local ticker = CreateFrame("Frame")
                    ticker:SetScript("OnUpdate", function(self, elapsed)
                        t = t + elapsed
                        if t >= delay then
                            self:SetScript("OnUpdate", nil); self:Hide()
                            SendChatMessage(capturedMsg, "RAID_WARNING")
                        end
                    end)
                    ticker:Show()
                end
            else
                DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[MS Changes]:|r " .. msg)
            end
        end
    end)
    announceRow:AddChild(announceBtn)

    local clearBtn = AceGUI:Create("Button")
    clearBtn:SetText("|cffff4d4dClear All|r")
    clearBtn:SetRelativeWidth(0.3)
    clearBtn:SetCallback("OnClick", function()
        msChanges = {}
        RebuildMSRows()
        RefreshMSCount()
        DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[MS Changes]:|r All changes cleared.")
    end)
    announceRow:AddChild(clearBtn)

    leftScroll:AddChild(announceRow)

    -- ── Section: Manual Add ───────────────────────────────────────────
    leftScroll:AddChild(MSHeading("Manual Add"))

    local addLabelRow = AceGUI:Create("SimpleGroup")
    addLabelRow:SetLayout("Flow")
    addLabelRow:SetFullWidth(true)

    local nameLbl = AceGUI:Create("Label")
    nameLbl:SetText("Player Name:")
    nameLbl:SetColor(unpack(C.dim))
    nameLbl:SetRelativeWidth(0.45)
    addLabelRow:AddChild(nameLbl)

    local specLbl = AceGUI:Create("Label")
    specLbl:SetText("Main Spec:")
    specLbl:SetColor(unpack(C.dim))
    specLbl:SetRelativeWidth(0.35)
    addLabelRow:AddChild(specLbl)

    local fillerLbl = AceGUI:Create("Label")
    fillerLbl:SetText("")
    fillerLbl:SetRelativeWidth(0.20)
    addLabelRow:AddChild(fillerLbl)

    leftScroll:AddChild(addLabelRow)

    local addRow = AceGUI:Create("SimpleGroup")
    addRow:SetLayout("Flow")
    addRow:SetFullWidth(true)

    local manNameEB = AceGUI:Create("EditBox")
    manNameEB:SetRelativeWidth(0.45)
    manNameEB:DisableButton(true)
    addRow:AddChild(manNameEB)
    MSCreateAutocomplete(manNameEB, f, MSFilterRosterNames)

    local manSpecEB = AceGUI:Create("EditBox")
    manSpecEB:SetRelativeWidth(0.35)
    manSpecEB:DisableButton(true)
    addRow:AddChild(manSpecEB)
    MSCreateAutocomplete(manSpecEB, f, MSFilterSpecs)

    local addManBtn = AceGUI:Create("Button")
    addManBtn:SetText("|cff20ff60+ Add|r")
    addManBtn:SetRelativeWidth(0.20)
    addManBtn:SetCallback("OnClick", function()
        local name = manNameEB:GetText():match("^%s*(.-)%s*$")
        local spec = manSpecEB:GetText():match("^%s*(.-)%s*$")
        if name == "" then
            DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[MS Changes]:|r Enter a player name.")
            return
        end
        local clean = NormalizeSpec(spec)
        if not clean then
            DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[MS Changes]:|r |cffff4444'" .. (spec == "" and "(empty)" or spec) .. "'|r is not a recognised spec.")
            return
        end
        -- Normalize: strip realm suffix, capitalize first letter (match auto-detection)
        name = (name:match("^([^%-]+)") or name):gsub("^%l", string.upper)
        msChanges[name] = clean
        manNameEB:SetText(""); manSpecEB:SetText("")
        DEFAULT_CHAT_FRAME:AddMessage("|cff20ff60[MS Changes]:|r Added |cff44aaff" .. name .. "|r -> |cff20ff60" .. clean .. "|r")
        RebuildMSRows(); RefreshMSCount()
    end)
    addRow:AddChild(addManBtn)

    leftScroll:AddChild(addRow)

    -- Hint: pick from raid
    local raidHint = AceGUI:Create("Label")
    raidHint:SetFullWidth(true)
    raidHint:SetColor(unpack(C.dim))
    raidHint:SetText("Tip: type the player name exactly as it appears in the raid roster.")
    leftScroll:AddChild(raidHint)

    -- ── Section: Recognised Specs ─────────────────────────────────────
    leftScroll:AddChild(MSHeading("Recognised Specs"))

    for _, line in ipairs({
        "Tank  Prot  Blood  Bear  /  Holy  Disc  Resto  Tree",
        "Fury  Arms  Ret  Combat  Assassination  Mut  Enhance",
        "Cat  Frost DK  Unholy  Blood DPS  /  Boomie  Balance",
        "Shadow  Arcane  Fire  MM  Survival  BM  Elemental",
        "Affliction  Demonology  /  DPS  Healer  Tank  Caster  Melee",
    }) do
        local specLine = AceGUI:Create("Label")
        specLine:SetFullWidth(true)
        specLine:SetColor(unpack(C.dim))
        specLine:SetText(line)
        leftScroll:AddChild(specLine)
    end

    -- ── Section: Recorded Changes ─────────────────────────────────────
    leftScroll:AddChild(MSHeading("Recorded Changes"))

    local msHeaderRow = AceGUI:Create("SimpleGroup")
    msHeaderRow:SetLayout("Flow")
    msHeaderRow:SetFullWidth(true)

    local plHdr = AceGUI:Create("Label")
    plHdr:SetText("Player")
    plHdr:SetColor(unpack(C.accent))
    plHdr:SetRelativeWidth(0.40)
    msHeaderRow:AddChild(plHdr)

    local specHdr = AceGUI:Create("Label")
    specHdr:SetText("Main Spec")
    specHdr:SetColor(unpack(C.accent))
    specHdr:SetRelativeWidth(0.35)
    msHeaderRow:AddChild(specHdr)

    local actHdr = AceGUI:Create("Label")
    actHdr:SetText("Action")
    actHdr:SetColor(unpack(C.accent))
    actHdr:SetRelativeWidth(0.25)
    msHeaderRow:AddChild(actHdr)

    leftScroll:AddChild(msHeaderRow)

    msRowCont = AceGUI:Create("SimpleGroup")
    msRowCont:SetLayout("List")
    msRowCont:SetFullWidth(true)
    leftScroll:AddChild(msRowCont)

    RebuildMSRows()

    -- Build Buff Order panel in the right column
    BuildBuffOrderPanel(rightScroll)

    f:SetScript("OnShow", function()
        RefreshDemandBtn()
        RebuildMSRows()
        RefreshMSCount()
    end)

    return f
end

-------------------------------------------------------------------------------
-- Whisper listener
-------------------------------------------------------------------------------
local msWhisperFrame = CreateFrame("Frame")
-- msWhisperFrame: only RL/ML responds to MS change whispers
msWhisperFrame:RegisterEvent("CHAT_MSG_WHISPER")
msWhisperFrame:SetScript("OnEvent", function(self, event, msg, sender)
    if not msIsOpen then return end
    if not msg or msg == "" then return end
    if not sender or sender == "" then return end

    local name = (sender:match("^([^%-]+)") or sender)
    name = name:gsub("^%l", string.upper)

    local clean = NormalizeSpec(msg)
    if not clean then
        -- Try to give a class-specific hint
        local class = GetRaidMemberClass(name)
        local hint
        if class and CLASS_VALID_SPECS[class] then
            hint = "[RNTools] Invalid spec for your class. Valid specs: " .. table.concat(CLASS_VALID_SPECS[class], ", ")
        else
            hint = "[RNTools] Invalid spec. Valid: Holy, Disc, Resto, Boomie, Feral, Fury, Arms, Prot, Ret, Enhance, Elem, Shadow, Arcane, Fire, Frost, MM, Surv, BM, Afflic, Demo, Destro, Combat, Assassination, Blood, Unholy."
        end
        SendChatMessage(hint, "WHISPER", nil, sender)
        return
    end

    -- Class validation: reject specs impossible for this class
    local class = GetRaidMemberClass(name)
    if class then
        local validList = table.concat(CLASS_VALID_SPECS[class] or {}, ", ")

        -- Resolve generic roles to class-specific specs
        if clean == "Tank" then
            local s = CLASS_TANK_SPEC[class]
            if s then clean = s
            else
                SendChatMessage("[RNTools] Your class cannot tank. Valid specs: " .. validList, "WHISPER", nil, sender)
                return
            end
        elseif clean == "Healer" then
            local s = CLASS_HEALER_SPEC[class]
            if s then clean = s
            else
                SendChatMessage("[RNTools] Your class cannot heal. Valid specs: " .. validList, "WHISPER", nil, sender)
                return
            end
        elseif clean == "DPS" then
            local s = CLASS_DPS_SPEC[class]
            if s then clean = s
            else
                SendChatMessage("[RNTools] Please specify your DPS spec. Valid specs: " .. validList, "WHISPER", nil, sender)
                return
            end
        end

        local ok, badList = IsSpecValidForClass(clean, class)
        if not ok then
            SendChatMessage(
                "[RNTools] " .. clean .. " is not a valid spec for your class. Valid specs: " .. badList,
                "WHISPER", nil, sender)
            return
        end
    end

    local isNew = (msChanges[name] == nil)
    msChanges[name] = clean

    SendChatMessage(
        "[RNTools] Received your MS change: " .. name .. " -> " .. clean .. ". Thank you!",
        "WHISPER", nil, sender)

    if isNew then
        DEFAULT_CHAT_FRAME:AddMessage("|cff20ff60[MS Changes]:|r |cff44aaff" .. name .. "|r -> |cff20ff60" .. clean .. "|r")
    else
        DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[MS Changes]:|r |cff44aaff" .. name .. "|r updated -> |cff20ff60" .. clean .. "|r")
    end

    RefreshMSCount()
    -- Rebuild rows if tab is visible
    if msRowCont then RebuildMSRows() end
end)

-------------------------------------------------------------------------------
-- Buff Order Module
-- Tracks paladin buff assignments and announces the order.
-- GBoK=25898  GBoW=25894  GBoM=25782  GBoS=25899
-------------------------------------------------------------------------------

local BUFF_LIST = {
    { name="Greater Blessing of Kings",    short="GBoK", spellId=25898, color="|cffFFD100" },
    { name="Greater Blessing of Might",    short="GBoM", spellId=25782, color="|cffFF8C00" },
    { name="Greater Blessing of Wisdom",   short="GBoW", spellId=25894, color="|cff44aaFF" },
    { name="Greater Blessing of Sanctuary",short="GBoS", spellId=25899, color="|cff20ff60" },
}

-- Saved assignments: buffAssignments[buffShort] = "PaladinName"
local buffAssignments = { GBoK="", GBoM="", GBoW="", GBoS="" }

-- Get paladins in raid
local function GetRaidPaladins()
    local pals = { "(Unassigned)" }
    local n = GetNumRaidMembers()
    if n == 0 then
        -- Also check party
        for i = 1, GetNumPartyMembers() do
            local _, cls = UnitClass("party"..i)
            if cls == "PALADIN" then
                local name = UnitName("party"..i)
                if name then pals[#pals+1] = name end
            end
        end
        local _, cls = UnitClass("player")
        if cls == "PALADIN" then
            pals[#pals+1] = UnitName("player")
        end
    else
        for i = 1, n do
            local _, cls = UnitClass("raid"..i)
            if cls == "PALADIN" then
                local name, _ = UnitName("raid"..i)
                if name then pals[#pals+1] = name end
            end
        end
    end
    return pals
end

-- Dropdown for buff→paladin selection
-- Uses EasyMenu + UIDropDownMenuTemplate (same as RaidBrowser filters - closes properly)
local buffDropFrames  = {}
local BUFF_DDMenu     = CreateFrame("Frame", "RNBuffDDMenu", UIParent, "UIDropDownMenuTemplate")
local buffOpenBtn     = nil

-- Refresh all buff dropdown button labels from buffAssignments
local function RefreshBuffDropdownDisplays()
    for short, btn in pairs(buffDropFrames) do
        local display = buffAssignments[short] ~= "" and buffAssignments[short] or "(Unassigned)"
        btn._label:SetText(display)
    end
end

-- Auto-assign paladins to Greater Blessings based on their main spec:
--   Retribution -> GBoM, Holy -> GBoW, Protection -> GBoS, GBoK -> a random
--   remaining paladin (or a random already-assigned one if everyone has a
--   buff). Special case: with <= 3 paladins, if one is Protection, that
--   paladin gets GBoK instead of GBoS (GBoS is left unassigned).
-- Core assignment logic — called directly or after inspect completes
local function DoAutoAssignBuffs()
    local pals = GetRaidPaladins()
    table.remove(pals, 1) -- drop the "(Unassigned)" placeholder

    if #pals == 0 then
        DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[Buff Order]:|r No paladins found in raid.")
        return
    end

    local palData = {}
    for _, name in ipairs(pals) do
        local entry = RNToolsSpecDB and RNToolsSpecDB[name]
        local spec = entry and entry.spec
        local impBoM = entry and entry.impBoM
        palData[#palData+1] = { name = name, spec = spec, impBoM = impBoM }
    end

    for _, buff in ipairs(BUFF_LIST) do
        buffAssignments[buff.short] = ""
    end

    local assigned = {}

    -- GBoM: prioritize Ret paladins with Improved Blessing of Might talent
    local retPals = {}
    for _, p in ipairs(palData) do
        if p.spec == "Retribution" then
            retPals[#retPals+1] = p
        end
    end
    table.sort(retPals, function(a, b)
        return (a.impBoM or 0) > (b.impBoM or 0)
    end)
    if #retPals > 0 then
        buffAssignments.GBoM = retPals[1].name
        assigned[retPals[1].name] = true
    end

    for _, p in ipairs(palData) do
        if p.spec == "Holy" and buffAssignments.GBoW == "" then
            buffAssignments.GBoW = p.name
            assigned[p.name] = true
        end
    end

    local protPal = nil
    for _, p in ipairs(palData) do
        if p.spec == "Protection" and not protPal then
            protPal = p.name
        end
    end

    local smallRaidSpecial = (#palData <= 3) and protPal ~= nil

    if not smallRaidSpecial and protPal then
        buffAssignments.GBoS = protPal
        assigned[protPal] = true
    end

    if smallRaidSpecial then
        buffAssignments.GBoK = protPal
        assigned[protPal] = true
    else
        local pool = {}
        for _, p in ipairs(palData) do
            if not assigned[p.name] then pool[#pool+1] = p.name end
        end
        if #pool > 0 then
            buffAssignments.GBoK = pool[math.random(#pool)]
        end
    end

    RefreshBuffDropdownDisplays()
    DEFAULT_CHAT_FRAME:AddMessage("|cff20ff60[Buff Order]:|r Auto-assigned buffs based on paladin specs.")
end

local function MakeBuffDropdown(parent, buffShort)
    local btn = MSMakeBtn(parent, buffAssignments[buffShort] ~= "" and buffAssignments[buffShort] or "(Unassigned)", 180, 24)
    btn:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -4, -3)
    btn._label:SetTextColor(unpack(C.text))
    btn._key = buffShort
    buffDropFrames[buffShort] = btn

    btn:SetScript("OnClick", function(self)
        -- Toggle close if already open
        if buffOpenBtn == self then
            CloseDropDownMenus()
            buffOpenBtn = nil
            return
        end
        buffOpenBtn = self
        local pals = GetRaidPaladins()
        local menu = {}
        for _, palName in ipairs(pals) do
            -- Skip paladins already assigned to OTHER buffs
            local alreadyUsed = false
            if palName ~= "(Unassigned)" then
                for short, assigned in pairs(buffAssignments) do
                    if short ~= self._key and assigned == palName then
                        alreadyUsed = true
                        break
                    end
                end
            end
            if not alreadyUsed then
                local capName  = palName
                local capShort = self._key
                local capBtn   = self
                menu[#menu+1] = {
                    text    = capName,
                    checked = (buffAssignments[capShort] == capName),
                    func    = function()
                        buffAssignments[capShort] = (capName == "(Unassigned)") and "" or capName
                        local display = buffAssignments[capShort] ~= "" and buffAssignments[capShort] or "(Unassigned)"
                        capBtn._label:SetText(display)
                        buffOpenBtn = nil
                    end,
                }
            end
        end
        EasyMenu(menu, BUFF_DDMenu, self, 0, 0, "MENU")
    end)
    return btn
end

function BuildBuffOrderPanel(scroll)
    scroll:AddChild(MSHeading("Buff Order"))

    local subLbl = AceGUI:Create("Label")
    subLbl:SetFullWidth(true)
    subLbl:SetJustifyH("CENTER")
    subLbl:SetColor(unpack(C.dim))
    subLbl:SetText("Assign paladins to Greater Blessings")
    scroll:AddChild(subLbl)

    -- ── Buff assignments ──────────────────────────────────────────────
    scroll:AddChild(MSHeading("Buff Assignments"))

    local hdrRow = AceGUI:Create("SimpleGroup")
    hdrRow:SetLayout("Flow")
    hdrRow:SetFullWidth(true)

    local lbl1 = AceGUI:Create("Label")
    lbl1:SetText("Blessing")
    lbl1:SetColor(unpack(C.accent))
    lbl1:SetRelativeWidth(0.5)
    hdrRow:AddChild(lbl1)

    local lbl2 = AceGUI:Create("Label")
    lbl2:SetText("Assigned Paladin")
    lbl2:SetColor(unpack(C.accent))
    lbl2:SetRelativeWidth(0.5)
    hdrRow:AddChild(lbl2)

    scroll:AddChild(hdrRow)

    for _, buff in ipairs(BUFF_LIST) do
        local row = AceGUI:Create("SimpleGroup")
        row:SetFullWidth(true)
        row.noAutoHeight = true
        row:SetHeight(30)
        scroll:AddChild(row)

        -- Icon from spell ID
        local spellName, _, spellTex = GetSpellInfo(buff.spellId)
        local iconFrame = CreateFrame("Frame", nil, row.content)
        iconFrame:SetSize(24, 24)
        iconFrame:SetPoint("LEFT", row.content, "LEFT", 4, 0)
        local iconTex = iconFrame:CreateTexture(nil, "ARTWORK")
        iconTex:SetAllPoints(iconFrame)
        if spellTex then iconTex:SetTexture(spellTex) end
        iconFrame:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetSpellByID(buff.spellId)
            GameTooltip:Show()
        end)
        iconFrame:SetScript("OnLeave", function() GameTooltip:Hide() end)
        iconFrame:EnableMouse(true)

        -- Buff name label next to icon
        local bLbl = row.content:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        bLbl:SetPoint("LEFT", iconFrame, "RIGHT", 4, 0)
        bLbl:SetTextColor(1, 0.82, 0, 1)
        local displayName = spellName or buff.name
        bLbl:SetText(buff.color .. displayName .. "|r")

        -- Paladin dropdown
        MakeBuffDropdown(row.content, buff.short)
    end

    local autoAssignBtn = AceGUI:Create("Button")
    autoAssignBtn:SetText("Auto Assign")
    autoAssignBtn:SetFullWidth(true)
    autoAssignBtn:SetCallback("OnClick", function() if RNTools_AutoAssignBuffs then RNTools_AutoAssignBuffs() end end)
    scroll:AddChild(autoAssignBtn)

    -- ── Announce ──────────────────────────────────────────────────────
    scroll:AddChild(MSHeading("Announce"))

    local function SendBuffAnnounce(channel)
        local lines = {}
        for _, buff in ipairs(BUFF_LIST) do
            local pal = buffAssignments[buff.short]
            lines[#lines+1] = buff.short .. ": " .. (pal ~= "" and pal or "Unassigned")
        end
        local msg = "Buff Order: " .. table.concat(lines, " / ")
        local inRaid = GetNumRaidMembers() > 0
        if channel == "rw" and inRaid then
            SendChatMessage(msg, "RAID_WARNING")
        elseif channel == "raid" and inRaid then
            SendChatMessage(msg, "RAID")
        else
            DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[Buff Order]:|r " .. msg)
        end
    end

    local annRow = AceGUI:Create("SimpleGroup")
    annRow:SetLayout("Flow")
    annRow:SetFullWidth(true)

    local rwBtn = AceGUI:Create("Button")
    rwBtn:SetText("Announce /rw")
    rwBtn:SetRelativeWidth(0.5)
    rwBtn:SetCallback("OnClick", function() SendBuffAnnounce("rw") end)
    annRow:AddChild(rwBtn)

    local raidBtn = AceGUI:Create("Button")
    raidBtn:SetText("Announce /raid")
    raidBtn:SetRelativeWidth(0.5)
    raidBtn:SetCallback("OnClick", function() SendBuffAnnounce("raid") end)
    annRow:AddChild(raidBtn)

    scroll:AddChild(annRow)

    -- ── Current assignment preview ────────────────────────────────────
    -- No manual refresh needed - EasyMenu reads live raid roster on every click
    local previewLbl = AceGUI:Create("Label")
    previewLbl:SetFullWidth(true)
    previewLbl:SetColor(unpack(C.dim))
    previewLbl:SetText("Tip: click a dropdown to assign. Paladins auto-detected from raid roster.")
    scroll:AddChild(previewLbl)
end

-- Global: announce buff order (called from Quick Access window)
function RNTools_AnnounceBuffOrder(channel)
    if not (RM_HasPermission and RM_HasPermission()) then
        DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Buff Order]:|r You need RL/ML to announce.")
        return
    end
    local lines = {}
    for _, buff in ipairs(BUFF_LIST) do
        local pal = buffAssignments[buff.short]
        lines[#lines+1] = buff.short .. ": " .. (pal ~= "" and pal or "Unassigned")
    end
    local msg = "Buff Order: " .. table.concat(lines, " / ")
    local inRaid = GetNumRaidMembers() > 0
    if channel == "rw" and inRaid then
        SendChatMessage(msg, "RAID_WARNING")
    elseif channel == "raid" and inRaid then
        SendChatMessage(msg, "RAID")
    else
        DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[Buff Order]:|r " .. msg)
    end
end

-- Global: trigger MS announce (called from Quick Access window)
function RNTools_AnnounceAllMSChanges()
    if not (RM_HasPermission and RM_HasPermission()) then
        DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[MS Changes]:|r You need RL/ML to announce.")
        return
    end
    local n = 0
    for _ in pairs(msChanges) do n = n + 1 end
    if n == 0 then
        DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[MS Changes]:|r No changes to announce.")
        return
    end
    local sorted = {}
    local p = RNToolsAddon and RNToolsAddon.db and RNToolsAddon.db.profile
    local fmt = (p and p.ms_announce_fmt) or "{name} -> {spec}"
    for name, spec in pairs(msChanges) do
        sorted[#sorted+1] = fmt:gsub("{name}", name):gsub("{spec}", spec)
    end
    table.sort(sorted)
    local inRaid = GetNumRaidMembers() > 0
    local prefix = "MS Changes " .. n .. ": "
    local line = prefix
    for i, entry in ipairs(sorted) do
        local add = (i == 1 and "" or ", ") .. entry
        if string.len(line) + string.len(add) > 250 then
            if inRaid then SendChatMessage(line, "RAID_WARNING") end
            line = "... " .. entry
        else
            line = line .. add
        end
    end
    if string.len(line) > string.len(prefix) then
        if inRaid then SendChatMessage(line, "RAID_WARNING") end
    end
    DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[MS Changes]:|r Announced " .. n .. " changes.")
end

-- Global: toggle MS Changes demand
function RNTools_ToggleMSChanges()
    if not (RM_HasPermission and RM_HasPermission()) then
        DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[MS Changes]:|r You need RL/ML to use this.")
        return
    end
    if not msIsOpen then
        MSShowSessionPopup(MSOpenListening)
        return
    end
    -- CLOSE
    msIsOpen = false
    local n = 0; for _ in pairs(msChanges) do n = n + 1 end
    local inRaid = GetNumRaidMembers() > 0
    if inRaid then
        SendChatMessage("[RealNibba's Raid Tools] Main Spec Changes CLOSED! " .. n .. " change" .. (n==1 and "" or "s") .. " received.", "RAID_WARNING")
    end
    DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[MS Changes]:|r Closed. " .. n .. " changes recorded.")
    RefreshDemandBtn()
    -- Auto-share with addon officers if enabled
    if RNToolsAddon and RNToolsAddon.db and RNToolsAddon.db.profile.ms_autoshare then
        RNTools_ShareMSData()
    end
end

-------------------------------------------------------------------------------
-- RNTools Spec Database (v2)
-- Zero-delay chaining: fires next NotifyInspect the instant INSPECT_TALENT_READY
-- arrives. OnUpdate rescue fires only when waiting for a response (3s timeout).
-- Queue stores player NAMES not unit tokens — immune to roster reshuffles.
-- Feral Druid bear/cat distinction via Protector of the Pack (tab 2, idx 22).
-------------------------------------------------------------------------------

if type(RNToolsSpecDB) ~= "table" then RNToolsSpecDB = {} end
-- Structure: RNToolsSpecDB[playerName] = {spec="Retribution", class="PALADIN", time=timestamp}

local SPEC_queue     = {}    -- ordered list of player names to inspect
local SPEC_curName   = nil   -- name of player currently being inspected
local SPEC_curUnit   = nil   -- unit token resolved at fire time (for inspect API)
local SPEC_rescue    = 0     -- OnUpdate accumulator — only active during a live request
local SPEC_total     = 0
local SPEC_done      = 0
local SPEC_active    = false
local SPEC_onComplete = nil  -- optional callback when queue finishes

local SPEC_statusFS   = nil
local SPEC_progressFS = nil

local SPEC_RESCUE_TIMEOUT = 3.0  -- seconds before treating a dropped packet as a timeout

local function SPEC_UpdateStatus(msg, color)
    color = color or "|cff888888"
    if SPEC_statusFS then SPEC_statusFS:SetText(color .. (msg or "") .. "|r") end
end

local function SPEC_UpdateProgress()
    if SPEC_progressFS then
        if SPEC_active then
            local cur = SPEC_curName and (" [" .. SPEC_curName .. "]") or ""
            SPEC_progressFS:SetText("|cffFFD100Gathering... " .. SPEC_done .. "/" .. SPEC_total .. cur .. "|r")
        else
            SPEC_progressFS:SetText("")
        end
    end
end

local SPEC_TREE_NAMES = {
    DEATHKNIGHT = {"Blood",         "Frost",        "Unholy"},
    DRUID       = {"Balance",       "Feral",        "Restoration"},
    HUNTER      = {"Beast Mastery", "Marksmanship", "Survival"},
    MAGE        = {"Arcane",        "Fire",         "Frost"},
    PALADIN     = {"Holy",          "Protection",   "Retribution"},
    PRIEST      = {"Discipline",    "Holy",         "Shadow"},
    ROGUE       = {"Assassination", "Combat",       "Subtlety"},
    SHAMAN      = {"Elemental",     "Enhancement",  "Restoration"},
    WARLOCK     = {"Affliction",    "Demonology",   "Destruction"},
    WARRIOR     = {"Arms",          "Fury",         "Protection"},
}

-- Read spec from inspect cache immediately after INSPECT_TALENT_READY fires.
-- Pass isInspect=true to all GetTalentTabInfo/GetTalentInfo calls.
local function SPEC_GetSpecFromInspect(unit)
    local _, engClass = UnitClass(unit)
    if not engClass then return nil, nil end
    local maxPoints, specIdx = 0, 0
    for i = 1, 3 do
        local _, _, pts = GetTalentTabInfo(i, true)
        if pts and pts > maxPoints then maxPoints = pts; specIdx = i end
    end
    if specIdx == 0 then return nil, engClass end
    local trees    = SPEC_TREE_NAMES[engClass]
    local specName = trees and trees[specIdx] or ("Tree "..specIdx)
    -- Feral Druid: distinguish tank (bear) vs dps (cat).
    -- "Protector of the Pack" is Feral tab (2), talent index 22 in 3.3.5a.
    -- Any points in it = bear spec.
    if engClass == "DRUID" and specIdx == 2 then
        local _, _, _, _, protPack = GetTalentInfo(2, 22, true)
        specName = (protPack and protPack > 0) and "Feral (Bear)" or "Feral (Cat)"
    end
    -- Paladin: check for Improved Blessing of Might (Ret tab 3, talent 1 in 3.3.5a)
    local impBoM = nil
    if engClass == "PALADIN" then
        local _, _, _, _, pts = GetTalentInfo(3, 1, true)
        impBoM = (pts and pts > 0) and pts or nil
    end
    return specName, engClass, impBoM
end

-- Resolve a player name to their current unit token in raid/party.
-- Called at fire time, not at queue-build time, so roster reshuffles don't matter.
local function SPEC_FindUnit(name)
    local n = GetNumRaidMembers()
    if n > 0 then
        for i = 1, n do
            if UnitName("raid"..i) == name then return "raid"..i end
        end
    else
        local p = GetNumPartyMembers()
        for i = 1, p do
            if UnitName("party"..i) == name then return "party"..i end
        end
    end
    return nil
end

-- Fire the next inspect immediately, or finish if queue is empty.
-- Skips disconnected or unfindable players without burning a timeout slot.
local function SPEC_FireNext()
    SPEC_curName = nil
    SPEC_curUnit = nil
    SPEC_rescue  = 0

    while #SPEC_queue > 0 do
        local name = table.remove(SPEC_queue, 1)
        local unit = SPEC_FindUnit(name)
        if unit and UnitIsConnected(unit) then
            SPEC_curName = name
            SPEC_curUnit = unit
            SPEC_UpdateProgress()
            NotifyInspect(unit)
            return  -- wait for INSPECT_TALENT_READY (or rescue timeout)
        else
            -- Offline or left group — skip instantly, no delay consumed
            SPEC_done = SPEC_done + 1
            SPEC_UpdateProgress()
        end
    end

    -- Queue exhausted
    SPEC_active = false
    SPEC_UpdateStatus("Done! Gathered " .. SPEC_done .. "/" .. SPEC_total .. " specs.", "|cff20ff60")
    SPEC_UpdateProgress()
    DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[RNTools Spec]:|r Gathered " ..
        SPEC_done .. "/" .. SPEC_total .. " player specs.")
    if SPEC_onComplete then
        local cb = SPEC_onComplete
        SPEC_onComplete = nil
        cb()
    end
end

-- Zero-delay chain: process result the instant INSPECT_TALENT_READY arrives,
-- then fire the next request immediately with no artificial delay.
local SPEC_inspectFrame = CreateFrame("Frame")
SPEC_inspectFrame:RegisterEvent("INSPECT_TALENT_READY")
SPEC_inspectFrame:RegisterEvent("PLAYER_LOGIN")
SPEC_inspectFrame:SetScript("OnEvent", function(self, event)
    if event == "PLAYER_LOGIN" then
        if type(RNToolsSpecDB) ~= "table" then RNToolsSpecDB = {} end
        return
    end
    -- INSPECT_TALENT_READY — in 3.3.5a this fires with no GUID argument,
    -- so we rely on SPEC_curUnit being the player we requested.
    if not SPEC_active or not SPEC_curUnit then return end
    local name = SPEC_curName or UnitName(SPEC_curUnit)
    if name then
        local spec, cls, impBoM = SPEC_GetSpecFromInspect(SPEC_curUnit)
        if spec and cls then
            RNToolsSpecDB[name] = {spec=spec, class=cls, time=time(), impBoM=impBoM}
        end
        SPEC_done = SPEC_done + 1
    end
    SPEC_FireNext()  -- 0ms — fire the next request this same frame
end)

-- Rescue OnUpdate: ONLY accumulates when a live request is in flight (SPEC_curUnit ~= nil).
-- Zero cost between requests — no elapsed accumulation when idle.
local SPEC_rescueFrame = CreateFrame("Frame")
SPEC_rescueFrame:SetScript("OnUpdate", function(self, elapsed)
    if not SPEC_active or not SPEC_curUnit then return end  -- idle — early out
    SPEC_rescue = SPEC_rescue + elapsed
    if SPEC_rescue < SPEC_RESCUE_TIMEOUT then return end
    -- Server dropped the packet or player is out of inspect range
    DEFAULT_CHAT_FRAME:AddMessage(
        "|cff888888[RNTools Spec]:|r Timeout on |cffFFD100" .. tostring(SPEC_curName) .. "|r — skipping.")
    SPEC_done = SPEC_done + 1
    SPEC_FireNext()
end)

function RNTools_PurgeSpecDB(days)
    if type(RNToolsSpecDB) ~= "table" then return end
    if not days or days <= 0 then return end
    local cutoff = time() - days * 86400
    local removed = 0
    for name, entry in pairs(RNToolsSpecDB) do
        if entry.time and entry.time < cutoff then
            RNToolsSpecDB[name] = nil
            removed = removed + 1
        end
    end
    if removed > 0 then
        DEFAULT_CHAT_FRAME:AddMessage("|cff888888[RNTools Spec]:|r Purged " .. removed .. " stale spec entries (>" .. days .. " days old).")
    end
end

function RNTools_GatherSpecData(forceAll)
    if SPEC_active then
        DEFAULT_CHAT_FRAME:AddMessage("|cffFF8C00[RNTools Spec]:|r Already gathering. Please wait.")
        return
    end

    SPEC_queue   = {}
    SPEC_total   = 0
    SPEC_done    = 0
    SPEC_active  = true
    SPEC_curName = nil
    SPEC_curUnit = nil
    SPEC_rescue  = 0

    local myName = UnitName("player")

    -- Own spec: read directly without inspect
    local _, myClass = UnitClass("player")
    if myClass then
        local maxP, specIdx = 0, 0
        for i = 1, 3 do
            local _, _, pts = GetTalentTabInfo(i, false)
            if pts and pts > maxP then maxP=pts; specIdx=i end
        end
        if specIdx > 0 then
            local trees = SPEC_TREE_NAMES[myClass]
            local spec  = trees and trees[specIdx] or ("Tree "..specIdx)
            if myClass == "DRUID" and specIdx == 2 then
                local _, _, _, _, protPack = GetTalentInfo(2, 22)
                spec = (protPack and protPack > 0) and "Feral (Bear)" or "Feral (Cat)"
            end
            local myImpBoM = nil
            if myClass == "PALADIN" then
                local _, _, _, _, pts = GetTalentInfo(3, 1, false)
                myImpBoM = (pts and pts > 0) and pts or nil
            end
            RNToolsSpecDB[myName] = {spec=spec, class=myClass, time=time(), impBoM=myImpBoM}
        end
    end

    -- Build name queue (names, not unit tokens)
    local n = GetNumRaidMembers()
    if n > 0 then
        for i = 1, n do
            local name, _, _, _, _, _, _, online = GetRaidRosterInfo(i)
            if online and name and name ~= myName then
                if forceAll or not RNToolsSpecDB[name] then
                    table.insert(SPEC_queue, name)
                    SPEC_total = SPEC_total + 1
                end
            end
        end
    elseif GetNumPartyMembers() > 0 then
        for i = 1, GetNumPartyMembers() do
            local name = UnitName("party"..i)
            if name and name ~= myName then
                if forceAll or not RNToolsSpecDB[name] then
                    table.insert(SPEC_queue, name)
                    SPEC_total = SPEC_total + 1
                end
            end
        end
    end

    if SPEC_total == 0 then
        SPEC_active = false
        SPEC_UpdateStatus("No new players to inspect.", "|cffFF8C00")
        DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[RNTools Spec]:|r No new players to inspect. Use Force Re-Gather to refresh all.")
        return
    end

    SPEC_UpdateStatus("Gathering specs...", "|cffFFD100")
    SPEC_UpdateProgress()
    DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[RNTools Spec]:|r Gathering " .. SPEC_total .. " player specs...")
    SPEC_FireNext()  -- kick off zero-delay chain
end

-- Inspect paladins for spec + Improved BoM, then run buff assignment
local function AutoAssignBuffs()
    local pals = GetRaidPaladins()
    table.remove(pals, 1)

    if #pals == 0 then
        DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[Buff Order]:|r No paladins found in raid.")
        return
    end

    local toInspect = {}
    local myName = UnitName("player")
    for _, name in ipairs(pals) do
        if name ~= myName then
            local entry = RNToolsSpecDB and RNToolsSpecDB[name]
            if not entry or not entry.spec then
                toInspect[#toInspect+1] = name
            elseif entry.spec == "Retribution" then
                toInspect[#toInspect+1] = name
            end
        end
    end

    local _, myClass = UnitClass("player")
    if myClass == "PALADIN" then
        local entry = RNToolsSpecDB and RNToolsSpecDB[myName]
        if not entry then
            local maxP, specIdx = 0, 0
            for i = 1, 3 do
                local _, _, pts = GetTalentTabInfo(i, false)
                if pts and pts > maxP then maxP = pts; specIdx = i end
            end
            if specIdx > 0 then
                local palTrees = {"Holy", "Protection", "Retribution"}
                local spec = palTrees[specIdx] or ("Tree "..specIdx)
                local _, _, _, _, impPts = GetTalentInfo(3, 1, false)
                RNToolsSpecDB[myName] = {spec=spec, class=myClass, time=time(),
                    impBoM = (impPts and impPts > 0) and impPts or nil}
            end
        elseif entry.spec == "Retribution" then
            local _, _, _, _, pts = GetTalentInfo(3, 1, false)
            entry.impBoM = (pts and pts > 0) and pts or nil
        end
    end

    if #toInspect == 0 then
        DoAutoAssignBuffs()
        return
    end

    DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[Buff Order]:|r Inspecting " ..
        #toInspect .. " paladin(s) for spec & Improved BoM...")

    if SPEC_active then
        DEFAULT_CHAT_FRAME:AddMessage("|cffFF8C00[Buff Order]:|r Inspect already in progress — assigning with current data.")
        DoAutoAssignBuffs()
        return
    end

    SPEC_queue      = toInspect
    SPEC_total      = #toInspect
    SPEC_done       = 0
    SPEC_active     = true
    SPEC_onComplete = DoAutoAssignBuffs
    SPEC_FireNext()
end

function RNTools_AutoAssignBuffs()
    AutoAssignBuffs()
end

-- Get effective spec for a player (MS Changes override takes priority)
function RNTools_GetPlayerSpec(playerName)
    -- Normalize playerName: strip realm suffix for consistent lookup
    local normName = (playerName:match("^([^%-]+)") or playerName):gsub("^%l", string.upper)
    -- MS Changes override: if player changed spec, use that
    if msChanges then
        if msChanges[normName] then return msChanges[normName], "mschange" end
        if msChanges[playerName] then return msChanges[playerName], "mschange" end
    end
    -- Inspect database
    local data = RNToolsSpecDB and RNToolsSpecDB[playerName]
    if data then return data.spec, data.class end
    -- Fallback for local player: read spec directly (can't inspect self)
    if playerName == UnitName("player") then
        local _, className = UnitClass("player")
        local tabIdx, maxPoints = 0, -1
        for i = 1, GetNumTalentTabs() do
            local _, _, _, _, pointsSpent = GetTalentTabInfo(i)
            if pointsSpent and pointsSpent > maxPoints then
                maxPoints = pointsSpent
                tabIdx = i
            end
        end
        if tabIdx > 0 and SPEC_TREE_NAMES then
            local key = className .. "_" .. tabIdx
            local specName = SPEC_TREE_NAMES[key]
            if specName then return specName, className end
        end
    end
    return nil, nil
end

-- Check if a player's spec is eligible to roll for an item
-- Returns: allowed (bool), reason (string)
function RNTools_CheckRollEligibility(playerName, itemName)
    if not itemName or not BiSRaidDB or not BiSRaidDB.items then return true, "" end
    local key  = itemName:lower()
    local data = BiSRaidDB.items[key]
    if not data then return true, "" end  -- item not in DB, allow roll

    local playerSpec, source = RNTools_GetPlayerSpec(playerName)
    if not playerSpec then return true, "" end  -- no spec data, allow roll

    -- Build allowed specs list from BiS + Alt
    local allowed = {}
    if data.bis then
        for i = 1, #data.bis do
            if data.bis[i] then allowed[data.bis[i]:lower()] = true end
        end
    end
    if data.alt then
        for i = 1, #data.alt do
            if data.alt[i] then allowed[data.alt[i]:lower()] = true end
        end
    end

    -- ── Eligibility alias map ────────────────────────────────────────────────
    -- Maps every DB spec string (lowercased) and every possible player-spec
    -- (from inspect or MS-Changes manual entry) to a canonical lowercase token.
    --
    -- FERAL DRUID DESIGN NOTE:
    --   DB uses "Feral Cat Druid" (DPS) and "Bear Tank Druid" (tank) as DISTINCT
    --   strings.  Our inspector stores "Feral (Cat)" or "Feral (Bear)" from
    --   Protector of the Pack detection.  We preserve this distinction end-to-end:
    --     DB  "Feral Cat Druid" / "Feral Cat"  →  canonical "feral (cat)"
    --     DB  "Bear Tank Druid"                →  canonical "feral (bear)"
    --     Inspect "Feral (Cat)"  / "Feral (Bear)" stay as their canonical tokens.
    --   A player whose spec is "Feral (Cat)" can roll ONLY on items whose
    --   normalizedAllowed contains "feral (cat)".  Because "feral (cat)" is a
    --   substring of neither "feral (bear)" and vice-versa, the substring check
    --   in the loop below correctly separates the two roles.
    --   Manual MS-Changes entry "Feral" (no suffix) resolves to no alias and stays
    --   "feral" — "feral (cat)":find("feral") = true, so a manual override grants
    --   the player the benefit of the doubt for any feral item (intentional).
    --
    -- FROST COLLISION NOTE (known limitation):
    --   "Frost" as an inspect spec covers both Frost DK and Frost Mage.  In practice
    --   this is harmless: Frost Mages wear cloth and cannot equip DK weapons/plate,
    --   and no Frost Mage BiS item in our DB is shared with Frost DK.  A proper fix
    --   would require class-aware spec tokens (out of scope).
    --
    -- HOLY / RESTORATION / PROTECTION COLLISIONS (intentional):
    --   "Holy Paladin" and "Holy Priest" both → "holy".
    --   "Restoration Druid" and "Restoration Shaman" both → "restoration".
    --   "Protection Warrior" and "Protection Paladin" both → "protection".
    --   Healer/tank roles intentionally share gear in the DB; cross-class rolling
    --   for those slots is desired.
    local SPEC_ALIASES = {
        -- ── Warrior ──────────────────────────────────────────────────────────
        ["fury warrior"]             = "fury",
        ["arms warrior"]             = "arms",
        ["protection warrior"]       = "protection",
        ["prot warrior"]             = "protection",
        ["prot war"]                 = "protection",
        -- ── Paladin ──────────────────────────────────────────────────────────
        ["ret paladin"]              = "retribution",
        ["retribution paladin"]      = "retribution",
        ["retri paladin"]            = "retribution",
        ["retri pala"]               = "retribution",
        ["ret pala"]                 = "retribution",
        ["retri"]                    = "retribution",
        ["retribution pala"]         = "retribution",
        ["holy paladin"]             = "holy",
        ["holy pala"]                = "holy",
        ["protection paladin"]       = "protection",
        ["prot paladin"]             = "protection",
        ["prot pala"]                = "protection",
        ["protection pala"]          = "protection",
        -- ── Rogue ────────────────────────────────────────────────────────────
        ["combat rogue"]             = "combat",
        ["assassination rogue"]      = "assassination",
        ["subtlety rogue"]           = "subtlety",
        -- ── Druid ────────────────────────────────────────────────────────────
        -- DB strings → canonical inspect tokens (preserves bear/cat distinction)
        ["feral cat druid"]          = "feral (cat)",
        ["feral cat"]                = "feral (cat)",
        ["cat druid"]                = "feral (cat)",
        ["bear tank druid"]          = "feral (bear)",
        ["bear druid"]               = "feral (bear)",
        ["bear tank"]                = "feral (bear)",
        ["feral bear"]               = "feral (bear)",
        ["bear"]                     = "feral (bear)",
        ["balance druid"]            = "balance",
        ["boomkin"]                  = "balance",
        ["boomie"]                   = "balance",
        ["moonkin"]                  = "balance",
        ["restoration druid"]        = "restoration",
        ["resto druid"]              = "restoration",
        ["tree"]                     = "restoration",
        -- ── Priest ───────────────────────────────────────────────────────────
        ["holy priest"]              = "holy",
        ["discipline priest"]        = "discipline",
        ["disc priest"]              = "discipline",
        ["disc"]                     = "discipline",
        ["shadow priest"]            = "shadow",
        ["spriest"]                  = "shadow",
        ["sp"]                       = "shadow",
        -- ── Shaman ───────────────────────────────────────────────────────────
        ["restoration shaman"]       = "restoration",
        ["resto shaman"]             = "restoration",
        ["resto sham"]               = "restoration",
        ["elemental shaman"]         = "elemental",
        ["ele shaman"]               = "elemental",
        ["ele sham"]                 = "elemental",
        ["enhancement shaman"]       = "enhancement",
        ["enh shaman"]               = "enhancement",
        ["enh sham"]                 = "enhancement",
        ["spellhancement shaman"]    = "enhancement",
        ["spellhancement"]           = "enhancement",
        -- ── Mage ─────────────────────────────────────────────────────────────
        ["arcane mage"]              = "arcane",
        ["fire mage"]                = "fire",
        ["fire ttw mage"]            = "fire",
        ["fire ttw"]                 = "fire",
        ["frost mage"]               = "frost",
        -- ── Warlock ──────────────────────────────────────────────────────────
        ["affliction warlock"]       = "affliction",
        ["demonology warlock"]       = "demonology",
        ["demo warlock"]             = "demonology",
        ["demo"]                     = "demonology",
        ["destruction warlock"]      = "destruction",
        ["destro warlock"]           = "destruction",
        ["destro"]                   = "destruction",
        -- ── Hunter ───────────────────────────────────────────────────────────
        ["marksmanship hunter"]      = "marksmanship",
        ["mm hunter"]                = "marksmanship",
        ["mm"]                       = "marksmanship",
        ["survival hunter"]          = "survival",
        ["surv hunter"]              = "survival",
        ["surv"]                     = "survival",
        ["beast mastery hunter"]     = "beast mastery",
        ["bm hunter"]                = "beast mastery",
        ["bm"]                       = "beast mastery",
        -- ── Death Knight ─────────────────────────────────────────────────────
        ["unholy death knight"]      = "unholy",
        ["unholy dk"]                = "unholy",
        ["frost death knight"]       = "frost",
        ["frost dk"]                 = "frost",
        ["blood tank death knight"]  = "blood",
        ["blood dk dps"]             = "blood",
        ["blood dps"]                = "blood",
        ["blood death knight"]       = "blood",
        ["blood dk"]                 = "blood",
        ["blood tank"]               = "blood",
        ["blood tank dk"]            = "blood",
        -- ── Canonical names produced by ParseSpecText (BiS_Tab) ──────────────
        ["spellhancement shaman"]    = "enhancement",
        ["feral cat druid"]          = "feral (cat)",
        ["bear tank druid"]          = "feral (bear)",
        ["balance druid"]            = "balance",
        ["restoration druid"]        = "restoration",
        ["holy priest"]              = "holy",
        ["shadow priest"]            = "shadow",
        ["discipline priest"]        = "discipline",
        ["restoration shaman"]       = "restoration",
        ["elemental shaman"]         = "elemental",
        ["enhancement shaman"]       = "enhancement",
        ["fury warrior"]             = "fury",
        ["arms warrior"]             = "arms",
        ["protection warrior"]       = "protection",
        ["retribution paladin"]      = "retribution",
        ["holy paladin"]             = "holy",
        ["protection paladin"]       = "protection",
        ["combat rogue"]             = "combat",
        ["assassination rogue"]      = "assassination",
        ["subtlety rogue"]           = "subtlety",
        ["arcane mage"]              = "arcane",
        ["fire mage"]                = "fire",
        ["fire ttw mage"]            = "fire",
        ["frost mage"]               = "frost",
        ["affliction warlock"]       = "affliction",
        ["demonology warlock"]       = "demonology",
        ["destruction warlock"]      = "destruction",
        ["marksmanship hunter"]      = "marksmanship",
        ["survival hunter"]          = "survival",
        ["beast mastery hunter"]     = "beast mastery",
    }

    -- Normalize allowed specs using alias map
    local normalizedAllowed = {}
    for k in pairs(allowed) do
        local alias = SPEC_ALIASES[k]
        normalizedAllowed[alias or k] = true
    end

    -- Check player spec against normalized allowed
    local specLower = playerSpec:lower()
    -- Also resolve the player spec through aliases
    local resolvedSpec = SPEC_ALIASES[specLower] or specLower

    for allowedSpec in pairs(normalizedAllowed) do
        if allowedSpec:find(resolvedSpec, 1, true) or resolvedSpec:find(allowedSpec, 1, true) then
            return true, ""
        end
        -- Also check original specLower for partial matches
        if allowedSpec:find(specLower, 1, true) or specLower:find(allowedSpec, 1, true) then
            return true, ""
        end
    end

    -- Not eligible - show both BIS and ALT specs in the message
    local bisText = data.bis_text or ""
    local altText = data.alt_text or ""
    local eligible = bisText
    if altText ~= "" then
        eligible = eligible .. (bisText ~= "" and " | Alt: " or "Alt: ") .. altText
    end
    if eligible == "" then eligible = "Unknown" end
    local reason = playerSpec .. " is not eligible (BIS: " .. (bisText ~= "" and bisText or "none") ..
                   (altText ~= "" and " / ALT: " .. altText or "") .. ")"
    return false, reason
end

-- Register UI refs (called from Quick Access builder)
function RNTools_SetSpecStatusFS(statusFS, progressFS)
    SPEC_statusFS   = statusFS
    SPEC_progressFS = progressFS
end

-- Get spec DB summary for display
function RNTools_GetSpecDBCount()
    local n = 0
    if RNToolsSpecDB then
        for _ in pairs(RNToolsSpecDB) do n = n + 1 end
    end
    return n
end

-- ── AceComm: share MS changes + buff order with addon officers ───────────────

local AC = {}
LibStub("AceComm-3.0"):Embed(AC)
local AS = LibStub("AceSerializer-3.0")
local COMM_PREFIX = "RNMS"

local function GetSenderRaidRank(name)
    local n = GetNumRaidMembers()
    for i = 1, n do
        local rName, rank, _, _, _, _, _, _, _, _, isML = GetRaidRosterInfo(i)
        if rName and rName:lower() == name:lower() then
            return rank, isML  -- rank 1=assist, 2=leader
        end
    end
    return 0, false
end

-- Receive MS data from another addon user
AC:RegisterComm(COMM_PREFIX, function(prefix, message, distribution, sender)
    -- Ignore our own messages
    if sender == UnitName("player") then return end

    -- Only accept from RL/ML/Assist
    local rank, isML = GetSenderRaidRank(sender)
    if rank < 1 and not isML then
        DEFAULT_CHAT_FRAME:AddMessage(
            "|cff888888[MS Changes]:|r Ignored data from |cff44aaff" .. sender ..
            "|r (no officer rank).")
        return
    end

    local ok, payload = AS:Deserialize(message)
    if not ok or type(payload) ~= "table" or payload.type ~= "RNMS_share" then return end

    local changed = 0
    if type(payload.changes) == "table" then
        for k, v in pairs(payload.changes) do
            msChanges[k] = v
            changed = changed + 1
        end
        if RebuildMSRows then RebuildMSRows() end
        if RefreshMSCount then RefreshMSCount() end
    end

    if type(payload.buffs) == "table" then
        for k, v in pairs(payload.buffs) do
            if buffAssignments[k] ~= nil then buffAssignments[k] = v end
        end
        if RefreshBuffDropdownDisplays then RefreshBuffDropdownDisplays() end
    end

    DEFAULT_CHAT_FRAME:AddMessage(
        "|cffFFD100[MS Changes]:|r Received " .. changed ..
        " MS record" .. (changed == 1 and "" or "s") ..
        " + buff order from |cff44aaff" .. sender .. "|r.")
end)

-- Send MS changes + buff assignments to all raid officers with the addon
function RNTools_ShareMSData()
    if not (RM_HasPermission and RM_HasPermission()) then
        DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[MS Changes]:|r You need RL/ML/Assist to share data.")
        return
    end

    local n = 0; for _ in pairs(msChanges) do n = n + 1 end
    if n == 0 then
        DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[MS Changes]:|r No changes to share.")
        return
    end

    local payload = {
        type    = "RNMS_share",
        changes = msChanges,
        buffs   = buffAssignments,
    }
    local data = AS:Serialize(payload)
    if type(data) ~= "string" then
        DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[MS Changes]:|r Failed to serialize data.")
        return
    end

    -- Send only to officers (Assist/RL/ML) in the raid
    local sent = 0
    local myName = UnitName("player")
    local raidSize = GetNumRaidMembers()
    for i = 1, raidSize do
        local rName, rank, _, _, _, _, _, online, _, _, isML = GetRaidRosterInfo(i)
        if rName and rName ~= myName and online and (rank >= 1 or isML) then
            AC:SendCommMessage(COMM_PREFIX, data, "WHISPER", rName)
            SendChatMessage(
                "[RealNibba's Raid Tools] Sent you " .. n .. " MS Change" ..
                (n == 1 and "" or "s") .. " + Buff Order assignments.",
                "WHISPER", nil, rName)
            sent = sent + 1
        end
    end

    if sent == 0 then
        DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[MS Changes]:|r No online officers found to share with.")
    else
        DEFAULT_CHAT_FRAME:AddMessage(
            "|cff20ff60[MS Changes]:|r Shared " .. n ..
            " MS record" .. (n == 1 and "" or "s") ..
            " + buff order with " .. sent .. " officer" .. (sent == 1 and "" or "s") .. ".")
    end
end

-- ── Persistence: save msChanges + buffAssignments across sessions ────────────

local msPersistFrame = CreateFrame("Frame")
msPersistFrame:RegisterEvent("PLAYER_LOGIN")
msPersistFrame:RegisterEvent("PLAYER_LOGOUT")
msPersistFrame:SetScript("OnEvent", function(self, event)
    if event == "PLAYER_LOGIN" then
        if type(RNTools_MSChanges) ~= "table" then RNTools_MSChanges = {} end
        -- Restore MS changes
        if type(RNTools_MSChanges.changes) == "table" then
            for k, v in pairs(RNTools_MSChanges.changes) do
                msChanges[k] = v
            end
            if RebuildMSRows then RebuildMSRows() end
            if RefreshMSCount then RefreshMSCount() end
        end
        -- Restore buff assignments
        if type(RNTools_MSChanges.buffAssignments) == "table" then
            for k, v in pairs(RNTools_MSChanges.buffAssignments) do
                if buffAssignments[k] ~= nil then
                    buffAssignments[k] = v
                end
            end
            if RefreshBuffDropdownDisplays then RefreshBuffDropdownDisplays() end
        end

    elseif event == "PLAYER_LOGOUT" then
        if type(RNTools_MSChanges) ~= "table" then RNTools_MSChanges = {} end
        -- Save MS changes
        RNTools_MSChanges.changes = {}
        for k, v in pairs(msChanges) do
            RNTools_MSChanges.changes[k] = v
        end
        -- Save buff assignments
        RNTools_MSChanges.buffAssignments = {}
        for k, v in pairs(buffAssignments) do
            RNTools_MSChanges.buffAssignments[k] = v
        end
    end
end)
