-------------------------------------------------------------------------------
-- RealNibba's Raid Tools - RaidBrowser.lua
-- Listens to Trade/General/Yell for LFM raid messages and displays them.
-- Core parsing logic ported from RaidBrowser addon (credit: original author).
-- No external libs needed - pure WoW 3.3.5a Lua.
-------------------------------------------------------------------------------

local AceGUI = LibStub("AceGUI-3.0")

local RB = {}
RB.messages   = {}   -- [sender] = {raid_info, roles, gs, time, message}
RB.expiry     = 600  -- seconds; updated from P().rb_expiry_min in OnEnable via RNTools_SetRBExpiry
RB.enabled    = false

-- Filter state (All = no filter)
local RB_Filter = {
    raidType = "All",   -- "All","ICC","ICC 8/12","Ulduar","ToC","ToGC","VoA","Naxxramas","RS","OS","Naxx"
    raidSize = "All",   -- "All","10","25"
    role     = "All",   -- "All","dps","tank","healer"
    weekly = "All",
}

-- Sort state, click-to-sort column headers in both Raid Browser and Mini Raid Browser.
-- key: "instance"|"gs"|"roles"|"sender"|"time"  dir: "asc"|"desc"
local RB_Sort = { key = "time", dir = "desc" }

-- Age refresh: only update age text every 15 seconds (lighter)
local RB_AgeLastUpdate = 0

local function RB_RaidMatchesFilter(info)
    local f = RB_Filter
    local name = info.raid and info.raid.name or ""
    -- Raid type filter
    if f.raidType ~= "All" then
        local t = f.raidType
        if t == "ICC" then
            if not (name:find("icc") and not name:find("8/12") and not name:find("partial") and not name:find("rep")) then return false end
        elseif t == "ICC 8/12" then
            if not name:find("8/12") then return false end
        elseif t == "Ulduar" then
            if not name:find("ulduar") then return false end
        elseif t == "ToC" then
            if not (name:find("toc") and not name:find("hc")) then return false end
        elseif t == "ToGC" then
            if not (name:find("toc") and name:find("hc")) then return false end
        elseif t == "VoA" then
            if not name:find("voa") then return false end
        elseif t == "Naxxramas" then
            if not name:find("naxx") then return false end
        elseif t == "RS" then
            if not name:find("rs") then return false end
        elseif t == "OS" then
            if not name:find("os") then return false end
        end
    end
    -- Raid size filter
    if f.raidSize ~= "All" then
        local sz = tostring(info.raid and info.raid.size or 0)
        if sz ~= f.raidSize then return false end
    end
    -- Role filter
    if f.role ~= "All" then
        local found = false
        if info.roles then
            for _, r in ipairs(info.roles) do
                if r == f.role then found = true; break end
            end
        end
        if not found then return false end
    end
    if f.weekly=="Weekly only" and not info.weekly then return false end
    if f.weekly=="Non-weekly" and info.weekly then return false end
    return true
end

-------------------------------------------------------------------------------
-- std.algorithm stubs needed by the parser
-------------------------------------------------------------------------------
local std = { algorithm = {} }

function std.algorithm.find_if(t, fn)
    for i, v in ipairs(t) do
        if fn(v) then return i end
    end
    return nil
end

function std.algorithm.transform(t, fn)
    local r = {}
    for i, v in ipairs(t) do r[i] = fn(v) end
    return r
end

