--[[
============================================================================
ETB BossLootTooltip Module v2
============================================================================
Shows armor/weapon transmog drops when hovering over NPCs/bosses.

Data pipeline:
  1. Reads compact data directly (avoids force-decoding all 55k items)
  2. Builds npcIndex: npcID → { [itemID] = flags }
  3. Builds encounter→NPC lookup from items that have both fields
  4. Resolves encounter-only items to NPCs via lookup
  5. Builds instance→NPCs lookup for boss grouping
  6. For BOSS-typed items with instanceID but no NPC/encounter (tier token
     items, shared loot), groups them under ALL known bosses in that instance
  7. Token items (cost field has {"i",tokenID,count}) flagged for display

Suppression:
  - During combat (PLAYER_REGEN_DISABLED)
  - During PvP (battlegrounds, arenas)
  - When player is dead/ghost
  - For non-creature units (players, objects, etc.)

Commands:
  /etb bossloot       → Toggle on/off
  /etb bossloot stats → Index statistics
  /etb bossloot build → Force rebuild index
============================================================================
]]--

local ADDON_NAME, NS = ...
local ETB = NS.ETB
if not ETB then return end

ETB.BossLootTooltip = ETB.BossLootTooltip or {}
local BLT = ETB.BossLootTooltip

-- ============================================================================
-- STATE
-- ============================================================================

BLT.initialized = false
BLT.npcIndex = nil          -- npcID → { [itemID] = flags }
BLT.indexBuilt = false
BLT.building = false
BLT.lastNPCID = nil         -- Prevent redundant tooltip updates
BLT.enabled = true          -- Master toggle

-- Stats
BLT.stats = {
    directNPC = 0,          -- Items mapped via direct NPC field
    encounterResolved = 0,  -- Items mapped via encounter→NPC resolution
    instanceGrouped = 0,    -- Items mapped via instance boss grouping
    totalNPCs = 0,
    totalItems = 0,
}

-- ============================================================================
-- CONFIGURATION
-- ============================================================================

local MAX_ITEMS_SHOWN = 20      -- Max items to display in tooltip
local HEADER_COLOR = "|cff00ccff"
local COLLECTED_COLOR = "|cff00ff00"
local UNCOLLECTED_COLOR = "|cffff6666"
local SEPARATOR_COLOR = "|cff444444"
local TOKEN_COLOR = "|cffff8000"
local DIFFICULTY_COLOR = "|cffffcc00"

-- ============================================================================
-- DIFFICULTY DETECTION
-- MoP Classic difficulty IDs → display name + JLoot bitmask
-- JLoot bitmask: 1=Normal, 2=Heroic, 4=LFR, 8=Thunderforged, 15=All
-- ============================================================================

local DIFFICULTY_INFO = {
    -- Dungeons
    [1]  = { name = "Normal",          short = "N",   bitmask = 1,  raid = false },
    [2]  = { name = "Heroic",          short = "H",   bitmask = 2,  raid = false },
    -- Raids: Normal shows diff=1 items only; Heroic shows diff=2 AND diff=8 (heroic-unique models)
    [3]  = { name = "10-Man Normal",   short = "10N", bitmask = 1,  raid = true },
    [4]  = { name = "25-Man Normal",   short = "25N", bitmask = 1,  raid = true },
    [5]  = { name = "10-Man Heroic",   short = "10H", bitmask = 10, raid = true },  -- 2+8: Heroic + Thunderforged/heroic-unique
    [6]  = { name = "25-Man Heroic",   short = "25H", bitmask = 10, raid = true },  -- 2+8: same
    [7]  = { name = "LFR",            short = "LFR", bitmask = 4,  raid = true },
    -- Flex (Siege of Orgrimmar)
    [14] = { name = "Flexible",        short = "Flex", bitmask = 1,  raid = true },
    -- Scenarios
    [11] = { name = "Scenario Heroic", short = "HS",  bitmask = 2,  raid = false },
    [12] = { name = "Scenario Normal", short = "NS",  bitmask = 1,  raid = false },
}

local function GetCurrentDifficulty()
    -- Returns: difficultyInfo table, or nil if not in an instance
    if not IsInInstance or not IsInInstance() then return nil end
    
    local _, instanceType, difficultyID = GetInstanceInfo()
    if not difficultyID or difficultyID == 0 then return nil end
    
    return DIFFICULTY_INFO[difficultyID], difficultyID
end

local function ItemMatchesDifficulty(itemID, diffBitmask)
    -- Check if item drops on the given difficulty
    -- Uses JLoot difficulty data; items without JLoot data are shown (no filter)
    if not diffBitmask or not ETB.JLoot then return true end
    
    local lootData = ETB.JLoot[itemID]
    if not lootData then return true end  -- not in JLoot = show anyway
    
    -- JLoot format: {encounterID, diffBitmask} or {{encID, diff}, {encID, diff}, ...}
    if type(lootData[1]) == "table" then
        -- Multi-entry: check if ANY entry matches
        for _, entry in ipairs(lootData) do
            local itemDiff = entry[2] or 15
            if bit.band(itemDiff, diffBitmask) > 0 then
                return true
            end
        end
        return false
    else
        local itemDiff = lootData[2] or 15
        return bit.band(itemDiff, diffBitmask) > 0
    end
end

-- Armor classID = 4, Weapon classID = 2
local VALID_ITEM_CLASSES = { [2] = true, [4] = true }

-- Non-transmog equip slots to exclude (not visible appearances)
local EXCLUDED_EQUIP_LOCS = {
    INVTYPE_TRINKET = true,
    INVTYPE_FINGER = true,
    INVTYPE_NECK = true,
}

-- Flags for how an item was mapped to an NPC
local FLAG_DIRECT = 1       -- Direct NPC field on item
local FLAG_ENCOUNTER = 2    -- Resolved via encounter→NPC
local FLAG_INSTANCE = 3     -- Grouped via instance boss grouping
local FLAG_TOKEN = 4        -- Has token cost (additive flag)

-- Source type indices (from Compact/Strings.lua ST table)
-- ST = {"ACHIEVEMENT","BOSS","CRAFTED","EVENT","OTHER","PVP","QUEST","RARE_MOB","REMOVED","REPUTATION","SCENARIO","VENDOR","WORLD_DROP"}
local ST_BOSS = 2
local ST_QUEST = 7
local ST_RARE = 8
local ST_VENDOR = 12

-- ============================================================================
-- WORLD BOSS: Encounter Journal encounterID → NPC ID mapping
-- These bosses' loot is coded as QUEST (ST=7) in MoP compact data.
-- SourceDB flags them correctly (flags & 0x08 = isWorldBoss).
-- ============================================================================

