--[[
============================================================================
Modules/BossLootTooltip/Index.lua  (added in v15.1.0 — Audit Batch 4 Part 1)
============================================================================

NPC-loot index builder for the BossLootTooltip orchestrator. Owner-pattern:
each method takes `owner` (the BLT orchestrator) as first argument and
accesses BLT state via owner.npcIndex, owner.stats, owner.indexBuilt, etc.

Methods owned here (3):
  BuildIndex          — primary index builder (~550 lines). Reads compact
                        data from Mapper, JLoot, RareMobLoot; resolves
                        encounter->NPC and instance->NPCs lookups; populates
                        owner.npcIndex[npcID] = { [itemID] = flags }
  GetNPCIDFromGUID    — extract numeric NPC ID from a unit GUID string
  BuildJLootIndex     — secondary index for JLoot (encounterID-keyed,
                        used as fallback when compact data has no match)

Index-local constants (NOT shared with other submodules):
  ST_BOSS, ST_QUEST, ST_RARE — source-type indices from compact data

External dependencies pulled from owner (the BLT orchestrator):
  owner.npcIndex, owner.indexBuilt, owner.building, owner.stats,
  owner._jlootByEnc, owner.FLAG_*, owner.VALID_ITEM_CLASSES,
  owner.EXCLUDED_EQUIP_LOCS, owner.WORLD_BOSS_ENC_TO_NPC,
  owner.WORLD_BOSS_NPC_SET, owner.AddToNPCIndex

Closure / shadowing notes: No function() closures, all synchronous.
Verified clean by bare-upvalue audit in test_v15_1_extraction.lua.
============================================================================
]]--
local ADDON_NAME, NS = ...
local ETB = NS.ETB
NS.ETB.BossLootTooltip = NS.ETB.BossLootTooltip or {}
NS.ETB.BossLootTooltip.Index = NS.ETB.BossLootTooltip.Index or {}
local Index = NS.ETB.BossLootTooltip.Index

-- =============================================================================
-- FILE-LOCAL CONSTANTS
-- =============================================================================
-- 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

-- =============================================================================
-- COOPERATIVE CHUNKING
-- =============================================================================
-- BuildIndex is one heavy synchronous pass over ~54k items (~400ms+ on a typical
-- machine) — a freeze felt on reload, the first boss-corpse hover, and zone-in.
-- Rather than refactor the build, we run the SAME code inside a coroutine and let
-- it yield every few ms; the work pump resumes it across frames. Because it's the
-- identical code path over identical static data, the resulting index is
-- byte-for-byte the same as the synchronous build — only the timing differs.
--
-- yieldCheck() is the single integration point: a no-op on the main thread (so a
-- direct/synchronous BuildIndex still runs straight through), and a time-sliced
-- yield when running inside the chunked coroutine. Placed in the two big loops
-- (PASS 1 over all items, and the JLoot pass), which dominate the cost.
local SLICE_MS = 6                       -- target build work per frame
local sliceDeadline = 0
local profclock = _G.debugprofilestop or function() return 0 end
local function yieldCheck()
    if not coroutine.running() then return end   -- main thread → never yields
    if profclock() >= sliceDeadline then coroutine.yield() end
end

