--[[
============================================================================
CollectionPlanner.lua  (added in v15.9.0)
============================================================================

The data/query layer behind the Collection Planner — answers, for any raid
or dungeon: "what appearances drop here, and which am I missing?"

This module does NOT build UI. It builds and caches the data model the
planner UI (PlannerUI.lua) renders.

----------------------------------------------------------------------------
DATA SOURCES (all already in the addon)
----------------------------------------------------------------------------
  ETB.JInstances  : [instID]  = { name, level, mountPolicy }
  ETB.JEncounters : [encID]   = { name, instanceID, orderInInstance }
  ETB.JLoot       : [itemID]  = { encID, diffBitmask }  -- or list for multi
  ETB.CollectionAPI:GetState(itemID) -> "COLLECTED" | "NOT_COLLECTED" | nil
  C_TransmogCollection.GetItemInfo(itemID) -> appearanceID (visualID)

----------------------------------------------------------------------------
THE MODEL
----------------------------------------------------------------------------
On first access (lazy — NOT at login) we build a reverse index:

  _encToItems : [encID] = { itemID, itemID, ... }   -- items dropping here

From that, per-encounter and per-instance completion is computed ON DEMAND
when the planner UI asks for it (GetInstanceProgress / GetEncounterItems),
because collection state changes as the player plays and must stay live.

We DON'T cache collection state long-term — only the static encToItems
index is cached. State is queried fresh (CollectionAPI has its own cache).

----------------------------------------------------------------------------
APPEARANCE DEDUP
----------------------------------------------------------------------------
Many itemIDs share one appearance (Normal vs Heroic versions of the same
look). The planner counts APPEARANCES, not itemIDs — collecting the Normal
version means you have the look, so the Heroic itemID shouldn't count as a
separate "missing" line. We dedupe by visualID within each encounter.

----------------------------------------------------------------------------
WHY LAZY
----------------------------------------------------------------------------
Building the reverse index touches all ~11k JLoot entries. That's cheap
(<50ms) but pointless at login — most sessions never open the planner.
First call to any public method triggers the one-time build.
============================================================================
]]

local ADDON_NAME, NS = ...
NS.ETB = NS.ETB or {}
local ETB = NS.ETB

NS.ETB.CollectionPlanner = NS.ETB.CollectionPlanner or {}
local CP = NS.ETB.CollectionPlanner

CP._indexBuilt = false
CP._encToItems = {}      -- encID -> { itemID, ... }
CP._instToEncs = {}      -- instID -> { encID, ... } (ordered)

-- ============================================================================
-- DIFFICULTY VARIANTS
-- An instance whose loot spans more than one difficulty is shown as several
-- variant rows in the planner tree — one per difficulty. Each variant filters
-- the instance's loot to items whose difficulty bitmask includes its bit.
-- ============================================================================

-- Raid 10/25 size bits. Their presence marks a Wrath-era raid, delineated
-- 10N / 10H / 25N / 25H rather than Normal / Heroic.
local RAID_SIZE_BITS = 16 + 32 + 64 + 128   -- 240

-- Difficulty bits valid for each content type. Raid difficulties (size codes,
-- Flexible) and dungeon difficulties (Titan Rune, Elemental Rune, Celestial)
-- are disjoint axes — an item shared between a raid and a dungeon must not
-- leak the other type's difficulty onto its instance.
local RAID_DIFF_BITS    = 1 + 2 + 16 + 32 + 64 + 128 + 256              -- 499
local DUNGEON_DIFF_BITS = 1 + 2 + 512 + 1024 + 2048 + 4096 + 8192       -- 15875

-- Bits that belong to ONE content type only. Used to detect and reject gear
-- that has leaked across the raid/dungeon divide. Titan Rune (512/1024),
-- Elemental Rune (2048/4096) and Celestial (8192) are dungeon-exclusive;
-- raid size codes (16/32/64/128) and Flexible (256) are raid-exclusive.
-- (Bits 1 Normal / 2 Heroic are shared and intentionally excluded here.)
local DUNGEON_ONLY_BITS = 512 + 1024 + 2048 + 4096 + 8192               -- 15872
local RAID_ONLY_BITS    = 16 + 32 + 64 + 128 + 256                      -- 496

-- Ordered variant catalog. Iterated in this order; only bits actually present
-- in an instance produce a row. `ordinal` builds a stable virtual key.
local DIFF_VARIANTS = {
    { ordinal = 1,  bitval = 16,   suffix = " (10-Man Normal)" },
    { ordinal = 2,  bitval = 32,   suffix = " (10-Man Heroic)" },
    { ordinal = 3,  bitval = 64,   suffix = " (25-Man Normal)" },
    { ordinal = 4,  bitval = 128,  suffix = " (25-Man Heroic)" },
    { ordinal = 5,  bitval = 1,    suffix = " (Normal)" },
    { ordinal = 6,  bitval = 256,  suffix = " (Flexible)" },
    { ordinal = 7,  bitval = 2,    suffix = " (Heroic)" },
    { ordinal = 8,  bitval = 512,  suffix = " (Titan Rune Alpha)" },
    { ordinal = 9,  bitval = 1024, suffix = " (Titan Rune Beta)" },
    { ordinal = 10, bitval = 2048, suffix = " (Elemental Rune Inferno)" },
    { ordinal = 11, bitval = 4096, suffix = " (Elemental Rune Twilight)" },
    { ordinal = 12, bitval = 8192, suffix = " (Celestial)" },
}