local WORLD_BOSS_ENC_TO_NPC = {
    -- MoP World Bosses
    [691]  = 60143,   -- Sha of Anger → NPC 60143
    [725]  = 62346,   -- Galleon (Salyis's Warband) → NPC 62346
    [814]  = 69099,   -- Nalak, The Storm Lord → NPC 69099
    [826]  = 69161,   -- Oondasta → NPC 69161
    [857]  = 71953,   -- Chi-Ji (August Celestials) → NPC 71953
    [858]  = 71954,   -- Niuzao (August Celestials) → NPC 71954
    [859]  = 71955,   -- Xuen (August Celestials) → NPC 71955
    [860]  = 71952,   -- Yu'lon (August Celestials) → NPC 71952
    [861]  = 72057,   -- Ordos, Fire-God of the Yaungol → NPC 72057
    -- Classic World Bosses
    [12397] = 12397,  -- Azuregos
    [6109]  = 6109,   -- Lord Kazzak
}

-- NPC IDs that are world bosses (for tooltip labeling)
local WORLD_BOSS_NPCS = {
    [60143] = "Sha of Anger",
    [62346] = "Galleon",
    [69099] = "Nalak",
    [69161] = "Oondasta",
    [71953] = "Chi-Ji",
    [71954] = "Niuzao",
    [71955] = "Xuen",
    [71952] = "Yu'lon",
    [72057] = "Ordos",
    [60491] = "Celestials", -- Celestial challenge NPC
}

-- Set version for fast lookups during indexing
local WORLD_BOSS_NPC_SET = {}
for npcID in pairs(WORLD_BOSS_NPCS) do
    WORLD_BOSS_NPC_SET[npcID] = true
end

local FLAG_WORLDBOSS = 8  -- Additive flag for world boss items
local FLAG_QUEST = 16     -- Additive flag for quest reward items
local FLAG_RARE = 32      -- Additive flag for rare mob items (from RareMobLoot data)

-- ============================================================================
-- INDEX BUILDER
-- ============================================================================

local function AddToNPCIndex(index, npcID, itemID, flag)
    if not npcID or npcID == 0 or not itemID then return false end
    if not index[npcID] then
        index[npcID] = {}
    end
    -- Flags: base type (1-3) in low bits + additive flags (TOKEN=4, WORLDBOSS=8)
    local existing = index[npcID][itemID]
    if not existing then
        index[npcID][itemID] = flag
        return true
    end
    -- Keep best base flag (lower = better: DIRECT=1 > ENCOUNTER=2 > INSTANCE=3)
    -- Merge additive flags (TOKEN, WORLDBOSS) from both
    local existBase = bit.band(existing, 3)
    local newBase = bit.band(flag, 3)
    local mergedAdditive = bit.bor(bit.band(existing, 0xFC), bit.band(flag, 0xFC))
    local bestBase = math.min(existBase, newBase)
    if bestBase > 0 then
        index[npcID][itemID] = bit.bor(bestBase, mergedAdditive)
    end
    return newBase < existBase
end

function BLT:BuildIndex()
    if self.indexBuilt or self.building then return end
    self.building = true
    
    local SDA = ETB.SourceData_ATT
    if not SDA then
        ETB:Debug("BossLoot", "SourceData_ATT not available")
        self.building = false
        return
    end
    
    -- Ensure MapperBridge name table is loaded (needed for NPC names)
    if SDA.EnsureLoaded then
        SDA:EnsureLoaded()
    end
    
    local compact = NS.Compact
    if not compact then
        ETB:Debug("BossLoot", "Compact data not available")
        self.building = false
        return
    end
    
    local npcIndex = {}             -- npcID → { [itemID] = flag }
    local encToNPCs = {}            -- encounterID → { [npcID] = true } (MULTI-NPC AWARE)
    local instanceNPCs = {}         -- instanceID → { [npcID] = true }
    local pendingEncounter = {}     -- items with encounter but no NPC
    local pendingInstance = {}      -- BOSS items with instance but no NPC/encounter
    
    local stats = { directNPC = 0, encounterResolved = 0, instanceGrouped = 0,
                    worldBoss = 0, questNPC = 0, rareMob = 0 }
    
    local expansions = { "Classic", "TBC", "Wrath", "Cata", "MoP" }
    
    -- ========================================================================
    -- PRE-SEED: World boss encounter→NPC mappings
    -- ========================================================================
    
    for encID, npcID in pairs(WORLD_BOSS_ENC_TO_NPC) do
        if not encToNPCs[encID] then encToNPCs[encID] = {} end
        encToNPCs[encID][npcID] = true
    end
    
    -- PRE-SEED: JEncToNpcs from BossLoot.lua (multi-NPC aware)
    if ETB._BossLootLoader then ETB._BossLootLoader(); ETB._BossLootLoader = nil end
    if ETB.JEncToNpcs then
        for encID, npcData in pairs(ETB.JEncToNpcs) do
            if not encToNPCs[encID] then encToNPCs[encID] = {} end
            if type(npcData) == "table" then
                for _, npc in ipairs(npcData) do
                    encToNPCs[encID][npc] = true
                end
            else
                encToNPCs[encID][npcData] = true
            end
        end
    end
    
    -- ========================================================================
    -- PASS 1: Build direct NPC index + encounter→NPC lookup + collect pending
    -- ONLY index BOSS (2) and RARE_MOB (8) source types — skip vendors,
    -- quest givers, world drops, crafted, PVP, etc. Those aren't "loot."
    -- ========================================================================
    
    for _, expName in ipairs(expansions) do
        local data = compact[expName]
        if data and data.I then
            for itemID, raw in pairs(data.I) do
                if type(raw) == "table" then
                    -- Synchronous class check (no async)
                    local _, _, _, equipLoc, _, classID = GetItemInfoInstant(itemID)
                    
                    -- Skip non-armor/weapon and non-visible slots (trinkets, rings, necks)
                    if classID and VALID_ITEM_CLASSES[classID] and not (equipLoc and EXCLUDED_EQUIP_LOCS[equipLoc]) then
                        local npcID = raw[2]
                        local st = raw[3]       -- source type index
                        local eID = raw[4]      -- encounter ID
                        local iID = raw[5]      -- instance ID
                        
                        -- ONLY process BOSS and RARE_MOB items for the loot tooltip
                        -- Vendor (12), Quest (7), World Drop (13), Crafted (3), PVP (6),
                        -- Event (4), Achievement (1), etc. are NOT boss loot.
                        -- EXCEPTION: QUEST items from known world boss NPCs (coded as
                        -- quest rewards but actually world boss drops)
                        local isWorldBossQuest = (st == ST_QUEST and npcID and WORLD_BOSS_NPC_SET[npcID])
                        if st ~= ST_BOSS and st ~= ST_RARE and not isWorldBossQuest then
                            -- skip entirely
                        
                        -- Skip confirmed world drops (marked as BOSS but drop from 20+ creatures)
                        elseif ETB.WorldDropIndex and ETB.WorldDropIndex[itemID] then
                            -- skip: confirmed world drop, not boss loot
                        else
                        
                        -- Normalize zeros
                        if npcID == 0 then npcID = nil end
                        if eID == 0 then eID = nil end
                        if iID == 0 then iID = nil end
                        
                        -- Detect token cost in field 27
                        local hasToken = false
                        local costField = raw[27]
                        if costField and type(costField) == "table" then
                            for _, c in ipairs(costField) do
                                if type(c) == "table" and c[1] == "i" then
                                    hasToken = true
                                    break
                                end
                            end
                        end
                        
                        if npcID then
                            -- Direct NPC mapping
                            local flag = hasToken and (FLAG_DIRECT + FLAG_TOKEN) or FLAG_DIRECT
                            AddToNPCIndex(npcIndex, npcID, itemID, flag)
                            stats.directNPC = stats.directNPC + 1
                            
                            -- Build encounter→NPCs and instance→NPCs lookups
                            if eID then
                                if not encToNPCs[eID] then encToNPCs[eID] = {} end
                                encToNPCs[eID][npcID] = true
                            end
                            if iID then
                                if not instanceNPCs[iID] then instanceNPCs[iID] = {} end
                                instanceNPCs[iID][npcID] = true
                            end
                            
                        elseif eID then
                            -- Has encounter but no NPC - queue for pass 2
                            pendingEncounter[#pendingEncounter + 1] = {
                                itemID = itemID, eID = eID, iID = iID, hasToken = hasToken
                            }
                            
                        elseif iID then
                            -- BOSS items with instance but no NPC/encounter
                            -- Instance grouping assigns them to ALL known bosses
                            -- in that instance. Token items get priority flag.
                            pendingInstance[#pendingInstance + 1] = {
                                itemID = itemID, iID = iID, hasToken = hasToken
                            }
                        end
                        
                        -- Process additional sources (field 26)
                        -- Only if they are BOSS-type sources
                        local srcs = raw[26]
                        if srcs and type(srcs) == "table" then
                            for _, src in ipairs(srcs) do
                                if type(src) == "table" then
                                    local srcST = src[4] or 0
                                    -- Only process BOSS or RARE additional sources
                                    if srcST == ST_BOSS or srcST == ST_RARE or srcST == 0 then
                                        local srcEID = (src[1] and src[1] ~= 0) and src[1] or nil
                                        local srcIID = (src[2] and src[2] ~= 0) and src[2] or nil
                                        local srcNPC = (src[3] and src[3] ~= 0) and src[3] or nil
                                        
                                        if srcNPC then
                                            AddToNPCIndex(npcIndex, srcNPC, itemID, FLAG_DIRECT)
                                            if srcEID then if not encToNPCs[srcEID] then encToNPCs[srcEID] = {} end; encToNPCs[srcEID][srcNPC] = true end
                                            if srcIID then
                                                if not instanceNPCs[srcIID] then instanceNPCs[srcIID] = {} end
                                                instanceNPCs[srcIID][srcNPC] = true
                                            end
                                        elseif srcEID then
                                            pendingEncounter[#pendingEncounter + 1] = {
                                                itemID = itemID, eID = srcEID, hasToken = hasToken
                                            }
                                        end
                                    end
                                end
                            end
                        end
                        
                        end -- end st == BOSS or RARE check
                    end
                end
            end
        end
    end
    
    -- ========================================================================
    -- PASS 1.5: SourceDB world boss recovery
    -- World boss items (Sha of Anger, Galleon, Nalak, Oondasta, Celestials,
    -- Ordos) are coded as QUEST (ST=7) in MoP compact data. SourceDB
    -- correctly flags them with isWorldBoss=true and has encounter IDs.
    -- ========================================================================
    
    -- Lazy-load SourceDB on first BuildIndex (deferred from login to save memory)
    if ETB.SourceDB and not ETB.SourceDB._loaded then
        ETB.SourceDB:LoadAll()
        ETB:Debug("BossLoot", "SourceDB loaded on demand")
    end
    
    if ETB.SourceDB and ETB.SourceDB.GetAllWorldBossItems then
        local wbItems = ETB.SourceDB:GetAllWorldBossItems()
        if wbItems then
            for _, wbEntry in ipairs(wbItems) do
                local itemID = wbEntry.itemID
                local encID = wbEntry.encounterID
                if itemID and encID then
                    local _, _, _, equipLoc, _, classID = GetItemInfoInstant(itemID)
                    if classID and VALID_ITEM_CLASSES[classID] and not (equipLoc and EXCLUDED_EQUIP_LOCS[equipLoc]) then
                        -- Resolve to ALL NPCs for this encounter (multi-NPC aware)
                        local npcs = encToNPCs[encID]
                        if npcs then
                            for npc in pairs(npcs) do
                                if AddToNPCIndex(npcIndex, npc, itemID, FLAG_DIRECT + FLAG_WORLDBOSS) then
                                    stats.worldBoss = stats.worldBoss + 1
                                end
                            end
                        else
                            pendingEncounter[#pendingEncounter + 1] = {
                                itemID = itemID, eID = encID, hasToken = false
                            }
                        end
                    end
                end
            end
        end
    end
    
    -- (Vendor-scan fallback removed: MoP world boss items are coded as QUEST,
    -- not VENDOR. Pass 1 already catches them via isWorldBossQuest.)
    
    -- ========================================================================
    -- PASS 1.6: SourceDB general loot supplement
    -- Ingests all SourceDB loot items with encounterIDs — not just world bosses.
    -- Recovers ~579 MoP items that compact data lacks encounter mappings for.
    -- Items already in npcIndex (via Pass 1 direct NPC) are skipped.
    -- Items with encounterIDs feed into encToNPCs for Pass 2 resolution.
    -- ========================================================================
    
    local sdbLootAdded = 0
    if ETB.SourceDB and ETB.SourceDB.GetAllLootItems then
        local lootItems = ETB.SourceDB:GetAllLootItems()
        if lootItems then
            for _, lootEntry in ipairs(lootItems) do
                local itemID = lootEntry.itemID
                local encID = lootEntry.encounterID
                if itemID and encID then
                    -- Skip if already indexed via any NPC
                    local alreadyIndexed = false
                    for _, items in pairs(npcIndex) do
                        if items[itemID] then alreadyIndexed = true; break end
                    end
                    if not alreadyIndexed then
                        local _, _, _, equipLoc, _, classID = GetItemInfoInstant(itemID)
                        if classID and VALID_ITEM_CLASSES[classID] and not (equipLoc and EXCLUDED_EQUIP_LOCS[equipLoc]) then
                            -- Try direct resolution via encToNPCs
                            local npcs = encToNPCs[encID]
                            if npcs then
                                for npc in pairs(npcs) do
                                    if AddToNPCIndex(npcIndex, npc, itemID, FLAG_ENCOUNTER) then
                                        sdbLootAdded = sdbLootAdded + 1
                                    end
                                end
                            else
                                -- Queue for Pass 2 encounter resolution
                                pendingEncounter[#pendingEncounter + 1] = {
                                    itemID = itemID, eID = encID,
                                    iID = lootEntry.instanceID, hasToken = false
                                }
                            end
                        end
                    end
                end
            end
        end
        ETB:Debug("BossLoot", string.format("SourceDB loot supplement: %d new item-NPC mappings", sdbLootAdded))
    end
    
    -- ========================================================================
    -- PASS 2: Resolve encounter-only items via encounter→NPCs lookup
    -- Uses: encToNPCs (set-based), world boss table, JNpcToEnc reverse.
    -- NOTE: EJ_GetCreatureInfo returns journal section IDs in MoP 5.4.8,
    -- NOT actual game npcIDs. Do not use it for NPC resolution.
    -- ========================================================================
    
    for _, pending in ipairs(pendingEncounter) do
        local npcs = encToNPCs[pending.eID]
        
        if npcs then
            -- Resolve to ALL NPCs for this encounter
            local flag = pending.hasToken and (FLAG_ENCOUNTER + FLAG_TOKEN) or FLAG_ENCOUNTER
            for npc in pairs(npcs) do
                if AddToNPCIndex(npcIndex, npc, pending.itemID, flag) then
                    stats.encounterResolved = stats.encounterResolved + 1
                end
                if pending.iID then
                    if not instanceNPCs[pending.iID] then instanceNPCs[pending.iID] = {} end
                    instanceNPCs[pending.iID][npc] = true
                end
            end
        end
    end
    
    -- ========================================================================
    -- PASS 3: Instance Shared Loot bucket
    -- Items with instanceID but no NPC/encounter. Previously dropped to
    -- prevent pollution (assigning all to ALL bosses). Now stored in a
    -- separate per-instance bucket and shown under "Shared Instance Loot"
    -- header when hovering any boss in that instance.
    -- Recovers 883 items (458 MoP) — mostly tier tokens, shared drops,
    -- and difficulty variants from ToT (230), HoF (102), ToES (92).
    -- ========================================================================
    
    local instanceLoot = {}      -- instanceID → { [itemID] = true }
    local npcToInstances = {}    -- npcID → { [instanceID] = true }
    
    for _, pending in ipairs(pendingInstance) do
        local iID = pending.iID
        if iID then
            -- Verify it's a transmog item
            local _, _, _, equipLoc, _, classID = GetItemInfoInstant(pending.itemID)
            if classID and VALID_ITEM_CLASSES[classID] and not (equipLoc and EXCLUDED_EQUIP_LOCS[equipLoc]) then
                if not instanceLoot[iID] then instanceLoot[iID] = {} end
                instanceLoot[iID][pending.itemID] = true
                stats.instanceGrouped = stats.instanceGrouped + 1
            end
        end
    end
    
    -- Build NPC → instances reverse lookup from instanceNPCs
    for iID, npcs in pairs(instanceNPCs) do
        for npc in pairs(npcs) do
            if not npcToInstances[npc] then npcToInstances[npc] = {} end
            npcToInstances[npc][iID] = true
        end
    end
    
    -- ========================================================================
    -- PASS 4: Quest NPC items (non-world-boss quest givers with transmog)
    -- These are actual quest givers where the reward is transmoggable gear.
    -- Scans QUEST items that have NPC IDs but aren't world bosses
    -- (world boss quest items were already caught in PASS 1).
    -- ========================================================================
    
    for _, expName in ipairs(expansions) do
        local data = compact[expName]
        if data and data.I then
            for itemID, raw in pairs(data.I) do
                if type(raw) == "table" then
                    local npcID = raw[2]
                    local st = raw[3]
                    if st == ST_QUEST and npcID and npcID ~= 0 and not WORLD_BOSS_NPC_SET[npcID] then
                        local _, _, _, equipLoc, _, classID = GetItemInfoInstant(itemID)
                        if classID and VALID_ITEM_CLASSES[classID] and not (equipLoc and EXCLUDED_EQUIP_LOCS[equipLoc]) then
                            if AddToNPCIndex(npcIndex, npcID, itemID, FLAG_DIRECT + FLAG_QUEST) then
                                stats.questNPC = stats.questNPC + 1
                            end
                        end
                    end
                end
            end
        end
    end
    
    -- ========================================================================
    -- PASS 5: Rare mob loot from RareMobLoot supplemental data
    -- 529 rare NPCs with transmog items.
    -- These are open-world rare spawns that drop BoE greens/blues.
    -- Only items that exist in our compact data are included.
    -- ========================================================================
    
    local rareMobData = NS.RareMobLoot
    if rareMobData then
        for npcID, itemList in pairs(rareMobData) do
            for _, itemID in ipairs(itemList) do
                local _, _, _, equipLoc, _, classID = GetItemInfoInstant(itemID)
                if classID and VALID_ITEM_CLASSES[classID] and not (equipLoc and EXCLUDED_EQUIP_LOCS[equipLoc]) then
                    if AddToNPCIndex(npcIndex, npcID, itemID, FLAG_DIRECT + FLAG_RARE) then
                        stats.rareMob = stats.rareMob + 1
                    end
                end
            end
        end
    end
    
    -- ========================================================================
    -- PASS 6: Journal Loot Database (JLoot) supplement
    -- Uses ETB.JLoot + encToNPCs to add items from the Blizzard encounter
    -- journal that compact data missed. Maps to ALL NPCs for ALL encounters
    -- (does NOT break after first match — preserves multi-encounter items).
    -- NOTE: Does NOT use EJ_GetCreatureInfo — it returns journal section IDs
    -- in MoP 5.4.8, not actual game npcIDs.
    -- ========================================================================
    
    local jlootAdded = 0
    if ETB._BossLootLoader then ETB._BossLootLoader(); ETB._BossLootLoader = nil end
    if ETB.JLoot then
        for itemID, lootData in pairs(ETB.JLoot) do
            -- Skip tier tokens — PASS 7 handles their NPC resolution
            -- from TierTokenSource which has authoritative boss mappings.
            -- Adding them here via JLoot causes wrong-boss conflicts.
            if ETB.TierTokenMap and ETB.TierTokenMap[itemID] then
                -- skip: tier token handled by PASS 7
            else
                -- JLoot is authoritative — items are confirmed boss drops.
                -- Filter non-transmoggable slots (trinkets, rings, necks).
                local _, _, _, jEquipLoc, _, jClassID = GetItemInfoInstant(itemID)
                if jClassID and not VALID_ITEM_CLASSES[jClassID] then
                    -- skip non-armor/weapon
                elseif jEquipLoc and EXCLUDED_EQUIP_LOCS[jEquipLoc] then
                    -- skip trinkets, rings, necks
                else
                    local entries = type(lootData[1]) == "table" and lootData or { lootData }
                    for _, entry in ipairs(entries) do
                        local encID = entry[1]
                        local npcs = encToNPCs[encID]
                        if npcs then
                            for npc in pairs(npcs) do
                                if AddToNPCIndex(npcIndex, npc, itemID, FLAG_ENCOUNTER) then
                                    jlootAdded = jlootAdded + 1
                                end
                            end
                        elseif ETB.JEncToNpcs then
                            local npc = ETB.JEncToNpcs[encID]
                            if npc and npc > 0 then
                                if AddToNPCIndex(npcIndex, npc, itemID, FLAG_ENCOUNTER) then
                                    jlootAdded = jlootAdded + 1
                                end
                            end
                        end
                    end
                end
            end
        end
        
        ETB:Debug("BossLoot", string.format("JLoot supplement: %d new item-NPC mappings added", jlootAdded))
    end
    
    -- ========================================================================
    -- PASS 6: Inject tier tokens from TierTokenSource into NPC index
    -- Tier tokens aren't transmog items so they're not in compact data,
    -- but bosses drop them and players need to see them in tooltips.
    -- ========================================================================
    
    local tierTokensAdded = 0
    if ETB._TierTokenMapLoader then ETB._TierTokenMapLoader(); ETB._TierTokenMapLoader = nil end
    if ETB.TierTokenSource and ETB.TierTokenMap then
        for tokenID, src in pairs(ETB.TierTokenSource) do
            local encID = src[1] or 0
            local instID = src[2] or 0
            local npcID = src[3] or 0
            
            -- Resolve NPC from encounter if needed
            if npcID == 0 and encID > 0 then
                -- Check our built encToNPCs table first
                local npcs = encToNPCs[encID]
                if npcs then
                    for npc in pairs(npcs) do
                        npcID = npc
                        break
                    end
                end
                -- Fallback to JEncToNpcs
                if npcID == 0 and ETB.JEncToNpcs then
                    npcID = ETB.JEncToNpcs[encID] or 0
                end
            end
            
            if npcID > 0 then
                if AddToNPCIndex(npcIndex, npcID, tokenID, FLAG_DIRECT + FLAG_TOKEN) then
                    tierTokensAdded = tierTokensAdded + 1
                end
            end
        end
        ETB:Debug("BossLoot", string.format("Tier tokens injected: %d", tierTokensAdded))
    end
    
    -- ========================================================================
    -- FINALIZE
    -- ========================================================================
    
    local totalNPCs, totalItems = 0, 0
    for _, items in pairs(npcIndex) do
        totalNPCs = totalNPCs + 1
        for _ in pairs(items) do
            totalItems = totalItems + 1
        end
    end
    
    self.npcIndex = npcIndex
    self.instanceLoot = instanceLoot        -- instanceID → { [itemID] = true }
    self.npcToInstances = npcToInstances    -- npcID → { [instanceID] = true }
    
    -- Count instance shared loot items
    local instLootCount = 0
    for _, items in pairs(instanceLoot) do
        for _ in pairs(items) do instLootCount = instLootCount + 1 end
    end
    
    self.stats = {
        directNPC = stats.directNPC,
        encounterResolved = stats.encounterResolved,
        instanceGrouped = stats.instanceGrouped,
        worldBoss = stats.worldBoss,
        questNPC = stats.questNPC,
        rareMob = stats.rareMob,
        jlootAdded = jlootAdded or 0,
        sdbLootAdded = sdbLootAdded or 0,
        tierTokens = tierTokensAdded or 0,
        instSharedLoot = instLootCount,
        totalNPCs = totalNPCs,
        totalItems = totalItems,
        pendingEncounter = #pendingEncounter,
        pendingInstance = #pendingInstance,
        encToNPCCount = 0,
        instanceNPCCount = 0,
    }
    for _ in pairs(encToNPCs) do self.stats.encToNPCCount = self.stats.encToNPCCount + 1 end
    for _ in pairs(instanceNPCs) do self.stats.instanceNPCCount = self.stats.instanceNPCCount + 1 end
    
    -- Build JLoot reverse index for fast hover lookups
    self:BuildJLootIndex()
    
    -- Pre-warm item cache in staggered batches. The server throttles rapid
    -- GetItemInfo requests — batching 50 items per 0.1s avoids the throttle
    -- and ensures heroic-unique VENDOR items are cached before player hovers.
    local uncached = {}
    for _, items in pairs(npcIndex) do
        for itemID in pairs(items) do
            if not GetItemInfo(itemID) then
                uncached[#uncached + 1] = itemID
            end
        end
    end
    if #uncached > 0 then
        local BATCH_SIZE = 50
        local batchIndex = 0
        local function warmBatch()
            local startIdx = batchIndex * BATCH_SIZE + 1
            local endIdx = math.min(startIdx + BATCH_SIZE - 1, #uncached)
            if startIdx > #uncached then return end
            for i = startIdx, endIdx do
                GetItemInfo(uncached[i])
            end
            batchIndex = batchIndex + 1
            if endIdx < #uncached then
                C_Timer.After(0.1, warmBatch)
            end
        end
        warmBatch()
        ETB:Debug("BossLoot", string.format("Pre-warming cache: %d items in %d batches",
            #uncached, math.ceil(#uncached / BATCH_SIZE)))
    end
    
    self.indexBuilt = true
    self.building = false
    
    ETB:Debug("BossLoot", string.format(
        "Index built: %d NPCs, %d items (direct:%d, enc:%d, instShared:%d, wBoss:%d, quest:%d, rare:%d, sdbLoot:%d, tierTokens:%d)",
        totalNPCs, totalItems, stats.directNPC, stats.encounterResolved,
        instLootCount, stats.worldBoss, stats.questNPC, stats.rareMob, sdbLootAdded, tierTokensAdded or 0))
end

-- ============================================================================
-- NPC ID EXTRACTION FROM GUID
-- ============================================================================

function BLT:GetNPCIDFromGUID(guid)
    if not guid then return nil end
    local unitType, _, _, _, _, npcID = strsplit("-", guid)
    if unitType == "Creature" or unitType == "Vehicle" then
        return tonumber(npcID)
    end
    return nil
end

-- ============================================================================
-- ITEM COLLECTION CHECK
-- Uses cross-class appearance check for MoP Classic:
--   GetItemInfo(itemID) → appearanceID, sourceID
--   GetAllAppearanceSources(appearanceID) → sourceID[] (CROSS-CLASS, not filtered!)
--   PlayerHasTransmogItemModifiedAppearance(sourceID) → boolean per source
--
-- KEY: GetAppearanceSources is CLASS-FILTERED (returns nil for other armor types!)
--      GetAllAppearanceSources is CROSS-CLASS (returns all sources)
--      GetSourceInfo.isCollected is also CLASS-FILTERED
--      PlayerHasTransmogItemModifiedAppearance is NOT class-filtered
-- ============================================================================

-- Collection status cache: avoids repeated API calls per render
-- Cleared on TRANSMOG_COLLECTION_UPDATED event
BLT._collectionCache = {}

function BLT:ClearCollectionCache()
    self._collectionCache = {}
end

-- Returns: true (collected), false (not collected), nil (can't determine yet)
function BLT:IsAppearanceCollected(appearanceID)
    if not appearanceID or not C_TransmogCollection then return nil end
    
    -- PRIMARY: GetAllAppearanceSources — cross-class, returns ALL sources for this appearance
    if C_TransmogCollection.GetAllAppearanceSources then
        local ok, allSources = pcall(C_TransmogCollection.GetAllAppearanceSources, appearanceID)
        if ok and allSources and #allSources > 0 and C_TransmogCollection.PlayerHasTransmogItemModifiedAppearance then
            for _, sid in ipairs(allSources) do
                local hasOK, hasIt = pcall(C_TransmogCollection.PlayerHasTransmogItemModifiedAppearance, sid)
                if hasOK and hasIt then
                    return true
                end
            end
            return false
        end
    end
    
    -- FALLBACK: GetAppearanceSources — class-filtered but still appearance-wide
    if C_TransmogCollection.GetAppearanceSources then
        local ok, sources = pcall(C_TransmogCollection.GetAppearanceSources, appearanceID)
        if ok and sources then
            for _, src in ipairs(sources) do
                if type(src) == "table" and src.isCollected then
                    return true
                end
                local sid = type(src) == "table" and src.sourceID or src
                if sid and C_TransmogCollection.PlayerHasTransmogItemModifiedAppearance then
                    local hasOK, hasIt = pcall(C_TransmogCollection.PlayerHasTransmogItemModifiedAppearance, sid)
                    if hasOK and hasIt then return true end
                end
            end
            return false
        end
    end
    
    return nil  -- can't determine
end

function BLT:IsItemCollected(itemID)
    if not itemID or not C_TransmogCollection then return false end
    
    -- Check cache first
    local cached = self._collectionCache[itemID]
    if cached ~= nil then return cached end
    
    -- Step 1: Get appearanceID via multiple approaches
    local appearanceID, sourceID
    if C_TransmogCollection.GetItemInfo then
        -- Try bare itemID
        local ok, aID, sID = pcall(C_TransmogCollection.GetItemInfo, itemID)
        if ok then appearanceID = aID; sourceID = sID end
        
        if not appearanceID then
            -- Try with item link from GetItemInfo
            local _, itemLink = GetItemInfo(itemID)
            if itemLink then
                ok, aID, sID = pcall(C_TransmogCollection.GetItemInfo, itemLink)
                if ok then appearanceID = aID; sourceID = sID end
            end
        end
        if not appearanceID then
            -- Try with manually constructed item link
            local link = "item:" .. itemID .. ":0:0:0:0:0:0:0"
            ok, aID, sID = pcall(C_TransmogCollection.GetItemInfo, link)
            if ok then appearanceID = aID; sourceID = sID end
        end
    end
    
    -- Step 2: Appearance-wide check (the correct way)
    if appearanceID then
        local result = self:IsAppearanceCollected(appearanceID)
        if result ~= nil then
            self._collectionCache[itemID] = result
            return result
        end
    end
    
    -- Step 3: Single sourceID check (less accurate — doesn't see shared appearances)
    if sourceID and C_TransmogCollection.PlayerHasTransmogItemModifiedAppearance then
        local ok, result = pcall(C_TransmogCollection.PlayerHasTransmogItemModifiedAppearance, sourceID)
        if ok then
            self._collectionCache[itemID] = result
            return result
        end
    end
    
    -- Step 4: Item not cached yet — DON'T cache false, let it retry next render
    -- (GET_ITEM_INFO_RECEIVED will trigger a tooltip rebuild)
    return false
end

-- ============================================================================
-- CLASS USABILITY CHECK
-- ============================================================================

function BLT:CanPlayerUseItem(itemID)
    if not itemID then return false end
    local _, _, _, equipLoc, _, classID, subclassID = GetItemInfoInstant(itemID)
    
    -- If item info isn't cached yet, assume usable (don't silently drop it)
    if not classID then return true end
    
    if not VALID_ITEM_CLASSES[classID] then return false end
    
    -- Exclude non-visible slots (trinkets, rings, necklaces)
    if equipLoc and EXCLUDED_EQUIP_LOCS[equipLoc] then return false end
    
    if classID == 4 and ETB.BlizzardData then -- Armor
        if subclassID == 6 then
            -- Shields: check weapon proficiency (only Warrior/Paladin/Shaman)
            if not ETB.BlizzardData:CanPlayerUseWeaponType("SHIELD") then
                return false
            end
        else
            -- Regular armor: check primary armor type
            local ARMOR_MAP = { [1]="CLOTH", [2]="LEATHER", [3]="MAIL", [4]="PLATE" }
            local armorType = ARMOR_MAP[subclassID]
            -- Misc armor (subclass 0: cloaks, shirts, tabards) skip armor check — all classes use
            if armorType and not ETB.BlizzardData:CanPlayerWearArmorType(armorType) then
                return false
            end
        end
    elseif classID == 2 and ETB.BlizzardData then -- Weapon
        local weaponType = ETB.BlizzardData:GetWeaponType(itemID)
        if weaponType and not ETB.BlizzardData:CanPlayerUseWeaponType(weaponType) then
            return false
        end
    end
    
    -- Check class restrictions (e.g., Warrior-only tier items)
    if ETB.ClassRestrictions then
        if not ETB.ClassRestrictions:IsItemUsableByPlayer(itemID) then
            return false
        end
    end
    
    return true
end

-- ============================================================================
-- TOOLTIP POPULATION
-- ============================================================================

-- Status icons (using common UI textures)
local ICON_COLLECTED = "|TInterface\\RaidFrame\\ReadyCheck-Ready:14:14:0:0|t"
local ICON_NOT_COLLECTED = "|TInterface\\RaidFrame\\ReadyCheck-NotReady:14:14:0:0|t"

function BLT:AddLootToTooltip(tooltip, npcID)
    if not tooltip or not npcID then return end
    if not self.indexBuilt then return end
    
    local npcItems = self.npcIndex[npcID]
    if not npcItems then return end
    
    -- Clear pending items for this render pass
    self._pendingItems = {}
    
    -- Detect current instance difficulty (for header display only)
    local diffInfo = GetCurrentDifficulty()
    
    -- Get player class bitmask for tier token expansion
    local playerClassBit = 0
    -- Primary: ETB.ClassRestrictions module
    if ETB.ClassRestrictions and ETB.ClassRestrictions.GetPlayerClassID then
        local cid = ETB.ClassRestrictions:GetPlayerClassID()
        if cid and cid >= 1 and cid <= 11 then
            local bits = {1,2,4,8,16,32,64,128,256,512,1024}
            playerClassBit = bits[cid] or 0
        end
    end
    -- Fallback: Blizzard API
    if playerClassBit == 0 then
        local _, _, classID = UnitClass("player")
        if classID and classID >= 1 and classID <= 11 then
            local bits = {1,2,4,8,16,32,64,128,256,512,1024}
            playerClassBit = bits[classID] or 0
        end
    end
    
    -- Determine bucket mode from JLoot diff values on this boss's items:
    --   "flat"     = Classic/TBC: single list, no headers
    --   "size"     = Wrath Naxx/OS/EoE/Ulduar/VoA: 10-Man / 25-Man
    --   "wrath4"   = Wrath ICC/ToC/RS: 10N / 10H / 25N / 25H
    --   "difficulty" = Cata/MoP: Normal / Heroic
    local has16or64, has32or128, hasCataStyle = false, false, false
    for itemID in pairs(npcItems) do
        if ETB.JLoot then
            local ld = ETB.JLoot[itemID]
            if ld then
                local d = type(ld[1]) == "table" and (ld[1][2] or 0) or (ld[2] or 0)
                if d == 16 or d == 64 then has16or64 = true end
                if d == 32 or d == 128 then has32or128 = true end
                if d == 1 or d == 2 or d == 3 or d == 7 or d == 8 or d == 11 or d == 15 then
                    hasCataStyle = true
                end
            end
        end
        if has32or128 then break end  -- 4-bucket confirmed, stop early
    end
    
    local bucketMode
    if has32or128 then
        bucketMode = "wrath4"
    elseif has16or64 then
        bucketMode = "size"
    elseif hasCataStyle then
        bucketMode = "difficulty"
    else
        bucketMode = "flat"
    end
    
    -- Bucket definitions per mode
    local buckets, bucketOrder, bucketNames, bucketColors, seenPerBucket
    
    if bucketMode == "wrath4" then
        buckets = { ["10N"]={}, ["10H"]={}, ["25N"]={}, ["25H"]={} }
        bucketOrder = {"10N", "10H", "25N", "25H"}
        bucketNames = { ["10N"]="10-Man Normal", ["10H"]="10-Man Heroic",
                        ["25N"]="25-Man Normal", ["25H"]="25-Man Heroic" }
        bucketColors = { ["10N"]="|cffffff00", ["10H"]="|cffff8000",
                         ["25N"]="|cffffff00", ["25H"]="|cffff8000" }
        seenPerBucket = { ["10N"]={}, ["10H"]={}, ["25N"]={}, ["25H"]={} }
    elseif bucketMode == "size" then
        buckets = { ["10"]={}, ["25"]={} }
        bucketOrder = {"10", "25"}
        bucketNames = { ["10"]="10-Man", ["25"]="25-Man" }
        bucketColors = { ["10"]="|cffffff00", ["25"]="|cffffff00" }
        seenPerBucket = { ["10"]={}, ["25"]={} }
    elseif bucketMode == "difficulty" then
        buckets = { ["N"]={}, ["H"]={} }
        bucketOrder = {"N", "H"}
        bucketNames = { ["N"]="Normal", ["H"]="Heroic" }
        bucketColors = { ["N"]="|cffffff00", ["H"]="|cffff8000" }
        seenPerBucket = { ["N"]={}, ["H"]={} }
    else -- flat
        buckets = { ["all"]={} }
        bucketOrder = {"all"}
        bucketNames = { ["all"]="" }
        bucketColors = { ["all"]="|cffffff00" }
        seenPerBucket = { ["all"]={} }
    end
    
    local collectedCount = 0
    local totalCount = 0
    
    local DIFF_IS_HEROIC = {[2]=true,[8]=true,[11]=true}
    
    local function getBucketKey(jlootDiff, forceBucket)
        if type(forceBucket) == "string" then return forceBucket end
        if bucketMode == "wrath4" then
            if jlootDiff == 16 then return "10N"
            elseif jlootDiff == 32 then return "10H"
            elseif jlootDiff == 64 then return "25N"
            elseif jlootDiff == 128 then return "25H"
            else return "25N" end
        elseif bucketMode == "size" then
            if jlootDiff == 16 then return "10"
            elseif jlootDiff == 64 then return "25"
            else return "25" end
        elseif bucketMode == "difficulty" then
            if forceBucket == true then return "H"
            elseif forceBucket == false then return "N"
            elseif jlootDiff and DIFF_IS_HEROIC[jlootDiff] then return "H"
            else return "N" end
        else
            return "all"
        end
    end
    
    local function getJLootDiff(itemID)
        if ETB.JLoot then
            local ld = ETB.JLoot[itemID]
            if ld then
                return type(ld[1]) == "table" and (ld[1][2] or nil) or (ld[2] or nil)
            end
        end
        return nil
    end
    
    local function addItem(itemID, isToken, forceBucket)
        local jd = getJLootDiff(itemID)
        local bucketKey = getBucketKey(jd, forceBucket)
        
        local seen = seenPerBucket[bucketKey]
        if seen[itemID] then return end
        
        local itemName, _, quality, _, _, _, _, _, _, texture = GetItemInfo(itemID)
        if not itemName then
            GetItemInfo(itemID)
            local _, _, _, _, icon = GetItemInfoInstant(itemID)
            itemName = nil
            texture = icon
            quality = 3
            if not self._pendingItems then self._pendingItems = {} end
            self._pendingItems[itemID] = npcID
        end
        if not itemName then
            itemName = "Loading..."
        end
        
        seen[itemID] = true
        
        local isCollected = not isToken and self:IsItemCollected(itemID) or false

        
        local entry = {
            itemID = itemID,
            name = itemName,
            quality = quality or 0,
            texture = texture,
            collected = isCollected,
            isToken = isToken or false,
        }
        
        buckets[bucketKey][#buckets[bucketKey] + 1] = entry
        
        if not isToken then
            totalCount = totalCount + 1
            if isCollected then
                collectedCount = collectedCount + 1
            end
        end
    end
    
    for itemID, flag in pairs(npcItems) do
        -- Quick filter: skip trinkets/rings/necks even if uncached
        local _, _, _, dispEquipLoc = GetItemInfoInstant(itemID)
        if dispEquipLoc and EXCLUDED_EQUIP_LOCS[dispEquipLoc] then
            -- skip non-transmoggable slots
        else
        local isTierToken = ETB.TierTokenMap and ETB.TierTokenMap[itemID]
        
        if not isTierToken and not self:CanPlayerUseItem(itemID) then
            -- skip
        elseif isTierToken then
            -- Only show this token if it has pieces for the player's class
            local hasClassPiece = false
            if playerClassBit > 0 then
                for _, mapping in ipairs(isTierToken) do
                    local classMask = mapping[2]
                    if classMask == 0 or bit.band(classMask, playerClassBit) > 0 then
                        hasClassPiece = true
                        break
                    end
                end
            else
                -- Class unknown: show all tokens
                hasClassPiece = true
            end
            
            if hasClassPiece then
                local tokenDiff = getJLootDiff(itemID)
                local tokenBucket = getBucketKey(tokenDiff, nil)
                
                -- Add the token to its bucket
                addItem(itemID, true, tokenBucket)
                
                -- Add expanded pieces into SAME bucket as token
                for _, mapping in ipairs(isTierToken) do
                    local pieceID = mapping[1]
                    local classMask = mapping[2]
                    if playerClassBit == 0 or classMask == 0 or bit.band(classMask, playerClassBit) > 0 then
                        addItem(pieceID, false, tokenBucket)
                    end
                end
            end
        else
            addItem(itemID, false)
        end
        end -- equipLoc filter
    end
    
    -- Add shared boss loot (items from the same instance but no specific boss)
    if self.instanceLoot then
        local instIDsToCheck = {}
        
        -- Primary: npcToInstances (from compact data)
        if self.npcToInstances and self.npcToInstances[npcID] then
            for instID in pairs(self.npcToInstances[npcID]) do
                instIDsToCheck[instID] = true
            end
        end
        
        -- Fallback: JNpcToEnc → JEncounters → instanceID (for JLoot-only bosses)
        if not next(instIDsToCheck) and ETB.JNpcToEnc and ETB.JEncounters then
            local encID = ETB.JNpcToEnc[npcID]
            if encID then
                local encData = ETB.JEncounters[encID]
                if encData and encData[2] then
                    instIDsToCheck[encData[2]] = true
                end
            end
        end
        
        for instID in pairs(instIDsToCheck) do
            local sharedItems = self.instanceLoot[instID]
            if sharedItems then
                for itemID in pairs(sharedItems) do
                    if self:CanPlayerUseItem(itemID) then
                        local _, _, _, sblEquipLoc = GetItemInfoInstant(itemID)
                        if not (sblEquipLoc and EXCLUDED_EQUIP_LOCS[sblEquipLoc]) then
                            addItem(itemID, false)
                        end
                    end
                end
            end
        end
    end
    
    if totalCount == 0 then
        local anyItems = false
        for _, key in ipairs(bucketOrder) do
            if #buckets[key] > 0 then anyItems = true; break end
        end
        if not anyItems then return end
    end
    
    -- Sort each bucket: tokens first, then quality desc, then name
    local function sortItems(a, b)
        if a.isToken ~= b.isToken then return a.isToken end

        if a.quality ~= b.quality then return a.quality > b.quality end
        return a.name < b.name
    end
    
    -- Dedup by name within each bucket
    local function dedupBucket(bucket)
        local seen = {}
        local deduped = {}
        for _, entry in ipairs(bucket) do
            if entry.isToken or entry.name == "Loading..." then
                deduped[#deduped + 1] = entry
            elseif not seen[entry.name] then
                seen[entry.name] = true
                deduped[#deduped + 1] = entry
            end
        end
        return deduped
    end
    
    for _, key in ipairs(bucketOrder) do
        table.sort(buckets[key], sortItems)
        buckets[key] = dedupBucket(buckets[key])
    end
    
    -- ========================================================================
    -- DIFFICULTY FILTER: When inside a raid, only show the matching bucket.
    -- Outside instances, show all buckets for reference.
    -- ========================================================================
    if diffInfo and bucketMode ~= "flat" then
        local _, _, difficultyID = GetInstanceInfo()
        local matchBucket = nil
        
        if bucketMode == "wrath4" then
            local DIFF_TO_WRATH4 = {
                [3]="10N", [5]="10H", [4]="25N", [6]="25H",
                [1]="10N", [2]="10H",  -- dungeons: map to 10-man equivalents
            }
            matchBucket = DIFF_TO_WRATH4[difficultyID]
        elseif bucketMode == "size" then
            local DIFF_TO_SIZE = {
                [3]="10", [5]="10", [4]="25", [6]="25",
                [1]="10", [2]="10",
            }
            matchBucket = DIFF_TO_SIZE[difficultyID]
        elseif bucketMode == "difficulty" then
            local DIFF_TO_NH = {
                [1]="N", [3]="N", [4]="N", [14]="N",  -- Normal, 10N, 25N, Flex
                [2]="H", [5]="H", [6]="H",            -- Heroic, 10H, 25H
            }
            matchBucket = DIFF_TO_NH[difficultyID]
        end
        
        if matchBucket and buckets[matchBucket] and #buckets[matchBucket] > 0 then
            bucketOrder = { matchBucket }
        end
    end
    
    -- Recount after dedup + difficulty filter
    totalCount = 0
    collectedCount = 0
    for _, key in ipairs(bucketOrder) do
        for _, entry in ipairs(buckets[key]) do
            if not entry.isToken then
                totalCount = totalCount + 1
                if entry.collected then collectedCount = collectedCount + 1 end
            end
        end
    end
    
    -- Render
    local pct = totalCount > 0 and (100 * collectedCount / totalCount) or 0
    local countColor = collectedCount == totalCount and COLLECTED_COLOR or UNCOLLECTED_COLOR
    
    if diffInfo then
        tooltip:AddDoubleLine(
            "|cffffff00Contains:|r " .. DIFFICULTY_COLOR .. diffInfo.name .. "|r",
            string.format("%s%d / %d (%.0f%%)|r", countColor, collectedCount, totalCount, pct)
        )
    else
        tooltip:AddDoubleLine(
            "|cffffff00Contains:|r",
            string.format("%s%d / %d (%.0f%%)|r", countColor, collectedCount, totalCount, pct)
        )
    end
    
    local shown = 0
    
    local function renderEntry(entry)
        if shown >= MAX_ITEMS_SHOWN then return false end
        local icon = entry.texture and ("|T" .. entry.texture .. ":14:14:0:0|t ") or ""
        if entry.isToken then
            tooltip:AddDoubleLine(
                " " .. icon .. TOKEN_COLOR .. "[" .. entry.name .. "]|r",
                TOKEN_COLOR .. "Token|r"
            )
        else
            local qualityColor = "|cffffffff"
            if entry.quality > 0 then
                local r, g, b = GetItemQualityColor(entry.quality)
                qualityColor = string.format("|cff%02x%02x%02x", r*255, g*255, b*255)
            end
            local status = entry.collected and ICON_COLLECTED or ICON_NOT_COLLECTED

            tooltip:AddDoubleLine(
                " " .. icon .. qualityColor .. "[" .. entry.name .. "]|r",
                status
            )
        end
        shown = shown + 1
        return true
    end
    
    -- Render each bucket with section header
    for _, key in ipairs(bucketOrder) do
        if #buckets[key] > 0 and shown < MAX_ITEMS_SHOWN then
            -- Only show header if it has a name (skip for flat mode)
            if bucketNames[key] ~= "" then
                tooltip:AddLine(bucketColors[key] .. bucketNames[key] .. "|r")
            end
            for _, entry in ipairs(buckets[key]) do
                if not renderEntry(entry) then break end
            end
        end
    end
    
    -- Overflow
    if shown >= MAX_ITEMS_SHOWN then
        local remaining = totalCount - shown
        if remaining > 0 then
            tooltip:AddLine("|cff888888+" .. remaining .. " more items...|r")
        end
    end
    
    tooltip:Show()
end

-- ============================================================================
-- EVENT HANDLING
-- ============================================================================

function BLT:ShouldSuppress()
    if UnitAffectingCombat("player") then return true end
    if C_PvP and C_PvP.IsBattleground and C_PvP.IsBattleground() then return true end
    if C_PvP and C_PvP.IsArena and C_PvP.IsArena() then return true end
    if UnitIsDeadOrGhost("player") then return true end
    return false
end

function BLT:OnMouseoverUnit()
    if not self.enabled then return end
    if self:ShouldSuppress() then return end
    
    -- Lazy init: build index on first NPC hover
    if not self.indexBuilt then
        self:BuildIndex()
        if not self.indexBuilt then return end
    end
    
    local guid = UnitGUID("mouseover")
    if not guid then return end
    
    local npcID = self:GetNPCIDFromGUID(guid)
    if not npcID then return end
    
    -- Skip if same NPC (tooltip already populated)
    if npcID == self.lastNPCID then return end
    self.lastNPCID = npcID
    
    -- Primary: compact data index
    if self.npcIndex[npcID] then
        self:AddLootToTooltip(GameTooltip, npcID)
        return
    end
    
    -- Secondary: JLoot database (JNpcToEnc → encounterID → pre-built reverse index)
    if ETB.JNpcToEnc and ETB.JEncounters and self._jlootByEnc then
        local encID = ETB.JNpcToEnc[npcID]
        if encID then
            local itemIDs = self._jlootByEnc[encID]
            if itemIDs then
                -- Add all JLoot items — don't filter with GetItemInfoInstant
                -- since heroic items may not be cached. Class filtering
                -- happens in addItem via CanPlayerUseItem (which returns true for unknowns).
                local jlootItems = {}
                for itemID in pairs(itemIDs) do
                    jlootItems[itemID] = FLAG_ENCOUNTER
                end
                if next(jlootItems) then
                    self.npcIndex[npcID] = jlootItems
                    self:AddLootToTooltip(GameTooltip, npcID)
                    return
                end
            end
        end
    end
end

-- ============================================================================
-- INITIALIZATION
-- ============================================================================

function BLT:Initialize()
    if self.initialized then return end
    
    local frame = CreateFrame("Frame")
    frame:RegisterEvent("UPDATE_MOUSEOVER_UNIT")
    frame:RegisterEvent("PLAYER_REGEN_DISABLED")
    frame:RegisterEvent("PLAYER_REGEN_ENABLED")
    frame:RegisterEvent("GET_ITEM_INFO_RECEIVED")
    frame:RegisterEvent("TRANSMOG_COLLECTION_UPDATED")
    
    frame:SetScript("OnEvent", function(_, event, arg1)
        if event == "UPDATE_MOUSEOVER_UNIT" then
            -- Compare to current NPC — Blizzard fires this multiple times per hover
            local guid = UnitGUID("mouseover")
            local currentNPC = guid and BLT:GetNPCIDFromGUID(guid)
            if currentNPC ~= BLT.lastNPCID then
                BLT.lastNPCID = nil
                BLT:OnMouseoverUnit()
            end
        elseif event == "PLAYER_REGEN_DISABLED" then
            BLT.lastNPCID = nil
            if GameTooltip:IsShown() then
                GameTooltip:Hide()
            end
        elseif event == "TRANSMOG_COLLECTION_UPDATED" then
            BLT:ClearCollectionCache()
            -- Re-add our section if hovering a boss (don't reset with SetUnit)
            if UnitExists("mouseover") and GameTooltip:IsShown() and BLT.lastNPCID then
                BLT:AddLootToTooltip(GameTooltip, BLT.lastNPCID)
            end
        elseif event == "GET_ITEM_INFO_RECEIVED" then
            -- Item just cached. If hovering a boss, re-add our loot section.
            -- DON'T call SetUnit — that resets the tooltip and causes revert.
            local itemID = arg1
            if itemID and BLT._pendingItems and BLT._pendingItems[itemID] then
                BLT._pendingItems[itemID] = nil
                if not BLT._refreshThrottle then
                    BLT._refreshThrottle = true
                    C_Timer.After(0.3, function()
                        BLT._refreshThrottle = false
                        if UnitExists("mouseover") and GameTooltip:IsShown() and BLT.lastNPCID then
                            -- Re-add our section without resetting blizzard tooltip
                            BLT:AddLootToTooltip(GameTooltip, BLT.lastNPCID)
                        end
                    end)
                end
            end
        end
    end)
    
    self.frame = frame
    self.initialized = true
    
    -- Hook GameTooltip:OnTooltipSetUnit — Blizzard internally refreshes unit
    -- tooltips (health, buffs, etc) which clears all our AddLine content.
    -- Re-add our loot section on every internal refresh so it persists.
    GameTooltip:HookScript("OnTooltipSetUnit", function(tooltip)
        if BLT.lastNPCID and not BLT:ShouldSuppress() then
            BLT:AddLootToTooltip(tooltip, BLT.lastNPCID)
        end
    end)
end

-- ============================================================================
-- SLASH COMMAND INTEGRATION
-- ============================================================================

function BLT:GetStats()
    if not self.indexBuilt then
        return "Index not built yet (builds on first NPC hover)"
    end
    local s = self.stats
    return string.format(
        "%d NPCs, %d total items\n" ..
        "  |cff00ff00Direct NPC:|r %d items\n" ..
        "  |cff00ccffEncounter resolved:|r %d items (from %d encounter-NPC mappings)\n" ..
        "  |cffff8000Instance shared:|r %d items (tier tokens, shared drops)\n" ..
        "  |cffff00ffWorld boss:|r %d items (SourceDB world boss recovery)\n" ..
        "  |cffffff00Quest NPC:|r %d items (quest givers with transmog rewards)\n" ..
        "  |cff00ffffRare mob:|r %d items (open-world rare spawns)\n" ..
        "  |cffffcc00SourceDB loot:|r %d items (general loot supplement)\n" ..
        "  Pending: %d encounters unresolved",
        s.totalNPCs, s.totalItems,
        s.directNPC,
        s.encounterResolved, s.encToNPCCount,
        s.instSharedLoot or 0,
        s.worldBoss or 0,
        s.questNPC or 0,
        s.rareMob or 0,
        s.sdbLootAdded or 0,
        s.pendingEncounter - s.encounterResolved)
end

function BLT:Toggle()
    self.enabled = not self.enabled
    local status = self.enabled and "|cff00ff00enabled|r" or "|cffff0000disabled|r"
    ETB:Debug("BossLoot", "Boss loot tooltips " .. status)
end

-- ============================================================================
-- AUTO-INIT: Enable on login with 5s delay (after all data loads)
-- ============================================================================

local initFrame = CreateFrame("Frame")
initFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
initFrame:SetScript("OnEvent", function(self, event)
    C_Timer.After(5, function()
        BLT:Initialize()
    end)
    self:UnregisterEvent("PLAYER_ENTERING_WORLD")
end)

BLT.enabled = true

-- Build reverse index: encounterID → {itemIDs} for fast JLoot lookup
-- Called once during BuildIndex, avoids O(n) scan on every hover
function BLT:BuildJLootIndex()
    if self._jlootByEnc then return end
    if ETB._BossLootLoader then ETB._BossLootLoader(); ETB._BossLootLoader = nil end
    if not ETB.JLoot then return end
    
    self._jlootByEnc = {}  -- encounterID → { [itemID] = true }
    
    for itemID, lootData in pairs(ETB.JLoot) do
        local entries = type(lootData[1]) == "table" and lootData or { lootData }
        for _, entry in ipairs(entries) do
            local encID = entry[1]
            if not self._jlootByEnc[encID] then
                self._jlootByEnc[encID] = {}
            end
            self._jlootByEnc[encID][itemID] = true
        end
    end
    
    local count = 0
    for _ in pairs(self._jlootByEnc) do count = count + 1 end
    ETB:Debug("BossLoot", string.format("JLoot reverse index: %d encounters", count))
end