-- Chunked entry point. Drives a coroutine that runs BuildIndex, resuming it each
-- frame via the work pump with a fresh time slice, until it completes. Falls back
-- to a straight synchronous build if coroutines/the work pump aren't available, or
-- if the coroutine errors (a one-time hitch beats a broken index). onComplete (if
-- given) fires once the index is ready.
-- v15.201.0: attach the precomputed loot index (LoadOnDemand sibling
-- EnhancedTransmogBrowser_Loot) instead of reconstructing it from the 6.8 MB
-- compact dataset + JLoot at runtime. Returns true if attached. The module is
-- byte-exact to the runtime BuildIndex output (it is *generated* from it), so
-- this is a drop-in: owner.npcIndex/instanceLoot/npcToInstances/_jlootByEnc are
-- the same tables the runtime build would have produced. Render-time player
-- filtering (CanPlayerUseItem etc.) is unchanged — the item-class/equip filter
-- is already baked into the precomputed index.
function Index:TryAttachPrecomputed(owner)
    if owner._noPrecompute then return false end          -- /etb dev override → force runtime build
    local LI = _G.__ETB_LootIndex
    if not LI then
        local fn = (C_AddOns and C_AddOns.LoadAddOn) or LoadAddOn
        if not fn then return false end
        local t0 = (ETB.Perf and _G.debugprofilestop) and _G.debugprofilestop() or nil
        pcall(fn, "EnhancedTransmogBrowser_Loot")
        if t0 then ETB.Perf:Record("loot.loadAddon", _G.debugprofilestop() - t0) end
        LI = _G.__ETB_LootIndex
    end
    if not LI or not LI.npcIndex then return false end
    -- Data-version gate: stale precompute (compact/JLoot changed) → runtime build.
    -- Gated on a DATA version, not the addon version, so code-only bumps keep it valid.
    if ETB.LOOT_DATA_VERSION and LI.dataVersion ~= ETB.LOOT_DATA_VERSION then
        ETB:Debug("BossLoot", string.format(
            "precomputed loot index dataVersion %s != %s; falling back to runtime build",
            tostring(LI.dataVersion), tostring(ETB.LOOT_DATA_VERSION)))
        return false
    end
    owner.npcIndex       = LI.npcIndex
    owner.instanceLoot   = LI.instanceLoot
    owner.npcToInstances = LI.npcToInstances
    owner._jlootByEnc    = LI.jlootByEnc
    if LI.stats then owner.stats = LI.stats end
    owner.indexBuilt = true
    owner.building   = false
    if ETB.LootService then ETB.LootService:RegisterIndex(LI) end  -- share one copy with ZoneScan/ERB
    ETB:Debug("BossLoot", string.format(
        "Attached precomputed loot index: %d NPCs, %d items (no runtime build)",
        (LI.stats and LI.stats.totalNPCs) or 0, (LI.stats and LI.stats.totalItems) or 0))
    return true
end

function Index:BuildIndexChunked(owner, onComplete)
    if owner.indexBuilt then if onComplete then onComplete() end return end
    if owner.building or owner._chunkRunning then return end
    -- Fast path: attach the precomputed index (no compact load, no scan).
    if self:TryAttachPrecomputed(owner) then
        if onComplete then onComplete(true) end
        return
    end
    if not (ETB.QueueWork and coroutine and coroutine.create and _G.debugprofilestop) then
        owner._chunkError = "work pump/coroutine unavailable"
        ETB:Debug("BossLoot", "chunked build unavailable; skipping synchronous fallback")
        if onComplete then onComplete(false, owner._chunkError) end
        return
    end
    owner._chunkRunning = true
    local co = coroutine.create(function() Index:BuildIndex(owner) end)
    local function pump()
        sliceDeadline = profclock() + SLICE_MS
        ETB._chunkDeadline = sliceDeadline   -- so EnsureLoaded()'s shared YieldSlice yields on the same budget
        local t0 = profclock()
        local kb0 = collectgarbage and collectgarbage("count") or 0
        local ok, err = coroutine.resume(co)
        -- Per-phase timing: attribute this slice's cost to whatever phase BuildIndex
        -- is currently in (owner._phase). A heap DROP across the slice means a GC ran
        -- inside it, so the time is GC, not the phase's own work — tag it so the next
        -- /etb perf distinguishes "this pass is slow" from "this pass triggered a GC".
        if ETB.Perf then
            local dt = profclock() - t0
            local kb1 = collectgarbage and collectgarbage("count") or 0
            local phase = owner._phase or "?"
            if kb1 < kb0 - 256 then phase = phase .. "+gc" end
            ETB.Perf:Record("bl." .. phase, dt)
        end
        if not ok then
            owner._chunkRunning = false
            owner.building = false
            owner._lazyBuildQueued = false
            owner._chunkError = tostring(err)
            ETB:Debug("BossLoot", "chunked build error; synchronous fallback suppressed: " .. tostring(err))
            if onComplete then onComplete(false, err) end
            return
        end
        if coroutine.status(co) == "dead" then
            owner._chunkRunning = false
            owner._chunkError = nil
            if onComplete then onComplete(true) end       -- BuildIndex set indexBuilt + building=false
        else
            ETB:QueueWork("bossLoot.chunkedBuild", pump, { pauseInCombat = true })
        end
    end
    ETB:QueueWork("bossLoot.chunkedBuild", pump, { pauseInCombat = true })