-- Given an instance's aggregate difficulty mask, return the ordered variants
-- that apply. Wrath-style raids (size bits present) use only the 10/25
-- variants; everything else uses Normal / Heroic + the special tiers.
local function _variantsForMask(mask)
    if not mask or mask == 0 then return {} end
    local wrathStyle = (bit.band(mask, RAID_SIZE_BITS) ~= 0)
    local out = {}
    for _, v in ipairs(DIFF_VARIANTS) do
        local isSizeBit = (v.bitval >= 16 and v.bitval <= 128)
        if wrathStyle == isSizeBit and bit.band(mask, v.bitval) ~= 0 then
            out[#out + 1] = v
        end
    end
    return out
end

-- Short display tags per difficulty bit, for consumers that want compact
-- labels (e.g. ZoneScan rows) rather than the parenthetical suffixes used
-- in the planner tree. Keyed by the same bit values as DIFF_VARIANTS.
local _DIFF_SHORT = {
    [16]   = "10N", [32]  = "10H", [64]   = "25N", [128] = "25H",
    [1]    = "Normal",            [256] = "Flex",  [2]   = "Heroic",
    [512]  = "Titan Rune A",      [1024] = "Titan Rune B",
    [2048] = "Elem Rune Inferno", [4096] = "Elem Rune Twilight",
    [8192] = "Celestial",
}

-- ============================================================================
-- DIFFICULTY ACCESSORS (read-only; for other modules, e.g. ZoneScan)
-- These surface the already-built per-encounter / per-item difficulty masks
-- and the variant catalog so nothing else has to re-derive difficulty rules
-- or duplicate the raid/dungeon cross-content guard. Each ensures the index.
-- ============================================================================

-- Content-guarded difficulty mask for one item AT one encounter. This is the
-- value to prefer when an encounter (boss) context is known, because it has
-- already had bits that can't belong to the encounter's instance type stripped.
function CP:GetEncounterItemDifficulty(encID, itemID)
    self:_ensureIndex()
    local diffs = self._encItemDiff and self._encItemDiff[encID]
    return (diffs and diffs[itemID]) or 0
end

-- Aggregate difficulty mask for an item across every encounter that drops it.
function CP:GetItemDifficultyMask(itemID)
    self:_ensureIndex()
    return (self._itemDiffMask and self._itemDiffMask[itemID]) or 0
end

-- Ordered array of short difficulty tags for a mask, e.g. {"10N","10H"} or
-- {"Normal","Heroic"}. Wrath-style raids (size bits present) collapse to the
-- 10/25 set; everything else uses Normal/Heroic + the special tiers. A mask
-- of 0 (no difficulty info) returns an empty list.
function CP:GetDifficultyLabels(mask)
    local out = {}
    for _, v in ipairs(_variantsForMask(mask)) do
        out[#out + 1] = _DIFF_SHORT[v.bitval]
            or (v.suffix:gsub("^%s*%((.-)%)%s*$", "%1"))
    end
    return out
end

-- Ordered difficulty bit values applicable to a mask (parallel to
-- GetDifficultyLabels). Lets a consumer cycle a difficulty filter through
-- exactly the variants present, without re-deriving the wrath/size collapse.
function CP:GetDifficultyBits(mask)
    local out = {}
    for _, v in ipairs(_variantsForMask(mask)) do out[#out + 1] = v.bitval end
    return out
end

-- ============================================================================
-- INDEX BUILD (lazy, one-time)
-- ============================================================================

function CP:_ensureIndex()
    if self._indexBuilt then return end

    -- Materialize JLoot / JEncounters only outside performance mode. This index
    -- is synchronous, so in the default low-memory profile the planner waits for
    -- an explicit full-feature opt-in instead of freezing on first open.
    if ETB._BossLootLoader then
        local inCombat = (InCombatLockdown and InCombatLockdown())
            or (UnitAffectingCombat and UnitAffectingCombat("player"))
        if inCombat or (ETB.IsLowMemoryMode and ETB:IsLowMemoryMode()) then return end
        if ETB.LootService then ETB.LootService:EnsureRawLoot()
        else ETB._BossLootLoader(); ETB._BossLootLoader = nil end
    end

    local JLoot = ETB.JLoot
    local JEncounters = ETB.JEncounters
    if not JLoot or not JEncounters then
        -- Data not available — leave index empty, will retry next call
        return
    end

    local encToItems   = {}  -- encID  -> { itemID, ... }  (each itemID once)
    local encItemDiff  = {}  -- encID  -> { [itemID] = difficulty bitmask }
    local itemDiffMask = {}  -- itemID -> difficulty bitmask (aggregated)

    -- DIFFICULTY BITMASK — sourced from ETB.JLootDiff, the clean per-item
    -- difficulty tags re-extracted from AtlasLoot Classic. Bits:
    --   1 Normal      2 Heroic       256 Flexible
    --   16/32/64/128  raid 10N / 10H / 25N / 25H
    --   512 / 1024    Titan Rune Alpha / Beta            (Wrath dungeons)
    --   2048 / 4096   Elemental Rune Inferno / Twilight  (Cata dungeons)
    --   8192          Celestial                          (MoP dungeons)
    -- Items absent from JLootDiff fall back to the raw JLoot diff value,
    -- which still behaves as a bitmask for the legacy 1/2/4/8/15 codes.
    local JLootDiff   = ETB.JLootDiff
    local JInstances0 = ETB.JInstances
    local bor = bit.bor

    local function _record(encID, itemID, mask)
        local bucket = encToItems[encID]
        if not bucket then bucket = {}; encToItems[encID] = bucket end
        local diffs = encItemDiff[encID]
        if not diffs then diffs = {}; encItemDiff[encID] = diffs end
        if diffs[itemID] == nil then
            -- first time this itemID is seen for this encounter
            bucket[#bucket + 1] = itemID
            diffs[itemID] = 0
        end
        diffs[itemID]        = bor(diffs[itemID], mask)
        itemDiffMask[itemID] = bor(itemDiffMask[itemID] or 0, mask)
    end

    -- Pure Wrath raid-size codes (16/32/64/128 only). The orphan Wrath raid
    -- loot is cleanly coded this way and is more granular than AtlasLoot,
    -- which lumps some Wrath raids (Naxx, Obsidian Sanctum) under Normal.
    local function _pureSize(raw)
        if raw and raw ~= 0 and bit.band(raw, RAID_SIZE_BITS) == raw then
            return raw
        end
        return 0
    end

    -- Resolve one item's difficulty mask for a specific encounter:
    --   * RAID + a clean raw size code -> use the raw size code (10/25/H).
    --   * otherwise -> the AtlasLoot tag from JLootDiff.
    --   * neither -> 0 (difficulty-agnostic; treated as Normal at filter
    --     time). The legacy cata-style raw codes (1/2/3/7/8/11/15) are
    --     never trusted — that inconsistency is why we re-extracted.
    local function _recordItem(encID, itemID, rawDiff, cleanMask)
        local encInfo  = JEncounters[encID]
        local instInfo = encInfo and JInstances0 and JInstances0[encInfo[2]]
        local isRaid   = instInfo and instInfo[3] == 1

        -- Cross-content guard. An item whose difficulty is EXCLUSIVELY a
        -- dungeon difficulty (Titan Rune / Elemental Rune / Celestial) is
        -- dungeon or vendor gear that merely shares a raid's loot/appearance.
        -- It must not be listed under a raid at all — not even via the Shared
        -- Boss Loot bucket. The reverse (raid-only gear under a dungeon) is
        -- rejected the same way. Items carrying BOTH (genuine dual-source) or
        -- only the shared Normal/Heroic bits are kept.
        if cleanMask and cleanMask ~= 0 then
            if isRaid and bit.band(cleanMask, DUNGEON_ONLY_BITS) ~= 0
                      and bit.band(cleanMask, RAID_ONLY_BITS) == 0 then
                return
            end
            if (not isRaid) and bit.band(cleanMask, RAID_ONLY_BITS) ~= 0
                            and bit.band(cleanMask, DUNGEON_ONLY_BITS) == 0 then
                return
            end
        end

        local rawSize  = _pureSize(rawDiff)
        local mask
        if isRaid and rawSize ~= 0 then
            mask = rawSize
        elseif cleanMask then
            mask = cleanMask
        else
            mask = 0
        end
        -- Content-type guard — strip bits that cannot belong to this
        -- instance type, so a shared itemID can't leak (e.g.) a Celestial
        -- dungeon tag onto a raid like Throne of Thunder.
        mask = bit.band(mask, isRaid and RAID_DIFF_BITS or DUNGEON_DIFF_BITS)
        _record(encID, itemID, mask)
    end

    -- Walk JLoot: each entry maps an itemID to one or more encounters.
    for itemID, info in pairs(JLoot) do
        if type(info) == "table" then
            local cleanMask = JLootDiff and JLootDiff[itemID]
            if type(info[1]) == "number" then
                _recordItem(info[1], itemID, info[2], cleanMask)
            else
                for _, entry in ipairs(info) do
                    if entry[1] then
                        _recordItem(entry[1], itemID, entry[2], cleanMask)
                    end
                end
            end
        end
    end

    -- Build instance -> ordered encounter list, and flag pseudo-encounters
    -- (Trash Drops / Shared Boss Loot — loot buckets, not real bosses).
    local instToEncs = {}
    local pseudoEnc  = {}
    for encID, encInfo in pairs(JEncounters) do
        local instID = encInfo[2]
        if instID then
            instToEncs[instID] = instToEncs[instID] or {}
            table.insert(instToEncs[instID], encID)
        end
        local nm = encInfo[1]
        if nm == "Trash Drops" or nm == "Shared Boss Loot" then
            pseudoEnc[encID] = true
        end
    end
    -- Sort each instance's encounters by orderInInstance
    for instID, encs in pairs(instToEncs) do
        table.sort(encs, function(a, b)
            local oa = (JEncounters[a] and JEncounters[a][3]) or 0
            local ob = (JEncounters[b] and JEncounters[b][3]) or 0
            return oa < ob
        end)
    end

    -- Per-instance difficulty coverage — the bitwise OR of every item's
    -- difficulty mask in the instance. GetInstanceList reads this to decide
    -- which difficulty variant rows to emit. Pseudo-encounters (trash /
    -- shared loot) are excluded — they are difficulty-agnostic and must not
    -- influence how the instance is split.
    local instDiffMask = {}
    for instID, encs in pairs(instToEncs) do
        local m = 0
        for _, encID in ipairs(encs) do
            local diffs = encItemDiff[encID]
            if diffs and not pseudoEnc[encID] then
                for _, dm in pairs(diffs) do m = bor(m, dm) end
            end
        end
        instDiffMask[instID] = m
    end

    self._encToItems    = encToItems
    self._encItemDiff   = encItemDiff
    self._itemDiffMask  = itemDiffMask
    self._instToEncs    = instToEncs
    self._instDiffMask  = instDiffMask
    self._pseudoEnc     = pseudoEnc
    self._indexBuilt = true

    -- ETB:Debug already self-gates on the ETB.debug flag, so just call it.
    local nEnc, nInst = 0, 0
    for _ in pairs(encToItems) do nEnc = nEnc + 1 end
    for _ in pairs(instToEncs) do nInst = nInst + 1 end
    ETB:Debug("CollectionPlanner", string.format(
        "Index built: %d encounters with loot across %d instances", nEnc, nInst))
end

-- ============================================================================
-- APPEARANCE RESOLUTION
-- ============================================================================
-- Resolves an itemID to its visualID (appearance). Used to dedup items that
-- share a look so the planner counts appearances, not itemIDs.

local function _itemVisualID(itemID)
    if C_TransmogCollection and C_TransmogCollection.GetItemInfo then
        local ok, appID = pcall(C_TransmogCollection.GetItemInfo, itemID)
        if ok and appID then return appID end
    end
    return nil
end

-- ============================================================================
-- CLASS RELEVANCE FILTER
-- ============================================================================
-- Decides whether an item belongs in the planner for the logged-in class.
--
-- This is STRICTER than the boss tooltip's filter. The tooltip only checks
-- explicit class restrictions (tier tokens). The planner additionally
-- filters by ARMOR TYPE: in MoP 5.4.8 a character can only transmogrify
-- their PRIMARY armor type (Hunter -> Mail only), so showing Cloth/Leather/
-- Plate to a Hunter is noise — they can never collect those appearances.
--
-- An item is kept when ALL of:
--   * it passes any explicit class restriction (ClassRestrictions), AND
--   * it is NOT armor (weapons, rings, trinkets, etc. — no armor-type
--     restriction), OR it is a CLOAK (all classes share cloak appearances),
--     OR its armor type equals the player's primary armor type.
--
-- GetItemInfoInstant in MoP 5.4.8 returns:
--   (itemID, itemType, itemSubType, equipLoc, icon, classID, subclassID)
-- classID 4 = Armor. subclassID for armor: 1=Cloth 2=Leather 3=Mail 4=Plate,
-- and subclassID 1 with equipLoc INVTYPE_CLOAK is the Back slot (cloaks are
-- stored as Cloth-subclass but are universal).

-- ARMOR / WEAPON classIDs and the armor subclass -> token map.
local _ARMOR_CLASS_ID        = 4
local _WEAPON_CLASS_ID       = 2
local _ARMOR_SHIELD_SUBCLASS = 6
local _SUBCLASS_TOKEN = { [1] = "CLOTH", [2] = "LEATHER", [3] = "MAIL", [4] = "PLATE" }

local function _set(...)
    local t = {}
    for i = 1, select("#", ...) do t[select(i, ...)] = true end
    return t
end

-- WEAPON PROFICIENCY — MoP 5.4.8. For each class (numeric classID 1-11),
-- the set of weapon subclassIDs that class can equip and thus transmogrify.
-- Weapon subclass IDs: 0 Axe1H, 1 Axe2H, 2 Bow, 3 Gun, 4 Mace1H, 5 Mace2H,
-- 6 Polearm, 7 Sword1H, 8 Sword2H, 10 Staff, 13 Fist, 15 Dagger, 16 Thrown,
-- 18 Crossbow, 19 Wand.
local _WEAPON_PROFICIENCY = {
    [1]  = _set(0,1,2,3,4,5,6,7,8,10,13,15,16,18), -- Warrior  (all but Wand)
    [2]  = _set(0,1,4,5,6,7,8),                    -- Paladin
    [3]  = _set(0,1,2,3,6,7,8,10,13,15,18),        -- Hunter   (no Maces/Wand)
    [4]  = _set(0,2,3,4,7,13,15,16,18),            -- Rogue    (1H only)
    [5]  = _set(4,10,15,19),                       -- Priest
    [6]  = _set(0,1,4,5,6,7,8),                    -- Death Knight
    [7]  = _set(0,1,4,5,10,13,15),                 -- Shaman   (no Swords)
    [8]  = _set(7,10,15,19),                       -- Mage
    [9]  = _set(7,10,15,19),                       -- Warlock
    [10] = _set(0,4,6,7,10,13),                    -- Monk     (no Daggers)
    [11] = _set(4,5,6,10,13,15),                   -- Druid    (no Swords/Axes)
}
-- Weapon subclasses the proficiency check applies to. Anything outside this
-- set (14 Generic, 20 Fishing Pole) is treated as universal.
local _WEAPON_RESTRICTABLE = _set(0,1,2,3,4,5,6,7,8,10,13,15,16,18,19)

-- Classes that can equip shields (armor subclass 6): Warrior, Paladin, Shaman.
local _SHIELD_CLASSES = _set(1, 2, 7)

-- Player's numeric classID (1-11). Cached — class is fixed for the session.
local _cachedClassID
local function _playerClassID()
    if _cachedClassID == nil then
        local _, _, cid = UnitClass("player")
        _cachedClassID = cid or false
    end
    return _cachedClassID or nil
end

-- Returns the player's primary (transmoggable) armor type token, or nil.
local function _playerPrimaryArmor()
    if ETB.BlizzardData and ETB.BlizzardData.GetPlayerPrimaryArmor then
        local ok, token = pcall(function()
            return ETB.BlizzardData:GetPlayerPrimaryArmor()
        end)
        if ok and token then return token end
    end
    return nil
end

-- True if `itemID` is relevant to the logged-in class — the class can equip
-- and transmogrify it. Filters by explicit class restriction, weapon
-- proficiency, shield usability, and armor type.
local function _itemRelevantToClass(itemID)
    -- 1. Explicit class restriction (tier tokens etc.) — same as tooltip.
    local CR = ETB.ClassRestrictions
    if CR and CR.IsItemUsableByPlayer then
        if not CR:IsItemUsableByPlayer(itemID) then
            return false
        end
    end

    -- GetItemInfoInstant is synchronous in MoP 5.4.8.
    local _, _, _, equipLoc, _, classID, subclassID = GetItemInfoInstant(itemID)
    local playerClass = _playerClassID()

    -- 2. WEAPONS — keep only weapon types this class can equip.
    if classID == _WEAPON_CLASS_ID then
        if playerClass and subclassID and _WEAPON_RESTRICTABLE[subclassID] then
            local prof = _WEAPON_PROFICIENCY[playerClass]
            if prof and not prof[subclassID] then
                return false
            end
        end
        return true
    end

    -- 3. ARMOR.
    if classID == _ARMOR_CLASS_ID then
        -- Cloaks (Back slot) are universal across all classes.
        if equipLoc == "INVTYPE_CLOAK" then
            return true
        end
        -- Shields — only Warrior / Paladin / Shaman.
        if subclassID == _ARMOR_SHIELD_SUBCLASS then
            if playerClass and not _SHIELD_CLASSES[playerClass] then
                return false
            end
            return true
        end
        -- Typed armor (Cloth/Leather/Mail/Plate) — keep only the player's
        -- primary armor type. Non-typed armor (rings, necks, trinkets,
        -- held off-hands, shirts, tabards) is universal.
        local itemArmorToken = _SUBCLASS_TOKEN[subclassID]
        if not itemArmorToken then
            return true
        end
        local primary = _playerPrimaryArmor()
        if not primary then
            return true  -- couldn't determine — fail open
        end
        return itemArmorToken == primary
    end

    -- 4. Everything else (gems, etc.) — relevant.
    return true
end

-- Public wrapper so other modules (e.g. ZoneScan) can reuse the exact same
-- armor-type / proficiency / class-restriction filter — single source of truth.
function CP:IsRelevantToClass(itemID)
    return _itemRelevantToClass(itemID)
end

-- ============================================================================
-- PUBLIC: ENCOUNTER-LEVEL QUERY
-- ============================================================================
--
-- Returns a list of appearance records for an encounter:
--   { { visualID=, itemID=, name=, icon=, collected=bool }, ... }
-- plus summary counts. Deduped by visualID — one entry per distinct look.
--
-- CLASS FILTERING: items not usable by the logged-in class are dropped
-- entirely, matching the boss tooltip's behavior (ClassRestrictions:
-- IsItemUsableByPlayer). The planner is character-specific — a Mage does
-- not see Warrior-locked tier tokens, etc.
--
-- CACHING: results are cached per-encounter. The cache is invalidated
-- when the player's collection changes (see Invalidate / event hook) so
-- repeated opens and tree refreshes are instant. The cache key includes
-- the player class so it's correct after a /reload on a different char.
--
-- itemID in each record is the "representative" item for that visual.

CP._encCache = {}        -- encID -> { items=, summary= }
CP._encCacheClass = nil  -- classID the cache was built for

function CP:_classKey()
    if ETB.ClassRestrictions and ETB.ClassRestrictions.GetPlayerClassID then
        return ETB.ClassRestrictions:GetPlayerClassID()
    end
    return 0
end

-- Drop the encounter cache (e.g. on appearance learned). Cheap.
function CP:InvalidateCache()
    self._encCache = {}
    self._instCache = {}
end

function CP:GetEncounterItems(encID, diffBit)
    self:_ensureIndex()

    -- Class changed since cache was built? Wipe it.
    local classKey = self:_classKey()
    if self._encCacheClass ~= classKey then
        self._encCache = {}
        self._instCache = {}
        self._encCacheClass = classKey
    end

    -- Cache key includes the difficulty filter so each difficulty variant
    -- view of an encounter is cached independently.
    local cacheKey = tostring(encID) .. ":" .. tostring(diffBit or 0)

    -- Serve from cache when available.
    local cached = self._encCache[cacheKey]
    if cached then
        return cached.items, cached.summary
    end

    local itemIDs = self._encToItems[encID]
    if not itemIDs then
        local empty = { total = 0, collected = 0, missing = 0, pending = 0 }
        self._encCache[cacheKey] = { items = {}, summary = empty }
        return {}, empty
    end

    -- Forge context — drops from Throne of Thunder / Siege of Orgrimmar can
    -- arrive +6 ilvl (Thunderforged / Warforged). Resolved once per call.
    local encInfo   = ETB.JEncounters and ETB.JEncounters[encID]
    local forgeInst = encInfo and encInfo[2]
    -- Forging applies to boss (and shared-boss) loot, not trash-mob drops.
    local forgeDesc = (ETB.Forge and forgeInst and encInfo[1] ~= "Trash Drops")
                      and ETB.Forge:GetInstanceForge(forgeInst) or nil

    local diffs = self._encItemDiff[encID]
    local band = bit.band
    -- Pseudo-encounters (trash / shared loot) are difficulty-agnostic — their
    -- loot shows under every difficulty variant of the instance.
    local applyDiff = diffBit and not self._pseudoEnc[encID]
    local byVisual = {}     -- visualID -> record
    local noVisual = {}     -- itemIDs that haven't resolved a visual yet

    for _, itemID in ipairs(itemIDs) do
        -- DIFFICULTY FILTER — in a difficulty variant view, keep only items
        -- whose difficulty bitmask includes this variant's bit. Items with
        -- no difficulty info default to Normal (bit 1).
        local diffOK = true
        if applyDiff then
            local dm = diffs and diffs[itemID] or 0
            if dm == 0 then dm = 1 end
            diffOK = (band(dm, diffBit) ~= 0)
        end
        -- CLASS + ARMOR-TYPE FILTER — drop items the logged-in class can't
        -- transmog. Stricter than the tooltip: also filters by armor type
        -- so a Hunter only sees Mail (plus weapons, cloaks, rings, etc.).
        if diffOK and _itemRelevantToClass(itemID) then
            local visualID = _itemVisualID(itemID)
            if visualID then
                -- This item HAS a transmog appearance — it belongs here.
                if not byVisual[visualID] then
                    -- Icon: GetItemInfoInstant is SYNCHRONOUS in MoP 5.4.8
                    -- and returns the icon at position 5 — so the icon is
                    -- always available immediately (no '?' placeholder).
                    -- Name comes from GetItemInfo (async) and may be nil
                    -- until cached; that only affects the tooltip text.
                    local name = GetItemInfo(itemID)
                    local _, _, _, _, instantIcon = GetItemInfoInstant(itemID)
                    local state = ETB.CollectionAPI and ETB.CollectionAPI:GetState(itemID)
                    byVisual[visualID] = {
                        visualID  = visualID,
                        itemID    = itemID,
                        name      = name,
                        icon      = instantIcon,
                        collected = (state == "COLLECTED"),
                        pending   = (state == nil),
                        forge     = (forgeDesc and ETB.Forge:CanItemForge(itemID, forgeInst))
                                    and forgeDesc.kind or nil,
                    }
                else
                    local rec = byVisual[visualID]
                    if not rec.collected then
                        local state = ETB.CollectionAPI and ETB.CollectionAPI:GetState(itemID)
                        if state == "COLLECTED" then
                            rec.collected = true
                            rec.pending = false
                        end
                    end
                end
            else
                -- No visualID. This means EITHER:
                --   (a) the item genuinely has no transmog appearance —
                --       trinkets, rings, necks, non-moggable items — OR
                --   (b) C_TransmogCollection hasn't cached it yet.
                --
                -- The planner is a TRANSMOG planner: case (a) items must be
                -- excluded entirely (they are not transmog gear). Case (b)
                -- is handled by the +6s warm-up which primes the appearance
                -- cache; any item opened before warm-up that truly has an
                -- appearance will resolve on the next refresh.
                --
                -- We record it as unresolved (so the result isn't cached
                -- prematurely) but do NOT add it to the displayed list.
                noVisual[#noVisual + 1] = itemID
            end
        end
    end

    local result = {}
    for _, rec in pairs(byVisual) do
        result[#result + 1] = rec
    end
    table.sort(result, function(a, b)
        if a.collected ~= b.collected then return a.collected end
        return tostring(a.name or "zzz") < tostring(b.name or "zzz")
    end)

    local collected, missing, pending = 0, 0, 0
    for _, rec in ipairs(result) do
        if rec.pending then pending = pending + 1
        elseif rec.collected then collected = collected + 1
        else missing = missing + 1 end
    end

    local summary = {
        total     = #result,
        collected = collected,
        missing   = missing,
        pending   = pending,
        unresolved = #noVisual,
    }

    -- Cache only when nothing is still pending — a partially-resolved
    -- result would otherwise get frozen with stale "pending" entries.
    if pending == 0 and #noVisual == 0 then
        self._encCache[cacheKey] = { items = result, summary = summary }
    end

    return result, summary
end

-- ============================================================================
-- PUBLIC: INSTANCE-LEVEL PROGRESS
-- ============================================================================
--
-- Returns a per-instance progress summary aggregating all its encounters.
-- Cached per-instance (same class-keyed invalidation as GetEncounterItems)
-- so the tree renders instantly on expand/collapse and re-open.

CP._instCache = {}

function CP:GetInstanceProgress(instKey)
    self:_ensureIndex()

    -- Class-keyed cache (GetEncounterItems already wiped on class change).
    local classKey = self:_classKey()
    if self._encCacheClass ~= classKey then
        self._encCache = {}
        self._instCache = {}
        self._encCacheClass = classKey
    end

    local cached = self._instCache[instKey]
    if cached then return cached end

    -- Resolve a (possibly virtual) instance key to its real instance ID and
    -- difficulty filter. Plain real instIDs pass through with no filter.
    local variant = self._variantByKey and self._variantByKey[instKey]
    local instID  = variant and variant.realInstID or instKey
    local diffBit = variant and variant.diffBit or nil

    local JInstances = ETB.JInstances
    local JEncounters = ETB.JEncounters
    local encs = self._instToEncs[instID]
    if not encs or not JInstances then return nil end

    local instInfo = JInstances[instID]
    local result = {
        instID    = instKey,
        name      = instInfo and instInfo[1] or ("Instance " .. instID),
        encounters = {},
        total = 0, collected = 0, missing = 0, pending = 0,
    }

    local anyPending = false
    for _, encID in ipairs(encs) do
        local _, summary = self:GetEncounterItems(encID, diffBit)
        -- In a difficulty variant view, bosses that drop nothing at that
        -- difficulty are omitted from the tree rather than shown empty.
        if not (diffBit and summary.total == 0) then
            local encInfo = JEncounters[encID]
            result.encounters[#result.encounters + 1] = {
                encID     = encID,
                name      = encInfo and encInfo[1] or ("Encounter " .. encID),
                total     = summary.total,
                collected = summary.collected,
                missing   = summary.missing,
                pending   = summary.pending,
            }
            result.total     = result.total + summary.total
            result.collected = result.collected + summary.collected
            result.missing   = result.missing + summary.missing
            result.pending   = result.pending + summary.pending
        end
        if summary.pending > 0 or (summary.unresolved or 0) > 0 then
            anyPending = true
        end
    end

    -- Only cache once fully resolved (no pending item data).
    if not anyPending then
        self._instCache[instKey] = result
    end

    return result
end

-- ============================================================================
-- PUBLIC: INSTANCE LIST
-- ============================================================================
--
-- Returns all instances that have loot, grouped for the planner tree.
-- Each entry: { instID, realInstID, name, level, isRaid, diffBit, encCount }
--
-- DIFFICULTY SPLIT: an instance whose loot spans more than one difficulty is
-- emitted as one row per difficulty — "<name> (10-Man Heroic)", "<name>
-- (Elemental Rune Twilight)", etc. — each with a distinct virtual instID and
-- a diffBit. Single-difficulty instances (Classic/TBC raids, Normal-only
-- dungeons) are emitted as one plain row with diffBit = nil.
--
-- Virtual key scheme: realInstID*100 + variant ordinal (1..12). Real instIDs
-- are <= 758 so virtual keys (>= 6300) never collide with them. _variantByKey
-- lets GetInstanceProgress / GetInstanceBiSProgress resolve a key back to
-- (realInstID, diffBit).

function CP:GetInstanceList()
    self:_ensureIndex()
    local JInstances = ETB.JInstances
    if not JInstances then return {} end

    self._variantByKey = {}
    local list = {}

    local function emit(key, realInstID, name, level, isRaid, diffBit, diffName, encCount)
        self._variantByKey[key] = { realInstID = realInstID, diffBit = diffBit }
        list[#list + 1] = {
            instID = key, realInstID = realInstID, name = name,
            level = level, isRaid = isRaid, diffBit = diffBit,
            diffName = diffName, encCount = encCount,
        }
    end

    for instID, encs in pairs(self._instToEncs) do
        if #encs > 0 then
            local info   = JInstances[instID]
            local name   = info and info[1] or ("Instance " .. instID)
            local level  = info and info[2] or 0
            local isRaid = (info and info[3] == 1) or false
            -- World boss instances have no difficulty tiers — each boss is a
            -- single fixed encounter. Never split them, even though their
            -- loot may share itemIDs (and thus difficulty bits) with raids.
            local isWorldBosses = name:find("World Bosses", 1, true) ~= nil
            -- Classic and TBC raids have a single fixed raid size (40 / 25 /
            -- 20 / 10-man). The 10/25-man difficulty PAIR is a Wrath-era
            -- mechanic, so a Classic/TBC raid must never split — even if a
            -- shared itemID leaks a size bit onto its difficulty mask (e.g.
            -- the 40-man Classic Onyxia, distinct from the Wrath 10/25 one).
            local isLegacyRaid = isRaid and (level or 0) <= 1
            local variants = (isWorldBosses or isLegacyRaid) and {}
                          or _variantsForMask(self._instDiffMask[instID])

            if #variants > 1 then
                -- Multiple difficulties -> one variant row per difficulty.
                for _, v in ipairs(variants) do
                    local dname = v.suffix:match("%((.+)%)")
                    emit(instID * 100 + v.ordinal, instID, name .. v.suffix,
                         level, isRaid, v.bitval, dname, #encs)
                end
            else
                -- Single difficulty (or none) -> one plain row.
                emit(instID, instID, name, level, isRaid, nil, nil, #encs)
            end
        end
    end

    table.sort(list, function(a, b)
        if a.level ~= b.level then return a.level < b.level end
        return a.name < b.name
    end)
    return list
end

-- ============================================================================
-- RAID / BiS MODE
-- ============================================================================
-- A second planner lens. Where Transmog mode answers "what appearances am I
-- missing", Raid mode answers "what BiS pieces drop here, and do I have the
-- appearance". BiS data comes from ERB.BiSData (the same data ERB's BiS
-- browser uses); the planner cross-references each BiS itemID against the
-- JLoot reverse index to place it under the correct boss.
--
-- Key differences from Transmog mode:
--   * Trinkets / rings / weapons ARE shown (they are valid BiS).
--   * NO armor-type filtering beyond what BiSData already implies — BiS
--     lists are per-spec, so they only contain class-appropriate gear.
--   * "Acquired" = appearance collected (per the user's choice), via
--     CollectionAPI:GetState, exactly like Transmog mode.
--   * Only the CURRENT PHASE is used (locked, per the user's choice).
--   * BiS items not sourced from a raid/dungeon boss have no JLoot entry
--     and therefore don't appear — the planner is raid/dungeon scoped, and
--     ERB's own BiS browser remains the complete list.

-- Build a forward index itemID -> encID from JLoot (lazy, cached).
CP._itemToEnc = nil

function CP:_ensureItemToEnc()
    if self._itemToEnc then return end
    self:_ensureIndex()
    local map = {}
    for encID, items in pairs(self._encToItems) do
        for _, itemID in ipairs(items) do
            -- First boss wins if an item somehow maps to several; rare.
            if not map[itemID] then map[itemID] = encID end
        end
    end
    self._itemToEnc = map
end

-- ============================================================================
-- WISHLIST / FARMING INTEGRATION
-- ============================================================================
-- Shared, accurate item->location resolver. The planner's JLoot-derived index
-- is the authoritative boss-loot graph, so the Wishlist's farming guide routes
-- through here instead of the older first-source-only lookup.
function CP:GetItemLocation(itemID)
    self:_ensureItemToEnc()
    local encID = self._itemToEnc[itemID]
    if not encID then return nil end
    local enc = ETB.JEncounters and ETB.JEncounters[encID]
    if not enc then return nil end
    local instID = enc[2]
    local inst   = ETB.JInstances and ETB.JInstances[instID]
    return {
        encID     = encID,
        encName   = enc[1],
        instID    = instID,
        instName  = (inst and inst[1]) or "Unknown",
        expansion = inst and inst[2],
        isRaid    = (inst and inst[3] == 1) or false,
    }
end

-- Per-encounter count of UNCOLLECTED wishlist items. Drives the star badges
-- in the planner tree, so a player who is farming can see at a glance which
-- bosses still hold something on their wishlist. Cached; invalidated on a
-- wishlist or collection change.
CP._wishlistEncCounts = nil

function CP:InvalidateWishlistCounts()
    self._wishlistEncCounts = nil
end

function CP:GetWishlistEncCounts()
    if self._wishlistEncCounts then return self._wishlistEncCounts end
    local counts = {}
    local WL = ETB.Wishlist
    if WL and (WL.HasAppearance or WL.HasItem) then
        self:_ensureIndex()
        local phT = C_TransmogCollection and C_TransmogCollection.PlayerHasTransmog
        local function wanted(itemID)
            if WL.HasAppearance then return (WL:HasAppearance(itemID)) end
            return WL:HasItem(itemID)
        end
        for encID, items in pairs(self._encToItems) do
            local n = 0
            for _, itemID in ipairs(items) do
                if wanted(itemID) then
                    local collected = false
                    if phT then
                        local ok, has = pcall(phT, itemID)
                        collected = ok and has
                    end
                    if not collected then n = n + 1 end
                end
            end
            if n > 0 then counts[encID] = n end
        end
    end
    self._wishlistEncCounts = counts
    return counts
end

-- Resolve the current phase key. Locked to ERB's current phase (or the
-- MoP final phase P3 as a fallback) per the user's decision.
function CP:_currentPhase()
    local ERB = ETB.ERB
    if ERB and ERB.DataBridge and ERB.DataBridge.currentPhase then
        return ERB.DataBridge.currentPhase
    end
    return "P3"
end

-- Resolve the player's class index + spec index for BiSData lookup.
-- Returns classID, specID or nil if not determinable.
function CP:_playerClassSpec()
    local ERB = ETB.ERB
    if ERB and ERB.GetPlayerInfo then
        local ok, info = pcall(function() return ERB:GetPlayerInfo() end)
        if ok and info and info.classID and info.specIndex and info.specIndex > 0 then
            return info.classID, info.specIndex
        end
    end
    return nil, nil
end

-- Collect every BiS itemID for the player's spec in the current phase,
-- across all sources in ERB.BiSData. Returns a set { [itemID]=slotID }.
function CP:_playerBiSItems()
    local ERB = ETB.ERB
    if not ERB then return {} end
    -- Trigger the lazy BiSData loader if present.
    if ERB._BiSDataLoader and not ERB.BiSData then
        pcall(ERB._BiSDataLoader)
    end
    if not ERB.BiSData then return {} end

    local classID, specID = self:_playerClassSpec()
    if not classID or not specID then return {} end
    local phase = self:_currentPhase()

    -- Cached per class/spec/phase — invalidated by InvalidateBiSCache.
    local cacheKey = tostring(classID)..":"..tostring(specID)..":"..tostring(phase)
    if self._biSItemsCache and self._biSItemsCache.key == cacheKey then
        return self._biSItemsCache.items
    end

    local items = {}
    for _, source in ipairs(ERB.Sources or {}) do
        local srcData = ERB.BiSData[source]
        local classData = srcData and srcData[classID]
        local specData  = classData and classData[specID]
        local phaseData = specData and specData[phase]
        if phaseData then
            for slotID, itemList in pairs(phaseData) do
                for _, itemID in ipairs(itemList) do
                    -- A given itemID keeps the first slot we see it in.
                    if not items[itemID] then items[itemID] = slotID end
                end
            end
        end
    end
    self._biSItemsCache = { key = cacheKey, items = items }
    return items
end

-- Per-encounter BiS query. Returns records for the BiS items that drop
-- from this encounter, plus a summary.
--
-- SOURCE OF TRUTH: this goes through ERB.DataBridge:GetBiSForNPC — the
-- SAME path ERB's own BiS browser uses. DataBridge has already resolved
-- the player's spec, the active source preference (wowtbc vs wh), and the
-- phase, and built an NPC->BiS-items index. By querying that index the
-- planner is guaranteed to show exactly what ERB shows — no divergence.
--
-- The planner tree is keyed by encounter; DataBridge is keyed by NPC. We
-- bridge with ETB.JEncToNpcs (encounter -> npcID or list of npcIDs).
CP._encBiSCache = nil
CP._encBiSCacheKey = nil

function CP:GetEncounterBiS(encID, diffBit)
    self:_ensureIndex()
    local DB = ETB.ERB and ETB.ERB.DataBridge
    if not DB or not DB.GetBiSForNPC then
        return {}, { total = 0, collected = 0, missing = 0, pending = 0 }
    end

    -- Cache keyed by ERB's loaded spec + source + phase. If ERB switches
    -- any of them, the key changes and we recompute.
    local key = string.format("%s:%s:%s:%s",
        tostring(DB.playerClassIdx), tostring(DB.playerSpecIdx),
        tostring(DB.currentSource or DB.source), tostring(DB.currentPhase))
    if self._encBiSCacheKey ~= key then
        self._encBiSCache = {}
        self._encBiSCacheKey = key
    end
    -- Per-encounter cache key also carries the difficulty filter so each
    -- difficulty variant view is cached independently.
    local cacheKey = tostring(encID) .. ":" .. tostring(diffBit or 0)
    local cached = self._encBiSCache[cacheKey]
    if cached then return cached.items, cached.summary end

    -- Gather the BiS itemIDs for this encounter, deduped into `seen`.
    local seen = {}
    if self._pseudoEnc[encID] then
        -- Pseudo-encounter (trash / shared loot) has no NPC — but BiS pieces
        -- DO drop from trash on some fights. Intersect the encounter's loot
        -- with the player's spec+class+phase BiS set (the same ERB.BiSData
        -- ERB's own browser uses) so only spec-relevant pieces show.
        local biS = self:_playerBiSItems()
        local items = self._encToItems[encID]
        if items then
            for _, itemID in ipairs(items) do
                if biS[itemID] then seen[itemID] = true end
            end
        end
    else
        -- Real encounter: route through DataBridge for exact ERB parity.
        local JEncToNpcs = ETB.JEncToNpcs
        local npcRef = JEncToNpcs and JEncToNpcs[encID]
        local npcList = {}
        if type(npcRef) == "table" then
            for _, n in ipairs(npcRef) do npcList[#npcList + 1] = n end
        elseif type(npcRef) == "number" then
            npcList[1] = npcRef
        end
        for _, npcID in ipairs(npcList) do
            local biSForNpc = DB:GetBiSForNPC(npcID)   -- { [itemID]=true }
            if biSForNpc then
                for itemID in pairs(biSForNpc) do
                    seen[itemID] = true
                end
            end
        end
    end

    -- Forge context — Throne of Thunder / Siege of Orgrimmar BiS pieces can
    -- drop +6 ilvl (Thunderforged / Warforged).
    local biSEncInfo   = ETB.JEncounters and ETB.JEncounters[encID]
    local biSForgeInst = biSEncInfo and biSEncInfo[2]
    -- Forging applies to boss (and shared-boss) loot, not trash-mob drops.
    local biSForgeDesc = (ETB.Forge and biSForgeInst and biSEncInfo[1] ~= "Trash Drops")
                         and ETB.Forge:GetInstanceForge(biSForgeInst) or nil

    local result = {}
    for itemID in pairs(seen) do
        -- DIFFICULTY FILTER — in a difficulty variant view keep only BiS
        -- pieces whose difficulty bitmask includes this variant's bit.
        -- Pieces with no difficulty info, and all pseudo-encounter loot,
        -- are shown under every variant.
        local diffOK = true
        if diffBit and not self._pseudoEnc[encID] then
            local dm = self._itemDiffMask and self._itemDiffMask[itemID]
            if dm and dm ~= 0 then
                diffOK = (bit.band(dm, diffBit) ~= 0)
            end
        end
        if diffOK then
        local name = GetItemInfo(itemID)
        local _, _, _, _, instantIcon = GetItemInfoInstant(itemID)
        local state = ETB.CollectionAPI and ETB.CollectionAPI:GetState(itemID)
        -- Pull BiS rank/slot from DataBridge so the row can show "BiS"
        -- vs "Alt 2" exactly as ERB labels it.
        local biSInfo = DB.GetItemBiSInfo and DB:GetItemBiSInfo(itemID)
        result[#result + 1] = {
            itemID    = itemID,
            name      = name,
            icon      = instantIcon,
            collected = (state == "COLLECTED"),
            pending   = (state == nil),
            rank      = biSInfo and biSInfo.rank,
            rankLabel = biSInfo and biSInfo.label,
            forge     = (biSForgeDesc and ETB.Forge:CanItemForge(itemID, biSForgeInst))
                        and biSForgeDesc.kind or nil,
        }
        end
    end
    table.sort(result, function(a, b)
        if a.collected ~= b.collected then return a.collected end
        return (a.rank or 99) < (b.rank or 99)
    end)

    local collected, missing, pending = 0, 0, 0
    for _, rec in ipairs(result) do
        if rec.pending then pending = pending + 1
        elseif rec.collected then collected = collected + 1
        else missing = missing + 1 end
    end
    local summary = { total = #result, collected = collected,
                      missing = missing, pending = pending }

    if pending == 0 then
        self._encBiSCache[cacheKey] = { items = result, summary = summary }
    end
    return result, summary
end

-- Per-instance BiS progress, aggregating its encounters. Mirrors
-- GetInstanceProgress but for the BiS lens.
function CP:GetInstanceBiSProgress(instKey)
    self:_ensureItemToEnc()
    local JInstances = ETB.JInstances
    local JEncounters = ETB.JEncounters

    -- Resolve a (possibly virtual) instance key to real ID + difficulty.
    local variant = self._variantByKey and self._variantByKey[instKey]
    local instID  = variant and variant.realInstID or instKey
    local diffBit = variant and variant.diffBit or nil

    local encs = self._instToEncs[instID]
    if not encs or not JInstances then return nil end

    local instInfo = JInstances[instID]
    local result = {
        instID = instKey,
        name   = instInfo and instInfo[1] or ("Instance " .. instID),
        encounters = {},
        total = 0, collected = 0, missing = 0, pending = 0,
    }
    for _, encID in ipairs(encs) do
        local _, summary = self:GetEncounterBiS(encID, diffBit)
        -- Inclusion rule: a pseudo-encounter (trash / shared loot) appears
        -- only when it actually carries BiS pieces for this spec; a real
        -- boss is omitted only when a difficulty-filtered view leaves it
        -- empty.
        local include
        if self._pseudoEnc[encID] then
            include = (summary.total > 0)
        else
            include = not (diffBit and summary.total == 0)
        end
        if include then
            local encInfo = JEncounters[encID]
            result.encounters[#result.encounters + 1] = {
                encID = encID,
                name  = encInfo and encInfo[1] or ("Encounter " .. encID),
                total = summary.total, collected = summary.collected,
                missing = summary.missing, pending = summary.pending,
            }
            result.total     = result.total + summary.total
            result.collected = result.collected + summary.collected
            result.missing   = result.missing + summary.missing
            result.pending   = result.pending + summary.pending
        end
    end
    return result
end

-- Invalidate BiS caches (call on spec change / collection change).
function CP:InvalidateBiSCache()
    self._encBiSCache = {}
    self._encBiSCacheKey = nil
    self._biSItemsCache = nil
end

-- ============================================================================
-- STATS (for /etb commands / debugging)
-- ============================================================================

function CP:GetStats()
    self:_ensureIndex()
    local nEnc, nInst, nItems = 0, 0, 0
    for _, items in pairs(self._encToItems) do
        nEnc = nEnc + 1
        nItems = nItems + #items
    end
    for _ in pairs(self._instToEncs) do nInst = nInst + 1 end
    return {
        encountersWithLoot = nEnc,
        instancesWithLoot  = nInst,
        totalLootEntries   = nItems,
        indexBuilt         = self._indexBuilt,
    }
end

-- ============================================================================
-- INITIALIZE
-- ============================================================================
-- Called once at startup. Does two things:
--
--   1. Subscribes to ETB_ITEM_COLLECTED so the progress cache is dropped
--      whenever the player collects something — keeps the planner accurate
--      without recomputing on every open.
--
--   2. Schedules a deferred warm-up: builds the reverse index and primes
--      GetItemInfo for every raid/dungeon item, spread across frames so it
--      never hitches. By the time the player opens the planner, the index
--      is built and item data is cached — so it opens instantly, matching
--      the rest of the addon. The warm-up is LOW priority and staggered;
--      it does not touch login.

function CP:Initialize()
    -- Cache invalidation on collection change
    if ETB.EventBus and ETB.EventBus.Register then
        ETB.EventBus:Register("ETB_ITEM_COLLECTED", function()
            CP:InvalidateCache()
            CP:InvalidateBiSCache()
            CP:InvalidateWishlistCounts()
        end)
        -- Wishlist edits change the planner's farming star badges.
        ETB.EventBus:Register("ETB_WISHLIST_CHANGED", function()
            CP:InvalidateWishlistCounts()
        end)
    end

    if ETB.IsLowMemoryMode and ETB:IsLowMemoryMode() then
        ETB:Debug("CollectionPlanner", "Low-memory mode: deferred warm-up skipped (lazy on first open)")
        return
    end

    ETB:Debug("CollectionPlanner", "Planner index builds lazily on first use")
    if false then

    -- Deferred warm-up. Use C_Timer.After when available; the addon ships
    -- an OnUpdate polyfill for MoP 5.4.8 where C_Timer is incomplete.
    local function warmUp()
        CP:_ensureIndex()
        if not CP._indexBuilt then return end

        -- Collect every itemID across all encounters, de-duplicated.
        local seen = {}
        local queue = {}
        for _, items in pairs(CP._encToItems) do
            for _, itemID in ipairs(items) do
                if not seen[itemID] then
                    seen[itemID] = true
                    queue[#queue + 1] = itemID
                end
            end
        end

        -- Prime GetItemInfo in gentle chunks so the client caches names/icons/
        -- visuals ahead of the player opening the planner without creating a
        -- noticeable item-info storm.
        local idx = 1
        local CHUNK = 20
        local function step()
            if ETB.IsStartupIdle and not ETB:IsStartupIdle({ ignoreQuietWindow = true }) then
                C_Timer.After(1, step)
                return
            end
            local stop = math.min(idx + CHUNK - 1, #queue)
            for i = idx, stop do
                local itemID = queue[i]
                if ETB.RequestItemInfoThrottled then
                    ETB:RequestItemInfoThrottled(itemID)
                else
                    GetItemInfo(itemID)
                end
                if C_TransmogCollection and C_TransmogCollection.GetItemInfo then
                    pcall(C_TransmogCollection.GetItemInfo, itemID)  -- visualID
                end
            end
            idx = stop + 1
            if idx <= #queue then
                if ETB.QueueWork then
                    ETB:QueueWork(nil, function() step(); return true end, { pauseInCombat = true, startupIdleOptions = { ignoreQuietWindow = true } })
                elseif C_Timer and C_Timer.After then
                    C_Timer.After(0.1, step)
                end
            end
        end
        step()
    end

    -- Schedule warm-up ~12s post-login — well clear of ETB's staggered init
    -- (+1s/+3s/+5s) AND of the load-in window where the player is moving out
    -- of the start area. The index build is synchronous, so keeping it away
    -- from that window avoids contributing to login-time stutter.
    if ETB.RunWhenStartupIdle then
        ETB:RunWhenStartupIdle("collectionPlanner.warmUp", warmUp, { delay = 20, interval = 3, maxWait = 300 })
    elseif C_Timer and C_Timer.After then
        C_Timer.After(20, warmUp)
    else
        -- No timer — planner still works, just builds lazily on first open.
        ETB:Debug("CollectionPlanner", "No C_Timer; warm-up skipped (lazy fallback)")
    end
    end
end

ETB:Debug("CollectionPlanner", "CollectionPlanner.lua loaded")