function std.algorithm.copy_back(t1, t2)
    local r = {}
    for _, v in ipairs(t1) do r[#r+1] = v end
    for _, v in ipairs(t2) do r[#r+1] = v end
    return r
end

-------------------------------------------------------------------------------
-- Parser (ported verbatim from RaidBrowser/core.lua, no changes)
-------------------------------------------------------------------------------
-- в”Ђв”Ђ PARSER: verbatim from original RaidBrowser core.lua в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
local sep_chars = '%s-_,.<>%*)(/#+&x'
local sep       = '[' .. sep_chars .. ']'
-- в”Ђв”Ђ Weekly quest alias map: any key present in the normalised message в†’ weekly=true
-- Covers all 11 WotLK weekly raid quests + every common Warmane shorthand.
-- Keys are lowercase; matched against NormalizeMsg() output (punct/brackets stripped).
local weeklyAliases = {
    -- Generic weekly keyword
    ["weekly"] = 1,
    -- в”Ђв”Ђ Naxxramas weeklies в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
    ["anubrekhan must die"]           = 1,   -- Arachnid Quarter
    ["anub must die"]                 = 1,
    ["anub"]                          = 1,
    ["noth the plaguebringer must die"]= 1,  -- Plague Quarter
    ["noth must die"]                 = 1,
    ["noth"]                          = 1,
    ["instructor razuvious must die"] = 1,   -- Military Quarter
    ["razuvious must die"]            = 1,
    ["razuvious"]                     = 1,
    ["patchwerk must die"]            = 1,   -- Construct Quarter
    ["patch must die"]                = 1,
    ["patch"]                         = 1,
    ["weekly patch"]                  = 1,
    -- в”Ђв”Ђ Eye of Eternity в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
    ["malygos must die"]              = 1,
    ["maly must die"]                 = 1,
    ["maly"]                          = 1,
    ["weekly maly"]                   = 1,
    -- в”Ђв”Ђ Obsidian Sanctum в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
    ["sartharion must die"]           = 1,
    ["sarth must die"]                = 1,
    ["sarth"]                         = 1,
    ["weekly sarth"]                  = 1,
    -- в”Ђв”Ђ Ulduar weeklies в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
    ["flame leviathan must die"]      = 1,
    ["fl must die"]                   = 1,
    ["weekly fl"]                     = 1,
    ["fl"]                            = 1,   -- short; only fires if "weekly" also present (see NeedsWeeklyContext)
    ["razorscale must die"]           = 1,
    ["raz must die"]                  = 1,
    ["weekly raz"]                    = 1,
    ["raz"]                           = 1,
    ["ignis the furnace master must die"] = 1,
    ["ignis must die"]                = 1,
    ["weekly ignis"]                  = 1,
    ["ignis"]                         = 1,
    ["xt002 deconstructor must die"]  = 1,
    ["xt must die"]                   = 1,
    ["weekly xt"]                     = 1,
    ["xt"]                            = 1,
    ["xt002"]                         = 1,
    -- в”Ђв”Ђ Trial of the Crusader в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
    ["lord jaraxxus must die"]        = 1,
    ["jaraxxus must die"]             = 1,
    ["jarax must die"]                = 1,
    ["weekly jaraxxus"]               = 1,
    ["weekly jarax"]                  = 1,
    ["jarax"]                         = 1,
    ["jaraxxus"]                      = 1,
    -- в”Ђв”Ђ Icecrown Citadel в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
    ["lord marrowgar must die"]       = 1,
    ["marrowgar must die"]            = 1,
    ["marrow must die"]               = 1,
    ["weekly marrowgar"]              = 1,
    ["weekly marrow"]                 = 1,
    ["marrow"]                        = 1,
    ["marrowgar"]                     = 1,
}

-- Ambiguous single-word shorthands that only count as "weekly" when
-- "weekly" itself (or "wkly") also appears in the message.
local weeklyContextRequired = {
    ["fl"]=1,  ["raz"]=1,     ["xt"]=1,    ["xt002"]=1, ["patch"]=1,
    ["sarth"]=1,["maly"]=1,  ["ignis"]=1, ["jarax"]=1, ["jaraxxus"]=1,
    ["anub"]=1, ["noth"]=1,  ["marrow"]=1,["marrowgar"]=1,
}

-- Strip colour codes, brackets, punctuation; collapse whitespace.
-- "[Patchwerk Must Die!]" в†’ "patchwerk must die"
-- "LFM  Weekly  FL"       в†’ "lfm weekly fl"
-- "XT-002"                в†’ "xt002"
local function NormalizeMsg(raw)
    local s = raw:lower()
    s = s:gsub('\124c%x+\124h.-\124h(%[.-%])\124h\124r', '%1')
    s = s:gsub('\124[ch]%S*', '')
    s = s:gsub('%[(.-)%]', ' %1 ')
    s = s:gsub('[%!%?%:%(%){%}%+%%@#&\'"~`]', ' ')
    s = s:gsub('%-', '')
    s = s:gsub('/', ' ')
    s = s:gsub('%s+', ' ')
    return (s:match('^%s*(.-)%s*$'))
end

-- Returns true if the normalised message is a weekly raid quest run.
-- Context-required aliases (fl, xt, patchвЂ¦) only fire when "weekly" is also present.
local function IsWeeklyMessage(norm)
    local hasWeeklyWord = norm:find('weekly', 1, true) or norm:find('wkly', 1, true)
    for alias, _ in pairs(weeklyAliases) do
        if norm:find(alias, 1, true) then
            if weeklyContextRequired[alias] then
                if hasWeeklyWord then return true end
            else
                return true
            end
        end
    end
    return false
end

local role_sep  = '[' .. sep_chars .. 'x\\/' .. ']'
local csep      = sep .. '*'
local psep      = sep .. '+'
local wtext     = '[%w' .. sep_chars .. ']*'
local meta_char = '\194\191'

local meta_or_sep = '[^' .. meta_char .. ']?' .. '[' .. sep .. ']?'
local non_meta    = '[^' .. meta_char .. ']*'

local function make_meta(text) return meta_char..text..meta_char end

local meta_role  = make_meta('role')
local meta_roles = make_meta('roles')
local meta_raid  = make_meta('raid')
local meta_gs    = make_meta('gs')
local meta_guild = make_meta('guild')

local raid_patterns_template = {
    hc = {
        '<raid>'..csep..'<size>'..csep..'m?a?n?'..csep..'f?u?l?l?'..csep..'hc?',
        sep..'hc?'..csep..'<raid>'..csep..'<size>',
        '<raid>'..csep..'hc?'..csep..'<size>',
        sep..'<size>'..csep..'ma?n?'..csep..'<raid>'..sep,
        '^<size>'..csep..'<raid>'..sep,
    },
    nm = {
        '<raid>'..csep..'<size>'..csep..'m?a?n?'..csep..'f?u?l?l?'..csep..'n?m?'..csep,
        sep..'nm?'..csep..'<raid>'..csep..'<size>',
        '<raid>'..csep..'n?m?'..csep..'<size>',
        sep..'<size>'..csep..'ma?n?'..csep..'<raid>'..sep,
        '^<size>'..csep..'<raid>'..sep,
    },
    simple = {
        '<raid>'..csep..'<size>'..csep..'m[an][an]'..csep..'f?u?l?l?',
        '<raid>'..csep..'<size>'..csep..'f?u?l?l?',
        '^<size>'..csep..'m?a?n?'..csep..'f?u?l?l?'..csep..'<raid>'..sep,
        sep..'<size>'..csep..'m?a?n?'..csep..'<raid>'..sep,
    },
}

local function create_pattern(raid_name_pattern, size, difficulty)
    difficulty = difficulty or 'nm'
    local sz = tostring(size)
    if size == 10 then sz = '1[0o]' elseif size == 40 then sz = '4[0p]' end
    local result = {}
    for _, p in ipairs(raid_patterns_template[difficulty]) do
        local pp = p:gsub('<raid>', raid_name_pattern):gsub('<size>', sz)
        result[#result+1] = pp
    end
    return result
end

local function copy_back(t1, t2)
    local r = {}
    for _,v in ipairs(t1) do r[#r+1]=v end
    for _,v in ipairs(t2) do r[#r+1]=v end
    return r
end

local raid_list = {
    -- ICC rep/farm
    { name='icc25rep', instance_name='Icecrown Citadel', size=25, patterns={
        'icc'..csep..'25'..csep..'m?a?n?'..csep..'repu?t?a?t?i?o?n?'..csep,
        'icc'..csep..'repu?t?a?t?i?o?n?'..csep..'25'..csep..'m?a?n?',
        'icc'..csep..'25'..csep..'nm?'..csep..'farm',
    }},
    { name='icc10rep', instance_name='Icecrown Citadel', size=10, patterns={
        'icc'..csep..'10'..csep..'m?a?n?'..csep..'repu?t?a?t?i?o?n?'..csep,
        'icc'..csep..'repu?t?a?t?i?o?n?'..csep..'10',
        'icc'..csep..'10'..csep..'nm?'..csep..'farm',
        'icc'..csep..'repu?t?a?t?i?o?n?',
    }},
    -- ICC HC before NM (order matters)
    -- 8/12 partial heroic (checked BEFORE full hc/nm to avoid double-match)
    -- Patterns include optional trailing hc/nm so icc25nm can't double-match the prefix
    { name='icc25_8/12', instance_name='Icecrown Citadel', size=25, patterns={
        'icc'..csep..'25'..csep..'8[/%-]12'..csep..'h?c?',
        'icc25'..csep..'8[/%-]12'..csep..'h?c?',
        '8[/%-]12'..csep..'icc'..csep..'25',
    }},
    { name='icc10_8/12', instance_name='Icecrown Citadel', size=10, patterns={
        'icc'..csep..'1[0o]'..csep..'8[/%-]12'..csep..'h?c?',
        'icc1[0o]'..csep..'8[/%-]12'..csep..'h?c?',
        '8[/%-]12'..csep..'icc'..csep..'1[0o]',
    }},
    -- Generic x/12 partials (4/12, 6/12, 10/12 etc)
    { name='icc25_partial', instance_name='Icecrown Citadel', size=25, patterns={
        'icc'..csep..'25'..csep..'[1-9][0-9]?[/%-]12'..csep..'h?c?',
        'icc25'..csep..'[1-9][0-9]?[/%-]12'..csep..'h?c?',
    }},
    { name='icc10_partial', instance_name='Icecrown Citadel', size=10, patterns={
        'icc'..csep..'1[0o]'..csep..'[1-9][0-9]?[/%-]12'..csep..'h?c?',
        'icc1[0o]'..csep..'[1-9][0-9]?[/%-]12'..csep..'h?c?',
    }},
    { name='icc10hc', instance_name='Icecrown Citadel', size=10,
      patterns=copy_back(create_pattern('icc',10,'hc'),{
        'bane of the fallen king',
        '1[0o]'..csep..'icc',
      }) },
    { name='icc25hc', instance_name='Icecrown Citadel', size=25,
      patterns=copy_back(create_pattern('icc',25,'hc'),{
        'the light of dawn',
        '25'..csep..'icc',
      }) },
    { name='icc10nm', instance_name='Icecrown Citadel', size=10,
      patterns=copy_back(create_pattern('icc',10,'nm'),{
        'icecrown'..csep..'citadel'..csep..'10',
        -- Marrowgar Must Die! weekly
        '(lord'..csep..')?marrowgar'..csep..'must'..csep..'die',
        'marrow'..csep..'must'..csep..'die',
        sep..'weekly'..csep..'marrow'..sep,
        sep..'marrow'..csep..'weekly',
      }) },
    { name='icc25nm', instance_name='Icecrown Citadel', size=25,
      patterns=copy_back(create_pattern('icc',25,'nm'),{
        'icecrown'..csep..'citadel'..csep..'25',
      }) },
    -- Trial of the Crusader
    { name='toc10hc', instance_name='Trial of the Crusader', size=10,
      patterns=copy_back(create_pattern('toc',10,'hc'),{
        'togc'..csep..'10', '10'..csep..'toc', '10'..csep..'togc', '10'..csep..'totc',
      }) },
    { name='toc25hc', instance_name='Trial of the Crusader', size=25,
      patterns=copy_back(create_pattern('toc',25,'hc'),{
        'togc'..csep..'25', '25'..csep..'toc', '25'..csep..'togc', '25'..csep..'totc',
      }) },
    { name='toc10nm', instance_name='Trial of the Crusader', size=10,
      patterns=copy_back(create_pattern('toc',10,'nm'),{
        '%[call of the crusade %(10 player%)%]',
        'totc'..csep..'10',
        -- Jaraxxus Must Die! weekly
        '(lord'..csep..')?jaraxxus'..csep..'must'..csep..'die',
        'jarax'..csep..'must'..csep..'die',
        sep..'weekly'..csep..'jarax'..sep,
        sep..'jarax'..csep..'weekly',
        sep..'weekly'..csep..'jaraxxus'..sep,
      }) },
    { name='toc25nm', instance_name='Trial of the Crusader', size=25,
      patterns=copy_back(create_pattern('toc',25,'nm'),{
        '%[call of the crusade %(25 player%)%]',
        'totc'..csep..'25',
      }) },
    -- Ruby Sanctum
    { name='rs10hc', instance_name='The Ruby Sanctum', size=10, patterns=create_pattern('rs',10,'hc') },
    { name='rs25hc', instance_name='The Ruby Sanctum', size=25,
      patterns=copy_back(create_pattern('rs',25,'hc'),{'ruby'..csep..'sanctum'..csep..'25'}) },
    { name='rs10nm', instance_name='The Ruby Sanctum', size=10,
      patterns=copy_back(create_pattern('rs',10,'nm'),{'ruby'..csep..'sanctum'..csep..'10'}) },
    { name='rs25nm', instance_name='The Ruby Sanctum', size=25,
      patterns=copy_back({' rs 25n '},create_pattern('rs',25,'nm')) },
    -- Vault of Archavon
    { name='voa10', instance_name='Vault of Archavon', size=10, patterns=create_pattern('voa',10,'simple') },
    { name='voa25', instance_name='Vault of Archavon', size=25, patterns=create_pattern('voa',25,'simple') },
    -- Ulduar  (aliases: ulduar, uldu, ull?a?duar)
    -- Weekly quests: Flame Leviathan, Razorscale, Ignis, XT-002
    { name='ulduar10', instance_name='Ulduar', size=10, patterns={
        'ull?a?d[au]?[au]?r?'..csep..'10',
        'uldu'..csep..'10',
        '1[0o]'..csep..'ull?a?d[au]?[au]?r?',
        '1[0o]'..csep..'uldu',
        -- weekly shorthands (size implied by context)
        'flame'..csep..'leviathan'..csep..'must'..csep..'die',
        'fl'..csep..'must'..csep..'die',
        sep..'weekly'..csep..'fl'..sep,
        sep..'fl'..csep..'weekly',
        'razorscale'..csep..'must'..csep..'die',
        'raz'..csep..'must'..csep..'die',
        sep..'weekly'..csep..'raz'..sep,
        'ignis'..csep..'(the'..csep..'furnace'..csep..'master'..csep..')?must'..csep..'die',
        sep..'weekly'..csep..'ignis'..sep,
        'xt'..csep..'0?0?2?'..csep..'(deconstructor'..csep..')?must'..csep..'die',
        sep..'weekly'..csep..'xt'..sep,
        sep..'xt'..csep..'weekly'..sep,
    }},
    { name='ulduar25', instance_name='Ulduar', size=25, patterns={
        'ull?a?d[au]?[au]?r?'..csep..'25',
        'uldu'..csep..'25',
        '25'..csep..'ull?a?d[au]?[au]?r?',
        '25'..csep..'uldu',
    }},
    -- Obsidian Sanctum  (weekly: Sartharion Must Die!)
    { name='os10', instance_name='The Obsidian Sanctum', size=10,
      patterns=copy_back(create_pattern('os',10,'simple'),{
        'sartha?r?i?o?n?'..csep..'must'..csep..'die',
        'sarth'..csep..'must'..csep..'die',
        sep..'weekly'..csep..'sarth'..sep,
        sep..'sarth'..csep..'weekly',
        sep..'obsidian'..csep..'sanctum',
      }) },
    { name='os25', instance_name='The Obsidian Sanctum', size=25,
      patterns=copy_back(create_pattern('os',25,'simple'),{
        '%[sartharion must die!%]',
      }) },
    -- Naxxramas  (weeklies: Anub'Rekhan, Noth, Razuvious, Patchwerk)
    { name='naxx10', instance_name='Naxxramas', size=10, patterns={
        'the fall of naxxramas %(10 player%)',
        'naxx?ramm?as'..csep..'10', 'naxx?'..csep..'10', 'naxx'..sep..'weekly',
        '1[0o]'..csep..'naxx?ramm?as', '1[0o]'..csep..'naxx?',
        -- Patchwerk Must Die!
        'patchwerk'..csep..'must'..csep..'die',
        sep..'weekly'..csep..'patch'..sep,
        sep..'patch'..csep..'weekly',
        -- Anub'Rekhan Must Die!
        'anub.?rekhan'..csep..'must'..csep..'die',
        sep..'weekly'..csep..'anub'..sep,
        -- Noth the Plaguebringer Must Die!
        'noth'..csep..'(the'..csep..'plaguebringer'..csep..')?must'..csep..'die',
        sep..'weekly'..csep..'noth'..sep,
        -- Instructor Razuvious Must Die!
        '(instructor'..csep..')?razuvious'..csep..'must'..csep..'die',
        sep..'weekly'..csep..'razuvious'..sep,
    }},
    { name='naxx25', instance_name='Naxxramas', size=25, patterns={
        'the fall of naxxramas %(25 player%)',
        'naxx?ramm?as'..csep..'25', 'naxx?'..csep..'25',
        '25'..csep..'naxx?ramm?as', '25'..csep..'naxx?',
        -- weekly boss patterns also valid for 25 (size often omitted)
        'patchwerk'..csep..'must'..csep..'die',
        'anub.?rekhan'..csep..'must'..csep..'die',
        'noth'..csep..'(the'..csep..'plaguebringer'..csep..')?must'..csep..'die',
        '(instructor'..csep..')?razuvious'..csep..'must'..csep..'die',
    }},
    -- Eye of Eternity  (weekly: Malygos Must Die!)
    { name='eoe10', instance_name='The Eye of Eternity', size=10,
      patterns=copy_back(create_pattern('eoe',10,'simple'),{
        ' eoe'..csep..'1?0?', 'malygo?s'..csep..'1?0?',
        'malygos'..csep..'must'..csep..'die',
        'maly'..csep..'must'..csep..'die',
        sep..'weekly'..csep..'maly'..sep,
        sep..'maly'..csep..'weekly',
      }) },
    { name='eoe25', instance_name='The Eye of Eternity', size=25,
      patterns=copy_back(create_pattern('eoe',25,'simple'),{
        'malygo?s'..csep..'25',
      }) },
    -- Onyxia
    { name='onyxia25', instance_name="Onyxia's Lair", size=25,
      patterns={"onyxia's lair (25 player)", 'onyx?i?a?'..csep..'25', sep..'ony'..csep..'25'..sep} },
    { name='onyxia10', instance_name="Onyxia's Lair", size=10,
      patterns={"onyxia's lair (10 player)", 'onyx?i?a?'..csep..'10', sep..'ony'..csep..'10'..sep} },
    -- Classic raids (Warmane also has progression)
    { name='molten_core40', instance_name='Molten Core', size=40,
      patterns={'molte?n'..csep..'core?', sep..'mc'..csep..'4?0?'..sep} },
    { name='black_temple25', instance_name='Black Temple', size=25,
      patterns={'black'..csep..'temple','[%s-_,.]+bt'..csep..'25[%s-_,.]+'} },
    { name='karazhan10', instance_name='Karazhan', size=10,
      patterns={'karaz?h?a?n?'..csep..'1?0?','^kz'..csep..'10',sep..'kz'..csep..'10'} },
}

local role_patterns = {
    dps = {
        'feral'..csep..'cat'..sep, 'feral'..csep..'d?p?s?',
        'ret'..csep..'pal[al]?[dy]?i?n?',
        'shadow'..csep..'pri?e?st', 'pri?e?st'..csep..'dps',
        'balance'..csep..'[ru][ud][iu]d?',
        'elem?e?n?t?a?l?'..csep..'shamm?[iy]?', 'mage',
        'boo?my?i?', 'boo?mki?n',
        'b[ru][ud][iu]d?',
        'rogue'..meta_or_sep, 'rouge'..meta_or_sep,
        'kitt?y', 'w?a?r?lock', 'spri?e?st', 'hunte?r?s?',
        'm[dp][dp]s'..meta_or_sep, 'r[dp][dp]s', 'dps',
        -- Warmane extras
        'fury'..sep, 'arms'..sep,
        'unholy'..csep..'dk', 'frost'..csep..'dk',
        'enh'..sep, 'enha?'..sep,
        'blood'..csep..'d[kp][kp]?'..csep..'dps', 'bdk'..csep..'dps',
        'mm'..sep, 'marks'..sep,
        'elem'..sep,
    },
    healer = {
        'h[ea][ea]l[ers]*', 'heler',
        'resto'..csep..'d[ru][ud][iu]d?', 'rd[ru][ud][iu]d?',
        meta_or_sep..'r'..csep..'d[ru][ud][iu]d?',
        'tree',
        'resto'..csep..'shamm?y?', 'rshamm?y?',
        meta_or_sep..'r'..csep..'shamm?y?',
        'disco?'..csep..'pri?e?st', 'dpri?e?st',
        meta_or_sep..'d'..csep..'pri?e?st',
        'holl?y'..csep..'palad?i?n?', 'hpala?d?i?n?',
        meta_or_sep..'h'..csep..'palad?i?n?',
        -- Warmane extras
        'hpall?y', 'rsham'..sep, 'rshammy', 'resto'..csep..'sham'..sep,
    },
    tank = {
        role_sep..'[mo]t'..role_sep, role_sep..'[mo]t'..csep..'$',
        'ta*n+a?k+s?', 'b[ea]*rs?',
        'prot'..csep..'pal[al]?[dy]?i?n?',
        -- Warmane extras
        'off'..csep..'tank', 'main'..csep..'tank',
        'prot'..csep..'pall?y', 'ppal'..sep,
        'blood'..csep..'dk', 'bdk'..sep,
        'prot'..csep..'war',
    },
}

local gearscore_patterns = {
    '[1-6].?[0-9]?kgs','[1-6]'..csep..'k[0-9]+','[1-6][.,][0-9]',
    '[1-6]'..csep..'k'..csep..'%+','[1-6]'..csep..'k'..sep,
    '%+?'..csep..'[1-6]'..csep..'k'..sep,'[1-6][0-9][0-9][0-9]','[1-6]%+',
}

local guild_recruit_patterns = {
    'recrui?ti?n?g?','we'..csep..'raid','we'..csep..'are'..csep..'raidi?n?g?',
    '[<({%-][%a%s]+[%-%})>]'..csep..'is'..csep..'a?',
    'is'..csep..'[%a%s]*playe?rs?',
    'autorecruit','raid'..csep..'time','active'..csep..'raiders?',
    'is'..csep..'a'..csep..'[%a]*'..csep..'[pvep][pvep][pvep]'..csep..'guild',
    'lf'..sep..'members',
}

local trade_patterns = { 'wts'..sep, 'wtb'..sep, 'selling'..sep, 'buying'..sep }

local lfm_patterns = {
    'l[fm][fm]?'..non_meta..meta_role..non_meta..meta_raid,
    meta_raid..non_meta..sep..'[0-9]+'..non_meta..meta_role,
    'lfm?'..csep..'[0-9]*'..csep..meta_role..non_meta..meta_gs..'.*'..meta_raid,
    'lfm?'..csep..'all'..non_meta..meta_raid,
    'need'..csep..'all'..non_meta..meta_raid,
    'seek'..csep..'all'..non_meta..meta_raid,
    meta_raid..non_meta..'lfm?'..csep..'all',
    meta_raid..non_meta..'need'..csep..'all',
    meta_raid..non_meta..meta_gs..non_meta..'lf'..csep..'all',
    meta_raid..non_meta..meta_gs..non_meta..'seek'..csep..'all',
    meta_raid..non_meta..meta_gs..non_meta..'need'..csep..'all',
    meta_raid..non_meta..meta_role..non_meta..meta_gs,
    meta_raid..non_meta..'need'..non_meta..meta_role,
    meta_raid..non_meta..'seek'..non_meta..meta_role,
    meta_raid..non_meta..'lfm?'..non_meta..meta_role,
    meta_raid..non_meta..'looki?ng'..csep..'for'..non_meta..meta_role,
    meta_raid..non_meta..'need'..csep..'all',
    meta_raid..non_meta..meta_gs..non_meta..meta_role,
    meta_role..non_meta..'for'..non_meta..meta_raid,
    meta_raid..non_meta..sep..'loot'..sep..non_meta..'wh?i?s?p?e?r?'..csep..'me',
    meta_raid..non_meta..meta_gs..non_meta..'wh?i?s?p?e?r?'..csep..'me',
    meta_raid..non_meta..meta_gs..non_meta..'wh?i?s?p?e?r?'..csep..'[A-Z][a-z]+',
    meta_raid..non_meta..non_meta..'wh?i?s?p?e?r?'..csep..'me'..non_meta..meta_gs,
    meta_raid..non_meta..'run'..non_meta..meta_gs,
    '[0-9]'..non_meta..meta_role..non_meta..meta_raid,
    meta_raid..non_meta..'reserved',
    'l[fm][fm]'..non_meta..meta_raid,
    meta_raid..non_meta..meta_role,
    'new'..csep..'run'..meta_raid,
    -- Extra: messages without lfm/need but clearly LFM
    meta_raid..non_meta..'fresh',
    meta_raid..non_meta..'from'..csep..'start',
    meta_raid..non_meta..'full'..csep..'run',
    meta_raid..non_meta..'full'..csep..'clear',
    meta_raid..non_meta..meta_gs,
    meta_raid..non_meta..'wh?i?s?p?e?r?',
    meta_raid..non_meta..'re[sz][sz]?',
    -- Weekly quest patterns: raid marker already in message via weekly boss name
    -- These fire when lex_raid() matched from a boss-name pattern in raid_list
    meta_raid..non_meta..'weekly',
    'weekly'..non_meta..meta_raid,
    meta_raid..non_meta..'must'..csep..'die',
    -- "must die" was in the original text; after bracket-stripping lex_raid matched
    -- Now we just need any recruiting signal - lfm/need/lf#m/w me
    'lfm?'..non_meta..meta_raid,
    meta_raid..non_meta..'lfm?',
    -- lf1m / lf2m / lf3m style
    'lf[1-9]m?'..non_meta..meta_raid,
    meta_raid..non_meta..'lf[1-9]m?',
    -- whisper signal  ("w me", "pst", "whisper")
    meta_raid..non_meta..'w'..sep..'me',
    meta_raid..non_meta..'pst'..sep,
    meta_raid..non_meta..'wh?i?s?p?',
    -- "need X" patterns
    meta_raid..non_meta..'need'..csep..'%d',
    meta_raid..non_meta..'need'..sep,
    'need'..sep..non_meta..meta_raid,
}

local rolelist_patterns = {
    meta_role..non_meta..'and'..non_meta..meta_role,
    meta_role..non_meta..'or'..non_meta..meta_role,
    meta_role..non_meta..meta_role,
}

local lfg_patterns = {
    '^'..csep..'lfg'..non_meta..meta_raid,
    '^'..csep..meta_gs..non_meta..meta_role..non_meta..'lf'..non_meta..meta_raid,
    '^'..csep..meta_role..non_meta..meta_gs..non_meta..'lf'..non_meta..meta_raid,
}

local guild_patterns = {
    '^guild'..psep..'[a-z]+[%s][a-z]*'..psep,
    '^guild'..sep..'[a-z]+[%s][a-z]*',
}

local function matches_any(msg, pats)
    for _, p in ipairs(pats) do if msg:find(p) then return true end end
    return false
end

local function format_gs(gs)
    gs = gs:gsub('[%s]*%+?',''):gsub('[kgs]',''):gsub(sep,'.')
    local n = tonumber(gs)
    if not n then return '?' end
    if n > 1000 then n=n/1000 elseif n > 100 then n=n/100 elseif n > 10 then n=n/10 end
    return string.format('%.1f', n)
end

local function lex_raid(msg)
    local raid = nil
    local num_unique_raids = 0
    for _, r in ipairs(raid_list) do
        local entry_matched = false
        for _, p in ipairs(r.patterns) do
            local m, n = msg:gsub(p, meta_raid)
            if n > 0 then
                msg = m
                if not entry_matched then
                    entry_matched = true
                    num_unique_raids = num_unique_raids + 1
                    if not raid then raid = r end
                end
                break
            end
        end
    end
    return raid, msg, num_unique_raids
end

local function lex_roles(roles, msg, role)
    local found = false
    for _, p in ipairs(role_patterns[role]) do
        local m, n = msg:gsub(p, meta_role)
        if n > 0 then msg = m; found = true end
    end
    if found then roles[#roles+1] = role end
    return roles, msg
end

local function lex_gs(msg)
    for _, p in ipairs(gearscore_patterns) do
        local t = msg:match(p)
        if t then return format_gs(t), msg:gsub(t, meta_gs, 1) end
    end
    return nil, msg
end

local function lex_achievements(msg)
    -- Strip WoW hyperlink colour codes, keep inner text
    msg = msg:gsub('\124cffffff00\124h.-\124h(%[.-%])\124h\124r', '%1')
    -- Also strip plain [Quest Name] brackets typed by players (e.g. [Patchwerk Must Die!])
    -- Keep the text inside so patterns can still match boss/quest names
    msg = msg:gsub('%[(.-)%]', ' %1 ')
    -- Collapse any double-spaces introduced
    msg = msg:gsub('%s%s+', ' ')
    return msg
end

local function lex_guild(msg)
    for _, p in ipairs(guild_patterns) do msg = msg:gsub(p, meta_guild) end
    return msg
end

local function reduce_roles(msg)
    local changed = true
    while changed do
        changed = false
        for _, p in ipairs(rolelist_patterns) do
            local m, n = msg:gsub(p, meta_role)
            if n > 0 then msg = m; changed = true end
        end
    end
    return msg
end

local function parse(raw)
    if not raw then return end
    -- Detect weekly BEFORE any stripping, using the normalised form
    local norm = NormalizeMsg(raw)
    local is_weekly = IsWeeklyMessage(norm)

    local msg = raw:lower()
    msg = msg:gsub('%*%*', '  ')   -- strip **bold** Discord markers
    msg = msg:gsub('%*', ' ')      -- strip *italic* markers
    msg = msg:gsub('https?://[%S]+', '')
    if matches_any(msg, guild_recruit_patterns) then return end
    if matches_any(msg, trade_patterns) then return end
    msg = msg:gsub(meta_char, '')
    msg = lex_guild(msg)
    msg = lex_achievements(msg)   -- strip achievement links BEFORE raid detection
    local raid, m2, num = lex_raid(msg)
    if not raid then return end
    if num > 1 then return end
    msg = m2
    local roles = {}
    if not msg:find('lfm? all ') and not msg:find('need all ') then
        roles, msg = lex_roles(roles, msg, 'dps')
        roles, msg = lex_roles(roles, msg, 'tank')
        roles, msg = lex_roles(roles, msg, 'healer')
    end
    if #roles == 0 then roles = {'dps','tank','healer'} end
    local gs
    gs, msg = lex_gs(msg)
    msg = reduce_roles(msg)
    if not matches_any(msg, lfm_patterns) then return end
    if matches_any(msg, lfg_patterns) then return end
    return raid, roles, gs or '', is_weekly
end

-- Reserved item detection
-- Warmane-specific patterns from real messages:
-- "BP/SFS-RESS"    = Boe+Primo / Shadowfrost Shards reserved
-- "ramaladin ress" = Ramaldni's Gift reserved
-- "cts ress"       = Cluster Trinkets reserved
-- "(1low 5600)"    = low GS slot reserved
-- "[Item] res"     = item link with ress after it

local function AddSnippet(found, raw, s, e)
    local cs = math.max(1, s - 8)
    local ce = math.min(#raw, e + 8)
    local snippet = raw:sub(cs, ce):match("^%s*(.-)%s*$")
    if snippet == "" then return end
    for _, ex in ipairs(found) do if ex == snippet then return end end
    found[#found+1] = snippet
end

local function DetectRessItems(raw_message)
    if not raw_message then return false, "" end
    local lo = string.lower(raw_message)
    local found = {}

    -- в”Ђв”Ђ Pattern set 1: explicit word "ress" or "res" or "reserved" в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
    -- Match: anything (word chars, +, /, -) immediately before ress keyword
    -- e.g. "bp/sfs-ress", "ramaladin ress", "cts ress", "bop ress", "sm ress"
    local ress_word = 're[sz]s+e?r?v?e?d?'
    for s, e in function() return lo:find(ress_word, (({lo:find(ress_word)})[2] or 0)+1) end, nil, nil do
        -- just iterate all occurrences
        break
    end
    -- Simpler: find all ress occurrences and grab context
    local search_pos = 1
    while true do
        local s, e = lo:find('re[sz]s+e?r?v?e?d?', search_pos)
        if not s then break end
        AddSnippet(found, raw_message, s, e)
        search_pos = e + 1
    end

    -- в”Ђв”Ђ Pattern set 2: known Warmane shorthand BEFORE "ress" в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
    -- "bp/sfs", "sfs", "bp", "sm", "cts", "bts", "sts", "bop", "b+o+p"
    local shorthands = {
        -- Warmane ICC specific reserved item shorthands
        'bp[/%-]sfs', 'sfs[/%-]bp',          -- Boe+Primo / Shadowfrost combo
        'sfs',                                 -- Shadowfrost Shards
        'bp'..csep..'primo',                   -- Boe+Primo
        'b%+o%+p', 'bop',                      -- BoP item
        'cts', 'bts', 'gts', 'sts',            -- Cluster/Blood/Glowing/Shadowed Twilight Scale
        'sm',                                  -- Shadowmourne
        'dv',                                  -- Death's Verdict / Death's Choice
        'ramald[ia]n[e]?',                     -- Ramaldni's Gift
        'dbw',                                 -- Deathbringer's Will
        'tws',                                 -- Tiny Abomination in a Jar / Twilight Scale
        'penumbra',                            -- Penumbra Pendant
        'apocalypse',                          -- Apocalypse's Advance
        'sharpened'..csep..'twilight',         -- Sharpened Twilight Scale
        'petrified'..csep..'twilight',         -- Petrified Twilight Scale
        'shadow?f?r?o?s?t?'..csep..'shard',   -- Shadowfrost Shard
        'shadow?mourne',                       -- Shadowmourne itself
        'bryntroll',                           -- Bryntroll
        'near'..csep..'invincible',            -- near-invincible reserved
        'invincible',                          -- Invincible's Reins
        'tiny'..csep..'abom',                  -- Tiny Abomination in a Jar
        'phylactery',                          -- Phylactery of the Nameless Lich
        'solace',                              -- Solace of the Defeated/Fallen
        'herkuml',                             -- Herkuml War Token
        -- BoE/disenchant reserved combos (common: "boe+de res")
        'boe',                                 -- Bind on Equip reserved
        'b%+d',                                -- B+D (BoE+Disenchant)
        'boe%+de', 'de%+boe',                  -- boe+de combo
        -- Standalone res/rez with boundaries
        ' res ', ' rez ',                      -- bare res/rez with spaces
        '%(res%)', '%-res', '/res',            -- (res), -res, /res
        ' res%)', ' rez%)',                    -- res) rez) end of bracket
    }
    for _, sh in ipairs(shorthands) do
        search_pos = 1
        while true do
            local s, e = lo:find(sh, search_pos)
            if not s then break end
            -- Check if "ress"/"res"/"reserved" appears within 20 chars after
            local after = lo:sub(e+1, e+25)
            if after:find('re[sz][sz]?e?r?v?e?d?') or after:find('reserv') then
                AddSnippet(found, raw_message, s, e+25)
            end
            -- Also check if preceded by context that includes ress (before the shorthand)
            local before = lo:sub(math.max(1,s-20), s-1)
            if before:find('re[sz][sz]?e?r?v?e?d?') or before:find('reserv') then
                AddSnippet(found, raw_message, math.max(1,s-20), e)
            end
            search_pos = e + 1
        end
    end

    -- в”Ђв”Ђ Pattern set 3: item links [Name] with ress nearby в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
    search_pos = 1
    while true do
        local s, e = raw_message:find('%[.-%]', search_pos)
        if not s then break end
        local link_text = raw_message:sub(s, e)
        local after = lo:sub(e+1, e+30)
        local before = lo:sub(math.max(1,s-20), s-1)
        if after:find('re[sz]s') or after:find('reserv') or
           before:find('re[sz]s') or before:find('reserv') then
            local snippet = link_text
            local dup = false
            for _, ex in ipairs(found) do if ex == snippet then dup = true; break end end
            if not dup then found[#found+1] = snippet end
        end
        search_pos = e + 1
    end

    -- в”Ђв”Ђ Pattern set 4: "(Xlow GSNUM)" = low-gs slot reserved в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
    search_pos = 1
    while true do
        local s, e = lo:find('%(%d*%s*low%s*%d+%)', search_pos)
        if not s then break end
        AddSnippet(found, raw_message, s, e)
        search_pos = e + 1
    end

    -- в”Ђв”Ђ Pattern set 5: standalone known item abbreviations в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
    -- These abbreviations almost exclusively appear in ress context on Warmane
    local solo_ress_items = {
        'dv'..sep,          -- Death's Verdict/Choice
        ' sts'..sep,        -- Sharpened Twilight Scale
        ' gts'..sep,        -- Glowing Twilight Scale
        ' cts'..sep,        -- Charred Twilight Scale
        ' bts'..sep,        -- Blood Twilight Scale? / Petrified
        ' pts'..sep,        -- Petrified Twilight Scale
        'penumbra'..sep,    -- Penumbra Pendant
        'apocalypse'..sep,  -- Apocalypse's Advance
    }
    -- Only flag these if the word "ress"/"res"/"reserved" also appears anywhere in the msg
    local has_ress_keyword = lo:find('re[sz]s') ~= nil
    if has_ress_keyword then
        for _, p in ipairs(solo_ress_items) do
            search_pos = 1
            while true do
                local s, e = lo:find(p, search_pos)
                if not s then break end
                AddSnippet(found, raw_message, s, e)
                search_pos = e + 1
            end
        end
    end

    if #found > 0 then
        return true, table.concat(found, "  /  ")
    end
    return false, ""
end

local gs_patterns = {
    '[1-6].?[0-9]?kgs','[1-6]'..csep..'k[0-9]+','[1-6][.,][0-9]',
    '[1-6]'..csep..'k'..csep..'%+','[1-6]'..csep..'k'..sep,
    '%+?'..csep..'[1-6]'..csep..'k'..sep,'[1-6][0-9][0-9][0-9]','[1-6]%+',
}

local guild_recruit_patterns = {
    'recrui?ti?n?g?','we'..csep..'raid','we'..csep..'are'..csep..'raidi?n?g?',
    'autorecruit','raid'..csep..'time','active'..csep..'raiders?',
}

local trade_patterns = { 'wts'..sep, 'wtb'..sep, 'selling'..sep, 'buying'..sep }

local lfm_patterns = {
    'l[fm][fm]?'..non_meta..meta_role..non_meta..meta_raid,
    meta_raid..non_meta..sep..'[0-9]+'..non_meta..meta_role,
    'lfm?'..csep..'[0-9]*'..csep..meta_role..non_meta..meta_gs..'.*'..meta_raid,
    'lfm?'..csep..'all'..non_meta..meta_raid,
    'need'..csep..'all'..non_meta..meta_raid,
    meta_raid..non_meta..'lfm?'..csep..'all',
    meta_raid..non_meta..'need'..csep..'all',
    meta_raid..non_meta..meta_role..non_meta..meta_gs,
    meta_raid..non_meta..'need'..non_meta..meta_role,
    meta_raid..non_meta..'lfm?'..non_meta..meta_role,
    meta_raid..non_meta..meta_gs..non_meta..meta_role,
    meta_role..non_meta..'for'..non_meta..meta_raid,
    meta_raid..non_meta..meta_gs..non_meta..'wh?i?s?p?e?r?'..csep..'me',
    meta_raid..non_meta..meta_gs..non_meta..'wh?i?s?p?e?r?'..csep..'[A-Z][a-z]+',
    meta_raid..non_meta..meta_gs..non_meta..'run',
    '[0-9]'..non_meta..meta_role..non_meta..meta_raid,
    meta_raid..non_meta..'reserved',
    'l[fm][fm]'..non_meta..meta_raid,
    meta_raid..non_meta..meta_role,
    -- Non-LFM recruitment patterns (common on Warmane - no "lfm" word)
    meta_raid..non_meta..'need'..non_meta..meta_role,
    meta_raid..non_meta..'need'..non_meta..meta_gs,
    'need'..non_meta..meta_role..non_meta..meta_raid,
    'need'..non_meta..meta_raid,
    meta_raid..non_meta..'fresh',
    meta_raid..non_meta..'from'..csep..'start',
    meta_raid..non_meta..'full'..csep..'run',
    meta_raid..non_meta..'full'..csep..'clear',
    meta_raid..non_meta..meta_gs,
    meta_raid..non_meta..'wh?i?s?p?e?r?',
    meta_raid..non_meta..'re[sz][sz]?e?r?v?e?d?',
    meta_raid..non_meta..'re[sz][sz]?s?',
}

local lfg_patterns = {
    '^'..csep..'lfg'..non_meta..meta_raid,
    '^'..csep..meta_gs..non_meta..meta_role..non_meta..'lf'..non_meta..meta_raid,
    '^'..csep..meta_role..non_meta..meta_gs..non_meta..'lf'..non_meta..meta_raid,
}

local rolelist_patterns = {
    meta_role..non_meta..'and'..non_meta..meta_role,
    meta_role..non_meta..'or'..non_meta..meta_role,
    meta_role..non_meta..meta_role,
}

local function matches_any(msg, pats)
    for _, p in ipairs(pats) do if msg:find(p) then return true end end
    return false
end

local function format_gs(gs)
    gs = gs:gsub('[%s]*%+?', ''):gsub('[kgs]',''):gsub(sep,'.')
    local n = tonumber(gs)
    if not n then return '?' end
    if n > 1000 then n = n/1000
    elseif n > 100 then n = n/100
    elseif n > 10  then n = n/10 end
    return string.format('%.1f', n)
end

local rbListener = CreateFrame("Frame")
rbListener:RegisterEvent("CHAT_MSG_CHANNEL")
rbListener:RegisterEvent("CHAT_MSG_YELL")
-- Map each weekly boss to its home raid entry (for synthetic weekly listings)
local WEEKLY_TO_RAID = {
    -- Naxxramas weeklies в†’ naxx10
    ["anubrekhan must die"]=1, ["anub must die"]=1,
    ["noth the plaguebringer must die"]=1, ["noth must die"]=1,
    ["instructor razuvious must die"]=1, ["razuvious must die"]=1,
    ["patchwerk must die"]=1, ["patch must die"]=1,
}
local WEEKLY_TO_RAID_EOE = {
    ["malygos must die"]=1, ["maly must die"]=1,
}
local WEEKLY_TO_RAID_OS = {
    ["sartharion must die"]=1, ["sarth must die"]=1,
}
local WEEKLY_TO_RAID_ULD = {
    ["flame leviathan must die"]=1, ["fl must die"]=1,
    ["razorscale must die"]=1, ["raz must die"]=1,
    ["ignis the furnace master must die"]=1, ["ignis must die"]=1,
    ["xt002 deconstructor must die"]=1, ["xt must die"]=1,
}
local WEEKLY_TO_RAID_TOC = {
    ["lord jaraxxus must die"]=1, ["jaraxxus must die"]=1, ["jarax must die"]=1,
}
local WEEKLY_TO_RAID_ICC = {
    ["lord marrowgar must die"]=1, ["marrowgar must die"]=1, ["marrow must die"]=1,
}

local function GetWeeklyRaidEntry(norm)
    for alias in pairs(WEEKLY_TO_RAID)     do if norm:find(alias,1,true) then
        for _,r in ipairs(raid_list) do if r.name=="naxx10" then return r end end
    end end
    for alias in pairs(WEEKLY_TO_RAID_EOE) do if norm:find(alias,1,true) then
        for _,r in ipairs(raid_list) do if r.name=="eoe10" then return r end end
    end end
    for alias in pairs(WEEKLY_TO_RAID_OS)  do if norm:find(alias,1,true) then
        for _,r in ipairs(raid_list) do if r.name=="os10" then return r end end
    end end
    for alias in pairs(WEEKLY_TO_RAID_ULD) do if norm:find(alias,1,true) then
        for _,r in ipairs(raid_list) do if r.name=="ulduar10" then return r end end
    end end
    for alias in pairs(WEEKLY_TO_RAID_TOC) do if norm:find(alias,1,true) then
        for _,r in ipairs(raid_list) do if r.name=="toc10nm" then return r end end
    end end
    for alias in pairs(WEEKLY_TO_RAID_ICC) do if norm:find(alias,1,true) then
        for _,r in ipairs(raid_list) do if r.name=="icc10nm" then return r end end
    end end
    -- Generic "weekly" keyword with no boss name в†’ naxx10 as fallback
    if norm:find("weekly",1,true) or norm:find("wkly",1,true) then
        for _,r in ipairs(raid_list) do if r.name=="naxx10" then return r end end
    end
    return nil
end

rbListener:SetScript("OnEvent", function(self, event, msg, sender, _, _, _, _, _, chanName)
    if not RB.enabled then return end
    if not msg or #msg < 8 then return end
    -- Channel type filtering based on Config settings
    do
        local p = RNToolsAddon and RNToolsAddon.db and RNToolsAddon.db.profile
        if event == "CHAT_MSG_YELL" then
            if p and p.rb_chan_yell == false then return end
        elseif event == "CHAT_MSG_CHANNEL" then
            local cLo = (type(chanName) == "string" and chanName or ""):lower()
            if cLo:find("world") or cLo:find("global") then
                if p and p.rb_chan_world == false then return end
            elseif cLo:find("trade") then
                if p and p.rb_chan_trade == false then return end
            elseif cLo:find("lfg") or cLo:find("looking") or cLo:find("group") then
                if p and p.rb_chan_lfg == false then return end
            end
        end
    end
    -- Quick pre-filter: skip messages with no raid keywords at all
    -- This avoids running the expensive parser on most trade/general messages
    local lo = msg:lower()
    -- в”Ђв”Ђ Broad pre-filter: skip messages with no raid/lfm keyword at all.
    -- Checked against the raw lowercased message BEFORE the expensive parser runs.
    -- Uses IsWeeklyMessage on the normalised form to catch weekly-only messages.
    local norm = NormalizeMsg(msg)
    local hasKeyword =
        -- ICC / Icecrown
        lo:find("icc") or lo:find("icecrown") or
        -- Ruby Sanctum / RS
        lo:find(" rs ") or lo:find("rs%d") or lo:find("ruby") or lo:find("sanctum") or
        -- ToC / ToGC / totc
        lo:find("toc") or lo:find("togc") or lo:find("totc") or
        -- Vault of Archavon
        lo:find("voa") or
        -- Naxxramas
        lo:find("naxx") or
        -- Ulduar
        lo:find("ulduar") or lo:find("uldu") or
        -- Obsidian Sanctum
        lo:find(" os ") or lo:find("os%d") or
        -- Eye of Eternity
        lo:find("eoe") or lo:find("malygos") or lo:find("maly") or
        -- Onyxia
        lo:find("onyxia") or lo:find(" ony ") or
        -- Karazhan / classic
        lo:find("kara") or lo:find("karazhan") or
        -- Molten Core / Black Temple
        lo:find("molten") or lo:find(" mc ") or lo:find("black temple") or
        -- LFM / LF#M phrases
        lo:find("lfm") or lo:find("lf%dm") or lo:find("lf1m") or lo:find("lf2m") or
        lo:find("looking for") or lo:find("need.*tank") or lo:find("need.*heal") or
        lo:find("need.*dps") or
        -- Weekly quest shorthands (use normalised form for bracket-stripping)
        IsWeeklyMessage(norm)
    if not hasKeyword then return end
    local raid, roles, gs, is_weekly = parse(msg)
    -- Weekly bypass: parse() returns nil for weekly-only messages (no raid name in text).
    -- If IsWeeklyMessage fires, synthesise a listing from the bossв†’raid lookup.
    if not raid and IsWeeklyMessage(norm) then
        raid = GetWeeklyRaidEntry(norm)
        if raid then
            roles     = {"dps","tank","healer"}
            gs        = ""
            is_weekly = true
        end
    end
    if not raid then return end
    -- Only run expensive ress detection if parse succeeded
    local has_ress, ress_text = DetectRessItems(msg)
    -- Cap at 50 entries to prevent unbounded memory/scan growth
    local count = 0
    for _ in pairs(RB.messages) do count = count + 1 end
    if count >= 50 then
        -- Remove oldest entry
        local oldest_time, oldest_sender = math.huge, nil
        for s, info in pairs(RB.messages) do
            if info.time < oldest_time then oldest_time = info.time; oldest_sender = s end
        end
        if oldest_sender then RB.messages[oldest_sender] = nil end
    end
    RB.messages[sender] = {
        raid=raid, roles=roles, gs=gs,
        time=time(), message=msg, sender=sender,
        has_ress=has_ress, ress_text=ress_text,
        weekly=is_weekly,
    }
    RB_RefreshList()
end)

-- Expiry ticker: remove listings older than RB.expiry seconds
local rbTicker = 0
local rbExpireFrame = CreateFrame("Frame")
rbExpireFrame:SetScript("OnUpdate", function(self, elapsed)
    -- Skip entirely if no messages to expire
    local hasAny = next(RB.messages)
    if not hasAny then rbTicker = 0; return end
    rbTicker = rbTicker + elapsed
    if rbTicker < 30 then return end
    rbTicker = 0
    local now = time()
    local changed = false
    for sender, info in pairs(RB.messages) do
        if now - info.time > RB.expiry then
            RB.messages[sender] = nil
            changed = true
        end
    end
    if changed then RB_RefreshList() end
end)

-------------------------------------------------------------------------------
-- UI helpers (same style as AutoInvite / MS_Changes)
-------------------------------------------------------------------------------
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},
    icc_hc   = {1.00,0.20,0.20,1},    -- red for heroic ICC
    icc_nm   = {0.80,0.80,0.80,1},    -- grey for normal
    rs       = {0.20,0.80,0.20,1},    -- green for RS
    toc      = {1.00,0.65,0.00,1},    -- orange for ToC
    other    = {0.60,0.60,0.80,1},    -- blue-grey for others
}

-- SetBackdrop: 1 draw call vs 5 separate textures = major drag-lag reduction
local RB_BACKDROP = {
    bgFile   = "Interface\\Buttons\\WHITE8X8",
    edgeFile = "Interface\\Buttons\\WHITE8X8",
    tile=false, tileSize=1, edgeSize=1,
    insets={left=1,right=1,top=1,bottom=1},
}

local function RBSetBG(f,r,g,b,a)
    if not f._rbBD then f:SetBackdrop(RB_BACKDROP); f._rbBD=true end
    f:SetBackdropColor(r,g,b,a or 1)
end

local function RBBorder(f,r,g,b,a)
    if not f._rbBD then f:SetBackdrop(RB_BACKDROP); f._rbBD=true end
    f:SetBackdropBorderColor(r,g,b,a or 1)
end

local function RBBtn(par,lbl,w,h)
    local btn=CreateFrame("Button",nil,par); btn:SetSize(w or 120,h or 24)
    RBSetBG(btn,unpack(C.btn_n)); RBBorder(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() RBSetBG(btn,unpack(C.btn_h)) end)
    btn:SetScript("OnLeave",function() RBSetBG(btn,unpack(C.btn_n)) end)
    btn:SetScript("OnMouseDown",function() RBSetBG(btn,unpack(C.btn_p)) end)
    btn:SetScript("OnMouseUp",function() RBSetBG(btn,unpack(C.btn_h)) end)
    btn._label=fs; return btn
end

-- Returns colour for the raid name text based on saved status
-- saved (locked) = grey,  not saved = cyan (matches original RaidBrowser)
local function RaidColor(name, is_locked)
    if is_locked then
        return 1.0, 0.2, 0.2, 1   -- RED: you are saved (cannot join)
    end
    -- Partial runs in orange so they stand out
    if name:find("partial") or name:find("8/12") then
        return 1.0, 0.65, 0.0, 1  -- ORANGE: partial heroic run
    end
    return 0.20, 1.00, 0.40, 1    -- GREEN: not saved (can join)
end

-- Check if player is saved to an instance (mirrors raid_browser.stats.raid_lock_info)
local function GetLockoutInfo(instance_name, size)
    if not instance_name or not size then return false, nil end
    for i = 1, GetNumSavedInstances() do
        local sname, _, reset, _, locked, _, _, _, ssize = GetSavedInstanceInfo(i)
        if sname and string.lower(sname) == string.lower(instance_name) and ssize == size and locked then
            return true, reset
        end
    end
    return false, nil
end

-- Format seconds into "X days Y hrs Z mins"
local function FormatSeconds(secs)
    if not secs or secs <= 0 then return "unknown" end
    local days  = math.floor(secs / 86400)
    local hours = math.floor((secs % 86400) / 3600)
    local mins  = math.floor((secs % 3600) / 60)
    local parts = {}
    if days  > 0 then parts[#parts+1] = days  .. " day"  .. (days  ~= 1 and "s" or "") end
    if hours > 0 then parts[#parts+1] = hours .. " hr"   .. (hours ~= 1 and "s" or "") end
    if mins  > 0 then parts[#parts+1] = mins  .. " min"  .. (mins  ~= 1 and "s" or "") end
    return table.concat(parts, " ")
end

local function RolesStr(roles)
    local icons = {dps="|cffFFFF00DPS|r", tank="|cff44aaFFTank|r", healer="|cff20ff60Heal|r"}
    local parts = {}
    for _, r in ipairs(roles) do parts[#parts+1] = icons[r] or r end
    return table.concat(parts, " ")
end

-------------------------------------------------------------------------------
-- Save Raid Gear (spec + GS snapshot, mirrors RaidBrowser raidset feature)
-------------------------------------------------------------------------------
local RB_Raidset = {
    active = { spec = nil, gs = nil },   -- auto-detected from talents
    primary   = nil,                      -- manually saved snapshot
    secondary = nil,                      -- manually saved snapshot
}

local function DetectActiveSpec()
    -- Read talent tabs to find highest-point tree
    local best_pts, best_name = 0, "Unknown"
    for i = 1, 3 do
        local name, _, pts = GetTalentTabInfo(i)
        if pts and pts > best_pts then
            best_pts = pts
            best_name = name or "Unknown"
        end
    end
    -- Feral druid distinction: Protector of the Pack talent = tank
    if best_name == "Feral Combat" then
        local _, _, _, _, pts = GetTalentInfo(2, 22)
        best_name = (pts and pts > 0) and "Feral (Bear)" or "Feral (Cat)"
    end
    return best_name
end

local function DetectActiveGS()
    -- Manual override takes priority
    if RB_Raidset and RB_Raidset.active and RB_Raidset.active.gs then
        return RB_Raidset.active.gs
    end
    -- Use GearScoreLite if available
    if GearScore_GetScore then
        local gs = GearScore_GetScore(UnitName("player"), "player")
        if gs and gs > 0 then
            return string.format("%.1f", gs / 1000)
        end
    end
    return nil
end

local function GetCurrentRaidset()
    local spec = DetectActiveSpec()
    local gs   = DetectActiveGS()
    return spec, gs
end

-- Achievement IDs per raid, ordered easiest -> hardest (same as original RaidBrowser)
local RAID_ACHIEVEMENTS = {
    icc = {
        4531, 4604,  -- Storming the Citadel 10/25 NM
        4628, 4632,  -- Storming the Citadel 10/25 HC
        4528, 4605, 4629, 4633,  -- Plagueworks NM/HC
        4529, 4606, 4630, 4634,  -- Crimson Hall NM/HC
        4527, 4607, 4631, 4635,  -- Frostwing Halls NM/HC
        4530, 4597,  -- The Frozen Throne 10/25 NM
        4583, 4584,  -- Bane of the Fallen King / The Light of Dawn (HC)
    },
    toc = { 3917, 3916, 3918, 3812 },
    rs  = { 4817, 4815, 4818, 4816 },
    voa = { 3136, 3137 },
    naxx= { 2176, 2177, 2178, 2179, 2180, 2181 },
    ulduar = { 2905, 2906, 3097, 3181, 3018 },
}

local function GetRaidKey(raid_name)
    local lo = raid_name:lower()
    if lo:find("icc") then return "icc"  -- catches icc25hc, icc25_8/12, icc25_partial etc
    elseif lo:find("toc") or lo:find("togc") then return "toc"
    elseif lo:find("rs")  or lo:find("ruby") then return "rs"
    elseif lo:find("voa") then return "voa"
    elseif lo:find("naxx") then return "naxx"
    elseif lo:find("ulduar") then return "ulduar"
    end
    return nil
end

local function FindBestAchievement(raid_name)
    local key = GetRaidKey(raid_name)
    if not key then return nil end
    local ids = RAID_ACHIEVEMENTS[key]
    if not ids then return nil end
    local best = nil
    -- Scan from easiest to hardest; keep updating so we end with the highest completed
    for i, id in ipairs(ids) do
        -- Check full achievement completion
        local _, _, _, completed = GetAchievementInfo(id)
        if completed then best = id end
        -- Check individual criteria (partial clears)
        local numCriteria = GetAchievementNumCriteria(id)
        if numCriteria and numCriteria > 0 then
            for j = 1, numCriteria do
                local _, _, critCompleted = GetAchievementCriteriaInfo(id, j)
                if critCompleted then best = id end
            end
        end
    end
    return best
end

local function BuildWhisperText(sender, raid_info)
    local spec, gs = GetCurrentRaidset()
    -- Get class name (e.g. "Paladin", "Death Knight")
    local class = UnitClass("player") or ""
    local msg = "inv"
    if gs   then msg = msg .. " " .. gs .. "k" end
    if spec then msg = msg .. " " .. spec end
    if class ~= "" then msg = msg .. " " .. class end
    -- Best achievement link for this raid
    local achieve_id = FindBestAchievement(raid_info.name)
    if achieve_id then
        local link = GetAchievementLink(achieve_id)
        if link then msg = msg .. " " .. link end
    end
    return "/w " .. sender .. " " .. msg, msg
end

local rbRaidsetFS = nil  -- status label updated by Save Raid Gear section

local function RefreshRaidsetLabel()
    if not rbRaidsetFS then return end
    local spec = DetectActiveSpec()
    local gs   = DetectActiveGS()
    local gsStr = gs and (gs .. "k") or "|cffff4444GS N/A (install GearScoreLite)|r"
    rbRaidsetFS:SetText("Active spec: |cff44aaff" .. spec .. "|r   GS: |cffFFD100" .. gsStr .. "|r")
end

-------------------------------------------------------------------------------
-- Row pool
-------------------------------------------------------------------------------
local RB_ROWS     = {}
local RB_ROW_H    = 28
local RB_MAX_ROWS = 20
local rbRowCont   = nil
local rbCountFS   = nil

-- Perf: module-level constant, never recreated per row
local RB_DISPLAY_NAMES = {
    ['icc25hc']='ICC25 HC',      ['icc10hc']='ICC10 HC',
    ['icc25nm']='ICC25 NM',      ['icc10nm']='ICC10 NM',
    ['icc25rep']='ICC25 Rep',    ['icc10rep']='ICC10 Rep',
    ['icc25_8/12']='ICC25 8/12 HC', ['icc10_8/12']='ICC10 8/12 HC',
    ['icc25_partial']='ICC25 x/12', ['icc10_partial']='ICC10 x/12',
    ['rs25hc']='RS25 HC',    ['rs10hc']='RS10 HC',
    ['rs25nm']='RS25 NM',    ['rs10nm']='RS10 NM',
    ['toc25hc']='ToGC 25',   ['toc10hc']='ToGC 10',
    ['toc25nm']='ToC 25',    ['toc10nm']='ToC 10',
    ['voa25']='VoA 25',      ['voa10']='VoA 10',
    ['ulduar25']='Ulduar 25',['ulduar10']='Ulduar 10',
    ['os25']='OS 25',        ['os10']='OS 10',
    ['naxx25']='Naxx 25',    ['naxx10']='Naxx 10',
    ['eoe25']='EoE 25',      ['eoe10']='EoE 10',
    ['onyxia25']='Onyxia 25',['onyxia10']='Onyxia 10',
    ['molten_core40']='MC 40',['black_temple25']='BT 25',
    ['karazhan10']='Kara 10',
}

-- Perf: lockout cache updated every 5s, not on every row render
local RB_LockCache     = {}
local RB_LockCacheTime = 0

local function GetLockoutCached(instance_name, size)
    local now = time()
    if now - RB_LockCacheTime > 5 then
        RB_LockCache     = {}
        RB_LockCacheTime = now
        for i = 1, GetNumSavedInstances() do
            local sname, _, reset, _, locked, _, _, _, ssize = GetSavedInstanceInfo(i)
            if sname and locked then
                local key = string.lower(sname) .. "_" .. tostring(ssize)
                RB_LockCache[key] = reset or 0
            end
        end
    end
    local key = string.lower(instance_name or "") .. "_" .. tostring(size)
    local reset = RB_LockCache[key]
    return reset ~= nil, reset
end

function RNTools_IsRaidBrowserEnabled()
    return RB.enabled == true
end

function RNTools_SetRBExpiry(minutes)
    local secs = tonumber(minutes)
    if secs and secs >= 1 then
        RB.expiry = secs * 60
    end
end

function RNTools_SetRBEnabled(val)
    RB.enabled = val == true
    if RB_RefreshList then RB_RefreshList() end
    if RefreshStatusOverlay then RefreshStatusOverlay() end
end

function RNTools_GetRBMessages()
    return RB.messages
end

function RNTools_RB_GetLockout(instance_name, size)
    return GetLockoutCached(instance_name, size)
end

function RNTools_RB_RaidColor(name, is_locked)
    return RaidColor(name, is_locked)
end

function RNTools_RB_DisplayName(name)
    return RB_DISPLAY_NAMES[name] or name:upper()
end

function RNTools_RB_BuildWhisper(sender, raid_info)
    return BuildWhisperText(sender, raid_info)
end

-- Returns a comparable value for `info` for the given sort key.
local function RB_SortValue(info, key)
    if key == "instance" then
        if info.weekly then return "weekly" end
        local name = info.raid and info.raid.name or ""
        return (RB_DISPLAY_NAMES[name] or name or ""):lower()
    elseif key == "gs" then
        return tonumber(info.gs) or -1
    elseif key == "roles" then
        return RolesStr(info.roles):lower()
    elseif key == "sender" then
        return (info.sender or ""):lower()
    end
    return info.time or 0   -- "time"/"age"
end

-- Sorts `list` in place per RB_Sort; ties break newest-first.
local function RB_SortList(list)
    local key, dir = RB_Sort.key, RB_Sort.dir
    table.sort(list, function(a, b)
        local va, vb = RB_SortValue(a, key), RB_SortValue(b, key)
        if va == vb then return (a.time or 0) > (b.time or 0) end
        if dir == "asc" then return va < vb else return va > vb end
    end)
end

function RB_RefreshList()
    if not rbRowCont then return end
    local now  = time()
    -- Build sorted + filtered list newest first
    local list = {}
    for sender, info in pairs(RB.messages) do
        if now - info.time <= RB.expiry and RB_RaidMatchesFilter(info) then
            list[#list+1] = info
        end
    end
    RB_SortList(list)

    local total = 0
    for _ in pairs(RB.messages) do total = total + 1 end
    if rbCountFS then
        local showing = #list
        if showing == total then
            rbCountFS:SetText(showing .. " active listing" .. (showing==1 and "" or "s"))
        else
            rbCountFS:SetText(showing .. " shown (filtered from " .. total .. ")")
        end
    end

    -- Only update age text every 15 seconds to reduce load
    local updateAge = (now - RB_AgeLastUpdate) >= 15
    if updateAge then RB_AgeLastUpdate = now end

    for i = 1, RB_MAX_ROWS do
        local row = RB_ROWS[i]
        if not row then break end
        local info = list[i]
        if info then
            local is_locked, reset_time = GetLockoutCached(info.raid.instance_name, info.raid.size)
            -- Store on row for tooltip
            row._info        = info
            row._is_locked   = is_locked
            row._reset_time  = reset_time
            -- Instance column: clean spaced display name
            if updateAge or not row._ageStr then
                local age = now - info.time
                row._ageStr = age < 60 and (age.."s") or (math.floor(age/60).."m")
            end
            -- Weekly = just "Weekly" in gold. Regular raids use normal name+color.
            if info.weekly then
                row._raidFS:SetText("Weekly")
                row._raidFS:SetTextColor(1.0, 0.82, 0.0, 1)
            else
                row._raidFS:SetText(RB_DISPLAY_NAMES[info.raid.name] or info.raid.name:upper())
                row._raidFS:SetTextColor(RaidColor(info.raid.name, is_locked))
            end
            row._gsFS:SetText(info.gs ~= "" and ("|cffFFD100" .. info.gs .. "k|r") or "")
            row._rolesFS:SetText(RolesStr(info.roles))
            row._senderFS:SetText(info.sender)
            row._ageFS:SetText("|cff666666" .. (row._ageStr or "?") .. "|r")
            -- Ress Items button
            local has_ress  = info.has_ress
            local ress_text = info.ress_text or ""
            if has_ress then
                row._ressBtn._label:SetText("|cff20ff60YES|r")
                RBSetBG(row._ressBtn, 0.04, 0.20, 0.08, 1)
                RBBorder(row._ressBtn, 0.10, 0.80, 0.30, 1)
            else
                row._ressBtn._label:SetText("|cffff4444NO|r")
                RBSetBG(row._ressBtn, 0.22, 0.04, 0.04, 1)
                RBBorder(row._ressBtn, 0.80, 0.20, 0.10, 1)
            end
            row._ress_has  = has_ress
            row._ress_text = ress_text
                local capturedInfo = info
            row._whisperBtn:SetScript("OnClick", function()
                local fullMsg, _ = BuildWhisperText(capturedInfo.sender, capturedInfo.raid)
                if ChatFrame1EditBox then
                    ChatFrame1EditBox:Show()
                    ChatFrame1EditBox:SetText(fullMsg)
                    ChatFrame1EditBox:SetFocus()
                    ChatFrame1EditBox:SetCursorPosition(999)
                end
            end)
            row._joinBtn:SetScript("OnClick", function()
                local _, appMsg = BuildWhisperText(capturedInfo.sender, capturedInfo.raid)
                SendChatMessage(appMsg, "WHISPER", nil, capturedInfo.sender)
                DEFAULT_CHAT_FRAME:AddMessage("|cff44aaff[Raid Browser]:|r Sent application to |cffFFD100" .. capturedInfo.sender .. "|r")
            end)
            row.frame:Show()
        else
            row._info = nil
            row.frame:Hide()
        end
    end
end

-------------------------------------------------------------------------------
-- Tab content
-------------------------------------------------------------------------------
function CreateRaidBrowserTabContent(parent, onClose)
    local f = CreateFrame("Frame", nil, parent)
    f:SetAllPoints(parent)
    f:Hide()
    RBSetBG(f, unpack(C.bg))
    RBBorder(f, 0, 0, 0, 0)

    local CONTENT_W = 962

    local scrollFrame = CreateFrame("ScrollFrame", "RNToolsRBScrollFrame", f, "UIPanelScrollFrameTemplate")
    scrollFrame:SetPoint("TOP",    f, "TOP",    0, -10)
    scrollFrame:SetPoint("BOTTOM", f, "BOTTOM", 0,  12)
    scrollFrame:SetWidth(CONTENT_W)

    -- 3.3.5a: replace the broken OnScrollRangeChanged entirely.
    -- The template version crashes when the scrollbar thumb region is zero-width.
    -- We rewrite it to safely clamp the thumb and skip if bar not ready.
    scrollFrame:SetScript("OnScrollRangeChanged", function(self, xrange, yrange)
        local name = self:GetName()
        if not name then return end
        local bar = _G[name .. "ScrollBar"]
        if not bar then return end
        local up   = _G[name .. "ScrollBarScrollUpButton"]
        local down = _G[name .. "ScrollBarScrollDownButton"]
        yrange = math.floor(yrange + 0.5)
        if yrange == 0 then
            bar:SetMinMaxValues(0, 0)
            bar:SetValue(0)
        else
            bar:SetMinMaxValues(0, yrange)
            bar:SetValue(math.min(bar:GetValue(), yrange))
        end
        -- Update the scroll buttons enabled state
        local val = bar:GetValue()
        if up   then if val > 0      then up:Enable()   else up:Disable()   end end
        if down then if val < yrange  then down:Enable() else down:Disable() end end
    end)

    local content = CreateFrame("Frame", nil, scrollFrame)
    content:SetWidth(CONTENT_W)
    scrollFrame:SetScrollChild(content)

    local yOffset = 0
    -- Anchors an AceGUI widget's frame full-width inside `content` at the running yOffset.
    local function PlaceWidget(widget, height, spacing)
        widget.frame:SetParent(content)
        widget.frame:ClearAllPoints()
        widget.frame:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -yOffset)
        widget.frame:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, -yOffset)
        widget.frame:SetHeight(height)
        widget.frame:Show()
        yOffset = yOffset + height + (spacing or 4)
    end
    -- Anchors a raw frame inside `content` at the running yOffset (left edge only,
    -- unless fullWidth is set -- the frame keeps its own width otherwise).
    local function PlaceRaw(frame, height, spacing, fullWidth)
        frame:SetParent(content)
        frame:ClearAllPoints()
        frame:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -yOffset)
        if fullWidth then
            frame:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, -yOffset)
        end
        frame:SetHeight(height)
        frame:Show()
        yOffset = yOffset + height + (spacing or 4)
    end
    -- Creates a full-width AceGUI SimpleGroup "row", placed (positioned + sized)
    -- BEFORE any children are added -- this is what makes row.content.width
    -- correct for SetRelativeWidth children added afterward (see CLAUDE.md
    -- "manually-anchored AceGUI containers" notes).
    local function NewRow(height, spacing)
        local row = AceGUI:Create("SimpleGroup")
        row:SetLayout("Flow")
        row.noAutoHeight = true
        row:SetWidth(CONTENT_W)
        PlaceWidget(row, height, spacing)
        return row
    end

    -- Title + subtitle
    local heading = AceGUI:Create("Heading")
    heading:SetText("Raid Browser")
    PlaceWidget(heading, 18, 6)

    local subLbl = AceGUI:Create("Label")
    subLbl:SetText("Auto-detects LFM messages in Trade / GLOBAL / Yell")
    subLbl:SetColor(unpack(C.dim))
    subLbl:SetJustifyH("CENTER")
    subLbl:SetWidth(CONTENT_W)
    PlaceWidget(subLbl, subLbl.frame.height, 10)

    -- ── Controls ─────────────────────────────────────────────────────
    local controlsHeading = AceGUI:Create("Heading")
    controlsHeading:SetText("Controls")
    PlaceWidget(controlsHeading, 18, 8)

    local controlsRow = NewRow(26, 10)

    local enableCB = AceGUI:Create("CheckBox")
    enableCB:SetRelativeWidth(0.22)
    local function RefreshEnableBtn()
        if RB.enabled then
            enableCB:SetValue(true)
            enableCB:SetLabel("|cff20ff60Enabled|r")
        else
            enableCB:SetValue(false)
            enableCB:SetLabel("|cffff4444Disabled|r")
        end
    end
    enableCB:SetCallback("OnValueChanged", function(_, _, checked)
        RB.enabled = checked
        RefreshEnableBtn()
    end)
    controlsRow:AddChild(enableCB)
    RefreshEnableBtn()

    local clearBtn = AceGUI:Create("Button")
    clearBtn:SetText("|cffff4d4dClear List|r")
    clearBtn:SetRelativeWidth(0.18)
    clearBtn:SetCallback("OnClick", function()
        RB.messages = {}
        RB_RefreshList()
    end)
    controlsRow:AddChild(clearBtn)

    local expiryLbl = AceGUI:Create("Label")
    expiryLbl:SetText("Listings expire after 60 seconds of silence")
    expiryLbl:SetColor(unpack(C.dim))
    expiryLbl:SetRelativeWidth(0.55)
    controlsRow:AddChild(expiryLbl)

    -- ── Save Raid Gear section ─────────────────────────────────────────
    local raidGearHeading = AceGUI:Create("Heading")
    raidGearHeading:SetText("Save Raid Gear  (auto-fills /w with your spec + GS)")
    PlaceWidget(raidGearHeading, 18, 8)

    local rbRaidsetLbl = AceGUI:Create("Label")
    rbRaidsetLbl:SetText("Detecting...")
    rbRaidsetLbl:SetColor(unpack(C.text))
    rbRaidsetLbl:SetWidth(CONTENT_W)
    PlaceWidget(rbRaidsetLbl, rbRaidsetLbl.frame.height, 4)
    rbRaidsetFS = rbRaidsetLbl

    local gsRow = NewRow(26, 6)

    local gsLbl = AceGUI:Create("Label")
    gsLbl:SetText("Override GS (optional, e.g. 6.1):")
    gsLbl:SetColor(unpack(C.dim))
    gsLbl:SetRelativeWidth(0.40)
    gsRow:AddChild(gsLbl)

    local gsEB = AceGUI:Create("EditBox")
    gsEB:DisableButton(true)
    gsEB:SetRelativeWidth(0.20)
    gsRow:AddChild(gsEB)

    local saveGSBtn = AceGUI:Create("Button")
    saveGSBtn:SetText("|cff33ff99Save GS Override|r")
    saveGSBtn:SetRelativeWidth(0.30)
    saveGSBtn:SetCallback("OnClick", function()
        local val = gsEB:GetText():match("^%s*(.-)%s*$")
        if val ~= "" then
            local n = tonumber(val)
            if n then
                if n > 100 then n = n / 1000 end  -- convert 6100 -> 6.1
                RB_Raidset.active.gs = string.format("%.1f", n)
                DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[Raid Browser]:|r GS override set to " .. RB_Raidset.active.gs .. "k")
                RefreshRaidsetLabel()
            else
                DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Raid Browser]:|r Invalid GS value.")
            end
        else
            RB_Raidset.active.gs = nil
            DEFAULT_CHAT_FRAME:AddMessage("|cffFFD100[Raid Browser]:|r GS override cleared.")
            RefreshRaidsetLabel()
        end
    end)
    gsRow:AddChild(saveGSBtn)

    local gsHint = AceGUI:Create("Label")
    gsHint:SetText("When you click /w on a listing, the chat box fills with:  /w Leader inv 6.2k Retribution Paladin [Achievement]")
    gsHint:SetColor(unpack(C.dim))
    gsHint:SetWidth(CONTENT_W)
    PlaceWidget(gsHint, gsHint.frame.height, 10)

    -- ── Filters section ───────────────────────────────────────────────
    local filtersHeading = AceGUI:Create("Heading")
    filtersHeading:SetText("Filters")
    PlaceWidget(filtersHeading, 18, 10)

    local filterRow = NewRow(44, 8)

    local raidTypes   = {"All","ICC","ICC 8/12","Ulduar","ToC","ToGC","VoA","Naxxramas","RS","OS"}
    local raidSizes   = {"All","10","25"}
    local roleTypes   = {"All","dps","tank","healer"}
    local weeklyTypes = {"All","Weekly only","Non-weekly"}

    local function MakeFilterDropdown(options, label, key, relWidth)
        local dd = AceGUI:Create("Dropdown")
        dd:SetRelativeWidth(relWidth)
        dd:SetLabel(label)
        local list, order = {}, {}
        for _, opt in ipairs(options) do
            list[opt] = opt
            order[#order+1] = opt
        end
        dd:SetList(list, order)
        dd:SetValue(RB_Filter[key])
        dd:SetCallback("OnValueChanged", function(_, _, value)
            RB_Filter[key] = value
            RB_RefreshList()
        end)
        filterRow:AddChild(dd)
        return dd
    end

    local fRaidType = MakeFilterDropdown(raidTypes,   "Raid Type", "raidType", 0.22)
    local fRaidSize = MakeFilterDropdown(raidSizes,   "Size",      "raidSize", 0.14)
    local fRole     = MakeFilterDropdown(roleTypes,   "Role",      "role",     0.14)
    local fWeekly   = MakeFilterDropdown(weeklyTypes, "Weekly",    "weekly",   0.18)

    local resetBtn = AceGUI:Create("Button")
    resetBtn:SetText("|cffFFD100Reset Filters|r")
    resetBtn:SetRelativeWidth(0.18)
    resetBtn:SetCallback("OnClick", function()
        RB_Filter.raidType = "All"; fRaidType:SetValue("All")
        RB_Filter.raidSize = "All"; fRaidSize:SetValue("All")
        RB_Filter.role     = "All"; fRole:SetValue("All")
        RB_Filter.weekly   = "All"; fWeekly:SetValue("All")
        RB_RefreshList()
    end)
    filterRow:AddChild(resetBtn)

    -- ── Active Raid Listings ────────────────────────────────────────────
    local listingsHeading = AceGUI:Create("Heading")
    listingsHeading:SetText("Active Raid Listings")
    PlaceWidget(listingsHeading, 18, 6)

    local rbCountLbl = AceGUI:Create("Label")
    rbCountLbl:SetText("0 active listings")
    rbCountLbl:SetColor(unpack(C.dim))
    rbCountLbl:SetWidth(CONTENT_W)
    PlaceWidget(rbCountLbl, rbCountLbl.frame.height, 6)
    rbCountFS = rbCountLbl

    -- Column headers (new order: Instance > GS > Roles > Ress > Action > Leader > Age)
    -- RB_ColHeaders maps sort key -> {widget, baseText}; non-sortable columns pass key=nil.
    local RB_ColHeaders = {}
    local function RB_RefreshHeaderLabels()
        for key, h in pairs(RB_ColHeaders) do
            local arrow = ""
            if RB_Sort.key == key then
                arrow = (RB_Sort.dir == "asc") and "  ^" or "  v"
            end
            h.widget:SetText(h.baseText .. arrow)
        end
    end

    local hdrRow = NewRow(18, 4)

    -- Column relative widths -- the row pool below uses matching pixel offsets
    -- (COL_X = relWidth * CONTENT_W, cumulative) so rows line up under headers.
    local COL_WIDTHS = {instance=0.18, gs=0.08, roles=0.16, ress=0.10, action=0.12, leader=0.18, age=0.10}

    local function ColHdr(text, key)
        local lbl = AceGUI:Create("InteractiveLabel")
        lbl:SetText(text)
        lbl:SetColor(unpack(C.accent))
        lbl:SetRelativeWidth(COL_WIDTHS[key] or 0.10)
        if key then
            RB_ColHeaders[key] = { widget = lbl, baseText = text }
            lbl:SetCallback("OnEnter", function() lbl:SetColor(1,1,1) end)
            lbl:SetCallback("OnLeave", function()
                if RB_Sort.key ~= key then lbl:SetColor(unpack(C.accent)) end
            end)
            lbl:SetCallback("OnClick", function()
                if RB_Sort.key == key then
                    RB_Sort.dir = (RB_Sort.dir == "asc") and "desc" or "asc"
                else
                    RB_Sort.key = key
                    RB_Sort.dir = (key == "gs") and "desc" or "asc"
                end
                RB_RefreshHeaderLabels()
                RB_RefreshList()
            end)
        end
        hdrRow:AddChild(lbl)
    end

    ColHdr("Instance", "instance")
    ColHdr("GS",       "gs")
    ColHdr("Roles",    "roles")
    ColHdr("Ress",     "ress")
    ColHdr("Action",   "action")
    ColHdr("Leader",   "sender")
    ColHdr("Age",      "time")
    RB_RefreshHeaderLabels()

    -- Row pool (kept raw -- perf-critical, never rebuilt; RB_RefreshList only
    -- calls :SetText()/:Show()/:Hide() on cached widget refs). Placed empty
    -- FIRST, then populated -- same ordering as every AceGUI row above.
    local rowCont = CreateFrame("Frame", nil, content)
    PlaceRaw(rowCont, RB_MAX_ROWS * (RB_ROW_H + 2), 8, true)
    rbRowCont = rowCont

    -- Pixel x-offsets matching COL_WIDTHS * CONTENT_W (cumulative)
    local COL_X = {instance=0, gs=173, roles=250, ress=404, action=499, leader=614, age=787}

    for i = 1, RB_MAX_ROWS do
        local row = CreateFrame("Frame", nil, rowCont)
        row:SetPoint("TOPLEFT", rowCont, "TOPLEFT", 0, -(i-1)*(RB_ROW_H+2))
        row:SetPoint("TOPRIGHT", rowCont, "TOPRIGHT", 0, -(i-1)*(RB_ROW_H+2))
        row:SetHeight(RB_ROW_H)
        row:SetBackdrop(RB_BACKDROP)
        local rc = i%2==0 and C.row_even or C.row_odd
        row:SetBackdropColor(rc[1],rc[2],rc[3],rc[4] or 0.9)
        row:SetBackdropBorderColor(0,0,0,0)
        row._rbBD = true

        local function FS(x)
            local fs = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
            fs:SetPoint("LEFT", row, "LEFT", x, 0)
            fs:SetTextColor(unpack(C.text))
            return fs
        end

        row._raidFS   = FS(COL_X.instance + 4)
        row._gsFS     = FS(COL_X.gs)
        row._rolesFS  = FS(COL_X.roles)

        -- Ress Items button
        row._ressBtn = RBBtn(row, "NO", 56, 20)
        row._ressBtn:SetPoint("LEFT", row, "LEFT", COL_X.ress, 0)
        -- OnEnter set ONCE here, reads row._ress_has/_ress_text (no per-refresh closures)
        local _rb = row
        row._ressBtn:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:ClearLines()
            if _rb._ress_has then
                GameTooltip:AddLine("|cff20ff60Reserved items detected:|r", 1, 0.82, 0)
                GameTooltip:AddLine(_rb._ress_text or "", 1, 1, 1, true)
            else
                GameTooltip:AddLine("|cffff4444No reserved items detected|r", 1, 0.82, 0)
                GameTooltip:AddLine("No ress/reserved keywords found.", 0.7, 0.7, 0.7)
            end
            GameTooltip:Show()
        end)
        row._ressBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)

        -- Action column: /w + Join
        row._whisperBtn = RBBtn(row, "/w", 38, 20)
        row._whisperBtn:SetPoint("LEFT", row, "LEFT", COL_X.action, 0)
        row._whisperBtn._label:SetTextColor(0.85, 0.75, 0.55, 1)

        row._joinBtn = RBBtn(row, "Join", 44, 20)
        row._joinBtn:SetPoint("LEFT", row._whisperBtn, "RIGHT", 2, 0)
        row._joinBtn._label:SetTextColor(0.20, 1.00, 0.40, 1)

        row._senderFS = FS(COL_X.leader)
        row._ageFS    = FS(COL_X.age)

        row.frame = row
        row:Hide()

        -- Tooltip on hover (mirrors original RaidBrowser OnEnter)
        row:EnableMouse(true)
        row:SetScript("OnEnter", function(self)
            if not self._info then return end
            local info = self._info
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:ClearLines()
            -- Original message
            GameTooltip:AddLine(info.message, 1, 1, 1, true)
            -- Age
            local age = time() - info.time
            GameTooltip:AddLine("Last seen: " .. age .. " seconds ago", 0.7, 0.7, 0.7)
            GameTooltip:AddLine(" ")
            -- Saved status
            if self._is_locked then
                GameTooltip:AddLine("You are |cffff4444saved|r for " .. info.raid.instance_name, 1, 0.82, 0)
                if self._reset_time and self._reset_time > 0 then
                    GameTooltip:AddLine("Lockout expires in " .. FormatSeconds(self._reset_time), 0.7, 0.7, 0.7)
                end
            else
                GameTooltip:AddLine("You are |cff00ffffnot saved|r for " .. info.raid.instance_name, 1, 0.82, 0)
            end
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine("Click |cff44aaff/w|r to whisper the raid leader", 0.60, 0.60, 0.60)
            GameTooltip:Show()
        end)
        row:SetScript("OnLeave", function()
            GameTooltip:Hide()
        end)

        RB_ROWS[i] = row
    end

    -- Hint
    local hintLbl = AceGUI:Create("Label")
    hintLbl:SetText("Detected in: Trade, GLOBAL, Yell.  Hover a row for full message.  Click /w or Join to apply.")
    hintLbl:SetColor(unpack(C.dim))
    hintLbl:SetWidth(CONTENT_W)
    PlaceWidget(hintLbl, hintLbl.frame.height, 4)

    content:SetHeight(yOffset)

    f:SetScript("OnShow", function()
        RefreshEnableBtn()
        RefreshRaidsetLabel()
        RB_RefreshList()
    end)

    return f
end