end

-- =============================================================================
-- METHODS (extracted from Modules/BossLootTooltip.lua in v15.1.0)
-- =============================================================================

function Index:BuildIndex(owner)
    if owner.indexBuilt or owner.building then return end
    owner.building = true
    
    local SDA = ETB.SourceData_ATT
    if not SDA then
        ETB:Debug("BossLoot", "SourceData_ATT not available")
        owner.building = false
        return
    end
    
    -- Ensure MapperBridge name table is loaded (needed for NPC names)
    owner._phase = "ensureLoaded"
    if SDA.EnsureLoaded then
        SDA:EnsureLoaded()
    end
    
    local compact = NS.Compact
    if not compact then
        ETB:Debug("BossLoot", "Compact data not available")
        owner.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" }
    local useSourceDBSupplement = ETB.Settings
        and ETB.Settings:Get("performance.bossLootSourceDBSupplement") == true
        and not (ETB.IsLowMemoryMode and ETB:IsLowMemoryMode())
    
    -- ========================================================================
    -- PRE-SEED: World boss encounter→NPC mappings
    -- ========================================================================
    
    for encID, npcID in pairs(owner.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
    
    -- ========================================================================
    owner._phase = "pass1"
    -- 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, rawV in pairs(data.I) do
                yieldCheck()
                local raw = NS.Compact.Head(rawV)
                if raw then
                    -- Synchronous class check (no async)
                    local _, _, _, equipLoc, _, classID = GetItemInfoInstant(itemID)

                    -- v15.163.0/.164.0: known world-boss loot must never be dropped
                    -- just because GetItemInfoInstant hasn't cached the item yet.
                    -- MoP world bosses are coded two ways: ST_BOSS (Sha, Galleon,
                    -- Nalak, Ordos) and ST_QUEST (Oondasta + the 4 Celestials —
                    -- their loot comes via the bonus-roll/quest system). Both are
                    -- the high item-ID range most likely to miss the cache at build
                    -- time, which would leave the tooltip blank. Render-time
                    -- CanPlayerUseItem + EXCLUDED_EQUIP_LOCS still filter these.
                    local isKnownWorldBoss = (raw[2] and owner.WORLD_BOSS_NPC_SET[raw[2]]
                        and (raw[3] == ST_BOSS or raw[3] == ST_QUEST))
                    local classOK = classID and owner.VALID_ITEM_CLASSES[classID]
                        and not (equipLoc and owner.EXCLUDED_EQUIP_LOCS[equipLoc])

                    -- Skip non-armor/weapon and non-visible slots (trinkets, rings, necks)
                    if classOK or isKnownWorldBoss 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 owner.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 (owner.FLAG_DIRECT + owner.FLAG_TOKEN) or owner.FLAG_DIRECT
                            owner.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
                                            owner.AddToNPCIndex(npcIndex, srcNPC, itemID, owner.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
    
    -- ========================================================================
    owner._phase = "pass1.5"
    -- 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 useSourceDBSupplement and ETB.SourceDB and not ETB.SourceDB._loaded then
        ETB.SourceDB:LoadAll()
        ETB:Debug("BossLoot", "SourceDB loaded on demand")
    end
    
    if useSourceDBSupplement and ETB.SourceDB and ETB.SourceDB.GetAllWorldBossItems then
        local wbItems = ETB.SourceDB:GetAllWorldBossItems()
        if wbItems then
            for _, wbEntry in ipairs(wbItems) do
                yieldCheck()
                local itemID = wbEntry.itemID
                local encID = wbEntry.encounterID
                if itemID and encID then
                    local _, _, _, equipLoc, _, classID = GetItemInfoInstant(itemID)
                    if classID and owner.VALID_ITEM_CLASSES[classID] and not (equipLoc and owner.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 owner.AddToNPCIndex(npcIndex, npc, itemID, owner.FLAG_DIRECT + owner.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.)
    
    -- ========================================================================
    owner._phase = "pass1.6"
    -- 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 useSourceDBSupplement and ETB.SourceDB and ETB.SourceDB.GetAllLootItems then
        local lootItems = ETB.SourceDB:GetAllLootItems()
        if lootItems then
            -- v15.42.0: flatten the existing NPC index into an O(1) set once,
            -- instead of re-scanning every NPC bucket for every loot item (was
            -- O(lootItems x NPCs) — a real login hitch on large datasets).
            local indexedItemIDs = {}
            for _, items in pairs(npcIndex) do
                for id in pairs(items) do indexedItemIDs[id] = true end
            end
            yieldCheck()
            for _, lootEntry in ipairs(lootItems) do
                yieldCheck()
                local itemID = lootEntry.itemID
                local encID = lootEntry.encounterID
                if itemID and encID then
                    if not indexedItemIDs[itemID] then
                        indexedItemIDs[itemID] = true
                        local _, _, _, equipLoc, _, classID = GetItemInfoInstant(itemID)
                        if classID and owner.VALID_ITEM_CLASSES[classID] and not (equipLoc and owner.EXCLUDED_EQUIP_LOCS[equipLoc]) then
                            -- Try direct resolution via encToNPCs
                            local npcs = encToNPCs[encID]
                            if npcs then
                                for npc in pairs(npcs) do
                                    if owner.AddToNPCIndex(npcIndex, npc, itemID, owner.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
    
    -- ========================================================================
    owner._phase = "pass2"
    -- 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
        yieldCheck()
        local npcs = encToNPCs[pending.eID]
        
        if npcs then
            -- Resolve to ALL NPCs for this encounter
            local flag = pending.hasToken and (owner.FLAG_ENCOUNTER + owner.FLAG_TOKEN) or owner.FLAG_ENCOUNTER
            for npc in pairs(npcs) do
                if owner.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
    
    -- ========================================================================
    owner._phase = "pass3"
    -- 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
        yieldCheck()
        local iID = pending.iID
        if iID then
            -- Verify it's a transmog item
            local _, _, _, equipLoc, _, classID = GetItemInfoInstant(pending.itemID)
            if classID and owner.VALID_ITEM_CLASSES[classID] and not (equipLoc and owner.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
    
    -- ========================================================================
    owner._phase = "pass4"
    -- 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, rawV in pairs(data.I) do
                yieldCheck()
                local raw = NS.Compact.Head(rawV)
                if raw then
                    local npcID = raw[2]
                    local st = raw[3]
                    if st == ST_QUEST and npcID and npcID ~= 0 and not owner.WORLD_BOSS_NPC_SET[npcID] then
                        local _, _, _, equipLoc, _, classID = GetItemInfoInstant(itemID)
                        if classID and owner.VALID_ITEM_CLASSES[classID] and not (equipLoc and owner.EXCLUDED_EQUIP_LOCS[equipLoc]) then
                            if owner.AddToNPCIndex(npcIndex, npcID, itemID, owner.FLAG_DIRECT + owner.FLAG_QUEST) then
                                stats.questNPC = stats.questNPC + 1
                            end
                        end
                    end
                end
            end
        end
    end
    
    -- ========================================================================
    owner._phase = "pass5"
    -- 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
            yieldCheck()
            for _, itemID in ipairs(itemList) do
                local _, _, _, equipLoc, _, classID = GetItemInfoInstant(itemID)
                if classID and owner.VALID_ITEM_CLASSES[classID] and not (equipLoc and owner.EXCLUDED_EQUIP_LOCS[equipLoc]) then
                    if owner.AddToNPCIndex(npcIndex, npcID, itemID, owner.FLAG_DIRECT + owner.FLAG_RARE) then
                        stats.rareMob = stats.rareMob + 1
                    end
                end
            end
        end
    end
    
    -- ========================================================================
    owner._phase = "jloot"
    -- 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
            yieldCheck()
            -- 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 owner.VALID_ITEM_CLASSES[jClassID] then
                    -- skip non-armor/weapon
                elseif jEquipLoc and owner.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 owner.AddToNPCIndex(npcIndex, npc, itemID, owner.FLAG_ENCOUNTER) then
                                    jlootAdded = jlootAdded + 1
                                end
                            end
                        elseif ETB.JEncToNpcs then
                            local npc = ETB.JEncToNpcs[encID]
                            if npc and npc > 0 then
                                if owner.AddToNPCIndex(npcIndex, npc, itemID, owner.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
    
    -- ========================================================================
    owner._phase = "tier"
    -- 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
            yieldCheck()
            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 owner.AddToNPCIndex(npcIndex, npcID, tokenID, owner.FLAG_DIRECT + owner.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
    
    owner.npcIndex = npcIndex
    owner.instanceLoot = instanceLoot        -- instanceID → { [itemID] = true }
    owner.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
    
    owner.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 owner.stats.encToNPCCount = owner.stats.encToNPCCount + 1 end
    for _ in pairs(instanceNPCs) do owner.stats.instanceNPCCount = owner.stats.instanceNPCCount + 1 end
    
    owner._phase = "reverseIdx"
    -- Build JLoot reverse index for fast hover lookups
    owner: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
            uncached[#uncached + 1] = itemID
        end
    end
    if false and #uncached > 0
        and not (ETB.Settings and ETB.Settings:Get("performance.lowMemory"))
    then
        -- v15.42.0: gentler warming — 15 items / 0.3s (was 50 / 0.1s, ~500/s)
        -- and never during combat, so cache warming can't fan a
        -- GET_ITEM_INFO_RECEIVED storm out into a combat hitch.
        local BATCH_SIZE = 15
        local batchIndex = 0
        local function warmBatch()
            if InCombatLockdown()
                or (ETB.IsStartupIdle and not ETB:IsStartupIdle({ ignoreQuietWindow = true }))
            then
                C_Timer.After(1, warmBatch)   -- hold off until out of combat
                return
            end
            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
                if ETB.RequestItemInfoThrottled then
                    ETB:RequestItemInfoThrottled(uncached[i])
                else
                    GetItemInfo(uncached[i])
                end
            end
            batchIndex = batchIndex + 1
            if endIdx < #uncached then
                C_Timer.After(0.3, warmBatch)
            end
        end
        warmBatch()
        ETB:Debug("BossLoot", string.format("Pre-warming cache: %d items in %d batches",
            #uncached, math.ceil(#uncached / BATCH_SIZE)))
    end
    
    owner.indexBuilt = true
    owner.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


function Index:GetNPCIDFromGUID(owner, 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


function Index:BuildJLootIndex(owner)
    if owner._jlootByEnc then return end
    if ETB._BossLootLoader then ETB._BossLootLoader(); ETB._BossLootLoader = nil end
    if not ETB.JLoot then return end
    
    owner._jlootByEnc = {}  -- encounterID → { [itemID] = true }
    
    for itemID, lootData in pairs(ETB.JLoot) do
        yieldCheck()
        local entries = type(lootData[1]) == "table" and lootData or { lootData }
        for _, entry in ipairs(entries) do
            local encID = entry[1]
            if not owner._jlootByEnc[encID] then
                owner._jlootByEnc[encID] = {}
            end
            owner._jlootByEnc[encID][itemID] = true
        end
    end
    
    local count = 0
    for _ in pairs(owner._jlootByEnc) do count = count + 1 end
    ETB:Debug("BossLoot", string.format("JLoot reverse index: %d encounters", count))
end
